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 + ? '