eleven / ocho Public

web: delete the legacy typing pipeline — the last live-wired piece of the retired feed (#911)

The shell's dedicated-frame typing path (onTyping/renderTyping/sendTyping +
timers, 167 lines) painted into the legacy #feed, which .app-mode hides
whenever any thread is open — its output was never visible. Everything
current (chat.js, native, both bot sidecars) speaks the chat.typing
relay-only ephemeral instead; hub.go keeps relaying the old frame for
legacy-client compat. Also rights two stale comments: the native fold's
'bots use this path' (they don't anymore) and CLAUDE.md's description of
what legacy code actually remains in shell.js.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3466b009a31753ae783285eae77e8bf665c014f4 parent 3d82cac
3 files changed, +17 −180
  • CLAUDE.md +9 −7
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +3 −1
  • web/static/shell.js +5 −172
diff --git a/CLAUDE.md b/CLAUDE.md
index fea9ea7..2faf19b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -163,13 +163,15 @@ Frontend module layout:
`decryptBytes` for binary). Format is base64url/`iv(12)||ciphertext`.
- `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`,
- `sendMessage`, `renderTyping`, `toggleReaction`, `openMessageSheet`,
- `buildFileCard`, `openLightbox`, the classic composer/message-action wiring, …).
- **Ignore that code — don't read it as a reference, edit it, or extend it. All
- chat behaviour lives in `apps/chat.js`.** It is pending deletion (phase 2 of
- retiring the original chat).
+ engine. **All chat behaviour lives in `apps/chat.js`** — the sole chat
+ experience, mounted full-pane into `#app-pane` (`.app-mode` hides the shell's
+ legacy chat surfaces). ⚠️ One last residue of the retired original chat
+ remains: the always-empty `#feed` div and its per-thread parking cache
+ (`feedCache`/`stashFeed`/`bindThreadState` and the alias maps
+ `msgEls`/`msgData`/… — nothing writes them anymore). Dead-in-effect, pending
+ deletion; don't extend it or read it as a reference. (The classic renderer
+ and its typing pipeline are already deleted; `openLightbox` is NOT legacy —
+ it's the live header-avatar zoom.)
- `format.js`, `ui.js` — **pure, framework-free helpers kept separate so they're
unit-testable in Node** (`tokenizeText`, `formatBytes`, `shouldSend`).
- `store.js` — the local message store (#457): IndexedDB cache of RAW server
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 8fa4e78..cebd902 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -1102,7 +1102,9 @@ public final class ChatFeedModel: ObservableObject {
}
}
case "typing":
- // The dedicated relay frame (bots use this path) — same payload.
+ // The dedicated relay frame — legacy-client compat only; everything
+ // current (web, native, both bot sidecars) sends the chat.typing
+ // ephemeral instead. Same payload, so fold it the same way.
if let blob = frame.blob { foldTypingBlob(blob) }
case "edit":
guard let id = frame.id, let blob = frame.blob, let key else { return }
diff --git a/web/static/shell.js b/web/static/shell.js
index df79caa..ce7e447 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -52,11 +52,10 @@ import {
translateMentions,
capAllows,
} from "/static/ui.js";
-import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId } from "/static/format.js";
+import { richTokens, blockTokens, firstLink, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId } from "/static/format.js";
import { emojify, emojiMatches } from "/static/emoji.js";
import { startBootScene, finishBootScene } from "/static/bootscene.js";
import { encodeBlurhash } from "/static/blurhash.js";
-import { MESSAGE_TYPE_TYPING } from "/static/message.js";
import { createIntegrity } from "/static/integrity.js";
import {
registerFloorCommand,
@@ -821,13 +820,6 @@ const avatarURLs = {}; // user id -> decrypted profile-photo object URL (cache)
const REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
const EDIT_WINDOW_MS = 15 * 60 * 1000; // mirrors editWindow (hub.go); server enforces it
-// Typing indicators. While you're typing we re-broadcast every TYPING_INTERVAL_MS
-// so others know you're still at it; each beat tells them to keep showing it for
-// TYPING_EXPIRY_S — comfortably longer than the beat, so a single late/dropped
-// beat doesn't flicker, and once you stop the receivers expire it on their own.
-const TYPING_INTERVAL_MS = 10000;
-const TYPING_EXPIRY_S = 15;
-
// Open a burst with a centered time separator when consecutive messages are more
// than this far apart (or land on a different day), rather than stamping each one.
// An exact per-message time is still available on hover (desktop).
@@ -850,15 +842,6 @@ let editing = null; // message id being edited, or null
let replyingTo = null; // { id, name, snippet } being replied to, or null
let typeahead = null; // active composer autocomplete: { kind, start, items, index } or null
-// Typing presence (for the open thread only): sender name -> epoch ms at which
-// their "typing" lapses, plus the timers driving our own beat and the receiver
-// expiry sweep.
-const typingUntil = new Map();
-let typingSweep = null;
-let typingBeat = null;
-let typingIdle = null;
-let typingEl = null; // the in-feed "X is typing…" row (created lazily by renderTyping)
-
const app = document.getElementById("app");
// The native shell (iOS/macOS) injects window.__lchatEmbed at documentStart with
// everything needed to render ONE thread: {token, name, myUserId, threadId,
@@ -1365,11 +1348,11 @@ function installNativeBridge() {
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).
+ // text, stop when it empties. The chat app owns the beats (chat.typing,
+ // relay-only ephemeral); before it has mounted there's no one to show a
+ // beat to, so pre-mount keystrokes are simply dropped.
typing(hasText) {
- if (chatAppNative) return chatAppNative.typing(hasText);
- if (hasText) noteTyping();
- else stopTyping();
+ if (chatAppNative) chatAppNative.typing(hasText);
},
// Stage a photo picked by the native + button (base64, standard alphabet). The
// web composer only handles images; anything else is rejected with an error.
@@ -4027,8 +4010,6 @@ async function openThread(id) {
if (!isCoarse) document.getElementById("text").focus();
return;
}
- stopTyping(); // stop beating into the thread we're leaving
- resetTyping(); // clear any "X is typing…" carried over from another thread
if (activeId) {
stashFeed(activeId); // park the outgoing thread's feed before the swap
stashAppPane(activeId); // and its full-pane app root, if it was an app-thread
@@ -8473,10 +8454,6 @@ function connectWS() {
// above it may briefly linger; harmless and gone on next thread open.)
return;
}
- if (msg.type === "typing") {
- onTyping(msg);
- return;
- }
if (msg.type !== "msg") return;
const tid = msg.thread_id;
// Echo of a message we're awaiting confirmation for (the server stores before
@@ -8667,150 +8644,6 @@ function connectWS() {
};
}
-// ---- typing indicators (thread-scoped, ephemeral) -----------------------
-// The name + expiry ride encrypted inside the blob; the server is a blind relay
-// (see hub.go's "typing" case) and fans it only to the thread's other members.
-
-// Paint the typing indicator from the live set. It lives INSIDE the feed as
-// its last row (appended below the newest message) rather than in a band between
-// feed and composer: a band resized the feed as it toggled — jolting the stream —
-// and the native shells (iOS/macOS) float their composer over the page bottom,
-// which covered it entirely. In-stream it scrolls with the messages.
-//
-// DMs get an iMessage-style incoming bubble with pulsing dots — the typer can
-// only be the one other person, so a name adds nothing. Groups keep the named
-// "X is typing…" line, where knowing who matters.
-function renderTyping() {
- const names = [...typingUntil.keys()];
- if (names.length === 0) {
- if (typingEl) typingEl.remove();
- return;
- }
- const feed = document.getElementById("feed");
- if (!feed) return;
- if (!typingEl) {
- typingEl = document.createElement("div");
- typingEl.setAttribute("aria-live", "polite");
- }
- const dm = (threads.find((x) => x.id === activeId) || {}).kind === "dm";
- const label = typingSummary(names);
- // Only rebuild the contents on a real change — recreating the dots on every
- // ~10s beat would visibly restart their animation.
- if (typingEl.dataset.mode !== (dm ? "bubble" : "text") || typingEl.dataset.label !== label) {
- typingEl.dataset.mode = dm ? "bubble" : "text";
- typingEl.dataset.label = label;
- typingEl.className = dm ? "typing typing-bubble" : "typing";
- typingEl.textContent = ""; // textContent: names are untrusted
- if (dm) {
- for (let i = 0; i < 3; i++) {
- const dot = document.createElement("span");
- dot.className = "dot";
- typingEl.appendChild(dot);
- }
- const sr = document.createElement("span"); // dots say nothing to a screen reader
- sr.className = "sr-only";
- sr.textContent = label;
- typingEl.appendChild(sr);
- } else {
- typingEl.textContent = label;
- }
- }
- const wasAtBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80;
- feed.appendChild(typingEl); // (re-)appending keeps it below the newest message
- if (wasAtBottom) feed.scrollTop = feed.scrollHeight;
-}
-
-// Prune lapsed typers once a second, but only while someone's typing — the timer
-// stops itself when the set empties, so a quiet chat runs no idle interval.
-function ensureTypingSweep() {
- if (typingSweep) return;
- typingSweep = setInterval(() => {
- const now = Date.now();
- let changed = false;
- for (const [name, until] of typingUntil) {
- if (until <= now) {
- typingUntil.delete(name);
- changed = true;
- }
- }
- if (changed) renderTyping();
- if (typingUntil.size === 0) {
- clearInterval(typingSweep);
- typingSweep = null;
- }
- }, 1000);
-}
-
-// A received typing beat: ignore other threads, decrypt to learn who, drop our
-// own echo, and (re)set that sender's expiry to now + their requested duration.
-async function onTyping(msg) {
- if (msg.thread_id !== activeId) return; // only show typing for the open thread
- const key = threadKeys[msg.thread_id];
- if (!key) return;
- let payload;
- try {
- payload = await decrypt(key, msg.blob);
- } catch {
- return; // wrong key or corrupt — ignore
- }
- if (!payload || !payload.name || payload.name === session.name) return;
- const secs = Math.min(Math.max(Number(payload.expiry) || 0, 0), 60); // clamp; bad input → 0
- if (secs <= 0) {
- // expiry 0 = an explicit "stopped typing" beat — clear this sender now.
- if (typingUntil.delete(payload.name)) renderTyping();
- return;
- }
- typingUntil.set(payload.name, Date.now() + secs * 1000);
- renderTyping();
- ensureTypingSweep();
-}
-
-// Broadcast one typing beat for the open thread, encrypted with its key. expiry 0
-// is a "stopped typing" signal that clears the indicator on receivers immediately.
-async function sendTyping(expiry = TYPING_EXPIRY_S) {
- if (!ws || ws.readyState !== WebSocket.OPEN || !activeId) return;
- const key = threadKeys[activeId];
- if (!key) return;
- const blob = await encrypt(key, {
- type: MESSAGE_TYPE_TYPING,
- name: session.name,
- expiry,
- });
- ws.send(JSON.stringify({ type: "typing", thread_id: activeId, blob }));
-}
-
-function stopTyping() {
- const wasTyping = !!(typingBeat || typingIdle);
- if (typingBeat) clearInterval(typingBeat);
- if (typingIdle) clearTimeout(typingIdle);
- typingBeat = null;
- typingIdle = null;
- // If we'd been typing, tell receivers we've stopped so they clear the indicator
- // now instead of waiting out the ~15s beat expiry.
- if (wasTyping) sendTyping(0);
-}
-
-// Called on each keystroke while there's text: beat now if we weren't already,
-// then keep beating every interval until a pause stops us.
-function noteTyping() {
- if (!typingBeat) {
- sendTyping();
- typingBeat = setInterval(sendTyping, TYPING_INTERVAL_MS);
- }
- if (typingIdle) clearTimeout(typingIdle);
- typingIdle = setTimeout(stopTyping, TYPING_INTERVAL_MS);
-}
-
-// Clear the receiver-side state + UI (e.g. when switching threads).
-function resetTyping() {
- typingUntil.clear();
- if (typingSweep) {
- clearInterval(typingSweep);
- typingSweep = null;
- }
- renderTyping();
-}
-
// ---- group actions (thread kebab) ---------------------------------------
// Edit group: photo + name in one modal (mirrors editProfile). The group photo