Promote unstable → beta: auth-surface bug pass (saved-key wipe fixes, logout re-lock, remove-profile forgets keys), cold-boot stale-while-revalidate, SW image cache + immutable upload headers, external-URL webseeds (upload+edit), reactions/channel-link/view-count fixes, DM switch clear, NIP-71 authoring UI removal

Includes:
- Auth bug pass: no-arg stored-key clear no longer wipes every saved account
  (logout + blocked-key cleanup now targeted); removing a saved profile forgets
  its nsec key + NIP-46 session; logout actually forgets the keep-unlocked key
  (previousPubkey); restoreUnlockedSigner enforces access control.
- Cold boot: access-control/trusted-seeds/list-sync no longer serialize boot when
  local caches exist (feed renders near-instantly on warm refresh).
- Media caching: SW stale-while-revalidate image cache (cross-origin thumbnails/
  avatars) + parallel-fetch fix; webp/avif/torrent immutable Cache-Control.
- External-URL posts/edits derive webseed magnets (ws= + hosted .torrent xs=).
- Fixes: like/dislike dispatch + session-actor signing, channel link in player
  modal, channel-card view counts, DMs cleared on account switch.
- UI: NIP-71 authoring sections removed from upload/edit modals (hashtags kept;
  stored NIP-71 extras preserved on save; nip71Edited false-positive fixed).
This commit is contained in:
thePR0M3TH3AN committed 2026-07-02 22:08:03 -04:00
commit 8b73565904
31 files changed
+2104 -736

No files matched your search

+8 -564
View File
@@ -57,492 +57,14 @@
</div>
</div>
<!-- NOTE: Keep this NIP-71 markup in sync with the upload and revert modals. -->
<div
class="space-y-4 rounded-lg border border-border/70 bg-overlay-black/30 p-4"
id="editNip71EventDetails"
data-nip71-section="event-details"
>
<fieldset
id="editNip71KindFieldset"
class="space-y-2"
data-nip71-fieldset="kind"
>
<legend class="text-sm font-semibold text-text">
NIP-71 event type
</legend>
<p class="text-xs text-muted">
Choose the video format. <span class="font-medium text-muted-strong">Kind 21</span> is a full video, while
<span class="font-medium text-muted-strong">Kind 22</span> is a short.
</p>
<div
class="form-control"
role="radiogroup"
aria-labelledby="editNip71KindFieldset"
>
<div class="menu sm:flex-row sm:flex-wrap sm:py-0" role="presentation">
<label class="menu__item gap-3 px-0 py-0" for="editNip71KindLong">
<input
type="radio"
name="editNip71Kind"
id="editNip71KindLong"
value="21"
checked
data-nip71-input="kind"
class="h-4 w-4 border-border bg-panel text-info focus:ring-info"
/>
<span class="text-sm text-text">Normal video (kind 21)</span>
</label>
<label class="menu__item gap-3 px-0 py-0" for="editNip71KindShort">
<input
type="radio"
name="editNip71Kind"
id="editNip71KindShort"
value="22"
data-nip71-input="kind"
class="h-4 w-4 border-border bg-panel text-info focus:ring-info"
/>
<span class="text-sm text-text">Short video (kind 22)</span>
</label>
</div>
</div>
</fieldset>
<div class="grid gap-4 md:grid-cols-2" data-nip71-grid="metadata">
<div class="space-y-2">
<label for="editNip71PublishedAt" class="block text-sm font-medium text-text">
Published at
</label>
<input
type="datetime-local"
id="editNip71PublishedAt"
data-nip71-input="published_at"
class="input"
/>
<p class="text-xs text-muted">
Set the canonical publish time for cross-client ordering.
</p>
</div>
<div class="space-y-2">
<label for="editNip71AltText" class="block text-sm font-medium text-text">
Alt text
</label>
<input
type="text"
id="editNip71AltText"
data-nip71-input="alt"
placeholder="Describe the visuals for screen readers"
class="input"
/>
<p class="text-xs text-muted">
Plain-language fallback shown when the media cannot load.
</p>
</div>
<div class="space-y-2">
<label for="editNip71Duration" class="block text-sm font-medium text-text">
Duration (seconds)
</label>
<input
type="number"
id="editNip71Duration"
min="0"
step="0.001"
data-nip71-input="duration"
class="input"
/>
<p class="text-xs text-muted">
Optional legacy fallback in seconds (fractions allowed) for clients that expect a top-level duration.
</p>
</div>
<div class="space-y-2">
<label for="editNip71ContentWarning" class="block text-sm font-medium text-text">
Content warning
</label>
<input
type="text"
id="editNip71ContentWarning"
data-nip71-input="content-warning"
placeholder="e.g., Flashing lights"
class="input"
/>
<p class="text-xs text-muted">
Leave blank if the video is safe for all audiences.
</p>
</div>
</div>
<div class="space-y-2">
<label for="editNip71Summary" class="block text-sm font-medium text-text">
Summary
</label>
<textarea
id="editNip71Summary"
rows="3"
data-nip71-input="summary"
class="textarea"
placeholder="Short synopsis used as the Nostr content field"
></textarea>
<p class="text-xs text-muted">
This becomes the event content. Keep it concise for Nostr readers.
</p>
</div>
</div>
<fieldset
id="editNip71TagsFieldset"
class="space-y-6 rounded-lg border border-border/70 bg-overlay-black/30 p-4"
data-nip71-section="tags"
>
<legend class="relative text-sm font-semibold text-text">
<span class="inline-block -ml-2 rounded px-2 bg-overlay-black/30">NIP-71 tags</span>
</legend>
<p class="text-xs text-muted">
Use the controls below to describe media variants, captions, chapters, participants, and references.
</p>
<section class="space-y-4" aria-labelledby="editNip71ImetaLabel" data-nip71-repeater="imeta">
<div class="flex items-center justify-between">
<h3 id="editNip71ImetaLabel" class="text-sm font-medium text-text">Image metadata (imeta)</h3>
<button
type="button"
class="btn-ghost focus-ring text-xs font-semibold"
data-nip71-add="imeta"
aria-describedby="editNip71ImetaHelp"
>
Add variant
</button>
</div>
<p id="editNip71ImetaHelp" class="text-xs text-muted">
Provide dimensions, MIME type, and resource links for each variant. Nested repeaters let you attach multiple
<code>image</code>, <code>fallback</code>, or <code>service</code> values.
</p>
<div id="editNip71ImetaList" class="space-y-4" data-nip71-list="imeta"></div>
<template id="editNip71ImetaTemplate" data-nip71-template="imeta">
<article class="card space-y-3 bg-surface/60 p-4" data-nip71-entry="imeta">
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
MIME type (<code>m</code>)
</label>
<input
type="text"
data-nip71-field="m"
placeholder="video/mp4"
class="input"
/>
<p class="text-3xs text-muted">RFC 2046 media type for this encoding.</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Pixel dimensions (<code>dim</code>)
</label>
<input
type="text"
data-nip71-field="dim"
placeholder="1920x1080"
class="input"
/>
<p class="text-3xs text-muted">Formatted as widthxheight.</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Streaming URL (<code>url</code>)
</label>
<input
type="url"
data-nip71-field="url"
placeholder="https://cdn.example.com/variant.m3u8"
class="input"
/>
<p class="text-3xs text-muted">HTTPS variant URL shared across clients.</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Magnet (<code>x</code>)
</label>
<input
type="text"
data-nip71-field="x"
placeholder="magnet:?xt=urn:btih:..."
class="input"
/>
<p class="text-3xs text-muted">Optional magnet pointer if the variant is seeded via P2P.</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Duration seconds (<code>duration</code>)
</label>
<input
type="number"
data-nip71-field="duration"
min="0"
step="0.001"
placeholder="29.97"
class="input"
/>
<p class="text-3xs text-muted">Fractional seconds supported; clients use this for precise timelines.</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Bitrate bps (<code>bitrate</code>)
</label>
<input
type="number"
data-nip71-field="bitrate"
min="0"
step="1"
placeholder="3500000"
class="input"
/>
<p class="text-3xs text-muted">Average bits per second for this variant.</p>
</div>
</div>
<div class="space-y-3" data-nip71-nested="image">
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">Image values</h4>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-nested-add="image"
>
Add image URL
</button>
</div>
<div class="space-y-2" data-nip71-nested-list="image"></div>
<template data-nip71-nested-template="image">
<div class="flex items-center gap-2" data-nip71-nested-entry="image">
<input
type="url"
placeholder="https://cdn.example.com/poster.jpg"
data-nip71-nested-field="image"
class="flex-1 input"
/>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="nested"
aria-label="Remove image URL"
>
Remove
</button>
</div>
</template>
</div>
<div class="space-y-3" data-nip71-nested="fallback">
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">Fallback URLs</h4>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-nested-add="fallback"
>
Add fallback
</button>
</div>
<div class="space-y-2" data-nip71-nested-list="fallback"></div>
<template data-nip71-nested-template="fallback">
<div class="flex items-center gap-2" data-nip71-nested-entry="fallback">
<input
type="url"
placeholder="https://cdn.backup.net/video.mp4"
data-nip71-nested-field="fallback"
class="flex-1 input"
/>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="nested"
aria-label="Remove fallback URL"
>
Remove
</button>
</div>
</template>
</div>
<div class="space-y-3" data-nip71-nested="service">
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">Services</h4>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-nested-add="service"
>
Add service
</button>
</div>
<div class="space-y-2" data-nip71-nested-list="service"></div>
<template data-nip71-nested-template="service">
<div class="flex items-center gap-2" data-nip71-nested-entry="service">
<input
type="text"
placeholder="https://api.example.com"
data-nip71-nested-field="service"
class="flex-1 input"
/>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="nested"
aria-label="Remove service entry"
>
Remove
</button>
</div>
</template>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="imeta"
>
Remove variant
</button>
</div>
</article>
</template>
</section>
<section class="space-y-4" aria-labelledby="editNip71TextTracksLabel" data-nip71-repeater="text-track">
<div class="flex items-center justify-between">
<h3 id="editNip71TextTracksLabel" class="text-sm font-medium text-text">
Caption &amp; subtitle tracks (text-track)
</h3>
<button
type="button"
class="btn-ghost focus-ring text-xs font-semibold"
data-nip71-add="text-track"
aria-describedby="editNip71TextTracksHelp"
>
Add track
</button>
</div>
<p id="editNip71TextTracksHelp" class="text-xs text-muted">
Supply caption or subtitle URLs along with file type and language so players can surface accessibility features.
</p>
<div id="editNip71TextTrackList" class="space-y-4" data-nip71-list="text-track"></div>
<template id="editNip71TextTrackTemplate" data-nip71-template="text-track">
<article class="card space-y-3 bg-surface/60 p-4" data-nip71-entry="text-track">
<div class="grid gap-3 md:grid-cols-3">
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Track URL</label>
<input
type="url"
data-nip71-field="url"
placeholder="https://cdn.example.com/subs.vtt"
class="input"
/>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Type</label>
<input
type="text"
data-nip71-field="type"
placeholder="text/vtt"
class="input"
/>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Language</label>
<input
type="text"
data-nip71-field="language"
placeholder="en"
class="input"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="text-track"
>
Remove track
</button>
</div>
</article>
</template>
</section>
<section class="space-y-4" aria-labelledby="editNip71SegmentsLabel" data-nip71-repeater="segment">
<div class="flex items-center justify-between">
<h3 id="editNip71SegmentsLabel" class="text-sm font-medium text-text">Chapters &amp; segments (segment)</h3>
<button
type="button"
class="btn-ghost focus-ring text-xs font-semibold"
data-nip71-add="segment"
aria-describedby="editNip71SegmentsHelp"
>
Add segment
</button>
</div>
<p id="editNip71SegmentsHelp" class="text-xs text-muted">
Outline chapters with start and end times, titles, and optional thumbnails.
</p>
<div id="editNip71SegmentList" class="space-y-4" data-nip71-list="segment"></div>
<template id="editNip71SegmentTemplate" data-nip71-template="segment">
<article class="card space-y-3 bg-surface/60 p-4" data-nip71-entry="segment">
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Start (seconds)</label>
<input
type="number"
data-nip71-field="start"
min="0"
step="1"
class="input"
/>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">End (seconds)</label>
<input
type="number"
data-nip71-field="end"
min="0"
step="1"
class="input"
/>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Title</label>
<input
type="text"
data-nip71-field="title"
placeholder="Chapter title"
class="input"
/>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Thumbnail URL</label>
<input
type="url"
data-nip71-field="thumbnail"
placeholder="https://cdn.example.com/frame.jpg"
class="input"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="segment"
>
Remove segment
</button>
</div>
</article>
</template>
</section>
<!-- Hashtags (kept from the old NIP-71 section: they feed bitvid's hashtag
feeds and the NIP-71 mirror; everything else in that section fed the
disabled FEATURE_PUBLISH_NIP71 path and was removed for a cleaner UI.
Existing videos' extra NIP-71 metadata is preserved on save. -->
<div class="space-y-4 rounded-lg border border-border/70 bg-overlay-black/30 p-4">
<section class="space-y-4" aria-labelledby="editNip71HashtagsLabel" data-nip71-repeater="t">
<div class="flex items-center justify-between">
<h3 id="editNip71HashtagsLabel" class="text-sm font-medium text-text">Hashtags (<code>t</code>)</h3>
<h3 id="editNip71HashtagsLabel" class="text-sm font-medium text-text">Hashtags</h3>
<button
type="button"
class="btn-ghost focus-ring text-xs font-semibold"
@@ -552,7 +74,7 @@
</button>
</div>
<p class="text-xs text-muted">
Enter keywords; leading <code>#</code> characters are stripped automatically when saving.
Tag your video so it shows up in hashtag feeds. Leading <code>#</code> characters are stripped automatically.
</p>
<div id="editNip71HashtagList" class="space-y-2" data-nip71-list="t"></div>
<template id="editNip71HashtagTemplate" data-nip71-template="t">
@@ -573,85 +95,7 @@
</div>
</template>
</section>
<section class="space-y-4" aria-labelledby="editNip71ParticipantsLabel" data-nip71-repeater="p">
<div class="flex items-center justify-between">
<h3 id="editNip71ParticipantsLabel" class="text-sm font-medium text-text">Participants (<code>p</code>)</h3>
<button
type="button"
class="btn-ghost focus-ring text-xs font-semibold"
data-nip71-add="p"
>
Add participant
</button>
</div>
<div id="editNip71ParticipantList" class="space-y-3" data-nip71-list="p"></div>
<template id="editNip71ParticipantTemplate" data-nip71-template="p">
<article class="card space-y-3 bg-surface/60 p-4" data-nip71-entry="p">
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Participant pubkey</label>
<input
type="text"
data-nip71-field="pubkey"
placeholder="npub1... or hex"
class="input"
/>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Relay hint</label>
<input
type="text"
data-nip71-field="relay"
placeholder="wss://relay.example.com"
class="input"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="p"
>
Remove participant
</button>
</div>
</article>
</template>
</section>
<section class="space-y-4" aria-labelledby="editNip71ReferencesLabel" data-nip71-repeater="r">
<div class="flex items-center justify-between">
<h3 id="editNip71ReferencesLabel" class="text-sm font-medium text-text">Reference links (<code>r</code>)</h3>
<button
type="button"
class="btn-ghost focus-ring text-xs font-semibold"
data-nip71-add="r"
>
Add reference
</button>
</div>
<div id="editNip71ReferenceList" class="space-y-2" data-nip71-list="r"></div>
<template id="editNip71ReferenceTemplate" data-nip71-template="r">
<div class="flex items-center gap-2" data-nip71-entry="r">
<input
type="url"
data-nip71-field="url"
placeholder="https://example.com/context"
class="flex-1 input"
/>
<button
type="button"
class="btn-ghost focus-ring text-3xs font-medium"
data-nip71-remove="r"
>
Remove
</button>
</div>
</template>
</section>
</fieldset>
</div>
<div class="space-y-1">
<label for="editVideoUrl" class="block text-sm font-medium text-text">
+5 -46
View File
@@ -330,52 +330,11 @@
<div id="section-advanced" class="hidden mt-4 space-y-6 pl-2 border-l-2 border-border/30">
<!-- NIP-71 Technical -->
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Content Warning</label>
<input id="input-content-warning" type="text" data-nip71-input="content-warning" class="input text-sm" placeholder="e.g. Nudity" />
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">Duration (sec)</label>
<input id="input-duration" type="number" step="1" data-nip71-input="duration" class="input text-sm" />
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="block text-xs font-medium text-muted-strong">Summary (NIP-71 Content)</label>
<label class="flex items-center gap-2 text-xs text-muted cursor-pointer">
<input type="checkbox" id="check-summary-unlock" class="checkbox h-3 w-3" />
Customize
</label>
</div>
<textarea id="input-summary" rows="2" data-nip71-input="summary" class="textarea text-sm text-muted" readonly></textarea>
</div>
<!-- IMETA Editor -->
<div class="space-y-3" data-nip71-repeater="imeta">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-text">Video Variants (IMETA)</h4>
<button type="button" class="btn-ghost px-2 py-1 text-xs" data-nip71-add="imeta">Add Variant</button>
</div>
<div id="imeta-list" class="space-y-4" data-nip71-list="imeta"></div>
<!-- IMETA Template -->
<template data-nip71-template="imeta">
<article class="rounded-md border border-border/70 bg-surface/50 p-3 space-y-3" data-nip71-entry="imeta">
<div class="grid gap-2 md:grid-cols-2 text-xs">
<input type="text" data-nip71-field="m" placeholder="MIME (video/mp4)" class="input" />
<input type="text" data-nip71-field="dim" placeholder="Dimensions (1920x1080)" class="input" />
<input type="url" data-nip71-field="url" placeholder="URL" class="input md:col-span-2" />
<input type="text" data-nip71-field="x" placeholder="Magnet" class="input md:col-span-2" />
</div>
<div class="flex justify-end">
<button type="button" class="text-xs text-critical hover:underline" data-nip71-remove="imeta">Remove Variant</button>
</div>
</article>
</template>
</div>
<!-- The old "NIP-71 Technical" inputs (content warning, duration,
summary, IMETA variants) were removed for a cleaner UI: they fed
the disabled FEATURE_PUBLISH_NIP71 path, and everything the NIP-71
mirror needs (duration/dimensions, content warning from the NSFW
toggle, the primary imeta) is derived automatically. -->
<!-- Additional Technical Sections (Web Seeds, etc) -->
<div class="grid gap-3 md:grid-cols-2">
+81 -23
View File
@@ -861,9 +861,33 @@ class Application {
_initServiceWorker() {
// Force update of any registered service workers to ensure latest code is used.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.update());
});
navigator.serviceWorker
.getRegistrations()
.then((registrations) => {
if (registrations.length) {
registrations.forEach((registration) => registration.update());
return;
}
// No registration yet (user never played a torrent): register the SW
// at boot so its cross-origin image cache (thumbnails/avatars) works
// for everyone. Same path/scope/options as js/webtorrent.js's
// setupServiceWorker, so its later register() resolves to this same
// registration — playback behavior is unchanged. Best-effort only.
return navigator.serviceWorker
.register("/sw.min.js", { scope: "/", updateViaCache: "none" })
.catch((error) => {
devLogger.warn(
"[Application] Boot service-worker registration skipped:",
error,
);
});
})
.catch((error) => {
devLogger.warn(
"[Application] Service-worker init failed:",
error,
);
});
}
}
@@ -959,8 +983,6 @@ class Application {
}
async _initAccessControl() {
const promises = [];
const aclRefreshPromise = accessControl
.refresh()
.then(() => {
@@ -981,6 +1003,36 @@ class Application {
);
});
// Admin-pane state is UI, not a boot dependency — never block boot on it.
const refreshAdminPane = () => {
if (!this.profileController) {
return;
}
Promise.resolve()
.then(() => this.profileController.refreshAdminPaneState())
.catch((error) => {
devLogger.warn(
"Failed to update admin pane after connecting to Nostr:",
error,
);
});
};
refreshAdminPane();
// Stale-while-revalidate boot: when the admin lists hydrated from the
// localStorage cache (every visit after the first), canAccess() is already
// accurate, so DON'T serialize boot behind the relay refresh — it continues
// in the background and the whitelist/blacklist change listeners re-filter
// the grids + re-apply trusted seeds when it lands. This was the
// "Fetching moderation filters…" cold-start stall (up to 15s before the
// feed could even start).
if (accessControl.isHydrated?.()) {
void aclRefreshPromise.then(refreshAdminPane);
return;
}
// First-ever visit (no cached lists): wait, so a whitelist-mode instance
// never renders an unfiltered/empty feed.
// Safeguard: Do not block app initialization indefinitely if relays are slow/unresponsive.
// 15s gives plenty of time for a healthy connection but prevents E2E test timeouts (60s).
const timeoutPromise = new Promise((resolve) => {
@@ -990,22 +1042,8 @@ class Application {
}, 15000);
});
promises.push(Promise.race([aclRefreshPromise, timeoutPromise]));
if (this.profileController) {
promises.push(
Promise.resolve()
.then(() => this.profileController.refreshAdminPaneState())
.catch((error) => {
devLogger.warn(
"Failed to update admin pane after connecting to Nostr:",
error,
);
})
);
}
await Promise.all(promises);
await Promise.race([aclRefreshPromise, timeoutPromise]);
refreshAdminPane();
}
async _syncSessionActorBlacklist(trigger) {
@@ -1226,7 +1264,9 @@ class Application {
// encrypted); safe no-op when nothing is cached or for NIP-07/46.
try {
if (typeof nostrClient.restoreUnlockedSigner === "function") {
await nostrClient.restoreUnlockedSigner(normalizedSaved);
await nostrClient.restoreUnlockedSigner(normalizedSaved, {
validator: this.buildAccessControlValidator(),
});
}
} catch (error) {
devLogger.warn(
@@ -2148,7 +2188,9 @@ class Application {
!isSignerCapable(resolveSignerCapabilities(getActiveSigner()), need)
) {
try {
const restored = await nostrClient.restoreUnlockedSigner(normalized);
const restored = await nostrClient.restoreUnlockedSigner(normalized, {
validator: this.buildAccessControlValidator(),
});
if (
restored?.restored &&
isSignerCapable(resolveSignerCapabilities(getActiveSigner()), need)
@@ -2222,6 +2264,22 @@ class Application {
return { ok: true, unlocked: true };
}
// Access-control gate passed into keep-unlocked signer restores so a
// since-blocked account's cached key is forgotten instead of restoring.
buildAccessControlValidator() {
return (pk) => {
if (
accessControl &&
typeof accessControl.canAccess === "function" &&
!accessControl.canAccess(pk)
) {
const error = new Error("Access restricted for this account.");
error.code = "access-denied";
throw error;
}
};
}
// True when the active (or given) account currently has a cached "keep
// unlocked" key — i.e. there is something to lock (TODO #51).
isSessionKeptUnlocked(pubkey) {
+49 -27
View File
@@ -15,6 +15,10 @@ import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecry
import { FEED_TYPES, FEATURE_TRENDING_FEED } from "../constants.js";
import { showPasswordPrompt } from "../ui/promptDialog.js";
import { evaluateStoredNsecSwitch } from "./storedNsecSwitch.js";
import {
createListCacheProbe,
waitForListCaches,
} from "./feedListCacheGate.js";
/**
* Kick a background watch-history refresh after login, once the feed-driving
@@ -200,6 +204,20 @@ export function createAuthSessionCoordinator(deps) {
if (detail?.identityChanged) {
this.resetViewLoggingState();
// Account switch: clear the PREVIOUS account's app-wide DM store + stop
// its subscription (logout does the same); resetting only the modal view
// left old messages on screen. keepSnapshot preserves each account's
// persisted DM cache so switching back stays fast.
try {
this.nostrService?.stopDirectMessageSubscription?.();
this.nostrService?.clearDirectMessages?.({ emit: true, keepSnapshot: true });
} catch (error) {
devLogger.warn(
"[Application] Failed to reset DMs on account switch:",
error,
);
}
}
this.resetPermissionPromptState();
@@ -650,11 +668,8 @@ export function createAuthSessionCoordinator(deps) {
authLoadingState: this.authLoadingState,
});
// Now that the NEW identity's lists (blocks/subscriptions/hashtags) have
// settled, backfill the profile modal if it is open — so switching
// accounts with the modal visible refreshes the pane the user is viewing
// with the freshly-loaded data instead of leaving the previous account's
// list on screen until a manual refresh. No-op when the modal is closed.
// The NEW identity's lists settled: backfill the open profile modal so a
// switch refreshes the visible pane (no-op when the modal is closed).
if (
this.profileController &&
typeof this.profileController.refreshOpenPanesForActiveIdentity ===
@@ -775,11 +790,21 @@ export function createAuthSessionCoordinator(deps) {
// lowered so settled results arrive faster. 8s prevents the feed from
// being blocked while still giving most lists time to decrypt.
const FEED_SYNC_TIMEOUT_MS = 8000;
// Cache-aware early release (rationale in feedListCacheGate.js): open the
// gate as soon as all three list caches are valid for this pubkey.
const listCacheReadyPromise = waitForListCaches(
createListCacheProbe({
userBlocks,
subscriptions,
hashtagPreferences: this.hashtagPreferences,
normalizeHexPubkey: (value) => this.normalizeHexPubkey(value),
activePubkey,
}),
{ timeoutMs: FEED_SYNC_TIMEOUT_MS },
);
const feedSyncPromise = Promise.race([
Promise.allSettled([
listStatePromise,
profileStatePromise,
]),
Promise.allSettled([listStatePromise, profileStatePromise]),
listCacheReadyPromise,
new Promise((resolve) => setTimeout(resolve, FEED_SYNC_TIMEOUT_MS)),
]).catch(() => null);
@@ -974,15 +999,14 @@ export function createAuthSessionCoordinator(deps) {
clearWatchHistoryConversationKeyCache();
clearWatchHistoryDecryptedChunkCache();
// Forget any cached "keep unlocked" key for the account being logged out, so
// logout actually re-locks it (TODO #51).
// Forget the cached "keep unlocked" key so logout re-locks (TODO #51).
// MUST read previousPubkey: authService.logout already nulled the active
// pubkey, so detail.pubkey/this.pubkey are empty here.
try {
const loggedOutPubkey = detail?.pubkey || this.pubkey;
if (
loggedOutPubkey &&
typeof nostrClient.forgetUnlockedSigner === "function"
) {
nostrClient.forgetUnlockedSigner(loggedOutPubkey);
const loggedOutPubkey =
detail?.previousPubkey || detail?.pubkey || this.pubkey;
if (loggedOutPubkey) {
nostrClient.forgetUnlockedSigner?.(loggedOutPubkey);
}
} catch (error) {
devLogger.warn("Failed to forget cached unlock during logout:", error);
@@ -1687,14 +1711,14 @@ export function createAuthSessionCoordinator(deps) {
}
}
// Removing a saved profile must also forget its cached "keep unlocked" key.
// A removed profile must not leave keys on the device: forget its cached
// "keep unlocked" key + stored credentials (nsec key, NIP-46 session).
try {
if (typeof nostrClient.forgetUnlockedSigner === "function") {
nostrClient.forgetUnlockedSigner(normalizedTarget);
}
nostrClient.forgetUnlockedSigner?.(normalizedTarget);
nostrClient.forgetStoredAccount?.(normalizedTarget);
} catch (error) {
devLogger.warn(
"[Application] Failed to forget cached unlock for removed profile:",
"[Application] Failed to forget credentials for removed profile:",
error,
);
}
@@ -1822,11 +1846,9 @@ export function createAuthSessionCoordinator(deps) {
return context;
}
// Block lists are NIP-04 encrypted. A reloaded nsec session can lose its
// in-memory key; re-unlock it (one passphrase prompt) rather than failing
// with a blanket signer error (TODO #57). Only short-circuit on the
// user-driven outcomes — anything else falls through so the mutation's own
// signer error (e.g. an unresponsive NIP-07 extension) still surfaces.
// Block lists are NIP-04 encrypted: re-unlock a reloaded nsec signer via
// one passphrase prompt (TODO #57). Only user-driven outcomes short-circuit;
// everything else falls through to the mutation's own signer errors.
if (typeof this.ensureEncryptionCapableSigner === "function") {
const ensured = await this.ensureEncryptionCapableSigner({
pubkey: actorHex,
+79
View File
@@ -0,0 +1,79 @@
// Cache-aware early release for the post-login feed gate. The login flow holds
// the video grid until blocks/subscriptions/hashtag lists settle (so the feed
// never flashes unfiltered content), time-boxed at several seconds. But on every
// warm boot those services hydrate valid per-pubkey CACHES almost immediately —
// filtering with cached lists is accurate, and the relay sync keeps running in
// the background (its change events re-filter when fresh data lands). This probe
// lets the gate open as soon as all three caches are usable instead of waiting
// for full relay sync + NIP-07 decryption ("Fetching subscriptions…" stall).
// Builds a () => boolean probe: true when all three list services hold a valid
// cache for the active pubkey. Mirrors the hasValid*Cache checks used by the
// login list-sync bookkeeping.
export function createListCacheProbe({
userBlocks,
subscriptions,
hashtagPreferences,
normalizeHexPubkey,
activePubkey,
} = {}) {
const normalize =
typeof normalizeHexPubkey === "function"
? normalizeHexPubkey
: (value) => (typeof value === "string" ? value.trim().toLowerCase() : "");
const target = normalize(activePubkey);
return () => {
if (!target) {
return false;
}
const blocksReady =
Boolean(userBlocks) &&
userBlocks.loaded === true &&
normalize(userBlocks.activePubkey) === target;
const subscriptionsReady =
Boolean(subscriptions) &&
subscriptions.loaded === true &&
normalize(subscriptions.currentUserPubkey) === target;
const hashtagsReady =
Boolean(hashtagPreferences) &&
hashtagPreferences.loaded === true &&
normalize(hashtagPreferences.activePubkey) === target;
return blocksReady && subscriptionsReady && hashtagsReady;
};
}
// Resolves "cache" as soon as the probe passes, or null at the timeout. Polls on
// a short interval — the caches hydrate within the first ticks on warm boots.
export function waitForListCaches(
probe,
{ timeoutMs = 8000, intervalMs = 150 } = {},
) {
return new Promise((resolve) => {
if (typeof probe !== "function") {
resolve(null);
return;
}
const startedAt = Date.now();
const check = () => {
let ready = false;
try {
ready = probe() === true;
} catch (error) {
ready = false;
}
if (ready) {
resolve("cache");
return;
}
if (Date.now() - startedAt >= timeoutMs) {
resolve(null);
return;
}
setTimeout(check, intervalMs);
};
check();
});
}
export default waitForListCaches;
+9 -3
View File
@@ -45,15 +45,21 @@ export function createRouterCoordinator(deps) {
window.location.hash = `#view=channel-profile&npub=${npub}`;
},
openCreatorChannel() {
if (!this.currentVideo || !this.currentVideo.pubkey) {
openCreatorChannel(pubkey) {
// Prefer an explicit pubkey (e.g. the video modal's own creator) and fall
// back to the active video's creator.
const targetPubkey =
(typeof pubkey === "string" && pubkey.trim()) ||
this.currentVideo?.pubkey ||
"";
if (!targetPubkey) {
this.showError("No creator info available.");
return;
}
try {
// Encode the hex pubkey to npub
const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey);
const npub = window.NostrTools.nip19.npubEncode(targetPubkey);
// Close the video modal
this.hideModal();
+8
View File
@@ -13,6 +13,10 @@ import { escapeHTML } from "./utils/domUtils.js";
import { formatShortNpub } from "./utils/formatters.js";
import createPopover from "./ui/overlay/popoverEngine.js";
import { VideoCard } from "./ui/components/VideoCard.js";
import {
subscribeChannelCardViewCount,
clearChannelCardViewCounts,
} from "./channelViewCounts.js";
import { createChannelProfileMenuPanel } from "./ui/components/videoMenuRenderers.js";
import { ALLOW_NSFW_CONTENT } from "./config.js";
import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js";
@@ -179,6 +183,7 @@ function updateChannelBannerBackground(el, url) {
export function clearChannelVideoCardRegistry() {
channelVideoCardsById.clear();
clearChannelCardViewCounts();
}
let currentChannelHex = null;
@@ -5029,6 +5034,9 @@ export async function renderChannelVideosFromList({
const cardEl = videoCard.getRoot();
if (cardEl) {
fragment.appendChild(cardEl);
// Subscribe the card to the shared view counter (the feed does this; the
// channel grid didn't, so its view counts rendered blank).
subscribeChannelCardViewCount(cardEl, pointerInfo);
}
}
+77
View File
@@ -0,0 +1,77 @@
// Wire channel-profile video cards to the shared view counter. The main feed does
// this in VideoListView (ensureViewCountSubscription / registerVideoViewCountElement);
// the channel-profile grid renders its own VideoCards and never subscribed them, so
// every card's view count rendered blank. This mirrors the feed's behavior against
// the SAME batched view-count transport (one shared kind-30079 subscription), so the
// counts are identical to what the feed shows — the channel just wasn't displaying
// them (the counting itself is unchanged and shared).
import {
subscribeToVideoViewCount,
unsubscribeFromVideoViewCount,
formatViewCount,
} from "./viewCounter.js";
import { devLogger } from "./utils/logger.js";
const activeSubscriptions = [];
function formatViewCountLabel(total) {
const value = Number.isFinite(total) ? Number(total) : 0;
return `${formatViewCount(value)} ${value === 1 ? "view" : "views"}`;
}
// Subscribe one channel card's [data-view-count] element to its pointer.
export function subscribeChannelCardViewCount(cardEl, pointerInfo) {
const pointer = pointerInfo?.pointer;
if (!cardEl || !pointer) {
return;
}
const el = cardEl.querySelector("[data-view-count]");
if (!el) {
return;
}
if (!el.textContent || !el.textContent.trim()) {
el.textContent = "– views";
}
try {
const token = subscribeToVideoViewCount(
pointer,
({ total, status, partial }) => {
let text;
if (Number.isFinite(total)) {
text = formatViewCountLabel(total);
if (partial) {
text = `${text} (partial)`;
}
} else if (status === "hydrating") {
text = "Loading views…";
} else {
text = "– views";
}
if (el.isConnected) {
el.textContent = text;
el.dataset.viewCountState = partial ? "partial" : status;
}
},
);
activeSubscriptions.push({ pointer, token });
} catch (error) {
devLogger.warn(
"[ChannelProfile] Failed to subscribe card view count:",
error,
);
}
}
// Tear down every channel-card view-count subscription (on grid re-render / close).
export function clearChannelCardViewCounts() {
while (activeSubscriptions.length) {
const { pointer, token } = activeSubscriptions.pop();
try {
unsubscribeFromVideoViewCount(pointer, token);
} catch (error) {
// best-effort teardown
}
}
}
+41 -4
View File
@@ -2539,9 +2539,10 @@ export class NostrClient {
try {
opts.validator(adapter.pubkey);
} catch (error) {
// Don't leave a since-blocked key persisted on this device.
// Don't leave the since-blocked key persisted on this device. Targeted:
// only THIS account's entry — the no-arg clear wiped every saved key.
try {
clearStoredSessionActorEntry();
clearStoredSessionActorEntry(adapter.pubkey);
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear stored session after access denial:",
@@ -2738,8 +2739,11 @@ export class NostrClient {
// Restore an nsec signer from the "keep unlocked" cache without a passphrase
// (TODO #51). Reads the cached decrypted key for `targetPubkey`, verifies it
// derives to that pubkey (defense-in-depth; a mismatch forgets the cache), and
// registers it app-wide. Returns { restored: boolean }.
async restoreUnlockedSigner(targetPubkey) {
// registers it app-wide. Pass a `validator` (access-control gate, like
// unlockStoredSessionActor's) where available — a since-blocked account's
// cached key is then forgotten instead of silently restoring its signer.
// Returns { restored: boolean }.
async restoreUnlockedSigner(targetPubkey, { validator } = {}) {
const pubkey =
typeof targetPubkey === "string" && HEX64_REGEX.test(targetPubkey.trim().toLowerCase())
? targetPubkey.trim().toLowerCase()
@@ -2771,6 +2775,17 @@ export class NostrClient {
return { restored: false, reason: "pubkey-mismatch" };
}
// Access-control gate (mirrors unlockStoredSessionActor): a since-blocked
// account must not silently restore its signer — forget its cached key.
if (typeof validator === "function") {
try {
validator(pubkey);
} catch (error) {
forgetUnlockedKey(pubkey);
return { restored: false, reason: "access-denied", error };
}
}
try {
const adapter = await this.registerPrivateKeySigner({
privateKey: privateKeyHex,
@@ -3064,6 +3079,28 @@ export class NostrClient {
return this.signerManager.clearStoredSessionActor();
}
// Forget ONE removed account's stored credentials (remembered nsec key +
// NIP-46 remote-signer session) without touching the active session. Used by
// the profile switcher's remove-account flow so a removed profile doesn't
// leave its keys on the device.
forgetStoredAccount(pubkey) {
const normalized =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (!normalized) {
return;
}
try {
this.signerManager.forgetStoredSessionActor(normalized);
} catch (error) {
devLogger.warn("[nostr] Failed to forget stored nsec key:", error);
}
try {
clearStoredNip46Session(normalized);
} catch (error) {
devLogger.warn("[nostr] Failed to forget stored NIP-46 session:", error);
}
}
/**
* Logs out the current user and clears all session state.
*
+53 -8
View File
@@ -138,16 +138,30 @@ export class SignerManager {
}
async ensureSessionActor(force = false) {
// The session actor is an ANONYMOUS telemetry key used to SIGN reactions /
// view events / watch-history when they aren't tied to the login. It must
// therefore carry a usable plaintext private key. A persisted nsec login is
// passphrase-encrypted (privateKey ""), so adopting one as the session actor
// made reactions fail with "Missing signing primitives" (a leftover saved-nsec
// entry hijacked the anonymous key). Only reuse an actor that can actually
// sign; otherwise fall through and generate a fresh ephemeral key.
const hasUsableKey = (actor) =>
Boolean(
actor &&
typeof actor.privateKey === "string" &&
HEX64_REGEX.test(actor.privateKey),
);
if (!force) {
if (this.sessionActor) {
if (hasUsableKey(this.sessionActor)) {
return this.sessionActor.pubkey;
}
if (this.lockedSessionActor) {
return this.lockedSessionActor.pubkey;
}
const storedEntry = readStoredSessionActorEntry();
if (storedEntry) {
// Track a locked/encrypted entry (other flows read it) but don't sign with it.
this.lockedSessionActor = storedEntry;
}
if (hasUsableKey(storedEntry)) {
this.sessionActor = storedEntry;
return storedEntry.pubkey;
}
@@ -189,12 +203,36 @@ export class SignerManager {
return newActor.pubkey;
}
clearStoredSessionActor() {
clearStoredSessionActorEntry();
// Forget a stored (remembered) key. Pass the account's pubkey — the no-arg
// form only clears the legacy v1 slot and must never be used to log out one
// account (it used to wipe EVERY saved account's key).
clearStoredSessionActor(pubkey) {
clearStoredSessionActorEntry(pubkey);
this.sessionActor = null;
this.lockedSessionActor = null;
}
// Forget ONE account's remembered key WITHOUT touching the current session's
// in-memory actors (used when removing a non-active saved profile). Only drops
// the in-memory references if they belong to the removed account.
forgetStoredSessionActor(pubkey) {
const target =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (!target) {
return;
}
clearStoredSessionActorEntry(target);
const matches = (actor) =>
typeof actor?.pubkey === "string" &&
actor.pubkey.trim().toLowerCase() === target;
if (matches(this.lockedSessionActor)) {
this.lockedSessionActor = null;
}
if (matches(this.sessionActor) && !this.sessionActor?.privateKey) {
this.sessionActor = null;
}
}
async ensureActiveSignerForPubkey(pubkey) {
const normalizedPubkey =
typeof pubkey === "string" && pubkey.trim()
@@ -1069,8 +1107,15 @@ export class SignerManager {
previousSessionActor.persisted !== true;
if (shouldClearStoredSession) {
this.lockedSessionActor = null;
this.clearStoredSessionActor();
// Forget ONLY the departing account's remembered key. The no-arg clear
// used to wipe the whole per-account map, so logging out one non-persisted
// nsec login destroyed every OTHER saved account's key. Skip entirely when
// we can't name the account — never wipe-all implicitly.
const clearTarget = previousSessionActor.pubkey || previousPubkey || "";
if (clearTarget) {
this.lockedSessionActor = null;
this.clearStoredSessionActor(clearTarget);
}
}
if (
+7 -7
View File
@@ -677,19 +677,19 @@ export function clearStoredSessionActor(pubkey) {
return;
}
clearStoredSessionActorIndexedDb();
// No target pubkey: clear ONLY the legacy v1 "last saved" slot. Wiping the
// per-account v2 map here deleted EVERY saved account's remembered nsec key —
// the no-arg callers were logout/teardown paths for ONE account, so logging
// out (or a blocked-key cleanup) silently destroyed the other saved accounts'
// keys and broke switching back to them. Same bug class as the NIP-46
// session-map wipe. A specific account is forgotten via
// clearStoredSessionActor(pubkey), which remains targeted.
if (typeof localStorage !== "undefined") {
try {
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to clear stored session actor:", error);
}
try {
localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to clear session actor map:", error);
}
}
}
+6 -2
View File
@@ -786,7 +786,11 @@ export class NostrService {
return Array.isArray(this.dmMessages) ? [...this.dmMessages] : [];
}
clearDirectMessages({ actorPubkey = null, emit = true } = {}) {
// keepSnapshot: clear the IN-MEMORY DM store but preserve each account's
// persisted per-actor snapshot. Used on account SWITCH so the new account
// doesn't show the previous account's messages, while switching back stays fast
// (its cached DMs survive). Logout still fully clears (keepSnapshot defaults off).
clearDirectMessages({ actorPubkey = null, emit = true, keepSnapshot = false } = {}) {
this.dmMessages = [];
this.dmMessageIndex = new Map();
this.dmHydratedFromSnapshot = false;
@@ -805,7 +809,7 @@ export class NostrService {
? actorPubkey
: this.dmActorPubkey || this.resolveActiveDmActor(),
);
if (activeActor) {
if (activeActor && !keepSnapshot) {
clearDirectMessageSnapshot(activeActor).catch((error) => {
userLogger.warn("[nostrService] Failed to clear DM snapshot", error);
});
+11
View File
@@ -68,6 +68,17 @@ async function waitForAccessControl({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS }
return { ok: true, timedOut: false };
}
// Cached admin lists (hydrated synchronously from localStorage at
// construction) are enough to seed trust — don't stall boot behind the relay
// refresh. If the refresh later changes editors/whitelist/blacklist, the
// change listeners registered in bootstrapTrustedSeeds re-apply the seeds.
if (
typeof accessControl.isHydrated === "function" &&
accessControl.isHydrated()
) {
return { ok: true, timedOut: false, fromCache: true };
}
let timedOut = false;
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
+7 -2
View File
@@ -28,7 +28,12 @@ function getCreateBucketConfig(region) {
};
}
function computeCacheControl(key) {
// Exported for tests. Upload keys are content-addressed (buildR2Key namespaces
// by the file's infohash), so media at a given key never changes — a long
// immutable Cache-Control is safe and makes browsers keep thumbnails/avatars/
// videos across visits instead of re-fetching them. Playlists (m3u8/mpd) stay
// short-lived because live/HLS manifests DO mutate in place.
export function computeCacheControl(key) {
if (!key) {
return DEFAULT_CACHE_CONTROL;
}
@@ -39,7 +44,7 @@ function computeCacheControl(key) {
}
if (
/\.(m4s|ts|mp4|webm|mov|mkv|png|jpg|jpeg|gif|svg|vtt|srt|mpg|mpeg)$/.test(
/\.(m4s|ts|mp4|webm|mov|mkv|png|jpg|jpeg|gif|svg|webp|avif|ico|vtt|srt|mpg|mpeg|torrent)$/.test(
lower
)
) {
+2 -2
View File
@@ -574,8 +574,8 @@ export default class ModalManager {
this.videoModalHandlers.contextAction,
);
this.videoModalHandlers.creatorNavigate = () => {
app.openCreatorChannel();
this.videoModalHandlers.creatorNavigate = (event) => {
app.openCreatorChannel(event?.detail?.pubkey);
};
this.videoModal.addEventListener(
"creator:navigate",
+106 -17
View File
@@ -101,6 +101,11 @@ export class EditModal {
this.nip71SectionKey = "edit";
this.originalNip71Metadata = null;
this.originalNip71MetadataJson = null;
// Full stored NIP-71 metadata for the active video. The form now edits only
// hashtags (the rest of the NIP-71 section was removed for a cleaner UI), so
// legacy extras (imeta, text tracks, participants, …) must be merged back in
// on save rather than replaced by the reduced form's empty output.
this.storedNip71Metadata = null;
}
addEventListener(type, listener, options) {
@@ -385,6 +390,7 @@ export class EditModal {
this.nip71FormManager.resetSection(this.nip71SectionKey);
this.originalNip71Metadata = null;
this.originalNip71MetadataJson = null;
this.storedNip71Metadata = null;
this.setSubmitState({ pending: false });
return;
}
@@ -430,6 +436,7 @@ export class EditModal {
this.nip71FormManager.resetSection(this.nip71SectionKey);
this.originalNip71Metadata = null;
this.originalNip71MetadataJson = null;
this.storedNip71Metadata = null;
this.activeVideo = null;
this.setSubmitState({ pending: false });
}
@@ -511,7 +518,19 @@ export class EditModal {
const initialNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
this.originalNip71Metadata = this.cloneNip71Metadata(initialNip71);
// Apply the SAME normalization submit applies to its collected copy, so the
// nip71Edited JSON comparison is between identically-shaped objects. Without
// this, submit's publishedAt string→timestamp / delete-empty step made every
// save look "edited" even when nothing changed.
this.normalizeCollectedNip71(this.originalNip71Metadata);
this.originalNip71MetadataJson = JSON.stringify(this.originalNip71Metadata);
// Keep the video's FULL stored NIP-71 metadata (pre-form): the reduced form
// only round-trips hashtags, and submit merges them into this so legacy
// extras survive a save.
this.storedNip71Metadata =
video.nip71 && typeof video.nip71 === "object"
? this.cloneNip71Metadata(video.nip71)
: null;
this.activeVideo = editContext;
if (this.root) {
@@ -888,7 +907,29 @@ export class EditModal {
}
}
submit() {
// Normalize a collectSection() result in place: convert the datetime-local
// publishedAt string back to unix seconds and drop it when empty/invalid.
// Applied to BOTH the open-time original and the submit-time copy so the
// nip71Edited comparison sees identically-shaped objects.
normalizeCollectedNip71(metadata) {
if (!metadata || typeof metadata.publishedAt !== "string") {
return metadata;
}
const dateStr = metadata.publishedAt;
if (dateStr) {
const dt = new Date(dateStr);
if (!isNaN(dt.getTime())) {
metadata.publishedAt = Math.floor(dt.getTime() / 1000);
} else {
delete metadata.publishedAt;
}
} else {
delete metadata.publishedAt;
}
return metadata;
}
async submit() {
if (this.pendingSubmit) {
return;
}
@@ -996,21 +1037,7 @@ export class EditModal {
const rawNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
const currentNip71 = this.cloneNip71Metadata(rawNip71);
// Convert publishedAt back to Unix timestamp (seconds)
if (currentNip71 && typeof currentNip71.publishedAt === "string") {
const dateStr = currentNip71.publishedAt;
if (dateStr) {
const dt = new Date(dateStr);
if (!isNaN(dt.getTime())) {
currentNip71.publishedAt = Math.floor(dt.getTime() / 1000);
} else {
delete currentNip71.publishedAt;
}
} else {
delete currentNip71.publishedAt;
}
}
this.normalizeCollectedNip71(currentNip71);
const hasImetaSource = Array.isArray(currentNip71?.imeta)
? currentNip71.imeta.some((variant) => {
if (!variant || typeof variant !== "object") {
@@ -1076,6 +1103,50 @@ export class EditModal {
return;
}
// Auto-derive a webseed torrent when the URL was changed to an external link
// with no magnet, so an edited external URL keeps the P2P benefit (magnet
// ws=url). Best-effort + size-capped; on CORS / too-large / any error it stays
// URL-only. Runs before the magnet normalization below so the derived magnet's
// ws/xs are extracted the same way a pasted magnet's are. When storage is
// unlocked we also host the tiny .torrent (xs=) so the webseed can bootstrap
// P2P — same as the upload modal.
if (
urlWasEdited &&
finalUrl &&
!finalMagnet &&
typeof this.mediaUploader?.deriveTorrentForExternalUrl === "function"
) {
try {
let connection = null;
try {
connection =
typeof this.mediaUploader.resolveActiveConnection === "function"
? await this.mediaUploader.resolveActiveConnection()
: null;
} catch (connectionError) {
connection = null;
}
const canHostTorrent = Boolean(
connection?.unlocked && connection?.credentials && connection?.provider,
);
const derived = await this.mediaUploader.deriveTorrentForExternalUrl(
finalUrl,
{
provider: canHostTorrent ? connection.provider : undefined,
credentials: canHostTorrent ? connection.credentials : undefined,
},
);
if (derived?.magnet) {
finalMagnet = derived.magnet;
}
} catch (error) {
devLogger.warn(
"[EditModal] External URL webseed derivation skipped:",
error?.message || error,
);
}
}
if (finalMagnet) {
// When the web seed list was manually edited, replace the magnet's full
// ws= set with exactly what's in the field — this is what makes removal
@@ -1126,7 +1197,25 @@ export class EditModal {
const currentNip71Json = JSON.stringify(currentNip71);
const nip71Edited = currentNip71Json !== this.originalNip71MetadataJson;
updatedData.nip71 = currentNip71;
// The form now edits ONLY hashtags. Merge them into the video's full stored
// NIP-71 metadata so legacy extras (imeta, text tracks, participants, …) are
// preserved on save instead of being replaced by the reduced form's empty
// fields. (Both sides of the nip71Edited comparison round-trip through the
// same reduced form, so it still fires exactly on hashtag changes.)
let finalNip71 = currentNip71;
if (this.storedNip71Metadata && typeof this.storedNip71Metadata === "object") {
const preserved = this.cloneNip71Metadata(this.storedNip71Metadata) || {};
const editedHashtags = Array.isArray(currentNip71?.hashtags)
? currentNip71.hashtags
: [];
preserved.hashtags = editedHashtags;
if (Array.isArray(preserved.t)) {
// Some stored payloads mirror hashtags under `t`; keep them in sync.
preserved.t = editedHashtags;
}
finalNip71 = preserved;
}
updatedData.nip71 = finalNip71;
updatedData.nip71Edited = nip71Edited;
const originalEvent = {
+58 -1
View File
@@ -1259,13 +1259,70 @@ export class UploadModal {
throw new Error("Please provide at least a Video URL or Magnet Link.");
}
if (!hasUrl && !hasImeta && hasMagnet) {
// Auto-derive a webseed torrent from the external URL so the link keeps the
// P2P benefit (fetch + hash the remote file → magnet with ws=url). Best-effort
// and size-capped: on CORS / too-large / any error it silently degrades to
// URL-only. Only runs when the user didn't already paste their own magnet.
if (hasUrl && !hasMagnet) {
const derived = await this.deriveExternalWebseed(metadata.url);
if (derived?.magnet) {
metadata.magnet = derived.magnet;
metadata.infoHash = derived.infoHash || metadata.infoHash || "";
metadata.ws = [metadata.url, this.inputs.ws?.value || ""];
if (derived.torrentUrl) metadata.xs = derived.torrentUrl;
}
}
if (!hasUrl && !hasImeta && metadata.magnet.length > 0) {
if (!(await showConfirm("Magnet-only uploads require active seeding. Proceed?"))) return;
}
await this.publish(metadata);
}
// Best-effort webseed derivation for an external URL (TODO: external-URL P2P).
// Hosts the tiny .torrent (xs=) only when storage is actually unlocked; otherwise
// returns a ws=-only magnet. Never throws — returns null so the caller publishes
// URL-only.
async deriveExternalWebseed(url) {
if (typeof this.mediaUploader?.deriveTorrentForExternalUrl !== "function") {
return null;
}
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
const canHostTorrent = Boolean(
this.activeProvider &&
this.activeCredentials &&
pubkey &&
this.storageService?.isUnlocked?.(pubkey),
);
const previousLabel = this.submitButton?.textContent;
if (this.submitButton) {
this.submitButton.disabled = true;
this.submitButton.textContent = "Computing torrent hash…";
}
try {
return await this.mediaUploader.deriveTorrentForExternalUrl(url, {
provider: canHostTorrent ? this.activeProvider : undefined,
credentials: canHostTorrent ? this.activeCredentials : undefined,
onProgress: ({ label }) => {
if (this.submitStatus && label) this.submitStatus.textContent = label;
},
});
} catch (error) {
userLogger.warn(
"[UploadModal] External URL webseed derivation skipped:",
error?.message || error,
);
return null;
} finally {
if (this.submitButton) {
this.submitButton.disabled = false;
if (previousLabel) this.submitButton.textContent = previousLabel;
}
if (this.submitStatus) this.submitStatus.textContent = "";
}
}
async publish(metadata) {
// Normalize & Publish
const { payload, errors } = normalizeVideoNotePayload(metadata);
+35 -4
View File
@@ -3017,7 +3017,11 @@ export class VideoModal {
handleCreatorNavigation() {
if (this.activeVideo?.pubkey) {
this.dispatch("navigate:profile", { pubkey: this.activeVideo.pubkey });
// ModalManager listens for "creator:navigate" (→ app.openCreatorChannel).
// The previous "navigate:profile" name had NO listener, so clicking the
// creator avatar/name did nothing — the event went into the void (same class
// as the old action:embed bug above).
this.dispatch("creator:navigate", { pubkey: this.activeVideo.pubkey });
}
}
@@ -3026,10 +3030,37 @@ export class VideoModal {
this.log("[VideoModal] More button clicked");
}
handleReactionClick(reaction) {
if (this.reactionsController) {
this.reactionsController.handleReaction(reaction);
handleReactionClick(event) {
// The reactionsController binds the like/dislike buttons and forwards the raw
// click event here. Derive WHICH button was clicked, then dispatch
// "video:reaction" — the event ModalManager listens for
// (→ app.handleVideoReaction → reactionController.handleReaction, which signs +
// publishes the kind-7). The old code called this.reactionsController.handleReaction
// (a method that doesn't exist on that controller) with the DOM event as the
// "reaction", so like/dislike silently did nothing. Also accept a bare "+"/"-"
// for programmatic callers.
let reaction = "";
if (event === "+" || event === "-") {
reaction = event;
} else {
const target =
event?.currentTarget instanceof Element
? event.currentTarget
: event?.target?.closest?.("#modalLikeBtn, #modalDislikeBtn") || null;
if (target && target === this.reactionButtons?.["+"]) {
reaction = "+";
} else if (target && target === this.reactionButtons?.["-"]) {
reaction = "-";
} else if (target?.id === "modalLikeBtn") {
reaction = "+";
} else if (target?.id === "modalDislikeBtn") {
reaction = "-";
}
}
if (reaction !== "+" && reaction !== "-") {
return;
}
this.dispatch("video:reaction", { reaction, video: this.activeVideo });
}
bindVideoEvents() {
+91
View File
@@ -12,6 +12,11 @@ import {
calculateTorrentInfoHash,
createTorrentMetadata,
} from "../../utils/torrentHash.js";
import {
deriveExternalUrlTorrent,
buildWebseedMagnet,
DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES,
} from "../../utils/externalUrlTorrent.js";
import { buildR2Key, buildPublicUrl } from "../../r2.js";
import { buildS3ObjectUrl } from "../../services/s3Service.js";
import { PROVIDERS } from "../../services/storageService.js";
@@ -307,6 +312,92 @@ export class MediaUploader {
return result;
}
/**
* Derive a WebTorrent webseed for an EXTERNALLY-hosted video URL so an external
* link keeps the P2P benefit. Best-effort: fetches the remote file (CORS-
* permitting), streams it under a size cap, computes the infoHash, and builds a
* magnet with the URL as the webseed (ws=). If storage credentials are available
* it also hosts the tiny .torrent so the webseed can bootstrap P2P (xs=);
* otherwise it returns a ws=-only magnet. Any failure throws (coded) so the
* caller degrades to URL-only.
*
* @returns {Promise<{ infoHash, magnet, torrentUrl, torrentFile, name, hasValidInfoHash }>}
*/
async deriveTorrentForExternalUrl(
url,
{ provider, credentials, onProgress, maxBytes = DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES } = {},
) {
const emit = (fraction, label) => {
if (typeof onProgress === "function") onProgress({ fraction, label });
};
emit(0, "Fetching the file to compute its hash…");
const derived = await deriveExternalUrlTorrent(url, {
maxBytes,
onProgress: ({ received, total }) => {
emit(total ? Math.min(1, received / total) : null, "Computing torrent hash…");
},
});
const result = {
infoHash: derived.infoHash,
magnet: derived.magnet,
torrentUrl: "",
torrentFile: derived.torrentFile,
name: derived.name,
hasValidInfoHash: Boolean(derived.infoHash),
};
// Host the tiny .torrent (metadata) so the webseed can actually bootstrap P2P.
// Requires storage; best-effort — a failure keeps the ws=-only magnet.
const effectiveProvider =
credentials?.provider || credentials?.meta?.provider || provider;
const npub = this.safeEncodeNpub(this.getCurrentPubkey());
if (credentials && effectiveProvider && derived.torrentFile && npub) {
try {
const service = this.serviceFor(effectiveProvider);
const { settings, bucketEntry } = await service.prepareUpload(npub, {
credentials,
});
const torrentKey = `${npub}/external/${derived.infoHash}.torrent`;
const torrentPublicUrl = this.publicUrlFor(
effectiveProvider,
bucketEntry.publicBaseUrl,
torrentKey,
);
emit(null, "Hosting torrent metadata…");
await service.uploadFile({
file: derived.torrentFile,
bucket: bucketEntry.bucket,
key: torrentKey,
accountId: settings.accountId,
endpoint: settings.endpoint,
provider: settings.provider || effectiveProvider || "cloudflare_r2",
region: settings.region,
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
forcePathStyle: settings.forcePathStyle,
createBucketIfMissing: true,
});
result.torrentUrl = torrentPublicUrl;
result.magnet = buildWebseedMagnet({
infoHash: derived.infoHash,
url,
name: derived.name,
torrentUrl: torrentPublicUrl,
});
} catch (torrentHostError) {
userLogger.warn(
"[mediaUploader] Could not host .torrent for external URL; publishing ws=-only:",
torrentHostError,
);
}
}
emit(1, "Ready to publish!");
return result;
}
}
export { isR2Provider, normalizeInfoHash, isValidInfoHash };
+167
View File
@@ -0,0 +1,167 @@
// Derive a WebTorrent webseed magnet from an externally-hosted video URL (TODO
// #45-adjacent / external-URL P2P). Posting a plain URL skips the torrent/webseed
// benefit; here we (best-effort) fetch the remote file, compute its infoHash, and
// build a magnet with the URL as the webseed (ws=). Fully optional: any failure
// (CORS, over the size cap, network) leaves the caller to fall back to URL-only.
//
// Constraints baked in:
// - CORS: fetch()-ing the remote requires the host to allow it (same requirement
// as webseed playback), so callers must treat failure as "URL-only".
// - Whole-file download: an infoHash requires reading every byte, so we stream
// with a hard size cap and abort past it (never buffer an unbounded download).
import { createTorrentMetadata as defaultCreateTorrentMetadata } from "./torrentHash.js";
export const DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES = 500 * 1024 * 1024; // 500 MB
export function deriveNameFromUrl(url) {
try {
const parsed = new URL(url);
// A path ending in "/" has no filename.
if (parsed.pathname.endsWith("/")) {
return "";
}
const last = parsed.pathname.split("/").filter(Boolean).pop() || "";
const decoded = decodeURIComponent(last);
return decoded && /\S/.test(decoded) ? decoded : "";
} catch (error) {
return "";
}
}
// magnet:?xt=urn:btih:<infoHash>&dn=<name>&ws=<url>[&xs=<torrentUrl>]
export function buildWebseedMagnet({ infoHash, url, name, torrentUrl } = {}) {
const hash = typeof infoHash === "string" ? infoHash.trim() : "";
const webseed = typeof url === "string" ? url.trim() : "";
if (!hash || !webseed) {
return "";
}
const dn = encodeURIComponent(name || deriveNameFromUrl(webseed) || "video");
let magnet = `magnet:?xt=urn:btih:${hash}&dn=${dn}&ws=${encodeURIComponent(webseed)}`;
if (typeof torrentUrl === "string" && torrentUrl.trim()) {
magnet += `&xs=${encodeURIComponent(torrentUrl.trim())}`;
}
return magnet;
}
// Fetch a URL into a Blob, streaming with a hard byte cap. Throws a coded error
// on any failure so the caller can distinguish + degrade:
// fetch-failed | too-large | (network error from fetchImpl)
export async function fetchBlobWithCap(
url,
{
maxBytes = DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES,
onProgress,
fetchImpl = typeof fetch === "function" ? fetch : null,
} = {},
) {
if (typeof fetchImpl !== "function") {
const error = new Error("fetch is unavailable in this environment.");
error.code = "fetch-unavailable";
throw error;
}
const response = await fetchImpl(url);
if (!response || !response.ok) {
const error = new Error(
`Could not fetch the URL (${response?.status ?? "no response"}).`,
);
error.code = "fetch-failed";
error.status = response?.status;
throw error;
}
const declaredTotal = Number(response.headers?.get?.("content-length")) || 0;
if (declaredTotal && declaredTotal > maxBytes) {
const error = new Error("The file is larger than the size cap.");
error.code = "too-large";
error.total = declaredTotal;
throw error;
}
const contentType = response.headers?.get?.("content-type") || "";
// Prefer streaming so we can abort a too-large download mid-flight.
if (response.body && typeof response.body.getReader === "function") {
const reader = response.body.getReader();
const chunks = [];
let received = 0;
for (;;) {
// eslint-disable-next-line no-await-in-loop
const { done, value } = await reader.read();
if (done) {
break;
}
received += value.byteLength;
if (received > maxBytes) {
try {
await reader.cancel();
} catch (cancelError) {
// ignore cancel failures
}
const error = new Error("The file is larger than the size cap.");
error.code = "too-large";
error.total = declaredTotal || received;
throw error;
}
chunks.push(value);
if (typeof onProgress === "function") {
onProgress({ received, total: declaredTotal });
}
}
return new Blob(chunks, contentType ? { type: contentType } : undefined);
}
// No streaming body: fall back to blob() and enforce the cap after the fact.
const blob = await response.blob();
if (blob.size > maxBytes) {
const error = new Error("The file is larger than the size cap.");
error.code = "too-large";
error.total = blob.size;
throw error;
}
return blob;
}
// Best-effort: fetch + hash a remote URL and return { infoHash, magnet, torrentFile,
// name }. Throws (coded) on CORS/size/network failure — the caller degrades to
// URL-only. `createMetadata` + `fileFactory` are injectable for tests.
export async function deriveExternalUrlTorrent(
url,
{
maxBytes = DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES,
onProgress,
fetchImpl,
createMetadata = defaultCreateTorrentMetadata,
fileFactory,
} = {},
) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
const error = new Error("A URL is required.");
error.code = "no-url";
throw error;
}
const blob = await fetchBlobWithCap(trimmed, { maxBytes, onProgress, fetchImpl });
const name = deriveNameFromUrl(trimmed) || "video";
const file =
typeof fileFactory === "function"
? fileFactory(blob, name)
: new File([blob], name, blob.type ? { type: blob.type } : undefined);
const metadata = await createMetadata(file, [trimmed]);
const infoHash = typeof metadata?.infoHash === "string" ? metadata.infoHash : "";
if (!infoHash) {
const error = new Error("Failed to compute the torrent info hash.");
error.code = "hash-failed";
throw error;
}
return {
infoHash,
name,
torrentFile: metadata?.torrentFile || null,
magnet: buildWebseedMagnet({ infoHash, url: trimmed, name }),
};
}
Vendored
+136 -3
View File
@@ -1,12 +1,129 @@
(() => {
"use strict";
const SW_VERSION = "bitvid-sw-2026-02-10";
const SW_VERSION = "bitvid-sw-2026-07-02.1";
const SW_METADATA = Object.freeze({
version: SW_VERSION,
runtimePathPrefix: "/webtorrent/",
});
// Cross-origin image cache (thumbnails / avatars / banners). These are the
// images bitvid does NOT control headers for (external hosts, user CDNs), so
// without this they can re-download on every visit. Strategy:
// stale-while-revalidate — serve the cached copy instantly, refresh it in the
// background — so revisits are instant AND a changed image self-heals on the
// next view. Same-origin assets are left alone (they ride the host's HTTP
// cache + the dist cache-purge pipeline). Bump the version to discard.
const IMAGE_CACHE = "bitvid-images-v1";
const IMAGE_CACHE_MAX_ENTRIES = 300;
// Trim is O(entries) (a full keys() enumeration), so amortize it instead of
// running it after every put — a cold grid load puts dozens of thumbnails in a
// burst, and per-put trims made that burst contend with the cache.match calls
// of later images (the "grid loads slowly" regression).
const IMAGE_CACHE_TRIM_EVERY = 20;
let imageCachePromise = null;
let imagePutsSinceTrim = 0;
function openImageCache() {
if (!imageCachePromise) {
imageCachePromise = caches.open(IMAGE_CACHE);
imageCachePromise.catch(() => {
imageCachePromise = null;
});
}
return imageCachePromise;
}
function isCacheableImageRequest(request) {
if (!request || request.method !== "GET") {
return false;
}
if (request.destination !== "image") {
return false;
}
const url = request.url || "";
if (!/^https?:/.test(url)) {
return false;
}
// Same-origin images (app assets) keep the normal HTTP-cache path.
try {
return new URL(url).origin !== self.location.origin;
} catch (error) {
return false;
}
}
async function trimImageCache(cache) {
try {
const keys = await cache.keys();
if (keys.length <= IMAGE_CACHE_MAX_ENTRIES) {
return;
}
// Cache keys() preserves insertion order; drop the oldest overflow.
const excess = keys.length - IMAGE_CACHE_MAX_ENTRIES;
await Promise.all(keys.slice(0, excess).map((key) => cache.delete(key)));
} catch (error) {
// Trimming is best-effort.
}
}
async function putImageInCache(request, response) {
try {
const cache = await openImageCache();
await cache.put(request, response);
imagePutsSinceTrim += 1;
if (imagePutsSinceTrim >= IMAGE_CACHE_TRIM_EVERY) {
imagePutsSinceTrim = 0;
await trimImageCache(cache);
}
} catch (error) {
// Quota pressure (opaque responses are padded heavily for quota
// accounting) or storage failure: drop the whole image cache and keep
// going — the page still got its network response.
imageCachePromise = null;
try {
await caches.delete(IMAGE_CACHE);
} catch (cleanupError) {
// fail open
}
}
}
async function handleImageRequest(event) {
const request = event.request;
// Start the network fetch IMMEDIATELY — never delay a download behind cache
// I/O. On a cold cache this makes the SW path as fast as no SW at all; on a
// warm cache the cached copy below still wins the race for first paint.
const network = fetch(request).then((response) => {
// Cache good responses; opaque (cross-origin no-cors) counts as good —
// its status is unreadable, and these are exactly the images we target.
if (response && (response.ok || response.type === "opaque")) {
event.waitUntil(putImageInCache(request, response.clone()));
}
return response;
});
// Mark handled so a network failure can't surface as an unhandled rejection
// while we're still consulting the cache; the original promise stays
// observable for the no-cache path below.
network.catch(() => {});
let cached = null;
try {
const cache = await openImageCache();
cached = await cache.match(request, { ignoreVary: true });
} catch (error) {
// Cache unavailable — plain network behavior.
}
if (cached) {
// Serve the cached copy instantly; the refresh continues in the background.
event.waitUntil(network.catch(() => {}));
return cached;
}
return network;
}
let cancelled = false;
function broadcastLifecycle(eventType, details = {}) {
@@ -46,7 +163,9 @@
event.waitUntil(Promise.all([self.skipWaiting(), broadcastLifecycle("install")]));
});
// Claim clients on activation and clear caches
// Claim clients on activation and clear caches. The CURRENT image cache is
// preserved (it is versioned; stale versions and everything else are wiped) so
// an SW update doesn't throw away every cached thumbnail/avatar.
self.addEventListener("activate", (event) => {
event.waitUntil(
Promise.all([
@@ -58,7 +177,11 @@
caches
.keys()
.then((cacheNames) =>
Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)))
Promise.all(
cacheNames
.filter((cacheName) => cacheName !== IMAGE_CACHE)
.map((cacheName) => caches.delete(cacheName))
)
),
])
);
@@ -68,6 +191,16 @@
self.addEventListener("fetch", (event) => {
const requestURL = event.request.url;
// Cross-origin images: serve from the image cache (stale-while-revalidate).
// Fail open — any handler error falls back to a plain network fetch so a
// caching bug can never break image loading.
if (isCacheableImageRequest(event.request)) {
event.respondWith(
handleImageRequest(event).catch(() => fetch(event.request))
);
return;
}
// Only handle WebTorrent streaming requests
if (!requestURL.includes("/webtorrent/")) {
return;
+65
View File
@@ -0,0 +1,65 @@
// Channel-profile cards showed BLANK view counts because the channel grid never
// subscribed its cards to the shared view counter (the main feed does). This wires
// each card's [data-view-count] element the same way. The count VALUES come from the
// same shared transport as the feed — the channel just wasn't displaying them.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-channel-card-view-count
// given: "a channel card element with a [data-view-count] span + a pointer"
// when: "subscribeChannelCardViewCount runs (and clearChannelCardViewCounts tears down)"
// then: "the element gets a non-blank view label; missing pointer/element/card are safe no-ops"
// observable_outcomes:
// - "a card with a pointer gets a non-blank view label (no longer blank)"
// - "no pointer / no [data-view-count] / no card -> no throw, no label forced"
// - "clearChannelCardViewCounts is idempotent and never throws"
// determinism_controls:
// - "JSDOM element; real module against the (uninitialised) shared view counter"
// anti_cheat_rationale:
// prevents: ["asserting the blank (broken) state", "hard-coded return value"]
// 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 { JSDOM } from "jsdom";
import {
subscribeChannelCardViewCount,
clearChannelCardViewCounts,
} from "../js/channelViewCounts.js";
function makeCard(html) {
const dom = new JSDOM(`<!DOCTYPE html><body>${html}</body>`);
return dom.window.document.body.firstElementChild;
}
test("a card with a pointer gets a non-blank view-count label (fixes the blank)", () => {
const card = makeCard('<div class="card"><span data-view-count></span></div>');
subscribeChannelCardViewCount(card, { pointer: ["a", 30078, "root-1"], key: "k1" });
const el = card.querySelector("[data-view-count]");
assert.ok(el.textContent && el.textContent.trim().length > 0, "label is not blank");
assert.match(el.textContent, /view|Loading/i, "shows a view/loading label");
clearChannelCardViewCounts();
});
test("safe no-ops: missing pointer, missing [data-view-count], or missing card", () => {
const cardNoEl = makeCard('<div class="card"></div>');
const cardWithEl = makeCard('<div class="card"><span data-view-count></span></div>');
assert.doesNotThrow(() => {
subscribeChannelCardViewCount(cardWithEl, {}); // no pointer
subscribeChannelCardViewCount(cardNoEl, { pointer: ["a", 30078, "x"] }); // no element
subscribeChannelCardViewCount(null, { pointer: ["a", 30078, "x"] }); // no card
});
// A card with no pointer must not have its (absent) element forced to a label.
assert.equal(cardWithEl.querySelector("[data-view-count]").textContent, "");
clearChannelCardViewCounts();
});
test("clearChannelCardViewCounts is idempotent and never throws", () => {
assert.doesNotThrow(() => {
clearChannelCardViewCounts();
clearChannelCardViewCounts();
});
});
+117
View File
@@ -310,3 +310,120 @@ test('does not show missing video error after modal closes', async () => {
'should not surface missing video error when closed',
);
});
// NIP-71 extras must survive a save now that the edit form only exposes hashtags
// (the rest of the NIP-71 section was removed for a cleaner UI). A hashtag edit
// must merge into the video's stored metadata — never replace it with the reduced
// form's empty fields.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-edit-nip71-preserved
// given: "a video whose stored nip71 carries imeta/textTracks/participants"
// when: "the user edits hashtags in the reduced form and submits"
// then: "updatedData.nip71 keeps the extras AND the new hashtags; nip71Edited reflects the hashtag change"
// observable_outcomes:
// - "imeta/textTracks/participants/publishedAt survive the save"
// - "hashtags (and a mirrored t key) update to the edited set"
// - "no hashtag change -> nip71Edited stays false"
// determinism_controls:
// - "JSDOM modal against the real component HTML; no network"
// anti_cheat_rationale:
// prevents: ["asserting the wiping (broken) behavior", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
test('editing hashtags preserves stored NIP-71 extras instead of wiping them', async () => {
const modal = createModal({ eventTarget: new EventTarget() });
await modal.load({ container });
const storedNip71 = {
publishedAt: 1700000000,
imeta: [{ url: 'https://cdn.example.com/v.mp4', m: 'video/mp4' }],
textTracks: [{ url: 'https://cdn.example.com/v.vtt', type: 'captions' }],
participants: [{ pubkey: 'a'.repeat(64) }],
hashtags: ['old'],
t: ['old'],
};
const video = {
id: 'video-nip71',
pubkey: 'pubkey1',
title: 'Sample video',
url: 'https://example.com/video.mp4',
thumbnail: '',
description: '',
enableComments: true,
isPrivate: false,
isNsfw: false,
isForKids: false,
nip71: storedNip71,
};
await modal.open(video);
// Edit hashtags through the reduced form (same path as the UI's Add hashtag).
const entry = modal.nip71FormManager.addRepeaterEntry('edit', 't');
modal.nip71FormManager.setFieldValue(entry, 'value', 'newtag');
const submissions = [];
modal.addEventListener('video:edit-submit', (event) => {
submissions.push(event.detail);
});
await modal.submit();
assert.equal(submissions.length, 1, 'submit emitted');
const savedNip71 = submissions[0].updatedData.nip71;
assert.equal(submissions[0].updatedData.nip71Edited, true, 'hashtag change detected');
assert.deepEqual(savedNip71.imeta, storedNip71.imeta, 'imeta preserved');
assert.deepEqual(savedNip71.textTracks, storedNip71.textTracks, 'text tracks preserved');
assert.deepEqual(savedNip71.participants, storedNip71.participants, 'participants preserved');
assert.equal(savedNip71.publishedAt, storedNip71.publishedAt, 'publishedAt preserved');
assert.deepEqual(savedNip71.hashtags, ['old', 'newtag'], 'edited hashtags applied');
assert.deepEqual(savedNip71.t, ['old', 'newtag'], 'mirrored t key kept in sync');
});
test('submitting without touching hashtags leaves nip71Edited false and extras intact', async () => {
const modal = createModal({ eventTarget: new EventTarget() });
await modal.load({ container });
const storedNip71 = {
imeta: [{ url: 'https://cdn.example.com/v.mp4', m: 'video/mp4' }],
hashtags: ['keepme'],
};
const video = {
id: 'video-nip71-b',
pubkey: 'pubkey1',
title: 'Sample video',
url: 'https://example.com/video.mp4',
thumbnail: '',
description: '',
enableComments: true,
isPrivate: false,
isNsfw: false,
isForKids: false,
nip71: storedNip71,
};
await modal.open(video);
const submissions = [];
modal.addEventListener('video:edit-submit', (event) => {
submissions.push(event.detail);
});
await modal.submit();
assert.equal(submissions.length, 1);
assert.equal(submissions[0].updatedData.nip71Edited, false, 'no hashtag change');
assert.deepEqual(
submissions[0].updatedData.nip71.imeta,
storedNip71.imeta,
'extras intact on an untouched save',
);
assert.deepEqual(submissions[0].updatedData.nip71.hashtags, ['keepme']);
});
+163
View File
@@ -0,0 +1,163 @@
// Derive a webseed magnet from an external URL (external-URL P2P). We fetch the
// remote file (best-effort, CORS-permitting), stream it under a hard size cap,
// compute the infoHash, and build a ws= magnet. Any failure must degrade to
// URL-only (the caller catches).
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-external-url-torrent
// given: "a remote URL + an injected fetch/hash"
// when: "deriveExternalUrlTorrent / fetchBlobWithCap / buildWebseedMagnet run"
// then: "magnet carries the infoHash + ws=url; oversize/fetch failures throw coded errors"
// observable_outcomes:
// - "buildWebseedMagnet: xt/dn/ws present; xs only when torrentUrl given; empty on missing infoHash/url"
// - "deriveNameFromUrl extracts + decodes the filename"
// - "fetchBlobWithCap streams under the cap and aborts past it (code too-large)"
// - "content-length over cap short-circuits before downloading"
// - "non-ok response -> fetch-failed"
// - "deriveExternalUrlTorrent returns { infoHash, magnet(ws=url) } via injected hash"
// determinism_controls:
// - "injected fetchImpl (fake Response) + createMetadata stub + fileFactory; no network/WebTorrent"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import {
buildWebseedMagnet,
deriveNameFromUrl,
fetchBlobWithCap,
deriveExternalUrlTorrent,
} from "../js/utils/externalUrlTorrent.js";
function streamResponse(bytes, { contentLength, contentType = "video/mp4", ok = true, status = 200, chunkSize = 4 } = {}) {
const headers = new Map();
if (contentLength !== null) {
headers.set("content-length", String(contentLength ?? bytes.length));
}
if (contentType) headers.set("content-type", contentType);
let offset = 0;
return {
ok,
status,
headers: { get: (k) => headers.get(k.toLowerCase()) ?? null },
body: {
getReader() {
return {
async read() {
if (offset >= bytes.length) return { done: true, value: undefined };
const end = Math.min(offset + chunkSize, bytes.length);
const value = bytes.slice(offset, end);
offset = end;
return { done: false, value };
},
async cancel() {},
};
},
},
};
}
test("buildWebseedMagnet: xt/dn/ws, xs only when torrentUrl is given", () => {
const m = buildWebseedMagnet({ infoHash: "abc123", url: "https://h/v.mp4", name: "v.mp4" });
assert.match(m, /^magnet:\?xt=urn:btih:abc123&dn=v\.mp4&ws=https%3A%2F%2Fh%2Fv\.mp4$/);
const withXs = buildWebseedMagnet({ infoHash: "abc", url: "https://h/v.mp4", torrentUrl: "https://h/v.torrent" });
assert.match(withXs, /&xs=https%3A%2F%2Fh%2Fv\.torrent$/);
});
test("buildWebseedMagnet: empty when infoHash or url is missing", () => {
assert.equal(buildWebseedMagnet({ url: "https://h/v.mp4" }), "");
assert.equal(buildWebseedMagnet({ infoHash: "abc" }), "");
assert.equal(buildWebseedMagnet({}), "");
});
test("deriveNameFromUrl extracts + decodes the filename", () => {
assert.equal(deriveNameFromUrl("https://h/path/My%20Clip.mp4"), "My Clip.mp4");
assert.equal(deriveNameFromUrl("https://h/path/"), "");
assert.equal(deriveNameFromUrl("not a url"), "");
});
test("fetchBlobWithCap streams the whole body under the cap", async () => {
const bytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const progress = [];
const blob = await fetchBlobWithCap("https://h/v.mp4", {
maxBytes: 1000,
onProgress: (p) => progress.push(p.received),
fetchImpl: async () => streamResponse(bytes, { chunkSize: 4 }),
});
assert.equal(blob.size, 10);
assert.deepEqual(progress, [4, 8, 10]);
});
test("fetchBlobWithCap aborts a stream that exceeds the cap (too-large)", async () => {
const bytes = new Uint8Array(20);
await assert.rejects(
() =>
fetchBlobWithCap("https://h/v.mp4", {
maxBytes: 8,
// no content-length header → only the streaming guard can catch it
fetchImpl: async () => streamResponse(bytes, { contentLength: null, chunkSize: 4 }),
}),
(err) => err.code === "too-large",
);
});
test("fetchBlobWithCap short-circuits on content-length over the cap (no download)", async () => {
let readerMade = false;
await assert.rejects(
() =>
fetchBlobWithCap("https://h/big.mp4", {
maxBytes: 100,
fetchImpl: async () => ({
ok: true,
status: 200,
headers: { get: (k) => (k.toLowerCase() === "content-length" ? "999999" : null) },
body: { getReader() { readerMade = true; return { read: async () => ({ done: true }) }; } },
}),
}),
(err) => err.code === "too-large" && err.total === 999999,
);
assert.equal(readerMade, false, "must not start reading the body when the size is known-too-big");
});
test("fetchBlobWithCap: non-ok response -> fetch-failed", async () => {
await assert.rejects(
() => fetchBlobWithCap("https://h/404", { fetchImpl: async () => ({ ok: false, status: 403, headers: { get: () => null } }) }),
(err) => err.code === "fetch-failed" && err.status === 403,
);
});
test("deriveExternalUrlTorrent returns infoHash + ws= magnet via the injected hasher", async () => {
const bytes = new Uint8Array([1, 2, 3, 4]);
const seen = { fileName: null, urlList: null };
const result = await deriveExternalUrlTorrent("https://host/clips/My%20Clip.mp4", {
maxBytes: 1000,
fetchImpl: async () => streamResponse(bytes),
fileFactory: (blob, name) => ({ blob, name }),
createMetadata: async (file, urlList) => {
seen.fileName = file.name;
seen.urlList = urlList;
return { infoHash: "deadbeef", torrentFile: new Uint8Array([9]) };
},
});
assert.equal(result.infoHash, "deadbeef");
assert.equal(seen.fileName, "My Clip.mp4", "file name derived from the URL");
assert.deepEqual(seen.urlList, ["https://host/clips/My%20Clip.mp4"], "url passed as the webseed");
assert.match(result.magnet, /xt=urn:btih:deadbeef/);
assert.match(result.magnet, /ws=https%3A%2F%2Fhost%2Fclips%2FMy%2520Clip\.mp4/);
assert.ok(result.torrentFile, "torrentFile returned for optional .torrent hosting");
});
test("deriveExternalUrlTorrent propagates a fetch failure (caller degrades to URL-only)", async () => {
await assert.rejects(
() =>
deriveExternalUrlTorrent("https://host/blocked.mp4", {
fetchImpl: async () => ({ ok: false, status: 0, headers: { get: () => null } }),
createMetadata: async () => ({ infoHash: "x" }),
}),
(err) => err.code === "fetch-failed",
);
});
+106
View File
@@ -0,0 +1,106 @@
// Cold-load "first impression" fix: the post-login feed gate can open as soon as
// blocks/subscriptions/hashtags hold valid per-pubkey CACHES (warm boot), instead
// of waiting for the full relay sync + decryption. These test the probe + waiter
// that implement the early release.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-feed-list-cache-gate
// given: "list services with/without valid per-pubkey caches"
// when: "createListCacheProbe / waitForListCaches run"
// then: "probe is true only when ALL THREE caches match the active pubkey; the waiter resolves 'cache' early or null at timeout"
// observable_outcomes:
// - "all three caches valid + matching pubkey -> probe true"
// - "any cache missing / stale pubkey / not loaded -> probe false"
// - "waiter resolves 'cache' once the probe flips true (caches hydrating late)"
// - "waiter resolves null at timeout when caches never hydrate (cold boot)"
// determinism_controls:
// - "plain state objects; short real timers (interval 5ms, timeout 40ms)"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import {
createListCacheProbe,
waitForListCaches,
} from "../js/app/feedListCacheGate.js";
const PUB = "A".repeat(64); // exercise normalization (upper → lower)
function services({ blocks = true, subs = true, tags = true, pubkey = PUB } = {}) {
return {
userBlocks: { loaded: blocks, activePubkey: pubkey },
subscriptions: { loaded: subs, currentUserPubkey: pubkey },
hashtagPreferences: { loaded: tags, activePubkey: pubkey },
};
}
const normalize = (v) => (typeof v === "string" ? v.trim().toLowerCase() : "");
test("probe is true only when ALL three caches are loaded for the active pubkey", () => {
const ok = createListCacheProbe({
...services(),
normalizeHexPubkey: normalize,
activePubkey: PUB.toLowerCase(),
});
assert.equal(ok(), true);
for (const partial of [
services({ blocks: false }),
services({ subs: false }),
services({ tags: false }),
services({ pubkey: "b".repeat(64) }), // caches belong to another account
]) {
const probe = createListCacheProbe({
...partial,
normalizeHexPubkey: normalize,
activePubkey: PUB.toLowerCase(),
});
assert.equal(probe(), false);
}
});
test("probe is false with no active pubkey or missing services", () => {
assert.equal(
createListCacheProbe({ ...services(), normalizeHexPubkey: normalize, activePubkey: "" })(),
false,
);
assert.equal(
createListCacheProbe({ normalizeHexPubkey: normalize, activePubkey: PUB })(),
false,
);
});
test("waiter resolves 'cache' once caches hydrate (late flip)", async () => {
const state = { ready: false };
setTimeout(() => {
state.ready = true;
}, 15);
const result = await waitForListCaches(() => state.ready, {
timeoutMs: 500,
intervalMs: 5,
});
assert.equal(result, "cache");
});
test("waiter resolves null at timeout when caches never hydrate (cold boot)", async () => {
const result = await waitForListCaches(() => false, {
timeoutMs: 40,
intervalMs: 5,
});
assert.equal(result, null);
});
test("a throwing probe is treated as not-ready (never breaks the gate)", async () => {
const result = await waitForListCaches(
() => {
throw new Error("boom");
},
{ timeoutMs: 30, intervalMs: 5 },
);
assert.equal(result, null);
});
+34
View File
@@ -80,4 +80,38 @@ describe("NostrService", () => {
assert.equal(mockClient.pool.list.mock.callCount(), 1);
});
});
// Account switch must not leave the previous account's DMs on screen: the DM
// store lives here (nostrService), so a switch clears the in-memory store. With
// keepSnapshot it preserves each account's persisted cache for fast switch-back.
describe("clearDirectMessages", () => {
it("empties the in-memory DM store and resets the active actor", () => {
nostrService.dmMessages = [{ id: "m1" }, { id: "m2" }];
nostrService.dmMessageIndex = new Map([["m1", 0], ["m2", 1]]);
nostrService.dmActorPubkey = "a".repeat(64);
const events = [];
const originalEmit = nostrService.emit;
nostrService.emit = (name) => events.push(name);
try {
nostrService.clearDirectMessages({ emit: true, keepSnapshot: true });
} finally {
nostrService.emit = originalEmit;
}
assert.deepEqual(nostrService.dmMessages, []);
assert.equal(nostrService.dmMessageIndex.size, 0);
assert.equal(nostrService.dmActorPubkey, null);
assert.ok(events.includes("directMessages:cleared"));
assert.ok(events.includes("directMessages:updated"));
});
it("clears the in-memory store in both keepSnapshot modes (logout parity)", () => {
nostrService.dmMessages = [{ id: "m1" }];
nostrService.dmMessageIndex = new Map([["m1", 0]]);
nostrService.clearDirectMessages({ emit: false, keepSnapshot: false });
assert.deepEqual(nostrService.dmMessages, []);
assert.equal(nostrService.dmMessageIndex.size, 0);
});
});
});
+18 -6
View File
@@ -4,7 +4,7 @@
// key survived, so switching among saved nsec accounts silently failed.
//
// test_integrity_note:
// change_type: ["new_tests"]
// change_type: ["new_tests", "spec_correction"]
// scenarios:
// - id: SCN-nsec-multi-account-storage
// given: "two nsec accounts each persist their encrypted key"
@@ -83,15 +83,27 @@ test("no-arg read returns the last-saved account as the default", () => {
assert.equal(readStoredSessionActorEntry().privateKeyEncrypted, "cipher-B");
});
test("no-arg clear wipes every stored account (logout)", () => {
// spec_correction: the previous test asserted "no-arg clear wipes every stored
// account (logout)". That encoded a bug: the no-arg clear is reached from
// per-account paths (SignerManager.logout's non-persisted-nsec cleanup, the
// blocked-key cleanup in registerPrivateKeySigner), so logging out ONE account
// silently destroyed every OTHER saved account's remembered key and broke
// switching back to them — the same bug class as the NIP-46 session-map wipe.
// Correct behavior: no-arg clears only the legacy v1 "last saved" slot; the
// per-account v2 map survives. Forgetting a specific account uses the targeted
// clearStoredSessionActor(pubkey) (tested above).
test("no-arg clear drops the legacy v1 slot but KEEPS per-account saved keys", () => {
persistSessionActor(actor(PUB_A, "cipher-A"));
persistSessionActor(actor(PUB_B, "cipher-B"));
clearStoredSessionActor();
clearStoredSessionActor(); // e.g. one account's logout cleanup
assert.deepEqual(listStoredSessionActorPubkeys(), []);
assert.equal(readStoredSessionActorEntry(PUB_A), null);
assert.equal(readStoredSessionActorEntry(), null);
// Both accounts remain switchable — their remembered keys survive.
assert.deepEqual(listStoredSessionActorPubkeys().sort(), [PUB_A, PUB_B].sort());
assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "cipher-A");
assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B");
// The legacy single-slot default is gone.
assert.equal(localStorage.getItem(SESSION_ACTOR_STORAGE_KEY), null);
});
test("a legacy v1 single-slot entry migrates into the per-pubkey map", () => {
+63
View File
@@ -0,0 +1,63 @@
// Cache-Control policy for uploaded media (TODO #55, part 1). Upload keys are
// content-addressed (buildR2Key namespaces by infohash), so media should get a
// long immutable Cache-Control — browsers then keep thumbnails/videos/torrents
// across visits. Mutable playlists (m3u8/mpd) must stay short-lived.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-upload-cache-control
// given: "an object key by extension"
// when: "computeCacheControl derives the Cache-Control header"
// then: "media/torrents are immutable-year; playlists revalidate; unknown gets the default"
// observable_outcomes:
// - "video + image (incl webp/avif) + .torrent keys -> public, max-age=31536000, immutable"
// - "m3u8/mpd -> public, max-age=60, must-revalidate"
// - "unknown/empty keys -> the 1h default (never immutable)"
// determinism_controls:
// - "pure function; explicit inputs"
// anti_cheat_rationale:
// prevents: ["hard-coded return value"]
// 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 { computeCacheControl } from "../js/storage/s3-multipart.js";
const IMMUTABLE = "public, max-age=31536000, immutable";
test("videos, images (incl. webp/avif), and torrents are immutable for a year", () => {
for (const key of [
"u/npub1x/abc123/video.mp4",
"u/npub1x/abc123/clip.webm",
"u/npub1x/abc123/thumb.jpg",
"u/npub1x/abc123/thumb.webp",
"u/npub1x/abc123/poster.avif",
"u/npub1x/abc123/video.torrent",
"u/npub1x/external/deadbeef.torrent",
"SHOUTY/PATH/THUMB.PNG",
]) {
assert.equal(computeCacheControl(key), IMMUTABLE, key);
}
});
test("mutable playlists stay short-lived (live manifests change in place)", () => {
assert.equal(
computeCacheControl("u/npub1x/live/stream.m3u8"),
"public, max-age=60, must-revalidate",
);
assert.equal(
computeCacheControl("u/npub1x/live/stream.mpd"),
"public, max-age=60, must-revalidate",
);
});
test("unknown or missing keys fall back to the 1h default, never immutable", () => {
for (const key of ["u/npub1x/uploads/file.bin", "no-extension", "", null, undefined]) {
const value = computeCacheControl(key);
assert.equal(value, "public, max-age=3600", String(key));
assert.ok(!value.includes("immutable"));
}
});
+304
View File
@@ -0,0 +1,304 @@
// Service-worker image cache (TODO #55, part 2): cross-origin images
// (thumbnails / avatars / banners) are served stale-while-revalidate from the
// Cache API so revisits are instant even when the host sends no cache headers.
// These tests load the REAL sw.min.js in a VM sandbox with stubbed SW globals and
// drive its actual fetch/activate handlers.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-sw-image-cache
// given: "the real sw.min.js evaluated with stubbed self/caches/fetch"
// when: "fetch events fire for cross-origin images / same-origin images / other URLs, and activate fires"
// then: "cross-origin images are cached + served stale-while-revalidate; others untouched; activate preserves ONLY the current image cache"
// observable_outcomes:
// - "cold fetch -> network response returned AND stored in bitvid-images-v1"
// - "warm fetch -> cached body served; background refresh re-fetches"
// - "network failure with a cached copy -> cached copy served"
// - "same-origin image + non-image URL -> respondWith never called"
// - "activate wipes other caches (incl. old image-cache versions) but keeps bitvid-images-v1"
// determinism_controls:
// - "vm sandbox; Map-backed CacheStorage stub; controllable fetch stub; no network"
// anti_cheat_rationale:
// prevents: ["testing a re-implementation instead of the shipped file", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import { readFile } from "node:fs/promises";
import vm from "node:vm";
const swSource = await readFile(new URL("../sw.min.js", import.meta.url), "utf8");
function makeCacheStorage() {
const stores = new Map(); // name -> Map(url -> response)
return {
stores,
async open(name) {
if (!stores.has(name)) {
stores.set(name, new Map());
}
const store = stores.get(name);
return {
async match(request) {
return store.get(request.url) || undefined;
},
async put(request, response) {
store.set(request.url, response);
},
async keys() {
return Array.from(store.keys()).map((url) => ({ url }));
},
async delete(request) {
return store.delete(request.url);
},
};
},
async keys() {
return Array.from(stores.keys());
},
async delete(name) {
return stores.delete(name);
},
};
}
function makeResponse(body, { ok = true, type = "basic" } = {}) {
return {
body,
ok,
type,
clone() {
return makeResponse(body, { ok, type });
},
};
}
function loadSw({ fetchImpl } = {}) {
const listeners = new Map();
const cacheStorage = makeCacheStorage();
const sandbox = {
location: { origin: "https://bitvid.network" },
registration: { scope: "/" },
addEventListener: (type, handler) => listeners.set(type, handler),
skipWaiting: async () => {},
clients: { matchAll: async () => [], claim: async () => {} },
caches: cacheStorage,
fetch: fetchImpl || (async () => makeResponse("net")),
URL,
Response,
Headers,
ReadableStream,
MessageChannel,
Promise,
Object,
Array,
setTimeout,
clearTimeout,
console,
};
sandbox.self = sandbox;
vm.createContext(sandbox);
vm.runInContext(swSource, sandbox);
return { listeners, cacheStorage, sandbox };
}
function makeFetchEvent(url, { destination = "image", method = "GET" } = {}) {
const event = {
request: { url, destination, method },
response: null,
pending: [],
respondWith(promise) {
this.response = Promise.resolve(promise);
},
waitUntil(promise) {
this.pending.push(Promise.resolve(promise).catch(() => {}));
},
};
return event;
}
const IMG = "https://cdn.example.com/thumb.jpg";
test("cold cross-origin image: network response served AND cached in bitvid-images-v1", async () => {
let fetches = 0;
const { listeners, cacheStorage } = loadSw({
fetchImpl: async () => {
fetches += 1;
return makeResponse(`net-${fetches}`);
},
});
const event = makeFetchEvent(IMG);
listeners.get("fetch")(event);
const response = await event.response;
assert.equal(response.body, "net-1", "network response returned to the page");
await Promise.all(event.pending);
const store = cacheStorage.stores.get("bitvid-images-v1");
assert.ok(store?.has(IMG), "response was stored in the image cache");
});
test("warm cross-origin image: cached copy served, background refresh still fetches", async () => {
let fetches = 0;
const { listeners, cacheStorage } = loadSw({
fetchImpl: async () => {
fetches += 1;
return makeResponse(`net-${fetches}`);
},
});
// Warm the cache.
const first = makeFetchEvent(IMG);
listeners.get("fetch")(first);
await first.response;
await Promise.all(first.pending);
assert.equal(fetches, 1);
// Second visit: served from cache; the revalidation fetch still happens.
const second = makeFetchEvent(IMG);
listeners.get("fetch")(second);
const response = await second.response;
assert.equal(response.body, "net-1", "cached copy served immediately");
await Promise.all(second.pending);
assert.equal(fetches, 2, "background refresh re-fetched the image");
assert.equal(
cacheStorage.stores.get("bitvid-images-v1").get(IMG).body,
"net-2",
"cache updated by the background refresh (self-heals changed images)",
);
});
test("network failure with a cached copy: cached copy served (offline-friendly)", async () => {
let shouldFail = false;
const { listeners } = loadSw({
fetchImpl: async () => {
if (shouldFail) {
throw new Error("offline");
}
return makeResponse("net-1");
},
});
const warm = makeFetchEvent(IMG);
listeners.get("fetch")(warm);
await warm.response;
await Promise.all(warm.pending);
shouldFail = true;
const offline = makeFetchEvent(IMG);
listeners.get("fetch")(offline);
const response = await offline.response;
assert.equal(response.body, "net-1", "cached copy served when the network fails");
await Promise.all(offline.pending);
});
test("same-origin images and non-image requests are NOT intercepted", () => {
const { listeners } = loadSw();
const sameOrigin = makeFetchEvent("https://bitvid.network/assets/logo.png");
listeners.get("fetch")(sameOrigin);
assert.equal(sameOrigin.response, null, "same-origin image left to the HTTP cache");
const nonImage = makeFetchEvent("https://cdn.example.com/data.json", {
destination: "fetch",
});
listeners.get("fetch")(nonImage);
assert.equal(nonImage.response, null, "non-image request untouched");
const post = makeFetchEvent(IMG, { method: "POST" });
listeners.get("fetch")(post);
assert.equal(post.response, null, "non-GET untouched");
});
test("activate wipes other caches but preserves the CURRENT image cache", async () => {
const { listeners, cacheStorage } = loadSw();
// Seed: the live image cache, an old image-cache version, and a stray cache.
(await cacheStorage.open("bitvid-images-v1")).put({ url: IMG }, makeResponse("keep"));
await cacheStorage.open("bitvid-images-v0");
await cacheStorage.open("some-old-cache");
const event = { pending: [], waitUntil(p) { this.pending.push(p); } };
listeners.get("activate")(event);
await Promise.all(event.pending);
const names = await cacheStorage.keys();
assert.deepEqual(names, ["bitvid-images-v1"], "only the current image cache survives");
assert.equal(
cacheStorage.stores.get("bitvid-images-v1").get(IMG).body,
"keep",
"cached thumbnails survive an SW update",
);
});
test("the network fetch starts immediately — never serialized behind cache I/O", async () => {
// Regression: the first version awaited caches.open + cache.match BEFORE
// starting the download, so a cold thumbnail burst loaded visibly slower than
// with no SW at all ("Recently Added fetching slowly").
let fetchStarted = false;
let releaseMatch;
const matchGate = new Promise((resolve) => {
releaseMatch = resolve;
});
const { listeners, sandbox } = loadSw({
fetchImpl: async () => {
fetchStarted = true;
return makeResponse("net-1");
},
});
// Make cache.match hang until released — the download must not wait for it.
const realOpen = sandbox.caches.open.bind(sandbox.caches);
sandbox.caches.open = async (name) => {
const cache = await realOpen(name);
return {
...cache,
async match(request) {
await matchGate;
return cache.match(request);
},
};
};
const event = makeFetchEvent(IMG);
listeners.get("fetch")(event);
await Promise.resolve(); // let the handler take its first steps
assert.equal(fetchStarted, true, "download began while cache.match was still pending");
releaseMatch();
const response = await event.response;
assert.equal(response.body, "net-1");
await Promise.all(event.pending);
});
test("opaque (cross-origin no-cors) responses are cached; failed basic responses are not", async () => {
let mode = "opaque";
const { listeners, cacheStorage } = loadSw({
fetchImpl: async () =>
mode === "opaque"
? makeResponse("opaque-body", { ok: false, type: "opaque" })
: makeResponse("error-body", { ok: false, type: "basic" }),
});
const opaqueEvent = makeFetchEvent(IMG);
listeners.get("fetch")(opaqueEvent);
await opaqueEvent.response;
await Promise.all(opaqueEvent.pending);
assert.ok(
cacheStorage.stores.get("bitvid-images-v1")?.has(IMG),
"opaque response cached (status unreadable by design)",
);
mode = "error";
const errUrl = "https://cdn.example.com/broken.png";
const errEvent = makeFetchEvent(errUrl);
listeners.get("fetch")(errEvent);
await errEvent.response;
await Promise.all(errEvent.pending);
assert.ok(
!cacheStorage.stores.get("bitvid-images-v1").has(errUrl),
"a non-ok basic response (404/500) is not cached",
);
});
+118
View File
@@ -190,3 +190,121 @@ test("VideoModal trims tag strip to fit modal width", async (t) => {
);
});
// Channel-profile link in the player modal (TODO #53): clicking the creator
// avatar/name must navigate to the creator's channel. It dispatched
// "navigate:profile", which had NO listener (ModalManager listens for
// "creator:navigate"), so the click did nothing — same class as the old
// action:embed bug. Now it dispatches "creator:navigate" with the pubkey.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-video-modal-creator-nav
// given: "a video modal with an active video"
// when: "handleCreatorNavigation runs (creator avatar/name click)"
// then: "it dispatches creator:navigate with the pubkey, not the dead navigate:profile"
// observable_outcomes:
// - "creator:navigate fires with { pubkey }"
// - "the old dead navigate:profile event never fires"
// determinism_controls:
// - "JSDOM modal via setupModal; synchronous dispatch"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "asserting the broken event name"]
// relaxation:
// did_relax_any_assertion: false
test("creator link dispatches creator:navigate with the pubkey (not the dead navigate:profile)", async (t) => {
const { modal, cleanup } = await setupModal();
t.after(cleanup);
modal.activeVideo = { pubkey: "a".repeat(64) };
let creatorNavigate = null;
let deadEventFired = false;
modal.addEventListener("creator:navigate", (event) => {
creatorNavigate = event.detail;
});
modal.addEventListener("navigate:profile", () => {
deadEventFired = true;
});
modal.handleCreatorNavigation();
assert.ok(creatorNavigate, "creator:navigate should fire (ModalManager listens for it)");
assert.equal(creatorNavigate.pubkey, "a".repeat(64));
assert.equal(deadEventFired, false, "the old listener-less navigate:profile must not be used");
});
test("creator navigation is a no-op without an active video pubkey", async (t) => {
const { modal, cleanup } = await setupModal();
t.after(cleanup);
modal.activeVideo = { pubkey: "" };
let fired = false;
modal.addEventListener("creator:navigate", () => {
fired = true;
});
modal.handleCreatorNavigation();
assert.equal(fired, false, "no pubkey → no navigation event");
});
// Like/dislike in the player modal (TODO #54): clicking a reaction button must
// dispatch "video:reaction" (→ app.handleVideoReaction → reactionController). The
// old handleReactionClick called this.reactionsController.handleReaction (a method
// that doesn't exist on the video-modal reactions controller) with the raw DOM
// event, so nothing was published.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-video-modal-reaction-dispatch
// given: "a video modal with an active video"
// when: "handleReactionClick runs for a like/dislike button or a bare +/-"
// then: "it dispatches video:reaction with the derived reaction; unrelated targets do nothing"
// observable_outcomes:
// - "bare '+'/'-' → video:reaction with that reaction"
// - "a click whose currentTarget is the like button → reaction '+'"
// - "a null/unrelated target → no dispatch"
// determinism_controls:
// - "JSDOM modal via setupModal; synchronous dispatch"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "asserting the broken call path"]
// relaxation:
// did_relax_any_assertion: false
test("like/dislike dispatches video:reaction with the derived reaction", async (t) => {
const { modal, cleanup } = await setupModal();
t.after(cleanup);
modal.activeVideo = { pubkey: "a".repeat(64) };
const events = [];
modal.addEventListener("video:reaction", (event) => events.push(event.detail));
modal.handleReactionClick("+");
modal.handleReactionClick("-");
assert.equal(events.length, 2, "both reactions dispatched");
assert.equal(events[0].reaction, "+");
assert.equal(events[1].reaction, "-");
assert.equal(events[0].video, modal.activeVideo, "carries the active video");
// And via a real button click (currentTarget = the bound like button).
if (modal.reactionButtons?.["+"]) {
modal.handleReactionClick({ currentTarget: modal.reactionButtons["+"] });
assert.equal(events[2]?.reaction, "+", "derived '+' from the like button element");
}
});
test("reaction click with no matching button does not dispatch", async (t) => {
const { modal, cleanup } = await setupModal();
t.after(cleanup);
let fired = false;
modal.addEventListener("video:reaction", () => {
fired = true;
});
modal.handleReactionClick({ currentTarget: null });
modal.handleReactionClick({});
assert.equal(fired, false, "no reaction event for an unrelated/empty target");
});
+80 -17
View File
@@ -1636,10 +1636,15 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that.
`onVideosShouldRefresh` path #46 uses, keyed by `videoRootId`.
### 53. Channel-profile link in the video player modal doesn't work (BUG)
- [ ] Clicking the channel/creator link inside the video player modal does nothing
(should navigate to that author's channel profile view). Audit the modal's
channel-link handler (`js/ui/videoModal*` / the author byline) — likely a missing
or wrong nav callback (compare with the working channel links in the grid cards).
- [x] **FIXED 2026-07-02.** Event-name mismatch: `VideoModal.handleCreatorNavigation`
dispatched `"navigate:profile"`, but ModalManager only listens for
`"creator:navigate"` → the click went into the void (same class as the old
`action:embed` bug). Renamed the dispatch to `"creator:navigate"` and threaded the
modal's own creator pubkey through: the handler now passes `event.detail.pubkey`
to `app.openCreatorChannel(pubkey)`, and `routerCoordinator.openCreatorChannel`
accepts an optional pubkey (falls back to the active video's creator). Tests:
`tests/video-modal-controllers.test.mjs` (+2 — dispatches creator:navigate with
the pubkey, never the dead event; no-op without a pubkey). Lint + build green.
### 54. Like / dislike buttons in the video player modal don't work (BUG)
- [ ] The reaction (like/dislike) buttons in the player modal are unresponsive / don't
@@ -1654,21 +1659,50 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that.
Tests: `tests/reaction-signer-gate.test.mjs`. **Still VERIFY on unstable** whether
any residual dead-button behavior remains (event binding / pointer availability),
which would be a separate wiring bug from the signer issue.
- [x] **The actual dead-button bug — FIXED 2026-07-02.** `VideoModal.handleReactionClick`
never derived the reaction or reached the publish path: it called
`this.reactionsController.handleReaction(event)` — a method that does NOT exist on
the video-modal reactions controller (which only binds buttons + does optimistic
UI), passing the raw DOM click event as the "reaction". So the #54 signer gate was
correct but unreachable. Now `handleReactionClick` derives `+`/`-` from the clicked
button (`#modalLikeBtn`/`#modalDislikeBtn`) and dispatches `video:reaction` — the
event ModalManager already routes to `app.handleVideoReaction` →
`reactionController.handleReaction` (which signs/publishes + runs the signer gate).
Tests: `tests/video-modal-controllers.test.mjs` (+2 — derives the reaction and
dispatches; unrelated target is a no-op). Lint + build green.
### 55. Video-grid event caching — faster loads, cache-first + merge-new (PERF / UX)
- [ ] **Channel-profile grid still loads slowly.** Even after the coalescing + relay
timeout + parity-seed fix, the channel wall streams in slowly. Cache that channel's
events so revisits render instantly.
- [ ] **Generalize to every grid:** cache the events behind each video grid (feed tabs +
channel walls) so they render from cache immediately, then refresh in the
background and **merge in only new events** (don't blank + re-fetch). Fresh content
still matters — proposed balance: **cache-and-refresh simultaneously**, appending
new events as they arrive, with an explicit **pull-to-refresh / refresh button**
for a forced re-fetch. Decide cache store (in-memory vs IndexedDB via
`js/state/cache.js`) + a staleness/TTL policy.
- [ ] **Cache media too:** thumbnails and profile images/banners should be cached (browser
cache headers / a warm cache / persistent store) so they don't reload from scratch
on every visit. Cross-ref `js/channelProfile.js` and the card renderers.
- [x] **VERIFIED 2026-07-02: event caching already exists — no gap.** Traced end to
end: `allEvents` (incl. ingested NIP-71 videos via `nip71IngestService.injectVideo`)
is persisted to IndexedDB (`bitvid:eventsCache:v1`, `PersistenceManager` +
`EventsCacheStore`), restored on boot (`restoreLocalData`), and `loadVideos` +
the channel grid render **cache-first then merge-new** (never blank+refetch).
Bullets 1–2 were already built; the remaining slowness is only a genuinely-cold
first visit, already bounded by the coalescing/timeout/parity fixes.
- [x] **Media caching part 1 — Cache-Control on uploads — DONE 2026-07-02.**
`computeCacheControl` (js/storage/s3-multipart.js) already stamped most media
immutable; added the missing `webp`/`avif`/`ico` **and `.torrent`** (safe:
`buildR2Key` namespaces by infohash → content-addressed). Playlists (m3u8/mpd)
stay short-lived. Exported + tested (`tests/storage-cache-control.test.mjs`, 3).
- [x] **Media caching part 2 — SW image cache — DONE 2026-07-02.** `sw.min.js` now
serves **cross-origin images** (thumbnails/avatars/banners — the hosts bitvid
doesn't control headers for) **stale-while-revalidate** from `bitvid-images-v1`
(Cache API): instant on revisit, background refresh self-heals changed images,
LRU-capped at 300 entries, quota errors fail open (drop the cache, never break
image loading), and every handler error falls back to plain `fetch`. The
activate handler's wipe-all now **preserves the current image cache** (old
versions + other caches still wiped). Same-origin assets are untouched (they
ride the host HTTP cache + dist cache-purge pipeline). `app._initServiceWorker`
now registers the SW at boot when none exists (same path/scope/options as
`webtorrent.js`'s `setupServiceWorker`, so playback's later `register()` is a
no-op on the same registration). SW_VERSION bumped. Tests:
`tests/sw-image-cache.test.mjs` (6 — runs the REAL sw.min.js in a VM sandbox:
cold/warm/offline/opaque flows, non-interception, activate preservation).
- [ ] **VERIFY on unstable:** revisit a feed/channel — thumbnails + avatars should
load instantly from the SW cache (DevTools → Network shows "(ServiceWorker)");
after an SW update the image cache survives. Optional follow-up: pull-to-refresh
/ manual refresh affordance for forced re-fetch (nice-to-have; background
merge-new already keeps grids fresh).
### 56. Upload & edit modals: prompt to unlock storage (PIN popup), don't just error (UX)
- [ ] When storage is locked, the **upload** and **edit** modals should open the
@@ -1725,3 +1759,32 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that.
(b) like/dislike a video, (c) block/unblock a creator, (d) subscribe to a channel,
(e) send a DM — each should show ONE passphrase prompt (not a blanket error), then
work; the single unlock should cover the rest until the next reload.
### 58. Remove NIP-71 authoring UI from upload/edit modals (clean UI)
- [x] **DONE 2026-07-02.** Decision (maintainer): bitvid authors kind-30078 and
MIRRORS to NIP-71 — it does not author NIP-71 directly — so the NIP-71 form
sections were confusing dead weight. Verified before removing: the My Videos
mirror (`buildNip71MirrorEvent`) derives EVERYTHING from the bitvid note
(title/url/magnet/thumbnail/description, duration+dims auto-captured at upload,
contentWarning from the NSFW toggle, hashtags), and the only consumer of the
form's NIP-71 metadata (`publishNip71Video`) is gated behind
`FEATURE_PUBLISH_NIP71: false`.
Removed: upload modal's "NIP-71 Technical" (content warning / duration /
summary+Customize) + IMETA variant editor; edit modal's kind selector,
published_at, alt, duration, content warning, summary, IMETA, text tracks,
segments, participants, references. KEPT: hashtags in both modals (they feed
hashtag feeds, the suggestion chips, and the mirror's `t` tags) and the web
seeds / torrent (xs) inputs (bitvid-note fields, not NIP-71).
Compatibility: existing videos' NIP-71 extras are PRESERVED on save — EditModal
keeps the full stored metadata (`storedNip71Metadata`) and merges the edited
hashtags into it instead of replacing it with the reduced form's empty output.
Also fixed a latent bug this exposed: `nip71Edited` compared a normalized
submit-side copy against an unnormalized original (publishedAt string vs
timestamp), so every save looked "edited"; both sides now share
`normalizeCollectedNip71`. Tests: +2 in `tests/edit-modal-submit-state.test.mjs`
(extras survive a hashtag edit; untouched save → nip71Edited false).
- [ ] **Post-launch cleanup (Option B, optional):** delete the dormant
`FEATURE_PUBLISH_NIP71` auto-publish path, the unused `Nip71FormManager`
sections (imeta/text-track/segment/p/r + scalar inputs), and the nip71
plumbing through `videoNotePayload` that only the removed forms fed.
RevertModal keeps its READ-ONLY use of the manager (version-history display).