chat: group chat UI — room tabs, creation flow, member mgmt
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
52da9abd44
commit
9267c7c6a1
3 files changed
+592
-108
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright">chat</span>
|
||||
<button id="chat-header-back" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="back to list"><i class="ph ph-caret-left text-sm"></i></button>
|
||||
<span class="flex-1"></span>
|
||||
<button id="chat-thread-disposable" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="disposable: messages drop after delivery"><i class="ph ph-fire text-sm"></i></button>
|
||||
<button id="chat-thread-info" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="room info"><i class="ph ph-info text-sm"></i></button>
|
||||
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="new conversation"><i class="ph ph-plus-square text-sm"></i></button>
|
||||
<button id="chat-refresh-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></i></button>
|
||||
<button id="chat-close-btn" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
|
||||
@@ -31,12 +33,35 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- New-conversation peer picker -->
|
||||
<!-- New-conversation peer picker / group creation.
|
||||
Shared view: a mode toggle at top switches between picking one
|
||||
peer (1:1) and building a group (name + multi-select). -->
|
||||
<div id="chat-pick-view" class="chat-view hidden">
|
||||
<div id="chat-pick-header">
|
||||
<button id="chat-pick-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
|
||||
<span class="font-pixel text-[10px] text-t-bright flex-1">pick a recipient</span>
|
||||
<span id="chat-pick-title" class="font-pixel text-[10px] text-t-bright flex-1">new conversation</span>
|
||||
</div>
|
||||
<div id="chat-pick-mode" class="chat-pick-mode">
|
||||
<button class="chat-pick-mode-btn active" data-mode="peer" type="button">1:1</button>
|
||||
<button class="chat-pick-mode-btn" data-mode="group" type="button">group</button>
|
||||
</div>
|
||||
<input id="chat-group-name" class="chat-group-name hidden" type="text" autocomplete="off" placeholder="group name..." maxlength="80">
|
||||
<div id="chat-pick-list" class="chat-pick-list"></div>
|
||||
<div id="chat-group-actions" class="chat-group-actions hidden">
|
||||
<span id="chat-group-count" class="font-pixel text-[9px] text-t-dim"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button id="chat-group-create" type="button" class="px-2 py-1 text-[10px] font-pixel border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Room info / member management view.
|
||||
Shown when the user taps the info icon in a room thread. -->
|
||||
<div id="chat-room-info-view" class="chat-view hidden">
|
||||
<div id="chat-room-info-header">
|
||||
<button id="chat-room-info-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
|
||||
<i class="ph ph-users-three text-t-accent text-sm"></i>
|
||||
<span id="chat-room-info-name" class="font-pixel text-[10px] text-t-bright flex-1 truncate"></span>
|
||||
</div>
|
||||
<div id="chat-room-info-body" class="chat-room-info-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
+427
-106
@@ -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
|
||||
? '<div class="chat-list-avatar"><i class="ph ph-users-three"></i></div>'
|
||||
: `<div class="chat-list-avatar">${escapeHtml(initial)}</div>`;
|
||||
const unreadBadge = parseInt(c.unread) > 0
|
||||
? `<span class="chat-unread-badge">${c.unread}</span>` : '';
|
||||
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 `
|
||||
<div class="chat-list-row" data-peer="${escapeHtml(c.peer)}">
|
||||
<div class="chat-list-avatar">${escapeHtml(initial)}</div>
|
||||
<div class="chat-list-row" data-kind="${isRoom ? 'room' : 'peer'}" data-key="${escapeHtml(c.key)}">
|
||||
${avatar}
|
||||
<div class="chat-list-info">
|
||||
<div class="chat-list-peer">
|
||||
<span>${escapeHtml(c.peer)}</span>
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<span class="chat-list-time">${escapeHtml(formatChatTime(c.last_at))}</span>
|
||||
</div>
|
||||
<div class="${previewClass}">${escapeHtml((c.last_body || '').slice(0, 80))}</div>
|
||||
<div class="${previewClass}">${preview}</div>
|
||||
</div>
|
||||
${unreadBadge}
|
||||
</div>
|
||||
@@ -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
|
||||
? '<span class="chat-peer-dot"></span>' : '';
|
||||
return `<button class="chat-peer-tab${active}" data-peer="${escapeHtml(c.peer)}" title="${escapeHtml(c.peer)}">${dot}<span>${escapeHtml(c.peer)}</span></button>`;
|
||||
const icon = isRoom
|
||||
? '<i class="ph ph-users-three chat-peer-tab-icon"></i>'
|
||||
: '';
|
||||
const label = c.label || c.key || '?';
|
||||
return `<button class="chat-peer-tab${active}" data-kind="${isRoom ? 'room' : 'peer'}" data-key="${escapeHtml(c.key)}" title="${escapeHtml(label)}">${dot}${icon}<span>${escapeHtml(label)}</span></button>`;
|
||||
}).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 = '<i class="ph ph-checks chat-receipt seen"></i>';
|
||||
else if (Number(m.delivered)) receipt = '<i class="ph ph-check chat-receipt delivered"></i>';
|
||||
else receipt = '<i class="ph ph-clock chat-receipt pending"></i>';
|
||||
}
|
||||
let authorLine = '';
|
||||
if (isRoom && !mine && m.peer !== lastAuthor) {
|
||||
authorLine = `<div class="chat-bubble-author">${escapeHtml(m.peer || '')}</div>`;
|
||||
}
|
||||
lastAuthor = m.peer;
|
||||
return `
|
||||
${authorLine}
|
||||
<div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div>
|
||||
<div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}${receipt}</div>
|
||||
`;
|
||||
}).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 = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel">loading...</div>';
|
||||
|
||||
@@ -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 = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel cursor-blink">no other users on the relay</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = others.map(t => `
|
||||
<div class="chat-pick-row" data-peer="${escapeHtml(t.username)}">
|
||||
<i class="ph ph-user-circle text-sm"></i>
|
||||
<span>${escapeHtml(t.username)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
list.innerHTML = others.map(t => {
|
||||
const selected = selectedMembers.has(t.username);
|
||||
const selCls = (pickMode === 'group' && selected) ? ' selected' : '';
|
||||
const check = pickMode === 'group'
|
||||
? `<i class="ph ph-check chat-pick-check"></i>`
|
||||
: '';
|
||||
return `
|
||||
<div class="chat-pick-row${selCls}" data-peer="${escapeHtml(t.username)}">
|
||||
<i class="ph ph-user-circle text-sm"></i>
|
||||
<span>${escapeHtml(t.username)}</span>
|
||||
${check}
|
||||
</div>
|
||||
`;
|
||||
}).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 = '<div class="text-[10px] text-t-dim font-pixel">loading...</div>';
|
||||
|
||||
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 = '<div class="text-[10px] text-t-danger font-pixel">room not found</div>';
|
||||
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 ? '<span class="owner-badge">owner</span>' : '';
|
||||
const removeBtn = (isOwner && u !== room.owner_username)
|
||||
? `<button class="btn danger" data-action="remove" data-user="${escapeHtml(u)}" style="margin-left:auto;padding:2px 8px;">remove</button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="chat-room-member-row">
|
||||
<i class="ph ph-user-circle text-t-accent"></i>
|
||||
<span>${escapeHtml(u)}</span>
|
||||
${ownerTag}
|
||||
${removeBtn}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const addSection = isOwner && (recipientCache?.tenants || []).length > (room.members || []).length
|
||||
? `<div class="section-label">Add members</div>
|
||||
<div id="chat-room-add-list">${
|
||||
(recipientCache?.tenants || [])
|
||||
.filter(t => t.username !== self && !(room.members || []).includes(t.username))
|
||||
.map(t => `<div class="chat-room-member-row"><i class="ph ph-user-circle"></i><span>${escapeHtml(t.username)}</span><button class="btn" style="margin-left:auto;padding:2px 8px;" data-action="add" data-user="${escapeHtml(t.username)}">add</button></div>`)
|
||||
.join('') || '<div class="text-[10px] text-t-dim font-pixel">everyone on the relay is already in</div>'
|
||||
}</div>`
|
||||
: '';
|
||||
|
||||
body.innerHTML = `
|
||||
<div>
|
||||
<div class="section-label">Members (${(room.members || []).length})</div>
|
||||
${memberRows || '<div class="text-[10px] text-t-dim">no members</div>'}
|
||||
</div>
|
||||
${addSection}
|
||||
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||
${isOwner
|
||||
? '<button class="btn danger" data-action="delete" style="flex:1">Delete room</button>'
|
||||
: '<button class="btn danger" data-action="leave" style="flex:1">Leave room</button>'
|
||||
}
|
||||
<button class="btn" data-action="back" style="flex:1">Back to chat</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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)',
|
||||
});
|
||||
Reference in new issue
Block a user