eleven / messenger Public

web: retire the original-chat feed — delete dead code + rename chat.js → shell.js (phase 2) (#178)

* web: delete the retired original-chat feed code

Phase 2 of retiring the original chat: the app engine (web/static/apps/chat.js)
is the sole chat, so the classic #feed renderer, composer wiring, message-action
handlers, reply UI, sendMessage, and message-text rendering are all dead. Remove
them (~1600 lines) along with the now-dead sub-branches inside kept functions
(WS handler, openThread, catchUpActiveMessages, the native bridge, and the
prebuild warmers).

Kept: toast, openLightbox, and openMentionMenu (shared with the app host); the
typing subsystem and the feedCache helpers (entangled with live openThread/WS or
harmless-unused). Widened catchUpActiveMessages' guard to activeAppFor so
personal app-threads still catch up on reconnect through the fold (the classic
tail used to cover them).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

* web: rename chat.js -> shell.js to reflect its host/shell role

After gutting the classic feed, this file no longer renders chat — it's the
page host/shell (bootstrap, sidebar/thread-list, WebSocket, crypto/keys,
uploads, push) that mounts the chat engine (apps/chat.js). Nothing imports it
(loaded via index.html's module script), so this is a git mv + the one script
tag + updating comment/doc references. The engine apps/chat.js keeps its name.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR2CLfAnugzdMGk7wGfVLx

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 11f3441e4a83d08b0e401e5501eaf7f0bac1d556 parent f7ef52d
12 files changed, +55 −1659
  • CLAUDE.md +4 −4
  • changelog/2026-07-04-delete-original-chat-code.md +1 −0
  • web/pages/index.html +2 −2
  • web/static/admin.js +1 −1
  • web/static/apps.js +12 −12
  • web/static/apps/README.md +6 −6
  • web/static/backup.js +2 −2
  • web/static/format.js +9 −9
  • web/static/push.js +2 −2
  • web/static/{chat.js => shell.js} +10 −1615
  • web/static/style.css +5 −5
  • web/static/ui.js +1 −1
diff --git a/CLAUDE.md b/CLAUDE.md
index f7e5a49..6d6e5a1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -80,7 +80,7 @@ in, so deploy is one file (no cgo).
Frontend module layout:
- `crypto.js` — AES-GCM helpers (`encrypt`/`decrypt` for JSON, `encryptBytes`/
`decryptBytes` for binary). Format is base64url/`iv(12)||ciphertext`.
-- `chat.js` — the chat page **host/shell**: sidebar, thread list, WebSocket,
+- `shell.js` — the chat page **host/shell**: sidebar, thread list, WebSocket,
crypto/keys, uploads, unfurl, push, and the bridge that mounts the app chat
engine. ⚠️ It also still holds the **retired original-chat feed** — the dead,
unreachable classic `#feed` renderer (`appendMessage`, `renderItem`,
@@ -124,7 +124,7 @@ mode is being too clever.
### Views: takeover by default, companion by opt-in
A personal app-thread is presented one of two ways, and **the app chooses** —
-`chat.js` no longer decides for every app:
+`shell.js` no longer decides for every app:
- **Takeover (default).** The app's `render` *is* the whole thread — its full
interactive surface, no chat pane, no toggle. This is the original full-pane
@@ -141,7 +141,7 @@ hook that projects each of its events into the Chat view (a bot bubble, or a
Interface via `render`. No `chatMessage` → takeover. So "wants a chat view" and
"knows how to project into chat" are the same declaration, and the companion split
is opt-in, not mandatory. The gate is `appWantsChatView(name)` in `apps.js`; the
-decision lives there, not spread through `chat.js`.
+decision lives there, not spread through `shell.js`.
## Conventions
@@ -185,7 +185,7 @@ decision lives there, not spread through `chat.js`.
go build -o lchat . # or: CGO_ENABLED=0 go build ./...
go test ./... # db_test, handlers_test, unfurl_test (httptest)
npm test # node test/{crypto,ui,format}.test.mjs
-node --check web/static/chat.js # JS isn't run by tests; syntax-check edits
+node --check web/static/shell.js # JS isn't run by tests; syntax-check edits
```
Add tests as you go. Go handler tests use `newTestServer(t)` which returns
diff --git a/changelog/2026-07-04-delete-original-chat-code.md b/changelog/2026-07-04-delete-original-chat-code.md
new file mode 100644
index 0000000..86377ff
--- /dev/null
+++ b/changelog/2026-07-04-delete-original-chat-code.md
@@ -0,0 +1 @@
+Housekeeping: removed the last of the old chat screen's code now that the newer chat engine handles every conversation. Nothing changes for you — messages, replies, reactions, edits, photos and typing all work exactly as before — the app is just a little leaner under the hood.
diff --git a/web/pages/index.html b/web/pages/index.html
index 4dce565..ce1d4ad 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -54,7 +54,7 @@
<div id="app"></div>
<!-- Full-screen cover shown until the app's first meaningful paint (theme bg is
already set pre-paint above), so a refresh doesn't flash an empty shell and
- then jump as messages load + scroll. chat.js removes it once ready. -->
+ then jump as messages load + scroll. shell.js removes it once ready. -->
<div id="app-loading" class="app-loading"><div class="spinner" aria-label="Loading"></div></div>
<div id="toast" class="toast"></div>
@@ -212,4 +212,4 @@
</div>
</template>
-<script type="module" src="/static/chat.js"></script>
+<script type="module" src="/static/shell.js"></script>
diff --git a/web/static/admin.js b/web/static/admin.js
index 6bb435f..7a18150 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -152,7 +152,7 @@ function wireServerAvatar() {
}
// Downscale + re-encode an image to a small JPEG (caps the long edge, strips
-// EXIF/GPS) — mirrors chat.js processImage, kept local so the admin console needs
+// EXIF/GPS) — mirrors shell.js processImage, kept local so the admin console needs
// no shared module. Returns the encoded bytes.
async function downscaleImage(file, maxDim) {
const bitmap = await createImageBitmap(file);
diff --git a/web/static/apps.js b/web/static/apps.js
index bdc28e4..a8f03dc 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -220,7 +220,7 @@ export function keyedList(el, items, opts) {
for (const gone of existing.values()) gone.remove();
}
-// appByName / personalApps let chat.js render an app by id and list the apps a
+// appByName / personalApps let shell.js render an app by id and list the apps a
// user can start (the "Apps" section of the New-chat dialog).
export function appByName(name) {
return apps.find((a) => a.name === name) || null;
@@ -364,7 +364,7 @@ export function takeSnapshot(threadId) {
return { states, upTo };
}
-// After a fold changes state, tell the host (chat.js) so it can persist a fresh
+// After a fold changes state, tell the host (shell.js) so it can persist a fresh
// snapshot. Debounced per thread: bursts of events (catch-up) save once.
let stateSaver = null; // (threadId) => void — host loads takeSnapshot itself
export function setStateSaver(fn) {
@@ -405,7 +405,7 @@ export function foldEvent(threadId, event, meta) {
}
// foldHostEvent folds a HOST-SYNTHESIZED event into a named app, reaching its
-// hostEvents handlers — the doorway chat.js uses to feed chat messages and
+// hostEvents handlers — the doorway shell.js uses to feed chat messages and
// server-enforced mutations (reactions, edits, deletes) into the chat app.
// Deliberately not reachable from wire payloads: foldEvent routes only by owned
// wire type, so a member can't spoof these by posting a typed message.
@@ -442,7 +442,7 @@ function foldInto(threadId, app, event, meta) {
}
// ---- emit transport: how an app event reaches the wire ------------------
-// chat.js owns the keys/socket, so it injects the transport rather than apps.js
+// shell.js owns the keys/socket, so it injects the transport rather than apps.js
// reaching into crypto/ws. transport(threadId, event) → Promise<boolean>.
let transport = null;
@@ -453,7 +453,7 @@ export function setEmitTransport(fn) {
// Scheduling: the server holds an encrypted "future message" and delivers it
// into the thread at the due time — it arrives exactly like any other event
// (folded, notified, pushed to closed apps), the server knowing only (thread,
-// time). chat.js injects { schedule(threadId, event, whenMs) → Promise<sid>,
+// time). shell.js injects { schedule(threadId, event, whenMs) → Promise<sid>,
// cancel(sid) → Promise<boolean> } since it owns the keys and the token.
let schedTransport = null;
export function setScheduleTransport(t) {
@@ -470,7 +470,7 @@ export function setEphemeralTransport(fn) {
}
// deliverEphemeral hands a live relay-only event to its owning app's hook.
-// Called by chat.js when an `ephemeral` frame arrives. Note: the sender gets its
+// Called by shell.js when an `ephemeral` frame arrives. Note: the sender gets its
// own echo too — a hook that shouldn't react to itself checks e.uid !== ctx.me.
export function deliverEphemeral(threadId, event) {
const app = typeOwner.get(event && event.type);
@@ -480,7 +480,7 @@ export function deliverEphemeral(threadId, event) {
} catch {}
}
-// chat.js injects: the current user's id (for per-user logic like "your vote"), a
+// shell.js injects: the current user's id (for per-user logic like "your vote"), a
// resolver that finds-or-creates the user's app-thread for a personal app, and a
// toast fn for the "filed into your list" confirmation.
let currentUser = null;
@@ -497,7 +497,7 @@ let toastFn = null;
export function setToast(fn) {
toastFn = fn;
}
-// The default bot an unknown-command reply is attributed to (Eleven). chat.js
+// The default bot an unknown-command reply is attributed to (Eleven). shell.js
// resolves it from the roster and returns { name, kind } (kind "ai"/"bot" → the
// badge) or null when there's no bot to name.
let defaultBotFn = null;
@@ -506,7 +506,7 @@ export function setDefaultBot(fn) {
}
// Open a thread — used ONLY by an action chip a command offers, never by the
// command itself (a command answers in place; leaving the context is the user's
-// tap — see CLAUDE.md). chat.js injects openThread.
+// tap — see CLAUDE.md). shell.js injects openThread.
let openThreadFn = null;
export function setThreadOpener(fn) {
openThreadFn = fn;
@@ -625,7 +625,7 @@ export function runCommand(text, activeThreadId, respond) {
// The registered commands as { name, desc, args, bot } — feeds the "/" typeahead.
// App commands (client-run) come first, then bot commands (sent to the bot). The
-// caller may hide `bot` ones in threads with no bot (see chat.js typeaheadItems).
+// caller may hide `bot` ones in threads with no bot (see shell.js typeaheadItems).
export function commandCatalog() {
const out = [...commandOwner.entries()].map(([name, app]) => ({
name,
@@ -647,7 +647,7 @@ export function commandNames() {
// reload (like Slack's ephemeral replies). Attributed to the answering bot/app,
// with optional trusted action chips ({ label, run }): they reuse the message
// action-chip look but bypass the untrusted-payload whitelist, since we mint
-// them locally. Both feeds (chat.js's #feed, the chat app's .chatapp-feed)
+// them locally. Both feeds (shell.js's #feed, the chat app's .chatapp-feed)
// append the returned node into their own feed and scroll to it.
export function buildLocalNotice({ sender, text, actions } = {}) {
const row = document.createElement("div");
@@ -770,7 +770,7 @@ export function buildLocalNotice({ sender, text, actions } = {}) {
// the server ever learning what (or that anything) was saved.
//
// messageActionsFor returns the applicable actions across all apps, each already
-// bound to resolve + emit into the right app-thread and toast on success. chat.js
+// bound to resolve + emit into the right app-thread and toast on success. shell.js
// appends these to both message menus.
export function messageActionsFor(msg) {
const out = [];
diff --git a/web/static/apps/README.md b/web/static/apps/README.md
index 9beeb3d..046025e 100644
--- a/web/static/apps/README.md
+++ b/web/static/apps/README.md
@@ -104,7 +104,7 @@ export const app = defineApp({
});
```
-Then register it (one line) in `web/static/chat.js`, next to the others:
+Then register it (one line) in `web/static/shell.js`, next to the others:
```js
import "/static/apps/note.js";
@@ -148,7 +148,7 @@ That's a whole app. Everything below is reference detail and the optional hooks.
Use for group tools: polls. (Example: `poll.js`.)
- **`system`** — mounted explicitly by the host, never listed in the Apps menu
and never given a shared panel. This is how chat-as-an-app is delivered
- (example: `chat.js`, the chat feed itself); you almost certainly want the other two.
+ (example: `shell.js`, the chat feed itself); you almost certainly want the other two.
Not every full-pane thread surface should be an app, though — a pending (not-yet-
joined) DM shows a waiting card instead of the chat app's pane, but that's a
@@ -338,7 +338,7 @@ Two rules:
- **Never touch reducer state from the hook.** Ephemeral events are allowed to
differ between devices (some were offline when it fired); folding one would
break convergence. Keep transient UI in module-level state and redraw it
- yourself (see `chat.js`'s typing line).
+ yourself (see `shell.js`'s typing line).
## Incremental surfaces — `keyedList`
@@ -453,7 +453,7 @@ encrypted, attributed (`e.uid` is the bot), and replayable.
## Chat as an app — the default chat experience
-`chat.js` (scope `system`) is the chat feed: group/DM threads render it
+`shell.js` (scope `system`) is the chat feed: group/DM threads render it
full-pane by default. It is the original chat retooled onto the framework, not
a parallel one. There is no new wire protocol: its composer emits the same
untyped `{ text }` payload every client has always understood, and the host
@@ -530,7 +530,7 @@ The chat app is the default. A device switches to the original feed with the
`localStorage.setItem("lchat-original-chat", "1")`) — the same menu item
switches back, and on the original feed the composer placeholder reads
“Message (original chat)” so you always know which engine you're on. Once the
-original path has soaked out of rotation, it (~2k lines of chat.js plus its
+original path has soaked out of rotation, it (~2k lines of shell.js plus its
CSS) can be deleted.
The design rule stands: **don't add app capabilities chat couldn't use**, and
@@ -579,7 +579,7 @@ Keep any non-trivial parsing pure and exported (like `parseReminder` in
## Shipping checklist
- [ ] `web/static/apps/<name>.js` — one file, self-contained (logic + styles).
-- [ ] `import "/static/apps/<name>.js";` added in `web/static/chat.js`.
+- [ ] `import "/static/apps/<name>.js";` added in `web/static/shell.js`.
- [ ] `node --check web/static/apps/<name>.js` passes.
- [ ] Reducer / parser tests added to `test/apps.test.mjs`; `npm test` green.
- [ ] A user-facing changelog entry in `changelog/<date>-<slug>.md`.
diff --git a/web/static/backup.js b/web/static/backup.js
index 4b1e450..24a085c 100644
--- a/web/static/backup.js
+++ b/web/static/backup.js
@@ -21,7 +21,7 @@
// — and in Node unit tests that import it from web/static/.
import { bytesToB64url, b64urlToBytes, generateUserKeypair, unwrapThreadKey } from "./crypto.js";
-const SESSION_KEY = "lchat_session"; // where chat.js keeps the signed-in session
+const SESSION_KEY = "lchat_session"; // where shell.js keeps the signed-in session
// localStorage keys. localStorage is already per-origin, so "enrolled" naturally
// means "this instance is backed up" without us keying by origin ourselves.
@@ -60,7 +60,7 @@ function snoozedUntil() {
return Number.isFinite(n) ? n : 0;
}
-// The group name rides along for the home server's confirmation UI. chat.js puts
+// The group name rides along for the home server's confirmation UI. shell.js puts
// the (substituted) group name in the page title; fall back to the host.
function groupName() {
const t = (document.title || "").trim();
diff --git a/web/static/format.js b/web/static/format.js
index 3a07e94..fa7a5e7 100644
--- a/web/static/format.js
+++ b/web/static/format.js
@@ -1,5 +1,5 @@
// Text formatting helpers. Pure functions so they can be unit-tested in Node;
-// the DOM rendering that consumes these lives in chat.js.
+// the DOM rendering that consumes these lives in shell.js.
// Matches http(s):// URLs, bare www. hosts, and scheme-less "host.tld" domains.
// The bare case is validated against a TLD allowlist (see linkHref) so file names
@@ -96,7 +96,7 @@ function formatSegment(text, out) {
// { text, href } → a link (http(s):// only) — never formatted inside
// { text, mark } → formatted run; mark ∈ b | i | s | code
// Links take precedence (we don't format inside a URL). Pure + flat (no nesting)
-// so chat.js builds DOM with text nodes + a single wrapper element each — no
+// so shell.js builds DOM with text nodes + a single wrapper element each — no
// innerHTML, so it stays XSS-safe.
export function richTokens(text) {
const tokens = [];
@@ -115,7 +115,7 @@ export function richTokens(text) {
// (still linkified + inline-formatted)
// Blank lines butting against a code/quote block are dropped — the blocks
// carry their own spacing, so the delimiter newlines shouldn't render too.
-// Pure + flat so chat.js builds <pre>/<blockquote> wrappers with no innerHTML.
+// Pure + flat so shell.js builds <pre>/<blockquote> wrappers with no innerHTML.
const FENCE_RE = /^\s*```(.*)$/;
const QUOTE_RE = /^\s*> ?(.*)$/;
@@ -166,7 +166,7 @@ export function blockTokens(text) {
}
// The first http(s) link in a message, skipping fenced code blocks (a URL
-// pasted as code shouldn't sprout a preview card). Used by chat.js to decide
+// pasted as code shouldn't sprout a preview card). Used by shell.js to decide
// which URL, if any, gets an OpenGraph unfurl.
export function firstLink(text) {
for (const b of blockTokens(text)) {
@@ -180,7 +180,7 @@ export function firstLink(text) {
// Parse a YouTube URL into { id, start } (start in seconds, 0 if none), or null
// for anything that isn't a YouTube video link. Covers the shapes people paste:
// watch?v=, youtu.be/, /shorts/, /live/, /embed/, with t= / start= offsets
-// (plain seconds, "90s", or "1h2m3s"). Pure so chat.js's click-to-play embed
+// (plain seconds, "90s", or "1h2m3s"). Pure so shell.js's click-to-play embed
// card stays unit-testable; the 11-char id alphabet is YouTube's own.
const YT_ID = /^[A-Za-z0-9_-]{11}$/;
export function youtubeId(url) {
@@ -217,7 +217,7 @@ export function youtubeId(url) {
// Build a short, plain-text preview of a message for a reply quote. Collapses
// whitespace and truncates long text; when a message has no text (e.g. a photo
// only), falls back to a label for its first attachment so the quote still says
-// something. Pure so the reply UI in chat.js stays testable.
+// something. Pure so the reply UI in shell.js stays testable.
export function replySnippet(payload, max = 140) {
const clamp = (s) => (s.length > max ? s.slice(0, max - 1).trimEnd() + "…" : s);
const text = (payload && payload.text ? String(payload.text) : "").replace(/\s+/g, " ").trim();
@@ -236,7 +236,7 @@ export function replySnippet(payload, max = 140) {
// and bubble-less (à la Messages). Grapheme-segmented so ZWJ families (👨‍👩‍👧‍👦),
// skin-tone modifiers (👍🏽) and flags (🇬🇧) each count as one. Any non-emoji,
// non-whitespace grapheme (a letter, a digit, punctuation) means it's normal
-// text, not pure emoji. Pure so the render path in chat.js stays testable.
+// text, not pure emoji. Pure so the render path in shell.js stays testable.
const EMOJI_GRAPHEME = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u;
export function emojiOnlyCount(text) {
const s = (text == null ? "" : String(text)).trim();
@@ -252,7 +252,7 @@ export function emojiOnlyCount(text) {
// Summarise who's currently typing into a subtle one-liner. Past two names it
// collapses to "Several people…" so the line never grows unbounded. Pure so the
-// presence UI in chat.js stays testable; the caller renders it with textContent
+// presence UI in shell.js stays testable; the caller renders it with textContent
// (the names are attacker-controlled).
export function typingSummary(names) {
const list = Array.isArray(names) ? names.filter(Boolean) : [];
@@ -270,7 +270,7 @@ export function typingSummary(names) {
// [A, B] → "A & B"
// [A, B, C] → "A, B & C"
// [A, B, C, D, …] → "A, B & N more" (N = names.length - 2)
-// Pure so the header/list rendering in chat.js stays testable.
+// Pure so the header/list rendering in shell.js stays testable.
export function autoGroupTitle(names) {
const list = Array.isArray(names) ? names.filter(Boolean) : [];
const n = list.length;
diff --git a/web/static/push.js b/web/static/push.js
index e9cd25e..764840f 100644
--- a/web/static/push.js
+++ b/web/static/push.js
@@ -1,4 +1,4 @@
-// Web Push helpers, driven by the chat page's onboarding flow (see chat.js).
+// Web Push helpers, driven by the chat page's onboarding flow (see shell.js).
export function idbSet(key, value) {
return new Promise((resolve, reject) => {
@@ -53,7 +53,7 @@ export function pushDenied() {
let swReg = null;
// Register the service worker. The per-thread keys it needs to decrypt push
-// payloads are written to IndexedDB separately by chat.js (idbSet "threadKeys").
+// payloads are written to IndexedDB separately by shell.js (idbSet "threadKeys").
// Best-effort; keeps any existing subscription in sync.
export async function registerSW(session) {
if (!("serviceWorker" in navigator)) return;
diff --git a/web/static/chat.js b/web/static/shell.js
similarity index 74%
rename from web/static/chat.js
rename to web/static/shell.js
index 5883949..829001f 100644
--- a/web/static/chat.js
+++ b/web/static/shell.js
@@ -493,9 +493,6 @@ async function boot() {
// Reveal only once the initial feed is loaded + scrolled — so the first paint
// is the finished layout, not an empty feed that then jumps to the bottom.
await opened;
- // With the open thread in place, warm the other visible chats in the background
- // so the first tap into any of them is instant (no fetch flash).
- prebuildVisibleThreads();
} catch (e) {
console.error(e);
app.innerHTML =
@@ -558,7 +555,7 @@ async function bootEmbed() {
}
// The bridge the native composer drives: forward typed text and staged photos into
-// the one crypto/WebSocket path (chat.js). Mirrors the hooks ChatView's WebBridge
+// the one crypto/WebSocket path (shell.js). Mirrors the hooks ChatView's WebBridge
// calls; staged-attachment state + errors are posted back on the "lchat" handler.
function installNativeBridge() {
window.lchatNative = {
@@ -568,11 +565,7 @@ function installNativeBridge() {
sendText(text) {
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
if (chatAppNative) return chatAppNative.sendText(text);
- const t = (text || "").trim();
- if (!t && !pending.length) return false;
- sendMessage(t);
- stopTyping(); // sending means you've stopped typing (mirrors the web submit)
- return true;
+ return false;
},
// The native composer's keystrokes: beat "typing" to the thread while there's
// text, stop when it empties (mirrors the web composer's input handler).
@@ -603,7 +596,6 @@ function installNativeBridge() {
// (cancelReply posts {replying: null} back).
cancelReply() {
if (chatAppNative) return chatAppNative.cancelReply();
- cancelReply();
},
// The native edit banner's ✕ — drops edit mode (cancelEdit posts
// {editing: null} back so the native banner + prefilled composer clear).
@@ -1167,257 +1159,6 @@ function renderApp() {
leaveGroup();
};
- const text = document.getElementById("text");
- const sendBtn = document.getElementById("send-btn");
- const composer = document.getElementById("composer");
- // Touch: while the keyboard is up the composer sits above it, so the home-indicator
- // safe-area inset below would just be dead space — drop it while focused.
- text.addEventListener("focus", () => composer.classList.add("editing"));
- text.addEventListener("blur", () => composer.classList.remove("editing"));
- // Tapping the conversation dismisses the keyboard (it otherwise stays up across sends).
- const feedForBlur = document.getElementById("feed");
- if (feedForBlur)
- feedForBlur.addEventListener("pointerdown", () => {
- if (document.activeElement === text) text.blur();
- });
- // Keep the feed pinned to the bottom when its viewport resizes. The stack
- // below it — autogrowing textarea, :emoji/@mention typeahead, reply/edit
- // banners, the keyboard inset — sits in normal flow, so any of it growing
- // shrinks the feed *without firing a scroll event*, leaving the chat wedged
- // not-quite-at-bottom while you type. `feedAtBottom` is refreshed on scroll,
- // so at resize time it reflects where the user was before the change; the
- // re-pin fires its own scroll event, which keeps the flag true.
- if (feedForBlur) {
- feedForBlur.addEventListener(
- "scroll",
- () => {
- feedAtBottom = feedForBlur.scrollHeight - feedForBlur.scrollTop - feedForBlur.clientHeight < 80;
- },
- { passive: true },
- );
- new ResizeObserver(() => {
- if (!feedAtBottom) return;
- feedForBlur.scrollTop = feedForBlur.scrollHeight;
- // WebKit restores the pre-resize scroll position *after* observers run,
- // undoing the line above (Chrome doesn't). Re-assert on the next frame,
- // once its restoration has happened — unconditionally: the was-at-bottom
- // decision was made above, and the restoration's own scroll event may
- // have already pushed the gap past the threshold (e.g. the 216px
- // typeahead), flipping feedAtBottom false. A user can't scroll within
- // the same frame, so this never fights a real scroll-away.
- requestAnimationFrame(() => {
- feedForBlur.scrollTop = feedForBlur.scrollHeight;
- });
- }).observe(feedForBlur);
- }
- // A tap on the composer's own blank padding must never close the keyboard.
- // The .send-btn::after hit extension is clipped by the field's overflow:hidden,
- // so the gutter just right of Send falls through to here — treat a near-miss
- // at Send's height as a send; anything else only keeps focus where it is.
- composer.addEventListener("pointerdown", (e) => {
- if (e.target !== composer) return;
- e.preventDefault(); // don't blur the field
- if (sendBtn.hidden || sendBtn.disabled) return;
- const r = sendBtn.getBoundingClientRect();
- if (e.clientX >= r.left - 8 && Math.abs(e.clientY - (r.top + r.height / 2)) <= r.height) {
- sendBtn.click();
- }
- });
- text.addEventListener("input", () => {
- updateSendVisibility();
- // Autogrow, keeping the feed pinned. The height:auto + scrollHeight-read
- // dance forces an intermediate layout with a *shorter* textarea — the feed
- // is momentarily taller there, and WebKit clamps its scrollTop down during
- // that layout (every keystroke), which is what used to strand the chat a
- // line short of the bottom while typing. Judge "was at bottom" before the
- // dance, re-pin after: the last write in the turn wins.
- const feedEl = document.getElementById("feed");
- const atBottom = feedEl && feedEl.scrollHeight - feedEl.scrollTop - feedEl.clientHeight < 80;
- text.style.height = "auto";
- text.style.height = Math.min(text.scrollHeight, 140) + "px";
- if (atBottom) feedEl.scrollTop = feedEl.scrollHeight;
- // A "/command" in progress is private — don't announce typing for it.
- if (text.value.trim() && text.value[0] !== "/") noteTyping();
- else stopTyping();
- updateTypeahead(); // :emoji / @mention autocomplete
- });
- text.addEventListener("keydown", (e) => {
- // The typeahead grabs the nav keys first when it's open.
- if (typeahead) {
- if (e.key === "ArrowDown") return e.preventDefault(), moveTypeahead(1);
- if (e.key === "ArrowUp") return e.preventDefault(), moveTypeahead(-1);
- if (e.key === "Enter" || e.key === "Tab") return e.preventDefault(), acceptTypeahead();
- if (e.key === "Escape") return e.preventDefault(), closeTypeahead();
- }
- if (e.key === "Enter" && shouldSend(e, isCoarse)) {
- e.preventDefault();
- composer.requestSubmit();
- }
- // ↑ on an empty composer edits your most recent still-editable message (à la
- // iMessage/Slack). Only when empty so it doesn't fight cursor movement.
- if (e.key === "ArrowUp" && editing == null && text.value === "") {
- const id = lastEditableMine();
- if (id != null) {
- e.preventDefault();
- startEdit(id);
- }
- }
- // ↓ undoes an accidental ↑-to-edit: only when nothing was typed (still the
- // original message) and there's no line below the cursor to navigate to.
- if (e.key === "ArrowDown" && editing != null) {
- const original = msgData[editing]?.data?.text ?? "";
- const afterCursor = text.value.slice(text.selectionEnd);
- if (text.value === original && !afterCursor.includes("\n")) {
- e.preventDefault();
- cancelEdit();
- }
- }
- });
- // Caret moves (click / arrows) can enter or leave a token too — re-check.
- text.addEventListener("keyup", (e) => {
- if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) updateTypeahead();
- });
- text.addEventListener("click", () => updateTypeahead());
- text.addEventListener("blur", () => setTimeout(closeTypeahead, 100)); // let a tap land first
- composer.onsubmit = (e) => {
- e.preventDefault();
- if (!sendMessage(text.value)) return; // still uploading — keep what's typed
- stopTyping(); // sending means you've stopped typing this message
- text.value = "";
- text.style.height = "auto";
- sendBtn.hidden = true;
- closeTypeahead();
- if (isCoarse) text.focus(); // keep the on-screen keyboard up after sending
- };
-
- // Keyboard shortcuts: cmd-T opens message menu, cmd-R starts quick reply.
- // When the menu is open, number keys trigger reactions or actions.
- let reactionSheetKeyListener = null;
-
- function getLatestMessage(othersOnly = false) {
- const feed = document.getElementById("feed");
- if (!feed) return null;
- const messages = feed.querySelectorAll(".msg");
- if (!messages.length) return null;
- // Find the latest message, optionally filtering to exclude own messages
- for (let i = messages.length - 1; i >= 0; i--) {
- const msgId = messages[i].dataset.id;
- if (!msgId) continue;
- if (othersOnly && msgData[msgId]?.mine) continue;
- return { id: msgId, el: messages[i] };
- }
- return null;
- }
-
- function attachReactionSheetKeyHandler(msgId, wrap) {
- // Wait a tick for the sheet to be in the DOM
- setTimeout(() => {
- const sheet = document.querySelector(".msg-sheet");
- if (!sheet) return;
-
- if (reactionSheetKeyListener) {
- document.removeEventListener("keydown", reactionSheetKeyListener, true);
- reactionSheetKeyListener = null;
- }
-
- reactionSheetKeyListener = (e) => {
- const num = parseInt(e.key, 10);
- if (isNaN(num) || num < 1 || num > 6) return;
-
- e.preventDefault();
- e.stopImmediatePropagation();
- const emoji = REACTIONS[num - 1];
- if (emoji) {
- toggleReaction(msgId, emoji);
- const currentSheet = document.querySelector(".msg-sheet");
- if (currentSheet) currentSheet.remove();
- wrap.style.visibility = "";
- document.removeEventListener("keydown", reactionSheetKeyListener, true);
- reactionSheetKeyListener = null;
- }
- };
-
- // Attach to document with capture, but also to the sheet itself
- document.addEventListener("keydown", reactionSheetKeyListener, true);
- sheet.addEventListener("keydown", reactionSheetKeyListener);
- }, 0);
- }
-
- document.addEventListener("keydown", (e) => {
- // Cmd/Ctrl+T: open message menu on latest message from someone else
- if ((e.metaKey || e.ctrlKey) && e.key === "t" && !text.value) {
- e.preventDefault();
- const msg = getLatestMessage(true); // othersOnly=true
- if (msg) {
- openMessageSheet(msg.id, msg.el);
- attachReactionSheetKeyHandler(msg.id, msg.el);
- }
- }
- // Cmd/Ctrl+R: start reply to latest message (including own)
- if ((e.metaKey || e.ctrlKey) && e.key === "r" && !text.value) {
- e.preventDefault();
- const msg = getLatestMessage(false); // othersOnly=false (all messages)
- if (msg && msgData[msg.id]?.replyMeta) {
- startReply(msgData[msg.id].replyMeta);
- }
- }
- });
-
- // Attachments: pick / drag-drop / paste an image into the open thread.
- const fileInput = document.getElementById("file-input");
- document.getElementById("attach-btn").onclick = () => fileInput.click();
- fileInput.onchange = () => {
- for (const f of fileInput.files) addAttachment(f);
- fileInput.value = "";
- };
- const pane = document.getElementById("thread-view");
- const dropOverlay = document.getElementById("drop-overlay");
- // The native macOS wrapper (Eleven) intercepts Finder file-drops itself — a
- // WKWebView doesn't hand the page the dropped files on macOS. Merely *registering*
- // a web drop handler here makes WebKit claim the drop, bypassing the native path,
- // so nothing uploads. Skip the web drop listeners in the native app (html.native).
- if (!document.documentElement.classList.contains("native")) {
- pane.addEventListener("dragover", (e) => {
- e.preventDefault();
- if (dropOverlay) dropOverlay.classList.remove("hidden");
- });
- pane.addEventListener("dragleave", (e) => {
- // Hide only when the cursor truly leaves the pane — dragleave also fires when
- // crossing onto a child element, so check relatedTarget.
- if (dropOverlay && !pane.contains(e.relatedTarget)) dropOverlay.classList.add("hidden");
- });
- pane.addEventListener("drop", (e) => {
- e.preventDefault();
- if (dropOverlay) dropOverlay.classList.add("hidden");
- for (const f of e.dataTransfer.files || []) addAttachment(f);
- });
- }
- text.addEventListener("paste", (e) => {
- const item = [...(e.clipboardData?.items || [])].find((i) => i.type.startsWith("image/"));
- if (item) {
- addAttachment(item.getAsFile());
- return;
- }
- const textItem = [...(e.clipboardData?.items || [])].find((i) => i.type === "text/plain");
- if (textItem) {
- const start = text.selectionStart;
- const beforeCursor = text.value.substring(0, start);
- const currentLine = beforeCursor.split("\n").pop();
- if (/^>\s*$/.test(currentLine)) {
- e.preventDefault();
- textItem.getAsString((pastedText) => {
- const blockquoted = pastedText.split("\n").map((line, i) => i === 0 ? line : "> " + line).join("\n");
- const end = text.selectionEnd;
- const before = text.value.substring(0, start);
- const after = text.value.substring(end);
- text.value = before + blockquoted + after;
- text.selectionStart = text.selectionEnd = start + blockquoted.length;
- text.dispatchEvent(new Event("input", { bubbles: true }));
- });
- }
- }
- });
-
renderThreadList();
// Embed mode opens its one thread explicitly (bootEmbed); don't auto-pick here.
if (embed) return Promise.resolve();
@@ -1490,7 +1231,7 @@ function renderThreadList() {
for (const t of shown) {
const row = document.createElement("button");
row.className = "thread-row" + (t.id === activeId ? " active" : "");
- row.dataset.id = t.id; // lets prebuildVisibleThreads map a visible row to its thread
+ row.dataset.id = t.id;
// DM/self rows show the person's profile photo; groups use initials (group
// photos are thread-scoped — phase 2). An app-thread shows the app's glyph.
@@ -1945,68 +1686,6 @@ async function openThread(id) {
await openAppThreadView(id, myOpen, renderApp, threadApp[id] || null);
return;
}
- setAppThreadMode(false); // a normal chat: restore feed + composer
-
- // The composer is never carried between threads: a staged photo is encrypted to
- // the old thread's key, and a half-typed line belongs to the thread you left.
- clearPending();
- cancelEdit();
- cancelReply();
- const composerText = document.getElementById("text");
- if (composerText) {
- composerText.value = "";
- composerText.dispatchEvent(new Event("input"));
- }
-
- const feed = document.getElementById("feed");
- const cached = feedCache[id];
- if (cached && cached.loaded) {
- // Instant restore: re-attach the parked nodes, no fetch, no flash, scroll kept.
- bindThreadState(cached);
- feed.innerHTML = "";
- if (cached.nodes) feed.appendChild(cached.nodes);
- cached.nodes = null;
- feed.scrollTop = cached.scrollTop != null ? cached.scrollTop : feed.scrollHeight;
- // Judge at-bottom from the restored offset right away — the scroll event the
- // assignment fires is async, so without this the *previous* thread's flag
- // could leak into this thread's catch-up pin decision below.
- feedAtBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80;
- if (!isCoarse) composerText.focus();
- renderActiveSurfaces(); // repaint shared-app panels for THIS thread — #app-surface is one
- // persistent element reused across threads, so without this it would keep showing
- // whatever the previous thread last rendered until a new event happened to arrive.
- catchUpActiveMessages(); // background: append anything that arrived while it was away
- return;
- }
-
- // First open of this thread: build the feed from scratch.
- const cache = (feedCache[id] = newFeedCache());
- bindThreadState(cache);
- feed.innerHTML = "";
- try {
- const r = await (
- await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`)
- ).json();
- if (myOpen !== openSeq) return; // switched threads while loading — abandon this load
- // Build the history off-DOM, then attach it in one shot. Appending live (one
- // node per awaited decrypt) reflowed #feed on every message and left it pinned
- // to the top until the whole load finished, then snapped to the bottom — a
- // cascade of visible layout shift on first open. A detached fragment never
- // paints mid-build, so the feed appears complete, already at the latest message.
- const frag = document.createDocumentFragment();
- resetThread(id); // fold this thread's app state fresh from the loaded window
- await seedAppState(id); // snapshot + catch-up first, so state predates the window
- if (myOpen !== openSeq) return; // switched threads while seeding — abandon
- for (const m of r.messages || []) await appendMessage(id, m, false, frag, cache);
- if (myOpen !== openSeq) return; // switched away mid-build — don't attach a stale feed
- feed.appendChild(frag);
- cache.loaded = true;
- if (id === activeId) renderActiveSurfaces(); // paint app panels from the folded state
- } catch (e) {
- console.error("load messages", e);
- }
- if (myOpen === openSeq) pinFeedToBottom(feed, myOpen);
- if (!isCoarse) document.getElementById("text").focus();
}
// Pin the feed to the bottom, and keep it pinned for a short settle window as
@@ -2037,548 +1716,6 @@ function pinFeedToBottom(feed, gen) {
requestAnimationFrame(tick);
}
-const PREBUILD_MAX = 10; // cap on chats warmed on load (a safety net over "visible")
-
-// Build a thread's feed off-screen into its feedCache entry, so the *first* tap
-// into it is the instant-restore path in openThread — no fetch round-trip, no
-// flash. Idempotent, and isolated from the open thread: it writes only into this
-// thread's own cache entry (the explicit `cache` arg threaded through
-// appendMessage), never the active-thread globals, so it's safe to run in the
-// background while another chat is open.
-async function prebuildThread(id) {
- if (id === activeId || feedCache[id] || !threadKeys[id]) return;
- const cache = (feedCache[id] = newFeedCache()); // claim the slot so nothing double-builds
- await seedAppState(id); // app state from snapshot + catch-up (fold-only, no DOM)
- try {
- const r = await (
- await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`)
- ).json();
- if (feedCache[id] !== cache) return; // opened for real meanwhile — let that build win
- const frag = document.createDocumentFragment();
- for (const m of r.messages || []) await appendMessage(id, m, false, frag, cache);
- if (feedCache[id] !== cache) return; // superseded while decrypting
- cache.nodes = frag; // parked; openThread re-attaches it instantly
- cache.scrollTop = null; // null → pin to bottom on first show
- cache.loaded = true;
- } catch {
- if (feedCache[id] === cache) delete feedCache[id]; // failed — let a real open retry cleanly
- }
-}
-
-// After first paint, warm the chats visible in the list so tapping any of them is
-// instant. Only above-the-fold rows (the flash the user sees is the initial fetch),
-// one at a time at idle so it never competes with the open thread or first paint.
-function prebuildVisibleThreads() {
- const run = async () => {
- for (const id of visibleThreadIds()) await prebuildThread(id);
- };
- if (typeof requestIdleCallback === "function") requestIdleCallback(() => run());
- else setTimeout(run, 200);
-}
-
-// Thread ids whose list rows are on-screen (capped). Falls back to the top rows
-// when the list isn't laid out — e.g. mobile currently showing a thread.
-function visibleThreadIds() {
- const list = document.getElementById("thread-list");
- if (!list) return [];
- const rows = [...list.querySelectorAll(".thread-row")];
- const vh = window.innerHeight || document.documentElement.clientHeight || 0;
- const onScreen = [];
- for (const row of rows) {
- const b = row.getBoundingClientRect();
- if (b.width === 0 && b.height === 0) continue; // not laid out (hidden pane)
- if (b.bottom > 0 && b.top < vh) onScreen.push(row.dataset.id);
- }
- const ids = onScreen.length ? onScreen : rows.map((r) => r.dataset.id);
- return ids.filter(Boolean).slice(0, PREBUILD_MAX);
-}
-
-// ============================================================================
-// RETIRED — original-chat feed. DEAD, UNREACHABLE CODE (pending deletion).
-// The app engine (web/static/apps/chat.js) is the sole chat; `chatAppFor` never
-// routes here anymore. Everything from here to the "mini-apps" section below is
-// the classic `#feed` renderer + composer + message actions.
-// DO NOT edit, extend, or copy from this — all chat behaviour lives in
-// apps/chat.js. (See CLAUDE.md.) Shared helpers still used by the app host
-// (processImage, loadBlob, encryptBytes, unfurl fetch) live outside this block.
-// ============================================================================
-// `container` lets the first open build the whole history into a detached fragment
-// (see openThread) so there's no per-message reflow; live appends omit it and
-// target the real #feed. Scroll/query still resolve against #feed either way.
-async function appendMessage(threadId, m, scroll = true, container = null, cache = null) {
- // A live append (no container) must target the open thread. Off-DOM builds — the
- // first-open fragment and prebuildThread — pass a container and write into an
- // explicit cache entry, so they're allowed to run for any thread.
- if (!container && threadId !== activeId) return;
- const key = threadKeys[threadId];
- if (!key) return;
- let data;
- try {
- data = await decrypt(key, m.blob);
- } catch {
- return; // can't decrypt — skip
- }
- if (!container && threadId !== activeId) return; // switched mid-decrypt — don't bleed into the new feed
- // A mini-app event, not a chat message: fold it into the app's state and give
- // the owning app an optional line in the stream — never a chat bubble. Runs for
- // both history (folds the loaded window) and live appends.
- if (isAppType(data.type)) {
- const app = foldEvent(threadId, data, { id: m.id, ts: data.ts });
- const line = renderAppLine(app, data, { id: m.id, ts: data.ts });
- if (line) {
- const dest = container || document.getElementById("feed");
- if (dest) dest.appendChild(line);
- if (!container && scroll) {
- const f = document.getElementById("feed");
- if (f) f.scrollTop = f.scrollHeight;
- }
- }
- if (threadId === activeId) renderActiveSurfaces();
- return;
- }
- // The state maps this message registers into: an explicit entry for off-DOM
- // builds, else the active thread's — whose maps ARE the globals (bindThreadState),
- // so live/catch-up appends behave exactly as before.
- const st = cache || feedCache[threadId];
- if (!st) return;
- // Their message just arrived → they're no longer typing. Clear it immediately
- // rather than waiting out the typing-beat expiry (the guaranteed signal). A
- // background prebuild (cache set) must never touch the open thread's typing.
- if (!cache && data.name && typingUntil.delete(data.name)) renderTyping();
- const feed = document.getElementById("feed");
- const target = container || feed; // where the node lands; feed for live appends
- const mine = ownMessage(data, myUserId, session.name);
- const t = threads.find((x) => x.id === threadId);
- const wrap = document.createElement("div");
- wrap.className = "msg " + (mine ? "me" : "them");
- wrap.dataset.id = m.id; // lets a reply quote scroll back to this message
- wrap.dataset.t = data.ts || m.created_at * 1000; // drives the time separators
- wrap.dataset.sender = data.name || ""; // drives sender-name collapse
- if (data.replyTo && data.replyTo.id) wrap.dataset.replyTo = data.replyTo.id;
- if (!mine && t && t.kind === "group") {
- const nm = document.createElement("div");
- nm.className = "name";
- nm.style.display = "flex"; // center the name text with its AI/BOT badge
- nm.style.alignItems = "center";
- nm.textContent = data.name || "";
- // Badge bot senders on the name label itself, so "who's a bot" is visible
- // on every message, not just in the picker. Sender names aren't signed, so
- // this is matched against the roster's agent labels (same trust level as
- // the name shown beside it).
- const ag = roster.find((e) => e.is_agent && e.label === data.name);
- if (ag) nm.appendChild(agentTag(true, ag.kind));
- wrap.appendChild(nm);
- }
- // The bubble + its quote/image/preview + hover icons + exact time live in
- // .content, which is the positioning context for the hover affordances.
- // Reactions sit OUTSIDE it (below), so the reply/edit icons and the hover time
- // center on the bubble, not on the bubble-plus-reactions.
- const content = document.createElement("div");
- content.className = "content";
- // A reply shows a tappable quote of its parent above everything else.
- if (data.replyTo) content.appendChild(buildReplyQuote(data.replyTo));
- // Attachments render above the text bubble: images inline, other files as a
- // download chip. A text-less attachment has no bubble.
- for (const a of data.attachments || []) {
- if (a.type && a.type.startsWith("image/")) {
- const img = document.createElement("img");
- img.className = "msg-image";
- img.alt = a.name || "photo";
- // Reserve the image's final box *before* the blob decrypts. aspect-ratio
- // alone can't do it: an unloaded <img> has no intrinsic width, so with only
- // a max-width it lays out 0×0 and the bubble collapses until the photo pops
- // in — the "bubbles run into each other then jump" shift on first open. Give
- // it a definite width (the same 260×320 cap as .msg-image, scaled to the
- // real dimensions, never upscaled) so the height is reserved up front.
- if (a.w && a.h) {
- img.style.aspectRatio = `${a.w} / ${a.h}`;
- const scale = Math.min(260 / a.w, 320 / a.h, 1);
- img.style.width = Math.round(a.w * scale) + "px";
- }
- loadBlob(a.id, threadId).then((url) => {
- if (url) {
- img.src = url;
- img.onclick = () => openLightbox(url, img);
- if (scroll) feed.scrollTop = feed.scrollHeight;
- }
- });
- content.appendChild(img);
- } else {
- content.appendChild(buildFileCard(a, threadId));
- }
- }
- if (data.text) {
- const bubble = document.createElement("div");
- // A message that's nothing but a few emoji renders large and bubble-less
- // (à la Messages). Anything alongside it — an attachment — makes it a
- // normal captioned message, so it keeps its bubble.
- const hasAttachments = Array.isArray(data.attachments) && data.attachments.length > 0;
- const jumbo = !hasAttachments && emojiOnlyCount(data.text) > 0;
- bubble.className = jumbo ? "bubble jumbo-emoji" : "bubble";
- renderText(bubble, data.text);
- content.appendChild(bubble);
- const link = firstLink(data.text);
- // A YouTube link gets a click-to-play video card instead of the OG unfurl —
- // built purely from the URL, so the server never sees it (unlike unfurl).
- const yt = link && youtubeId(link);
- if (yt) content.appendChild(buildYouTubeCard(yt));
- else if (link) addLinkPreview(content, link);
- }
- // Helper-proposed one-tap actions render as chips under the bubble (both the
- // history and live paths come through here). sanitizeActions whitelists.
- const chips = sanitizeActions(data.actions);
- if (chips.length) content.appendChild(buildActionChips(chips));
- // Reply/edit affordances: desktop hover icons (reply on any message; edit on
- // your own in-window ones), touch swipe-right to reply. The press-hold /
- // double-tap picker also offers both, via the replyMeta stored in msgData.
- const replyMeta = { id: m.id, name: data.name, snippet: replySnippet(data) };
- if (isCoarse) {
- attachSwipeReply(wrap, replyMeta);
- } else {
- // Desktop: a full-height strip beside the bubble with the action icons and the
- // exact time, all revealed on hover (see .msg-actions). Full height so the
- // pointer can travel from anywhere in the bubble straight to an icon.
- const actions = document.createElement("div");
- actions.className = "msg-actions";
- actions.appendChild(buildHoverReact(m.id));
- actions.appendChild(buildHoverReply(replyMeta));
- const remaining = m.created_at * 1000 + EDIT_WINDOW_MS - Date.now();
- if (mine && data.text && remaining > 0) {
- const editBtn = buildHoverEdit(m.id);
- actions.appendChild(editBtn);
- setTimeout(() => editBtn.remove(), remaining); // drop it when the window lapses
- }
- const timeEl = document.createElement("span");
- timeEl.className = "msg-time";
- timeEl.textContent = formatTime(data.ts || m.created_at * 1000);
- actions.appendChild(timeEl);
- content.appendChild(actions);
- }
- wrap.appendChild(content);
- // Reactions: track which are mine (server's per-message flag), render the chips
- // below the bubble (outside .content), and wire the press-hold / double-tap picker.
- for (const r of m.reactions || []) {
- if (r.mine) st.myReactions.add(`${m.id}|${r.emoji}`);
- }
- renderReactions(wrap, m.id, m.reactions || [], st.myReactions);
- attachReactionGesture(wrap, m.id);
- // Count this reply against its parent, so the parent's tag can show "N Replies".
- if (data.replyTo && data.replyTo.id) bumpReplyCount(data.replyTo.id, st);
- // A persistent "N Replies" tag below the message, tappable to jump to the first
- // reply. Hidden until a reply arrives.
- const replyCount = document.createElement("div");
- replyCount.className = "reply-count";
- replyCount.hidden = true;
- replyCount.onclick = () => {
- const first = feed.querySelector(`.msg[data-reply-to="${m.id}"]`);
- if (first) scrollToMessage(first.dataset.id);
- };
- wrap.appendChild(replyCount);
- st.replyCountEls[m.id] = replyCount;
- paintReplyCount(m.id, st); // reflect replies that may already have arrived
- if (m.edited_at) markEdited(wrap); // persistent "Edited" label below the bubble
- st.msgEls[m.id] = wrap;
- st.msgData[m.id] = { mine, createdAt: m.created_at, data, replyMeta };
- if (scroll) wrap.classList.add("appear"); // live send/receive eases in; history doesn't
- // A live append lands above the in-feed typing row (someone else may still be
- // typing) so the indicator stays glued below the newest message.
- if (target === feed && typingEl && typingEl.parentNode === feed) feed.insertBefore(wrap, typingEl);
- else target.appendChild(wrap);
- refreshSeparator(wrap); // centered time separator above, if the gap warrants
- refreshSenderName(wrap); // hide a repeated "them" name within a run
- if (scroll) {
- // A live send/receive glides the feed up so the new bubble eases into view.
- // Reduced-motion falls back to an instant jump.
- if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
- feed.scrollTop = feed.scrollHeight;
- } else {
- feed.scrollTo({ top: feed.scrollHeight, behavior: "smooth" });
- }
- }
-}
-
-// A click-to-play YouTube card: thumbnail + play button, swapped for the real
-// player only on tap — so nothing loads from YouTube (beyond the thumbnail
-// image) unless someone chooses to play. The embed uses youtube-nocookie.com
-// (YouTube's reduced-tracking domain) with rel=0 (related videos limited to the
-// same channel). yt is youtubeId()'s validated { id, start } — an 11-char id
-// and integer seconds — so the URL is safe by construction.
-function buildYouTubeCard(yt) {
- const card = document.createElement("div");
- card.className = "yt-card";
- const btn = document.createElement("button");
- btn.type = "button";
- btn.className = "yt-poster";
- btn.setAttribute("aria-label", "Play video");
- const img = document.createElement("img");
- img.className = "yt-thumb";
- img.alt = "";
- img.loading = "lazy";
- img.src = `https://i.ytimg.com/vi/${yt.id}/hqdefault.jpg`;
- // A dead/private video serves a grey 120px placeholder; drop to a plain card.
- img.onload = () => {
- if (img.naturalWidth <= 120) card.classList.add("yt-bare");
- };
- img.onerror = () => card.classList.add("yt-bare");
- const play = document.createElement("span");
- play.className = "yt-play";
- play.innerHTML = ICON_PLAY; // static in-code SVG (safe)
- btn.appendChild(img);
- btn.appendChild(play);
- btn.onclick = () => {
- const frame = document.createElement("iframe");
- frame.className = "yt-frame";
- frame.src =
- `https://www.youtube-nocookie.com/embed/${yt.id}?autoplay=1&rel=0` +
- (yt.start ? `&start=${yt.start}` : "");
- frame.allow = "autoplay; encrypted-media; fullscreen; picture-in-picture";
- frame.allowFullscreen = true;
- frame.title = "YouTube video";
- btn.replaceWith(frame);
- };
- card.appendChild(btn);
- return card;
-}
-
-// Fetch an OpenGraph preview for a URL and append a card below the bubble. The
-// server briefly sees the URL to build the card (the one documented exception);
-// the card image loads from its origin (no proxy).
-async function addLinkPreview(wrap, url) {
- let pv;
- try {
- pv = await (
- await fetch(`/api/unfurl?token=${encodeURIComponent(session.token)}&url=${encodeURIComponent(url)}`)
- ).json();
- } catch {
- return;
- }
- if (!pv || !pv.ok || (!pv.title && !pv.image)) return;
- const card = document.createElement("a");
- card.className = "link-preview";
- card.href = url;
- card.target = "_blank";
- card.rel = "noopener noreferrer";
- if (pv.image) {
- const img = document.createElement("img");
- img.className = "lp-image";
- img.alt = "";
- img.src = pv.image;
- img.onerror = () => img.remove();
- card.appendChild(img);
- }
- const body = document.createElement("div");
- body.className = "lp-body";
- if (pv.title) {
- const t = document.createElement("div");
- t.className = "lp-title";
- t.textContent = pv.title;
- body.appendChild(t);
- }
- if (pv.description) {
- const d = document.createElement("div");
- d.className = "lp-desc";
- d.textContent = pv.description;
- body.appendChild(d);
- }
- const host = document.createElement("div");
- host.className = "lp-host";
- try {
- host.textContent = new URL(url).host;
- } catch {}
- body.appendChild(host);
- card.appendChild(body);
- const reactions = wrap.querySelector(".reactions");
- if (reactions) wrap.insertBefore(card, reactions);
- else wrap.appendChild(card);
-}
-
-// Append a persistent "Edited" label below a message (once).
-function markEdited(wrap) {
- if (wrap.querySelector(".edited-tag")) return;
- const e = document.createElement("div");
- e.className = "edited-tag";
- e.textContent = "Edited";
- wrap.appendChild(e);
-}
-
-// Bump (and repaint) the reply tally for a parent message as replies to it arrive.
-// `st` targets a specific thread's cache entry (prebuild / first-open); omitted, it
-// falls back to the active thread's globals.
-function bumpReplyCount(parentId, st) {
- const counts = st ? st.replyCounts : replyCounts;
- counts[parentId] = (counts[parentId] || 0) + 1;
- paintReplyCount(parentId, st);
-}
-
-function paintReplyCount(parentId, st) {
- const el = (st ? st.replyCountEls : replyCountEls)[parentId];
- if (!el) return; // parent older than the loaded window — nothing to mark
- const n = (st ? st.replyCounts : replyCounts)[parentId] || 0;
- el.textContent = n === 1 ? "1 Reply" : n + " Replies";
- el.hidden = n === 0;
-}
-
-// Render a message's reaction chips (rebuilds the row in place). "Mine" chips are
-// highlighted; tapping a chip toggles that reaction. `mineSet` targets a specific
-// thread's reaction set (prebuild / first-open); omitted, it uses the active one.
-function renderReactions(wrap, msgId, reactions, mineSet) {
- const mine = mineSet || myReactions;
- const old = wrap.querySelector(".reactions");
- if (old) old.remove();
- if (!reactions || !reactions.length) return;
- const row = document.createElement("div");
- row.className = "reactions";
- for (const r of reactions) {
- if (!r.count) continue;
- const chip = document.createElement("button");
- chip.className = "reaction-chip" + (mine.has(`${msgId}|${r.emoji}`) ? " mine" : "");
- chip.textContent = `${r.emoji} ${r.count}`;
- chip.onclick = (e) => {
- e.stopPropagation();
- toggleReaction(msgId, r.emoji);
- };
- row.appendChild(chip);
- }
- // Keep chips above the reply/edited tags even when re-rendered on a live update.
- const anchor = wrap.querySelector(".reply-count");
- if (anchor) wrap.insertBefore(row, anchor);
- else wrap.appendChild(row);
-}
-
-function toggleReaction(msgId, emoji) {
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
- const key = `${msgId}|${emoji}`;
- const mine = myReactions.has(key);
- if (mine) myReactions.delete(key);
- else myReactions.add(key);
- ws.send(JSON.stringify({ type: mine ? "unreact" : "react", thread_id: activeId, message_id: Number(msgId), emoji }));
-}
-
-// Open the emoji picker over a message; press-and-hold (desktop) or double-tap
-// (touch) trigger it. Tapping an emoji toggles that reaction.
-function openReactionPicker(msgId, anchor) {
- document.querySelector(".emoji-picker")?.remove();
- const pop = document.createElement("div");
- pop.className = "emoji-picker";
- for (const emoji of REACTIONS) {
- const b = document.createElement("button");
- b.className = "emoji-opt";
- b.textContent = emoji;
- b.onclick = () => {
- toggleReaction(msgId, emoji);
- pop.remove();
- };
- pop.appendChild(b);
- }
- const d = msgData[msgId];
- // Reply to any message (icon, set off from the emoji row).
- if (d && d.replyMeta) {
- const reply = document.createElement("button");
- reply.className = "picker-action";
- reply.innerHTML = ICON_REPLY; // static in-code SVG (safe)
- reply.setAttribute("aria-label", "Reply");
- reply.title = "Reply";
- reply.onclick = () => {
- startReply(d.replyMeta);
- pop.remove();
- };
- pop.appendChild(reply);
- }
- // Edit, for your own messages still within the edit window (server enforces it).
- if (d && d.mine && d.data && d.data.text && Date.now() - d.createdAt * 1000 < EDIT_WINDOW_MS) {
- const edit = document.createElement("button");
- edit.className = "picker-action";
- edit.innerHTML = ICON_EDIT; // static in-code SVG (safe)
- edit.setAttribute("aria-label", "Edit");
- edit.title = "Edit";
- edit.onclick = () => {
- startEdit(msgId);
- pop.remove();
- };
- pop.appendChild(edit);
- }
- // Delete (unsend), for your own messages — any time.
- if (d && d.mine) {
- const del = document.createElement("button");
- del.className = "picker-action";
- del.innerHTML = ICON_TRASH; // static in-code SVG (safe)
- del.setAttribute("aria-label", "Delete");
- del.title = "Delete";
- del.onclick = () => {
- pop.remove();
- if (confirm("Delete this message for everyone? This can't be undone.")) deleteMessage(msgId);
- };
- pop.appendChild(del);
- }
- // Actions mini-apps contribute (e.g. "Save link"). Each carries an emoji glyph
- // rendered as textContent — no innerHTML for anything an app supplies.
- for (const act of appMessageActions(msgId)) {
- const b = document.createElement("button");
- b.className = "picker-action";
- b.textContent = act.glyph;
- b.setAttribute("aria-label", act.label);
- b.title = act.label;
- b.onclick = () => {
- act.run();
- pop.remove();
- };
- pop.appendChild(b);
- }
- document.body.appendChild(pop);
- const r = anchor.getBoundingClientRect();
- pop.style.top = `${Math.max(8, r.top - pop.offsetHeight - 8)}px`;
- pop.style.left = `${Math.min(r.left, window.innerWidth - pop.offsetWidth - 8)}px`;
- const close = (e) => {
- if (!pop.contains(e.target)) {
- pop.remove();
- document.removeEventListener("pointerdown", close, true);
- }
- };
- setTimeout(() => document.addEventListener("pointerdown", close, true), 0);
-}
-
-// Actions mini-apps contribute for this message (e.g. Bookmarks' "Save link" on
-// any message with a link). Each: { label, glyph, run } — an emoji glyph instead
-// of an SVG icon, so the app stays self-contained. Empty unless an app opts in.
-function appMessageActions(msgId) {
- const d = msgData[msgId];
- if (!d || !d.data) return [];
- return messageActionsFor({ text: d.data.text || "", from: d.data.name || "" });
-}
-
-// The actions in the mobile long-press menu, in a sensible order: Reply, Copy, then
-// app actions (Save link…), then your-own Edit (in-window) and Delete. Each is
-// { label, icon | glyph, danger?, run }.
-function messageActions(msgId) {
- const d = msgData[msgId];
- if (!d) return [];
- const acts = [];
- if (d.replyMeta) acts.push({ label: "Reply", icon: ICON_REPLY, run: () => startReply(d.replyMeta) });
- if (d.data && d.data.text) acts.push({ label: "Copy", icon: ICON_COPY, run: () => copyMessageText(d.data.text) });
- for (const a of appMessageActions(msgId)) acts.push({ label: a.label, glyph: a.glyph, run: a.run });
- if (d.mine && d.data && d.data.text && Date.now() - d.createdAt * 1000 < EDIT_WINDOW_MS)
- acts.push({ label: "Edit", icon: ICON_EDIT, run: () => startEdit(msgId) });
- if (d.mine)
- acts.push({
- label: "Delete",
- icon: ICON_TRASH,
- danger: true,
- run: () => {
- if (confirm("Delete this message for everyone? This can't be undone.")) deleteMessage(msgId);
- },
- });
- return acts;
-}
-
-async function copyMessageText(text) {
- try {
- await navigator.clipboard.writeText(text);
- toast("Copied");
- } catch {
- toast("Couldn't copy");
- }
-}
-
// A brief status pill (reuses the #toast element).
let toastTimer;
function toast(msg) {
@@ -2590,532 +1727,6 @@ function toast(msg) {
toastTimer = setTimeout(() => t.classList.remove("show"), 1600);
}
-// Mobile long-press menu (WhatsApp-style): a dim backdrop with the emoji reactions
-// floating above the message and a labelled action menu below it.
-function openMessageSheet(msgId, wrap) {
- document.querySelector(".msg-sheet")?.remove();
- const bubble = wrap.querySelector(".bubble") || wrap;
- const brect = bubble.getBoundingClientRect(); // for horizontal alignment
- const rect = wrap.getBoundingClientRect(); // the whole message (what we lift)
- const mine = !!(msgData[msgId] && msgData[msgId].mine);
-
- const backdrop = document.createElement("div");
- backdrop.className = "msg-sheet";
-
- // A non-interactive copy of the message, lifted above the dim so it stays legible
- // while the reactions float above it and the menu below — the whole cluster is
- // repositioned vertically so both fit even when the message is at the screen edge.
- const clone = wrap.cloneNode(true);
- clone.classList.add("msg-sheet-clone");
- clone.classList.remove("appear");
- clone.style.left = rect.left + "px";
- clone.style.width = rect.width + "px";
-
- const reactions = document.createElement("div");
- reactions.className = "msg-sheet-reactions";
- for (const emoji of REACTIONS) {
- const b = document.createElement("button");
- b.className = "sheet-emoji";
- b.textContent = emoji;
- b.onclick = () => {
- toggleReaction(msgId, emoji);
- close();
- };
- reactions.appendChild(b);
- }
-
- const menu = document.createElement("div");
- menu.className = "msg-sheet-menu";
- for (const a of messageActions(msgId)) {
- const item = document.createElement("button");
- item.className = "msg-sheet-item" + (a.danger ? " danger" : "");
- const label = document.createElement("span");
- label.textContent = a.label;
- const icon = document.createElement("span");
- icon.className = "msg-sheet-icon";
- if (a.glyph) icon.textContent = a.glyph; // app action: emoji glyph (textContent)
- else icon.innerHTML = a.icon; // static in-code SVG (safe)
- item.appendChild(label);
- item.appendChild(icon);
- item.onclick = () => {
- a.run();
- close();
- };
- menu.appendChild(item);
- }
-
- backdrop.appendChild(clone);
- backdrop.appendChild(reactions);
- backdrop.appendChild(menu);
- document.body.appendChild(backdrop);
-
- const M = 8;
- const GAP = 10;
- const vw = window.innerWidth;
- const vh = window.innerHeight;
- const rH = reactions.offsetHeight;
- const mH = menu.offsetHeight;
- // Cap the lifted message so the reactions + menu always fit around it.
- const maxMsgH = Math.max(60, vh - 2 * M - rH - mH - 2 * GAP);
- const bH = Math.min(rect.height, maxMsgH);
- if (rect.height > bH) {
- clone.style.maxHeight = bH + "px";
- clone.style.overflow = "hidden";
- }
- // Keep the message near where it was, but slide it just enough that the reactions
- // fit above and the menu below.
- const minTop = M + rH + GAP;
- const maxTop = Math.max(minTop, vh - M - mH - GAP - bH);
- const msgTop = Math.max(minTop, Math.min(rect.top, maxTop));
- clone.style.top = msgTop + "px";
-
- const placeX = (el) => {
- const w = el.offsetWidth;
- const left = mine ? brect.right - w : brect.left;
- return Math.max(M, Math.min(left, vw - w - M));
- };
- reactions.style.top = msgTop - GAP - rH + "px";
- reactions.style.left = placeX(reactions) + "px";
- menu.style.top = msgTop + bH + GAP + "px";
- menu.style.left = placeX(menu) + "px";
-
- // Hide the original while its lifted copy is shown; restore on close.
- wrap.style.visibility = "hidden";
-
- function close() {
- wrap.style.visibility = "";
- backdrop.remove();
- document.removeEventListener("pointerdown", onDoc, true);
- document.removeEventListener("keydown", onEsc);
- }
- const onDoc = (e) => {
- if (!reactions.contains(e.target) && !menu.contains(e.target)) close();
- };
- const onEsc = (e) => {
- if (e.key === "Escape") {
- e.preventDefault();
- close();
- }
- };
- // Deferred so the press that opened the sheet doesn't immediately close it.
- setTimeout(() => {
- document.addEventListener("pointerdown", onDoc, true);
- document.addEventListener("keydown", onEsc);
- }, 0);
-}
-
-// Double-tap (touch) → a quick emoji reactions bar. Just the emojis: the actions
-// (reply/copy/edit/delete) live in the long-press menu. Reuses the compact picker.
-function openReactionBar(msgId, anchor) {
- document.querySelector(".emoji-picker")?.remove();
- const pop = document.createElement("div");
- pop.className = "emoji-picker";
- for (const emoji of REACTIONS) {
- const b = document.createElement("button");
- b.className = "emoji-opt";
- b.textContent = emoji;
- b.onclick = () => {
- toggleReaction(msgId, emoji);
- pop.remove();
- };
- pop.appendChild(b);
- }
- document.body.appendChild(pop);
- const r = anchor.getBoundingClientRect();
- pop.style.top = `${Math.max(8, r.top - pop.offsetHeight - 8)}px`;
- pop.style.left = `${Math.min(r.left, window.innerWidth - pop.offsetWidth - 8)}px`;
- const close = (e) => {
- if (!pop.contains(e.target)) {
- pop.remove();
- document.removeEventListener("pointerdown", close, true);
- }
- };
- setTimeout(() => document.addEventListener("pointerdown", close, true), 0);
-}
-
-// Touch: long-press → the message menu (reactions + reply/copy/edit/delete); a quick
-// double-tap → the reactions bar. Desktop keeps press-and-hold (≈450ms) opening the
-// emoji/action picker + the hover strip.
-function attachReactionGesture(wrap, msgId) {
- if (isCoarse) {
- let timer = null;
- let sx = 0;
- let sy = 0;
- let lastTap = 0;
- let longFired = false;
- const cancel = () => {
- if (timer) {
- clearTimeout(timer);
- timer = null;
- }
- };
- wrap.addEventListener(
- "touchstart",
- (e) => {
- cancel();
- longFired = false;
- if (e.touches.length !== 1) return;
- sx = e.touches[0].clientX;
- sy = e.touches[0].clientY;
- timer = setTimeout(() => {
- timer = null;
- longFired = true;
- openMessageSheet(msgId, wrap);
- }, 500);
- },
- { passive: true }
- );
- wrap.addEventListener(
- "touchmove",
- (e) => {
- if (!timer) return;
- const t = e.touches[0];
- // A drag (swipe-to-reply or a scroll) isn't a long-press.
- if (Math.abs(t.clientX - sx) > 10 || Math.abs(t.clientY - sy) > 10) cancel();
- },
- { passive: true }
- );
- wrap.addEventListener("touchend", () => {
- const wasLong = longFired;
- cancel();
- if (wasLong) {
- lastTap = 0; // the long-press already opened the menu — not a tap
- return;
- }
- const now = Date.now();
- if (now - lastTap < 300) {
- lastTap = 0;
- openReactionBar(msgId, wrap); // second quick tap
- } else {
- lastTap = now;
- }
- });
- wrap.addEventListener("touchcancel", () => {
- cancel();
- lastTap = 0;
- });
- } else {
- let timer = null;
- const cancel = () => {
- clearTimeout(timer);
- timer = null;
- };
- wrap.addEventListener("mousedown", () => {
- timer = setTimeout(() => openReactionPicker(msgId, wrap), 450);
- });
- wrap.addEventListener("mouseup", cancel);
- wrap.addEventListener("mouseleave", cancel);
- wrap.addEventListener("mousemove", cancel);
- }
-}
-
-// ---- replies ------------------------------------------------------------
-// The reply link rides inside the encrypted payload (payload.replyTo =
-// {id, name, snippet}); the server never learns who replied to whom.
-
-// Scroll to the replied-to message and flash it, if it's still loaded.
-function scrollToMessage(id) {
- const target = document.querySelector(`.msg[data-id="${id}"]`);
- if (!target) return;
- target.scrollIntoView({ behavior: "smooth", block: "center" });
- target.classList.remove("flash");
- void target.offsetWidth; // restart the animation if it's still mid-flash
- target.classList.add("flash");
-}
-
-// A tappable quote of the message being replied to, shown above the reply.
-function buildReplyQuote(rt) {
- const q = document.createElement("button");
- q.type = "button";
- q.className = "reply-quote";
- const who = document.createElement("div");
- who.className = "reply-quote-name";
- who.textContent = rt.name || "";
- const txt = document.createElement("div");
- txt.className = "reply-quote-text";
- txt.textContent = rt.snippet || "";
- q.appendChild(who);
- q.appendChild(txt);
- q.onclick = () => scrollToMessage(rt.id);
- return q;
-}
-
-// Desktop-only: a reply icon revealed beside the bubble on hover, so reply is
-// discoverable without the press-and-hold. (Touch uses swipe-right instead.)
-function buildHoverReply(meta) {
- const b = document.createElement("button");
- b.type = "button";
- b.className = "reply-hover";
- b.innerHTML = ICON_REPLY;
- b.setAttribute("aria-label", "Reply");
- b.onclick = (e) => {
- e.stopPropagation();
- startReply(meta);
- };
- return b;
-}
-
-// Desktop-only edit icon, mirroring buildHoverReply — only for your own in-window
-// messages, so editing is discoverable without the press-and-hold picker.
-function buildHoverEdit(id) {
- const b = document.createElement("button");
- b.type = "button";
- b.className = "edit-hover";
- b.innerHTML = ICON_EDIT;
- b.setAttribute("aria-label", "Edit");
- b.onclick = (e) => {
- e.stopPropagation();
- startEdit(id);
- };
- return b;
-}
-
-// Desktop-only react icon beside the reply icon; opens the emoji picker anchored
-// to itself.
-function buildHoverReact(id) {
- const b = document.createElement("button");
- b.type = "button";
- b.className = "react-hover";
- b.innerHTML = ICON_REACT;
- b.setAttribute("aria-label", "React");
- b.onclick = (e) => {
- e.stopPropagation();
- openReactionPicker(id, b);
- };
- return b;
-}
-
-// Swipe a message to the right (touch) to reply — à la Messages/WhatsApp. The
-// message follows the finger; release past the threshold opens the reply.
-// touch-action: pan-y (CSS) keeps vertical scrolling native while we take the
-// horizontal drag; a small reply arrow fades in as you drag.
-function attachSwipeReply(el, meta) {
- const TRIGGER = 56;
- const MAX = 80;
- let startX = 0,
- startY = 0,
- dx = 0,
- mode = "", // "" undecided, "swipe", or "no" (vertical/other)
- icon = null,
- raf = 0;
-
- // Write the transform once per frame (rAF-batched) using translate3d, which
- // keeps the bubble on a GPU compositor layer — no per-move repaint of the
- // subtree, so the drag stays smooth even in the iOS webview.
- const render = () => {
- raf = 0;
- el.style.transform = "translate3d(" + dx + "px,0,0)";
- if (icon) icon.style.opacity = Math.min(1, dx / TRIGGER);
- };
- const schedule = () => {
- if (!raf) raf = requestAnimationFrame(render);
- };
-
- const cleanup = (animate) => {
- if (raf) {
- cancelAnimationFrame(raf);
- raf = 0;
- }
- if (icon) {
- icon.remove();
- icon = null;
- }
- if (animate) {
- el.style.transition = "transform 0.18s ease";
- el.style.transform = "translate3d(0,0,0)";
- const done = () => {
- el.style.transition = "";
- el.style.transform = "";
- el.style.willChange = "";
- el.removeEventListener("transitionend", done);
- };
- el.addEventListener("transitionend", done);
- } else {
- el.style.transition = "";
- el.style.transform = "";
- el.style.willChange = "";
- }
- };
-
- el.addEventListener("pointerdown", (e) => {
- if (e.pointerType !== "touch") return;
- startX = e.clientX;
- startY = e.clientY;
- dx = 0;
- mode = "";
- });
- el.addEventListener("pointermove", (e) => {
- if (e.pointerType !== "touch") return;
- const ax = e.clientX - startX;
- const ay = e.clientY - startY;
- if (mode === "") {
- if (Math.abs(ax) < 10 && Math.abs(ay) < 10) return;
- mode = ax > 0 && Math.abs(ax) > Math.abs(ay) * 1.3 ? "swipe" : "no";
- if (mode === "swipe") {
- el.style.willChange = "transform"; // promote the bubble to its own layer for the drag
- icon = document.createElement("div");
- icon.className = "swipe-reply-icon";
- icon.innerHTML = ICON_REPLY;
- el.appendChild(icon);
- }
- }
- if (mode !== "swipe") return;
- dx = Math.max(0, Math.min(ax, MAX));
- schedule();
- });
- const onEnd = (e) => {
- if (e.pointerType && e.pointerType !== "touch") return;
- if (mode === "swipe") {
- const trigger = dx >= TRIGGER;
- cleanup(true);
- if (trigger) startReply(meta);
- }
- mode = "";
- };
- el.addEventListener("pointerup", onEnd);
- el.addEventListener("pointercancel", onEnd);
-}
-
-// Begin replying: stage the parent and show a cancelable banner above the
-// composer. Replying and editing are mutually exclusive.
-function startReply(meta) {
- if (!meta) return;
- cancelEdit();
- replyingTo = meta;
- const feed = document.getElementById("feed");
- const atBottom = feed && feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80;
- const box = document.getElementById("composer-reply");
- box.innerHTML = "";
- const bar = document.createElement("div");
- bar.className = "reply-bar";
- const icon = document.createElement("span");
- icon.className = "reply-bar-icon";
- icon.innerHTML = ICON_REPLY;
- const body = document.createElement("div");
- body.className = "reply-bar-body";
- const who = document.createElement("div");
- who.className = "reply-bar-name";
- who.textContent = "Replying to " + (meta.name || "");
- const txt = document.createElement("div");
- txt.className = "reply-bar-text";
- txt.textContent = meta.snippet || "";
- body.appendChild(who);
- body.appendChild(txt);
- const x = document.createElement("button");
- x.type = "button";
- x.className = "reply-bar-cancel";
- x.innerHTML = ICON_X;
- x.setAttribute("aria-label", "Cancel reply");
- x.onclick = cancelReply;
- bar.appendChild(icon);
- bar.appendChild(body);
- bar.appendChild(x);
- box.appendChild(bar);
- box.classList.remove("hidden");
- if (atBottom && feed) feed.scrollTop = feed.scrollHeight;
- // The native shell draws its own reply banner (this one is display:none in
- // embed mode) and focuses its own composer.
- if (embed) nativePost({ replying: { name: meta.name || "", snippet: meta.snippet || "" } });
- else document.getElementById("text").focus();
- // ESC key cancels reply but preserves text
- const replyEscHandler = (e) => {
- if (e.key === "Escape" && replyingTo) {
- e.preventDefault();
- cancelReply();
- }
- };
- document.addEventListener("keydown", replyEscHandler);
- // Store handler so it can be removed if a new reply starts or edit begins
- replyEscHandler._bound = true;
- cancelReply._escHandler = replyEscHandler;
-}
-
-function cancelReply() {
- if (cancelReply._escHandler) {
- document.removeEventListener("keydown", cancelReply._escHandler);
- cancelReply._escHandler = null;
- }
- replyingTo = null;
- const box = document.getElementById("composer-reply");
- if (box) {
- box.innerHTML = "";
- box.classList.add("hidden");
- }
- if (embed) nativePost({ replying: null });
-}
-
-// Returns true when the composer should be reset (message sent, or nothing to
-// send), and false when it should be left intact — i.e. an attachment is still
-// uploading, so the caller keeps the typed text rather than wiping it.
-// Render a command's private "only visible to you" reply into the original feed
-// (a client-only row — never sent, gone on reload). The chat mini-app has its
-// own copy against .chatapp-feed.
-function showLocalNotice(response) {
- const feed = document.getElementById("feed");
- if (!feed) return;
- feed.appendChild(buildLocalNotice(response));
- feed.scrollTop = feed.scrollHeight;
-}
-
-function sendMessage(raw) {
- const text = emojify((raw || "").trim()); // :tada: → 🎉 before encrypting
- // Can't send right now (no open thread, or the socket is down / reconnecting) —
- // return false so the composer keeps what's typed rather than clearing it. The
- // send button is greyed meanwhile, but Enter can still land here.
- if (!activeId || !ws || ws.readyState !== WebSocket.OPEN) return false;
-
- // A slash command (e.g. "/todo buy milk", or an unknown "/foo"): handle it
- // instead of sending a chat message, answering in place with a private "only
- // visible to you" reply appended to this feed. Skipped while editing so you can
- // still edit a message whose text starts with "/".
- if (editing == null && runCommand(text, activeId, showLocalNotice)) return true;
-
- // Editing an existing message: re-encrypt the original payload with new text
- // (preserving its name + any attachments) and send an edit.
- if (editing != null) {
- // Capture the id before cancelEdit() (below) resets `editing` to null — the
- // encrypt().then() send is async, so reading `editing` inside it would race
- // and ship message_id: null, which the server drops.
- const id = editing;
- const orig = msgData[id] && msgData[id].data;
- if (text && orig) {
- encrypt(threadKeys[activeId], { ...orig, text }).then((blob) => {
- ws.send(JSON.stringify({ type: "edit", thread_id: activeId, message_id: id, blob }));
- });
- }
- cancelEdit();
- return true;
- }
-
- // Don't send while any attachment is still uploading — the send button is
- // disabled meanwhile, but Enter can still reach here. Bail so the typed text
- // is kept; the user sends once every chip resolves.
- if (pending.some((p) => p.loading)) return false;
- if (!text && !pending.length) return true;
- // The sender's user id rides inside the ciphertext (never as server metadata):
- // it's what lets your other devices recognise the message as yours.
- const payload = { name: session.name, text, ts: Date.now() };
- if (myUserId) payload.uid = myUserId;
- if (pending.length) {
- // The name/type/size ride inside the encrypted payload; strip local-only fields.
- payload.attachments = pending.map((a) => {
- const out = { id: a.id, name: a.name, type: a.type };
- if (a.w) out.w = a.w;
- if (a.h) out.h = a.h;
- if (a.size) out.size = a.size;
- return out;
- });
- }
- // Carry the reply link inside the encrypted payload (the server stays blind).
- if (replyingTo) payload.replyTo = replyingTo;
- clearPending();
- cancelReply();
- encrypt(threadKeys[activeId], payload).then((blob) => {
- ws.send(JSON.stringify({ type: "msg", thread_id: activeId, blob }));
- });
- return true;
-}
-
// ---- mini-apps ----------------------------------------------------------
// Mini-app events ride the exact same encrypted-message path as chat: the app
// only produces a typed payload, and the transport wraps it with the same
@@ -3764,32 +2375,6 @@ function lastEditableMine() {
return best;
}
-// Enter edit mode: load the message text into the composer with an "Editing" banner.
-function startEdit(msgId) {
- const d = msgData[msgId];
- if (!d || !d.data) return;
- cancelReply(); // editing and replying are mutually exclusive
- editing = msgId;
- const text = document.getElementById("text");
- text.value = d.data.text || "";
- text.dispatchEvent(new Event("input")); // resize + reveal send button
- text.focus();
- const banner = document.getElementById("composer-edit");
- banner.textContent = "Editing message";
- const x = document.createElement("button");
- x.className = "banner-x";
- x.type = "button";
- x.textContent = "×";
- x.onclick = cancelEdit;
- banner.appendChild(x);
- banner.classList.remove("hidden");
- // Native shell: the web composer + banner above are display:none in embed mode,
- // so hand the original text to the native composer and let it show its own
- // "Editing" banner (mirrors the reply bridge). A native send then hits the
- // editing path in sendMessage just like the web composer would.
- if (embed) nativePost({ editing: { text: d.data.text || "" } });
-}
-
function cancelEdit() {
editing = null;
const banner = document.getElementById("composer-edit");
@@ -3980,41 +2565,6 @@ async function loadBlob(id, threadId = activeId, type) {
}
}
-// A download chip for a non-image attachment: icon + name + size. The bytes are
-// fetched + decrypted lazily; clicking downloads the file under its real name.
-// textContent only — never innerHTML on the attacker-controlled name.
-function buildFileCard(a, threadId) {
- const card = document.createElement("a");
- card.className = "file-chip";
- card.href = "#";
- card.download = a.name || "file";
- const icon = document.createElement("span");
- icon.className = "file-icon";
- icon.textContent = fileIcon(a.name);
- const meta = document.createElement("span");
- meta.className = "file-meta";
- const nm = document.createElement("span");
- nm.className = "file-name";
- nm.textContent = a.name || "file";
- meta.appendChild(nm);
- if (a.size) {
- const sz = document.createElement("span");
- sz.className = "file-sub";
- sz.textContent = formatBytes(a.size);
- meta.appendChild(sz);
- }
- card.appendChild(icon);
- card.appendChild(meta);
- // Don't follow the placeholder "#" href if tapped before the blob is ready.
- card.addEventListener("click", (e) => {
- if (card.getAttribute("href") === "#") e.preventDefault();
- });
- loadBlob(a.id, threadId, a.type).then((url) => {
- if (url) card.href = url;
- });
- return card;
-}
-
function openLightbox(url, source) {
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const back = document.createElement("div");
@@ -4444,11 +2994,11 @@ function showConnStatus(text) {
async function catchUpActiveMessages() {
const id = activeId;
if (!id || !threadKeys[id]) return;
- // A chat-app thread catches up through the fold: re-fetch the window, fold
- // anything unseen (per-message-id dedupe makes the rest no-ops), re-render.
- // Without this, messages missed while the socket was down would only appear
- // after leaving and re-entering the thread.
- if (chatAppFor(id)) {
+ // An app-rendered thread (the chat app, or a personal app-thread) catches up
+ // through the fold: re-fetch the window, fold anything unseen (per-message-id
+ // dedupe makes the rest no-ops), re-render. Without this, messages missed while
+ // the socket was down would only appear after leaving and re-entering the thread.
+ if (activeAppFor(id)) {
try {
const r = await (
await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`)
@@ -4461,27 +3011,6 @@ async function catchUpActiveMessages() {
}
return;
}
- try {
- const r = await (
- await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}`)
- ).json();
- if (id !== activeId) return; // switched threads while fetching — abandon
- const feed = document.getElementById("feed");
- let added = 0;
- for (const m of r.messages || []) {
- if (msgEls[m.id]) continue; // already rendered (live or from the initial load)
- await appendMessage(id, m, false);
- added++;
- }
- // Jump to the newest when we appended something — and also when the user
- // was last at the bottom: a message that live-appended while rendering was
- // suspended (hidden tab, parked webview) never finished its smooth scroll,
- // leaving the feed shy of the newest with nothing left to append. Someone
- // reading history is never yanked down (feedAtBottom is false for them).
- if ((added || feedAtBottom) && id === activeId && feed) pinFeedToBottom(feed, openSeq);
- } catch (e) {
- console.error("catch up messages", e);
- }
}
// Catch up on messages missed while we were offline. The WebSocket only relays
@@ -4597,9 +3126,6 @@ function connectWS() {
foldHostEvent(msg.thread_id, "chat", { type: "chat.react", msgId: msg.message_id, reactions: msg.reactions || [] });
if (msg.thread_id === activeId) renderActiveSurfaces();
}
- if (msg.thread_id === activeId && msgEls[msg.message_id]) {
- renderReactions(msgEls[msg.message_id], msg.message_id, msg.reactions || []);
- }
return;
}
if (msg.type === "edit") {
@@ -4612,21 +3138,6 @@ function connectWS() {
if (msg.thread_id === activeId) renderActiveSurfaces();
} catch {}
}
- if (msg.thread_id === activeId && msgEls[msg.id]) {
- try {
- const nd = await decrypt(threadKeys[msg.thread_id], msg.blob);
- // Re-validate after the await: a fast thread switch could have cleared
- // msgEls or changed the active thread while we were decrypting.
- if (msg.thread_id !== activeId || !msgEls[msg.id]) return;
- const bubble = msgEls[msg.id].querySelector(".bubble");
- if (bubble) {
- bubble.textContent = "";
- renderText(bubble, nd.text || "");
- markEdited(msgEls[msg.id]);
- }
- if (msgData[msg.id]) msgData[msg.id].data = nd;
- } catch {}
- }
return;
}
if (msg.type === "delete") {
@@ -4638,24 +3149,6 @@ function connectWS() {
}
// Hard-deleted upstream: drop the bubble + its tracked state. (A separator
// above it may briefly linger; harmless and gone on next thread open.)
- if (msg.thread_id === activeId && msgEls[msg.id]) {
- const next = nextMsgOf(msgEls[msg.id]);
- // Keep reply tallies honest: drop this message's tag, and if it was itself a
- // reply, decrement its parent's count.
- const parent = msgData[msg.id]?.data?.replyTo?.id;
- delete replyCountEls[msg.id];
- if (parent && replyCounts[parent]) {
- replyCounts[parent]--;
- paintReplyCount(parent);
- }
- msgEls[msg.id].remove();
- delete msgEls[msg.id];
- delete msgData[msg.id];
- if (next) {
- refreshSeparator(next);
- refreshSenderName(next);
- }
- }
return;
}
if (msg.type === "typing") {
@@ -4721,10 +3214,9 @@ function connectWS() {
foldEvent(tid, payload, { id: msg.id, ts: payload.ts });
foldAppEventBubble(tid, owner, payload, { id: msg.id, ts: payload.ts });
if (tid === activeId) renderActiveSurfaces();
- } else if (tid === activeId) {
- await appendMessage(tid, msg, true);
} else {
foldEvent(tid, payload, { id: msg.id, ts: payload.ts });
+ if (tid === activeId) renderActiveSurfaces();
}
notifyLive(tid, payload);
const pv = appPreview(payload);
@@ -4745,12 +3237,7 @@ function connectWS() {
foldHostEvent(tid, "chat", { ...payload, type: "chat.msg" }, { id: msg.id, ts: payload.ts });
if (tid === activeId) renderActiveSurfaces();
}
- if (tid === activeId) {
- // A chat-app thread already drew this message via renderActiveSurfaces()
- // above; the classic-feed appendMessage would only re-decrypt the same blob
- // and paint a phantom bubble into the hidden #feed, so skip it there.
- if (!chatAppFor(tid)) await appendMessage(tid, msg, true);
- } else {
+ if (tid !== activeId) {
unread[tid] = !ownMessage(payload, myUserId, session.name);
bumpThread(tid);
}
@@ -6358,59 +4845,6 @@ function closeTypeahead() {
}
}
-// ---- message text rendering ---------------------------------------------
-
-function escapeRegExp(s) {
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
-}
-
-// Append a plain-text run, wrapping @mentions of known members in a chip. Names
-// can contain spaces, so we match the actual roster labels (longest first)
-// rather than a generic word; a mention must begin at a boundary so emails and
-// mid-word "@" don't light up.
-function appendTextWithMentions(el, text) {
- const labels = roster.map((e) => e.label).filter(Boolean).sort((a, b) => b.length - a.length);
- if (!labels.length) {
- el.appendChild(document.createTextNode(text));
- return;
- }
- const re = new RegExp("@(?:" + labels.map(escapeRegExp).join("|") + ")(?![\\w])", "g");
- let last = 0;
- let m;
- while ((m = re.exec(text))) {
- const prev = text[m.index - 1];
- if (prev !== undefined && /[\w@.]/.test(prev)) continue; // mid-word / email → not a mention
- if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index)));
- el.appendChild(buildMention(m[0]));
- last = m.index + m[0].length;
- }
- if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
-}
-
-// A single "@Name" chip. When it resolves to a member other than you, it's
-// tappable and opens a little menu (Message them). Otherwise it's just bold text.
-function buildMention(display) {
- const entry = roster.find((e) => e.label === display.slice(1)); // strip the "@"
- const span = document.createElement("span");
- span.className = "mention";
- span.textContent = display;
- if (entry && entry.user_id !== myUserId) {
- span.classList.add("mention-link");
- span.setAttribute("role", "button");
- span.tabIndex = 0;
- const open = (e) => {
- e.preventDefault();
- e.stopPropagation();
- openMentionMenu(entry, span);
- };
- span.addEventListener("click", open);
- span.addEventListener("keydown", (e) => {
- if (e.key === "Enter" || e.key === " ") open(e);
- });
- }
- return span;
-}
-
// Small menu anchored to a tapped @mention. One action for now — message the
// person (reopen the existing DM, or start one) — with room to grow.
function openMentionMenu(entry, anchor) {
@@ -6447,45 +4881,6 @@ function openMentionMenu(entry, anchor) {
setTimeout(() => document.addEventListener("pointerdown", close, true), 0);
}
-function renderInline(el, text) {
- for (const t of richTokens(text)) {
- if (t.href) {
- const a = document.createElement("a");
- a.href = t.href;
- a.target = "_blank";
- a.rel = "noopener noreferrer";
- a.textContent = t.text;
- el.appendChild(a);
- } else if (t.mark) {
- const w = document.createElement(MARK_TAG[t.mark]);
- w.textContent = t.text;
- el.appendChild(w);
- } else {
- appendTextWithMentions(el, t.text);
- }
- }
-}
-
-// Block structure first (``` fences, > quotes), then inline formatting inside
-// everything except code, which renders verbatim. Still no innerHTML anywhere.
-function renderText(el, text) {
- for (const b of blockTokens(text)) {
- if (b.type === "code") {
- const pre = document.createElement("pre");
- const code = document.createElement("code");
- code.textContent = b.text;
- pre.appendChild(code);
- el.appendChild(pre);
- } else if (b.type === "quote") {
- const q = document.createElement("blockquote");
- renderInline(q, b.text);
- el.appendChild(q);
- } else {
- renderInline(el, b.text);
- }
- }
-}
-
// A centered modal sheet. Click the backdrop or Esc to dismiss.
function modal() {
const prevFocus = document.activeElement; // restored when the sheet closes
diff --git a/web/static/style.css b/web/static/style.css
index 223bcf2..f714c93 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -763,7 +763,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
/* mobile long-press message menu (WhatsApp-style): a dim backdrop, the emoji
reactions floating above the message, and a labelled action menu below it
- (see openMessageSheet in chat.js). */
+ (see openMessageSheet in shell.js). */
.msg-sheet {
position: fixed;
inset: 0;
@@ -908,7 +908,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.reply-bar-cancel:hover { background: var(--bubble-them); }
/* Subtle "… is typing" line. It renders INSIDE the feed, below the newest
- message (chat.js appends/removes it), not in a band between feed and
+ message (shell.js appends/removes it), not in a band between feed and
composer: a band resized the feed as it toggled — jolting the stream — and
the native shells float their composer over the page bottom, which covered
it entirely. In-stream it scrolls with the messages, like an incoming one. */
@@ -1233,14 +1233,14 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
/* admin: a labelled checkbox row (e.g. "let members invite people") */
.toggle-row { display: flex; align-items: center; gap: 10px; cursor: pointer; font-weight: 600; }
-/* BOT badge on admin People rows (chat.js has its own inline-styled agentTag) */
+/* BOT badge on admin People rows (shell.js has its own inline-styled agentTag) */
.bot-tag {
margin-left: 6px; font-size: 0.7em; font-weight: 600; letter-spacing: 0.03em;
padding: 1px 5px; border-radius: 6px; background: var(--accent, #888); color: #fff; vertical-align: middle;
}
.toggle-row input { width: 18px; height: 18px; }
-/* pending DM: waiting for an invited person to join (web/static/chat.js). While
+/* pending DM: waiting for an invited person to join (web/static/shell.js). While
waiting there's no one to talk to, so the feed + composer are hidden and a single
centered card fills the pane. It clears once the invitee joins and chats.
.app-pane is included so the chat mini-app's full-pane feed/composer (which
@@ -1543,7 +1543,7 @@ html.native .thread-head {
/* Native shell (iOS/macOS) embeds ONE thread's feed — the app draws its own
sidebar, title bar, and composer. Hide the web sidebar / thread header / composer
and let the feed fill the view (overriding the mobile one-pane-at-a-time rules).
- Gated on body.native-embed, which only chat.js sets when window.__lchatEmbed is
+ Gated on body.native-embed, which only shell.js sets when window.__lchatEmbed is
injected; browsers are unaffected. */
.native-embed .sidebar { display: none !important; }
.native-embed .thread-pane { display: flex !important; }
diff --git a/web/static/ui.js b/web/static/ui.js
index fe492a7..1851243 100644
--- a/web/static/ui.js
+++ b/web/static/ui.js
@@ -3,7 +3,7 @@
// ---- themes -------------------------------------------------------------
// Colour themes + appearance live entirely client-side (a localStorage pref,
// never the server). These pure helpers are the single source of truth for the
-// valid values + defaults; chat.js applies them and the pre-paint script in the
+// valid values + defaults; shell.js applies them and the pre-paint script in the
// page <head> mirrors this list (keep them in sync).
export const THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"];
export const APPEARANCES = ["light", "dark", "system"];