From 9267c7c6a1158bb7669c8d882caa4707019e97aa Mon Sep 17 00:00:00 2001 From: th Date: Wed, 15 Apr 2026 20:15:50 +0200 Subject: [PATCH] =?UTF-8?q?chat:=20group=20chat=20UI=20=E2=80=94=20room=20?= =?UTF-8?q?tabs,=20creation=20flow,=20member=20mgmt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalises the flyout from peer-only to a thread abstraction ({kind: 'peer'|'room', key, label}). Conversations list and peer strip render rooms with a group icon alongside 1:1 peers; clicking either opens the same thread view. New UI surface: - Pick view gets a 1:1 / group mode switch. Group mode reveals a name field + multi-select member list and a Create button. - Room threads show an info icon in the flyout header that opens a member list; owners can add/remove members, rename-later, or delete the room; non-owners can leave. - 1:1 threads show a disposable toggle (ph-fire) in the header; flipping it tells the relay to drop future sends after delivery instead of persisting. - Group bubbles show the author's username above each message when it changes, matching common chat UX. Thread open triggers a backfill fetch so cold caches / second devices catch up on older history from the relay. Co-Authored-By: Claude Opus 4.6 (1M context) --- public/plugins/chat/chat.css | 138 +++++++++ public/plugins/chat/chat.html | 29 +- public/plugins/chat/chat.js | 533 +++++++++++++++++++++++++++------- 3 files changed, 592 insertions(+), 108 deletions(-) diff --git a/public/plugins/chat/chat.css b/public/plugins/chat/chat.css index 93e783c..da9ad32 100644 --- a/public/plugins/chat/chat.css +++ b/public/plugins/chat/chat.css @@ -329,6 +329,144 @@ color: var(--t-accent); } +.chat-pick-row.selected { + background: var(--t-raised); + color: var(--t-accent); +} +.chat-pick-row .chat-pick-check { + margin-left: auto; + color: var(--t-accent); + font-size: 12px; +} +.chat-pick-row:not(.selected) .chat-pick-check { visibility: hidden; } + +/* Picker mode switch (1:1 vs. group). */ +.chat-pick-mode { + display: flex; + gap: 4px; + padding: 6px 10px; + border-bottom: 1px solid var(--t-border); + background: var(--t-bg); + flex-shrink: 0; +} +.chat-pick-mode-btn { + flex: 1; + padding: 3px 8px; + border: 1px solid var(--t-border); + background: transparent; + color: var(--t-muted); + font-family: var(--t-font-pixel); + font-size: 10px; + cursor: pointer; + transition: border-color 0.12s, color 0.12s, background 0.12s; +} +.chat-pick-mode-btn:hover { color: var(--t-accent); border-color: var(--t-accent); } +.chat-pick-mode-btn.active { + border-color: var(--t-accent); + background: var(--t-accent); + color: var(--t-bg); +} + +.chat-group-name { + margin: 8px 10px 4px; + padding: 5px 8px; + background: var(--t-surface); + border: 1px solid var(--t-border); + color: var(--t-bright); + font-size: 11px; + font-family: var(--t-font-mono, monospace); + outline: none; +} +.chat-group-name:focus { border-color: var(--t-accent); } +.chat-group-name.hidden { display: none; } + +.chat-group-actions { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-top: 1px solid var(--t-border); + background: var(--t-bg); + flex-shrink: 0; +} +.chat-group-actions.hidden { display: none; } + +/* Room-info view: member list + owner actions. */ +#chat-room-info-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--t-border); + background: var(--t-bg); + flex-shrink: 0; +} +.chat-room-info-body { + flex: 1; + overflow-y: auto; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 10px; +} +.chat-room-info-body .section-label { + font-family: var(--t-font-pixel); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--t-dim); + margin-top: 4px; +} +.chat-room-member-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--t-bright); + padding: 3px 0; +} +.chat-room-member-row .owner-badge { + font-family: var(--t-font-pixel); + font-size: 8px; + color: var(--t-accent); + background: var(--t-raised); + padding: 1px 5px; + border: 1px solid var(--t-accent); + margin-left: 4px; +} +.chat-room-info-body .btn { + padding: 4px 10px; + font-size: 10px; + font-family: var(--t-font-pixel); + border: 1px solid var(--t-border); + background: transparent; + color: var(--t-muted); + cursor: pointer; + transition: all 0.12s; +} +.chat-room-info-body .btn:hover { border-color: var(--t-accent); color: var(--t-accent); } +.chat-room-info-body .btn.danger:hover { border-color: var(--t-danger); color: var(--t-danger); } + +/* Author name above group-thread bubbles. */ +.chat-bubble-author { + font-family: var(--t-font-pixel); + font-size: 8px; + color: var(--t-dim); + margin-top: 4px; + padding-left: 2px; +} + +/* Peer-tab variant for rooms — group icon instead of initial. */ +.chat-peer-tab .chat-peer-tab-icon { + font-size: 10px; + flex-shrink: 0; +} + +/* Disposable / info icons in flyout header. */ +#chat-thread-disposable.active { + color: var(--t-accent); +} + /* ── Footer button ── */ #chat-footer-btn { diff --git a/public/plugins/chat/chat.html b/public/plugins/chat/chat.html index 2c1cca3..3d7581c 100644 --- a/public/plugins/chat/chat.html +++ b/public/plugins/chat/chat.html @@ -6,6 +6,8 @@ chat + + @@ -31,12 +33,35 @@ - + + + + diff --git a/public/plugins/chat/chat.js b/public/plugins/chat/chat.js index 0916107..adfe063 100644 --- a/public/plugins/chat/chat.js +++ b/public/plugins/chat/chat.js @@ -3,6 +3,12 @@ // Footer button + bottom-right flyout. Polls /api/chat/poll on a loop // (faster while open, slower while closed). Blinks the footer button // when new incoming messages arrive while the flyout is closed. +// +// Threads come in two kinds: +// peer — classic 1:1 with a single relay tenant. Disposable flag per +// thread drops messages from the relay after delivery. +// room — named group chat with a member list. Always persistent on +// the relay so every device / late joiner has full history. const hyprcrd = window.hyprcrd; if (!hyprcrd) throw new Error('[chat plugin] window.hyprcrd missing'); @@ -12,16 +18,18 @@ const $ = (id) => document.getElementById(id); // ── State ───────────────────────────────────────────────────────── let conversations = []; -let currentPeer = null; +// Null = list view. Otherwise: { kind: 'peer'|'room', key, label }. +let currentThread = null; let currentMessages = []; let pollHandle = null; -let pollIntervalMs = 20000; // closed default +let pollIntervalMs = 20000; let recipientCache = null; // { tenants: [...], self: '...' } let initialPollDone = false; -// Chat polls aggressively because the endpoint is cheap (single indexed -// SELECT with LIMIT 500). Card sync stays on its own slower interval — -// CalDAV is the expensive one. Tab-hidden pauses the timer entirely. +// Group creation pick-view state. Reset on every pick-view open. +let pickMode = 'peer'; // 'peer' or 'group' +let selectedMembers = new Set(); + const POLL_OPEN_MS = 8000; const POLL_CLOSED_MS = 20000; @@ -49,6 +57,36 @@ function isOpen() { return !$('chat-flyout')?.classList.contains('hidden'); } +function threadFromItem(item) { + return { kind: item.kind || 'peer', key: item.key ?? item.peer, label: item.label ?? item.peer }; +} + +function sameThread(a, b) { + return a && b && a.kind === b.kind && a.key === b.key; +} + +function threadBody(thread) { + return thread.kind === 'room' ? { room_uid: thread.key } : { peer: thread.key }; +} + +function threadStorageKey(thread) { + return `${thread.kind}:${thread.key}`; +} + +function parseStorageKey(str) { + if (!str) return null; + const i = str.indexOf(':'); + if (i < 0) return { kind: 'peer', key: str, label: str }; + return { kind: str.slice(0, i), key: str.slice(i + 1), label: str.slice(i + 1) }; +} + +async function getSelfUsername() { + if (recipientCache?.self) return recipientCache.self; + const res = await fetch('api/chat/recipients').then(r => r.json()).catch(() => null); + if (res?.ok) recipientCache = res; + return recipientCache?.self || ''; +} + // ── Footer button ───────────────────────────────────────────────── function injectFooterButton() { @@ -56,7 +94,6 @@ function injectFooterButton() { if (!footer) return; if (document.getElementById('chat-footer-btn')) return; - // Append next to the help button on the right side of the footer const rightGroup = footer.querySelector('.flex.items-center.gap-2:last-of-type'); if (!rightGroup) return; @@ -101,29 +138,29 @@ async function toggleFlyout() { async function openFlyout() { $('chat-flyout').classList.remove('hidden'); - // Capture blink state BEFORE we clear it — it tells us a peer wrote since last close const wasBlinking = $('chat-footer-btn')?.classList.contains('blinking'); stopBlink(); pollIntervalMs = POLL_OPEN_MS; restartPoll(); await loadConversations(); - await pollOnce(); // immediate refresh on open + await pollOnce(); await loadConversations(); - // Pick the right initial view: - // 1. If blinking → newest peer with unread (= someone wrote to us) - // 2. Else → last peer the user opened manually (persisted) - // 3. Else → list view + // Initial view: newest unread thread after a blink, else the last + // thread the user opened (peer or room), else the list. let target = null; if (wasBlinking) { const unread = conversations .filter(c => parseInt(c.unread) > 0) - .sort((a, b) => String(b.last_at).localeCompare(String(a.last_at))); - if (unread.length) target = unread[0].peer; + .sort((a, b) => String(b.last_at || '').localeCompare(String(a.last_at || ''))); + if (unread.length) target = threadFromItem(unread[0]); } if (!target) { - const lastPeer = localStorage.getItem('chat-last-peer'); - if (lastPeer && conversations.some(c => c.peer === lastPeer)) target = lastPeer; + const remembered = parseStorageKey(localStorage.getItem('chat-last-thread')); + if (remembered && conversations.some(c => c.kind === remembered.kind && c.key === remembered.key)) { + const item = conversations.find(c => c.kind === remembered.kind && c.key === remembered.key); + target = threadFromItem(item); + } } if (target) await openConversation(target); else showListView(); @@ -137,31 +174,48 @@ function closeFlyout() { // ── Views ───────────────────────────────────────────────────────── -function showListView() { - $('chat-list-view').classList.remove('hidden'); - $('chat-thread-view').classList.add('hidden'); - $('chat-pick-view').classList.add('hidden'); - $('chat-flyout-title').textContent = 'chat'; - $('chat-header-back')?.classList.add('hidden'); - currentPeer = null; +function hideAllViews() { + ['chat-list-view', 'chat-thread-view', 'chat-pick-view', 'chat-room-info-view'] + .forEach(id => $(id)?.classList.add('hidden')); } -function showThreadView(peer) { - $('chat-list-view').classList.add('hidden'); +function showListView() { + hideAllViews(); + $('chat-list-view').classList.remove('hidden'); + $('chat-flyout-title').textContent = 'chat'; + $('chat-header-back')?.classList.add('hidden'); + $('chat-thread-disposable')?.classList.add('hidden'); + $('chat-thread-info')?.classList.add('hidden'); + currentThread = null; +} + +function showThreadView(thread) { + hideAllViews(); $('chat-thread-view').classList.remove('hidden'); - $('chat-pick-view').classList.add('hidden'); $('chat-flyout-title').textContent = 'chat'; $('chat-header-back')?.classList.remove('hidden'); - currentPeer = peer; - // Re-render tabs so the active highlight follows the thread switch. + currentThread = thread; + // Room-only: info icon. 1:1-only: disposable icon. + $('chat-thread-info')?.classList.toggle('hidden', thread.kind !== 'room'); + $('chat-thread-disposable')?.classList.toggle('hidden', thread.kind !== 'peer'); + if (thread.kind === 'peer') refreshDisposableIcon(thread.key); renderPeerTabs(); } function showPickView() { - $('chat-list-view').classList.add('hidden'); - $('chat-thread-view').classList.add('hidden'); + hideAllViews(); $('chat-pick-view').classList.remove('hidden'); $('chat-header-back')?.classList.add('hidden'); + $('chat-thread-disposable')?.classList.add('hidden'); + $('chat-thread-info')?.classList.add('hidden'); +} + +function showRoomInfoView() { + hideAllViews(); + $('chat-room-info-view').classList.remove('hidden'); + $('chat-header-back')?.classList.add('hidden'); + $('chat-thread-disposable')?.classList.add('hidden'); + $('chat-thread-info')?.classList.add('hidden'); } // ── Conversation list ───────────────────────────────────────────── @@ -186,19 +240,29 @@ function renderConversations() { } empty.classList.add('hidden'); list.innerHTML = conversations.map(c => { - const initial = (c.peer || '?').charAt(0).toUpperCase(); + const isRoom = c.kind === 'room'; + const label = c.label || c.key || '?'; + const initial = label.charAt(0).toUpperCase(); + const avatar = isRoom + ? '
' + : `
${escapeHtml(initial)}
`; const unreadBadge = parseInt(c.unread) > 0 ? `${c.unread}` : ''; - const previewClass = c.last_direction === 'out' ? 'chat-list-preview outgoing' : 'chat-list-preview'; + const previewClass = (!isRoom && c.last_direction === 'out') + ? 'chat-list-preview outgoing' + : 'chat-list-preview'; + const preview = isRoom && c.last_author + ? `${escapeHtml(c.last_author)}: ${escapeHtml((c.last_body || '').slice(0, 70))}` + : escapeHtml((c.last_body || '').slice(0, 80)); return ` -
-
${escapeHtml(initial)}
+
+ ${avatar}
- ${escapeHtml(c.peer)} + ${escapeHtml(label)} ${escapeHtml(formatChatTime(c.last_at))}
-
${escapeHtml((c.last_body || '').slice(0, 80))}
+
${preview}
${unreadBadge}
@@ -206,7 +270,10 @@ function renderConversations() { }).join(''); list.querySelectorAll('.chat-list-row').forEach(row => { - row.addEventListener('click', () => openConversation(row.dataset.peer)); + row.addEventListener('click', () => { + const item = conversations.find(c => c.kind === row.dataset.kind && c.key === row.dataset.key); + if (item) openConversation(threadFromItem(item)); + }); }); } @@ -218,36 +285,36 @@ function renderConversations() { function renderPeerTabs() { const strip = $('chat-thread-peers'); if (!strip) return; - // Fold the current peer in even if no messages exist yet (fresh - // pick-view selection), so switching back to it via tab is always - // possible. - const peers = conversations.slice(); - if (currentPeer && !peers.some(c => c.peer === currentPeer)) { - peers.unshift({ peer: currentPeer, unread: 0 }); + const tabs = conversations.slice(); + // Fold the current thread in even if it's brand-new (e.g. just + // created a room and haven't synced conversations yet), so the + // active tab is always visible. + if (currentThread && !tabs.some(c => c.kind === currentThread.kind && c.key === currentThread.key)) { + tabs.unshift({ kind: currentThread.kind, key: currentThread.key, label: currentThread.label, unread: 0 }); } - if (!peers.length) { strip.innerHTML = ''; return; } - strip.innerHTML = peers.map(c => { - const active = c.peer === currentPeer ? ' active' : ''; - const dot = parseInt(c.unread) > 0 && c.peer !== currentPeer + if (!tabs.length) { strip.innerHTML = ''; return; } + strip.innerHTML = tabs.map(c => { + const isRoom = c.kind === 'room'; + const active = currentThread && currentThread.kind === c.kind && currentThread.key === c.key ? ' active' : ''; + const notActive = !active; + const dot = parseInt(c.unread) > 0 && notActive ? '' : ''; - return ``; + const icon = isRoom + ? '' + : ''; + const label = c.label || c.key || '?'; + return ``; }).join(''); strip.querySelectorAll('.chat-peer-tab').forEach(tab => { tab.addEventListener('click', () => { - const peer = tab.dataset.peer; - if (peer && peer !== currentPeer) openConversation(peer); + const thread = { kind: tab.dataset.kind, key: tab.dataset.key, label: tab.querySelector('span')?.textContent || tab.dataset.key }; + if (!sameThread(currentThread, thread)) openConversation(thread); }); }); - // Keep the active tab in view after a switch so the user can see - // what's selected when the list overflows. const active = strip.querySelector('.chat-peer-tab.active'); if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } -// Redirect vertical wheel scrolls to horizontal scroll on the peer -// strip. Attached once on init — preventDefault only when we actually -// consume the delta, so a trackpad user's native horizontal gesture -// still works. function wirePeerStripWheel() { const strip = $('chat-thread-peers'); if (!strip || strip.dataset.wheelWired) return; @@ -262,45 +329,74 @@ function wirePeerStripWheel() { // ── Thread ──────────────────────────────────────────────────────── -async function openConversation(peer) { - showThreadView(peer); - localStorage.setItem('chat-last-peer', peer); - await loadThread(peer); - // Mark all incoming as read - await hyprcrd.api('api/chat/read', { method: 'POST', body: { peer } }).catch(() => {}); - // Refresh badges in the background +async function openConversation(thread) { + showThreadView(thread); + localStorage.setItem('chat-last-thread', threadStorageKey(thread)); + await loadThread(thread); + // Backfill older messages from the relay on open so a second device / + // cold cache catches up. Fire-and-forget: the re-render happens via + // the subsequent conversations refresh and the next poll tick. + backfillThread(thread).catch(() => {}); + await hyprcrd.api('api/chat/read', { method: 'POST', body: threadBody(thread) }).catch(() => {}); loadConversations(); setTimeout(() => $('chat-thread-input')?.focus(), 50); } -async function loadThread(peer) { - const res = await fetch('api/chat/messages?peer=' + encodeURIComponent(peer)) - .then(r => r.json()).catch(() => null); +async function loadThread(thread) { + const qs = thread.kind === 'room' + ? 'room_uid=' + encodeURIComponent(thread.key) + : 'peer=' + encodeURIComponent(thread.key); + const res = await fetch('api/chat/messages?' + qs).then(r => r.json()).catch(() => null); if (!res) return; currentMessages = res.messages || []; - renderThread(); + await renderThread(); } -function renderThread() { +async function backfillThread(thread) { + const body = threadBody(thread); + // Oldest id we have locally is the lower bound for the backfill — + // we want messages BEFORE it. + const lowest = currentMessages.reduce((min, m) => { + const id = Number(m.id || 0); + return min === 0 || (id > 0 && id < min) ? id : min; + }, 0); + const res = await hyprcrd.api('api/chat/backfill', { + method: 'POST', + body: { ...body, before: lowest, limit: 100 }, + }).catch(() => null); + if (res?.ok && (res.backfilled || 0) > 0 && sameThread(thread, currentThread)) { + await loadThread(thread); + } +} + +async function renderThread() { const wrap = $('chat-thread-messages'); if (!wrap) return; + const self = await getSelfUsername(); + const isRoom = currentThread?.kind === 'room'; + let lastAuthor = null; wrap.innerHTML = currentMessages.map(m => { - const cls = m.direction === 'out' ? 'out' : 'in'; - // Read receipts for outgoing messages: single check = recipient - // polled (delivered), double check = recipient opened the thread - // (seen). Incoming messages get no icon. + // For groups: author == self → "out" bubble regardless of the + // stored direction (which reflects which device inserted the row). + const mine = isRoom ? (m.peer === self) : (m.direction === 'out'); + const cls = mine ? 'out' : 'in'; let receipt = ''; - if (m.direction === 'out') { + if (!isRoom && mine) { if (Number(m.seen)) receipt = ''; else if (Number(m.delivered)) receipt = ''; else receipt = ''; } + let authorLine = ''; + if (isRoom && !mine && m.peer !== lastAuthor) { + authorLine = `
${escapeHtml(m.peer || '')}
`; + } + lastAuthor = m.peer; return ` + ${authorLine}
${escapeHtml(m.body)}
${escapeHtml(formatChatTime(m.sent_at))}${receipt}
`; }).join(''); - // Scroll to bottom wrap.scrollTop = wrap.scrollHeight; } @@ -308,7 +404,7 @@ async function sendMessage(e) { e?.preventDefault(); const input = $('chat-thread-input'); const body = input.value.trim(); - if (!body || !currentPeer) return; + if (!body || !currentThread) return; const sendBtn = $('chat-thread-send'); sendBtn.disabled = true; input.disabled = true; @@ -316,7 +412,7 @@ async function sendMessage(e) { try { const res = await hyprcrd.api('api/chat/send', { method: 'POST', - body: { peer: currentPeer, body }, + body: { ...threadBody(currentThread), body }, }); if (!res.ok) { const detail = [ @@ -334,7 +430,7 @@ async function sendMessage(e) { return; } input.value = ''; - await loadThread(currentPeer); + await loadThread(currentThread); await loadConversations(); } catch (err) { await hyprcrd.dialog.show({ @@ -351,10 +447,15 @@ async function sendMessage(e) { } } -// ── Pick recipient ──────────────────────────────────────────────── +// ── Pick recipient / group creation ─────────────────────────────── async function openPickView() { showPickView(); + pickMode = 'peer'; + selectedMembers = new Set(); + applyPickMode(); + $('chat-group-name').value = ''; + const list = $('chat-pick-list'); list.innerHTML = '
loading...
'; @@ -366,25 +467,243 @@ async function openPickView() { } recipientCache = res; } + renderPickList(); +} - const tenants = recipientCache.tenants || []; +function applyPickMode() { + const nameInput = $('chat-group-name'); + const actions = $('chat-group-actions'); + const title = $('chat-pick-title'); + document.querySelectorAll('.chat-pick-mode-btn').forEach(b => { + b.classList.toggle('active', b.dataset.mode === pickMode); + }); + if (pickMode === 'group') { + nameInput.classList.remove('hidden'); + actions.classList.remove('hidden'); + if (title) title.textContent = 'new group'; + updateGroupCount(); + } else { + nameInput.classList.add('hidden'); + actions.classList.add('hidden'); + if (title) title.textContent = 'new conversation'; + } + renderPickList(); +} + +function renderPickList() { + const list = $('chat-pick-list'); + if (!list || !recipientCache) return; const self = recipientCache.self; - const others = tenants.filter(t => t.username !== self); + const others = (recipientCache.tenants || []).filter(t => t.username !== self); if (!others.length) { list.innerHTML = ''; return; } - list.innerHTML = others.map(t => ` -
- - ${escapeHtml(t.username)} -
- `).join(''); + list.innerHTML = others.map(t => { + const selected = selectedMembers.has(t.username); + const selCls = (pickMode === 'group' && selected) ? ' selected' : ''; + const check = pickMode === 'group' + ? `` + : ''; + return ` +
+ + ${escapeHtml(t.username)} + ${check} +
+ `; + }).join(''); list.querySelectorAll('.chat-pick-row').forEach(row => { - row.addEventListener('click', () => openConversation(row.dataset.peer)); + row.addEventListener('click', () => { + const u = row.dataset.peer; + if (pickMode === 'group') { + if (selectedMembers.has(u)) selectedMembers.delete(u); + else selectedMembers.add(u); + renderPickList(); + updateGroupCount(); + } else { + openConversation({ kind: 'peer', key: u, label: u }); + } + }); }); } +function updateGroupCount() { + const el = $('chat-group-count'); + if (!el) return; + const n = selectedMembers.size; + el.textContent = n ? `${n} member${n === 1 ? '' : 's'} selected` : 'pick at least one member'; +} + +async function createRoom() { + const name = $('chat-group-name').value.trim(); + if (!name) { + await hyprcrd.dialog.show({ title: 'missing name', message: 'Give the group a name.', okLabel: 'Ok', cancelLabel: '' }); + return; + } + if (!selectedMembers.size) { + await hyprcrd.dialog.show({ title: 'no members', message: 'Pick at least one other member.', okLabel: 'Ok', cancelLabel: '' }); + return; + } + const res = await hyprcrd.api('api/chat/rooms', { + method: 'POST', + body: { name, members: [...selectedMembers] }, + }); + if (!res?.ok) { + await hyprcrd.dialog.show({ + title: 'create failed', + message: res?.error || 'unknown error', + danger: true, + okLabel: 'Ok', + cancelLabel: '', + }); + return; + } + await loadConversations(); + openConversation({ kind: 'room', key: res.uid, label: name }); +} + +// ── Room info / member management ────────────────────────────── + +async function openRoomInfo() { + if (currentThread?.kind !== 'room') return; + const savedThread = currentThread; + showRoomInfoView(); + $('chat-room-info-name').textContent = savedThread.label || savedThread.key; + const body = $('chat-room-info-body'); + body.innerHTML = '
loading...
'; + + const res = await fetch('api/chat/rooms').then(r => r.json()).catch(() => null); + const room = (res?.rooms || []).find(r => r.uid === savedThread.key); + const self = await getSelfUsername(); + if (!room) { + body.innerHTML = '
room not found
'; + return; + } + const isOwner = room.owner_username === self; + const others = (res?.tenants || recipientCache?.tenants || []) + .filter(t => t.username !== self && !(room.members || []).includes(t.username)); + + const memberRows = (room.members || []).map(u => { + const ownerTag = u === room.owner_username ? 'owner' : ''; + const removeBtn = (isOwner && u !== room.owner_username) + ? `` + : ''; + return ` +
+ + ${escapeHtml(u)} + ${ownerTag} + ${removeBtn} +
+ `; + }).join(''); + + const addSection = isOwner && (recipientCache?.tenants || []).length > (room.members || []).length + ? ` +
${ + (recipientCache?.tenants || []) + .filter(t => t.username !== self && !(room.members || []).includes(t.username)) + .map(t => `
${escapeHtml(t.username)}
`) + .join('') || '
everyone on the relay is already in
' + }
` + : ''; + + body.innerHTML = ` +
+ + ${memberRows || '
no members
'} +
+ ${addSection} +
+ ${isOwner + ? '' + : '' + } + +
+ `; + + body.querySelectorAll('button[data-action]').forEach(btn => { + btn.addEventListener('click', () => handleRoomAction(btn.dataset.action, btn.dataset.user, savedThread)); + }); +} + +async function handleRoomAction(action, user, thread) { + const uid = thread.key; + if (action === 'back') { + openConversation(thread); + return; + } + if (action === 'remove' || action === 'add') { + // Fetch current room, mutate members, PATCH. + const res = await fetch('api/chat/rooms').then(r => r.json()).catch(() => null); + const room = (res?.rooms || []).find(r => r.uid === uid); + if (!room) return; + let members = [...(room.members || [])]; + if (action === 'remove') members = members.filter(m => m !== user); + else if (!members.includes(user)) members.push(user); + // Relay adds owner automatically, but we forward the full list + // minus the owner (owner ALWAYS stays on relay side). + const patchRes = await hyprcrd.api(`api/chat/rooms/${uid}`, { + method: 'PATCH', + body: { members }, + }); + if (!patchRes?.ok) { + await hyprcrd.dialog.show({ title: 'update failed', message: patchRes?.error || 'unknown', danger: true, okLabel: 'Ok', cancelLabel: '' }); + } + await pollOnce(); // refresh local rooms mirror + await openRoomInfo(); // re-render + return; + } + if (action === 'leave' || action === 'delete') { + const confirm = await hyprcrd.dialog.show({ + title: action === 'delete' ? 'Delete room?' : 'Leave room?', + message: action === 'delete' + ? 'All members lose access and the message history is wiped.' + : 'You will stop receiving messages in this room.', + danger: true, + okLabel: action === 'delete' ? 'Delete' : 'Leave', + }); + if (!confirm) return; + const res = await hyprcrd.api(`api/chat/rooms/${uid}`, { method: 'DELETE' }); + if (!res?.ok) { + await hyprcrd.dialog.show({ title: 'failed', message: res?.error || 'unknown', danger: true, okLabel: 'Ok', cancelLabel: '' }); + return; + } + await pollOnce(); + await loadConversations(); + showListView(); + } +} + +// ── Disposable toggle (1:1) ────────────────────────────────────── + +async function refreshDisposableIcon(peer) { + const btn = $('chat-thread-disposable'); + if (!btn) return; + const res = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer)) + .then(r => r.json()).catch(() => null); + const on = !!res?.disposable; + btn.classList.toggle('active', on); + btn.setAttribute('data-tip', on + ? 'disposable: new messages drop after delivery — click to persist' + : 'persistent: messages live on the relay — click to make disposable'); +} + +async function toggleDisposable() { + if (currentThread?.kind !== 'peer') return; + const peer = currentThread.key; + const cur = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer)) + .then(r => r.json()).catch(() => ({ disposable: 0 })); + const next = cur.disposable ? 0 : 1; + await hyprcrd.api('api/chat/thread-settings', { + method: 'POST', + body: { peer, disposable: next }, + }); + refreshDisposableIcon(peer); +} + // ── Polling ─────────────────────────────────────────────────────── async function pollOnce() { @@ -397,20 +716,15 @@ async function pollOnce() { const receiptUpdates = res.receipts || 0; if (newCount > 0) { await loadConversations(); - // If we're inside a thread for any peer, refresh + auto-read it - if (isOpen() && currentPeer) { - await loadThread(currentPeer); - hyprcrd.api('api/chat/read', { method: 'POST', body: { peer: currentPeer } }) + if (isOpen() && currentThread) { + await loadThread(currentThread); + hyprcrd.api('api/chat/read', { method: 'POST', body: threadBody(currentThread) }) .then(() => loadConversations()).catch(() => {}); } - // Blink if the flyout is closed if (!isOpen()) startBlink(); - } else if (receiptUpdates > 0 && isOpen() && currentPeer) { - // No new incoming, but receipts (delivered/seen) changed for - // our outgoing — refresh the thread so the check icons update. - await loadThread(currentPeer); + } else if (receiptUpdates > 0 && isOpen() && currentThread) { + await loadThread(currentThread); } else if (initialPollDone === false) { - // First poll: refresh badges in case we have unread from a previous session await loadConversations(); } initialPollDone = true; @@ -438,14 +752,26 @@ function init() { $('chat-header-back').addEventListener('click', () => { showListView(); loadConversations(); }); $('chat-pick-back').addEventListener('click', showListView); $('chat-thread-form').addEventListener('submit', sendMessage); + $('chat-thread-info')?.addEventListener('click', openRoomInfo); + $('chat-thread-disposable')?.addEventListener('click', toggleDisposable); + $('chat-room-info-back')?.addEventListener('click', () => { + if (currentThread) openConversation(currentThread); + else showListView(); + }); + $('chat-group-create')?.addEventListener('click', createRoom); + document.querySelectorAll('.chat-pick-mode-btn').forEach(btn => { + btn.addEventListener('click', () => { + pickMode = btn.dataset.mode; + applyPickMode(); + }); + }); + wirePeerStripWheel(); - // Esc closes flyout when focus is inside it $('chat-flyout').addEventListener('keydown', e => { if (e.key === 'Escape') { e.stopPropagation(); closeFlyout(); } }); - // Defer the first POST (poll) until auth has set csrf. GETs are fine immediately. loadConversations().catch(() => {}); const startPolling = () => { pollOnce().catch(() => {}); @@ -457,7 +783,6 @@ function init() { hyprcrd.hooks.on('csrf.ready', startPolling); } - // Pause polling when the page is hidden document.addEventListener('visibilitychange', () => { if (document.hidden) { if (pollHandle) { clearInterval(pollHandle); pollHandle = null; } @@ -467,10 +792,6 @@ function init() { } }); - // Also refresh on the global sync heartbeat. Our own timer keeps - // the faster flyout-open cadence (UX-specific); tick guarantees - // that the header Sync button refreshes chat too, even when the - // flyout is closed and our own timer sits at 20s. hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); }); } @@ -482,6 +803,6 @@ if (document.readyState === 'loading') { hyprcrd.registerPlugin?.({ name: 'chat', - version: '1.0.0', - description: 'Encrypted chat between relay tenants', + version: '1.1.0', + description: 'Encrypted chat between relay tenants (1:1 + group rooms)', });