| 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 |