From b3c121fa94b89f5596fb406c8a34575cb5c1b2a1 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:38:57 -0500 Subject: [PATCH] community: the inbox check belongs to the app, not to the page that happened to be open --- src/App.svelte | 4 ++ src/lib/community.svelte.js | 76 +++++++++++++++++++++++++++++++------ src/views/Community.svelte | 15 +++++--- 3 files changed, 77 insertions(+), 18 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 28420e0..e3f2073 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -37,6 +37,7 @@ refreshRecents, restoreSession, } from "./lib/state.svelte.js"; + import { startInboxWatch } from "./lib/community.svelte.js"; import { floatScroll } from "./lib/scrollbar.js"; import { initMiniBridge } from "./lib/miniBridge.svelte.js"; import { applyGlass, armGlassResizeGuard } from "./lib/glass.js"; @@ -134,6 +135,9 @@ // this way the mode can be toggled mid-drag without leaving stale state. armGlassResizeGuard(); autoConnect(); + // Sealed cargo arrives on the inbox check, and that has to keep happening + // wherever the user is — not only while the Community page is mounted. + void startInboxWatch(); refreshLibrary(); refreshPlaylists(); refreshRecents(); diff --git a/src/lib/community.svelte.js b/src/lib/community.svelte.js index a15b6b4..e64cc60 100644 --- a/src/lib/community.svelte.js +++ b/src/lib/community.svelte.js @@ -314,12 +314,12 @@ export function toggleRoomFav(name) { } // ---- boot ------------------------------------------------------------------------- -let booted = false; -/** Idempotent: the first Community mount wires the listeners and pulls the - * initial state; later mounts only refresh. */ -export async function initCommunity() { - if (!booted) { - booted = true; +let wired = false; +/** The backend's community events, wired once per session. Both the app-wide + * inbox watch and the Community page need them; whoever arrives first wires. */ +function wireEvents() { + if (!wired) { + wired = true; api.onEvent("freenet:status", (status) => { if (community.freenet) community.freenet.status = status; else void refreshFreenet(); @@ -348,10 +348,59 @@ export async function initCommunity() { if (p.status === 1 || p.status === 2) noteSeen(p.username); }); } +} + +/** Idempotent: pulls the state the Community page draws. */ +export async function initCommunity() { + wireEvents(); await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox(), refreshSpaces(), refreshOverlap()]); await refreshPersona(); } +// ---- the inbox watch --------------------------------------------------------------- +/** Everything sealed — friend requests, shares, proposal pings — arrives on one + * inbox check, and that check used to live in Community.svelte's effect. So + * nothing arrived unless that page happened to be open: a friend request sat + * unseen while you were in the Library, and the proposal toast could only fire + * somewhere it was least needed. The watch belongs to the app. + * + * App.svelte starts it once at boot. The gate is re-read every tick, so a node + * that comes up later — or an inbox published later — simply starts working + * without anything having to restart the timer. */ +const INBOX_POLL_MS = 90_000; +let inboxWatch = 0; +let polling = false; + +export async function startInboxWatch() { + if (inboxWatch) return; + wireEvents(); + inboxWatch = setInterval(() => void pollInbox(), INBOX_POLL_MS); + await refreshFreenet(); + await pollInbox(); +} + +/** One tick, and quiet by design: a node that is down or an inbox that was + * never published is the resting state of this app, not an error to show. */ +async function pollInbox() { + if (polling) return; + polling = true; + try { + // If the first read failed we have no status to gate on, and the + // status event only speaks when something changes — so try again + // rather than sit inert for the session. + if (!community.freenet) await refreshFreenet(); + await refreshInbox(); + if (!adapterConnected(community.freenet?.status)) return; + if (!community.inbox?.published) return; + await readInbox(); + } catch { + // A background poll never surfaces; the manual button is the one that + // reports. The backend logs the reason either way. + } finally { + polling = false; + } +} + // ---- native Soulseek cards --------------------------------------------------------- /** Fetch a person's native card and interests once per session, both in * flight together; errors are kept per person so the drawer can say so. */ @@ -527,12 +576,15 @@ export const publishInbox = () => community.inbox = await api.inboxPublish(); }); /** Read our inbox through the node, act on sealed cargo, prune it. */ -export const checkInbox = () => - run("inboxcheck", async () => { - community.inboxLast = await api.inboxCheck(); - await Promise.all([refreshInbox(), refreshFriends(), refreshShares()]); - return community.inboxLast; - }); +async function readInbox() { + community.inboxLast = await api.inboxCheck(); + await Promise.all([refreshInbox(), refreshFriends(), refreshShares()]); + return community.inboxLast; +} +/** The Check now button: busy state, and a failure the page can show. The + * background watch below deliberately does NOT go through this — a poll that + * fails must not stamp the shared error slot. */ +export const checkInbox = () => run("inboxcheck", readInbox); export const friendRequestByKey = (personaKey) => friendAction("friend:" + personaKey, () => api.friendRequestByKey(personaKey)); export const friendshipForKey = (key) => { const mine = community.friends.filter((f) => f.persona_key === key); diff --git a/src/views/Community.svelte b/src/views/Community.svelte index 0d53f34..a9e553f 100644 --- a/src/views/Community.svelte +++ b/src/views/Community.svelte @@ -134,6 +134,11 @@ const hue = $derived(key ? hueOf(key) : null); const freenetOn = $derived(!!community.freenet?.enabled); const nodeUp = $derived(adapterConnected(community.freenet?.status)); + // A boolean, deliberately: reading community.inbox?.published straight from + // an effect makes that effect depend on the whole record, and a check + // REPLACES the record — which re-ran the effect, which checked again. A + // derived only notifies when its value actually flips. + const inboxLive = $derived(!!community.inbox?.published); const online = $derived(app.connection === "online"); const me = $derived(app.username ?? ""); const ownLink = $derived(community.links?.own ?? null); @@ -449,14 +454,12 @@ const id = await shareImport(card); if (id) goToView("playlist", { playlistId: id, label: card.name }); } - // The inbox is polled while this page is open and the node is up: on - // arrival and every 90 s. Sealed cargo becomes friend requests and share - // cards through the same stores the PM carry feeds. + // The app polls the inbox for the whole session (startInboxWatch, in + // community.svelte.js) — arriving here only asks for a fresh read now, so + // the page opens on current state instead of waiting out the interval. $effect(() => { - if (!nodeUp || !key || !community.inbox?.published) return; + if (!nodeUp || !key || !inboxLive) return; untrack(() => void checkInbox()); - const t = setInterval(() => void checkInbox(), 90_000); - return () => clearInterval(t); }); let addKey = $state(""); // ---- spaces ------------------------------------------------------------