Add read receipts, disappearing messages, and group invite links
Receipts: messages now carry a shared client id (mid); recipients send delivered/read receipts over the E2E relay, rendered as ✓/✓✓ ticks (blue when read). Groups aggregate — ticks turn on once every member acks. Disappearing messages: per-chat timer (Off..1 week) stored locally and synced to peers via an encrypted config control message; messages carry a ttl and a client-side sweeper deletes them from both sides on schedule. Group invite links: new group_invites table + /groups/:id/invite (mint/ revoke) and /groups/join routes. Group info gains "Add people" and "Invite via link" (shareable URL + QR); opening a #join=<token> link joins the group and re-syncs every member's roster over the relay. store.js v3 adds a mid index + getByMid/put/sweepExpired; SW cache v9. Verified: 12 live server tests (invite/join/add/revoke/leave) + 11 store unit tests (receipts, sweep, meta) all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3d9f712867
commit
726d9d53ba
7 changed files with 490 additions and 20 deletions
11
migrations/0004_group_invites.sql
Normal file
11
migrations/0004_group_invites.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- Shareable group invite links. A token is a bearer capability: anyone with an
|
||||
-- account who has the link can join the group. Revoke by deleting the row(s).
|
||||
-- The server still only ever holds group METADATA — message content stays E2E.
|
||||
|
||||
CREATE TABLE group_invites (
|
||||
token TEXT PRIMARY KEY,
|
||||
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_group_invites_group ON group_invites(group_id);
|
||||
|
|
@ -175,7 +175,11 @@
|
|||
.bubble.in { background: var(--glass-2); border: 1px solid var(--stroke); align-self: flex-start; border-bottom-left-radius: 5px; }
|
||||
.bubble.out { background: var(--out-grad); align-self: flex-end; border-bottom-right-radius: 5px; box-shadow: 0 4px 16px rgba(255,45,85,.3); }
|
||||
.bubble .sender { font-size: 12.5px; font-weight: 700; margin-bottom: 2px; }
|
||||
.bubble .time { font-size: 10.5px; color: rgba(255,255,255,.55); text-align: right; margin-top: 3px; }
|
||||
.bubble .time { font-size: 10.5px; color: rgba(255,255,255,.55); text-align: right; margin-top: 3px; display: flex; gap: 4px; justify-content: flex-end; align-items: center; }
|
||||
.bubble .ticks { font-size: 11px; letter-spacing: -2px; color: rgba(255,255,255,.6); }
|
||||
.bubble .ticks.read { color: #7DE2FF; text-shadow: 0 0 6px rgba(125,226,255,.5); }
|
||||
.sys-note { align-self: center; max-width: 82%; text-align: center; font-size: 12px; color: var(--muted); background: rgba(255,255,255,.05); border: 1px solid var(--stroke); padding: 5px 12px; border-radius: 14px; margin: 6px 0; animation: rise .22s ease; }
|
||||
.head-action.timer.active { background: rgba(255,45,85,.22); color: var(--accent-2); box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
.media-img { max-width: 260px; border-radius: 12px; display: block; }
|
||||
.media-file { color: var(--text); text-decoration: none; font-weight: 500; }
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ export const api = {
|
|||
getGroup: (id) => req("GET", "/api/groups/" + id),
|
||||
addGroupMember: (id, userId) => req("POST", "/api/groups/" + id + "/members", { userId }),
|
||||
leaveGroup: (id) => req("POST", "/api/groups/" + id + "/leave", {}),
|
||||
groupInvite: (id) => req("POST", "/api/groups/" + id + "/invite", {}),
|
||||
revokeGroupInvite: (id) => req("DELETE", "/api/groups/" + id + "/invite"),
|
||||
joinGroup: (token) => req("POST", "/api/groups/join", { token }),
|
||||
uploadMedia: async (ciphertext) => {
|
||||
const res = await req("POST", "/api/media", ciphertext, true);
|
||||
return res.json ? res.json() : res;
|
||||
|
|
|
|||
397
public/js/app.js
397
public/js/app.js
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { api, setToken, MailboxSocket } from "./api.js";
|
||||
import * as C from "./crypto.js";
|
||||
import { useAccount, contacts, groups, messages as msgStore } from "./store.js";
|
||||
import { useAccount, contacts, groups, messages as msgStore, meta } from "./store.js";
|
||||
import * as accounts from "./accounts.js";
|
||||
import qrcode from "./vendor/qrcode.js";
|
||||
import { CallManager } from "./call.js";
|
||||
|
|
@ -145,8 +145,24 @@ async function enterApp() {
|
|||
await refreshChats();
|
||||
connectSocket();
|
||||
initCalls();
|
||||
startSweeper();
|
||||
maybeReplenishPreKeys();
|
||||
registerServiceWorker();
|
||||
await maybeHandleJoin();
|
||||
}
|
||||
|
||||
// Disappearing-message durations offered in the timer menu.
|
||||
const DISAPPEAR_OPTIONS = [
|
||||
{ label: "Off", secs: 0 },
|
||||
{ label: "30 seconds", secs: 30 },
|
||||
{ label: "5 minutes", secs: 300 },
|
||||
{ label: "1 hour", secs: 3600 },
|
||||
{ label: "8 hours", secs: 28800 },
|
||||
{ label: "1 day", secs: 86400 },
|
||||
{ label: "1 week", secs: 604800 },
|
||||
];
|
||||
function disappearLabel(secs) {
|
||||
return DISAPPEAR_OPTIONS.find((o) => o.secs === secs)?.label || `${secs}s`;
|
||||
}
|
||||
|
||||
// ---------- calls ----------
|
||||
|
|
@ -235,6 +251,55 @@ async function syncGroups() {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
// If the app was opened via an invite link (#join=<token> or ?join=<token>),
|
||||
// join that group now that we're authenticated.
|
||||
function getJoinToken() {
|
||||
const h = location.hash.match(/join=([A-Za-z0-9_-]+)/);
|
||||
if (h) return h[1];
|
||||
return new URL(location.href).searchParams.get("join");
|
||||
}
|
||||
async function maybeHandleJoin() {
|
||||
const token = getJoinToken();
|
||||
if (!token) return;
|
||||
history.replaceState(null, "", location.pathname); // consume the token from the URL
|
||||
try {
|
||||
const g = await api.joinGroup(token);
|
||||
await groups.put(g);
|
||||
await refreshChats();
|
||||
await announceGroupUpdate(g); // tell existing members to pull the new roster
|
||||
await openChat({ type: "group", id: g.groupId, name: g.name, sub: `${g.members.length} members`, group: g });
|
||||
toast("Joined " + g.name, "info");
|
||||
} catch (ex) {
|
||||
toast(ex.message === "invalid or expired invite" ? "That invite link is no longer valid" : "Couldn't join group: " + ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Tell every other member of a group to re-pull its roster (membership changed).
|
||||
async function announceGroupUpdate(group) {
|
||||
const others = (group.members || []).filter((m) => m.userId !== state.me.userId);
|
||||
await Promise.all(
|
||||
others.map((m) =>
|
||||
deliverToUser(m.userId, null, JSON.stringify({ k: "group-update", groupId: group.groupId })).catch(() => {})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Re-fetch a group's metadata from the server and refresh anything showing it.
|
||||
async function resyncGroup(groupId) {
|
||||
try {
|
||||
const g = await api.getGroup(groupId);
|
||||
await groups.put(g);
|
||||
if (state.activeChat?.type === "group" && state.activeChat.id === groupId) {
|
||||
state.activeChat.group = g;
|
||||
state.activeChat.name = g.name;
|
||||
}
|
||||
await refreshChats();
|
||||
return g;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Unified chat list: DMs + groups, most-recent first.
|
||||
async function refreshChats() {
|
||||
const list = $("#chatList");
|
||||
|
|
@ -751,6 +816,7 @@ function senderName(userId, group) {
|
|||
|
||||
async function openChat(chat) {
|
||||
state.activeChat = chat;
|
||||
chat.disappearing = (await meta.get("disap:" + chatConvo(chat))) || 0;
|
||||
$(".shell")?.classList.add("chat-open");
|
||||
await refreshChats(); // reflect active highlight
|
||||
const main = $("#main");
|
||||
|
|
@ -772,10 +838,15 @@ async function openChat(chat) {
|
|||
el("div", { className: "chat-sub", textContent: sub })
|
||||
)
|
||||
);
|
||||
const timerBtn = el("button", {
|
||||
className: "head-action timer" + (chat.disappearing > 0 ? " active" : ""),
|
||||
title: "Disappearing messages", textContent: "⏲", onclick: () => openDisappearingMenu(chat),
|
||||
});
|
||||
if (chat.type === "group") {
|
||||
head.append(el("button", { className: "head-action", title: "Group info", textContent: "ⓘ", onclick: () => showGroupInfo(chat) }));
|
||||
head.append(timerBtn, el("button", { className: "head-action", title: "Group info", textContent: "ⓘ", onclick: () => showGroupInfo(chat) }));
|
||||
} else {
|
||||
head.append(
|
||||
timerBtn,
|
||||
el("button", { className: "head-action", title: "Voice call", textContent: "📞", onclick: () => startCallWith(chat, false) }),
|
||||
el("button", { className: "head-action", title: "Video call", textContent: "🎥", onclick: () => startCallWith(chat, true) })
|
||||
);
|
||||
|
|
@ -793,9 +864,11 @@ async function openChat(chat) {
|
|||
|
||||
main.append(head, thread, composer);
|
||||
|
||||
const history = await msgStore.forConvo(chatConvo(chat));
|
||||
const nowTs = Date.now();
|
||||
const history = (await msgStore.forConvo(chatConvo(chat))).filter((m) => !(m.expiresAt && m.expiresAt <= nowTs));
|
||||
for (const m of history) renderMessage(m, chat);
|
||||
scrollThread();
|
||||
sendReadReceipts(chat, history); // we're looking at it — mark incoming as read
|
||||
|
||||
composer.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -835,14 +908,21 @@ function showGroupInfo(chat) {
|
|||
)
|
||||
)
|
||||
);
|
||||
const addBtn = el("button", { type: "button", className: "acct-action", textContent: "+ Add people" });
|
||||
const inviteBtn = el("button", { type: "button", className: "acct-action", textContent: "🔗 Invite via link" });
|
||||
const leave = el("button", { type: "button", className: "acct-action danger", textContent: "Leave group" });
|
||||
const { close } = makeOverlay(
|
||||
el("h2", { className: "modal-title", textContent: chat.name }),
|
||||
el("div", { className: "modal-note", textContent: `${chat.group.members.length} members` }),
|
||||
...rows, leave
|
||||
...rows, addBtn, inviteBtn, leave
|
||||
);
|
||||
|
||||
addBtn.onclick = () => { close(); addGroupPeople(chat); };
|
||||
inviteBtn.onclick = () => { close(); showGroupInvite(chat); };
|
||||
leave.onclick = async () => {
|
||||
try { await api.leaveGroup(chat.id); } catch {}
|
||||
// let the rest of the group drop us from their roster
|
||||
announceGroupUpdate(chat.group).catch(() => {});
|
||||
await groups.del(chat.id);
|
||||
close();
|
||||
state.activeChat = null;
|
||||
|
|
@ -852,6 +932,87 @@ function showGroupInfo(chat) {
|
|||
};
|
||||
}
|
||||
|
||||
// Add one or more members by username, then re-sync everyone's roster.
|
||||
function addGroupPeople(chat) {
|
||||
openModal({
|
||||
title: "Add people",
|
||||
placeholder: "Enter a username",
|
||||
action: "Add",
|
||||
onSubmit: async (value, setError, closeModal) => {
|
||||
const uname = value.trim().toLowerCase();
|
||||
if (!uname) return setError("Enter a username");
|
||||
if (chat.group.members.some((m) => m.username === uname)) return setError("@" + uname + " is already in the group");
|
||||
let user;
|
||||
try { user = await api.lookup(uname); }
|
||||
catch { return setError("No user found: @" + uname); }
|
||||
try {
|
||||
const updated = await api.addGroupMember(chat.id, user.userId);
|
||||
await groups.put(updated);
|
||||
state.activeChat && state.activeChat.id === chat.id && (state.activeChat.group = updated);
|
||||
chat.group = updated;
|
||||
await announceGroupUpdate(updated); // includes the new member, who now learns of the group
|
||||
await refreshChats();
|
||||
closeModal();
|
||||
toast("Added @" + uname, "info");
|
||||
showGroupInfo(chat);
|
||||
} catch (ex) {
|
||||
setError(ex.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Show a shareable invite link + QR for the group.
|
||||
async function showGroupInvite(chat) {
|
||||
const status = el("div", { className: "modal-note", textContent: "Creating invite link…" });
|
||||
const linkBox = el("div", { className: "code-box small", style: "display:none" });
|
||||
const qrHolder = el("div", {});
|
||||
const copy = el("button", { type: "button", className: "primary", textContent: "Copy link" });
|
||||
const share = el("button", { type: "button", className: "ghost", textContent: "Share" });
|
||||
const revoke = el("button", { type: "button", className: "acct-action danger", textContent: "Reset link", style: "display:none" });
|
||||
const { close } = makeOverlay(
|
||||
el("h2", { className: "modal-title", textContent: "Invite to " + chat.name }),
|
||||
el("p", { className: "modal-note", textContent: "Anyone with a Cipher account who opens this link can join the group. Reset it to revoke the old link." }),
|
||||
qrHolder, status, linkBox,
|
||||
el("div", { className: "modal-actions" }, share, copy),
|
||||
revoke
|
||||
);
|
||||
|
||||
let link = "";
|
||||
const render = (token) => {
|
||||
link = `${location.origin}/#join=${token}`;
|
||||
status.style.display = "none";
|
||||
linkBox.style.display = "block";
|
||||
linkBox.textContent = link;
|
||||
qrHolder.innerHTML = "";
|
||||
qrHolder.append(qrImg(link));
|
||||
revoke.style.display = "block";
|
||||
};
|
||||
try {
|
||||
const { token } = await api.groupInvite(chat.id);
|
||||
render(token);
|
||||
} catch (ex) {
|
||||
status.textContent = "Couldn't create invite: " + ex.message;
|
||||
return;
|
||||
}
|
||||
copy.onclick = () => { navigator.clipboard?.writeText(link); copy.textContent = "Copied ✓"; };
|
||||
share.onclick = () => {
|
||||
if (navigator.share) navigator.share({ title: "Join " + chat.name + " on Cipher", url: link }).catch(() => {});
|
||||
else { navigator.clipboard?.writeText(link); copy.textContent = "Copied ✓"; }
|
||||
};
|
||||
revoke.onclick = async () => {
|
||||
revoke.disabled = true;
|
||||
try {
|
||||
await api.revokeGroupInvite(chat.id);
|
||||
const { token } = await api.groupInvite(chat.id);
|
||||
render(token);
|
||||
copy.textContent = "Copy link";
|
||||
toast("Invite link reset", "info");
|
||||
} catch (ex) { toast(ex.message); }
|
||||
finally { revoke.disabled = false; }
|
||||
};
|
||||
}
|
||||
|
||||
// ---- sending ----
|
||||
async function ensureRecipientDevices(contact) {
|
||||
const bundle = await api.bundle(contact.userId);
|
||||
|
|
@ -893,19 +1054,44 @@ async function deliver(chat, payloadObj) {
|
|||
}
|
||||
}
|
||||
|
||||
// How many peers a message is sent to (for group receipt aggregation).
|
||||
function recipientCount(chat) {
|
||||
return chat.type === "group"
|
||||
? chat.group.members.filter((m) => m.userId !== state.me.userId).length
|
||||
: 1;
|
||||
}
|
||||
// Extra fields tracking outgoing delivery/read state + disappearing timer.
|
||||
function outMeta(chat, ttl, ts) {
|
||||
return {
|
||||
status: "sent",
|
||||
recipients: recipientCount(chat),
|
||||
deliveredBy: [],
|
||||
readBy: [],
|
||||
...(ttl > 0 ? { expiresAt: ts + ttl * 1000 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function sendText(chat, text) {
|
||||
await deliver(chat, { k: "text", text });
|
||||
await persistAndRender(chatConvo(chat), chat.id, { dir: "out", kind: "text", text, senderId: state.me.userId, ts: Date.now() });
|
||||
const mid = crypto.randomUUID();
|
||||
const ttl = chat.disappearing || 0;
|
||||
const ts = Date.now();
|
||||
await deliver(chat, { k: "text", text, mid, ttl });
|
||||
await persistAndRender(chatConvo(chat), chat.id, {
|
||||
dir: "out", kind: "text", text, mid, senderId: state.me.userId, ts, ...outMeta(chat, ttl, ts),
|
||||
});
|
||||
}
|
||||
|
||||
async function sendMedia(chat, file) {
|
||||
const mid = crypto.randomUUID();
|
||||
const ttl = chat.disappearing || 0;
|
||||
const ts = Date.now();
|
||||
const buf = await file.arrayBuffer();
|
||||
const enc = await C.encryptMedia(buf);
|
||||
const { key } = await api.uploadMedia(enc.ciphertext);
|
||||
await deliver(chat, { k: "media", key, keyB64: enc.keyB64, ivB64: enc.ivB64, name: file.name, type: file.type, size: file.size });
|
||||
await deliver(chat, { k: "media", key, keyB64: enc.keyB64, ivB64: enc.ivB64, name: file.name, type: file.type, size: file.size, mid, ttl });
|
||||
await persistAndRender(chatConvo(chat), chat.id, {
|
||||
dir: "out", kind: "media", name: file.name, mime: file.type,
|
||||
localUrl: URL.createObjectURL(file), senderId: state.me.userId, ts: Date.now(),
|
||||
dir: "out", kind: "media", name: file.name, mime: file.type, mid,
|
||||
localUrl: URL.createObjectURL(file), senderId: state.me.userId, ts, ...outMeta(chat, ttl, ts),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -938,9 +1124,30 @@ async function onEnvelope(envelope) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Delivery/read receipt — update the tick on the sent message, never rendered.
|
||||
if (data.k === "receipt") {
|
||||
await applyReceipt(envelope.fromUserId, data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Group roster changed (member added / someone joined via link) — re-pull it.
|
||||
if (data.k === "group-update") {
|
||||
await resyncGroup(data.groupId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disappearing-timer change from the other side — apply + note it inline.
|
||||
if (data.k === "config") {
|
||||
await applyConfig(envelope.fromUserId, data);
|
||||
return;
|
||||
}
|
||||
|
||||
const ts = envelope.sentAt || Date.now();
|
||||
const base = { dir: "in", senderId: envelope.fromUserId, ts, mid: data.mid };
|
||||
if (data.ttl > 0) base.expiresAt = ts + data.ttl * 1000;
|
||||
const msg = data.k === "media"
|
||||
? { dir: "in", kind: "media", name: data.name, mime: data.type, mediaRef: { key: data.key, keyB64: data.keyB64, ivB64: data.ivB64 }, senderId: envelope.fromUserId, ts: envelope.sentAt || Date.now() }
|
||||
: { dir: "in", kind: "text", text: data.text, senderId: envelope.fromUserId, ts: envelope.sentAt || Date.now() };
|
||||
? { ...base, kind: "media", name: data.name, mime: data.type, mediaRef: { key: data.key, keyB64: data.keyB64, ivB64: data.ivB64 } }
|
||||
: { ...base, kind: "text", text: data.text };
|
||||
|
||||
if (data.groupId) {
|
||||
// group message — make sure we know the group, then file it there
|
||||
|
|
@ -961,6 +1168,81 @@ async function onEnvelope(envelope) {
|
|||
}
|
||||
await persistAndRender(convoKeyDM(envelope.fromUserId), envelope.fromUserId, msg);
|
||||
}
|
||||
|
||||
// Acknowledge back to the sender: delivered always, read if they're looking at it.
|
||||
if (data.mid) {
|
||||
const groupId = data.groupId || null;
|
||||
sendReceipt(envelope.fromUserId, [data.mid], "delivered", groupId);
|
||||
const convo = groupId ? "g:" + groupId : convoKeyDM(envelope.fromUserId);
|
||||
if (state.activeChat && chatConvo(state.activeChat) === convo && document.hasFocus()) {
|
||||
sendReceipt(envelope.fromUserId, [data.mid], "read", groupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- receipts ----------
|
||||
function sendReceipt(toUserId, mids, sub, groupId) {
|
||||
if (!mids.length) return;
|
||||
deliverToUser(toUserId, null, JSON.stringify({ k: "receipt", sub, mids, groupId })).catch(() => {});
|
||||
}
|
||||
|
||||
// Send read receipts for every incoming message currently in view.
|
||||
async function sendReadReceipts(chat, history) {
|
||||
const groupId = chat.type === "group" ? chat.id : null;
|
||||
const bySender = new Map();
|
||||
for (const m of history) {
|
||||
if (m.dir !== "in" || !m.mid || !m.senderId) continue;
|
||||
if (!bySender.has(m.senderId)) bySender.set(m.senderId, []);
|
||||
bySender.get(m.senderId).push(m.mid);
|
||||
}
|
||||
for (const [sender, mids] of bySender) sendReceipt(sender, mids, "read", groupId);
|
||||
}
|
||||
|
||||
// Apply an inbound receipt to the matching sent message and refresh its tick.
|
||||
async function applyReceipt(fromUserId, data) {
|
||||
const mids = data.mids || (data.mid ? [data.mid] : []);
|
||||
const rank = { sent: 0, delivered: 1, read: 2 };
|
||||
for (const mid of mids) {
|
||||
const rec = await msgStore.getByMid(mid);
|
||||
if (!rec || rec.dir !== "out") continue;
|
||||
if (rec.recipients > 1 || (rec.convo || "").startsWith("g:")) {
|
||||
// Group: aggregate — read implies delivered.
|
||||
const add = (field) => {
|
||||
const s = new Set(rec[field] || []);
|
||||
s.add(fromUserId);
|
||||
rec[field] = [...s];
|
||||
};
|
||||
add("deliveredBy");
|
||||
if (data.sub === "read") add("readBy");
|
||||
} else if ((rank[data.sub] ?? 0) > (rank[rec.status] ?? 0)) {
|
||||
rec.status = data.sub;
|
||||
}
|
||||
await msgStore.put(rec);
|
||||
updateTick(rec);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- disappearing config ----------
|
||||
async function applyConfig(fromUserId, data) {
|
||||
const groupId = data.groupId || null;
|
||||
const convo = groupId ? "g:" + groupId : convoKeyDM(fromUserId);
|
||||
const secs = data.disappearing || 0;
|
||||
await meta.set("disap:" + convo, secs);
|
||||
if (state.activeChat && chatConvo(state.activeChat) === convo) state.activeChat.disappearing = secs;
|
||||
const who = await displayNameFor(fromUserId, groupId);
|
||||
const text = secs > 0
|
||||
? `${who} set disappearing messages to ${disappearLabel(secs)}`
|
||||
: `${who} turned off disappearing messages`;
|
||||
await persistAndRender(convo, groupId || fromUserId, { dir: "in", kind: "system", text, mid: crypto.randomUUID(), ts: Date.now() });
|
||||
}
|
||||
|
||||
async function displayNameFor(userId, groupId) {
|
||||
if (groupId) {
|
||||
const g = await groups.get(groupId);
|
||||
return senderName(userId, g);
|
||||
}
|
||||
const c = await contacts.get(userId);
|
||||
return c ? c.displayName || c.username : userId.slice(0, 6);
|
||||
}
|
||||
|
||||
function convoKeyDM(otherUserId) {
|
||||
|
|
@ -980,8 +1262,18 @@ async function persistAndRender(convo, chatId, msg) {
|
|||
function renderMessage(m, chat) {
|
||||
const thread = $("#thread");
|
||||
if (!thread) return;
|
||||
|
||||
// System notices (disappearing timer changes) render as a centered pill.
|
||||
if (m.kind === "system") {
|
||||
const pill = el("div", { className: "sys-note", textContent: m.text });
|
||||
if (m.mid) pill.dataset.mid = m.mid;
|
||||
thread.append(pill);
|
||||
return;
|
||||
}
|
||||
|
||||
const out = m.dir === "out";
|
||||
const bubble = el("div", { className: "bubble " + (out ? "out" : "in") });
|
||||
if (m.mid) bubble.dataset.mid = m.mid;
|
||||
// sender label for incoming group messages
|
||||
if (!out && chat && chat.type === "group") {
|
||||
const name = senderName(m.senderId, chat.group);
|
||||
|
|
@ -1004,10 +1296,37 @@ function renderMessage(m, chat) {
|
|||
} else {
|
||||
bubble.append(el("div", { className: "text", textContent: m.text }));
|
||||
}
|
||||
bubble.append(el("div", { className: "time", textContent: fmtTime(m.ts) }));
|
||||
const metaRow = el("div", { className: "time", textContent: fmtTime(m.ts) });
|
||||
if (out) {
|
||||
const ticks = el("span", { className: "ticks" });
|
||||
applyTickGlyph(ticks, tickState(m, chat));
|
||||
metaRow.append(ticks);
|
||||
}
|
||||
bubble.append(metaRow);
|
||||
thread.append(bubble);
|
||||
}
|
||||
|
||||
// Delivery/read tick state for an outgoing message.
|
||||
function tickState(rec, chat) {
|
||||
if (chat && chat.type === "group") {
|
||||
const recips = rec.recipients || 0;
|
||||
if (recips > 0 && (rec.readBy || []).length >= recips) return "read";
|
||||
if (recips > 0 && (rec.deliveredBy || []).length >= recips) return "delivered";
|
||||
return "sent";
|
||||
}
|
||||
return rec.status || "sent";
|
||||
}
|
||||
function applyTickGlyph(node, st) {
|
||||
node.classList.toggle("read", st === "read");
|
||||
node.textContent = st === "sent" ? "✓" : "✓✓";
|
||||
}
|
||||
// Re-render the tick on a message that's currently on screen.
|
||||
function updateTick(rec) {
|
||||
if (!state.activeChat || rec.convo !== chatConvo(state.activeChat)) return;
|
||||
const node = document.querySelector(`#thread .bubble[data-mid="${rec.mid}"] .ticks`);
|
||||
if (node) applyTickGlyph(node, tickState(rec, state.activeChat));
|
||||
}
|
||||
|
||||
async function loadMediaInto(img, ref) {
|
||||
try {
|
||||
const ct = await api.fetchMedia(ref.key);
|
||||
|
|
@ -1039,6 +1358,60 @@ function fmtTime(ts) {
|
|||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// ---------- disappearing messages ----------
|
||||
function openDisappearingMenu(chat) {
|
||||
const rows = DISAPPEAR_OPTIONS.map((opt) =>
|
||||
el("button", {
|
||||
type: "button",
|
||||
className: "acct-action" + ((chat.disappearing || 0) === opt.secs ? " active" : ""),
|
||||
textContent: opt.label,
|
||||
onclick: async () => {
|
||||
close();
|
||||
try { await setDisappearing(chat, opt.secs); }
|
||||
catch (ex) { toast(ex.message); }
|
||||
},
|
||||
})
|
||||
);
|
||||
const { close } = makeOverlay(
|
||||
el("h2", { className: "modal-title", textContent: "Disappearing messages" }),
|
||||
el("p", { className: "modal-note", textContent: "New messages in this chat vanish from every device after the timer. It applies to everyone in the conversation." }),
|
||||
...rows
|
||||
);
|
||||
}
|
||||
|
||||
async function setDisappearing(chat, secs) {
|
||||
const convo = chatConvo(chat);
|
||||
await meta.set("disap:" + convo, secs);
|
||||
chat.disappearing = secs;
|
||||
await deliver(chat, { k: "config", disappearing: secs });
|
||||
const text = secs > 0
|
||||
? `You set disappearing messages to ${disappearLabel(secs)}`
|
||||
: "You turned off disappearing messages";
|
||||
await persistAndRender(convo, chat.id, { dir: "out", kind: "system", text, mid: crypto.randomUUID(), ts: Date.now() });
|
||||
// reflect the timer indicator in the header
|
||||
const btn = document.querySelector("#main .head-action.timer");
|
||||
if (btn) btn.classList.toggle("active", secs > 0);
|
||||
}
|
||||
|
||||
// Periodically delete messages whose disappearing timer elapsed.
|
||||
function startSweeper() {
|
||||
if (state.sweeper) clearInterval(state.sweeper);
|
||||
const tick = async () => {
|
||||
try {
|
||||
const removed = await msgStore.sweepExpired(Date.now());
|
||||
if (!removed.length) return;
|
||||
if (state.activeChat) {
|
||||
const convo = chatConvo(state.activeChat);
|
||||
for (const r of removed) {
|
||||
if (r.convo === convo) document.querySelector(`#thread [data-mid="${r.mid}"]`)?.remove();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
state.sweeper = setInterval(tick, 10000);
|
||||
tick();
|
||||
}
|
||||
|
||||
// ---------- prekey replenishment ----------
|
||||
async function maybeReplenishPreKeys() {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// IndexedDB persistence: the Signal protocol store + app data (contacts, chats).
|
||||
// Private keys live ONLY here, in the browser. They are never sent to the server.
|
||||
|
||||
const DB_VERSION = 2; // v2 adds the "groups" object store
|
||||
const DB_VERSION = 3; // v2 adds "groups"; v3 adds a "mid" index on messages (receipts + disappearing)
|
||||
|
||||
// The store is namespaced PER ACCOUNT so multiple accounts can coexist on one
|
||||
// browser without their Signal identities colliding. Namespace by username
|
||||
|
|
@ -27,17 +27,23 @@ function openDB() {
|
|||
const db = req.result;
|
||||
// Signal protocol material (identity keypair, prekeys, sessions, peer identities).
|
||||
if (!db.objectStoreNames.contains("signal")) db.createObjectStore("signal");
|
||||
// App metadata (my account, settings).
|
||||
// App metadata (my account, settings, per-chat disappearing timers).
|
||||
if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta");
|
||||
// Known contacts, keyed by userId.
|
||||
if (!db.objectStoreNames.contains("contacts")) db.createObjectStore("contacts", { keyPath: "userId" });
|
||||
// Known groups, keyed by groupId.
|
||||
if (!db.objectStoreNames.contains("groups")) db.createObjectStore("groups", { keyPath: "groupId" });
|
||||
// Message history, keyed by autoincrement, indexed by conversation.
|
||||
// Message history, keyed by autoincrement, indexed by conversation + message id.
|
||||
let msgs;
|
||||
if (!db.objectStoreNames.contains("messages")) {
|
||||
const s = db.createObjectStore("messages", { keyPath: "id", autoIncrement: true });
|
||||
s.createIndex("convo", "convo");
|
||||
msgs = db.createObjectStore("messages", { keyPath: "id", autoIncrement: true });
|
||||
msgs.createIndex("convo", "convo");
|
||||
} else {
|
||||
msgs = req.transaction.objectStore("messages");
|
||||
}
|
||||
// "mid" is the client-generated message id shared by both parties, used to
|
||||
// match receipts back to the sent message and to expire disappearing messages.
|
||||
if (!msgs.indexNames.contains("mid")) msgs.createIndex("mid", "mid");
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
|
|
@ -112,10 +118,31 @@ export const messages = {
|
|||
async add(msg) {
|
||||
return reqP((await tx("messages", "readwrite")).add(msg));
|
||||
},
|
||||
// Overwrite an existing record (must carry its autoincrement `id`).
|
||||
async put(msg) {
|
||||
return reqP((await tx("messages", "readwrite")).put(msg));
|
||||
},
|
||||
async forConvo(convo) {
|
||||
const idx = (await tx("messages")).index("convo");
|
||||
return reqP(idx.getAll(convo));
|
||||
},
|
||||
// Look a message up by its shared client id (for applying receipts).
|
||||
async getByMid(mid) {
|
||||
if (!mid) return undefined;
|
||||
const idx = (await tx("messages")).index("mid");
|
||||
return reqP(idx.get(mid));
|
||||
},
|
||||
// Delete every message whose disappearing timer has elapsed; returns them so
|
||||
// the UI can drop their bubbles.
|
||||
async sweepExpired(nowTs) {
|
||||
const all = await reqP((await tx("messages")).getAll());
|
||||
const expired = all.filter((m) => m.expiresAt && m.expiresAt <= nowTs);
|
||||
if (expired.length) {
|
||||
const s = await tx("messages", "readwrite");
|
||||
for (const m of expired) s.delete(m.id);
|
||||
}
|
||||
return expired;
|
||||
},
|
||||
};
|
||||
|
||||
// ---- Signal protocol store ----
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Service worker: offline app shell + Web Push wake-up.
|
||||
// It never touches message plaintext — decryption happens only in the page.
|
||||
|
||||
const CACHE = "cipher-v7";
|
||||
const CACHE = "cipher-v9";
|
||||
const SHELL = [
|
||||
"/",
|
||||
"/index.html",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import { Hono } from "hono";
|
||||
import { requireAuth } from "../lib/auth.js";
|
||||
import { uuid, now } from "../lib/util.js";
|
||||
import { uuid, now, randomToken } from "../lib/util.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
|
|
@ -92,6 +92,58 @@ app.post("/groups/:id/members", requireAuth(), async (c) => {
|
|||
return c.json(await loadGroup(c.env.DB, groupId, me));
|
||||
});
|
||||
|
||||
// POST /api/groups/:id/invite — create (or return the existing) shareable invite
|
||||
// token. Only members can mint one. The token is the whole capability.
|
||||
app.post("/groups/:id/invite", requireAuth(), async (c) => {
|
||||
const me = c.get("userId");
|
||||
const groupId = c.req.param("id");
|
||||
const g = await loadGroup(c.env.DB, groupId, me);
|
||||
if (!g) return c.json({ error: "not found" }, 404);
|
||||
let row = await c.env.DB.prepare("SELECT token FROM group_invites WHERE group_id = ? LIMIT 1")
|
||||
.bind(groupId)
|
||||
.first();
|
||||
if (!row) {
|
||||
const token = randomToken(18);
|
||||
await c.env.DB.prepare(
|
||||
"INSERT INTO group_invites (token, group_id, created_by, created_at) VALUES (?,?,?,?)"
|
||||
)
|
||||
.bind(token, groupId, me, now())
|
||||
.run();
|
||||
row = { token };
|
||||
}
|
||||
return c.json({ token: row.token });
|
||||
});
|
||||
|
||||
// DELETE /api/groups/:id/invite — revoke every invite token for the group.
|
||||
app.delete("/groups/:id/invite", requireAuth(), async (c) => {
|
||||
const me = c.get("userId");
|
||||
const groupId = c.req.param("id");
|
||||
const g = await loadGroup(c.env.DB, groupId, me);
|
||||
if (!g) return c.json({ error: "not found" }, 404);
|
||||
await c.env.DB.prepare("DELETE FROM group_invites WHERE group_id = ?").bind(groupId).run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/groups/join body: { token } — join a group via an invite link.
|
||||
app.post("/groups/join", requireAuth(), async (c) => {
|
||||
const me = c.get("userId");
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const token = String(body?.token || "").trim();
|
||||
if (!token) return c.json({ error: "invite token required" }, 400);
|
||||
const inv = await c.env.DB.prepare("SELECT group_id FROM group_invites WHERE token = ?")
|
||||
.bind(token)
|
||||
.first();
|
||||
if (!inv) return c.json({ error: "invalid or expired invite" }, 404);
|
||||
await c.env.DB.prepare(
|
||||
"INSERT OR IGNORE INTO group_members (group_id, user_id, added_at) VALUES (?,?,?)"
|
||||
)
|
||||
.bind(inv.group_id, me, now())
|
||||
.run();
|
||||
const g = await loadGroup(c.env.DB, inv.group_id, me);
|
||||
if (!g) return c.json({ error: "could not join" }, 500);
|
||||
return c.json(g);
|
||||
});
|
||||
|
||||
// POST /api/groups/:id/leave
|
||||
app.post("/groups/:id/leave", requireAuth(), async (c) => {
|
||||
const me = c.get("userId");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue