mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[feed] Auto-refresh + optimistic insert after publishing a video (#46)
After a successful publish, the new video now appears in the feed instantly
without a manual page reload.
- Add nostrClient.ingestLocalVideoEvent(rawEvent) + a thin nostrService wrapper:
optimistically insert the just-published event into the shared active-video
cache. Mirrors live ingestion (convert -> root created_at -> tombstone guard
-> allEvents/activeMap), so it obeys the same dedupe/active-key rules — a
later relay echo of the same event won't duplicate it and an older revision
can't overwrite a newer active entry (#20/#21).
- app.publishVideoNote: inject publishResult.legacy, then refresh via the
canonical refreshAllVideoGrids({ reason: "video-published", forceMainReload:
true }) instead of a bare loadVideos() — refreshes whichever feed is active
(For You / Explore / Recent) plus subscription/channel grids and forces a
render (bypassing the load cooldown) so the injected video shows.
Tests: tests/optimistic-feed-insert.test.mjs (6).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
95fe88784a
commit
61e4b25de7
5 files changed
+225
-8
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user