| index 39f4b3a..855ffa8 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -46,6 +46,7 @@ import { |
| } from "/static/push.js"; |
| |
| const SESSION_KEY = "lchat_session"; |
| +const ACTIVE_KEY = "lchat_active"; // last-open thread id, so a refresh lands back in it |
| const isCoarse = matchMedia("(pointer: coarse)").matches; |
| const MAX_UPLOAD = 25 * 1024 * 1024; // matches maxUploadBytes (main.go) |
| const MAX_IMAGE_DIM = 2048; // long-edge cap; bigger images are downscaled in-browser |
| @@ -285,7 +286,7 @@ async function boot() { |
| await ensurePubkey(); |
| await loadRoster(); |
| await Promise.all([setupInstanceKey(), loadThreads()]); |
| - renderApp(); |
| + const opened = renderApp(); // paints the shell; opens the initial thread (async) |
| connectWS(); |
| registerSW(session); // best-effort; subscribes if notifications already on |
| startHeartbeat(); |
| @@ -296,14 +297,28 @@ async function boot() { |
| if (e.data && e.data.type === "open-thread" && e.data.thread) openThreadById(e.data.thread); |
| }); |
| } |
| + // 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; |
| } catch (e) { |
| console.error(e); |
| app.innerHTML = |
| `<div class="center"><div class="card"><h1>Something went wrong</h1>` + |
| `<p>Couldn't load your chats. Try reloading.</p></div></div>`; |
| + } finally { |
| + hideLoading(); |
| } |
| } |
| |
| +// Fade out and remove the full-screen loading cover (shown until the app's first |
| +// meaningful paint). Idempotent — safe to call from any entry path. |
| +function hideLoading() { |
| + const el = document.getElementById("app-loading"); |
| + if (!el) return; |
| + el.classList.add("hidden"); |
| + setTimeout(() => el.remove(), 250); // after the CSS fade |
| +} |
| + |
| // ---- data --------------------------------------------------------------- |
| |
| function ensurePubkey() { |
| @@ -326,6 +341,17 @@ function rosterLabel(userId) { |
| return e ? e.label : "Someone"; |
| } |
| |
| +// A small "BOT" tag to mark an agent in the roster picker, so it's clear before |
| +// you add it that it isn't a person. Self-contained styling (no CSS dependency). |
| +function agentTag() { |
| + const t = document.createElement("span"); |
| + t.textContent = "BOT"; |
| + t.title = "This is an agent (bot), not a person"; |
| + t.style.cssText = |
| + "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;"; |
| + return t; |
| +} |
| + |
| // The instance key is a single AES key shared by all members, used only for |
| // all-members content (profile photos). It's wrapped to each member's user |
| // pubkey (server-blind, reusing the thread-key wrap). On boot: adopt our copy if |
| @@ -741,16 +767,21 @@ function renderApp() { |
| }); |
| |
| renderThreadList(); |
| - // A notification tapped while the app was closed opens with #thread=<id> — |
| - // land straight in it (on any platform). Otherwise desktop shows the first |
| - // chat; touch stays on the list. |
| + // Choose the thread to land in, in priority order: a tapped-notification |
| + // deep-link (#thread=), the one we were last in (so a refresh reloads where you |
| + // were), else — desktop only — the first visible chat. Returns the open promise |
| + // so boot() can drop the loading cover once the feed is in place (no jump). |
| const deep = initialThreadId(); |
| + let target = null; |
| if (deep && threads.some((t) => t.id === deep)) { |
| history.replaceState(null, "", location.pathname + location.search); // consume the deep link |
| - openThread(deep); |
| - } else if (!isCoarse && threads.length) { |
| - openThread(threads[0].id); |
| + target = deep; |
| + } else { |
| + const saved = localStorage.getItem(ACTIVE_KEY); |
| + if (saved && threads.some((t) => t.id === saved) && !hidden[saved]) target = saved; |
| + else if (!isCoarse) target = (threads.find((t) => !hidden[t.id]) || {}).id || null; |
| } |
| + return target ? openThread(target) : Promise.resolve(); |
| } |
| |
| // The thread id from a deep-link fragment (#thread=<id>), or null. Kept in the |
| @@ -965,6 +996,8 @@ function refreshSenderName(el) { |
| |
| function showList() { |
| document.getElementById("tt").classList.remove("show-thread"); |
| + // Back to the list is the state to restore on refresh (mobile), not the thread. |
| + localStorage.removeItem(ACTIVE_KEY); |
| } |
| |
| // Header avatar: a group's photo, or the DM/self person's profile photo. |
| @@ -1080,6 +1113,7 @@ async function openThread(id) { |
| |
| const myOpen = ++openSeq; |
| activeId = id; |
| + localStorage.setItem(ACTIVE_KEY, id); // remember where we are, to restore on refresh |
| unread[id] = false; |
| // Opening a closed chat reopens it (locally + server, synced to your devices). |
| if (hidden[id]) { |
| @@ -2583,6 +2617,7 @@ async function closeChat() { |
| }).catch(() => {}); |
| stashFeed(id); // keep its rendered feed cached for a quick reopen |
| activeId = null; |
| + localStorage.removeItem(ACTIVE_KEY); // don't reopen a closed chat on refresh |
| renderThreadList(); |
| document.getElementById("thread-view").classList.add("hidden"); |
| document.getElementById("thread-empty").classList.remove("hidden"); |
| @@ -2698,6 +2733,7 @@ async function leaveGroup() { |
| threads = threads.filter((x) => x.id !== activeId); |
| delete threadKeys[activeId]; |
| activeId = null; |
| + localStorage.removeItem(ACTIVE_KEY); // the thread we were in is gone |
| renderThreadList(); |
| document.getElementById("thread-view").classList.add("hidden"); |
| document.getElementById("thread-empty").classList.remove("hidden"); |
| @@ -2805,6 +2841,7 @@ function showNewChat() { |
| }; |
| const nm = document.createElement("span"); |
| nm.textContent = e.label; |
| + if (e.is_agent) nm.appendChild(agentTag()); |
| row.appendChild(cb); |
| row.appendChild(nm); |
| list.appendChild(row); |
| @@ -2812,6 +2849,7 @@ function showNewChat() { |
| const b = document.createElement("button"); |
| b.className = "list-row"; |
| b.textContent = e.label; |
| + if (e.is_agent) b.appendChild(agentTag()); |
| b.onclick = () => { |
| const dm = existingDM(e.user_id); |
| if (dm) { |
| @@ -3157,6 +3195,7 @@ function parseLink(text) { |
| } |
| |
| function showNoInvite() { |
| + hideLoading(); |
| app.appendChild(tpl("noinvite-tpl")); |
| const linkInput = document.getElementById("link"); |
| const nameInput = document.getElementById("name"); |
| @@ -3217,6 +3256,7 @@ function showNoInvite() { |
| } |
| |
| function showA2HS(done) { |
| + hideLoading(); |
| app.innerHTML = ""; |
| app.appendChild(tpl("a2hs-tpl")); |
| document.getElementById("a2hs-continue").onclick = () => { |