[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:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-30 22:49:02 -04:00
1 parent 95fe88784a
commit 61e4b25de7
5 files changed
+225 -8

No files matched your search

+24 -2
View File
@@ -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(
+56
View File
@@ -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;
+18
View File
@@ -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();