diff --git a/js/app.js b/js/app.js index 3954d30c..8c3fea39 100644 --- a/js/app.js +++ b/js/app.js @@ -3197,8 +3197,9 @@ class Application { } let relaySummary = null; + let publishResult = null; try { - const publishResult = await this.nostrService.publishVideoNote( + publishResult = await this.nostrService.publishVideoNote( publishPayload, this.pubkey, ); @@ -3219,10 +3220,31 @@ class Application { this.uploadModal.close(); } + // Optimistically insert the just-published note into the shared cache so it + // appears in the feed instantly, before the relays echo it back. Obeys the + // same dedupe/active-key rules as live ingestion, so a later relay copy + // won't duplicate it (see #20/#21). + try { + if (publishResult?.legacy) { + this.nostrService.ingestLocalVideoEvent(publishResult.legacy); + } + } catch (error) { + devLogger.warn( + "[Application] Optimistic feed insert after publishing failed:", + error, + ); + } + let loadVideosError = null; try { - await this.loadVideos(); + // Refresh whichever feed is active (For You / Explore / Recent) plus the + // subscription and channel grids — not just the main recent feed — and + // force a render so the newly injected video is shown. + await this.refreshAllVideoGrids({ + reason: "video-published", + forceMainReload: true, + }); } catch (error) { loadVideosError = error; devLogger.error( diff --git a/js/nostr/client.js b/js/nostr/client.js index 38d8993c..e7fa1fd2 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -4306,6 +4306,62 @@ export class NostrClient { ); } + // Optimistically add a just-published video event to the local caches so it + // shows in the feed immediately, without waiting for it to propagate back + // from the relays. Mirrors the per-event ingestion the live subscription and + // fetchVideosByAuthors use (convert → root created_at → tombstone guard → + // allEvents/activeMap), so it obeys the same dedupe/active-key rules — a later + // relay copy of the same event (same created_at) won't displace it, and an + // older revision can't overwrite a newer active entry. Returns the ingested + // video, or null if the event isn't a usable video note. + ingestLocalVideoEvent(rawEvent) { + if (!rawEvent || typeof rawEvent !== "object" || !rawEvent.id) { + return null; + } + + let video; + try { + video = convertEventToVideo(rawEvent); + } catch (error) { + devLogger.warn( + "[nostr] Failed to convert published event for optimistic feed insert:", + error, + ); + return null; + } + if (!video || video.invalid) { + return null; + } + + this.applyRootCreatedAt(video); + if (this.rawEvents && typeof this.rawEvents.set === "function") { + this.rawEvents.set(rawEvent.id, rawEvent); + } + + const activeKey = getActiveKey(video); + if (video.deleted) { + this.recordTombstone(activeKey, video.created_at); + return video; + } + + this.applyTombstoneGuard(video); + if (video.deleted) { + // A tombstone guard flipped it to deleted — don't surface it. + return video; + } + + this.allEvents.set(rawEvent.id, video); + this.dirtyEventIds.add(rawEvent.id); + + const existing = this.activeMap.get(activeKey); + if (!existing || video.created_at > existing.created_at) { + this.activeMap.set(activeKey, video); + this.applyRootCreatedAt(video); + } + + return video; + } + handleEvent(event) { if (!event || typeof event !== "object") return; diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 639f2713..203d915f 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -1575,6 +1575,24 @@ export class NostrService { return this.filterVideos(all, options); } + // Optimistically insert a just-published video into the shared client caches + // so the feed can render it before it round-trips through the relays. Keeps + // the author index in sync so channel/subscription grids reflect it too. + ingestLocalVideoEvent(rawEvent) { + if ( + !this.nostrClient || + typeof this.nostrClient.ingestLocalVideoEvent !== "function" + ) { + return null; + } + const video = this.nostrClient.ingestLocalVideoEvent(rawEvent); + if (video && !video.deleted && !video.invalid) { + this.markAuthorIndexDirty(); + this.emit("videos:updated", { videos: null, reason: "local-publish" }); + } + return video; + } + getActiveVideosByAuthors(authors = [], options = {}) { const candidates = ensureSet(authors); const normalizedAuthors = new Set(); diff --git a/tests/optimistic-feed-insert.test.mjs b/tests/optimistic-feed-insert.test.mjs new file mode 100644 index 00000000..fe2896bc --- /dev/null +++ b/tests/optimistic-feed-insert.test.mjs @@ -0,0 +1,112 @@ +// Optimistic feed insert after publishing (#46): a just-published video is +// added to the client's active-video cache immediately so it shows in the feed +// without waiting for the relays to echo it back — while still obeying the same +// dedupe / active-key rules as live ingestion, so a relay copy of the same +// event can't duplicate it and an older revision can't overwrite a newer one. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-optimistic-feed-insert +// given: "a freshly signed video event" +// when: "ingestLocalVideoEvent is called with it" +// then: "getActiveVideos includes it exactly once, newest-revision-wins" +// observable_outcomes: +// - "getActiveVideos() surfaces the ingested video" +// - "re-ingesting the same event does not create a duplicate active entry" +// - "an older revision of the same root does not replace a newer active one" +// - "an invalid event returns null and inserts nothing" +// determinism_controls: +// - "in-memory NostrClient + localStorage polyfill; explicit created_at values" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { NostrClient } from "../js/nostr/client.js"; + +const PUB = "f".repeat(64); + +function videoEvent({ id, root, title, createdAt }) { + return { + id, + pubkey: PUB, + kind: 30078, + created_at: createdAt, + content: JSON.stringify({ + version: 3, + title, + url: "https://cdn.example/v.mp4", + videoRootId: root, + }), + tags: [["d", root]], + }; +} + +test("a published event is surfaced in the active feed immediately", () => { + const client = new NostrClient(); + const video = client.ingestLocalVideoEvent( + videoEvent({ id: "evt-1", root: "root-1", title: "Hello", createdAt: 1000 }), + ); + + assert.ok(video, "returns the ingested video"); + const active = client.getActiveVideos(); + assert.equal(active.length, 1); + assert.equal(active[0].id, "evt-1"); + assert.equal(active[0].title, "Hello"); +}); + +test("re-ingesting the same event does not duplicate it in the feed", () => { + const client = new NostrClient(); + const evt = videoEvent({ id: "evt-1", root: "root-1", title: "Hello", createdAt: 1000 }); + client.ingestLocalVideoEvent(evt); + client.ingestLocalVideoEvent(evt); + + assert.equal(client.getActiveVideos().length, 1, "same event stays a single active entry"); +}); + +test("a newer revision of the same root replaces the active entry", () => { + const client = new NostrClient(); + client.ingestLocalVideoEvent( + videoEvent({ id: "evt-old", root: "root-1", title: "Old", createdAt: 1000 }), + ); + client.ingestLocalVideoEvent( + videoEvent({ id: "evt-new", root: "root-1", title: "New", createdAt: 2000 }), + ); + + const active = client.getActiveVideos(); + assert.equal(active.length, 1, "one active entry per root"); + assert.equal(active[0].title, "New", "newest revision wins"); +}); + +test("an older revision does NOT overwrite a newer active entry", () => { + const client = new NostrClient(); + client.ingestLocalVideoEvent( + videoEvent({ id: "evt-new", root: "root-1", title: "New", createdAt: 2000 }), + ); + // A late-arriving older copy (e.g. a stale relay echo) must not clobber it. + client.ingestLocalVideoEvent( + videoEvent({ id: "evt-old", root: "root-1", title: "Old", createdAt: 1000 }), + ); + + const active = client.getActiveVideos(); + assert.equal(active.length, 1); + assert.equal(active[0].title, "New", "older revision must not displace the newer active entry"); +}); + +test("an invalid event returns null and inserts nothing", () => { + const client = new NostrClient(); + const result = client.ingestLocalVideoEvent({ id: "bad", content: "not-json", tags: [] }); + assert.equal(result, null); + assert.equal(client.getActiveVideos().length, 0); +}); + +test("a null/garbage argument is handled without throwing", () => { + const client = new NostrClient(); + assert.equal(client.ingestLocalVideoEvent(null), null); + assert.equal(client.ingestLocalVideoEvent({}), null); + assert.equal(client.getActiveVideos().length, 0); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 0dc12b4f..e2cb65dc 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -579,12 +579,21 @@ toward freshness and looked identical. Gave each a structural identity: Rank by usage count; clicking a chip adds it to the new video's tags. ### 46. Feed auto-refreshes after posting a video (UX) -- [ ] After a video note is published, **automatically refresh the feed** so the new - video appears without a manual page reload. Hook the post-publish success (upload - modal `upload:submit` / `publishVideoNote` completion) to - `refreshAllVideoGrids`/`onVideosShouldRefresh` (optimistically inject the just- - published event so it shows instantly). Mind the feed-identity + dedupe paths (#20/ - #21) so the injected event isn't dropped or duplicated. +- [x] **DONE 2026-06-30.** `publishVideoNote` now (1) **optimistically injects** the + just-published legacy event into the shared client cache via a new + `nostrClient.ingestLocalVideoEvent(rawEvent)` (+ thin `nostrService` wrapper), so the + new video shows in the feed **instantly**, before the relays echo it back; and (2) + refreshes via the canonical `refreshAllVideoGrids({ reason: "video-published", + forceMainReload: true })` instead of a bare `loadVideos()` — so it refreshes + **whichever feed is active** (For You / Explore / Recent) plus the subscription and + channel grids, and forces a render (bypassing the load cooldown) so the injected + video is actually shown. `ingestLocalVideoEvent` mirrors live ingestion (convert → + root created_at → tombstone guard → allEvents/activeMap), so it obeys the same + dedupe/active-key rules (#20/#21): a later relay copy of the same event (same + `created_at`) won't duplicate it, and an older revision can't overwrite a newer + active entry. Tests: `tests/optimistic-feed-insert.test.mjs` (6 — surfaced instantly, + no-duplicate on re-ingest, newer-wins, older-doesn't-clobber, invalid→null, garbage + handled). ### 47. "Most Zapped" sidebar tab (trending-by-zaps) - [ ] Add a **Most Zapped** sidebar tab: like Trending (#27, view-count) but ranked by