mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
commit
8b73565904
31 files changed
+2104
-736
No files matched your search
@@ -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 & 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 & 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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user