Spaces:
Running
Running
Download index.html from CJLabKIST/messenger-probe: direct link, hf CLI and curl.
- Browser
- Download file 50.3 kB
-
https://huggingface.co/spaces/CJLabKIST/messenger-probe/resolve/main/index.html
- Command line
-
hf download hf://spaces/CJLabKIST/messenger-probe/index.html
-
curl -L -o index.html https://huggingface.co/spaces/CJLabKIST/messenger-probe/resolve/main/index.html
50.3 kB
| <html lang="ko"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <!-- HF static serving injects its OAuth <script> and corrupts the multi-byte character that straddles byte 8192 of this file. Keep the first 8 KB pure ASCII: padding follows. --> | |
| <!-- --> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Team Messenger (probe)</title> | |
| <style> | |
| :root{ | |
| --bg:#ffffff; --bg2:#f7f7f5; --bg3:#efefed; --fg:#37352f; --dim:#787774; --line:#e9e9e7; | |
| --accent:#2383e2; --accent-fg:#fff; --mine:#2383e2; --theirs:#f1f1ef; --danger:#e03e3e; --ok:#0f7b6c; --warn:#d9730d; | |
| } | |
| @media (prefers-color-scheme: dark){ | |
| :root:not([data-theme="light"]){ | |
| --bg:#191919; --bg2:#202020; --bg3:#2a2a2a; --fg:#e6e6e4; --dim:#9b9a97; --line:#2f2f2f; --theirs:#2a2a2a; | |
| } | |
| } | |
| :root[data-theme="dark"]{ --bg:#191919; --bg2:#202020; --bg3:#2a2a2a; --fg:#e6e6e4; --dim:#9b9a97; --line:#2f2f2f; --theirs:#2a2a2a; } | |
| *{box-sizing:border-box} | |
| html,body{margin:0;height:100%} | |
| body{font:14px/1.45 ui-sans-serif,-apple-system,"Segoe UI","Apple SD Gothic Neo","Noto Sans KR",sans-serif;background:var(--bg);color:var(--fg)} | |
| button{font:inherit;color:inherit;cursor:pointer} | |
| input,textarea{font:inherit;color:inherit} | |
| .hidden{display:none} | |
| .btn{border:1px solid var(--line);background:var(--bg);border-radius:6px;padding:6px 12px} | |
| .btn:hover{background:var(--bg2)} | |
| .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)} | |
| .btn.wide{width:100%;padding:9px} | |
| .field{width:100%;border:1px solid var(--line);background:var(--bg);border-radius:6px;padding:8px 10px} | |
| .av{width:32px;height:32px;border-radius:50%;flex-shrink:0;background:var(--bg3);object-fit:cover} | |
| .av.sm{width:26px;height:26px} | |
| /* center screens */ | |
| .center{display:flex;align-items:center;justify-content:center;min-height:100%;padding:16px;background:var(--bg2)} | |
| .card{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:24px;max-width:380px;width:100%} | |
| .card h1{font-size:18px;margin:0 0 4px} | |
| .card p{color:var(--dim);margin:0 0 16px;font-size:13px} | |
| .sep{display:flex;align-items:center;gap:8px;color:var(--dim);font-size:12px;margin:14px 0} | |
| .sep:before,.sep:after{content:"";flex:1;height:1px;background:var(--line)} | |
| .err{color:var(--danger);font-size:12.5px;margin-top:10px;white-space:pre-wrap} | |
| .note{color:var(--dim);font-size:12px;margin-top:8px} | |
| /* app shell */ | |
| #app{display:flex;flex-direction:column;height:100%} | |
| .topbar{height:44px;flex-shrink:0;display:flex;align-items:center;gap:10px;padding:0 12px;border-bottom:1px solid var(--line)} | |
| .topbar b{font-size:14px} | |
| .topbar .sp{flex:1} | |
| .topbar .who{display:flex;align-items:center;gap:6px;color:var(--dim);font-size:12.5px} | |
| .body{flex:1;display:flex;min-height:0} | |
| .nav{width:56px;flex-shrink:0;border-right:1px solid var(--line);display:flex;flex-direction:column;align-items:center;padding:8px 0;gap:2px;background:var(--bg2)} | |
| .nav button{width:44px;border:none;background:none;border-radius:6px;padding:6px 0 4px;display:flex;flex-direction:column;align-items:center;gap:1px;color:var(--dim);font-size:10px} | |
| .nav button svg{width:19px;height:19px} | |
| .nav button:hover{background:var(--bg3)} | |
| .nav button.on{color:var(--fg);background:var(--bg3)} | |
| .nav button:disabled{opacity:.35;cursor:default} | |
| .list{width:280px;flex-shrink:0;border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0} | |
| .list .hd{padding:10px 12px 6px} | |
| .list .hd h2{font-size:16px;margin:0 0 8px} | |
| .list .scroll{flex:1;overflow-y:auto;padding:0 6px 10px} | |
| .row{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:6px;cursor:pointer} | |
| .row:hover{background:var(--bg2)} | |
| .row.on{background:var(--bg3)} | |
| .row .tx{flex:1;min-width:0} | |
| .row .nm{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} | |
| .row .lm{color:var(--dim);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;gap:6px} | |
| .row .lm span:first-child{overflow:hidden;text-overflow:ellipsis} | |
| .row.unread .nm,.row.unread .lm span:first-child{font-weight:600;color:var(--fg)} | |
| .badge{background:var(--accent);color:#fff;border-radius:10px;font-size:11px;font-weight:600;padding:0 6px;min-width:18px;text-align:center;line-height:18px} | |
| .empty{color:var(--dim);text-align:center;padding:40px 16px;font-size:13px} | |
| .main{flex:1;display:flex;flex-direction:column;min-width:0;min-height:0} | |
| .chead{height:52px;flex-shrink:0;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;padding:0 14px} | |
| .chead .t{font-weight:600} | |
| .chead .s{color:var(--dim);font-size:12px} | |
| .back{display:none;border:none;background:none;font-size:18px;padding:2px 6px} | |
| #msgs{flex:1;overflow-y:auto;padding:12px 14px} | |
| .day{text-align:center;color:var(--dim);font-size:11.5px;margin:12px 0 6px} | |
| .m{display:flex;gap:8px;margin-top:6px;align-items:flex-end} | |
| .m.cont{margin-top:2px} | |
| .m .avs{width:26px;flex-shrink:0} | |
| .m .col{max-width:min(70%,560px);display:flex;flex-direction:column} | |
| .m .who{font-size:11.5px;color:var(--dim);margin:0 0 2px 10px} | |
| .bub{background:var(--theirs);border-radius:16px;padding:7px 12px;white-space:pre-wrap;overflow-wrap:anywhere} | |
| .m .tm{font-size:10.5px;color:var(--dim);margin:0 6px 2px;white-space:nowrap} | |
| .m.mine{flex-direction:row-reverse} | |
| .m.mine .col{align-items:flex-end} | |
| .m.mine .bub{background:var(--mine);color:#fff} | |
| .m.pending .bub{opacity:.55} | |
| .m.failed .bub{background:var(--danger);color:#fff} | |
| .composer{flex-shrink:0;border-top:1px solid var(--line);padding:8px 10px;display:flex;gap:8px;align-items:flex-end} | |
| .composer textarea{flex:1;resize:none;border:1px solid var(--line);border-radius:18px;padding:8px 14px;background:var(--bg2);max-height:140px;outline:none;line-height:1.4} | |
| .composer textarea:focus{border-color:var(--accent)} | |
| .iconbtn{border:none;background:none;border-radius:50%;width:34px;height:34px;display:flex;align-items:center;justify-content:center;color:var(--dim);flex-shrink:0} | |
| .iconbtn:hover{background:var(--bg2)} | |
| .iconbtn:disabled{opacity:.35;cursor:default} | |
| .send{color:var(--accent)} | |
| .dpane{flex:1;display:flex;min-width:0;min-height:0} | |
| .diag{flex:1;overflow-y:auto;padding:16px 20px} | |
| .diag h2{font-size:16px;margin:0 0 4px} | |
| .diag table{border-collapse:collapse;width:100%;max-width:760px;margin:12px 0} | |
| .diag td{border-bottom:1px solid var(--line);padding:7px 8px;vertical-align:top;font-size:13px} | |
| .diag td:first-child{width:30px} | |
| .diag td:nth-child(2){width:200px;color:var(--dim)} | |
| .ok{color:var(--ok)} .bad{color:var(--danger)} .warn{color:var(--warn)} | |
| .bub .txt{white-space:pre-wrap} | |
| .bub.media{background:none;padding:0} | |
| .bub img.att-img{display:block;max-width:min(320px,100%);max-height:280px;border-radius:12px;border:1px solid var(--line);cursor:zoom-in;background:var(--bg3);min-width:80px;min-height:60px} | |
| .att-file{display:flex;align-items:center;gap:10px;padding:8px 10px;margin-top:4px;border-radius:10px;background:var(--bg);color:var(--fg);border:1px solid var(--line);cursor:pointer;text-decoration:none;max-width:320px} | |
| .att-file:hover{background:var(--bg2)} | |
| .att-file .ic{width:34px;height:34px;border-radius:6px;background:var(--bg3);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;color:var(--dim);flex-shrink:0} | |
| .att-file .fn{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} | |
| .att-file .fs{font-size:11.5px;color:var(--dim)} | |
| .att-gap{height:4px} | |
| .dropping #msgs{outline:2px dashed var(--accent);outline-offset:-6px} | |
| #lightbox{position:fixed;inset:0;background:rgba(0,0,0,.8);display:flex;align-items:center;justify-content:center;z-index:25;cursor:zoom-out} | |
| #lightbox img{max-width:94vw;max-height:94vh;border-radius:6px} | |
| #toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);background:#37352f;color:#fff;padding:8px 14px;border-radius:6px;font-size:13px;z-index:20;max-width:90vw} | |
| #fatal{position:fixed;top:0;left:0;right:0;background:var(--danger);color:#fff;padding:8px 12px;font-size:12.5px;z-index:30;white-space:pre-wrap} | |
| /* embed mode: Notion */ | |
| .embed .topbar{height:38px} | |
| .embed .topbar .brand{display:none} | |
| .embed .nav{width:48px} | |
| .embed .nav button span{display:none} | |
| .embed .list{width:240px} | |
| /* narrow: messenger-style single pane */ | |
| @media (max-width:720px){ | |
| .list{width:auto;flex:1;border-right:none} | |
| .main{display:none} | |
| .body.inchat .list{display:none} | |
| .body.inchat .main{display:flex} | |
| .back{display:block} | |
| .nav{width:48px} | |
| .nav button span{display:none} | |
| } | |
| </style> | |
| <script> | |
| // Surface load/runtime errors visibly β this probe is debugged from the user's browser only. | |
| window.addEventListener("error", function (e) { showFatal(e.message + (e.filename ? " @" + e.filename.split("/").pop() + ":" + e.lineno : "")); }); | |
| window.addEventListener("unhandledrejection", function (e) { showFatal(String(e.reason && e.reason.message || e.reason)); }); | |
| function showFatal(msg) { var d = document.getElementById("fatal"); if (!d) { d = document.createElement("div"); d.id = "fatal"; document.body.appendChild(d); } d.textContent = "μ€λ₯: " + msg; } | |
| </script> | |
| </head> | |
| <body> | |
| <!-- login --> | |
| <div id="loginView" class="center hidden"> | |
| <div class="card"> | |
| <h1>Team Messenger</h1> | |
| <p>CJ Lab μ μ© Β· Hugging Face κ³μ μΌλ‘ λ‘κ·ΈμΈ (μνν)</p> | |
| <button id="btnPopup" class="btn primary wide">Hugging Faceλ‘ λ‘κ·ΈμΈ</button> | |
| <button id="btnRedirect" class="btn wide" style="margin-top:6px">μ΄ μ°½μμ λ‘κ·ΈμΈ</button> | |
| <div class="sep">λλ</div> | |
| <input id="tokenIn" class="field" type="password" placeholder="hf_β¦ μ‘μΈμ€ ν ν°"> | |
| <button id="btnToken" class="btn wide" style="margin-top:6px">ν ν°μΌλ‘ λ‘κ·ΈμΈ</button> | |
| <div id="loginErr" class="err"></div> | |
| <div class="note" id="loginNote"></div> | |
| </div> | |
| </div> | |
| <!-- popup callback / waiting --> | |
| <div id="popupView" class="center hidden"><div class="card"><h1 id="popupTitle">λ‘κ·ΈμΈ μ€β¦</h1><p id="popupMsg"></p></div></div> | |
| <!-- access denied (spec wording) --> | |
| <div id="deniedView" class="center hidden"> | |
| <div class="card"> | |
| <h1>Access denied.</h1> | |
| <p>This messenger is restricted to members of the configured Hugging Face Organization.</p> | |
| <p id="deniedDetail" style="font-size:12px"></p> | |
| <button id="btnDeniedLogout" class="btn">λ€λ₯Έ κ³μ μΌλ‘ λ‘κ·ΈμΈ</button> | |
| </div> | |
| </div> | |
| <div id="app" class="hidden"> | |
| <div class="topbar"> | |
| <b class="brand">Team Messenger</b><span class="warn" style="font-size:11px">μνν</span> | |
| <span class="sp"></span> | |
| <span class="who"><img id="meAv" class="av sm" alt=""><span id="meName"></span></span> | |
| </div> | |
| <div class="body" id="body"> | |
| <nav class="nav" id="nav"> | |
| <button data-nav="chats" title="Chats"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span>Chats</span></button> | |
| <button data-nav="people" title="People"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg><span>People</span></button> | |
| <button disabled title="Groups β λ€μ λ¨κ³"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg><span>Groups</span></button> | |
| <button disabled title="Files β λ€μ λ¨κ³"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg><span>Files</span></button> | |
| <span style="flex:1"></span> | |
| <button data-nav="settings" title="Settings Β· μ§λ¨"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span>Settings</span></button> | |
| </nav> | |
| <section class="list" id="listPane"> | |
| <div class="hd"><h2 id="listTitle">Chats</h2><input id="search" class="field" placeholder="Search"></div> | |
| <div class="scroll" id="listBody"></div> | |
| </section> | |
| <section class="main" id="chatPane"> | |
| <div class="chead"> | |
| <button class="back" id="back">βΉ</button> | |
| <img id="cAv" class="av" alt=""> | |
| <div><div class="t" id="cName"></div><div class="s" id="cSub"></div></div> | |
| </div> | |
| <div id="msgs"><div class="empty">μΌμͺ½μμ λνλ₯Ό κ³ λ₯΄κ±°λ Peopleμμ νμμ λλ₯΄μΈμ.</div></div> | |
| <div class="composer" id="composer"> | |
| <button class="iconbtn" id="attachBtn" title="νμΌΒ·μ¬μ§ 첨λΆ"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button> | |
| <textarea id="input" rows="1" placeholder="Messageβ¦"></textarea> | |
| <button class="iconbtn send" id="sendBtn" disabled title="Send"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2 21l21-9L2 3v7l15 2-15 2z"/></svg></button> | |
| </div> | |
| </section> | |
| <section class="dpane hidden" id="diagPane"><div class="diag" id="diagBody"></div></section> | |
| </div> | |
| </div> | |
| <div id="toast" class="hidden"></div> | |
| <input type="file" id="filePick" multiple class="hidden"> | |
| <div id="lightbox" class="hidden"><img alt=""></div> | |
| <script type="module"> | |
| import { oauthLoginUrl, oauthHandleRedirectIfPresent, uploadFiles } from "https://cdn.jsdelivr.net/npm/@huggingface/hub@2.17.5/+esm"; | |
| // βββββββββββββββββββββββββββββ config (no secrets here: everything is public in a static Space) | |
| const CFG = { | |
| ORG: "CJLabKIST", | |
| BUCKET: "CJLabKIST/messenger-probe", | |
| HANDOFF_BUCKET: "CJLabKIST/messenger-handoff", // public; holds only AES-encrypted, short-lived login handoffs | |
| HUB: "https://huggingface.co", | |
| POLL_CHAT_MS: 4000, | |
| POLL_LIST_MS: 15000, | |
| }; | |
| const $ = (s) => document.querySelector(s); | |
| const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); | |
| const params = new URLSearchParams(location.search); | |
| const IN_IFRAME = (() => { try { return window.self !== window.top; } catch { return true; } })(); | |
| const EMBED = params.get("embed") === "1" || IN_IFRAME; | |
| if (EMBED) document.body.classList.add("embed"); | |
| // βββββββββββββββββββββββββββββ diagnostics log (shown on Settings, copyable) | |
| const DIAG = {}; | |
| function diag(key, ok, detail) { DIAG[key] = { ok, detail: String(detail ?? ""), at: new Date().toLocaleTimeString("ko-KR") }; if (state.view === "settings") renderDiag(); } | |
| // βββββββββββββββββββββββββββββ storage that may be blocked (iframe partitioning, Safari, etc.) | |
| const store = { | |
| ok: (() => { try { localStorage.setItem("__t", "1"); localStorage.removeItem("__t"); return true; } catch { return false; } })(), | |
| mem: {}, | |
| get(k, d) { try { const v = this.ok ? localStorage.getItem("tm_" + k) : this.mem[k]; return v == null ? d : JSON.parse(v); } catch { return d; } }, | |
| set(k, v) { const s = JSON.stringify(v); if (this.ok) { try { localStorage.setItem("tm_" + k, s); } catch {} } else this.mem[k] = s; }, | |
| del(k) { if (this.ok) { try { localStorage.removeItem("tm_" + k); } catch {} } delete this.mem[k]; }, | |
| }; | |
| const state = { | |
| token: null, me: null, loginMethod: null, | |
| members: [], view: "chats", convId: null, | |
| convs: new Map(), // convId -> { id, peer, files: [paths], last: msg } | |
| msgCache: new Map(), // path -> message (bucket files are immutable) | |
| pending: [], // optimistic messages in current conv | |
| }; | |
| function toast(t, ms = 2500) { const e = $("#toast"); e.textContent = t; e.classList.remove("hidden"); clearTimeout(toast.t); toast.t = setTimeout(() => e.classList.add("hidden"), ms); } | |
| function show(id) { for (const v of ["loginView", "popupView", "deniedView", "app"]) $("#" + v).classList.toggle("hidden", v !== id); } | |
| const avatarUrl = (u) => (!u ? "" : u.startsWith("http") ? u : CFG.HUB + u); | |
| // βββββββββββββββββββββββββββββ HF API layer (all Hub calls go through here) | |
| async function hf(path, opts = {}) { | |
| const r = await fetch(path.startsWith("http") ? path : CFG.HUB + path, { | |
| ...opts, cache: "no-store", | |
| headers: { ...(state.token ? { Authorization: "Bearer " + state.token } : {}), ...(opts.headers || {}) }, | |
| }); | |
| if (!r.ok) { let m = r.statusText; try { const j = await r.json(); m = j.error || m; } catch {} const e = new Error(r.status + " " + m); e.status = r.status; throw e; } | |
| return r; | |
| } | |
| const Bucket = { | |
| async list(prefix) { | |
| try { return await (await hf(`/api/buckets/${CFG.BUCKET}/tree/${prefix}?recursive=true`)).json(); } | |
| catch (e) { if (e.status === 404) return []; throw e; } | |
| }, | |
| async readJson(path) { | |
| const r = await hf(`/buckets/${CFG.BUCKET}/resolve/${path.split("/").map(encodeURIComponent).join("/")}`); | |
| return r.json(); | |
| }, | |
| async writeJson(path, obj) { | |
| await uploadFiles({ | |
| repo: { type: "bucket", name: CFG.BUCKET }, accessToken: state.token, useWebWorkers: false, | |
| files: [{ path, content: new Blob([JSON.stringify(obj)], { type: "application/json" }) }], | |
| }); | |
| }, | |
| }; | |
| // βββββββββββββββββββββββββββββ login handoff (iframe β popup) | |
| // HF pages send COOP: same-origin, so a popup opened from the Notion iframe loses window.opener, | |
| // and partitioned storage means the popup and the iframe share nothing. Bridge them through HF itself: | |
| // the iframe makes a random key K and passes it in the URL fragment (never sent to any server); | |
| // the popup logs in, encrypts the token with K and drops it in a public bucket under sha256(K); | |
| // the iframe polls that path, decrypts, then deletes the file. | |
| const b64u = { | |
| enc: (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""), | |
| dec: (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4)), (c) => c.charCodeAt(0)), | |
| }; | |
| async function sha256hex(s) { const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); return [...new Uint8Array(h)].map((b) => b.toString(16).padStart(2, "0")).join(""); } | |
| const aesKey = (k) => crypto.subtle.importKey("raw", b64u.dec(k), "AES-GCM", false, ["encrypt", "decrypt"]); | |
| async function seal(k, obj) { | |
| const iv = crypto.getRandomValues(new Uint8Array(12)); | |
| const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await aesKey(k), new TextEncoder().encode(JSON.stringify(obj))); | |
| return { iv: b64u.enc(iv), ct: b64u.enc(ct) }; | |
| } | |
| async function unseal(k, box) { | |
| const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64u.dec(box.iv) }, await aesKey(k), b64u.dec(box.ct)); | |
| return JSON.parse(new TextDecoder().decode(pt)); | |
| } | |
| const handoffPath = async (k) => `handoff/${await sha256hex(k)}.json`; | |
| async function handoffPut(k, payload) { | |
| await uploadFiles({ | |
| repo: { type: "bucket", name: CFG.HANDOFF_BUCKET }, accessToken: payload.token, useWebWorkers: false, | |
| files: [{ path: await handoffPath(k), content: new Blob([JSON.stringify(await seal(k, payload))], { type: "application/json" }) }], | |
| }); | |
| } | |
| async function handoffDelete(path, token) { | |
| await fetch(`${CFG.HUB}/api/buckets/${CFG.HANDOFF_BUCKET}/batch`, { | |
| method: "POST", headers: { Authorization: "Bearer " + token, "Content-Type": "application/x-ndjson" }, | |
| body: JSON.stringify({ type: "deleteFile", path }) + "\n", | |
| }); | |
| } | |
| async function handoffPoll(k) { | |
| const path = await handoffPath(k), until = Date.now() + 180000; | |
| while (Date.now() < until && !state.me) { | |
| await new Promise((r) => setTimeout(r, 1500)); | |
| let r; | |
| try { r = await fetch(`${CFG.HUB}/buckets/${CFG.HANDOFF_BUCKET}/resolve/${path}?t=${Date.now()}`, { cache: "no-store" }); } catch { continue; } | |
| if (r.status === 404) continue; | |
| if (!r.ok) { diag("popup", false, "handoff μ½κΈ° μ€ν¨: HTTP " + r.status); return; } | |
| try { | |
| const p = await unseal(k, await r.json()); | |
| diag("popup", true, "νμ λ‘κ·ΈμΈ β μνΈν handoff(Bucket)λ‘ ν ν° λ°μ"); | |
| handoffDelete(path, p.token).then(() => diag("handoffDel", true, "handoff νμΌ μμ λ¨"), (e) => diag("handoffDel", false, e.message)); | |
| await acceptLogin(p.token, p.exp, "oauth-popup", p.orgs); | |
| } catch (e) { diag("popup", false, "handoff 볡νΈν μ€ν¨: " + e.message); } | |
| return; | |
| } | |
| if (!state.me) { diag("popup", false, "3λΆ μμ νμ λ‘κ·ΈμΈ κ²°κ³Όκ° μ€μ§ μμ"); $("#loginErr").textContent = "νμ λ‘κ·ΈμΈ κ²°κ³Όλ₯Ό λ°μ§ λͺ»νμ΅λλ€. λ€μ μλνκ±°λ ν ν°μΌλ‘ λ‘κ·ΈμΈν΄ μ£ΌμΈμ."; } | |
| } | |
| // βββββββββββββββββββββββββββββ auth | |
| // huggingface.co login pages send X-Frame-Options: SAMEORIGIN, so inside any iframe (Notion, the | |
| // HF Space page) login must happen in a top-level popup; the token comes back via postMessage when | |
| // window.opener survives, otherwise via the encrypted bucket handoff above. | |
| async function boot() { | |
| const vars = window.huggingface?.variables || {}; | |
| diag("iframe", true, IN_IFRAME ? "iframe μ (embed)" + (document.referrer ? " Β· λΆλͺ¨: " + new URL(document.referrer).host : "") : "μ΅μμ μ°½"); | |
| diag("storage", store.ok, store.ok ? "localStorage μ¬μ© κ°λ₯" : "localStorage λ§ν β μλ‘κ³ μΉ¨νλ©΄ λ‘κ·ΈμΈμ΄ νλ¦Ό"); | |
| diag("oauthcfg", !!vars.OAUTH_CLIENT_ID, vars.OAUTH_CLIENT_ID ? "OAuth μ€μ μ£Όμ λ¨ Β· scopes: " + vars.OAUTH_SCOPES : "OAuth μ€μ μμ (HF Space λ°μμ μ΄μκ±°λ README μ€μ λλ½)"); | |
| // 1) returning from HF OAuth? | |
| let res = null; | |
| try { res = await oauthHandleRedirectIfPresent(); } catch (e) { diag("oauth", false, "μ½λ°± μ²λ¦¬ μ€ν¨: " + e.message); } | |
| if (res) { | |
| history.replaceState(null, "", location.pathname + (EMBED && !IN_IFRAME ? "?embed=1" : "")); | |
| const payload = { type: "tm-oauth", token: res.accessToken, exp: +new Date(res.accessTokenExpiresAt), orgs: res.userInfo.orgs || [] }; | |
| if (res.state === "popup") { | |
| show("popupView"); | |
| const k = store.get("handoffK"); store.del("handoffK"); | |
| const via = []; | |
| if (window.opener) { try { window.opener.postMessage(payload, location.origin); via.push("postMessage"); } catch (e) {} } | |
| if (k) { | |
| $("#popupMsg").textContent = "μλ νλ©΄μΌλ‘ λ‘κ·ΈμΈ μ 보λ₯Ό 보λ΄λ μ€β¦"; | |
| try { await handoffPut(k, payload); via.push("μνΈν handoff"); } catch (e) { $("#popupMsg").textContent = "handoff μ μ₯ μ€ν¨: " + e.message; } | |
| } | |
| if (via.length) { | |
| $("#popupTitle").textContent = "λ‘κ·ΈμΈ μλ£"; | |
| $("#popupMsg").textContent = `μλ νλ©΄μΌλ‘ λμκ°μΈμ (${via.join(" + ")}). μ΄ μ°½μ κ³§ λ«νλλ€.`; | |
| setTimeout(() => window.close(), 1500); | |
| } else { | |
| $("#popupTitle").textContent = "λ‘κ·ΈμΈμ λμ§λ§ μλ νλ©΄μΌλ‘ λλ €λ³΄λ΄μ§ λͺ»νμ΅λλ€"; | |
| $("#popupMsg").textContent = "window.openerλ handoff ν€λ μμ΅λλ€. μ΄ μ°½μ λ«κ³ ν ν° λ‘κ·ΈμΈμ μ¨ μ£ΌμΈμ."; | |
| } | |
| return; | |
| } | |
| await acceptLogin(payload.token, payload.exp, "oauth-redirect", payload.orgs); | |
| return; | |
| } | |
| // 2) popup opened by the iframe: go straight to HF authorize (must be top-level) | |
| if (params.get("popup") === "1") { | |
| show("popupView"); | |
| const k = new URLSearchParams(location.hash.slice(1)).get("k"); | |
| if (k) store.set("handoffK", k); | |
| try { location.href = await oauthLoginUrl({ scopes: vars.OAUTH_SCOPES, state: "popup", redirectUrl: location.origin + location.pathname }); } | |
| catch (e) { $("#popupTitle").textContent = "λ‘κ·ΈμΈ μμ μ€ν¨"; $("#popupMsg").textContent = e.message; } | |
| return; | |
| } | |
| // 3) saved session | |
| const saved = store.get("auth"); | |
| if (saved && (!saved.exp || saved.exp > Date.now() + 60000)) { | |
| if (await acceptLogin(saved.token, saved.exp, saved.method + " (μ μ₯λ μΈμ )", null, true)) return; | |
| } | |
| showLogin(); | |
| } | |
| function showLogin(msg) { | |
| show("loginView"); | |
| const has = !!window.huggingface?.variables?.OAUTH_CLIENT_ID; | |
| $("#btnPopup").disabled = !has; $("#btnRedirect").disabled = !has; | |
| $("#btnRedirect").classList.toggle("hidden", IN_IFRAME); // HF login page refuses to render inside iframes (X-Frame-Options) | |
| $("#loginNote").textContent = IN_IFRAME ? "λ€λ₯Έ νμ΄μ§(Notion, HF Space νλ©΄) μμμλ λ‘κ·ΈμΈμ΄ νμ μ°½μΌλ‘ μ΄λ¦½λλ€." : ""; | |
| if (msg) $("#loginErr").textContent = msg; | |
| } | |
| window.addEventListener("message", (ev) => { | |
| if (ev.origin !== location.origin || ev.data?.type !== "tm-oauth") return; | |
| diag("popup", true, "νμ μμ ν ν° λ°μ"); | |
| acceptLogin(ev.data.token, ev.data.exp, "oauth-popup", ev.data.orgs); | |
| }); | |
| $("#btnPopup").onclick = () => { | |
| const k = b64u.enc(crypto.getRandomValues(new Uint8Array(32))); | |
| // open synchronously inside the click handler so popup blockers allow it | |
| const w = window.open(location.origin + location.pathname + "?popup=1#k=" + k, "tm-login", "width=520,height=720"); | |
| if (!w) { diag("popup", false, "νμ μ΄ μ°¨λ¨λ¨"); $("#loginErr").textContent = "νμ μ΄ μ°¨λ¨λμ΅λλ€. νμ μ νμ©νκ±°λ ν ν°μΌλ‘ λ‘κ·ΈμΈν΄ μ£ΌμΈμ."; return; } | |
| diag("popup", null, "νμ μ΄λ¦Ό Β· μλ΅ κΈ°λ€λ¦¬λ μ€"); | |
| $("#loginErr").textContent = ""; $("#loginNote").textContent = "νμ μ°½μμ λ‘κ·ΈμΈμ λ§μΉλ©΄ μ¬κΈ°λ‘ μλμΌλ‘ λ€μ΄μ΅λλ€β¦"; | |
| handoffPoll(k); | |
| }; | |
| $("#btnRedirect").onclick = async () => { | |
| try { location.href = await oauthLoginUrl({ scopes: window.huggingface.variables.OAUTH_SCOPES, state: "redirect", redirectUrl: location.origin + location.pathname }); } | |
| catch (e) { $("#loginErr").textContent = "λ‘κ·ΈμΈ μμ μ€ν¨: " + e.message; } | |
| }; | |
| $("#btnToken").onclick = async () => { | |
| const t = $("#tokenIn").value.trim(); if (!t) return; | |
| $("#loginErr").textContent = ""; | |
| if (!(await acceptLogin(t, null, "token"))) { if (!$("#loginErr").textContent) $("#loginErr").textContent = "ν ν°μ΄ μ¬λ°λ₯΄μ§ μμ΅λλ€."; } | |
| }; | |
| $("#tokenIn").onkeydown = (e) => { if (e.key === "Enter") $("#btnToken").click(); }; | |
| $("#btnDeniedLogout").onclick = () => logout(); | |
| async function acceptLogin(token, exp, method, oauthOrgs, silent) { | |
| state.token = token; | |
| let u; | |
| try { u = await (await hf("/api/whoami-v2")).json(); } | |
| catch (e) { state.token = null; diag("login", false, method + " μ€ν¨: " + e.message); if (!silent) $("#loginErr").textContent = "λ‘κ·ΈμΈ νμΈ μ€ν¨: " + e.message; else store.del("auth"); return false; } | |
| state.me = { name: u.name, fullname: u.fullname || u.name, avatar: avatarUrl(u.avatarUrl) }; | |
| state.loginMethod = method; | |
| diag("login", true, `${method} Β· @${u.name} Β· ν ν° μ’ λ₯: ${u.auth?.type === "oauth" ? "OAuth" : u.auth?.accessToken?.role || "?"}`); | |
| // Organization gate. Membership comes from the Hub, never from client-provided claims. | |
| const orgs = (u.orgs || []).map((o) => o.name); | |
| const oauthOrgNames = (oauthOrgs || []).map((o) => o.preferred_username || o.name); | |
| const isMember = orgs.includes(CFG.ORG) || oauthOrgNames.includes(CFG.ORG); | |
| diag("org", isMember, isMember ? `${CFG.ORG} λ©€λ² νμΈ` : `${CFG.ORG} μμ Β· 보μ΄λ μ‘°μ§: ${orgs.join(", ") || "(μμ)"}`); | |
| if (!isMember) { | |
| show("deniedView"); | |
| $("#deniedDetail").textContent = `@${u.name} Β· μ΄ λ‘κ·ΈμΈμΌλ‘ 보μ΄λ μ‘°μ§: ${orgs.join(", ") || "μμ"}` + | |
| (u.auth?.type === "oauth" ? " β λ‘κ·ΈμΈ λμ νλ©΄μμ μ‘°μ§ μ κ·Όμ νμ©νμ§ μμμ μ μμ΅λλ€." : ""); | |
| store.del("auth"); return true; | |
| } | |
| store.set("auth", { token, exp, method: method.split(" ")[0] }); | |
| await startApp(); | |
| return true; | |
| } | |
| function logout() { store.del("auth"); state.token = null; state.me = null; stopTimers(); showLogin(); } | |
| // βββββββββββββββββββββββββββββ app | |
| let timers = []; | |
| function stopTimers() { timers.forEach(clearInterval); timers = []; } | |
| async function startApp() { | |
| show("app"); | |
| $("#meAv").src = state.me.avatar; $("#meName").textContent = state.me.fullname; | |
| await Promise.all([loadMembers(), loadConversations()]); | |
| const last = store.get("conv"); | |
| setView("chats"); | |
| if (last && state.convs.has(last)) openConv(last); | |
| stopTimers(); | |
| timers.push(setInterval(() => { if (!document.hidden && state.convId) refreshConv(); }, CFG.POLL_CHAT_MS)); | |
| timers.push(setInterval(() => { if (!document.hidden) loadConversations(); }, CFG.POLL_LIST_MS)); | |
| } | |
| $("#nav").onclick = (e) => { const b = e.target.closest("button[data-nav]"); if (b) setView(b.dataset.nav); }; | |
| function setView(v) { | |
| state.view = v; | |
| document.querySelectorAll("#nav button[data-nav]").forEach((b) => b.classList.toggle("on", b.dataset.nav === v)); | |
| $("#listPane").classList.toggle("hidden", v === "settings"); | |
| $("#chatPane").classList.toggle("hidden", v === "settings"); | |
| $("#diagPane").classList.toggle("hidden", v !== "settings"); | |
| $("#listTitle").textContent = v === "people" ? "People" : "Chats"; | |
| if (v === "settings") renderDiag(); else renderList(); | |
| } | |
| $("#search").oninput = () => renderList(); | |
| // members: real HF organization members | |
| async function loadMembers() { | |
| try { | |
| const r = await (await hf(`/api/organizations/${CFG.ORG}/members?limit=500`)).json(); | |
| state.members = r.map((m) => ({ name: m.user, fullname: m.fullname || m.user, avatar: avatarUrl(m.avatarUrl), role: m.role })); | |
| diag("members", true, `${state.members.length}λͺ : ${state.members.map((m) => m.name).join(", ")}`); | |
| } catch (e) { diag("members", false, e.message); } | |
| } | |
| const isMe = (name) => !!state.me && String(name).toLowerCase() === state.me.name.toLowerCase(); | |
| function member(name) { | |
| const m = state.members.find((x) => x.name.toLowerCase() === String(name).toLowerCase()) || | |
| (isMe(name) ? { name: state.me.name, fullname: state.me.fullname, avatar: state.me.avatar } : { name, fullname: name, avatar: "" }); | |
| return m; | |
| } | |
| // Self-chat ("note to self") is just the DM of a user with themselves: dm__me__me. | |
| const convTitle = (c) => (isMe(c.peer) ? "λμκ² (λ©λͺ¨)" : member(c.peer).fullname); | |
| // Deterministic DM id: same pair β same conversation, regardless of who starts it. | |
| function dmId(a, b) { const [x, y] = [a.toLowerCase(), b.toLowerCase()].sort(); return `dm__${x}__${y}`; } | |
| function peerOf(id) { const [, x, y] = id.split("__"); return x === state.me.name.toLowerCase() ? y : x; } | |
| // Storage layout: one immutable JSON file per message β | |
| // messages/<conversation_id>/<ISO-time>__<sender>__<rand>.json | |
| // No read-modify-write, so two people sending at once can never overwrite each other. | |
| async function loadConversations() { | |
| let files; | |
| try { files = await Bucket.list("messages"); diag("bucketList", true, `λͺ©λ‘ μ½κΈ° μ±κ³΅ Β· νμΌ ${files.length}κ°`); } | |
| catch (e) { diag("bucketList", false, "Bucket λͺ©λ‘ μ€ν¨: " + e.message); return; } | |
| const me = state.me.name.toLowerCase(); | |
| const byConv = new Map(); | |
| for (const f of files) { | |
| if (f.type !== "file") continue; | |
| const parts = f.path.split("/"); if (parts.length !== 3) continue; | |
| const id = parts[1]; | |
| if (!id.startsWith("dm__") || !id.split("__").slice(1).includes(me)) continue; // probe: client-side filter only | |
| (byConv.get(id) || byConv.set(id, []).get(id)).push(f.path); | |
| } | |
| for (const [id, paths] of byConv) { | |
| paths.sort(); | |
| const c = state.convs.get(id) || { id, peer: peerOf(id) }; | |
| c.files = paths; | |
| const lastPath = paths[paths.length - 1]; | |
| if (!c.last || c.last._path !== lastPath) { | |
| try { c.last = await getMsg(lastPath); } catch {} | |
| } | |
| state.convs.set(id, c); | |
| } | |
| if (state.view !== "settings") renderList(); | |
| } | |
| async function getMsg(path) { | |
| if (state.msgCache.has(path)) return state.msgCache.get(path); | |
| const m = await Bucket.readJson(path); m._path = path; | |
| state.msgCache.set(path, m); return m; | |
| } | |
| function unread(c) { | |
| const seen = store.get("seen", {})[c.id]; | |
| if (!c.files) return 0; | |
| return c.files.filter((p) => (!seen || p > seen) && !p.includes(`__${state.me.name}__`)).length; | |
| } | |
| function relTime(iso) { | |
| const s = (Date.now() - new Date(iso)) / 1000; | |
| if (s < 60) return "λ°©κΈ"; if (s < 3600) return Math.floor(s / 60) + "λΆ"; if (s < 86400) return Math.floor(s / 3600) + "μκ°"; | |
| return new Date(iso).toLocaleDateString("ko-KR", { month: "numeric", day: "numeric" }); | |
| } | |
| function renderList() { | |
| const q = $("#search").value.trim().toLowerCase(); | |
| const body = $("#listBody"); | |
| if (state.view === "people") { | |
| const ms = state.members.filter((m) => !q || (m.name + m.fullname + (isMe(m.name) ? "λμκ² λ©λͺ¨" : "")).toLowerCase().includes(q)) | |
| .sort((a, b) => (isMe(b.name) ? 1 : 0) - (isMe(a.name) ? 1 : 0)); | |
| body.innerHTML = ms.length ? ms.map((m) => `<div class="row" data-peer="${esc(m.name)}"><img class="av" src="${esc(m.avatar)}" alt=""> | |
| <div class="tx"><div class="nm">${esc(m.fullname)}${isMe(m.name) ? " (λ)" : ""}</div><div class="lm"><span>${isMe(m.name) ? "λμκ² λ³΄λ΄κΈ° Β· λ©λͺ¨" : "@" + esc(m.name) + " Β· " + esc(m.role || "")}</span></div></div></div>`).join("") | |
| : `<div class="empty">κ²μ κ²°κ³Όκ° μμ΅λλ€</div>`; | |
| return; | |
| } | |
| const convs = [...state.convs.values()].filter((c) => { | |
| if (!q) return true; const m = member(c.peer); | |
| return (m.name + convTitle(c) + (c.last ? preview(c.last) : "")).toLowerCase().includes(q); | |
| }).sort((a, b) => (b.last?.created_at || "").localeCompare(a.last?.created_at || "")); | |
| body.innerHTML = convs.length ? convs.map((c) => { | |
| const m = member(c.peer), un = c.id === state.convId ? 0 : unread(c); | |
| const lm = c.last ? (c.last.sender === state.me.name && !isMe(c.peer) ? "λ: " : "") + preview(c.last) : ""; | |
| return `<div class="row ${c.id === state.convId ? "on" : ""} ${un ? "unread" : ""}" data-conv="${esc(c.id)}"><img class="av" src="${esc(m.avatar)}" alt=""> | |
| <div class="tx"><div class="nm">${esc(convTitle(c))}</div><div class="lm"><span>${esc(lm.split("\n")[0])}</span><span>${c.last ? relTime(c.last.created_at) : ""}</span></div></div> | |
| ${un ? `<span class="badge">${un}</span>` : ""}</div>`; | |
| }).join("") : `<div class="empty">μμ§ λνκ° μμ΅λλ€.<br>Peopleμμ νμμ λλ¬ μμνμΈμ.</div>`; | |
| } | |
| $("#listBody").onclick = (e) => { | |
| const r = e.target.closest(".row"); if (!r) return; | |
| if (r.dataset.conv) openConv(r.dataset.conv); | |
| else if (r.dataset.peer) { const id = dmId(state.me.name, r.dataset.peer); if (!state.convs.has(id)) state.convs.set(id, { id, peer: peerOf(id), files: [] }); setView("chats"); openConv(id); } | |
| }; | |
| $("#back").onclick = () => { $("#body").classList.remove("inchat"); state.convId = null; renderList(); }; | |
| async function openConv(id) { | |
| state.convId = id; state.pending = []; store.set("conv", id); | |
| $("#body").classList.add("inchat"); | |
| const c = state.convs.get(id), m = member(c.peer); | |
| $("#cAv").src = m.avatar; $("#cName").textContent = convTitle(c); $("#cSub").textContent = isMe(c.peer) ? "λλ§ λ³΄λ λ©λͺ¨ μ±ν " : "@" + m.name; | |
| $("#msgs").innerHTML = `<div class="empty">λΆλ¬μ€λ μ€β¦</div>`; | |
| renderList(); | |
| await refreshConv(true); | |
| $("#input").focus(); | |
| } | |
| async function refreshConv(force) { | |
| const id = state.convId; if (!id) return; | |
| let files; | |
| try { files = (await Bucket.list("messages/" + id)).filter((f) => f.type === "file").map((f) => f.path).sort(); } | |
| catch (e) { if (force) $("#msgs").innerHTML = `<div class="empty">λΆλ¬μ€κΈ° μ€ν¨: ${esc(e.message)}</div>`; return; } | |
| if (state.convId !== id) return; | |
| const c = state.convs.get(id); const changed = force || !c.files || c.files.length !== files.length; | |
| c.files = files; | |
| if (!changed) return; | |
| const msgs = await Promise.all(files.map((p) => getMsg(p).catch(() => null))); | |
| if (state.convId !== id) return; | |
| const real = msgs.filter(Boolean); | |
| if (real.length) c.last = real[real.length - 1]; | |
| // drop optimistic entries the server now has | |
| state.pending = state.pending.filter((p) => !real.some((r) => r.id === p.id)); | |
| if (files.length) { const seen = store.get("seen", {}); seen[id] = files[files.length - 1]; store.set("seen", seen); } | |
| renderMsgs(real.concat(state.pending)); | |
| renderList(); | |
| } | |
| // βββββββββββββββββββββββββββββ attachments | |
| // Files go to the same private bucket: attachments/<conversation_id>/<time>__<sender>__<name>. | |
| // Private bucket files need the Authorization header, so <img>/<a> can't point at them directly: | |
| // we fetch each one once as a blob and use an object URL. | |
| const IMAGE_EXT = ["png", "jpg", "jpeg", "webp", "gif"]; | |
| const extOf = (n) => (n.includes(".") ? n.split(".").pop().toLowerCase() : ""); | |
| const isImage = (a) => IMAGE_EXT.includes(extOf(a.name)) || /^image\/(png|jpe?g|webp|gif)$/.test(a.mime || ""); | |
| const fmtSize = (n) => (n < 1024 ? n + " B" : n < 1048576 ? (n / 1024).toFixed(1) + " KB" : n < 1073741824 ? (n / 1048576).toFixed(1) + " MB" : (n / 1073741824).toFixed(2) + " GB"); | |
| const safeName = (n) => (n || "file").replace(/[\\/:*?"<>|#%]+/g, "_").replace(/\s+/g, " ").trim().slice(0, 120); | |
| const blobUrls = new Map(); // path -> Promise<objectURL> | |
| function attUrl(path) { | |
| if (!blobUrls.has(path)) { | |
| const pr = hf(`/buckets/${CFG.BUCKET}/resolve/${path.split("/").map(encodeURIComponent).join("/")}`) | |
| .then((r) => r.blob()).then((b) => URL.createObjectURL(b)); | |
| pr.catch(() => blobUrls.delete(path)); | |
| blobUrls.set(path, pr); | |
| } | |
| return blobUrls.get(path); | |
| } | |
| function attHtml(a, local) { | |
| if (isImage(a)) return `<img class="att-img" data-path="${esc(a.path || "")}" ${local ? `src="${esc(local)}"` : ""} alt="${esc(a.name)}" title="${esc(a.name)}">`; | |
| return `<a class="att-file" data-path="${esc(a.path || "")}" data-name="${esc(a.name)}"><span class="ic">${esc((extOf(a.name) || "FILE").slice(0, 4).toUpperCase())}</span> | |
| <span style="min-width:0"><div class="fn">${esc(a.name)}</div><div class="fs">${fmtSize(a.size)}</div></span></a>`; | |
| } | |
| const preview = (m) => (m.content ? m.content : m.attachments?.length ? (m.attachments.every(isImage) ? "π· μ¬μ§" : "π " + m.attachments[0].name) + (m.attachments.length > 1 ? ` μΈ ${m.attachments.length - 1}κ°` : "") : ""); | |
| function renderMsgs(msgs) { | |
| const box = $("#msgs"); const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80; | |
| if (!msgs.length) { box.innerHTML = `<div class="empty">첫 λ©μμ§λ₯Ό λ³΄λ΄ λ³΄μΈμ.</div>`; return; } | |
| let html = "", prevDay = "", prev = null; | |
| for (const m of msgs) { | |
| const d = new Date(m.created_at), day = d.toDateString(); | |
| if (day !== prevDay) { html += `<div class="day">${d.toLocaleDateString("ko-KR", { month: "long", day: "numeric", weekday: "short" })}</div>`; prevDay = day; prev = null; } | |
| const mine = m.sender === state.me.name, cont = prev && prev.sender === m.sender && d - new Date(prev.created_at) < 120000; | |
| const who = member(m.sender); | |
| const atts = m.attachments || []; | |
| const onlyMedia = !m.content && atts.length && atts.every(isImage); | |
| const body = (m.content ? `<div class="txt">${esc(m.content)}</div>` : "") + | |
| atts.map((a, i) => (i || m.content ? `<div class="att-gap"></div>` : "") + attHtml(a, m._local?.[i])).join(""); | |
| html += `<div class="m ${mine ? "mine" : ""} ${cont ? "cont" : ""} ${m._pending ? "pending" : ""} ${m._failed ? "failed" : ""}"> | |
| ${mine ? "" : `<div class="avs">${cont ? "" : `<img class="av sm" src="${esc(who.avatar)}" alt="">`}</div>`} | |
| <div class="col">${!mine && !cont ? `<div class="who">${esc(who.fullname)}</div>` : ""}<div class="bub ${onlyMedia ? "media" : ""}">${body}</div></div> | |
| <div class="tm">${m._failed ? "μ μ‘ μ€ν¨" : m._pending ? (atts.length ? "μ¬λ¦¬λ μ€" : "보λ΄λ μ€") : d.toLocaleTimeString("ko-KR", { hour: "numeric", minute: "2-digit" })}</div></div>`; | |
| prev = m; | |
| } | |
| box.innerHTML = html; | |
| box.querySelectorAll("img.att-img[data-path]").forEach((img) => { | |
| if (img.getAttribute("src") || !img.dataset.path) return; | |
| attUrl(img.dataset.path).then((u) => { img.src = u; if (atBottom) box.scrollTop = box.scrollHeight; }, () => { img.alt = "β μ΄λ―Έμ§λ₯Ό λΆλ¬μ€μ§ λͺ»ν¨"; }); | |
| }); | |
| if (atBottom || msgs.some((m) => m._pending)) box.scrollTop = box.scrollHeight; | |
| } | |
| $("#msgs").onclick = async (e) => { | |
| const img = e.target.closest("img.att-img"); | |
| if (img && img.src) { $("#lightbox img").src = img.src; $("#lightbox").classList.remove("hidden"); return; } | |
| const f = e.target.closest(".att-file"); | |
| if (f && f.dataset.path) { | |
| toast("λ΄λ €λ°λ μ€β¦", 60000); | |
| try { const u = await attUrl(f.dataset.path); const a = document.createElement("a"); a.href = u; a.download = f.dataset.name; a.click(); toast("λ€μ΄λ‘λ: " + f.dataset.name); } | |
| catch (er) { toast("λ€μ΄λ‘λ μ€ν¨: " + er.message, 5000); } | |
| } | |
| }; | |
| $("#lightbox").onclick = () => $("#lightbox").classList.add("hidden"); | |
| const input = $("#input"); | |
| input.oninput = () => { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 140) + "px"; $("#sendBtn").disabled = !input.value.trim(); }; | |
| input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); } }; | |
| $("#sendBtn").onclick = () => send(); | |
| $("#attachBtn").onclick = () => { if (state.convId) $("#filePick").click(); else toast("λ¨Όμ λνλ₯Ό μ¬μΈμ"); }; | |
| $("#filePick").onchange = (e) => { const fs = [...e.target.files]; e.target.value = ""; if (fs.length) send(fs); }; | |
| input.addEventListener("paste", (e) => { const fs = [...(e.clipboardData?.files || [])]; if (fs.length) { e.preventDefault(); send(fs); } }); | |
| const chatPane = $("#chatPane"); | |
| chatPane.addEventListener("dragover", (e) => { if (state.convId && e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); chatPane.classList.add("dropping"); } }); | |
| chatPane.addEventListener("dragleave", (e) => { if (!chatPane.contains(e.relatedTarget)) chatPane.classList.remove("dropping"); }); | |
| chatPane.addEventListener("drop", (e) => { e.preventDefault(); chatPane.classList.remove("dropping"); const fs = [...(e.dataTransfer?.files || [])]; if (fs.length && state.convId) send(fs); }); | |
| async function send(files = []) { | |
| const text = input.value.trim(); if ((!text && !files.length) || !state.convId) return; | |
| input.value = ""; input.oninput(); | |
| const convId = state.convId, now = new Date(), stamp = now.toISOString().replace(/[:.]/g, "-"); | |
| const attachments = files.map((f) => ({ | |
| name: f.name || "image.png", size: f.size, mime: f.type || "", | |
| path: `attachments/${convId}/${stamp}__${state.me.name}__${safeName(f.name || "image.png")}`, | |
| })); | |
| const msg = { | |
| id: "msg_" + now.getTime().toString(36) + Math.random().toString(36).slice(2, 8), | |
| conversation_id: convId, sender: state.me.name, | |
| type: !attachments.length ? "text" : attachments.every(isImage) ? "image" : "file", | |
| content: text, created_at: now.toISOString(), reply_to: null, attachments, reactions: {}, read_by: [state.me.name], | |
| }; | |
| const path = `messages/${convId}/${stamp}__${state.me.name}__${msg.id.slice(-6)}.json`; | |
| const pend = { ...msg, _pending: true, _local: files.map((f, i) => (isImage(attachments[i]) ? URL.createObjectURL(f) : null)) }; | |
| state.pending.push(pend); | |
| const c = state.convs.get(convId); | |
| const rerender = () => { if (state.convId === convId) renderMsgs((c.files || []).map((p) => state.msgCache.get(p)).filter(Boolean).concat(state.pending)); }; | |
| rerender(); | |
| const t0 = performance.now(); | |
| try { | |
| if (files.length) { | |
| const total = files.reduce((s, f) => s + f.size, 0); | |
| toast(`μ¬λ¦¬λ μ€β¦ ${files.length}κ° Β· ${fmtSize(total)}`, 600000); | |
| await uploadFiles({ | |
| repo: { type: "bucket", name: CFG.BUCKET }, accessToken: state.token, useWebWorkers: false, | |
| files: files.map((f, i) => ({ path: attachments[i].path, content: f })), | |
| }); | |
| // reuse the local blob for our own images so they don't need a re-download | |
| attachments.forEach((a, i) => { if (pend._local[i]) blobUrls.set(a.path, Promise.resolve(pend._local[i])); }); | |
| diag("attach", true, `μ²¨λΆ ${files.length}κ° Β· ${fmtSize(total)} Β· ${Math.round(performance.now() - t0)} ms`); | |
| toast("μ²¨λΆ μλ£"); | |
| } | |
| await Bucket.writeJson(path, msg); | |
| diag("bucketWrite", true, `λ©μμ§ μ μ₯ μ±κ³΅ Β· ${Math.round(performance.now() - t0)} ms`); | |
| store.set("lastSent", { path, id: msg.id, at: msg.created_at }); | |
| await refreshConv(true); | |
| } catch (e) { | |
| pend._failed = true; pend._pending = false; | |
| diag(files.length ? "attach" : "bucketWrite", false, "μ μ₯ μ€ν¨: " + e.message); | |
| rerender(); | |
| toast("μ μ‘ μ€ν¨: " + e.message, 6000); | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββ Settings Β· μ§λ¨ (feasibility checklist) | |
| const CHECKS = [ | |
| ["iframe", "μ€ν μμΉ"], ["storage", "λΈλΌμ°μ μ μ₯μ"], ["oauthcfg", "OAuth μ€μ "], ["popup", "νμ λ‘κ·ΈμΈ"], | |
| ["handoffDel", "handoff μ 리"], ["login", "λ‘κ·ΈμΈ"], ["org", "μ‘°μ§ λ©€λ² νμΈ"], ["members", "νμ λͺ©λ‘"], ["bucketList", "Bucket μ½κΈ°"], | |
| ["bucketWrite", "Bucket μ°κΈ°"], ["attach", "νμΌ μ²¨λΆ"], ["persist", "μλ‘κ³ μΉ¨ ν μ μ§"], | |
| ]; | |
| async function checkPersist() { | |
| const ls = store.get("lastSent"); | |
| if (!ls) return diag("persist", null, "μμ§ λ³΄λΈ λ©μμ§ μμ β λ©μμ§λ₯Ό νλ λ³΄λΈ λ€ μλ‘κ³ μΉ¨νκ³ λ€μ 보μΈμ"); | |
| try { const m = await Bucket.readJson(ls.path); diag("persist", m.id === ls.id, m.id === ls.id ? `λ§μ§λ§μΌλ‘ λ³΄λΈ λ©μμ§(${new Date(ls.at).toLocaleTimeString("ko-KR")})κ° Bucketμ μμ` : "λ€λ₯Έ λ΄μ©μ΄ μ½ν"); } | |
| catch (e) { diag("persist", false, "λ§μ§λ§ λ©μμ§λ₯Ό λͺ» μ½μ: " + e.message); } | |
| } | |
| function renderDiag() { | |
| const mark = (v) => (v === true ? `<span class="ok">β</span>` : v === false ? `<span class="bad">β</span>` : `<span class="warn">β¦</span>`); | |
| $("#diagBody").innerHTML = `<h2>Settings Β· μ§λ¨</h2> | |
| <div style="color:var(--dim);font-size:12.5px">μ΄ μννμΌλ‘ HFλ§μΌλ‘ λλμ§ νμΈνλ νλͺ©μ λλ€. λ¬Έμ κ° μμΌλ©΄ [κ²°κ³Ό 볡μ¬]λ‘ λ³΄λ΄ μ£ΌμΈμ.</div> | |
| <table>${CHECKS.map(([k, label]) => `<tr><td>${DIAG[k] ? mark(DIAG[k].ok) : "Β·"}</td><td>${label}</td><td>${esc(DIAG[k]?.detail || "μμ§ νμΈ μ ν¨")}</td></tr>`).join("")}</table> | |
| <div style="display:flex;gap:6px;flex-wrap:wrap"> | |
| <button class="btn" id="dPersist">μλ‘κ³ μΉ¨ ν μ μ§ νμΈ</button> | |
| <button class="btn" id="dCopy">κ²°κ³Ό 볡μ¬</button> | |
| <button class="btn" id="dTheme">ν λ§ μ ν</button> | |
| <button class="btn" id="dLogout">λ‘κ·Έμμ</button> | |
| </div> | |
| <div class="note" style="margin-top:14px;max-width:760px">μνν νκ³: μλ²κ° μμ΄μ λΈλΌμ°μ κ° λ‘κ·ΈμΈν μ¬λμ ν ν°μΌλ‘ Bucketμ μ§μ μ κ·Όν©λλ€. | |
| κ·Έλμ <b>DM λΉκ³΅κ°κ° 보μ₯λμ§ μμ΅λλ€</b>(μ‘°μ§μμ μ μ₯μλ₯Ό μ§μ μ΄λ©΄ λͺ¨λ λνλ₯Ό λ³Ό μ μμ). λ³Έ μ ν(Docker Space + FastAPI)μμλ μλ²κ° λν μ°Έμ¬μλ§ μ½κ² λ§μ΅λλ€.</div>`; | |
| $("#dPersist").onclick = checkPersist; | |
| $("#dCopy").onclick = async () => { | |
| const txt = CHECKS.map(([k, l]) => `${DIAG[k] ? (DIAG[k].ok === true ? "OK" : DIAG[k].ok === false ? "FAIL" : "..") : "-"}\t${l}\t${DIAG[k]?.detail || ""}`).join("\n") + `\nUA: ${navigator.userAgent}`; | |
| try { await navigator.clipboard.writeText(txt); toast("볡μ¬νμ΅λλ€"); } catch { prompt("볡μ¬νμΈμ", txt); } | |
| }; | |
| $("#dTheme").onclick = () => { const d = document.documentElement; d.dataset.theme = (d.dataset.theme || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")) === "dark" ? "light" : "dark"; }; | |
| $("#dLogout").onclick = logout; | |
| } | |
| document.addEventListener("visibilitychange", () => { if (!document.hidden && state.convId) refreshConv(); }); | |
| boot().then(() => { if (state.me) checkPersist(); }); | |
| </script> | |
| </body> | |
| </html> | |