Encrypted group chats + major UI redesign
Groups (E2E via pairwise fan-out — sender encrypts each message separately to every member with existing Signal sessions; server holds only name+membership): - migration 0003: groups + group_members; routes/groups.js (create/list/get/add/leave) - client: unified DM+group chat model, group create modal w/ member chips, fan-out deliver, groupId payload routing on receive, lazy group fetch, sender names in group bubbles, group info + leave UI overhaul — glassmorphism redesign: - animated aurora backdrop, blurred glass panels, violet→cyan gradient brand - color-hashed gradient avatars (per name), group avatars - message bubbles with rise animation + tails, gradient outgoing bubbles - polished auth card w/ gradient border, pill actions, modal pop-in, toast styling - responsive: full-width thread on mobile with back button Verified live: 3-user group message fans out and both members decrypt. SW cache v4->v5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
34c7ef08eb
commit
c48f9f4006
8 changed files with 622 additions and 237 deletions
18
migrations/0003_groups.sql
Normal file
18
migrations/0003_groups.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
-- Group chats. The server stores group METADATA (name + membership) so members
|
||||
-- can discover each other, but message content is still E2E: the sender encrypts
|
||||
-- each message separately to every member (pairwise Signal fan-out).
|
||||
|
||||
CREATE TABLE groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE group_members (
|
||||
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
added_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (group_id, user_id)
|
||||
);
|
||||
CREATE INDEX idx_group_members_user ON group_members(user_id);
|
||||
|
|
@ -3,145 +3,225 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b141a" />
|
||||
<meta name="theme-color" content="#0a0a14" />
|
||||
<title>Cipher — encrypted messaging</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon.svg" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b141a;
|
||||
--panel: #111b21;
|
||||
--panel-2: #202c33;
|
||||
--accent: #00a884;
|
||||
--accent-2: #005c4b;
|
||||
--text: #e9edef;
|
||||
--muted: #8696a0;
|
||||
--bubble-in: #202c33;
|
||||
--bubble-out: #005c4b;
|
||||
--bg: #0a0a14;
|
||||
--bg-2: #0d0d1a;
|
||||
--glass: rgba(28, 28, 48, 0.55);
|
||||
--glass-2: rgba(40, 40, 66, 0.5);
|
||||
--stroke: rgba(255, 255, 255, 0.08);
|
||||
--stroke-2: rgba(255, 255, 255, 0.14);
|
||||
--text: #eef0f8;
|
||||
--muted: #9aa0c0;
|
||||
--accent: #7c5cff; /* violet */
|
||||
--accent-2: #22d3ee; /* cyan */
|
||||
--accent-grad: linear-gradient(135deg, #7c5cff, #22d3ee);
|
||||
--out-grad: linear-gradient(135deg, #6d4bff, #3b2ea8);
|
||||
--danger: #ff6b81;
|
||||
--radius: 18px;
|
||||
--shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg); color: var(--text); height: 100dvh; overflow: hidden;
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: var(--text); height: 100dvh; overflow: hidden; background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
/* animated aurora backdrop */
|
||||
body::before {
|
||||
content: ""; position: fixed; inset: -20%; z-index: -2;
|
||||
background:
|
||||
radial-gradient(40% 40% at 20% 25%, rgba(124, 92, 255, 0.28), transparent 60%),
|
||||
radial-gradient(45% 45% at 82% 20%, rgba(34, 211, 238, 0.20), transparent 60%),
|
||||
radial-gradient(50% 50% at 60% 90%, rgba(236, 72, 153, 0.16), transparent 60%);
|
||||
filter: blur(30px); animation: drift 22s ease-in-out infinite alternate;
|
||||
}
|
||||
body::after { content: ""; position: fixed; inset: 0; z-index: -1; background: radial-gradient(120% 120% at 50% 0%, transparent 40%, rgba(0,0,0,0.55)); }
|
||||
@keyframes drift {
|
||||
0% { transform: translate(0,0) scale(1); }
|
||||
100% { transform: translate(-4%, 3%) scale(1.12); }
|
||||
}
|
||||
#app { height: 100dvh; }
|
||||
::selection { background: rgba(124,92,255,.4); }
|
||||
|
||||
/* ---- auth ---- */
|
||||
/* scrollbars */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 8px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.2); }
|
||||
|
||||
button { cursor: pointer; font: inherit; border: none; color: inherit; transition: transform .12s ease, background .2s ease, box-shadow .2s ease, opacity .2s; }
|
||||
button:active { transform: scale(.96); }
|
||||
button:disabled { opacity: .55; cursor: default; }
|
||||
input, textarea { font: inherit; }
|
||||
|
||||
/* ---------- auth ---------- */
|
||||
.auth-card {
|
||||
max-width: 360px; margin: 12vh auto 0; padding: 32px 24px;
|
||||
background: var(--panel); border-radius: 16px; display: flex; flex-direction: column; gap: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,.4);
|
||||
max-width: 380px; margin: 10vh auto 0; padding: 34px 28px;
|
||||
background: var(--glass); backdrop-filter: blur(24px) saturate(140%); -webkit-backdrop-filter: blur(24px) saturate(140%);
|
||||
border: 1px solid var(--stroke); border-radius: 26px; box-shadow: var(--shadow);
|
||||
display: flex; flex-direction: column; gap: 13px; position: relative; overflow: hidden;
|
||||
animation: pop .4s cubic-bezier(.2,.9,.3,1.2);
|
||||
}
|
||||
.auth-card h1 { margin: 0; text-align: center; color: var(--accent); letter-spacing: .5px; }
|
||||
.tagline { margin: 0 0 8px; text-align: center; color: var(--muted); font-size: 13px; }
|
||||
.auth-card::before {
|
||||
content: ""; position: absolute; inset: 0; padding: 1px; border-radius: 26px;
|
||||
background: linear-gradient(135deg, rgba(124,92,255,.6), rgba(34,211,238,.5), transparent 60%);
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none;
|
||||
}
|
||||
.auth-card h1 {
|
||||
margin: 4px 0 0; text-align: center; font-size: 34px; letter-spacing: .5px; font-weight: 800;
|
||||
background: var(--accent-grad); -webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
.tagline { margin: 0 0 6px; text-align: center; color: var(--muted); font-size: 13px; }
|
||||
.auth-card input {
|
||||
padding: 12px 14px; border-radius: 10px; border: 1px solid var(--panel-2);
|
||||
background: var(--panel-2); color: var(--text); font-size: 15px;
|
||||
padding: 13px 15px; border-radius: 13px; border: 1px solid var(--stroke); background: rgba(0,0,0,.25); color: var(--text); font-size: 15px; transition: border .2s, box-shadow .2s;
|
||||
}
|
||||
.auth-card input:focus { outline: 2px solid var(--accent); }
|
||||
.auth-err { color: #f15c6d; font-size: 13px; min-height: 16px; text-align: center; }
|
||||
button { cursor: pointer; font-size: 15px; padding: 12px 14px; border-radius: 10px; border: none; }
|
||||
button.primary { background: var(--accent); color: #04140f; font-weight: 600; }
|
||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--accent); }
|
||||
button:disabled { opacity: .6; cursor: default; }
|
||||
|
||||
/* ---- shell ---- */
|
||||
.shell { display: grid; grid-template-columns: 340px 1fr; height: 100dvh; }
|
||||
.sidebar { background: var(--panel); border-right: 1px solid #0a0f13; display: flex; flex-direction: column; min-height: 0; }
|
||||
.side-header { padding: 16px; border-bottom: 1px solid #0a0f13; }
|
||||
.me { font-weight: 600; }
|
||||
.handle { color: var(--muted); font-size: 13px; }
|
||||
.new-chat { margin: 12px; background: var(--panel-2); color: var(--text); text-align: left; }
|
||||
.chat-list { overflow-y: auto; flex: 1; }
|
||||
.chat-item { display: flex; gap: 12px; padding: 12px 16px; cursor: pointer; align-items: center; }
|
||||
.chat-item:hover { background: var(--panel-2); }
|
||||
.avatar {
|
||||
width: 42px; height: 42px; border-radius: 50%; background: var(--accent-2);
|
||||
display: grid; place-items: center; font-weight: 700; color: var(--text); flex: none;
|
||||
}
|
||||
.chat-name { font-weight: 500; }
|
||||
.chat-sub { color: var(--muted); font-size: 13px; }
|
||||
|
||||
/* ---- main ---- */
|
||||
.main { display: flex; flex-direction: column; min-height: 0; background:
|
||||
linear-gradient(rgba(11,20,26,.95), rgba(11,20,26,.95)); }
|
||||
.empty { margin: auto; color: var(--muted); }
|
||||
.chat-head { display: flex; gap: 12px; align-items: center; padding: 12px 16px; background: var(--panel); border-bottom: 1px solid #0a0f13; }
|
||||
.back-btn { display: none; background: transparent; color: var(--text); font-size: 26px; line-height: 1; padding: 0 6px; }
|
||||
.thread { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.bubble { max-width: 65%; padding: 8px 10px 6px; border-radius: 10px; font-size: 14.5px; line-height: 1.35; position: relative; word-wrap: break-word; }
|
||||
.bubble.in { background: var(--bubble-in); align-self: flex-start; border-top-left-radius: 2px; }
|
||||
.bubble.out { background: var(--bubble-out); align-self: flex-end; border-top-right-radius: 2px; }
|
||||
.bubble .time { font-size: 10.5px; color: var(--muted); text-align: right; margin-top: 2px; }
|
||||
.media-img { max-width: 260px; border-radius: 8px; display: block; }
|
||||
.media-file { color: var(--text); text-decoration: none; }
|
||||
.composer { display: flex; gap: 10px; padding: 12px 16px; background: var(--panel); align-items: center; }
|
||||
.composer input[type=text], .composer input:not([type]) {
|
||||
flex: 1; padding: 11px 14px; border-radius: 20px; border: none; background: var(--panel-2); color: var(--text); font-size: 15px;
|
||||
}
|
||||
.composer input:focus { outline: none; }
|
||||
.attach { cursor: pointer; font-size: 20px; user-select: none; }
|
||||
.send { background: var(--accent); color: #04140f; font-weight: 600; border-radius: 20px; }
|
||||
|
||||
/* ---- modal ---- */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
||||
display: grid; place-items: center; z-index: 100; padding: 20px;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--panel); border-radius: 14px; padding: 24px;
|
||||
width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 14px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.5);
|
||||
}
|
||||
.modal-title { margin: 0; font-size: 18px; }
|
||||
.modal-input {
|
||||
padding: 12px 14px; border-radius: 10px; border: 1px solid var(--panel-2);
|
||||
background: var(--panel-2); color: var(--text); font-size: 15px;
|
||||
}
|
||||
.modal-input:focus { outline: 2px solid var(--accent); }
|
||||
.modal-err { color: #f15c6d; font-size: 13px; min-height: 16px; }
|
||||
.modal-note { color: var(--muted); font-size: 13px; line-height: 1.4; }
|
||||
textarea.modal-input { resize: vertical; font-family: inherit; }
|
||||
.code-box {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 18px; letter-spacing: 1px;
|
||||
background: var(--panel-2); padding: 14px; border-radius: 10px; text-align: center;
|
||||
word-break: break-all; user-select: all;
|
||||
}
|
||||
.code-box.small { font-size: 12px; letter-spacing: 0; }
|
||||
.qr { display: block; width: 220px; height: 220px; margin: 0 auto; border-radius: 10px; background: #fff; }
|
||||
.qr-video { width: 100%; max-width: 280px; border-radius: 10px; display: block; margin: 8px auto; background: #000; }
|
||||
.auth-divider { text-align: center; color: var(--muted); font-size: 12px; margin-top: 6px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.link-btn { background: transparent; color: var(--accent); border: none; padding: 6px; font-size: 14px; cursor: pointer; }
|
||||
.auth-card input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124,92,255,.22); }
|
||||
.auth-err, .modal-err { color: var(--danger); font-size: 13px; min-height: 16px; text-align: center; }
|
||||
.auth-divider { text-align: center; color: var(--muted); font-size: 11px; margin-top: 6px; text-transform: uppercase; letter-spacing: 2px; opacity: .7; }
|
||||
.link-btn { background: transparent; color: var(--accent-2); padding: 5px; font-size: 14px; font-weight: 500; }
|
||||
.link-btn:hover { text-decoration: underline; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; }
|
||||
.modal-actions button { padding: 10px 16px; }
|
||||
|
||||
/* ---- account menu ---- */
|
||||
.side-header { cursor: pointer; }
|
||||
.side-header:hover { background: var(--panel-2); }
|
||||
.caret { color: var(--muted); font-size: 12px; }
|
||||
.acct-row, .acct-action {
|
||||
text-align: left; background: var(--panel-2); color: var(--text);
|
||||
padding: 11px 14px; border-radius: 10px;
|
||||
button.primary {
|
||||
padding: 13px 16px; border-radius: 13px; background: var(--accent-grad); color: #fff; font-weight: 700;
|
||||
box-shadow: 0 8px 24px rgba(124,92,255,.35);
|
||||
}
|
||||
.acct-row.active { outline: 1px solid var(--accent); }
|
||||
.acct-action { background: transparent; color: var(--accent); border: 1px solid var(--panel-2); }
|
||||
.acct-action.danger { color: #f15c6d; border-color: #4a2a2f; }
|
||||
button.primary:hover { box-shadow: 0 10px 30px rgba(124,92,255,.5); transform: translateY(-1px); }
|
||||
button.ghost {
|
||||
padding: 13px 16px; border-radius: 13px; background: rgba(255,255,255,.05); color: var(--text); border: 1px solid var(--stroke);
|
||||
}
|
||||
button.ghost:hover { background: rgba(255,255,255,.1); }
|
||||
|
||||
/* ---- toasts ---- */
|
||||
#toasts { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 200; display: flex; flex-direction: column; gap: 8px; align-items: center; }
|
||||
.toast { padding: 12px 18px; border-radius: 10px; font-size: 14px; box-shadow: 0 8px 30px rgba(0,0,0,.5); max-width: 90vw; }
|
||||
.toast.error { background: #3a1c22; color: #ffb3bd; border: 1px solid #5a2a33; }
|
||||
.toast.info { background: var(--panel-2); color: var(--text); }
|
||||
/* ---------- shell ---------- */
|
||||
.shell { display: grid; grid-template-columns: 350px 1fr; height: 100dvh; padding: 14px; gap: 14px; }
|
||||
.sidebar, .main {
|
||||
background: var(--glass); backdrop-filter: blur(22px) saturate(140%); -webkit-backdrop-filter: blur(22px) saturate(140%);
|
||||
border: 1px solid var(--stroke); border-radius: 24px; overflow: hidden; box-shadow: var(--shadow);
|
||||
}
|
||||
.sidebar { display: flex; flex-direction: column; min-height: 0; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
/* Sidebar shows by default; hide it once a chat is open so the thread is full-width. */
|
||||
.side-header { display: flex; gap: 12px; align-items: center; padding: 16px 18px; cursor: pointer; border-bottom: 1px solid var(--stroke); transition: background .2s; }
|
||||
.side-header:hover { background: rgba(255,255,255,.04); }
|
||||
.side-id { min-width: 0; }
|
||||
.me { font-weight: 700; display: flex; align-items: center; gap: 5px; }
|
||||
.caret { color: var(--muted); font-size: 11px; }
|
||||
.handle { color: var(--muted); font-size: 12.5px; }
|
||||
|
||||
.side-actions { display: flex; gap: 10px; padding: 12px 14px; }
|
||||
.pill {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 10px; border-radius: 13px; background: rgba(255,255,255,.05); border: 1px solid var(--stroke); color: var(--text); font-weight: 600; font-size: 14px;
|
||||
}
|
||||
.pill:hover { background: rgba(124,92,255,.16); border-color: rgba(124,92,255,.5); }
|
||||
.pill span { font-size: 16px; line-height: 1; }
|
||||
|
||||
.chat-list { overflow-y: auto; flex: 1; padding: 6px; }
|
||||
.list-empty { color: var(--muted); text-align: center; padding: 30px 10px; font-size: 14px; }
|
||||
.chat-item { display: flex; gap: 13px; padding: 11px 12px; cursor: pointer; align-items: center; border-radius: 15px; transition: background .18s; }
|
||||
.chat-item:hover { background: rgba(255,255,255,.05); }
|
||||
.chat-item.active { background: rgba(124,92,255,.18); box-shadow: inset 0 0 0 1px rgba(124,92,255,.4); }
|
||||
.chat-meta { min-width: 0; }
|
||||
.chat-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-sub { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.avatar {
|
||||
width: 46px; height: 46px; border-radius: 15px; flex: none; display: grid; place-items: center;
|
||||
font-weight: 800; color: #fff; font-size: 18px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.14), 0 4px 14px rgba(0,0,0,.3); position: relative;
|
||||
}
|
||||
.avatar.sm { width: 34px; height: 34px; border-radius: 11px; font-size: 14px; }
|
||||
.avatar.group .group-glyph { font-size: 20px; }
|
||||
|
||||
/* ---------- main pane ---------- */
|
||||
.main { display: flex; flex-direction: column; min-height: 0; }
|
||||
.empty { margin: auto; text-align: center; padding: 30px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
.empty-badge { width: 76px; height: 76px; border-radius: 24px; display: grid; place-items: center; font-size: 34px; background: var(--accent-grad); box-shadow: 0 12px 40px rgba(124,92,255,.4); animation: float 4s ease-in-out infinite; }
|
||||
@keyframes float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
|
||||
.empty-title { font-weight: 700; font-size: 17px; }
|
||||
.empty-sub { color: var(--muted); font-size: 14px; max-width: 280px; }
|
||||
|
||||
.chat-head { display: flex; gap: 13px; align-items: center; padding: 13px 18px; border-bottom: 1px solid var(--stroke); background: rgba(255,255,255,.02); }
|
||||
.chat-head-meta { flex: 1; min-width: 0; }
|
||||
.back-btn { display: none; background: transparent; color: var(--text); font-size: 26px; line-height: 1; padding: 0 4px; }
|
||||
.head-action { background: rgba(255,255,255,.06); color: var(--text); width: 38px; height: 38px; border-radius: 12px; font-size: 17px; }
|
||||
.head-action:hover { background: rgba(255,255,255,.12); }
|
||||
|
||||
.thread { flex: 1; overflow-y: auto; padding: 22px 22px 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.bubble {
|
||||
max-width: 66%; padding: 9px 13px 7px; border-radius: 18px; font-size: 14.5px; line-height: 1.4; position: relative; word-wrap: break-word;
|
||||
animation: rise .22s ease; box-shadow: 0 2px 10px rgba(0,0,0,.18);
|
||||
}
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
.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(109,75,255,.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; }
|
||||
.media-img { max-width: 260px; border-radius: 12px; display: block; }
|
||||
.media-file { color: var(--text); text-decoration: none; font-weight: 500; }
|
||||
|
||||
.composer { display: flex; gap: 10px; padding: 14px 16px; align-items: center; border-top: 1px solid var(--stroke); background: rgba(255,255,255,.02); }
|
||||
.composer-input { flex: 1; padding: 12px 16px; border-radius: 22px; border: 1px solid var(--stroke); background: rgba(0,0,0,.28); color: var(--text); font-size: 15px; transition: border .2s, box-shadow .2s; }
|
||||
.composer-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124,92,255,.2); }
|
||||
.attach { cursor: pointer; font-size: 20px; width: 42px; height: 42px; border-radius: 50%; display: grid; place-items: center; background: rgba(255,255,255,.06); user-select: none; flex: none; transition: background .2s; }
|
||||
.attach:hover { background: rgba(255,255,255,.12); }
|
||||
.send { width: 46px; height: 46px; border-radius: 50%; background: var(--accent-grad); color: #fff; font-size: 17px; flex: none; box-shadow: 0 6px 18px rgba(124,92,255,.4); display: grid; place-items: center; }
|
||||
.send:hover { transform: translateY(-1px) scale(1.03); }
|
||||
|
||||
/* ---------- modal ---------- */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(6,6,14,.66); backdrop-filter: blur(6px); display: grid; place-items: center; z-index: 100; padding: 20px; animation: fade .2s ease; }
|
||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
.modal-card {
|
||||
background: var(--glass); backdrop-filter: blur(26px) saturate(150%); -webkit-backdrop-filter: blur(26px) saturate(150%);
|
||||
border: 1px solid var(--stroke-2); border-radius: 22px; padding: 24px; width: 100%; max-width: 380px;
|
||||
display: flex; flex-direction: column; gap: 13px; box-shadow: var(--shadow); max-height: 84vh; overflow-y: auto;
|
||||
animation: pop .28s cubic-bezier(.2,.9,.3,1.2);
|
||||
}
|
||||
@keyframes pop { from { opacity: 0; transform: scale(.94) translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
.modal-title { margin: 0; font-size: 19px; font-weight: 700; }
|
||||
.modal-input { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--stroke); background: rgba(0,0,0,.28); color: var(--text); font-size: 15px; }
|
||||
.modal-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124,92,255,.2); }
|
||||
textarea.modal-input { resize: vertical; font-family: inherit; }
|
||||
.modal-note { color: var(--muted); font-size: 13px; line-height: 1.45; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; }
|
||||
.modal-actions button { padding: 10px 18px; }
|
||||
|
||||
.code-box { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 18px; letter-spacing: 1px; background: rgba(0,0,0,.35); padding: 15px; border-radius: 12px; text-align: center; word-break: break-all; user-select: all; border: 1px dashed var(--stroke-2); }
|
||||
.code-box.small { font-size: 12px; letter-spacing: 0; }
|
||||
.qr { display: block; width: 224px; height: 224px; margin: 4px auto; border-radius: 14px; background: #fff; padding: 8px; }
|
||||
.qr-video { width: 100%; max-width: 280px; border-radius: 14px; display: block; margin: 8px auto; background: #000; }
|
||||
|
||||
/* account menu + group rows */
|
||||
.acct-row, .acct-action { text-align: left; background: rgba(255,255,255,.05); color: var(--text); padding: 12px 14px; border-radius: 13px; border: 1px solid var(--stroke); font-weight: 500; }
|
||||
.acct-row:hover, .acct-action:hover { background: rgba(255,255,255,.1); }
|
||||
.acct-row.active { outline: 1px solid var(--accent); background: rgba(124,92,255,.16); }
|
||||
.acct-action { color: var(--accent-2); text-align: center; }
|
||||
.acct-action.danger { color: var(--danger); }
|
||||
.member-row { display: flex; gap: 12px; align-items: center; padding: 4px 0; }
|
||||
|
||||
/* group creation chips */
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.chip { background: rgba(124,92,255,.2); border: 1px solid rgba(124,92,255,.4); color: var(--text); padding: 5px 10px; border-radius: 20px; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.chip-x { cursor: pointer; font-weight: 700; opacity: .8; }
|
||||
.chip-x:hover { opacity: 1; }
|
||||
|
||||
/* ---------- toasts ---------- */
|
||||
#toasts { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 200; display: flex; flex-direction: column; gap: 8px; align-items: center; }
|
||||
.toast { padding: 12px 20px; border-radius: 14px; font-size: 14px; box-shadow: var(--shadow); max-width: 90vw; backdrop-filter: blur(20px); border: 1px solid var(--stroke-2); animation: rise .25s ease; }
|
||||
.toast.error { background: rgba(80, 24, 36, .8); color: #ffc2cc; }
|
||||
.toast.info { background: rgba(30, 40, 66, .8); color: var(--text); }
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
@media (max-width: 760px) {
|
||||
.shell { grid-template-columns: 1fr; padding: 8px; gap: 8px; }
|
||||
.shell.chat-open .sidebar { display: none; }
|
||||
.shell:not(.chat-open) .main { display: none; }
|
||||
.back-btn { display: block; }
|
||||
.bubble { max-width: 82%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,13 @@ export const api = {
|
|||
provisionNew: (ephemeralPub) => req("POST", "/api/provision/new", { ephemeralPub }),
|
||||
provisionGet: (id) => req("GET", "/api/provision/" + id),
|
||||
provisionComplete: (id, payload) => req("POST", "/api/provision/" + id + "/complete", { payload }),
|
||||
|
||||
// groups (metadata only; messages are E2E fan-out)
|
||||
createGroup: (name, memberUserIds) => req("POST", "/api/groups", { name, memberUserIds }),
|
||||
listGroups: () => req("GET", "/api/groups"),
|
||||
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", {}),
|
||||
uploadMedia: async (ciphertext) => {
|
||||
const res = await req("POST", "/api/media", ciphertext, true);
|
||||
return res.json ? res.json() : res;
|
||||
|
|
|
|||
389
public/js/app.js
389
public/js/app.js
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { api, setToken, MailboxSocket } from "./api.js";
|
||||
import * as C from "./crypto.js";
|
||||
import { useAccount, contacts, messages as msgStore } from "./store.js";
|
||||
import { useAccount, contacts, groups, messages as msgStore } from "./store.js";
|
||||
import * as accounts from "./accounts.js";
|
||||
import qrcode from "./vendor/qrcode.js";
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ const state = {
|
|||
me: null, // { userId, deviceId, username, displayName }
|
||||
token: null,
|
||||
socket: null,
|
||||
activeContact: null, // contact object
|
||||
activeChat: null, // { type:"dm"|"group", id, name, contact?, group? }
|
||||
};
|
||||
|
||||
// ---------- boot ----------
|
||||
|
|
@ -36,7 +36,7 @@ async function activateAccount(acct) {
|
|||
if (state.socket) { state.socket.close(); state.socket = null; }
|
||||
state.me = acct;
|
||||
state.token = acct.token;
|
||||
state.activeContact = null;
|
||||
state.activeChat = null;
|
||||
useAccount(acct.username); // per-account IndexedDB namespace
|
||||
setToken(acct.token);
|
||||
accounts.setActive(acct.username);
|
||||
|
|
@ -139,12 +139,29 @@ async function onAuthenticated(res) {
|
|||
// ---------- main app ----------
|
||||
async function enterApp() {
|
||||
renderShell();
|
||||
await refreshContacts();
|
||||
await syncGroups();
|
||||
await refreshChats();
|
||||
connectSocket();
|
||||
maybeReplenishPreKeys();
|
||||
registerServiceWorker();
|
||||
}
|
||||
|
||||
// Deterministic vibrant colour from a string — used for avatars.
|
||||
function hashHue(str) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
||||
return h % 360;
|
||||
}
|
||||
function avatarEl(name, { group = false, size = "" } = {}) {
|
||||
const initial = (name || "?").trim()[0]?.toUpperCase() || "?";
|
||||
const hue = hashHue(name || "?");
|
||||
const a = el("div", { className: "avatar" + (size ? " " + size : "") + (group ? " group" : "") });
|
||||
a.style.background = `linear-gradient(135deg, hsl(${hue} 70% 52%), hsl(${(hue + 40) % 360} 70% 42%))`;
|
||||
a.textContent = group ? "" : initial;
|
||||
if (group) a.append(el("span", { className: "group-glyph", textContent: "⛊" }));
|
||||
return a;
|
||||
}
|
||||
|
||||
function renderShell() {
|
||||
const root = $("#app");
|
||||
root.innerHTML = "";
|
||||
|
|
@ -152,38 +169,71 @@ function renderShell() {
|
|||
|
||||
// Sidebar
|
||||
const sidebar = el("aside", { className: "sidebar" });
|
||||
const header = el("div", { className: "side-header", title: "Accounts", onclick: openAccountMenu });
|
||||
const header = el("div", { className: "side-header", title: "Accounts & settings", onclick: openAccountMenu });
|
||||
header.append(
|
||||
avatarEl(state.me.displayName || state.me.username),
|
||||
el("div", { className: "side-id" },
|
||||
el("div", { className: "me" },
|
||||
document.createTextNode(state.me.displayName || state.me.username),
|
||||
el("span", { className: "caret", textContent: " ▾" })
|
||||
el("span", { className: "caret", textContent: "▾" })
|
||||
),
|
||||
el("div", { className: "handle", textContent: "@" + state.me.username })
|
||||
)
|
||||
);
|
||||
const actions = el("div", { className: "side-actions" },
|
||||
el("button", { className: "pill", onclick: startNewChat }, el("span", { textContent: "+" }), document.createTextNode("Chat")),
|
||||
el("button", { className: "pill", onclick: startNewGroup }, el("span", { textContent: "⛊" }), document.createTextNode("Group"))
|
||||
);
|
||||
const newChat = el("button", { className: "new-chat", textContent: "+ New chat", onclick: startNewChat });
|
||||
const list = el("div", { className: "chat-list", id: "chatList" });
|
||||
sidebar.append(header, newChat, list);
|
||||
sidebar.append(header, actions, list);
|
||||
|
||||
// Main pane
|
||||
const main = el("section", { className: "main", id: "main" });
|
||||
main.append(el("div", { className: "empty", textContent: "Select a chat or start a new one" }));
|
||||
main.append(emptyState());
|
||||
|
||||
shell.append(sidebar, main);
|
||||
root.append(shell);
|
||||
}
|
||||
|
||||
async function refreshContacts() {
|
||||
function emptyState() {
|
||||
return el("div", { className: "empty" },
|
||||
el("div", { className: "empty-badge", textContent: "🔒" }),
|
||||
el("div", { className: "empty-title", textContent: "Your messages are end-to-end encrypted" }),
|
||||
el("div", { className: "empty-sub", textContent: "Pick a conversation, or start a new chat or group." })
|
||||
);
|
||||
}
|
||||
|
||||
// Pull the latest group list from the server into the local store.
|
||||
async function syncGroups() {
|
||||
try {
|
||||
const { groups: gs } = await api.listGroups();
|
||||
for (const g of gs) await groups.put(g);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Unified chat list: DMs + groups, most-recent first.
|
||||
async function refreshChats() {
|
||||
const list = $("#chatList");
|
||||
if (!list) return;
|
||||
const cs = await contacts.all();
|
||||
const gs = await groups.all();
|
||||
const items = [
|
||||
...gs.map((g) => ({ type: "group", id: g.groupId, name: g.name, sub: `${g.members.length} members`, group: g })),
|
||||
...cs.map((c) => ({ type: "dm", id: c.userId, name: c.displayName || c.username, sub: "@" + c.username, contact: c })),
|
||||
];
|
||||
list.innerHTML = "";
|
||||
const all = await contacts.all();
|
||||
for (const c of all) {
|
||||
const item = el("div", { className: "chat-item", onclick: () => openChat(c) });
|
||||
if (!items.length) {
|
||||
list.append(el("div", { className: "list-empty", textContent: "No conversations yet" }));
|
||||
return;
|
||||
}
|
||||
for (const chat of items) {
|
||||
const active = state.activeChat && state.activeChat.type === chat.type && state.activeChat.id === chat.id;
|
||||
const item = el("div", { className: "chat-item" + (active ? " active" : ""), onclick: () => openChat(chat) });
|
||||
item.append(
|
||||
el("div", { className: "avatar", textContent: (c.displayName || c.username)[0].toUpperCase() }),
|
||||
avatarEl(chat.name, { group: chat.type === "group" }),
|
||||
el("div", { className: "chat-meta" },
|
||||
el("div", { className: "chat-name", textContent: c.displayName || c.username }),
|
||||
el("div", { className: "chat-sub", textContent: "@" + c.username })
|
||||
el("div", { className: "chat-name", textContent: chat.name }),
|
||||
el("div", { className: "chat-sub", textContent: chat.sub })
|
||||
)
|
||||
);
|
||||
list.append(item);
|
||||
|
|
@ -201,16 +251,11 @@ function startNewChat() {
|
|||
if (uname === state.me.username) return setError("That's you 🙂");
|
||||
try {
|
||||
const user = await api.lookup(uname);
|
||||
const contact = {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
deviceIds: [],
|
||||
};
|
||||
const contact = { userId: user.userId, username: user.username, displayName: user.displayName, deviceIds: [] };
|
||||
await contacts.put(contact);
|
||||
await refreshContacts();
|
||||
await refreshChats();
|
||||
close();
|
||||
await openChat(contact);
|
||||
await openChat({ type: "dm", id: contact.userId, name: contact.displayName || contact.username, sub: "@" + contact.username, contact });
|
||||
} catch (ex) {
|
||||
setError(ex.message === "not found" ? "No user found: @" + uname : ex.message);
|
||||
}
|
||||
|
|
@ -218,6 +263,65 @@ function startNewChat() {
|
|||
});
|
||||
}
|
||||
|
||||
// ---------- new group ----------
|
||||
function startNewGroup() {
|
||||
const nameIn = el("input", { className: "modal-input", placeholder: "Group name" });
|
||||
const memberIn = el("input", { className: "modal-input", placeholder: "Add member by username, then Enter" });
|
||||
const chips = el("div", { className: "chips" });
|
||||
const err = el("div", { className: "modal-err" });
|
||||
const cancel = el("button", { type: "button", className: "ghost", textContent: "Cancel" });
|
||||
const create = el("button", { type: "button", className: "primary", textContent: "Create group" });
|
||||
const members = []; // {userId, username, displayName}
|
||||
|
||||
const addMember = async () => {
|
||||
err.textContent = "";
|
||||
const uname = memberIn.value.trim().toLowerCase();
|
||||
if (!uname) return;
|
||||
if (uname === state.me.username) { err.textContent = "You're already in the group"; return; }
|
||||
if (members.some((m) => m.username === uname)) { memberIn.value = ""; return; }
|
||||
try {
|
||||
const user = await api.lookup(uname);
|
||||
members.push({ userId: user.userId, username: user.username, displayName: user.displayName });
|
||||
memberIn.value = "";
|
||||
renderChips();
|
||||
} catch {
|
||||
err.textContent = "No user found: @" + uname;
|
||||
}
|
||||
};
|
||||
const renderChips = () => {
|
||||
chips.innerHTML = "";
|
||||
for (const m of members) {
|
||||
const chip = el("span", { className: "chip" }, document.createTextNode("@" + m.username));
|
||||
chip.append(el("span", { className: "chip-x", textContent: "×", onclick: () => { members.splice(members.indexOf(m), 1); renderChips(); } }));
|
||||
chips.append(chip);
|
||||
}
|
||||
};
|
||||
|
||||
const { close } = makeOverlay(
|
||||
el("h2", { className: "modal-title", textContent: "New group" }),
|
||||
nameIn, memberIn, chips, err,
|
||||
el("div", { className: "modal-actions" }, cancel, create)
|
||||
);
|
||||
memberIn.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); addMember(); } };
|
||||
cancel.onclick = close;
|
||||
create.onclick = async () => {
|
||||
err.textContent = "";
|
||||
if (!nameIn.value.trim()) return (err.textContent = "Group name required");
|
||||
if (!members.length) return (err.textContent = "Add at least one member");
|
||||
create.disabled = true; create.textContent = "Creating…";
|
||||
try {
|
||||
const group = await api.createGroup(nameIn.value.trim(), members.map((m) => m.userId));
|
||||
await groups.put(group);
|
||||
await refreshChats();
|
||||
close();
|
||||
await openChat({ type: "group", id: group.groupId, name: group.name, sub: `${group.members.length} members`, group });
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
create.disabled = false; create.textContent = "Create group";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Lightweight in-app modal (replaces window.prompt, which Brave/PWAs suppress).
|
||||
function openModal({ title, placeholder, action, onSubmit }) {
|
||||
const input = el("input", { className: "modal-input", placeholder, autocomplete: "off" });
|
||||
|
|
@ -546,47 +650,57 @@ function toast(message, kind = "error") {
|
|||
}
|
||||
|
||||
// ---------- chat view ----------
|
||||
async function openChat(contact) {
|
||||
state.activeContact = contact;
|
||||
// ---- conversation helpers (unify DMs + groups) ----
|
||||
function chatConvo(chat) {
|
||||
return chat.type === "group" ? "g:" + chat.id : state.me.userId + ":" + chat.id;
|
||||
}
|
||||
function senderName(userId, group) {
|
||||
if (userId === state.me.userId) return "You";
|
||||
const m = group?.members?.find((x) => x.userId === userId);
|
||||
return m ? m.displayName || m.username : userId.slice(0, 6);
|
||||
}
|
||||
|
||||
async function openChat(chat) {
|
||||
state.activeChat = chat;
|
||||
$(".shell")?.classList.add("chat-open");
|
||||
await refreshChats(); // reflect active highlight
|
||||
const main = $("#main");
|
||||
main.innerHTML = "";
|
||||
|
||||
const back = el("button", {
|
||||
className: "back-btn",
|
||||
textContent: "‹",
|
||||
title: "Back",
|
||||
onclick: () => {
|
||||
state.activeContact = null;
|
||||
$(".shell")?.classList.remove("chat-open");
|
||||
},
|
||||
className: "back-btn", textContent: "‹", title: "Back",
|
||||
onclick: () => { state.activeChat = null; $(".shell")?.classList.remove("chat-open"); refreshChats(); showEmpty(); },
|
||||
});
|
||||
const sub = chat.type === "group"
|
||||
? `${chat.group.members.length} members · 🔒 encrypted`
|
||||
: "🔒 end-to-end encrypted";
|
||||
const head = el("div", { className: "chat-head" });
|
||||
head.append(
|
||||
back,
|
||||
el("div", { className: "avatar", textContent: (contact.displayName || contact.username)[0].toUpperCase() }),
|
||||
el("div", {},
|
||||
el("div", { className: "chat-name", textContent: contact.displayName || contact.username }),
|
||||
el("div", { className: "chat-sub", textContent: "🔒 end-to-end encrypted" })
|
||||
avatarEl(chat.name, { group: chat.type === "group" }),
|
||||
el("div", { className: "chat-head-meta" },
|
||||
el("div", { className: "chat-name", textContent: chat.name }),
|
||||
el("div", { className: "chat-sub", textContent: sub })
|
||||
)
|
||||
);
|
||||
if (chat.type === "group") {
|
||||
head.append(el("button", { className: "head-action", title: "Group info", textContent: "ⓘ", onclick: () => showGroupInfo(chat) }));
|
||||
}
|
||||
|
||||
const thread = el("div", { className: "thread", id: "thread" });
|
||||
|
||||
const composer = el("form", { className: "composer" });
|
||||
const input = el("input", { placeholder: "Type a message", autocomplete: "off" });
|
||||
const attach = el("label", { className: "attach", textContent: "📎" });
|
||||
const attach = el("label", { className: "attach", title: "Attach", textContent: "+" });
|
||||
const fileInput = el("input", { type: "file", style: "display:none" });
|
||||
attach.append(fileInput);
|
||||
const sendBtn = el("button", { type: "submit", className: "send", textContent: "Send" });
|
||||
const input = el("input", { className: "composer-input", placeholder: "Message", autocomplete: "off" });
|
||||
const sendBtn = el("button", { type: "submit", className: "send", title: "Send", textContent: "➤" });
|
||||
composer.append(attach, input, sendBtn);
|
||||
|
||||
main.append(head, thread, composer);
|
||||
|
||||
// load history
|
||||
const convo = state.me.userId + ":" + contact.userId;
|
||||
const history = await msgStore.forConvo(convo);
|
||||
for (const m of history) renderMessage(m);
|
||||
const history = await msgStore.forConvo(chatConvo(chat));
|
||||
for (const m of history) renderMessage(m, chat);
|
||||
scrollThread();
|
||||
|
||||
composer.onsubmit = async (e) => {
|
||||
|
|
@ -595,32 +709,56 @@ async function openChat(contact) {
|
|||
if (!text) return;
|
||||
input.value = "";
|
||||
try {
|
||||
await sendText(contact, text);
|
||||
await sendText(chat, text);
|
||||
} catch (ex) {
|
||||
console.error("send failed", ex);
|
||||
toast("Couldn't send: " + ex.message);
|
||||
input.value = text; // restore so the message isn't lost
|
||||
input.value = text;
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.onchange = async () => {
|
||||
const file = fileInput.files[0];
|
||||
if (file) {
|
||||
try {
|
||||
await sendMedia(contact, file);
|
||||
} catch (ex) {
|
||||
console.error("media send failed", ex);
|
||||
toast("Couldn't send file: " + ex.message);
|
||||
}
|
||||
try { await sendMedia(chat, file); }
|
||||
catch (ex) { console.error("media send failed", ex); toast("Couldn't send file: " + ex.message); }
|
||||
}
|
||||
fileInput.value = "";
|
||||
};
|
||||
}
|
||||
|
||||
function convoKey(otherUserId) {
|
||||
return state.me.userId + ":" + otherUserId;
|
||||
function showEmpty() {
|
||||
const main = $("#main");
|
||||
if (main) { main.innerHTML = ""; main.append(emptyState()); }
|
||||
}
|
||||
|
||||
function showGroupInfo(chat) {
|
||||
const rows = chat.group.members.map((m) =>
|
||||
el("div", { className: "member-row" },
|
||||
avatarEl(m.displayName || m.username),
|
||||
el("div", {},
|
||||
el("div", { className: "chat-name", textContent: (m.displayName || m.username) + (m.userId === state.me.userId ? " (you)" : "") }),
|
||||
el("div", { className: "chat-sub", textContent: "@" + m.username })
|
||||
)
|
||||
)
|
||||
);
|
||||
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
|
||||
);
|
||||
leave.onclick = async () => {
|
||||
try { await api.leaveGroup(chat.id); } catch {}
|
||||
await groups.del(chat.id);
|
||||
close();
|
||||
state.activeChat = null;
|
||||
showEmpty();
|
||||
await refreshChats();
|
||||
toast("Left " + chat.name, "info");
|
||||
};
|
||||
}
|
||||
|
||||
// ---- sending ----
|
||||
async function ensureRecipientDevices(contact) {
|
||||
const bundle = await api.bundle(contact.userId);
|
||||
contact.deviceIds = bundle.bundles.map((b) => b.deviceId);
|
||||
|
|
@ -629,42 +767,52 @@ async function ensureRecipientDevices(contact) {
|
|||
return contact.deviceIds;
|
||||
}
|
||||
|
||||
async function sendText(contact, text) {
|
||||
// structured payload lets us carry media refs in the same E2E channel
|
||||
const payload = JSON.stringify({ k: "text", text });
|
||||
await deliver(contact, payload);
|
||||
await persistAndRender(contact.userId, { dir: "out", kind: "text", text, ts: Date.now() });
|
||||
// Encrypt+send a payload to one user (all their devices).
|
||||
async function deliverToUser(userId, deviceIdsCache, plaintext) {
|
||||
let deviceIds = deviceIdsCache;
|
||||
if (!deviceIds || !deviceIds.length) {
|
||||
const bundle = await api.bundle(userId);
|
||||
deviceIds = bundle.bundles.map((b) => b.deviceId);
|
||||
await C.ensureSessions(bundle);
|
||||
}
|
||||
const messages = await C.encryptForUser(userId, deviceIds, plaintext);
|
||||
await api.send(userId, messages);
|
||||
return deviceIds;
|
||||
}
|
||||
|
||||
async function sendMedia(contact, file) {
|
||||
// Deliver a payload to a chat: one recipient for DMs, fan-out for groups.
|
||||
async function deliver(chat, payloadObj) {
|
||||
if (chat.type === "group") {
|
||||
const plaintext = JSON.stringify({ ...payloadObj, groupId: chat.id });
|
||||
const others = chat.group.members.filter((m) => m.userId !== state.me.userId);
|
||||
// Fan out — encrypt separately to each member (pairwise Signal sessions).
|
||||
await Promise.all(others.map((m) => deliverToUser(m.userId, null, plaintext).catch((e) => {
|
||||
console.error("fan-out to", m.username, "failed", e);
|
||||
toast("Couldn't reach @" + m.username);
|
||||
})));
|
||||
} else {
|
||||
const contact = chat.contact;
|
||||
let deviceIds = contact.deviceIds;
|
||||
if (!deviceIds || !deviceIds.length) deviceIds = await ensureRecipientDevices(contact);
|
||||
contact.deviceIds = await deliverToUser(contact.userId, deviceIds, JSON.stringify(payloadObj));
|
||||
await contacts.put(contact);
|
||||
}
|
||||
}
|
||||
|
||||
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() });
|
||||
}
|
||||
|
||||
async function sendMedia(chat, file) {
|
||||
const buf = await file.arrayBuffer();
|
||||
const enc = await C.encryptMedia(buf);
|
||||
const { key } = await api.uploadMedia(enc.ciphertext);
|
||||
const payload = JSON.stringify({
|
||||
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 });
|
||||
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(),
|
||||
});
|
||||
await deliver(contact, payload);
|
||||
await persistAndRender(contact.userId, {
|
||||
dir: "out",
|
||||
kind: "media",
|
||||
name: file.name,
|
||||
mime: file.type,
|
||||
localUrl: URL.createObjectURL(file),
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function deliver(contact, plaintextPayload) {
|
||||
let deviceIds = contact.deviceIds;
|
||||
if (!deviceIds || !deviceIds.length) deviceIds = await ensureRecipientDevices(contact);
|
||||
const messages = await C.encryptForUser(contact.userId, deviceIds, plaintextPayload);
|
||||
await api.send(contact.userId, messages);
|
||||
}
|
||||
|
||||
// ---------- receiving ----------
|
||||
|
|
@ -675,62 +823,69 @@ function connectSocket() {
|
|||
}
|
||||
|
||||
async function onEnvelope(envelope) {
|
||||
// Look up / create the sender contact.
|
||||
let contact = await contactByUserId(envelope.fromUserId);
|
||||
let plaintext;
|
||||
try {
|
||||
plaintext = await C.decryptEnvelope(envelope, envelope.fromUserId);
|
||||
} catch (ex) {
|
||||
console.error("decrypt failed", ex);
|
||||
toast("Couldn't decrypt a message (session mismatch)");
|
||||
throw ex; // leave it unacked so it can retry after the session heals
|
||||
throw ex; // leave unacked to retry after the session heals
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(plaintext);
|
||||
} catch {
|
||||
data = { k: "text", text: plaintext };
|
||||
}
|
||||
try { data = JSON.parse(plaintext); } catch { data = { k: "text", text: plaintext }; }
|
||||
|
||||
if (!contact) {
|
||||
// Unknown sender — create a stub contact so the message has a home.
|
||||
contact = { userId: envelope.fromUserId, username: envelope.fromUserId.slice(0, 8), displayName: "New contact", deviceIds: [] };
|
||||
await contacts.put(contact);
|
||||
await refreshContacts();
|
||||
}
|
||||
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() };
|
||||
|
||||
if (data.k === "media") {
|
||||
await persistAndRender(envelope.fromUserId, {
|
||||
dir: "in",
|
||||
kind: "media",
|
||||
name: data.name,
|
||||
mime: data.type,
|
||||
mediaRef: { key: data.key, keyB64: data.keyB64, ivB64: data.ivB64 },
|
||||
ts: envelope.sentAt || Date.now(),
|
||||
});
|
||||
if (data.groupId) {
|
||||
// group message — make sure we know the group, then file it there
|
||||
let g = await groups.get(data.groupId);
|
||||
if (!g) {
|
||||
try { g = await api.getGroup(data.groupId); await groups.put(g); await refreshChats(); } catch { return; }
|
||||
}
|
||||
await persistAndRender("g:" + data.groupId, data.groupId, msg);
|
||||
} else {
|
||||
await persistAndRender(envelope.fromUserId, { dir: "in", kind: "text", text: data.text, ts: envelope.sentAt || Date.now() });
|
||||
// DM — ensure a contact exists
|
||||
let contact = await contacts.get(envelope.fromUserId);
|
||||
if (!contact) {
|
||||
let profile = null;
|
||||
try { profile = await api.lookup(envelope.fromUserId); } catch {}
|
||||
contact = { userId: envelope.fromUserId, username: profile?.username || envelope.fromUserId.slice(0, 8), displayName: profile?.displayName || "New contact", deviceIds: [] };
|
||||
await contacts.put(contact);
|
||||
await refreshChats();
|
||||
}
|
||||
await persistAndRender(convoKeyDM(envelope.fromUserId), envelope.fromUserId, msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function contactByUserId(userId) {
|
||||
return contacts.get(userId);
|
||||
function convoKeyDM(otherUserId) {
|
||||
return state.me.userId + ":" + otherUserId;
|
||||
}
|
||||
|
||||
// ---------- persistence + render ----------
|
||||
async function persistAndRender(otherUserId, msg) {
|
||||
const record = { ...msg, convo: convoKey(otherUserId), otherUserId };
|
||||
async function persistAndRender(convo, chatId, msg) {
|
||||
const record = { ...msg, convo };
|
||||
await msgStore.add(record);
|
||||
if (state.activeContact && state.activeContact.userId === otherUserId) {
|
||||
renderMessage(record);
|
||||
if (state.activeChat && state.activeChat.id === chatId) {
|
||||
renderMessage(record, state.activeChat);
|
||||
scrollThread();
|
||||
}
|
||||
}
|
||||
|
||||
function renderMessage(m) {
|
||||
function renderMessage(m, chat) {
|
||||
const thread = $("#thread");
|
||||
if (!thread) return;
|
||||
const bubble = el("div", { className: "bubble " + (m.dir === "out" ? "out" : "in") });
|
||||
const out = m.dir === "out";
|
||||
const bubble = el("div", { className: "bubble " + (out ? "out" : "in") });
|
||||
// sender label for incoming group messages
|
||||
if (!out && chat && chat.type === "group") {
|
||||
const name = senderName(m.senderId, chat.group);
|
||||
const label = el("div", { className: "sender" });
|
||||
label.textContent = name;
|
||||
label.style.color = `hsl(${hashHue(name)} 65% 68%)`;
|
||||
bubble.append(label);
|
||||
}
|
||||
if (m.kind === "media") {
|
||||
if (m.mime && m.mime.startsWith("image/")) {
|
||||
const img = el("img", { className: "media-img", alt: m.name || "image" });
|
||||
|
|
|
|||
|
|
@ -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 = 1;
|
||||
const DB_VERSION = 2; // v2 adds the "groups" object store
|
||||
|
||||
// The store is namespaced PER ACCOUNT so multiple accounts can coexist on one
|
||||
// browser without their Signal identities colliding. Namespace by username
|
||||
|
|
@ -31,6 +31,8 @@ function openDB() {
|
|||
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.
|
||||
if (!db.objectStoreNames.contains("messages")) {
|
||||
const s = db.createObjectStore("messages", { keyPath: "id", autoIncrement: true });
|
||||
|
|
@ -89,6 +91,22 @@ export const contacts = {
|
|||
},
|
||||
};
|
||||
|
||||
// ---- groups ----
|
||||
export const groups = {
|
||||
async put(group) {
|
||||
return reqP((await tx("groups", "readwrite")).put(group));
|
||||
},
|
||||
async get(groupId) {
|
||||
return reqP((await tx("groups")).get(groupId));
|
||||
},
|
||||
async all() {
|
||||
return reqP((await tx("groups")).getAll());
|
||||
},
|
||||
async del(groupId) {
|
||||
return reqP((await tx("groups", "readwrite")).delete(groupId));
|
||||
},
|
||||
};
|
||||
|
||||
// ---- messages ----
|
||||
export const messages = {
|
||||
async add(msg) {
|
||||
|
|
|
|||
|
|
@ -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-v4";
|
||||
const CACHE = "cipher-v5";
|
||||
const SHELL = [
|
||||
"/",
|
||||
"/index.html",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import keys from "./routes/keys.js";
|
|||
import messages from "./routes/messages.js";
|
||||
import media from "./routes/media.js";
|
||||
import backup from "./routes/backup.js";
|
||||
import groups from "./routes/groups.js";
|
||||
|
||||
export { Mailbox } from "./do/mailbox.js";
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ app.route("/api", keys);
|
|||
app.route("/api", messages);
|
||||
app.route("/api", media);
|
||||
app.route("/api", backup);
|
||||
app.route("/api", groups);
|
||||
|
||||
// Everything else falls through to static assets (the PWA), handled by the
|
||||
// ASSETS binding via not_found_handling: single-page-application.
|
||||
|
|
|
|||
105
src/routes/groups.js
Normal file
105
src/routes/groups.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Group metadata. The server knows who is in a group and its name (like
|
||||
// WhatsApp) but never sees message content — messages are E2E fan-out.
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { requireAuth } from "../lib/auth.js";
|
||||
import { uuid, now } from "../lib/util.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Load a group's members joined with their public profiles. Returns null if the
|
||||
// caller is not a member (groups are only visible to members).
|
||||
async function loadGroup(db, groupId, callerId) {
|
||||
const g = await db.prepare("SELECT id, name, created_by, created_at FROM groups WHERE id = ?")
|
||||
.bind(groupId)
|
||||
.first();
|
||||
if (!g) return null;
|
||||
const members = await db
|
||||
.prepare(
|
||||
`SELECT u.id AS userId, u.username, u.display_name AS displayName
|
||||
FROM group_members gm JOIN users u ON u.id = gm.user_id
|
||||
WHERE gm.group_id = ? ORDER BY gm.added_at ASC`
|
||||
)
|
||||
.bind(groupId)
|
||||
.all();
|
||||
const list = members.results || [];
|
||||
if (!list.some((m) => m.userId === callerId)) return null;
|
||||
return { groupId: g.id, name: g.name, createdBy: g.created_by, members: list };
|
||||
}
|
||||
|
||||
// POST /api/groups body: { name, memberUserIds: [...] }
|
||||
app.post("/groups", requireAuth(), async (c) => {
|
||||
const me = c.get("userId");
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const name = String(body?.name || "").trim().slice(0, 60);
|
||||
if (!name) return c.json({ error: "group name required" }, 400);
|
||||
const memberIds = Array.from(new Set([me, ...(body?.memberUserIds || [])]));
|
||||
if (memberIds.length < 2) return c.json({ error: "add at least one other member" }, 400);
|
||||
|
||||
const groupId = uuid();
|
||||
const ts = now();
|
||||
const stmts = [
|
||||
c.env.DB.prepare("INSERT INTO groups (id, name, created_by, created_at) VALUES (?,?,?,?)")
|
||||
.bind(groupId, name, me, ts),
|
||||
];
|
||||
for (const uid of memberIds) {
|
||||
stmts.push(
|
||||
c.env.DB.prepare(
|
||||
"INSERT OR IGNORE INTO group_members (group_id, user_id, added_at) VALUES (?,?,?)"
|
||||
).bind(groupId, uid, ts)
|
||||
);
|
||||
}
|
||||
await c.env.DB.batch(stmts);
|
||||
const group = await loadGroup(c.env.DB, groupId, me);
|
||||
return c.json(group);
|
||||
});
|
||||
|
||||
// GET /api/groups — every group the caller belongs to
|
||||
app.get("/groups", requireAuth(), async (c) => {
|
||||
const me = c.get("userId");
|
||||
const ids = await c.env.DB.prepare("SELECT group_id FROM group_members WHERE user_id = ?")
|
||||
.bind(me)
|
||||
.all();
|
||||
const groups = [];
|
||||
for (const row of ids.results || []) {
|
||||
const g = await loadGroup(c.env.DB, row.group_id, me);
|
||||
if (g) groups.push(g);
|
||||
}
|
||||
return c.json({ groups });
|
||||
});
|
||||
|
||||
// GET /api/groups/:id
|
||||
app.get("/groups/:id", requireAuth(), async (c) => {
|
||||
const me = c.get("userId");
|
||||
const g = await loadGroup(c.env.DB, c.req.param("id"), me);
|
||||
if (!g) return c.json({ error: "not found" }, 404);
|
||||
return c.json(g);
|
||||
});
|
||||
|
||||
// POST /api/groups/:id/members body: { userId }
|
||||
app.post("/groups/:id/members", 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);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body?.userId) return c.json({ error: "userId required" }, 400);
|
||||
await c.env.DB.prepare(
|
||||
"INSERT OR IGNORE INTO group_members (group_id, user_id, added_at) VALUES (?,?,?)"
|
||||
)
|
||||
.bind(groupId, body.userId, now())
|
||||
.run();
|
||||
return c.json(await loadGroup(c.env.DB, groupId, me));
|
||||
});
|
||||
|
||||
// POST /api/groups/:id/leave
|
||||
app.post("/groups/:id/leave", requireAuth(), async (c) => {
|
||||
const me = c.get("userId");
|
||||
const groupId = c.req.param("id");
|
||||
await c.env.DB.prepare("DELETE FROM group_members WHERE group_id = ? AND user_id = ?")
|
||||
.bind(groupId, me)
|
||||
.run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default app;
|
||||
Loading…
Add table
Reference in a new issue