-
${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 = '
no other users on the relay
';
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
+ ? `
Add members
+
${
+ (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 = `
+
+
Members (${(room.members || []).length})
+ ${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)',
});