Compare commits

..
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 5 9f20907f35 Merge unstable into main
Brings 14 commits to the production branch, including the BitLogin widget
re-vendored at 205b067 with recorded provenance.

main and unstable had diverged: both independently committed a "security-
audit build" widget update (0592c0ee here, 847a855e there), so the merge
conflicted on six vendor/bitlogin files, including a rename/rename on the
shared chunk -- Vite content-hashes those filenames, so two builds of the
same source produce different names.

Every conflict resolved by taking unstable's side wholesale. Vendored files
are build output, not authored source; a hand-merged bundle would correspond
to no build and no commit. The result was then verified against a clean
rebuild of BitLogin at 205b067 rather than trusted: vendor/bitlogin is
byte-identical to packages/widget/dist from a from-scratch build, the file
set matches origin/unstable exactly, and the manifest reads
provenance=attributed at 205b0678cc67.

main's widget was the OLDER build (0d118d59), predating BitLogin's
second-audit hardening, so this moves production forward rather than back.

Existing sign-ins are unaffected. normalize.ts, argon2id.ts, hkdf.ts and
scalarExpand.ts are byte-identical across this range, so login name plus
password derive the same locator, signing scalar and capsule key. Accounts
created with a hand-typed password still work -- the manual-password removal
took away the creation, recovery and rotation controls, not the acceptance.
What changes: creating, recovering or rotating now issues a generated
six-word passphrase.

Full suite green on the merged tree: 2354 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
2026-07-31 23:22:47 -04:00
thePR0M3TH3ANandClaude Opus 5 fbac54dd48 Re-vendor BitLogin at 205b067, and record its provenance
Updates the vendored widget to BitLogin 205b067, which includes the
second-audit hardening the previous bytes predated -- phrase-gated sudo key,
deterministic migration, the pinned derivation chain -- plus the entropy
work: the closed nonce/IV surface, fail-closed RNG tests, and the
package-wide Math.random ban.

Also ports the provenance recording from BitUnlock's vendoring script. The
manifest now carries repository, commit, commit date and toolchain, and the
script refuses to vendor from a BitLogin checkout with uncommitted changes,
resolved before vendorDir is deleted so the refusal leaves the committed
files intact. Hashes prove the bytes have not changed since they were
committed; only the commit says which BitLogin produced them, and version
"0.1.0" has covered many substantive changes.

EXISTING SIGN-INS ARE UNAFFECTED, which matters most here since this is the
one consumer with real users. normalize.ts, argon2id.ts, hkdf.ts and
scalarExpand.ts are byte-identical between the previously vendored build and
205b067, so login name + password derive the same locator, signing scalar
and capsule key as before. Accounts created with a hand-typed password still
sign in: the manual-password removal took away the creation, recovery and
rotation CONTROLS, not the acceptance, and nothing on the login path
inspects the password's shape. BitLogin's legacyPassword.e2e.test.ts pins
that, including the migration path of signing in with an old typed password
and rotating onto a generated one.

What does change for users: creating a new account, recovering, or rotating
now hands out a generated six-word passphrase instead of accepting a typed
one.

2354 unit tests pass, including the 10 bitlogin-specific ones in
auth-providers-bitlogin and bitlogin-modal-integration.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
2026-07-31 22:17:08 -04:00
thePR0M3TH3ANandClaude Fable 5 58922e55c3 Record the caching decision: content-hash filenames, then immutable
Option A chosen. Noting explicitly that Option B should not ship as an interim
step -- the two are not stackable, and a bounded max-age shipped now is the
thing that never gets revisited.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 14:53:07 -04:00
thePR0M3TH3ANandClaude Fable 5 5e69fa4a7d Todo: static asset caching, and why "just add immutable" would break it
/css/* and /js/* have no header rule at all, so every stylesheet and script
revalidates on every page load. The obvious fix is a trap: asset URLs are not
content-hashed, so a long max-age with immutable would pin returning users to
stale CSS/JS after each deploy with no way to bust it. Records both real
options and the measured live headers.

Also corrects an earlier hand-off note claiming ~10 failing tests here; all
three suites pass on unstable as of today.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 12:31:36 -04:00
thePR0M3TH3ANandClaude Fable 5 8d40d85079 Delete netlify.toml; the site deploys on Vercel
Vestigial. Verified dead before removing: the live headers on
bitvid.network come from vercel.json, not from this file -- /js/* is
served with Vercel's default `max-age=0, must-revalidate` rather than
the `immutable, max-age=31536000` netlify.toml asks for, which is proof
the file is not consulted by anything deploying this site. It cost real
time today by making the deploy target look like Netlify.

Two rules it carried that vercel.json does NOT, both deliberately left
out rather than silently dropped:
- `Content-Security-Policy: frame-ancestors *` on /*. No behaviour
  change: absence is equally permissive, and the embed page relies on
  being framable, not on the header.
- Long-lived immutable caching for /css/* and /js/*. This one is a real
  (small) performance regression that predates this commit -- the live
  site has been serving those with max-age=0 for as long as it has been
  on Vercel. Worth porting into vercel.json separately; deleting a file
  nothing reads is not the change that should carry it.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 10:14:43 -04:00
thePR0M3TH3ANandClaude Fable 5 0592c0eed1 Update the vendored BitLogin widget to the security-audit build
Was on the 2026-07-25 build. Carries an adversarial audit's fixes, the
ones that matter most for a site that hands the widget real identities:

- Origin binding is enforced INSIDE the crypto worker and derived from
  self.location instead of a caller-supplied field, so a page cannot
  read or adopt a wallet connection bound to a different site.
- Phrase recovery no longer trips the rollback alarm on every later
  login (the mark is keyed by the everyday pubkey, which recovery
  preserves, while the recovered capsule restarts at generation 0) --
  users who had ever rotated a password previously saw "possibly
  revoked credentials" forever, with no way to clear it.
- quorumMet counts relays that ANSWERED rather than ones that merely
  accepted a socket, and relay responses are now checked against the
  address requested, so a single junk event can no longer block
  registration, rotation, and recovery.
- Password rotation checks the new capsule reached quorum BEFORE
  publishing the tombstone that destroys the old one; a failed rotation
  used to be able to leave neither password working.
- Derived secrets (Argon2id output, both PRKs, the BIP-39 seed) are now
  wiped at their derivation site, as the spec always claimed.
- The worker RPC validates message shapes, times out, and rejects
  pending calls when the worker dies, so a torn-down worker surfaces as
  an error instead of a permanent hang.
- Synthetic (script-dispatched) clicks no longer drive consent UI.

Verified: the 10 pre-existing test failures in this suite are unchanged
by this update (checked by stashing the vendor directory and re-running
-- 1330 pass / 10 fail either way), and the BitLogin auth-provider
tests pass. dist/ is gitignored build output and regenerates.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 10:05:15 -04:00
thePR0M3TH3ANandClaude Fable 5 847a855ec8 Update the vendored BitLogin widget to the security-audit build
Was on the 2026-07-25 build. Carries an adversarial audit's fixes, the
ones that matter most for a site that hands the widget real identities:

- Origin binding is enforced INSIDE the crypto worker and derived from
  self.location instead of a caller-supplied field, so a page cannot
  read or adopt a wallet connection bound to a different site.
- Phrase recovery no longer trips the rollback alarm on every later
  login (the mark is keyed by the everyday pubkey, which recovery
  preserves, while the recovered capsule restarts at generation 0) --
  users who had ever rotated a password previously saw "possibly
  revoked credentials" forever, with no way to clear it.
- quorumMet counts relays that ANSWERED rather than ones that merely
  accepted a socket, and relay responses are now checked against the
  address requested, so a single junk event can no longer block
  registration, rotation, and recovery.
- Password rotation checks the new capsule reached quorum BEFORE
  publishing the tombstone that destroys the old one; a failed rotation
  used to be able to leave neither password working.
- Derived secrets (Argon2id output, both PRKs, the BIP-39 seed) are now
  wiped at their derivation site, as the spec always claimed.
- The worker RPC validates message shapes, times out, and rejects
  pending calls when the worker dies, so a torn-down worker surfaces as
  an error instead of a permanent hang.
- Synthetic (script-dispatched) clicks no longer drive consent UI.

Verified: the 10 pre-existing test failures in this suite are unchanged
by this update (checked by stashing the vendor directory and re-running
-- 1330 pass / 10 fail either way), and the BitLogin auth-provider
tests pass. dist/ is gitignored build output and regenerates.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 10:02:48 -04:00
thePR0M3TH3ANandClaude Fable 5 5cce3b726a docs: close stale innerHTML TODO — ShareNostrModal has no innerHTML left
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:45:23 -04:00
thePR0M3TH3ANandClaude Fable 5 975b996f4c [nostr-core] Pin the NIP-46 multi-account lifecycle contract (TODO #33)
The 2026-06-25 audit blamed "logout logs out everyone" / broken profile
switching / unknown-user-after-refresh on the single shared nip46Client and
single-slot session storage. Re-auditing the current code shows every named
suspect has since been fixed piecemeal: per-account v2 session map with v1
last-connected default, keepStored logout, scoped no-arg clear (v1 slot only),
reuseStored+expectPubkey switch wired end-to-end into
useStoredRemoteSigner({pubkey}), and a validator-enforced boot restore.

None of that was pinned at the manager level, so any layer could regress
silently. tests/nip46-signer-lifecycle.test.mjs now exercises the real
SignerManager against the real signer registry and real (polyfilled) storage:

- logging out the active NIP-46 account destroys its live client exactly once
  while BOTH accounts' stored sessions and the other account's registry signer
  survive
- a failed targeted restore (passphrase-locked or missing session) fails typed
  and deletes nothing
- disconnectRemoteSigner with and without keepStored never touches the
  per-account map

Mutation-checked: reinjecting the historical bugs (logout without keepStored;
no-arg clear wiping the v2 map) fails 2/6; real code passes 6/6.

TODO #33 updated: remaining work is the on-phone Amber verification the item
already gates on — and if that still misbehaves, the failure is now known to
live ABOVE this contract (UI/state reconciliation on restore failure), which
narrows the search considerably.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:33:26 -04:00
thePR0M3TH3ANandClaude Fable 5 2eb7d09865 docs: note that visual baselines are CI-canonical now
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:23:10 -04:00
thePR0M3TH3ANandClaude Fable 5 e99ba0e817 docs: refresh KNOWN_ISSUES against current test reality
- jsdom entry: resolved long ago (28.1.0 installed, in devDeps) — was still
  marked Active with an install remediation.
- webseed-playbook.spec.ts:31: resolved by f48150ed; documents the actual fix
  (record attached srcs instead of asserting the final cleared src).
- uploadModal-reset: not reproducible across 2026-07-28..30 sweeps; left on
  file with instructions if the ordering-dependent hang recurs.
- login-flows.spec.ts:149: corrected — it is NOT a passes-in-isolation flake;
  it failed in isolation on 2026-07-28 (local + CI e2e-headless) while passing
  in the e2e job. Notes the reverted fix attempt and links it to pre-launch
  TODO #33, which is the same subsystem.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:22:21 -04:00
github-actions[bot] ab120a03e1 test(visual): regenerate baselines on ubuntu-latest
Captured by the update-visual-baselines workflow so the baseline environment matches the visual-regression job that compares against them.
2026-07-30 15:16:54 +00:00
thePR0M3TH3ANandClaude Fable 5 5d0709f5f4 [ci] Fix baseline workflow YAML — literal block was terminated early [update-visual-baselines]
The multi-line git commit message inside the run block dropped below the
literal block's indentation, ending the block mid-command and failing the
whole workflow file at parse time (0s failure, no jobs). Use two -m flags on
one shell line instead. YAML now validated locally before push.

Token included so this push performs the regeneration.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:15:26 -04:00
thePR0M3TH3ANandClaude Fable 5 350feae907 [ci] Allow triggering baseline regeneration from unstable [update-visual-baselines]
workflow_dispatch only works for workflows on the default branch, which this
file will not reach until the next promotion. Gate the job on an explicit
[update-visual-baselines] commit-message token instead so it is dispatchable
from unstable now; the workflow's own commit omits the token, so it cannot
retrigger itself.

This commit carries the token, so pushing it performs the first regeneration.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:14:17 -04:00
thePR0M3TH3ANandClaude Fable 5 31be84ba82 [ci] Add a workflow to regenerate visual baselines on the CI runner
The visual-regression job pixel-compares tests/visual/baselines.json on
ubuntu-latest, but the committed baselines were captured on developer machines.
Chromium builds and font fallbacks differ between environments — the token
stack's "Inter"/"Fira Code" are not shipped or installed anywhere, so every
machine renders its own system fallback — and the kitchen-sink default-theme
diff drifted past MAX_DIFF_RATIO on CI while passing 13/13 locally. ci.yml
documents exactly this mismatch as the reason the job is quarantined from
blocking deploys.

The CI diff artifact confirms it: baseline and actual render identically except
for ghosted/offset text in the high-contrast lower half — font-metric drift,
not a design change. (The dark upper half diffs too little to trip the 0.2
pixelmatch threshold, which is why only the default theme fails.)

Fix the class of problem instead of the instance: capture baselines in the SAME
runner image, browser install and font stack that the comparison job uses.
workflow_dispatch only — regenerating baselines is an explicit decision made
when a visual change is intentional, never a side effect of a push. The
workflow re-runs the comparison against its own output before committing, so a
bad capture cannot land.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
2026-07-30 11:11:14 -04:00
thePR0M3TH3ANandClaude Opus 5 f48150ed6b [ci] Fix the stale tests that had CI red
CI has been failing on unstable for weeks, across four unit files and a set of
e2e specs. None of it was a product regression: every failure is a test that
encodes behavior a later commit deliberately changed. Each is corrected against
the spec that superseded it, not adjusted until it passed.

Unit:

- video-modal-moderation: the fixture set flat `blurThumbnail`/`trustedMuteCount`,
  but normalizeVideoModerationContext derives shouldShow from
  `original.blurThumbnail`/`trustedCount`, so the assertion ran against a
  context whose early return had just deleted the blur it was checking. 4525c1c6
  also made a lone trusted mute a non-blocking ranking signal (its sibling test
  was renamed to say exactly that; this one was missed). Fixture now mirrors
  what moderationDecorator emits.
- trustBootstrap: asserted a subscription to onWhitelistChange that 18e08bf2
  deliberately removed -- buildTrustedSeeds derives seeds from the super admin,
  editors and fallback seeds, so a whitelist change cannot alter them. The
  removal is now pinned so it is not reinstated by reflex.
- moderation-service: 18e08bf2 removed `status.whitelisted ||` from
  isTrustedReporter (being allowed to publish is not a statement that your
  reports carry weight). A whitelisted reporter now counts toward the report
  total but not toward trusted.
- playbackService_forcedSource: forced P2P now falls back to a hosted URL when
  the torrent definitively fails -- playbackService documents the change and its
  rollback. The old assertion did not merely fail, it HUNG: forced sources skip
  the start timeout, so the now-reachable hosted attempt waited forever on a
  <video> that never emits.

E2E:

- ui-modals: asserted exactly one `.bv-modal` per component page, but
  profile-modal.html also bundles #storageCorsModal (d7ad1516). Requires at
  least one and drives the primary, which is what the test's own injected
  querySelector already used.
- coverage-utility-modules: used `author:abc123`, which 9afbc0cb correctly began
  rejecting as undecodable. Uses a real pubkey for the success path and pins the
  rejection separately.
- webseed-playback: asserted the FINAL src equals the CDN URL while also
  tolerating "No playable source found" in its status check -- contradictory,
  since a failed attempt calls resetVideoElement() and clears src. Headless CI
  cannot decode the archive.org H.264 sample. Now records src attachments and
  asserts the URL was attached, which is the behavior under test.
- nostr-runtime-coverage: asserted a legacy plaintext session-actor entry reads
  back as null. Since 8692dac6 added per-account storage, readV1Entry PURGES the
  plaintext entry and the reader falls back to the sole remaining encrypted
  account -- so null no longer describes the security property. Asserts the
  property directly: no plaintext key survives, in the entry or in raw storage.

Also drops the syncAuthUiState share-gate wiring added in d141c316. It was
redundant: Application.updateShareNostrAuthState() already fires on init, login,
logout, pubkey-change and signer-change and forwards to
videoModal.setShareNostrAuthState(). That call short-circuited on its own
`if (!this.videoModal?.setShareNostrAuthState) return;` guard purely because the
method did not exist -- adding it (d141c316) was the whole fix. The tests now
pin that contract instead of a second, competing code path.

All 416 unit files, all three CI shards, both DM suites and lint are green.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 17:37:24 -04:00
thePR0M3TH3ANandClaude Opus 5 244f527cf6 [share] Put author and relay hints in the shared nevent
buildShareUrlFromEventId encoded a BARE event id: no author, no relay hints. A
viewer whose relay set did not happen to carry that event could not resolve it,
so bitvid fell back to the home feed -- the "the shared link just opens
bitvid.network" report.

It is not a rare edge case. The reported video was present on nos.lol but
absent from relay.damus.io, relay.snort.social and relay.primal.net -- three of
bitvid's four bootstrap relays -- so most viewers arriving cold could not open
it at all.

The pointer now carries:

- the video's author, pulled from videosMap, which also lets clients look the
  event up by author+kind if the id itself has aged out of a relay
- up to three relay hints from the client's write relays (falling back to its
  general relay list), deduped and scheme-filtered

Both are best-effort: an unknown video or a malformed pubkey still produces the
old bare-id pointer rather than an invalid one, and no relays configured omits
the hint instead of encoding an empty array.

Also restores the path separator. The base URL has its trailing slash stripped,
so shared links read `https://bitvid.network?v=...`. That is legal and browsers
normalize it, but it looks like a typo in a note and some link parsers
mis-detect where the URL ends.

New tests fail 6/9 without the change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 16:08:34 -04:00
thePR0M3TH3ANandClaude Opus 5 99f8e88969 [playback] Don't yank blocked autoplay into WebTorrent
On mobile a bitvid share link opens with no user gesture, so the browser
refuses autoplay. bitvid handled that correctly -- it showed "Press play to
start the hosted video." and armed a no-stall watchdog to wait -- but the 3s
start timeout kept running underneath and fired anyway. For a video with both a
hosted URL and a magnet that meant:

  Press play to start the hosted video.
  Switching to WebTorrent...

The element was reset out from under the prompt the viewer was about to act on,
and the swarm (often seedless, the webseed being the very URL we just
abandoned) played nothing. Desktop usually hides this because an established
Media Engagement score lets autoplay through, so the same link plays there.

A blocked autoplay is not a stalled source. Suspend the start timeout while we
are waiting on a user gesture and let the attempt settle on its own -- it
resolves when they press play, or when a real media error or stall fires. The
guard is cleared on the `play` gesture so a genuine stall afterwards still
fails over normally.

Reproduced on an emulated phone against the reported video
(v.nostr.build/1eNLjw5el05Uc3KU.mp4, magnet with a webseed): before, statuses
end "Press play..." then "Switching to WebTorrent..." with the P2P path
entered; after, they end at "Press play..." and playback starts on tap. The new
tests fail 2/4 without the guard.

Note for future test authors: createSession() overwrites any
playbackStartTimeout passed in its options with the service's value, so a
per-session override is silently ignored -- set it on the PlaybackService
constructor or the test races the real 3s default and proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 15:26:44 -04:00
thePR0M3TH3ANandClaude Opus 5 c0d006a5fc [og] Derive the preview origin from the request
SITE_ORIGIN is a constant defaulting to https://bitvid.network, so the unstable
deployment advertised production's canonical URL and, worse, a twitter:player
pointing at production's embed -- testing a preview link silently exercised the
live site.

Build shareUrl, embedUrl and the fallback card from the request's own
proto+host instead, so every deployment (production, unstable, preview) is
self-consistent. BITVID_SITE_ORIGIN remains the fallback when the host header
is absent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:48:32 -04:00
thePR0M3TH3ANandClaude Opus 5 04075ff56b [og] Route crawlers via middleware, not a rewrite
The OG function deployed and worked (`/api/og?v=<nevent>` returns the real
title, description, thumbnail and player card), but crawlers hitting
`/?v=<nevent>` still got the site-wide card: Vercel evaluates `rewrites` AFTER
the filesystem, and `/` matches the static index.html, so the rewrite never
fired.

Middleware runs before the filesystem check, so that is where the routing
belongs. Replace the dead `rewrites` block with a root middleware that matches
only `/`, bails immediately when there is no `v` param or the user-agent is not
a known preview crawler, and otherwise rewrites to /api/og.

Real viewers are unaffected -- they fall through to the untouched SPA. Note the
one behavioral difference from the rewrite design: middleware now executes on
homepage requests (matcher is `/` only) rather than being invoked solely for
crawlers. It returns next() after two cheap checks.

Verified on the live unstable deployment that /api/og already renders
`x-bitvid-og: video` with og:type video.other for the real note; this only
fixes how crawlers reach it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:40:20 -04:00
thePR0M3TH3ANandClaude Opus 5 e5784893a8 [build] Drop the stale pnpm-lock.yaml that was failing deploys
Adding api/ made Vercel run a second install phase to bundle the serverless
function. That phase auto-detects the lockfile and ignores the project's
`installCommand: npm install`, so it picked pnpm and failed:

  ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with "frozen-lockfile" because
  pnpm-lock.yaml is not up to date with package.json

The static build itself succeeded every time -- version injected, deploy
artifact verified -- but the failed function install aborted the whole
deployment, so unstable kept serving the last good build from two days prior.

pnpm-lock.yaml has not been touched since 2026-02-08 and was already stale by
7 added and 4 mismatched dependencies BEFORE this branch (bitcoin-connect,
blossom-client-sdk, cssnano, nostr-wasm, postcss-cli, postcss-import, esbuild,
nostr-tools, jsdom, stylelint); hls.js merely joined the list. It never
mattered because nothing installed from it until now.

Nothing references pnpm: package.json scripts, .github/workflows and
CONTRIBUTING all use npm, and vercel.json already pins
`installCommand: npm install`. package-lock.json is the maintained lockfile.
Keeping a second, unmaintained one only guarantees the two disagree again.

Restore with `git revert` (and regenerate it with `pnpm install
--lockfile-only`) if pnpm is ever adopted deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:31:01 -04:00
thePR0M3TH3ANandClaude Opus 5 d141c316a9 [ui] Make "Share on Nostr" a working button
The share menu's "Share on Nostr" entry was never clickable, no matter who was
logged in -- it rendered as inert, dimmed text.

VideoModal initialized `shareNostrAuthState` to { isLoggedIn: false, hasSigner:
false } and nothing ever wrote to it. createVideoShareMenuPanel gates the item
on `isLoggedIn && hasSigner`, so it was always disabled, and the click
delegation in bindShareMenuActions() bails on `button.disabled` -- so the whole
share-to-Nostr flow behind it was unreachable.

The renderer and the publish path were both already correct; only the gate was
missing:

- VideoModal.setShareNostrAuthState() records the state and closes an already
  open share menu when it changes, so a menu opened before login does not keep
  showing the stale disabled item. It is a no-op when nothing changed, to avoid
  slamming the menu shut on every sync.
- syncAuthUiState() computes the gate from isUserLoggedIn() plus a
  signing-capable active signer, mirroring ShareNostrController.handleShare():
  a signer without signEvent would only produce an error toast on click.
- syncAuthUiState() was only ever called at bootstrap, so logging in
  mid-session left the gate closed. It now self-installs a bitvid:auth-changed
  listener on first run (bound once, re-entrancy safe) rather than adding a
  call site in js/app.js, which is at its file-size cap.

Verified in a browser that the rendered item is disabled when logged out or
signer-less and enabled + click-dispatching with both; the new tests fail 5/9
with the gate push removed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:16:18 -04:00
thePR0M3TH3ANandClaude Opus 5 b84558e928 [og] Render per-video OpenGraph tags for crawlers
index.html carries one set of site-wide OG tags, so every /?v=<nevent> link
previewed as the same generic bitvid card -- never the video's title or
thumbnail. bitvid is a static SPA and crawlers do not run JS, so the client
cannot fix this.

api/og.js renders the tags on demand. Nothing is pre-generated and nothing is
stored: the video is a query param, not a path, so one stateless function
serves every video and dist/ is byte-identical with or without it. The only
caching is the CDN edge (s-maxage=300) plus a per-container memo of the admin
lists.

This is never load-bearing. Only crawler user-agents are rewritten to it
(vercel.json), humans get a 302 to the untouched SPA, and a static/nsite deploy
with no functions keeps working -- it just serves the site-wide card. Rollback
is deleting the `rewrites` block.

- Emits twitter:player / og:video:url pointing at embed.html?pointer=<nevent>,
  so a shared link plays inline on Twitter/Discord/Telegram rather than showing
  a still. frame-ancestors * already permits the embed.
- Fails closed. A crawler never authenticates, so anything emitted is public
  forever in third-party caches: deleted, isPrivate, isNsfw/content-warning,
  blacklisted authors, and -- while whitelist mode is on -- an unavailable or
  empty whitelist all fall back to the generic card.
- Escapes every attribute. Titles come from arbitrary nostr events and this HTML
  is served from bitvid's own origin; only http(s) URLs reach a meta tag.
- Parses BOTH event shapes: bitvid's kind-30078 v3 JSON and the NIP-71 tag shape
  (kinds 21/22/34235/34236) that nostube/slidestr publish, where the title is a
  `title` tag and the thumbnail an `imeta image` entry. Reading only the JSON
  shape made every NIP-71 video fall back to the generic card.
- Relay budget is 4s: a cold pool's first read measured ~3s, and 1.5s silently
  degraded every preview. Warm containers answer in <600ms.

Admin-list identity and the relay set are imported from js/config.js,
js/nostr/toolkit.js and js/nostrEventSchemas.js rather than duplicated -- a
hardcoded copy of the namespace gated every preview during development, and a
test now fails if any npub or wss:// literal reappears in the handler.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 13:59:25 -04:00
thePR0M3TH3ANandClaude Opus 5 38b51f13a5 [share] Give share-to-Nostr notes a real preview
"Share on Nostr" published a kind-1 note whose body ended with a bare thumbnail
URL. Many clients only inline such a URL when a NIP-92 `imeta` entry describes
it, so the viewer often saw a plain link instead of a preview image.

- buildShareEvent now emits an `imeta` entry for the thumbnail. The media type
  is inferred from the extension and OMITTED when unknown -- a wrong `m` is
  worse than a missing one, since clients use it to decide whether to render
  inline at all, so extensionless Blossom URLs ship url + alt only.
- inferImageMimeTypeFromUrl lives in a SEPARATE map from EXTENSION_MIME_MAP:
  that map gates video upload/publish paths, and folding image types into it
  would let an image pass a video check.
- shareNostrController was passing only { id, pubkey } to buildShareEvent, so
  the tag could never be built; it now forwards title and thumbnail.

When a video has a NIP-71 mirror, the note instead quotes it with
`nostr:naddr1...`, which nostr clients render as a native video quote card --
the closest thing to a rich preview that does not depend on any client fetching
OpenGraph tags (most never do).

- The mirror is addressable (kind 34235/34236, author = video pubkey,
  d = videoRootId), so js/nostr/mirrorPointer.js derives the coordinate with no
  relay lookup, and the pointer survives the author editing the video where a
  plain `nevent` would rot on the next replace.
- The pointer is only embedded after nip71MirrorService.findMirror() confirms
  the mirror exists. A pointer to a missing event renders as a broken/empty
  quote box, which is worse than the plain-link fallback, so a disabled
  service, a throwing lookup, a 2.5s timeout or a missing videoRootId all fall
  back to the thumbnail note.
- resolveMirrorKind prefers the kinds actually observed on relays and only then
  falls back to the same portrait rule the mirror builder uses, so a derived
  pointer matches what would be published rather than guessing independently.
- While quoting, the raw thumbnail URL is dropped from the body and the imeta
  tag is skipped: the quote card already shows the thumbnail, and emitting both
  rendered the same image twice. An `a` tag links the note to the coordinate.

Known limitation: a client rendering the quote card still has to play the
video, so mirrors whose imeta url is an .m3u8 hit the same HLS wall -- title and
thumbnail render, playback depends on the client.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 13:59:10 -04:00
thePR0M3TH3ANandClaude Opus 5 4404728410 [playback] Add HLS support and stop blaming stale MediaErrors
bitvid attached every hosted source with `videoEl.src = url`, which only plays
HLS on Safari-family browsers. Firefox 146 fails with MEDIA_ERR_SRC_NOT_SUPPORTED
(canPlayType returns ""), so every .m3u8 note -- e.g. anything published by
nostube/slidestr -- was unplayable there and the URL-health badge showed
"offline". Chrome/Brave 149 happen to play HLS natively, which is why this
looked browser-specific.

Vendor hls.js (npm run build:hls) and route HLS sources through it:

- js/services/hlsPlayback.js owns detection, engine choice and the hls.js
  lifecycle. Engine choice keys off MediaSource, NOT canPlayType: Chrome
  returns "maybe" for every HLS type, so a native-first branch would hang.
  Native is used only when MSE is genuinely unavailable.
- The bundle is the FULL build, not hls.js/light -- light drops the alternate
  audio stream controller and real ladders demux audio into an EXT-X-MEDIA
  group. It is lazy-imported only when a .m3u8 is encountered, so ordinary
  MP4/WebTorrent playback never pays the ~500KB.
- modalCoordinator tears the instance down first; clearing `src` alone leaves
  the transmuxing worker and segment fetches running after the modal closes.
- urlHealthController probes HLS by fetching the playlist for #EXTM3U. The old
  bare-<video> probe could not load it and on Chrome emitted no events at all,
  burning the full timeout on every HLS card.
- HLS_PLAYBACK_START_TIMEOUT widens the 3s start window, which fired
  mid-startup: the bundle, master playlist, variant playlist, init segment and
  first media segment are four-plus serial round trips.

Also fixes two bugs found while verifying this:

- A stale MediaError was reported as the current failure. Teardown clears `src`
  and calls load(), leaving MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute")
  on the element; a later watchdog fallback rendered that as "Hosted playback
  failed: source not supported or blocked." over a stream that was playing
  fine. Only a MediaError produced by the current attempt is surfaced now.
  Sources are also bound before the watchdogs are armed, so hls.js's own
  attachMedia() src swap cannot read as a playback failure.
- `audio/x-mpegurl` is a legacy alias for an HLS playlist of any kind, not an
  audio-only assertion. isAudioOnlyVideoObject misfiled HLS video ladders
  published with it as podcasts and dropped them from every video feed.

Verified end-to-end in Chromium, Chrome 149, Brave 149 and Firefox 146 against
a live HLS stream: Firefox goes from "No playable source found." to playing.
Rollback: stop calling attachHlsSource() and the previous `src` assignment
returns; see docs/playback-fallback.md.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 13:58:55 -04:00
thePR0M3TH3AN 726c0d26ed Update performance plan progress 2026-07-26 18:41:34 -04:00
thePR0M3TH3AN bb6614e3d3 Render cached feeds before recommendation refresh 2026-07-26 18:38:03 -04:00
thePR0M3TH3AN 687f8c438d Optimize feed lifecycle and navigation rendering 2026-07-26 18:27:57 -04:00
thePR0M3TH3AN 2c4aefe872 Document channel follower count scope 2026-07-26 17:59:50 -04:00
thePR0M3TH3AN b774de6d76 Harden profile data loading 2026-07-26 17:59:50 -04:00
thePR0M3TH3AN 5f439d6863 Lazy-load recommendation indexes 2026-07-26 17:59:45 -04:00
thePR0M3TH3AN fe976b3ab7 Harden Nostr startup performance 2026-07-26 17:20:52 -04:00
thePR0M3TH3AN 4525c1c657 Improve moderation restoration and mobile card actions 2026-07-26 15:35:20 -04:00
thePR0M3TH3AN 3735d57410 fix(mobile): keep modal actions above browser chrome 2026-07-26 14:58:09 -04:00
thePR0M3TH3AN 4dd150b010 fix(reactions): load legacy event-tagged video reactions 2026-07-26 14:45:38 -04:00
thePR0M3TH3AN c200c84fc5 test(visual): refresh mobile video modal baseline 2026-07-26 14:38:48 -04:00
thePR0M3TH3AN d19040c625 chore(deploy): enable unstable Vercel deployments 2026-07-26 14:32:31 -04:00
thePR0M3TH3AN 5764482f2a feat(mobile): redesign profile settings navigation 2026-07-26 13:50:17 -04:00
118 changed files with 30628 additions and 5503 deletions

No files matched your search

@@ -0,0 +1,95 @@
# Regenerate the visual-regression baselines IN the environment that compares
# them.
#
# The baselines in tests/visual/baselines.json are pixel-compared on
# ubuntu-latest CI, but they were historically captured on a developer machine.
# Chromium builds and font fallbacks differ between the two ("Inter"/"Fira
# Code" are not shipped or installed anywhere, so every environment renders its
# own system fallback), so the kitchen-sink diff drifted past MAX_DIFF_RATIO on
# CI while passing locally — ci.yml even documents the mismatch as the reason
# visual-regression must not block deploys. Capturing the baselines on the same
# runner image, browser install and font stack as the comparison removes the
# environment variable from the equation entirely.
#
# Two explicit triggers, both requiring deliberate human intent — regenerating
# baselines must never be a side effect of ordinary work:
#
# 1. workflow_dispatch — only usable once this file reaches the default
# branch (GitHub does not index dispatchable workflows on other branches).
# 2. A push whose head commit message contains [update-visual-baselines] —
# works from unstable today, before any promotion. The workflow's own
# commit omits the token, so it cannot retrigger itself.
name: Update visual baselines
on:
workflow_dispatch:
push:
branches: [unstable]
permissions:
contents: write
jobs:
update-baselines:
if: >-
github.event_name == 'workflow_dispatch' ||
contains(github.event.head_commit.message, '[update-visual-baselines]')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Node.js 22
uses: actions/setup-node@v5
with:
node-version: 22
# Mirror the visual-regression job's caches so the regenerated baselines
# come from the same dependency tree the comparison job will use.
- name: Cache npm directory
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-22-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-22-npm-
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-22-modules-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-22-modules-
- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Regenerate baselines
run: npm run test:visual:update
- name: Verify the fresh baselines pass in this environment
run: npx playwright test --project=visual
- name: Commit updated baselines
run: |
if git diff --quiet -- tests/visual/baselines.json; then
echo "Baselines unchanged; nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add tests/visual/baselines.json
git commit \
-m "test(visual): regenerate baselines on ubuntu-latest" \
-m "Captured by the update-visual-baselines workflow so the baseline environment matches the visual-regression job that compares against them."
git push
+1
View File
@@ -22,3 +22,4 @@ TEST_LOG.mdtest_logs/nip-hits.txt
# Local site archive for manual nsite upload (npm run archive)
bitvid-site.zip
.env*
+26 -14
View File
@@ -3,11 +3,10 @@
## Test Infrastructure
### Missing `jsdom` Dependency
- **Status:** Active
- **Status:** Resolved (2026-07-30)
- **Detected:** 2026-02-26
- **Description:** Multiple unit tests fail with `ERR_MODULE_NOT_FOUND` because `jsdom` is imported but not installed in the project.
- **Impact:** Prevents running unit tests that rely on DOM simulation.
- **Remediation:** `npm install --save-dev jsdom`
- **Description:** Unit tests failed with `ERR_MODULE_NOT_FOUND` because `jsdom` was imported but not installed.
- **Resolution:** `jsdom` has been a devDependency for some time (28.1.0 installed and in `package.json`); the jsdom-based suites run clean.
## Run Notes (2026-03-04)
@@ -22,19 +21,32 @@
- **Description:** `tests/nostr-send-direct-message.test.mjs` must pass `Uint8Array` secret keys to `getPublicKey/finalizeEvent` for current `nostr-tools`.
- **Impact:** Passing hex strings causes failures like `expected Uint8Array, got type=string`.
## Run Notes (2026-06-20)
## Run Notes (2026-06-20, updated 2026-07-30)
### Additional E2E specs flake under full parallel load (pass in isolation)
### `login-flows.spec.ts:149` — upload/profile buttons stay visible after logout
- **Status:** Active — NOT merely a parallel-load flake
- **Description:** Previously filed as a worker-contention flake that "passes deterministically in isolation." On 2026-07-28 it failed **in isolation** as well (chromium, local and CI `e2e-headless`), while passing in the CI `e2e-tests (e2e)` job — environment-sensitive, not load-sensitive. Direct probes of the same flow (raw `__bitvidTest__.loginWithNsec` → `logout()` against a mock relay) hide the button correctly, so the failure is specific to something in the Playwright fixture path. An attempted fix (running `applyLoggedOutUiState()` before the awaited NWC/profile teardown in `_executeAuthLogout`) did not change the outcome and was reverted rather than shipped unverified.
- **Impact:** One e2e failure in `e2e-headless`; logout hides gated UI correctly in manual testing and direct harness probes.
- **Next step:** Investigate alongside pre-launch TODO #33 ("logout logs out everyone" / NIP-46 session persistence) — same subsystem.
### `video-upload-publish-lifecycle.spec.ts:49` / `:79` flake under full parallel load
- **Status:** Active
- **Description:** Under the full `test:e2e` run (378 tests, 4 workers) these intermittently fail on `not.toBeVisible`/feed-appearance timeouts, but pass deterministically when re-run in isolation (`--workers=1`): `login-flows.spec.ts:149` (upload/profile buttons hide after logout), `video-upload-publish-lifecycle.spec.ts:49` and `:79` (URL-first publish appears / owner delete). Same root cause as the Firefox feed-hydration flakes above — worker resource contention, not an app regression (verified: pass in isolation on the current branch).
- **Impact:** Full-suite e2e is flaky; isolated reruns are green.
- **Description:** Under the full `test:e2e` run these intermittently fail on feed-appearance timeouts but pass on retry (CI marks them flaky). Worker resource contention.
- **Impact:** Full-suite e2e occasionally needs retries; isolated reruns are green.
### `webseed-playback.spec.ts:31` fails on Firefox headless (CDN mode)
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
- **Description:** "plays directly from HTML link (CDN mode)" fails on `--project=e2e-firefox` with `#modalVideo` `src` empty instead of the test's archive.org URL. The Chromium variant and the WebTorrent-fallback variant (`:63`) both pass. Firefox-headless-specific (external URL reachability/codec), not an app regression.
- **Impact:** One Firefox-only e2e failure; CDN playback works on Chromium and in manual testing.
- **Status:** Resolved (2026-07-28, `f48150ed`)
- **Description:** The test asserted the **final** `src` equals the CDN URL while simultaneously tolerating "No playable source found" in its status assertion — contradictory, because a failed hosted attempt calls `resetVideoElement()`, which clears `src`. Headless CI cannot decode the archive.org H.264 sample, so the cleared-src path was routine there.
- **Resolution:** The test now records every `src` the player attaches via a MutationObserver and asserts the CDN URL was attached — the actual behavior under test. Green in both `e2e` and `e2e-firefox` CI jobs.
### `uploadModal-reset.test.mjs` ("UploadModal Reset Logic") hangs/cancels
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
- **Description:** The jsdom-based UploadModal reset suite intermittently fails with `cancelledByParent` / "Promise resolution is still pending but the event loop has already resolved" (~12s hang). It passes or fails depending on full-suite test ordering/timing (an async-hang flake, likely a torrent-metadata/upload promise not settling under the mocked services). Not a regression from the MediaUploader extraction — it fails identically on `1b11cb1b`.
- **Impact:** One unit suite can report `not ok 1`; the runner still exits 0. Other tests unaffected.
- **Status:** Not reproducible (2026-07-30)
- **Description:** Intermittent `cancelledByParent` / "Promise resolution is still pending" hang, order/timing dependent.
- **Current state:** Passes cleanly (`pass=3 fail=0 cancelled=0`) in isolation and across repeated full-suite sweeps on 2026-07-28–30. Left on file because the original failure was ordering-dependent; if it recurs, capture the full-suite ordering that produced it.
## Run Notes (2026-07-30)
### Visual baselines are CI-canonical — local `test:visual` may fail the kitchen-sink diff
- **Status:** By design (since `ab120a03`)
- **Description:** `tests/visual/baselines.json` is regenerated on ubuntu-latest by the `update-visual-baselines` workflow (trigger: `workflow_dispatch` once promoted, or a commit containing `[update-visual-baselines]`). Baselines can only match one environment, and the CI gate is the one that matters — so a dev machine with different Chromium/font rendering may now fail `npm run test:visual` locally (typically the kitchen-sink default theme) even though CI is green.
- **Workaround:** Run visual tests in the pinned container via `scripts/run-playwright-docker.sh`, or rely on CI. Never regenerate baselines from a dev machine.
+363
View File
@@ -0,0 +1,363 @@
// Per-video OpenGraph / Twitter-player rendering for crawlers.
//
// bitvid is a static SPA: index.html carries one set of site-wide OG tags, so
// every `/?v=<nevent>` link previewed as the same generic bitvid card. Crawlers
// don't run JS, so the client can't fix that — this module renders the tags a
// crawler needs, and a thin host adapter (api/og.js on Vercel) feeds it.
//
// PURE by design: no network, no host globals. All I/O lives in the adapter so
// the escaping and gating rules below stay unit-testable.
//
// Rollback: remove the crawler rewrite from vercel.json. The static site keeps
// serving its existing tags and nothing else changes — this is never on the
// path of a real user's page load, and `npm run archive` (nsite) is unaffected.
// Player card dimensions. Twitter refuses a player card without explicit
// dimensions, and 16:9 matches bitvid's embed shell.
export const DEFAULT_PLAYER_WIDTH = 1280;
export const DEFAULT_PLAYER_HEIGHT = 720;
const MAX_TITLE_LENGTH = 120;
const MAX_DESCRIPTION_LENGTH = 200;
/**
* Escape a value for use inside a double-quoted HTML attribute.
*
* Titles and descriptions come from arbitrary nostr events — treat every one as
* hostile. A title of `"><script>…` would otherwise break out of the meta tag
* and inject markup into a page we serve from our own origin.
*/
export function escapeHtmlAttribute(value) {
if (typeof value !== "string") {
return "";
}
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* Only http(s) URLs may reach a meta tag. Anything else (javascript:, data:,
* relative paths) is dropped — a crawler resolving them is at best useless and
* at worst a vector.
*/
export function sanitizeAbsoluteUrl(value) {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
return "";
}
let parsed;
try {
parsed = new URL(trimmed);
} catch (err) {
return "";
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return "";
}
return parsed.toString();
}
/** Collapse whitespace and hard-truncate for preview surfaces. */
export function normalizeText(value, maxLength) {
const collapsed =
typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
if (!collapsed || !Number.isFinite(maxLength) || maxLength <= 0) {
return collapsed;
}
if (collapsed.length <= maxLength) {
return collapsed;
}
return `${collapsed.slice(0, maxLength - 1).trimEnd()}…`;
}
/**
* Decide whether a video may be described in a link preview.
*
* A crawler never authenticates, so anything emitted here is fully public and
* outlives us in third-party caches. The rule is fail-closed: only a video that
* is affirmatively public gets a rich card; everything else — including the
* "we couldn't determine it" case — falls back to the generic bitvid card.
*
* @returns {{allowed: boolean, reason: string}}
*/
export function isPubliclyShareable(video, options = {}) {
if (!video || typeof video !== "object") {
return { allowed: false, reason: "missing-video" };
}
if (video.invalid) {
return { allowed: false, reason: "invalid-video" };
}
if (video.deleted === true) {
return { allowed: false, reason: "deleted" };
}
if (video.isPrivate === true) {
return { allowed: false, reason: "private" };
}
// NSFW thumbnails must never be injected into an unsuspecting timeline.
if (video.isNsfw === true) {
return { allowed: false, reason: "nsfw" };
}
const pubkey =
typeof video.pubkey === "string" ? video.pubkey.trim().toLowerCase() : "";
if (!pubkey) {
return { allowed: false, reason: "missing-author" };
}
const blacklist = options.blacklist instanceof Set ? options.blacklist : null;
if (blacklist && blacklist.has(pubkey)) {
return { allowed: false, reason: "blacklisted" };
}
if (options.whitelistEnabled) {
const whitelist =
options.whitelist instanceof Set ? options.whitelist : null;
// No usable whitelist while whitelist mode is on means we cannot prove the
// author is allowed. Fail closed rather than leak a gated video's metadata.
if (!whitelist || whitelist.size === 0) {
return { allowed: false, reason: "whitelist-unavailable" };
}
if (!whitelist.has(pubkey)) {
return { allowed: false, reason: "not-whitelisted" };
}
}
return { allowed: true, reason: "ok" };
}
// bitvid's own notes are kind 30078 with a v3 JSON body. Other clients
// (nostube/slidestr and anything NIP-71 native) publish kind 21/22/34235/34236
// with the metadata in TAGS and the description in `content`. bitvid normalizes
// both in the browser, so the preview must understand both too — parsing only
// the JSON shape made every NIP-71 video fall back to the generic card.
export const NIP71_VIDEO_KINDS = Object.freeze([21, 22, 34235, 34236]);
function firstTagValue(tags, name) {
if (!Array.isArray(tags)) {
return "";
}
for (const tag of tags) {
if (Array.isArray(tag) && tag[0] === name && typeof tag[1] === "string") {
const value = tag[1].trim();
if (value) {
return value;
}
}
}
return "";
}
function hasTag(tags, name) {
return (
Array.isArray(tags) &&
tags.some((tag) => Array.isArray(tag) && tag[0] === name)
);
}
/** First `image <url>` entry inside any imeta tag. */
function imetaImageUrl(tags) {
if (!Array.isArray(tags)) {
return "";
}
for (const tag of tags) {
if (!Array.isArray(tag) || tag[0] !== "imeta") {
continue;
}
for (let i = 1; i < tag.length; i += 1) {
const entry = typeof tag[i] === "string" ? tag[i].trim() : "";
if (/^image\s+/i.test(entry)) {
const url = sanitizeAbsoluteUrl(entry.replace(/^image\s+/i, ""));
if (url) {
return url;
}
}
}
}
return "";
}
/**
* Normalize a raw nostr event into the fields the preview needs, for both the
* bitvid v3 JSON shape and the NIP-71 tag shape. Returns null when the event is
* not a recognizable video note (caller renders the generic card).
*/
export function parseVideoEvent(event) {
if (!event || typeof event !== "object") {
return null;
}
const base = { pubkey: event.pubkey, deleted: false, isPrivate: false };
// --- bitvid native (kind 30078, JSON content) ---
if (typeof event.content === "string" && event.content.trim().startsWith("{")) {
try {
const content = JSON.parse(event.content);
if (content && typeof content === "object") {
return {
...base,
title: content.title,
description: content.description,
thumbnail: content.thumbnail,
deleted: content.deleted === true,
isPrivate: content.isPrivate === true,
isNsfw: content.isNsfw === true || hasTag(event.tags, "content-warning"),
invalid: !content.title || (!content.url && !content.magnet),
};
}
} catch (err) {
// Not JSON after all — fall through to the NIP-71 reading.
}
}
// --- NIP-71 (metadata in tags, description in content) ---
if (NIP71_VIDEO_KINDS.includes(event.kind)) {
const title = firstTagValue(event.tags, "title");
const thumbnail =
imetaImageUrl(event.tags) ||
sanitizeAbsoluteUrl(firstTagValue(event.tags, "image")) ||
sanitizeAbsoluteUrl(firstTagValue(event.tags, "thumb"));
const description =
(typeof event.content === "string" && event.content.trim()) ||
firstTagValue(event.tags, "summary") ||
firstTagValue(event.tags, "alt");
return {
...base,
title,
description,
thumbnail,
// NIP-71 has no privacy flag; `content-warning` is the NSFW signal.
isNsfw: hasTag(event.tags, "content-warning"),
invalid: !title,
};
}
return null;
}
function metaTag(attribute, name, content) {
return ` <meta ${attribute}="${escapeHtmlAttribute(name)}" content="${escapeHtmlAttribute(content)}" />`;
}
/**
* Render the crawler-facing HTML document.
*
* @param {object} params
* @param {object|null} params.video Normalized video object, or null.
* @param {string} params.shareUrl Canonical `/?v=<nevent>` URL.
* @param {string} [params.embedUrl] `/embed.html?pointer=<nevent>` player URL.
* @param {object} params.fallback Site-wide defaults (title/description/image/url).
* @param {object} [params.gate] Result of isPubliclyShareable().
* @returns {string} A complete HTML document.
*/
export function renderOgHtml({
video = null,
shareUrl = "",
embedUrl = "",
fallback = {},
gate = null,
} = {}) {
const siteName = normalizeText(fallback.siteName || "bitvid", 60);
const fallbackTitle = normalizeText(
fallback.title || "bitvid - Decentralized Video Sharing",
MAX_TITLE_LENGTH
);
const fallbackDescription = normalizeText(
fallback.description || "Decentralized video sharing on Nostr.",
MAX_DESCRIPTION_LENGTH
);
const fallbackImage = sanitizeAbsoluteUrl(fallback.image);
const canonical = sanitizeAbsoluteUrl(shareUrl) || sanitizeAbsoluteUrl(fallback.url);
const decision = gate || isPubliclyShareable(video);
const useVideo = decision.allowed === true;
const title = useVideo
? normalizeText(video.title, MAX_TITLE_LENGTH) || fallbackTitle
: fallbackTitle;
const description = useVideo
? normalizeText(video.description, MAX_DESCRIPTION_LENGTH) ||
fallbackDescription
: fallbackDescription;
const image = useVideo
? sanitizeAbsoluteUrl(video.thumbnail) || fallbackImage
: fallbackImage;
const player = useVideo ? sanitizeAbsoluteUrl(embedUrl) : "";
const lines = [
"<!doctype html>",
'<html lang="en">',
" <head>",
' <meta charset="utf-8" />',
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
` <title>${escapeHtmlAttribute(title)}</title>`,
];
if (canonical) {
lines.push(` <link rel="canonical" href="${escapeHtmlAttribute(canonical)}" />`);
}
lines.push(metaTag("name", "description", description));
lines.push(metaTag("property", "og:site_name", siteName));
lines.push(metaTag("property", "og:type", useVideo ? "video.other" : "website"));
lines.push(metaTag("property", "og:title", title));
lines.push(metaTag("property", "og:description", description));
if (canonical) {
lines.push(metaTag("property", "og:url", canonical));
}
if (image) {
lines.push(metaTag("property", "og:image", image));
lines.push(metaTag("property", "og:image:alt", title));
}
if (player) {
// A player card is what makes a shared bitvid link behave like a YouTube
// link: the video plays inline in the timeline instead of showing a still.
lines.push(metaTag("property", "og:video:url", player));
lines.push(metaTag("property", "og:video:secure_url", player));
lines.push(metaTag("property", "og:video:type", "text/html"));
lines.push(metaTag("property", "og:video:width", String(DEFAULT_PLAYER_WIDTH)));
lines.push(metaTag("property", "og:video:height", String(DEFAULT_PLAYER_HEIGHT)));
lines.push(metaTag("name", "twitter:card", "player"));
lines.push(metaTag("name", "twitter:player", player));
lines.push(metaTag("name", "twitter:player:width", String(DEFAULT_PLAYER_WIDTH)));
lines.push(metaTag("name", "twitter:player:height", String(DEFAULT_PLAYER_HEIGHT)));
} else {
lines.push(metaTag("name", "twitter:card", image ? "summary_large_image" : "summary"));
}
lines.push(metaTag("name", "twitter:title", title));
lines.push(metaTag("name", "twitter:description", description));
if (image) {
lines.push(metaTag("name", "twitter:image", image));
}
lines.push(" </head>");
lines.push(" <body>");
// A human should never land here (only crawler user-agents are rewritten),
// but if one does, give them a real link rather than a blank page.
lines.push(
canonical
? ` <p><a href="${escapeHtmlAttribute(canonical)}">${escapeHtmlAttribute(title)}</a></p>`
: ` <p>${escapeHtmlAttribute(title)}</p>`
);
lines.push(" </body>");
lines.push("</html>");
return `${lines.join("\n")}\n`;
}
// Conservative crawler match. Being wrong in the "not a bot" direction is free
// (the visitor gets the normal SPA); being wrong the other way would serve a
// human this stub page, so the pattern stays narrow and explicit.
export const CRAWLER_USER_AGENT_PATTERN =
/(facebookexternalhit|twitterbot|slackbot|discordbot|telegrambot|whatsapp|linkedinbot|pinterest|redditbot|embedly|quora link preview|nostrbot|primal|iframely|opengraph|mastodon|bluesky|skypeuripreview|vkshare|googlebot|bingbot|applebot|yandexbot|duckduckbot|baiduspider|petalbot|semrushbot|ahrefsbot|developers\.google\.com\/\+\/web\/snippet)/i;
export function isCrawlerUserAgent(userAgent) {
return typeof userAgent === "string"
? CRAWLER_USER_AGENT_PATTERN.test(userAgent)
: false;
}
+273
View File
@@ -0,0 +1,273 @@
// Vercel serverless handler: renders per-video OpenGraph / Twitter-player tags
// for crawlers hitting `/?v=<nevent>`.
//
// Nothing is pre-generated and nothing is persisted. This is a stateless
// function invoked per crawler request; `dist/` is byte-identical with or
// without it. The only caching is the CDN edge cache via Cache-Control below.
//
// This must never become load-bearing for the app: real users are not routed
// here (see the user-agent rewrite in vercel.json), and a static/nsite deploy
// without any functions keeps working exactly as it does today, just with the
// site-wide card.
import { nip19 } from "nostr-tools";
import { SimplePool, useWebSocketImplementation } from "nostr-tools/pool";
import WebSocket from "ws";
import {
isCrawlerUserAgent,
isPubliclyShareable,
parseVideoEvent,
renderOgHtml,
} from "./_lib/ogRenderer.mjs";
// Imported, not duplicated: these modules are plain ESM with no DOM access, so
// the function reads the SAME admin-list identity and relay set the browser
// does. A hardcoded copy would silently drift the moment an operator retunes
// the instance, and the failure mode (previews gated forever, or worse, a stale
// whitelist) would be invisible.
import {
ADMIN_LIST_NAMESPACE,
ADMIN_SUPER_NPUB,
} from "../js/config.js";
import { DEFAULT_RELAY_URLS } from "../js/nostr/toolkit.js";
import { ADMIN_LIST_IDENTIFIERS } from "../js/nostrEventSchemas.js";
useWebSocketImplementation(WebSocket);
const RELAYS = Array.from(DEFAULT_RELAY_URLS);
// Admin moderation lists are addressable kind-30000 sets published by the
// instance's super admin (js/adminListStore.js).
const ADMIN_LIST_KIND = 30000;
// Measured: a COLD pool's first read across these relays can take ~3s (WSS
// handshake + REQ round trip on four connections). 1.5s and 2.5s both cut it
// off and served the generic card every time. Twitter allows ~5s and Facebook
// ~10s, so 4s buys a correct card on a cold container while still leaving
// headroom under Vercel's function limit. Warm containers answer in <600ms.
const RELAY_TIMEOUT_MS = 4000;
// Admin lists are identical for every request, so re-reading them on each crawl
// is pure waste (and it was starving the video read of its budget). Memoized in
// module scope: warm invocations skip the relay round trip entirely. This is a
// per-container, in-memory cache of PUBLIC data that dies with the container —
// nothing is persisted anywhere.
const ADMIN_LIST_TTL_MS = 5 * 60 * 1000;
let adminListCache = { expiresAt: 0, whitelist: null, blacklist: null };
const SITE_ORIGIN = process.env.BITVID_SITE_ORIGIN || "https://bitvid.network";
const FALLBACK = Object.freeze({
siteName: "bitvid",
title: "bitvid - Decentralized Video Sharing",
description:
"bitvid is a decentralized video sharing platform built on Nostr and WebTorrent.",
image: `${SITE_ORIGIN}/assets/jpg/bitvid.jpg`,
url: SITE_ORIGIN,
});
function withTimeout(promise, ms, fallbackValue) {
return Promise.race([
promise.catch(() => fallbackValue),
new Promise((resolve) => setTimeout(() => resolve(fallbackValue), ms)),
]);
}
/** Decode `?v=` (nevent/naddr/note/raw hex) into a relay filter. */
function pointerToFilter(pointer) {
const trimmed = typeof pointer === "string" ? pointer.trim() : "";
if (!trimmed) {
return null;
}
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return { ids: [trimmed.toLowerCase()] };
}
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === "nevent" && decoded.data?.id) {
return { ids: [decoded.data.id] };
}
if (decoded.type === "note" && typeof decoded.data === "string") {
return { ids: [decoded.data] };
}
if (decoded.type === "naddr" && decoded.data?.identifier) {
return {
kinds: [decoded.data.kind],
authors: [decoded.data.pubkey],
"#d": [decoded.data.identifier],
};
}
} catch (err) {
// Unparseable pointer -> generic card.
}
return null;
}
function extractPubkeySet(event) {
const out = new Set();
if (!event || !Array.isArray(event.tags)) {
return out;
}
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag[0] !== "p" || typeof tag[1] !== "string") {
continue;
}
const value = tag[1].trim().toLowerCase();
if (/^[0-9a-f]{64}$/.test(value)) {
out.add(value);
continue;
}
try {
const decoded = nip19.decode(value);
if (decoded.type === "npub" && typeof decoded.data === "string") {
out.add(decoded.data.toLowerCase());
}
} catch (err) {
// ignore malformed entries
}
}
return out;
}
export default async function handler(req, res) {
const host = req.headers?.host || new URL(SITE_ORIGIN).host;
const proto = req.headers?.["x-forwarded-proto"] || "https";
const requestUrl = new URL(req.url || "/", `${proto}://${host}`);
const pointer = requestUrl.searchParams.get("v") || "";
// Derive the origin from the REQUEST, not from a constant. Otherwise every
// non-production deployment (unstable, previews) advertises production's
// canonical URL and — worse — a twitter:player pointing at production's
// embed, so testing a preview link silently exercises the live site.
const origin = `${proto}://${host}`;
const shareUrl = pointer
? `${origin}/?v=${encodeURIComponent(pointer)}`
: origin;
const embedUrl = pointer
? `${origin}/embed.html?pointer=${encodeURIComponent(pointer)}`
: "";
const renderFallback = (reason) => {
// Short TTL: a video may be published, edited or moderated at any time, and
// we would rather re-ask the relays than pin a wrong card in someone's CDN.
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "public, s-maxage=300, stale-while-revalidate=600");
res.setHeader("X-Bitvid-OG", reason);
res.status(200).send(
renderOgHtml({
video: null,
shareUrl,
fallback: { ...FALLBACK, url: origin, image: `${origin}/assets/jpg/bitvid.jpg` },
})
);
};
// Defensive: the rewrite should only send crawlers here, but never serve a
// human this stub if the rule ever widens.
if (!isCrawlerUserAgent(req.headers?.["user-agent"])) {
res.setHeader("Cache-Control", "no-store");
res.redirect(302, shareUrl);
return;
}
const filter = pointerToFilter(pointer);
if (!filter) {
renderFallback("no-pointer");
return;
}
const pool = new SimplePool();
let superAdminHex = "";
try {
const decoded = nip19.decode(ADMIN_SUPER_NPUB);
superAdminHex = decoded.type === "npub" ? decoded.data : "";
} catch (err) {
superAdminHex = "";
}
try {
const listFilter = (identifier) => ({
kinds: [ADMIN_LIST_KIND],
"#d": [`${ADMIN_LIST_NAMESPACE}:${identifier}`],
...(superAdminHex ? { authors: [superAdminHex] } : {}),
});
// One shared deadline across all three reads.
const now = Date.now();
const adminListsFresh =
adminListCache.expiresAt > now && adminListCache.whitelist instanceof Set;
const [videoEvent, whitelistEvent, blacklistEvent] = await Promise.all([
withTimeout(pool.get(RELAYS, filter), RELAY_TIMEOUT_MS, null),
adminListsFresh
? Promise.resolve(null)
: withTimeout(
pool.get(RELAYS, listFilter(ADMIN_LIST_IDENTIFIERS.whitelist)),
RELAY_TIMEOUT_MS,
null
),
adminListsFresh
? Promise.resolve(null)
: withTimeout(
pool.get(RELAYS, listFilter(ADMIN_LIST_IDENTIFIERS.blacklist)),
RELAY_TIMEOUT_MS,
null
),
]);
let whitelist = adminListsFresh ? adminListCache.whitelist : null;
let blacklist = adminListsFresh ? adminListCache.blacklist : null;
if (!adminListsFresh) {
whitelist = extractPubkeySet(whitelistEvent);
blacklist = extractPubkeySet(blacklistEvent);
// Only cache a whitelist we actually got. Caching an empty one would pin
// "whitelist-unavailable" for the whole TTL after a single slow read.
if (whitelist.size > 0) {
adminListCache = {
expiresAt: now + ADMIN_LIST_TTL_MS,
whitelist,
blacklist,
};
}
}
const video = parseVideoEvent(videoEvent);
if (!video) {
renderFallback("event-not-found");
return;
}
const gate = isPubliclyShareable(video, {
whitelistEnabled: true,
whitelist,
blacklist,
});
if (!gate.allowed) {
renderFallback(`gated:${gate.reason}`);
return;
}
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "public, s-maxage=300, stale-while-revalidate=600");
res.setHeader("X-Bitvid-OG", "video");
res.status(200).send(
renderOgHtml({
video,
shareUrl,
embedUrl,
fallback: { ...FALLBACK, url: origin, image: `${origin}/assets/jpg/bitvid.jpg` },
gate,
})
);
} catch (err) {
renderFallback("error");
} finally {
try {
pool.close(RELAYS);
} catch (err) {
// ignore
}
}
}
+28 -4
View File
@@ -100,7 +100,15 @@
>
<!-- Left navigation column -->
<div class="profile-modal__menu" data-profile-mobile-menu aria-hidden="false">
<nav class="profile-modal__nav" aria-label="Profile sections">
<div class="profile-modal__menu-heading">
<h3 id="profileModalMenuHeading" class="profile-modal__menu-title">
Profile &amp; settings
</h3>
<p class="profile-modal__menu-hint">
Choose what you want to manage
</p>
</div>
<nav class="profile-modal__nav" aria-labelledby="profileModalMenuHeading">
<button
id="profileNavAccount"
type="button"
@@ -108,6 +116,7 @@
aria-controls="profilePaneAccount"
aria-selected="true"
data-state="active"
data-mobile-description="Profile and account settings"
>
<svg
class="h-4 w-4"
@@ -130,6 +139,7 @@
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePaneRelays"
aria-selected="false"
data-mobile-description="Manage your relay connections"
>
<svg
class="h-4 w-4"
@@ -152,6 +162,7 @@
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePaneWallet"
aria-selected="false"
data-mobile-description="Manage your wallet and payments"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -173,6 +184,7 @@
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePaneStorage"
aria-selected="false"
data-mobile-description="Manage your stored files"
>
<svg
class="h-4 w-4"
@@ -195,6 +207,7 @@
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePaneMyVideos"
aria-selected="false"
data-mobile-description="View and manage your videos"
>
<svg
class="h-4 w-4"
@@ -217,6 +230,7 @@
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePanePlaylists"
aria-selected="false"
data-mobile-description="Organize your playlists"
>
<svg
class="h-4 w-4"
@@ -239,6 +253,7 @@
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePaneHashtags"
aria-selected="false"
data-mobile-description="Explore and follow hashtags"
>
<svg
class="h-4 w-4"
@@ -261,6 +276,7 @@
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePaneSubscriptions"
aria-selected="false"
data-mobile-description="Manage your subscriptions"
>
<svg
class="h-4 w-4"
@@ -286,6 +302,7 @@
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePaneFriends"
aria-selected="false"
data-mobile-description="Find and manage your friends"
>
<svg
class="h-4 w-4"
@@ -308,6 +325,7 @@
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePaneSafety"
aria-selected="false"
data-mobile-description="Privacy and safety settings"
>
<svg
class="h-4 w-4"
@@ -330,6 +348,7 @@
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePaneBlocked"
aria-selected="false"
data-mobile-description="Manage blocked users"
>
<svg
class="h-4 w-4"
@@ -352,6 +371,7 @@
class="btn-ghost focus-ring relative w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePaneMessages"
aria-selected="false"
data-mobile-description="View your messages"
>
<svg
class="h-4 w-4"
@@ -379,6 +399,7 @@
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePaneHistory"
aria-selected="false"
data-mobile-description="View your watch history"
>
<svg
class="h-4 w-4"
@@ -408,6 +429,7 @@
class="btn-ghost focus-ring hidden w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePaneAdmin"
aria-selected="false"
data-mobile-description="Moderate users and content"
>
<svg
class="h-4 w-4"
@@ -429,10 +451,12 @@
aria-hidden="true"
></span>
</button>
</nav>
<div class="profile-modal__mobile-actions" role="group" aria-label="Session actions">
<button
id="profileMobileLockNowBtn"
type="button"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-6 lg:hidden hidden"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium lg:hidden hidden"
title="Forget your unlocked key on this device and re-lock storage + wallet now"
>
<svg
@@ -453,7 +477,7 @@
<button
id="profileMobileLogoutBtn"
type="button"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-2 lg:hidden"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium lg:hidden"
data-variant="critical"
>
<svg
@@ -471,7 +495,7 @@
</svg>
Logout
</button>
</nav>
</div>
</div>
<!-- Right content panes -->
+234 -36
View File
@@ -759,6 +759,14 @@
@apply btn focus-ring rounded-full px-4 py-3 text-sm shadow-lg;
}
/* Fixed mobile actions need to clear the browser home indicator as well as
the visual spacing requested by their component. */
@media (max-width: calc(theme("screens.md") - 0.02px)) {
.mobile-safe-bottom {
bottom: calc(var(--space-lg) + env(safe-area-inset-bottom));
}
}
.site-update-notice__panel {
@apply w-full rounded-2xl border border-border bg-overlay-panel-soft p-4 text-left text-text shadow-modal backdrop-blur-sm;
}
@@ -767,6 +775,44 @@
@apply btn-ghost focus-ring -mr-2 -mt-2 h-9 w-9 rounded-full p-0 text-lg leading-none;
}
.site-tagline__text {
text-wrap: balance;
}
@media (max-width: calc(theme("screens.md") - 0.02px)) {
/* Both controls are fixed to the lower-right corner on phones. Keep the
site-update notice above the search FAB so neither tap target obscures
the other. */
.site-update-notice {
right: var(--space-lg);
bottom: calc(
var(--space-lg) + var(--space-2xl-plus) + var(--space-sm)
);
max-inline-size: min(
var(--size-20rem),
calc(100vw - (var(--space-lg) * 2))
);
}
.site-update-notice__button {
min-block-size: var(--size-11);
padding-inline: var(--space-md);
padding-block: var(--space-sm);
}
.site-tagline {
margin-block: var(--space-lg) var(--space-xl);
padding-inline: var(--space-md);
}
.site-tagline .site-tagline__text {
font-size: var(--font-size-lg);
line-height: var(--line-height-tight);
letter-spacing: var(--tracking-normal);
color: var(--color-text-muted);
}
}
.login-option::after {
content: "";
position: absolute;
@@ -983,7 +1029,7 @@
}
.moderation-badge {
@apply inline-flex flex-col items-start gap-sm rounded-xl border px-3 py-1.5 text-xs font-semibold normal-case tracking-tight shadow-sm;
@apply inline-flex max-w-full flex-col items-start gap-sm rounded-xl border px-3 py-1.5 text-xs font-semibold normal-case tracking-tight shadow-sm;
}
.moderation-badge--interactive {
@@ -991,11 +1037,11 @@
}
.moderation-badge__actions {
@apply inline-flex flex-nowrap items-center gap-sm;
@apply flex max-w-full flex-wrap items-center gap-sm;
}
.moderation-badge__label {
@apply inline-flex items-center gap-xs whitespace-nowrap;
@apply inline-flex min-w-0 items-center gap-xs;
}
.moderation-badge[data-variant="warning"] {
@@ -1035,11 +1081,12 @@
}
.moderation-badge__text {
@apply whitespace-nowrap text-xs font-semibold tracking-tight;
@apply min-w-0 text-xs font-semibold tracking-tight;
}
.moderation-badge__action {
@apply inline-flex items-center gap-1.5 rounded-full border border-status-warning-border bg-status-warning-surface px-3 py-1 text-xs font-semibold normal-case tracking-tight text-status-warning-on transition duration-150 ease-out;
@apply inline-flex max-w-full items-center gap-1.5 rounded-full border border-status-warning-border bg-status-warning-surface px-3 py-1 text-xs font-semibold normal-case tracking-tight text-status-warning-on transition duration-150 ease-out;
overflow-wrap: anywhere;
background-color: color-mix(
in srgb,
var(--status-warning) 16%,
@@ -1526,6 +1573,9 @@
.modal-sheet {
inline-size: 100vw;
block-size: 100vh;
block-size: 100dvh;
min-block-size: 100vh;
min-block-size: 100dvh;
max-width: none;
max-height: none;
margin: 0;
@@ -3592,6 +3642,12 @@
display: flex;
flex-direction: column;
gap: var(--space-sm);
min-block-size: 0;
}
.profile-modal__menu-heading,
.profile-modal__mobile-actions {
display: none;
}
.profile-modal__pane-wrapper {
@@ -3772,55 +3828,185 @@
Chromium drops the whole block. `theme("screens.lg")` resolves to a literal at
build time, which is valid. */
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
.profile-modal__identity {
align-self: stretch;
max-inline-size: none;
min-block-size: var(--size-6rem);
margin-inline: var(--profile-modal-padding-inline);
margin-block: 0;
padding: var(--space-md);
border: 0;
border-radius: var(--radius-xl);
color: var(--color-white);
background:
radial-gradient(
100% 130% at 100% 0%,
color-mix(in srgb, var(--color-accent) 82%, transparent),
transparent 58%
),
linear-gradient(
120deg,
var(--color-text-strong) 0%,
color-mix(in srgb, var(--color-text-strong) 82%, var(--color-accent)) 100%
);
}
.profile-modal__identity-avatar {
inline-size: var(--space-2xl-plus);
block-size: var(--space-2xl-plus);
border-color: var(--color-white);
}
.profile-modal__identity-kicker {
color: var(--color-white);
}
.profile-modal__identity-name {
font-size: var(--font-size-lg);
color: var(--color-white);
}
.profile-modal__menu {
flex: 1 1 auto;
overflow-y: auto;
overscroll-behavior: contain;
padding-block-end: max(var(--space-lg), env(safe-area-inset-bottom));
scroll-padding-block-end: max(
var(--space-lg),
env(safe-area-inset-bottom)
);
scrollbar-gutter: stable;
}
.profile-modal__menu-heading {
display: block;
padding-block: var(--space-xs) var(--space-sm);
}
.profile-modal__menu-title {
margin: 0;
font-size: var(--font-size-sm);
font-weight: 700;
letter-spacing: var(--tracking-wide);
color: var(--color-text);
}
.profile-modal__menu-hint {
margin: var(--space-3xs) 0 0;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.profile-modal__nav {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
display: flex;
flex-direction: column;
gap: var(--space-xs);
/* Every tile the SAME height, so a 2-line label can't stretch its row
taller than the others (the "ragged grid of ellipses" fix). */
grid-auto-rows: var(--profile-modal-nav-tile-height);
align-self: stretch;
}
.profile-modal__nav > button {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: var(--space-2xl-plus) minmax(0, 1fr) var(--space-lg);
grid-template-rows: auto auto;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--space-2xs);
justify-content: start;
column-gap: var(--space-md);
row-gap: var(--space-3xs);
width: 100%;
block-size: 100%;
/* Consistent rounded-square tiles, not rounded-full ellipses (btn's
default) that turn into circles once a tile gets taller. */
border-radius: var(--radius-xl);
/* Subtle fill so they read as solid cards, not floating outlines. The
active tile's bg-primary (higher specificity) still wins. */
min-block-size: var(--size-5-75rem);
text-align: start;
font-size: var(--font-size-sm);
font-weight: 700;
line-height: var(--line-height-tight);
border-radius: var(--radius-lg);
border-color: var(--color-border-subtle);
background-color: var(--color-panel);
padding-block: var(--space-xs);
padding-inline: var(--space-3xs);
line-height: 1.15;
padding: var(--space-sm);
overflow: hidden;
/* wrap labels at spaces, not mid-word */
overflow-wrap: break-word;
word-break: normal;
hyphens: none;
box-shadow: var(--shadow-sm);
}
.profile-modal__nav > button[data-state="active"] {
color: var(--color-text);
border-color: color-mix(in srgb, var(--color-accent) 36%, transparent);
background-color: color-mix(in srgb, var(--color-accent) 9%, var(--color-panel));
box-shadow:
inset var(--size-3px) 0 0 var(--color-accent),
var(--shadow-md);
}
.profile-modal__nav > button:focus-visible {
outline-offset: calc(var(--space-3xs) * -1);
}
/* slightly larger glyph for the tile */
.profile-modal__nav > button svg {
width: var(--space-lg);
height: var(--space-lg);
flex-shrink: 0;
grid-column: 1;
grid-row: 1 / span 2;
width: var(--space-xl);
height: var(--space-xl);
padding: var(--space-sm);
box-sizing: content-box;
border-radius: var(--radius-md);
color: var(--color-text);
background-color: var(--color-page-alt);
}
.profile-modal__nav > button::before {
content: "";
grid-column: 3;
grid-row: 1 / span 2;
inline-size: var(--space-xs);
block-size: var(--space-xs);
border-block-start: var(--border-width-hairline) solid currentColor;
border-inline-end: var(--border-width-hairline) solid currentColor;
transform: rotate(45deg);
}
.profile-modal__nav > button::after {
content: attr(data-mobile-description);
grid-column: 2;
grid-row: 2;
align-self: start;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 400;
line-height: var(--line-height-tight);
}
.profile-modal__nav > button[data-state="active"] svg {
color: var(--color-white);
background-color: var(--color-accent);
}
/* In the tile grid the unread dot rides the tile's top-right corner instead
of the vertical-nav left edge (where it landed in the grid gutter). */
.profile-modal__nav > button .profile-nav__unread-dot {
top: var(--space-2xs);
top: var(--space-sm);
left: auto;
right: var(--space-2xs);
right: calc(var(--space-sm) + var(--space-lg));
transform: none;
}
.profile-modal__mobile-actions {
display: flex;
flex: 0 0 auto;
gap: var(--space-xs);
padding-block-start: var(--space-xs);
padding-block-end: max(
var(--space-sm),
env(safe-area-inset-bottom)
);
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--color-page) 0%, transparent),
var(--color-page) var(--space-sm)
);
}
.profile-modal__mobile-actions > button {
flex: 1 1 0;
justify-content: center;
min-block-size: var(--size-11);
border-radius: var(--radius-lg);
}
}
@media (min-width: theme("screens.lg")) {
@@ -6734,6 +6920,18 @@
max-height: none;
}
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
#profileModal {
height: 100dvh;
min-height: 100dvh;
}
#profileModal .bv-modal__panel {
min-height: 0;
overflow: hidden;
}
}
#profileModal .bv-modal__panel {
width: 100%;
height: 100%;
+4
View File
@@ -39,6 +39,10 @@ Welcome to the bitvid documentation. This directory contains detailed architectu
## Active plans
- **[Performance & Efficiency Plan](./performance-efficiency-plan.md)**: A
measurement-first staged plan for reducing client CPU, memory, and network
work without degrading Nostr discovery, feed freshness, moderation, or
playback. Tracked as TODO #34.
- **[BitUnlock Permission-Layer TODO](../todo/TODO_2026-07-23_bitunlock-permission-layer.md)**: Ordered implementation checklist for the BitUnlock-backed media permission and subscription work.
For more details on contributing, please see [CONTRIBUTING.md](../CONTRIBUTING.md).
+113
View File
@@ -0,0 +1,113 @@
# Channel subscriber counts — feature scope
> Status: **Scoped; not implemented** · Created 2026-07-26
## Goal
Show a useful subscriber/follower count on public BitVid channel profiles without
misrepresenting an estimate as a global Nostr fact or weakening private
subscriptions.
## Constraint that determines the design
BitVid subscriptions are encrypted, private NIP-51 lists. Relays cannot answer
"how many people subscribe to this creator?" from those lists. Querying public
follow lists, where they exist, would be incomplete, expensive, sybil-prone,
and would encourage users to make private follows discoverable. Therefore this
feature must not derive a global count by scanning followers' events.
## Recommended product definition
Ship a clearly labelled **BitVid followers** count, not an unqualified global
"Subscribers" count. It should count only voluntary, public follow attestations
that BitVid can verify and should remain an estimate of the BitVid-visible
audience. Keep the existing private subscription feature private and do not use
it as a counting source.
Before implementation, make one explicit product decision:
1. **Public attestation count (recommended):** a viewer may opt in to publish a
public, signed follow attestation; BitVid counts valid unique authors for a
creator. This has a real verifiable numerator but is necessarily partial.
2. **Creator-reported count:** creator publishes a signed number. This is cheap,
but is self-reported and must be labelled as such.
3. **No public count:** retain private subscriptions only. This is the strongest
privacy option.
Do not silently fall back from option 1 to option 2.
## Proposed implementation slice (option 1)
### Data model and protocol
- Research existing Nostr follow/interest conventions first; reuse an
interoperable event if one supports a public creator follow without exposing
the private BitVid list.
- If no suitable convention exists, write a small BitVid event proposal before
coding: event kind, tags, replaceability/deletion semantics, creator target,
timestamp rules, relay selection, and migration/version tag.
- Count at most one current attestation per follower/creator relationship.
- Ignore malformed events, self-follows, deleted/replaced events, and events
outside the selected protocol version.
- Treat counts as eventually consistent and relay-scoped. Never claim a global
Nostr total.
### Read path and caching
- Add a dedicated count service/store; do not add follower scans to
`channelProfile.js` rendering or the normal feed bootstrap.
- Fetch only when a channel profile is opened, with a bounded relay set,
cancellation on profile navigation, in-flight deduplication, and a short
cache TTL.
- Render a compact skeleton or omit the count while loading. A failed count
lookup must never block profile metadata, video loading, zap controls, or
subscriptions.
- Expose source/coverage only in a low-noise detail surface if needed (for
example, "BitVid-visible followers"), not as a misleading exact total.
### Channel-profile UI
- Place the count in the creator summary/stat row near videos/zaps, not in the
profile modal.
- Use the chosen product label consistently: `BitVid followers` for public
attestations; `Creator-reported subscribers` for self-reported data.
- Provide an accessible label and compact mobile layout; large counts use
locale-aware formatting.
- The viewer's private Subscribe/Unsubscribe control remains unchanged. If an
opt-in public follow is added, it is a separate, explicit privacy choice.
### Abuse and privacy safeguards
- Do not expose who follows whom by default beyond the public attestations the
viewer chose to publish.
- Do not rank or moderate creators solely by this count.
- Rate-limit/bound relay queries and deduplicate by author; document that sybil
resistance is not provided by a raw public count.
- Preserve viewer blocks/mutes and creator visibility rules in any optional
follower preview; the count itself must not leak a viewer's private list.
## Tests and acceptance criteria
- Unit tests: unique-author counting, replacement/deletion, malformed events,
self-follow exclusion, cache TTL, and formatted display values.
- Service tests: bounded/cancelled relay queries, timeout/failure returns an
unavailable state without clearing a good cached count.
- UI tests: loading, available, unavailable, and mobile wrapping states.
- Manual QA: opening a channel profile has no visible impact on Home feed or
private subscription list loading; toggling a private subscription publishes
no public attestation unless the viewer explicitly opts in.
## Non-goals for the first release
- A global Nostr subscriber count.
- Inferring counts from private encrypted lists.
- Follower directories, recommendations, notifications, or monetized tiers.
- Using counts as a trust, moderation, or discovery ranking signal.
## Implementation order
1. Approve the product definition and public/private wording.
2. Complete protocol/convention research and record the selected event shape.
3. Build the bounded count store with tests and fixtures.
4. Add channel-profile presentation and responsive UI tests.
5. Run privacy/performance QA before enabling public-attestation publishing.
+1 -1
View File
@@ -36,7 +36,7 @@ bitvid is follow-centric. Your Home feed comes from people you follow (F1). Disc
- The badge tooltip and `aria-label` continue to list the specific trusted contacts who muted or reported the content when that metadata is available.
- Viewers who override a trusted mute hide keep a **Block** pill on the same badge so they can block the creator without hunting through another menu.
- Selecting **Block** immediately publishes an account block for the creator, clears any temporary overrides, and reloads the feed so the author disappears from subsequent fetches.
- Trusted mute decisions now block autoplay even when report thresholds have not fired, and any associated avatars or channel banners adopt the blurred state alongside the primary thumbnail so the entire card reflects the moderation choice.
- Trusted mutes are a ranking-only signal until their hide threshold is reached. A single stale or bad-faith mute never blurs a creator, blocks playback, or adds a warning badge; feed sorters place those items later. At the configured mute-hide threshold, the card is hidden and its visuals are blurred like any other hide decision.
## Where to extend
Thread new moderation behaviors through the same service → stage → app → UI flow above. Extending the existing layers keeps overrides, feature flags, and QA hooks consistent—avoid spinning up parallel moderation modules unless the architecture document explicitly calls for it.
+1 -1
View File
@@ -56,7 +56,7 @@ Expected:
}
```
Expected: author’s items are downranked/blurred for this viewer. When the mute threshold (`TRUSTED_MUTE_HIDE_THRESHOLD`, sourced from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); upstream example: 20) is met, cards render with "Hidden · {count} trusted mute(s)" and require a "Show anyway" override to display content plus a "Restore default moderation" control to revert.
Expected: author’s items are downranked for this viewer without blur, playback blocking, or a warning badge. When the mute threshold (`TRUSTED_MUTE_HIDE_THRESHOLD`, sourced from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); upstream example: 20) is met, cards render with "Hidden · {count} trusted mute(s)" and require a "Show anyway" override to display content plus a "Restore default moderation" control to revert.
**Exercises:** [`ModerationService.ingestTrustedMuteEvent()`](../../js/services/moderationService.js) updates [`ModerationService.isAuthorMutedByTrusted()`](../../js/services/moderationService.js) / [`getTrustedMutersForAuthor()`](../../js/services/moderationService.js), which [`createModerationStage()`](../../js/feedEngine/stages.js) propagates to the video item before [`VideoCard.refreshModerationUi()`](../../js/ui/components/VideoCard.js) renders the mute state.
+439
View File
@@ -0,0 +1,439 @@
# BitVid Performance & Efficiency Plan
Status: **Stage 2 shipped; Stage 3 in progress — observer teardown shipped, probe cancellation next**
## Progress snapshot — 2026-07-26
The plan is being delivered as independent, test-gated slices on `unstable`.
### Shipped
- **Stage 0 foundation:** the opt-in local performance harness is available;
it remains disabled unless the loopback-only storage flag is set.
- **Stage 1 complete:** localhost relay-request monitoring is explicit opt-in.
- **Stage 2 complete:** `ExploreDataService` is demand-created for For You and
Explore, pauses outside active recommendation views, coalesces concurrent
history/IDF refreshes, and no longer blocks cached For You rendering.
- **Stage 3 first slice:** card, URL-health, and grid-health observers are
disposed when feed markup is replaced or unmounted.
- **Stage 7 first slice:** recommendation refresh bursts are coalesced into one
active pass plus one follow-up; Most Zapped ordering has regression coverage.
### Remaining
- Capture and retain the complete Stage 0 baseline scenarios, including cache
size, worker lifetime, probe counts, and browser CPU/RSS.
- Finish Stage 3 with owner-aware queue pruning and abort signals for URL and
WebTorrent probes, then verify hidden-card reveal and fallback behavior.
- Finish Stage 7 with bounded, category-aware event-cache eviction and same-feed
refresh coalescing without swallowing explicit refreshes.
- Implement Stage 5's service-worker update cadence and image-cache freshness
policy after measuring warm-feed revalidation.
- Profile before changing Stage 4 (modal visuals) or Stage 6 (bundle/lazy
loading); these remain unstarted and measurement-dependent.
### Current release evidence
- `5f439d68`: lazy recommendation indexes.
- `687f8c43`: lifecycle disposal, refresh coalescing, and Most Zapped ordering.
- `bb6614e3`: cache-first For You navigation rendering.
- Targeted regression suites and production builds pass for each shipped slice;
the current remote tip is `origin/unstable` at `bb6614e3`.
## Goal
Reduce idle CPU, GPU, memory, relay traffic, and unnecessary network activity
without making feeds stale or weakening BitVid's Nostr-native recommendation
system.
BitVid must continue to ingest relevant notes, follows, zaps, watch events, and
moderation signals promptly. The optimization is about scheduling and bounded
work, not removing recommendation inputs or live feed updates.
## Baseline findings
Local observations on 2026-07-26 while serving the production build:
| Component | Observed load | Conclusion |
| --- | --- | --- |
| Static `serve dist` process | under 1% CPU, about 115 MB RSS | Not the heat source. |
| Browser main/renderer/GPU processes | renderer about 22% CPU, GPU about 7% CPU, renderer about 430 MB RSS | Primary area for profiling and improvement. |
| System memory | 24 GiB available; no swap in use | Not currently memory pressured. |
Code audit candidates:
- `ExploreDataService` starts for every session and refreshes watch-history tags
every minute and tag-IDF every five minutes, even outside Explore/For You.
- `devReqMonitor.js` automatically patches WebSocket sends on localhost and
wakes every two seconds. This is development-only but makes local testing
noisier and more expensive than production.
- App startup registers/checks the service worker and explicitly calls
`registration.update()` on every load.
- URL/torrent health checks can allocate media probes and WebTorrent tracker
work; they must remain viewport-driven, bounded, and cancellable.
- The video-modal ambient background can perform recurring canvas draws while a
video plays. It is a likely GPU hotspot and needs measurement before tuning.
- The image service-worker cache revalidates external images in the background;
warm feeds can still cause many network/cache operations.
## Deeper system audit
This table separates verified mechanics from hypotheses. It is deliberately not
a list of features to remove: every item has a product boundary that must stay
intact.
| System | Verified finding | Risk / opportunity | Planned boundary |
| --- | --- | --- | --- |
| Nostr subscriptions and feed loading | Feed loading deduplicates normal in-flight requests and active feeds need live relay updates. | Over-throttling would make Home, follows, zaps, and moderation stale. | Preserve subscriptions and event ingestion; coalesce only duplicate refresh/re-rank work. |
| For You / Explore indexes | `ExploreDataService` is constructed during bootstrap, clones the video set into a worker, refreshes history tags each minute and IDF each five minutes. Event, visibility, and timer paths can request the same broad work. | Unused views pay recurrent CPU, worker-message, and allocation cost. | Keep lightweight dirty/version signals live; create and refresh the worker only for an active or imminent recommendation view, with one serialized refresh per index. |
| Event persistence and relay cache | IndexedDB writes are differential and idle-scheduled, but the payload is built from the complete `allEvents` and tombstone maps; restore likewise rehydrates the complete unexpired cache. TTL expiry is 10 minutes, with no explicit record/byte budget. | Large relay sessions can raise startup, structured-clone/serialization, IndexedDB, and renderer-memory cost. | Measure actual record/byte growth first, then add an eviction budget that preserves active/latest, user-owned, watched, and moderation-relevant events. Never replace the cache with a smaller unscoped relay query. |
| URL liveness cards | The observer is viewport/prefetch-margin driven, and URL checks are deduplicated at controller level. Hidden-until-verified cards intentionally start an eager check so they can become visible. | A large hidden-card batch can still fill work queues; discarded cards cannot presently express cancellation through the generic observer. | Retain the reveal guarantee, but add queue cancellation, page/feed budgets, and primary-source-first fallback. |
| WebTorrent stream health | Torrent probes are globally queued (two at a time), cached, deduplicated, capped at four peer connections and destroy each probe torrent. A reusable probe client remains after first use. | The safeguards are good, but queue entries may outlive a discarded feed and a health badge can still pull WebTorrent into an otherwise hosted-video session. | Add cancellation/queue pruning and explicit health-probe telemetry before changing limits; evaluate lazy WebTorrent loading separately from playback correctness. |
| WebTorrent startup bundle | The WebTorrent facade imports the roughly 212 KB minified implementation at module evaluation time. | Browsers can download/parse a torrent stack before the viewer plays or probes a torrent. | Measure module transfer/parse on cold Home, then introduce a lazy implementation loader only if hosted playback and health-badge behavior remain correct. |
| Service worker and external images | Boot checks existing registrations and calls `update()`; the image strategy returns cache quickly but starts a network revalidation for each cached image request. | Warm feeds can produce avoidable fetch/cache churn even when their images are already fresh. | Keep deploy reliability and bounded cache trimming; add a tested freshness policy rather than disabling updates or image caching. |
| Modal visuals and UI rendering | The ambient-video helper schedules animation-frame callbacks while active, although its current modal wiring needs trace validation. Feed rerenders replace card DOM and attach health observers again. | Canvas work may be a GPU cost; observer lifecycle needs a retained-node/queue profile before calling it a leak. | Instrument active animation callbacks and observed-card counts. Tune only if a playback trace shows material cost; add explicit observer teardown if retained cards are confirmed. |
| Development diagnostics | On localhost, `devReqMonitor` patches WebSocket sends and wakes every two seconds. | It contaminates local CPU and relay measurements but is not a production heat source. | Make it opt-in so normal development reproduces production behavior. |
### What is *not* a candidate for removal
- Nostr notes, follows, zaps, reactions, watch events, and moderation signals
remain the raw material for feeds and recommendations.
- Active-feed subscriptions, source fallback, WebTorrent playback, and the
stale-while-revalidate cache remain product capabilities.
- A timer or worker is changed only after a trace identifies it as active in a
relevant scenario; no optimization is justified merely because code exists.
## Design rules
1. **Nostr ingestion stays live.** Keep subscriptions for active feeds and
logged-in viewer context. New relevant events update local indexes promptly.
2. **Expensive work is demand-driven.** A new event marks an index stale; it
does not force a whole recommendation rebuild until the associated feed is
active or about to become active.
3. **Inactive views do no recurring work.** Hidden tabs, closed modals, and
inactive feed views must pause timers, observers, and probe queues.
4. **Every background task has a bound.** Cap concurrency, use TTL caches,
cancel obsolete work, and destroy listeners/workers when their feature is no
longer in use.
5. **No silent product degradation.** Feed freshness, source fallback,
WebTorrent playback, moderation, and user-visible recommendation inputs are
acceptance criteria in every stage.
6. **Use cancellation, not just lower limits.** A task belonging to a removed
card, closed modal, inactive feed, or hidden document must be removable from
the queue; lowering concurrency alone can leave obsolete work running.
7. **Measure both wall time and retained state.** A faster task that retains a
large worker snapshot, observer target, cache record, or probe client is not
an efficiency win.
8. **Preserve discovery boundaries.** A lifecycle or batching optimization must
use the same scoped relay/source selection for historical fetch and live
subscription. Never make a feature appear faster by querying a smaller,
unrelated source set and silently omitting valid user data.
Every affected feature needs a regression fixture containing data available
only through its scoped historical source and data arriving live afterward.
## Measurement harness (Stage 0) — foundation shipped; baseline capture remains
Create a local-only, opt-in performance harness. It must be disabled by
default and absent from production behavior.
Implemented baseline surface: on `localhost`, `127.0.0.1`, or `::1`, set
`localStorage.__bitvidPerformanceHarness__ = "1"` and reload. The console API
`window.__bitvidPerformance.snapshot()` reports bounded long-task, resource,
navigation, heap (when supported), mark, and manually supplied gauge data.
`mark`, `setGauge`, and `increment` are available for later stage-specific
instrumentation. The harness does not initialize on a public host, even if the
storage key is set.
### Measurements
- Long tasks via `PerformanceObserver`.
- JS heap, where the browser exposes it.
- Active workers, timers, observers, URL media probes, torrent probes, and
relay subscription/request counts.
- Feed refresh duration, item count, and recomputation reason.
- Recommendation-index snapshots: input size, queue/coalescing count, worker
lifetime, and stale-to-fresh latency.
- Event-cache record count, estimated serialized bytes, restore duration, IDB
writes/deletes, and protected-versus-evicted record categories.
- Browser process CPU/RSS sampled externally during test scenarios.
### Standard scenarios
1. Cold load to Home; wait 60 seconds without interaction.
2. Scroll through two feed pages, then stop.
3. Open and close Profile; remain on Home.
4. Open Explore and For You; receive a new relevant note, follow, zap, and
moderation update.
5. Open hosted playback, then close it.
6. Open torrent-backed playback, then close it.
7. Background the tab for two minutes and restore it.
8. Load an inbox with both legacy NIP-04 and historical NIP-17 conversations,
then receive a live NIP-17 message; verify all conversations are present.
### Stage gate
Record baseline results before any optimization. The harness itself must have a
test proving it does not initialize unless its explicit local debug flag is
set.
## Stage 1 — Make localhost relay monitoring explicit — complete
### Change
Change `js/devReqMonitor.js` so it only installs when
`localStorage.__bitvidReqMonitor__ === "1"` on a loopback host. Do not
auto-enable merely because the host is localhost, and never permit the monitor
on a public host.
### Why
It patches every WebSocket send, parses REQ frames, and has a two-second timer.
It is useful during a relay-storm investigation but should not distort ordinary
local performance testing.
### Tests and acceptance
- With the flag off: no WebSocket patch and no monitoring timer.
- With the flag on: existing relay-storm warnings still identify REQ kinds.
- No production host can enable it without the explicit local flag.
- Compare idle local CPU/long-task baseline before and after.
Implemented with `isRequestMonitorEnabled()` / `installRequestMonitor()` and
regression coverage for disabled localhost, enabled loopback, and public-host
rejection. To use it during a targeted local relay investigation, set
`localStorage.__bitvidReqMonitor__ = "1"` and reload; remove the key or set it
to any other value for normal profiling.
### Rollback
One isolated commit; restoring the previous activation condition restores the
old diagnostic behavior.
## Stage 2 — Lazy recommendation indexes
Implemented: `ExploreDataService` is no longer created during
ordinary bootstrap. Home/Profile sessions therefore create no recommendation
worker or recommendation intervals. Opening For You or Explore creates and
activates the service; events received while inactive mark it dirty and are
included in the activation refresh. Concurrent history/IDF signals share one
running rebuild and retain one forced follow-up pass. Focused lifecycle,
feed-coordinator, production-build, and browser navigation checks pass; manual
For You/Explore validation confirmed normal recommendations and responsive Home.
### Change
Do not initialize `ExploreDataService` globally in application bootstrap.
Start it when Explore or For You is first opened, and stop/pause its intervals
when neither view is active or the document is hidden.
Keep Nostr ingestion active. Incoming videos, watch-history changes, follows,
zaps, preference changes, and relevant moderation changes mark the associated
index stale. Refresh the index immediately when its feed is visible, or just
before rendering it.
Use a small lifecycle coordinator with these states: `not-created`, `idle`,
`dirty`, `refreshing`, and `paused-hidden`. A refresh request while refreshing
sets one follow-up dirty bit rather than starting another full worker message.
Snapshot only the minimal fields required by the worker and record snapshot
size in the local-only harness.
### Why
The current worker receives cloned video/history data on a one-minute and
five-minute cadence even when the viewer is using Home, Profile, or a modal.
### Tests and acceptance
- Home/Profile-only session creates no Explore worker or recommendation
intervals.
- Opening Explore or For You initializes the worker once and produces the same
ranking inputs as today.
- A new relevant note, follow, watch event, zap, or moderation update is
reflected on the next active-feed refresh.
- Backgrounding the tab pauses the intervals; restoring it performs one bounded
catch-up refresh.
- Existing For You tiering, Trending, and recommendation tests remain green.
- Timer, visibility, and event triggers coalesce into at most one active and
one follow-up index calculation per index.
### Rollback
Feature-scoped lifecycle change behind a small coordinator boundary; restore
eager initialization if feed freshness regresses.
## Stage 3 — Bound source-health work to visible cards
**Progress:** first lifecycle-disposal slice shipped in `687f8c43`; queue
ownership, cancellation, and abortable active probes remain.
### Audit findings (2026-07-26)
- `createCardObserver` retains an `IntersectionObserver` per grid container but
has no disposal API. `VideoListView.render()` replaces card DOM without
disconnecting the old observer, so detached cards can stay observed.
- `gridHealth` has a globally bounded, priority-ordered torrent queue, but its
queued jobs have no owner/cancellation handle. A discarded card can therefore
still start a real tracker/WebRTC probe.
- URL health uses an independent observer and video-probe wait queue with the
same missing ownership boundary. Its fetch timeout aborts only on timeout,
not when the card/grid disappears.
Implementation order: add disposable observer registrations and call them from
grid replacement/unmount; associate queued jobs with card owners and prune jobs
when their last owner disappears; finally pass an abort signal into active URL
and torrent probes. Cache/in-flight deduplication stays shared so one visible
card never cancels work still needed by another visible card.
### Change
Keep the existing viewport/preload observer and current two-at-a-time torrent
queue. Add an abort/cancellation token through URL probes and queue pruning for
WebTorrent work when cards leave the feed or its container is discarded. Hidden
cards that must be verified before reveal receive a small, observable per-feed
budget rather than an unbounded eager batch. Preserve deduplication by
event/source and separate positive/negative TTLs.
Do not probe every alternate mirror eagerly. Probe the primary source first and
only test an alternate when the primary is unavailable or playback needs it.
### Why
Media-element probes and WebTorrent tracker probes are useful for accurate
source badges and fallback, but are among the most expensive background tasks.
### Tests and acceptance
- Scrolling a large feed never exceeds the configured URL/torrent concurrency
limit.
- Off-screen and removed cards do not start or retain probes.
- Cached results render badges without a second request.
- Dead URL still falls back to a working mirror or WebTorrent source.
- Closing a feed/modal tears down pending health work.
- Hidden-card verification is bounded and still reveals cards whose source
proves healthy.
### Rollback
Keep the existing probe implementation available behind the observer boundary
until playback and badge tests pass on Chromium and Firefox.
## Stage 4 — Reduce playback visual overhead
### Change
First trace whether the video-modal ambient canvas is actually wired into the
current modal path, then profile it. If it appears in the hot path,
replace the free-running animation loop with `requestVideoFrameCallback` where
available, a capped fallback cadence, and automatic disablement for
`prefers-reduced-motion` or a low-power preference.
The effect must exist only while the modal video is actively playing and be
fully torn down on pause, close, source change, and hidden document.
### Tests and acceptance
- Hosted and torrent playback remain visually correct.
- Pause/close/source-change cancel all animation callbacks and observers.
- Background tabs make no canvas draws.
- Reduced-motion mode does not attach the visual effect.
- Compare renderer/GPU CPU while playing the same short clip before and after.
### Rollback
Keep the visual treatment behind a feature/config gate until manual playback QA
is complete.
## Stage 5 — Service-worker and image-cache cadence
### Change
- Keep service-worker registration, but avoid forcing a full update check on
every application boot. Use normal browser update cadence or a bounded
version/age policy.
- Add an image-cache freshness window so a cached external image is served
immediately and only revalidated when stale or needed by a visible card.
### Tests and acceptance
- A fresh deploy still activates normally without a hard refresh.
- Rollback deploy still works with hashed assets and normal reload.
- Cached thumbnails/avatars paint immediately.
- Warm feed loads do not revalidate every external image at once.
- Image cache remains bounded and recovers safely from quota failures.
### Rollback
Keep cache format/version semantics intact; any regression can revert the
freshness policy without deleting user-visible image caching.
## Stage 6 — Bundle and feature activation review
### Change
Start with the WebTorrent implementation, because it is currently imported by
the facade at module evaluation time. Review other heavy feature modules only
from measured cold-load transfer/parse evidence. Dynamically import at first
use where safe: profile management, DMs, upload, torrent playback, and
feature-specific Nostr helpers. Do not defer the active feed, authentication,
or basic playback path in a way that creates visible delay.
### Tests and acceptance
- Initial Home load has fewer transferred/parsed bytes.
- Opening each deferred UI initializes once and still works offline/static-hosted.
- No feature code is fetched when its flag is off.
- Build artifact, cache, E2E, and visual tests pass.
## Stage 7 — Cache budget and feed-refresh coalescing
**Progress:** recommendation-index refresh coalescing shipped in `5f439d68`;
the event-cache budget and full same-feed refresh coordinator remain.
### Change
After Stage 0 records real cache growth, add explicit event-cache record and
byte budgets plus category-aware eviction. Protect currently active feed items,
the newest active version of an event, local/user-owned content, recent watch
history pointers, and moderation state needed for the current viewer. Evict
older, unreferenced relay data first. In the feed coordinator, coalesce
same-feed refresh requests and record the initiating reason, without delaying
an explicit user refresh.
### Why
The cache is already incremental at write time, but its full-map snapshot and
restore behavior can become expensive as a long-lived relay session grows.
Coalescing protects against several valid live signals causing redundant
render/rank passes together.
### Tests and acceptance
- Seeded oversized caches restore only the protected/bounded record set and
issue normal scoped relay fetches for missing items.
- Feed data, deletions, replacements, moderation decisions, and recent watch
history produce the same visible result before and after eviction.
- A burst of equivalent refresh signals produces one running refresh plus, at
most, one follow-up; an explicit refresh is never swallowed.
- IndexedDB and localStorage fallback behavior remain covered.
### Rollback
Version cache metadata and keep the current restore format readable. Reverting
the budget must not erase cached user data.
## Release procedure
Each stage ships independently. The recommended order is Stage 0, Stage 1,
Stage 2, Stage 3, Stage 7, Stage 5, then the measurement-dependent visual and
bundle work (Stages 4 and 6):
1. Capture baseline and define the expected improvement.
2. Make one focused implementation commit.
3. Run the stage's targeted tests plus relevant browser QA.
4. Compare measurement results to the baseline.
5. Run `npm run build` and the applicable design checks.
6. Promote only when no feed freshness, playback, moderation, or cache
regression is observed.
Do not combine stages in one commit. A failed stage is reverted independently
without discarding validated improvements from earlier stages.
+125
View File
@@ -94,8 +94,133 @@ sequenceDiagram
end
```
## HLS (.m3u8) sources
Hosted sources whose path ends in `.m3u8` are HLS playlists and cannot be
assigned to `videoEl.src` on every browser. `js/services/hlsPlayback.js` owns
the decision and the hls.js lifecycle.
**Engine choice.** hls.js (MSE) wins whenever `MediaSource` can back it; native
playback is used only when MSE is unavailable. Do NOT branch on `canPlayType`
alone — measured behavior differs sharply:
| Browser | `canPlayType("application/vnd.apple.mpegurl")` | bare `<video src=…m3u8>` |
| --- | --- | --- |
| Firefox 146 | `""` | `MEDIA_ERR_SRC_NOT_SUPPORTED` (code 4) |
| Chrome 149 | `"maybe"` | plays natively |
Before this existed, Firefox reported `No playable source found.` for every HLS
note (and the URL-health badge showed `❌ CDN`), while Chrome happened to work.
**Wiring.**
- `js/services/playbackService.js` — `attemptHostedPlayback` routes HLS
candidates through `attachHlsSource()`; `resetVideoElement()` calls
`detachHls()` before clearing `src`.
- `js/app/modalCoordinator.js` — `teardownVideoElement` calls `detachHls()`
first; otherwise the transmuxer worker and segment fetches outlive the modal.
- `js/ui/urlHealthController.js` — probes HLS by fetching the playlist and
checking for `#EXTM3U` instead of using a bare `<video>` (which cannot load it
and, on Chrome, emits no events at all — burning the whole probe timeout).
- `HLS_PLAYBACK_START_TIMEOUT` (`js/constants.js`) widens the 3s start window,
which fires mid-startup for HLS: the bundle, master playlist, variant
playlist, init segment and first media segment are serial round trips.
**Vendoring.** `npm run build:hls` regenerates `vendor/hls.bundle.min.js` from
the pinned devDependency. It is the FULL hls.js build, not `hls.js/light` —
light drops the alternate-audio stream controller, and real ladders (e.g.
nostube/slidestr) demux audio into its own `EXT-X-MEDIA` rendition group. The
bundle is lazy-imported only when an HLS URL is encountered, so ordinary
MP4/WebTorrent playback never pays for it.
**Rollback.** Stop calling `attachHlsSource()` from `playbackService.js` /
`playbackCoordinator.js` and the previous `videoEl.src = url` behavior returns;
revert the `urlHealthController.js` branch to restore the old element probe.
Nothing else imports `hlsPlayback.js`, and the vendored bundle is inert once
unreferenced.
## Implementation notes
- The playback session is responsible for cleanup and emits events (`status`, `sourcechange`, `error`, `finished`) that `bitvidApp` listens to for UI updates.
- `bitvidApp.playVideoWithFallback` keeps the current video card and modal state in sync with the normalized magnet values so copy actions and stats reflect the actual playback payload.
## Link previews (NIP-92 imeta + per-video OpenGraph)
Two independent surfaces make a shared bitvid video render richly.
**1. Inside nostr clients — `js/nostrEventSchemas.js`.**
`buildShareEvent()` appends a NIP-92 `imeta` entry describing the thumbnail URL
that the note body ends with. Many clients only inline such a URL when an imeta
entry backs it; without it the viewer sees a bare link. The media type is
inferred from the extension (`inferImageMimeTypeFromUrl`, `js/utils/mime.js`)
and OMITTED when unknown — a wrong `m` is worse than a missing one, because
clients use it to decide whether to render inline at all. Extensionless
Blossom-style URLs therefore ship `url` + `alt` only.
**2. Everywhere else — `api/og.js` + `api/_lib/ogRenderer.mjs`.**
`index.html` carries one set of site-wide OG tags, so every `/?v=<nevent>` link
previewed as the same generic card. Crawlers don't run JS, so this is rendered
server-side on demand.
- **Nothing is pre-generated or stored.** One stateless function serves all
videos; the video is a query param, not a path. `dist/` is byte-identical
with or without it. The only caching is the CDN edge (`s-maxage=300`) plus a
per-container in-memory memo of the admin lists.
- **Never load-bearing.** Only crawler user-agents are rewritten to it
(`vercel.json`), humans get the untouched SPA, and a static/nsite deploy with
no functions keeps working — it just serves the site-wide card. Rollback is
deleting the `rewrites` block.
- **Player card, not just an image.** It emits `twitter:player` / `og:video:url`
pointing at `embed.html?pointer=<nevent>`, so a shared link plays inline on
Twitter/Discord/Telegram. `frame-ancestors *` (`_headers`) already permits it.
- **Fails closed.** A crawler never authenticates, so anything emitted is public
forever in third-party caches. `isPubliclyShareable()` requires an
affirmatively public video: `deleted`, `isPrivate`, `isNsfw`/`content-warning`,
blacklisted authors, and — while whitelist mode is on — an unavailable or
empty whitelist all fall back to the generic card.
- **Both event shapes.** `parseVideoEvent()` reads bitvid's kind-30078 v3 JSON
AND the NIP-71 tag shape (kinds 21/22/34235/34236) that nostube/slidestr
publish, where the title lives in a `title` tag and the thumbnail in an
`imeta image` entry.
- **Timeouts.** A cold pool's first relay read measured ~3s, so the budget is
4s (Twitter allows ~5s); warm containers answer in <600ms because the admin
lists are memoized for 5 minutes.
Config is imported from `js/config.js`, `js/nostr/toolkit.js` and
`js/nostrEventSchemas.js` rather than duplicated, and `tests/og-renderer.test.mjs`
fails if that regresses to hardcoded literals.
### Quoting the NIP-71 mirror
When a video has a NIP-71 mirror, the share note embeds `nostr:naddr1…` for it.
Nostr clients render that as a **native video quote card** — the closest thing
to a rich preview that does not depend on any client fetching OpenGraph tags
(most never do; the OG endpoint above mainly serves Twitter/Discord/Telegram).
The mirror is addressable (kind 34235/34236, author = video pubkey,
`d` = `videoRootId`), so `js/nostr/mirrorPointer.js` derives the coordinate with
no relay lookup, and the pointer survives the author editing the video — a plain
`nevent` would rot on the next replace.
Rules that keep this from looking worse than the plain link:
- **Verify before pointing.** `ShareNostrController.resolveMirrorPointer()`
confirms the mirror exists via `nip71MirrorService.findMirror()` before
embedding anything. A pointer to a missing event renders as a broken/empty
quote box. Every failure path — service disabled, lookup throws, lookup times
out (2.5s), no `videoRootId` — falls back to the thumbnail-URL note.
- **Use the observed kind.** `resolveMirrorKind()` prefers the kinds actually
seen on relays over the portrait heuristic. Only when nothing was observed does
it fall back to the SAME rule the mirror builder uses (`nip71Mirror.js`:
`hasDims && height > width`), so a derived pointer matches what would be
published rather than guessing independently.
- **Never emit both images.** While quoting, the raw thumbnail URL is dropped
from the body and the `imeta` tag is skipped — the quote card already shows the
thumbnail, and keeping both rendered the same image twice.
- An `a` tag (`kind:pubkey:d`) links the note to the quoted coordinate for
quote-aware clients and indexers.
Known limitation: a client rendering the quote card still needs to play the
video, and mirrors whose `imeta url` is an `.m3u8` hit the same HLS wall bitvid
did — the thumbnail and title render, playback depends on the client.
+3 -3
View File
@@ -374,8 +374,8 @@
<!-- Tagline fade matches the cards. -->
<div class="fade-in fade-in-delay-300 mb-8 text-center">
<h2 class="text-2xl font-bold tracking-wide text-muted-strong">
<div class="site-tagline fade-in fade-in-delay-300 mb-8 text-center">
<h2 class="site-tagline__text text-2xl font-bold tracking-wide text-muted-strong">
seed. zap. subscribe.
</h2>
</div>
@@ -386,7 +386,7 @@
<!-- Mobile Search FAB -->
<button
id="mobileSearchFab"
class="focus-ring fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-accent-strong shadow-lg transition-transform duration-200 hover:scale-105 active:scale-95 md:hidden"
class="mobile-safe-bottom focus-ring fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-accent-strong shadow-lg transition-transform duration-200 hover:scale-105 active:scale-95 md:hidden"
aria-label="Open search"
>
<svg
+1
View File
@@ -735,6 +735,7 @@ class Application {
ModerationActionController,
setModerationOverride,
clearModerationOverride,
clearAuthorModerationOverride,
userBlocks,
buildVideoAddressPointer,
VIDEO_EVENT_KIND,
+30
View File
@@ -176,6 +176,22 @@ export function createAuthSessionCoordinator(deps) {
return {
async handleAuthLogin(detail = {}) {
const requestedPubkey =
this.normalizeHexPubkey?.(detail?.pubkey || this.pubkey) ||
detail?.pubkey ||
this.pubkey ||
"";
const inFlightLogin = this._authLoginSync;
if (
requestedPubkey &&
inFlightLogin?.pubkey === requestedPubkey &&
inFlightLogin?.promise
) {
userLogger.info("[auth-login-duplicate-ignored]", {
pubkey: requestedPubkey,
});
return inFlightLogin.promise;
}
const authLoginStart = now();
userLogger.info("[auth-login-start]", {
pubkey: detail?.pubkey || this.pubkey || null,
@@ -721,6 +737,20 @@ export function createAuthSessionCoordinator(deps) {
return listSyncDetail;
});
// A provider/widget can repeat its success event while the first login is
// still hydrating lists. The identity is already active, so replaying the
// full lifecycle only doubles relay work and grid refreshes. Join the
// original lifecycle until its required list sync settles instead.
const authLoginSync = Promise.resolve(listStatePromise).finally(() => {
if (this._authLoginSync?.promise === authLoginSync) {
this._authLoginSync = null;
}
});
this._authLoginSync = {
pubkey: activePubkey || requestedPubkey,
promise: authLoginSync,
};
// DMs are intentionally NOT eagerly loaded at login. A cold DM load
// (limit: 50) is a burst of nip-07 decrypts that competes with the
// feed-driving lists (blocks/subscriptions/hashtags) for the SAME
+38
View File
@@ -1014,6 +1014,27 @@ export function createFeedCoordinator(deps) {
devLogger.log(`Starting loadFeedVideos(${feedType})... (forceFetch =`, forceFetch, ")");
this.setFeedTelemetryContext(feedType);
const recommendationFeed =
feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.EXPLORE;
let exploreActivation = null;
if (recommendationFeed && typeof this.ensureExploreDataService === "function") {
// Recommendation indexes are an enhancement, not a prerequisite for
// showing cached videos. Activating the worker here used to leave a
// returning For You view stuck on its static loading markup whenever
// worker startup was slow or unavailable.
exploreActivation = Promise.resolve(this.ensureExploreDataService()).catch(
(error) => {
devLogger.warn(
`[Application] Recommendation data activation failed for ${feedType}:`,
error,
);
return null;
},
);
} else if (this.exploreDataService?.setActive) {
this.exploreDataService.setActive(false);
}
let includeTags = false;
let loadingMessage = "Fetching videos\u2026";
let refreshMethod = null;
@@ -1109,6 +1130,23 @@ export function createFeedCoordinator(deps) {
if (this.videoListView) {
this.videoListView.state.videosMap = this.videosMap;
}
// Re-score once the optional recommendation indexes are ready, but only
// if the user is still looking at this feed. The first render above is
// deliberately cache-first and must not wait for worker startup.
if (exploreActivation) {
void exploreActivation.then(() => {
if (!this.isFeedActive(feedType)) {
return;
}
return this.refreshFeed(feedType, { reason: "recommendation-data-ready" });
}).catch((error) => {
devLogger.warn(
`[Application] Failed to refresh ${feedType} after recommendation data activation:`,
error,
);
});
}
},
/**
+9 -2
View File
@@ -3,11 +3,13 @@
/**
* Modal open/close lifecycle and controller routing.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
* Stateful collaborators (clients, services, app state) are injected from the
* Application composition root rather than imported at module scope. Pure,
* stateless helper modules are imported directly, as in the other coordinators.
*
* Methods use `this` which is bound to the Application instance.
*/
import { detachHls } from "../services/hlsPlayback.js";
/**
* @param {object} deps - Injected dependencies.
@@ -562,6 +564,11 @@ export function createModalCoordinator(deps) {
`[teardownVideoElement] Resetting video (replaceNode=${replaceNode}) readyState=${videoElement.readyState} networkState=${videoElement.networkState} src=${describeSource()}`
);
// Destroy any hls.js instance FIRST. It owns a MediaSource, a transmuxing
// worker and in-flight segment fetches; clearing `src` without destroying
// it leaves those running (and downloading) after the modal is closed.
safe(() => detachHls(videoElement));
safe(() => videoElement.pause());
safe(() => {
+2
View File
@@ -19,6 +19,7 @@ export function createModerationCoordinator(deps) {
ModerationActionController,
setModerationOverride,
clearModerationOverride,
clearAuthorModerationOverride,
userBlocks,
buildVideoAddressPointer,
VIDEO_EVENT_KIND,
@@ -210,6 +211,7 @@ export function createModerationCoordinator(deps) {
services: {
setModerationOverride,
clearModerationOverride,
clearAuthorModerationOverride,
userBlocks,
},
selectors: {
+78 -5
View File
@@ -3,11 +3,17 @@
/**
* URL-first + magnet fallback playback pipeline.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
* Stateful collaborators (clients, services, app state) are injected from the
* Application composition root rather than imported at module scope. Pure,
* stateless helper modules are imported directly, as in the other coordinators.
*
* Methods use `this` which is bound to the Application instance.
*/
import {
attachHlsSource,
detachHls,
isHlsUrl,
} from "../services/hlsPlayback.js";
/**
* Normalize a deep-link / caller "force this playback source" value to the
@@ -197,7 +203,41 @@ export function createPlaybackCoordinator(deps) {
if (!base) {
return "";
}
return `${base}?v=${encodeURIComponent(nevent)}`;
// getShareUrlBase() strips the trailing slash, so add the path separator
// back. `https://bitvid.network?v=...` is legal and browsers normalize
// it, but it reads as a typo in a shared note and some link parsers
// mis-detect where the URL ends.
return `${base}/?v=${encodeURIComponent(nevent)}`;
},
/**
* Relay hints to embed in a shared pointer.
*
* Without these an `nevent` is just a bare id: a viewer whose relay set
* doesn't happen to carry the event cannot resolve it, and bitvid falls
* back to the home feed — the "shared link just opens bitvid.network"
* report. Capped to a few because the hints inflate every shared URL.
*/
getSharePointerRelays() {
const candidates = Array.isArray(nostrClient?.writeRelays)
? nostrClient.writeRelays
: Array.isArray(nostrClient?.relays)
? nostrClient.relays
: [];
const seen = new Set();
const out = [];
for (const entry of candidates) {
const url = typeof entry === "string" ? entry.trim() : "";
if (!url || !/^wss?:\/\//i.test(url) || seen.has(url)) {
continue;
}
seen.add(url);
out.push(url);
if (out.length >= 3) {
break;
}
}
return out;
},
buildShareUrlFromEventId(eventId) {
@@ -206,7 +246,24 @@ export function createPlaybackCoordinator(deps) {
}
try {
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
const pointer = { id: eventId };
// Author + relay hints make the pointer resolvable by someone who does
// not already follow this creator. The author also lets clients look
// the event up by author+kind if the id itself has aged out.
const known = this.videosMap?.get?.(eventId);
const author =
typeof known?.pubkey === "string" ? known.pubkey.trim() : "";
if (/^[0-9a-f]{64}$/i.test(author)) {
pointer.author = author.toLowerCase();
}
const relays = this.getSharePointerRelays();
if (relays.length) {
pointer.relays = relays;
}
const nevent = window.NostrTools.nip19.neventEncode(pointer);
return this.buildShareUrlFromNevent(nevent);
} catch (err) {
devLogger.error("Error generating nevent for share URL:", err);
@@ -355,7 +412,23 @@ export function createPlaybackCoordinator(deps) {
return false;
}
target.src = sanitizedUrl;
// HLS playlists cannot be assigned to `src` outside Safari; route them
// through hls.js/MSE (see js/services/hlsPlayback.js).
if (isHlsUrl(sanitizedUrl)) {
const handle = await attachHlsSource(target, sanitizedUrl, {
log: (...args) => devLogger.log(...args),
});
if (!handle) {
devLogger.warn(
"[playHttp] HLS source could not be attached:",
sanitizedUrl
);
return false;
}
} else {
detachHls(target);
target.src = sanitizedUrl;
}
try {
await target.play();
+11
View File
@@ -189,6 +189,17 @@ export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD =
export const DEFAULT_TRUST_SEED_NPUBS = SANITIZED_DEFAULT_TRUST_SEED_NPUBS;
export const PLAYBACK_START_TIMEOUT = SANITIZED_DEFAULT_PLAYBACK_START_TIMEOUT;
// HLS needs a bigger start window than a progressive MP4. Before the first
// frame can render, hls.js must lazy-load its (vendored) bundle, fetch the
// master playlist, fetch the chosen variant playlist, then fetch the init
// segment and the first media segment — four or more serial round trips versus
// the single ranged GET a plain MP4 needs. The 3s default fires mid-startup and
// reports a perfectly healthy stream as "No playable source found."
export const HLS_PLAYBACK_START_TIMEOUT = Math.max(
SANITIZED_DEFAULT_PLAYBACK_START_TIMEOUT,
15000
);
const DEFAULT_FLAGS = Object.freeze({
URL_FIRST_ENABLED: DEFAULT_PLAYBACK_SOURCE !== "torrent", // try URL before magnet in the player
FEATURE_WATCH_HISTORY_V2: true,
+79 -44
View File
@@ -1,69 +1,104 @@
// Dev-only relay REQ-rate monitor.
// Local-only relay REQ-rate monitor.
//
// Patches WebSocket.send to count outgoing Nostr REQ frames and, every 2s,
// console.warns when the rate exceeds a threshold — naming the storming filter
// kinds. This runs inside the real browser session against real relays/lists,
// which the headless harness cannot fully reproduce. It is purely observational
// and only activates on localhost (or when `localStorage.__bitvidReqMonitor__`
// is set), so production is unaffected.
//
// Remove this import from index.js once the login-perf work is done.
// It patches WebSocket.send for a real-session relay-storm investigation, so
// normal localhost development must opt in explicitly. Public deployments can
// never enable it, even if a storage key is present.
(function installReqMonitor() {
if (typeof window === "undefined" || typeof WebSocket === "undefined") return;
export const REQUEST_MONITOR_STORAGE_KEY = "__bitvidReqMonitor__";
const enabled =
location.hostname === "localhost" ||
location.hostname === "127.0.0.1" ||
(() => {
try {
return localStorage.getItem("__bitvidReqMonitor__") === "1";
} catch {
return false;
}
})();
if (!enabled || window.__bitvidReqMonitorInstalled) return;
window.__bitvidReqMonitorInstalled = true;
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
const THRESHOLD = 15;
const WINDOW_MS = 2000;
function readStorage(storage) {
try {
return storage?.getItem?.(REQUEST_MONITOR_STORAGE_KEY) ?? null;
} catch (_) {
return null;
}
}
export function isRequestMonitorEnabled({ locationRef, storage } = {}) {
const hostname = typeof locationRef?.hostname === "string"
? locationRef.hostname.toLowerCase()
: "";
return LOCAL_HOSTS.has(hostname) && readStorage(storage) === "1";
}
export function installRequestMonitor({
scope = typeof window !== "undefined" ? window : null,
locationRef = scope?.location,
storage = scope?.localStorage,
WebSocketClass = scope?.WebSocket,
setIntervalFn = scope?.setInterval?.bind(scope),
clearIntervalFn = scope?.clearInterval?.bind(scope),
logger = scope?.console || console,
} = {}) {
if (
!scope ||
!WebSocketClass?.prototype ||
typeof WebSocketClass.prototype.send !== "function" ||
typeof setIntervalFn !== "function" ||
!isRequestMonitorEnabled({ locationRef, storage })
) {
return null;
}
if (scope.__bitvidReqMonitorInstalled) {
return scope.__bitvidReqMonitorInstalled;
}
const THRESHOLD = 15; // REQ/s above this is a storm worth reporting
const WINDOW_MS = 2000;
let windowReqs = 0;
const byKind = new Map();
const originalSend = WebSocketClass.prototype.send;
const origSend = WebSocket.prototype.send;
WebSocket.prototype.send = function (data) {
WebSocketClass.prototype.send = function monitoredSend(data) {
try {
if (typeof data === "string" && data.startsWith('["REQ"')) {
windowReqs += 1;
const msg = JSON.parse(data);
for (const f of msg.slice(2)) {
const k = Array.isArray(f?.kinds) ? `kind ${f.kinds.join("/")}` : "kind ?";
byKind.set(k, (byKind.get(k) || 0) + 1);
const message = JSON.parse(data);
for (const filter of message.slice(2)) {
const kind = Array.isArray(filter?.kinds)
? `kind ${filter.kinds.join("/")}`
: "kind ?";
byKind.set(kind, (byKind.get(kind) || 0) + 1);
}
}
} catch {
/* ignore */
} catch (_) {
// Diagnostics must never affect a WebSocket send.
}
return origSend.apply(this, arguments);
return originalSend.apply(this, arguments);
};
setInterval(() => {
const perSec = (windowReqs / WINDOW_MS) * 1000;
if (perSec >= THRESHOLD) {
const intervalId = setIntervalFn(() => {
const perSecond = (windowReqs / WINDOW_MS) * 1000;
if (perSecond >= THRESHOLD) {
const top = [...byKind.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([k, n]) => `${k}=${n}`)
.map(([kind, count]) => `${kind}=${count}`)
.join(", ");
// eslint-disable-next-line no-console
console.warn(
`[req-monitor] RELAY STORM ${perSec.toFixed(0)} REQ/s over ${WINDOW_MS / 1000}s → ${top}`,
logger?.warn?.(
`[req-monitor] RELAY STORM ${perSecond.toFixed(0)} REQ/s over ${WINDOW_MS / 1000}s → ${top}`,
);
}
windowReqs = 0;
byKind.clear();
}, WINDOW_MS);
// eslint-disable-next-line no-console
console.info("[req-monitor] active — will warn if relay REQ rate spikes.");
})();
const monitor = {
stop() {
WebSocketClass.prototype.send = originalSend;
if (typeof clearIntervalFn === "function") {
clearIntervalFn(intervalId);
}
if (scope.__bitvidReqMonitorInstalled === monitor) {
delete scope.__bitvidReqMonitorInstalled;
}
},
};
scope.__bitvidReqMonitorInstalled = monitor;
logger?.info?.("[req-monitor] active — will warn if relay REQ rate spikes.");
return monitor;
}
installRequestMonitor();
+29 -1
View File
@@ -26,6 +26,7 @@ export function createCardObserver(options = {}) {
prepareEntries,
onCardVisible,
onCardRegister,
onCardUnregister,
createState,
} = options;
@@ -37,7 +38,9 @@ export function createCardObserver(options = {}) {
return state;
}
const observedCards = new WeakSet();
// A Set is intentional here: disposal must enumerate every card so the
// browser observer cannot retain detached feed nodes after a rerender.
const observedCards = new Set();
const customState = normalizeState(
typeof createState === "function" ? createState(container) : {}
);
@@ -201,9 +204,34 @@ export function createCardObserver(options = {}) {
return state ? state.customState : null;
}
function disconnect(container) {
const state = containerState.get(container);
if (!state) {
return;
}
for (const card of state.observedCards) {
if (typeof onCardUnregister === "function") {
try {
onCardUnregister({
card,
state: state.customState,
context: { container: state.container, observer: state.observer },
});
} catch (err) {
userLogger.warn("[cardObserver] onCardUnregister failed", err);
}
}
}
state.observedCards.clear();
state.observer.disconnect();
containerState.delete(container);
}
return {
observe,
refresh,
getState,
disconnect,
};
}
+11 -5
View File
@@ -10,7 +10,7 @@ import { markAsNormalized } from "./utils.js";
// re-runs the feed on the metric cache's debounced change signal).
// - Trending (#27): runtime.getViewCount (viewCounter cache)
// - Most Zapped (#47): runtime.getZapTotal (zapTotals cache)
function createCountRankedSorter(resolveMetric) {
function createCountRankedSorter(resolveMetric, { spreadByAuthor = true } = {}) {
return function countRankedSorter(items = [], context = {}) {
if (!Array.isArray(items)) {
return [];
@@ -54,9 +54,12 @@ function createCountRankedSorter(resolveMetric) {
const live = items.filter((entry) => !isMuted(entry));
const muted = items.filter((entry) => isMuted(entry));
// Rank by the metric, then spread authors so the tab isn't walls of one
// creator. The top-ranked item is still chosen first, so it stays topmost.
return [...spreadAuthors(live.sort(compare)), ...muted.sort(compare)];
const rankedLive = live.sort(compare);
// Trending benefits from a little creator diversity. Most Zapped is a
// literal leaderboard, so it must retain strict total ordering instead of
// moving a larger zap total below a smaller one to avoid repeat authors.
const orderedLive = spreadByAuthor ? spreadAuthors(rankedLive) : rankedLive;
return [...orderedLive, ...muted.sort(compare)];
};
}
@@ -66,7 +69,10 @@ export function createTrendingSorter() {
// "Most Zapped" (#47): same ranking mechanics over sats totals.
export function createMostZappedSorter() {
return createCountRankedSorter((context) => context?.runtime?.getZapTotal);
return createCountRankedSorter(
(context) => context?.runtime?.getZapTotal,
{ spreadByAuthor: false },
);
}
// Round-robin interleave by author: one video per creator per pass, each pass in
+6 -7
View File
@@ -1138,8 +1138,11 @@ export function createModerationStage({
const adminWhitelist = adminStatus?.whitelisted === true;
const adminWhitelistBypass = false;
const blockAutoplay =
trustedCount >= normalizedAutoplayThreshold || trustedMuted || viewerMuted;
// A trusted mute is a ranking signal, not a content verdict. It sinks an
// author in feed sorters, but a single stale or bad-faith mute must not
// blur a creator or stop playback. Only the report threshold, the
// viewer's own mute, or the higher mute-hide threshold can do that.
const blockAutoplay = trustedCount >= normalizedAutoplayThreshold || viewerMuted;
const blurFromReports = trustedCount >= normalizedBlurThreshold;
let blurThumbnail = blurFromReports;
let blurReason = blurThumbnail ? "trusted-report" : "";
@@ -1269,22 +1272,18 @@ export function createModerationStage({
}
}
if (!blurThumbnail && (viewerMuted || trustedMuted || hideTriggered)) {
if (!blurThumbnail && (viewerMuted || hideTriggered)) {
blurThumbnail = true;
if (hideTriggered) {
blurReason = hideReason || "trusted-hide";
} else if (viewerMuted) {
blurReason = "viewer-mute";
} else if (trustedMuted) {
blurReason = "trusted-mute";
}
} else if (blurThumbnail) {
if (hideTriggered) {
blurReason = hideReason || "trusted-hide";
} else if (viewerMuted && !blurFromReports) {
blurReason = "viewer-mute";
} else if (trustedMuted && !blurFromReports) {
blurReason = "trusted-mute";
} else if (!blurReason && blurFromReports) {
blurReason = "trusted-report";
}
+2 -1
View File
@@ -573,10 +573,11 @@ const gridCardObserver = createCardObserver({
export function attachHealthBadges(container, options = {}) {
if (!(container instanceof HTMLElement)) {
return;
return () => {};
}
if (options && typeof options.onUpdate === "function") {
badgeUpdateListeners.add(options.onUpdate);
}
gridCardObserver.observe(container);
return () => gridCardObserver.disconnect(container);
}
+5
View File
@@ -3,6 +3,7 @@
import { validateInstanceConfig } from "../config/validate-config.js";
import "./bufferPolyfill.js";
import "./devReqMonitor.js"; // dev-only relay REQ-rate monitor (localhost only)
import { initPerformanceHarness } from "./performanceHarness.js";
import {
getApplication,
setApplication,
@@ -59,6 +60,10 @@ import { installTestHarness } from "./testHarness.js";
validateInstanceConfig();
// Local-only, explicit opt-in measurement. This is a no-op in production and
// does not patch application APIs or change scheduling.
initPerformanceHarness();
// Install test harness early so relay overrides apply before connections.
// Only activates when ?__test__=1 or localStorage __bitvidTestMode__ is set.
installTestHarness();
+117
View File
@@ -0,0 +1,117 @@
// Addressable pointers to a video's NIP-71 mirror.
//
// The mirror (js/nostr/nip71Mirror.js) is an ADDRESSABLE event: kind
// 34235/34236, author = the video's pubkey, `d` = videoRootId. That makes its
// coordinate fully derivable from a video object with no relay lookup, and the
// pointer keeps resolving after the author edits the video (a plain `nevent`
// would rot on the next replace).
//
// Used by the share flow so a shared note can embed `nostr:naddr1…`, which
// nostr clients render as a native video quote card instead of a bare link.
import {
NIP71_NORMAL_VIDEO_KIND,
NIP71_SHORT_VIDEO_KIND,
} from "./nip71Mirror.js";
export { NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND };
const str = (value) => (typeof value === "string" ? value.trim() : "");
/**
* Which mirror kind a video's pointer should address.
*
* Prefers `kinds` — the kinds actually observed on relays (nip71MirrorService
* `findMirror`), which is ground truth. Only when that is unavailable does it
* fall back to the SAME portrait heuristic the mirror builder uses
* (nip71Mirror.js: `hasDims && height > width`), so a derived pointer matches
* the event that would be published rather than guessing independently.
*
* Returns 0 when there is nothing to point at.
*/
export function resolveMirrorKind({ kinds = [], width, height } = {}) {
const observed = (Array.isArray(kinds) ? kinds : []).filter(
(kind) => kind === NIP71_NORMAL_VIDEO_KIND || kind === NIP71_SHORT_VIDEO_KIND
);
if (observed.length === 1) {
return observed[0];
}
if (observed.length > 1) {
// Cross-kind duplicate (nip71MirrorService flags this as `duplicate`).
// Prefer the normal kind so the pointer is stable rather than arbitrary.
return observed.includes(NIP71_NORMAL_VIDEO_KIND)
? NIP71_NORMAL_VIDEO_KIND
: observed[0];
}
const w = Number(width);
const h = Number(height);
const hasDims = Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0;
if (!hasDims) {
return NIP71_NORMAL_VIDEO_KIND;
}
return h > w ? NIP71_SHORT_VIDEO_KIND : NIP71_NORMAL_VIDEO_KIND;
}
/**
* Build the `a`-tag coordinate (`kind:pubkey:d`) for a video's mirror.
* Returns "" when the inputs cannot address anything.
*/
export function buildMirrorCoordinate({ pubkey, videoRootId, kind } = {}) {
const author = str(pubkey);
const identifier = str(videoRootId);
const resolvedKind = Number(kind);
if (!author || !identifier || !Number.isFinite(resolvedKind) || resolvedKind <= 0) {
return "";
}
return `${resolvedKind}:${author}:${identifier}`;
}
/**
* Build `naddr1…` for a video's mirror.
*
* `nip19` is injected so this stays testable without a browser; callers in the
* app pass `window.NostrTools.nip19`. Returns "" on any failure — a share note
* must never embed a malformed pointer, because clients render that as a broken
* quote box, which is strictly worse than the plain-link fallback.
*
* Relay hints matter here: without them a client that doesn't already have the
* mirror has nowhere to fetch it from, and the quote card renders empty.
*/
export function buildMirrorNaddr({
pubkey,
videoRootId,
kind,
relays = [],
nip19,
} = {}) {
const author = str(pubkey);
const identifier = str(videoRootId);
const resolvedKind = Number(kind);
if (!author || !identifier || !Number.isFinite(resolvedKind) || resolvedKind <= 0) {
return "";
}
if (!nip19 || typeof nip19.naddrEncode !== "function") {
return "";
}
const relayHints = (Array.isArray(relays) ? relays : [])
.map((entry) => str(entry))
.filter((entry) => /^wss?:\/\//i.test(entry))
// A handful is plenty; an naddr carrying a user's whole relay list gets
// unwieldy in note bodies and leaks more than it helps.
.slice(0, 3);
try {
return (
nip19.naddrEncode({
kind: resolvedKind,
pubkey: author,
identifier,
relays: relayHints,
}) || ""
);
} catch (err) {
return "";
}
}
+14
View File
@@ -1465,6 +1465,17 @@ function inferMimeTypeFromUrl(url) {
// (kind 30078) can carry `imeta` variants with an `m <mime>` field just like
// NIP-71 events; a podcast/music note advertises `m audio/*`. Mirrors the
// video/audio split in nip71IngestAdapter.js so the native path stays consistent.
// HLS playlist MIME types. `audio/mpegurl` / `audio/x-mpegurl` are LEGACY
// aliases for an HLS playlist of any kind — they do NOT assert audio-only
// content, and a publisher using one for a video ladder would otherwise be
// misfiled as a podcast and dropped from the video feeds by
// isAudioOnlyVideoObject(). A playlist is a container, so it votes for neither
// side and we let the URL/`imeta m video/*` signals decide.
// Kept inline (not imported from js/services/hlsPlayback.js) so this parser
// stays free of any DOM/logger dependency — it also runs inside workers.
const HLS_PLAYLIST_MIME_PATTERN =
/^(?:application|audio|video)\/(?:x-|vnd\.apple\.)?mpegurl$/i;
function detectImetaMediaKinds(tags = []) {
let hasVideo = false;
let hasAudio = false;
@@ -1482,6 +1493,9 @@ function detectImetaMediaKinds(tags = []) {
continue;
}
const mime = trimmed.replace(/^m\s+/i, "").trim().toLowerCase();
if (HLS_PLAYLIST_MIME_PATTERN.test(mime.split(";")[0].trim())) {
continue;
}
if (mime.startsWith("video/")) {
hasVideo = true;
} else if (mime.startsWith("audio/")) {
+47
View File
@@ -23,6 +23,7 @@ import {
buildStoragePointerValue,
deriveStoragePointerFromUrl,
} from "./utils/storagePointer.js";
import { inferImageMimeTypeFromUrl } from "./utils/mime.js";
/**
* Enumeration of all supported Nostr event types in the application.
@@ -1800,6 +1801,52 @@ export function buildShareEvent(params) {
tags.push(["p", normalizedVideoPubkey, "", "mention"]);
}
// Addressable coordinate of the video's NIP-71 mirror, when one exists. Lets
// quote-aware clients and indexers link the share note to the video event
// that its body quotes via `nostr:naddr1…`.
const mirrorCoordinate =
typeof video?.mirrorCoordinate === "string"
? video.mirrorCoordinate.trim()
: "";
if (/^\d+:[0-9a-f]{64}:/i.test(mirrorCoordinate)) {
tags.push(["a", mirrorCoordinate, "", "mention"]);
}
// NIP-92 `imeta` for the thumbnail. The share content ends with the raw
// thumbnail URL, and many clients only render such a URL inline when an
// imeta entry describes it — without this the viewer just sees a bare link.
// Only emitted for http(s) thumbnails: relative paths and data: URIs are
// meaningless to a reader on another client.
//
// SKIPPED when the note quotes a mirror: the quote card already renders the
// video with its own thumbnail, and the body carries no bare image URL to
// describe. Emitting both showed the same image twice.
const quotesMirror =
typeof video?.mirrorNaddr === "string" && video.mirrorNaddr.trim() !== "";
const thumbnailUrl =
typeof video?.thumbnail === "string" ? video.thumbnail.trim() : "";
if (!quotesMirror && /^https?:\/\//i.test(thumbnailUrl)) {
const imeta = ["imeta", `url ${thumbnailUrl}`];
// Omit `m` entirely when the extension is unrecognized. A wrong media type
// is worse than a missing one — clients use it to decide whether to render
// the URL as an image at all.
const mimeType = inferImageMimeTypeFromUrl(thumbnailUrl);
if (mimeType) {
imeta.push(`m ${mimeType}`);
}
// Alt text for screen readers, and a caption fallback in clients that show
// it. Newlines would corrupt the space-delimited imeta entry, so collapse.
const altText =
typeof video?.title === "string" ? video.title.replace(/\s+/g, " ").trim() : "";
if (altText) {
imeta.push(`alt ${altText}`);
}
tags.push(imeta);
}
const seenRelayTags = new Set();
const pushRelayTag = (url, marker) => {
+236
View File
@@ -0,0 +1,236 @@
// Local-only performance measurement harness.
//
// This intentionally observes rather than changes application behavior. It is
// available only on a loopback host *and* after an explicit localStorage opt-in,
// so a production visitor cannot accidentally enable it.
const STORAGE_KEY = "__bitvidPerformanceHarness__";
const GLOBAL_KEY = "__bitvidPerformance";
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
const MAX_LONG_TASKS = 200;
const MAX_MARKS = 200;
function readStorage(storage, key) {
try {
return storage?.getItem?.(key) ?? null;
} catch (_) {
return null;
}
}
function finiteNumber(value, fallback = 0) {
return Number.isFinite(value) ? Number(value) : fallback;
}
function normalizeMarkName(name) {
return typeof name === "string" ? name.trim().slice(0, 120) : "";
}
export function isLocalPerformanceHarnessEnabled({
locationRef = typeof location !== "undefined" ? location : null,
storage = typeof localStorage !== "undefined" ? localStorage : null,
} = {}) {
const hostname = typeof locationRef?.hostname === "string"
? locationRef.hostname.toLowerCase()
: "";
return LOCAL_HOSTS.has(hostname) && readStorage(storage, STORAGE_KEY) === "1";
}
export class PerformanceHarness {
constructor({ scope, performanceRef, PerformanceObserverClass } = {}) {
this.scope = scope || null;
this.performance = performanceRef || this.scope?.performance || null;
this.PerformanceObserverClass =
PerformanceObserverClass || this.scope?.PerformanceObserver || null;
this.startedAt = 0;
this.longTasks = [];
this.marks = [];
this.gauges = new Map();
this.longTaskObserver = null;
this.started = false;
}
start() {
if (this.started) {
return this;
}
this.started = true;
this.startedAt = this.now();
this.observeLongTasks();
this.mark("harness-start");
if (this.scope) {
this.scope[GLOBAL_KEY] = this.publicApi();
}
return this;
}
stop() {
try {
this.longTaskObserver?.disconnect?.();
} catch (_) {
// Diagnostics must never affect application teardown.
}
this.longTaskObserver = null;
this.started = false;
if (this.scope?.[GLOBAL_KEY]) {
delete this.scope[GLOBAL_KEY];
}
}
now() {
return finiteNumber(this.performance?.now?.(), Date.now());
}
observeLongTasks() {
if (typeof this.PerformanceObserverClass !== "function") {
return;
}
try {
const observer = new this.PerformanceObserverClass((list) => {
const entries = list?.getEntries?.() || [];
for (const entry of entries) {
this.recordLongTask(entry);
}
});
observer.observe({ type: "longtask", buffered: true });
this.longTaskObserver = observer;
} catch (_) {
// Long Task timing is optional and unsupported in several browsers.
}
}
recordLongTask(entry) {
const task = {
startTime: finiteNumber(entry?.startTime),
duration: finiteNumber(entry?.duration),
name: typeof entry?.name === "string" ? entry.name : "longtask",
};
this.longTasks.push(task);
if (this.longTasks.length > MAX_LONG_TASKS) {
this.longTasks.splice(0, this.longTasks.length - MAX_LONG_TASKS);
}
}
mark(name, detail = {}) {
const normalizedName = normalizeMarkName(name);
if (!normalizedName) {
return false;
}
const entry = {
name: normalizedName,
at: this.now(),
detail: detail && typeof detail === "object" ? { ...detail } : {},
};
this.marks.push(entry);
if (this.marks.length > MAX_MARKS) {
this.marks.splice(0, this.marks.length - MAX_MARKS);
}
try {
this.performance?.mark?.(`bitvid:${normalizedName}`);
} catch (_) {
// User Timing is also optional; retain the local mark either way.
}
return true;
}
setGauge(name, value) {
const normalizedName = normalizeMarkName(name);
if (!normalizedName || !Number.isFinite(value)) {
return false;
}
this.gauges.set(normalizedName, Number(value));
return true;
}
increment(name, amount = 1) {
const normalizedName = normalizeMarkName(name);
if (!normalizedName || !Number.isFinite(amount)) {
return false;
}
return this.setGauge(normalizedName, (this.gauges.get(normalizedName) || 0) + amount);
}
snapshot() {
const resources = this.performance?.getEntriesByType?.("resource") || [];
const navigation = this.performance?.getEntriesByType?.("navigation")?.[0] || null;
const memory = this.performance?.memory;
const longTaskDurationMs = this.longTasks.reduce(
(total, task) => total + finiteNumber(task.duration),
0,
);
return {
enabled: this.started,
startedAt: this.startedAt,
elapsedMs: Math.max(0, this.now() - this.startedAt),
longTasks: {
count: this.longTasks.length,
totalDurationMs: longTaskDurationMs,
recent: this.longTasks.slice(-20),
},
marks: this.marks.slice(-50),
gauges: Object.fromEntries(this.gauges.entries()),
resources: {
count: resources.length,
transferSize: resources.reduce(
(total, entry) => total + finiteNumber(entry?.transferSize),
0,
),
},
navigation: navigation
? {
domContentLoadedMs: finiteNumber(navigation.domContentLoadedEventEnd),
loadEventMs: finiteNumber(navigation.loadEventEnd),
}
: null,
memory: memory
? {
usedJsHeapSize: finiteNumber(memory.usedJSHeapSize),
totalJsHeapSize: finiteNumber(memory.totalJSHeapSize),
jsHeapSizeLimit: finiteNumber(memory.jsHeapSizeLimit),
}
: null,
};
}
reset() {
this.longTasks.length = 0;
this.marks.length = 0;
this.gauges.clear();
this.mark("harness-reset");
}
publicApi() {
return Object.freeze({
snapshot: () => this.snapshot(),
mark: (name, detail) => this.mark(name, detail),
setGauge: (name, value) => this.setGauge(name, value),
increment: (name, amount) => this.increment(name, amount),
reset: () => this.reset(),
stop: () => this.stop(),
});
}
}
export function initPerformanceHarness({
scope = typeof window !== "undefined" ? window : null,
locationRef = scope?.location,
storage = scope?.localStorage,
performanceRef = scope?.performance,
PerformanceObserverClass = scope?.PerformanceObserver,
} = {}) {
if (!scope || !isLocalPerformanceHarnessEnabled({ locationRef, storage })) {
return null;
}
if (scope[GLOBAL_KEY]) {
return scope[GLOBAL_KEY];
}
const harness = new PerformanceHarness({
scope,
performanceRef,
PerformanceObserverClass,
});
harness.start();
return scope[GLOBAL_KEY];
}
export const PERFORMANCE_HARNESS_STORAGE_KEY = STORAGE_KEY;
+94 -38
View File
@@ -19,11 +19,21 @@ function clonePointerDescriptor(pointer) {
value:
typeof pointer.value === "string" && pointer.value.trim()
? pointer.value.trim()
: "",
: ""
};
if (typeof pointer.relay === "string" && pointer.relay.trim()) {
descriptor.relay = pointer.relay.trim();
}
const eventId =
typeof pointer.eventId === "string" && pointer.eventId.trim()
? pointer.eventId.trim()
: typeof pointer.pointerEventId === "string" &&
pointer.pointerEventId.trim()
? pointer.pointerEventId.trim()
: "";
if (eventId) {
descriptor.eventId = eventId;
}
return descriptor;
}
@@ -36,7 +46,16 @@ function canonicalizePointerForState(pointerInput) {
if (!key) {
return null;
}
const descriptor = clonePointerDescriptor(normalized);
const descriptor = clonePointerDescriptor({
...normalized,
eventId:
typeof pointerInput?.eventId === "string" && pointerInput.eventId.trim()
? pointerInput.eventId
: typeof pointerInput?.pointerEventId === "string" &&
pointerInput.pointerEventId.trim()
? pointerInput.pointerEventId
: ""
});
return descriptor ? { key, pointer: descriptor } : null;
}
@@ -50,7 +69,7 @@ function ensurePointerState(key, pointer) {
lastUpdatedAt: 0,
latestCreatedAt: 0,
lastSyncedAt: 0,
hydrated: false,
hydrated: false
};
pointerStates.set(key, state);
return state;
@@ -59,8 +78,16 @@ function ensurePointerState(key, pointer) {
const descriptor = clonePointerDescriptor(pointer);
if (!state.pointer) {
state.pointer = descriptor;
} else if (descriptor?.relay && !state.pointer.relay) {
state.pointer = { ...state.pointer, relay: descriptor.relay };
} else {
state.pointer = {
...state.pointer,
...(descriptor?.relay && !state.pointer.relay
? { relay: descriptor.relay }
: {}),
...(descriptor?.eventId && !state.pointer.eventId
? { eventId: descriptor.eventId }
: {})
};
}
}
return state;
@@ -174,7 +201,10 @@ function applyReactionToState(key, pointer, event) {
state.lastUpdatedAt = Date.now();
if (Number.isFinite(normalized.created_at)) {
const created = Math.max(0, Math.floor(normalized.created_at));
if (!Number.isFinite(state.latestCreatedAt) || created > state.latestCreatedAt) {
if (
!Number.isFinite(state.latestCreatedAt) ||
created > state.latestCreatedAt
) {
state.latestCreatedAt = created;
}
}
@@ -198,7 +228,7 @@ function snapshotState(state) {
reactions[pubkey] = {
content: record.content,
created_at: record.created_at,
eventId: record.eventId,
eventId: record.eventId
};
}
const latestCreatedAt = Number.isFinite(state.latestCreatedAt)
@@ -212,7 +242,7 @@ function snapshotState(state) {
lastUpdatedAt: state.lastUpdatedAt,
latestCreatedAt,
lastSyncedAt: state.lastSyncedAt,
hydrated: Boolean(state.hydrated),
hydrated: Boolean(state.hydrated)
};
}
@@ -271,7 +301,7 @@ function subscribeToPointer(pointerInput, handler) {
.catch((error) => {
devLogger.warn(
"[reactionCounter] Failed to hydrate reaction history:",
error,
error
);
})
.finally(() => {
@@ -299,7 +329,7 @@ function subscribeToPointer(pointerInput, handler) {
.catch((error) => {
devLogger.warn(
"[reactionCounter] Failed to hydrate reaction history after pool init:",
error,
error
);
})
.finally(() => {
@@ -314,7 +344,7 @@ function subscribeToPointer(pointerInput, handler) {
.catch((error) => {
devLogger.warn(
"[reactionCounter] Failed to initialize nostr pool for reactions:",
error,
error
);
});
}
@@ -372,12 +402,19 @@ function ingestLocalReaction({ event, pointer }) {
if (!canonical) {
return;
}
const changed = applyReactionToState(canonical.key, canonical.pointer, event);
const changed = applyReactionToState(
canonical.key,
canonical.pointer,
event
);
if (changed) {
notifyHandlers(canonical.key);
}
} catch (error) {
userLogger.warn("[reactionCounter] Failed to ingest local reaction:", error);
userLogger.warn(
"[reactionCounter] Failed to ingest local reaction:",
error
);
}
}
@@ -446,7 +483,7 @@ function gatherRelayCandidates(pointer) {
return relays;
}
function buildReactionFilters(pointer, { limit, since } = {}) {
export function buildReactionFilters(pointer, { limit, since } = {}) {
if (!pointer || typeof pointer !== "object") {
return [];
}
@@ -460,23 +497,29 @@ function buildReactionFilters(pointer, { limit, since } = {}) {
return [];
}
const filter = { kinds: [REACTION_EVENT_KIND] };
const withBounds = (filter) => {
if (Number.isFinite(limit) && limit > 0) {
filter.limit = Math.max(1, Math.floor(limit));
}
if (Number.isFinite(since) && since >= 0) {
filter.since = Math.max(0, Math.floor(since));
}
return filter;
};
if (type === "a") {
filter["#a"] = [value];
} else {
filter["#e"] = [value];
if (type === "e") {
return [withBounds({ kinds: [REACTION_EVENT_KIND], "#e": [value] })];
}
if (Number.isFinite(limit) && limit > 0) {
filter.limit = Math.max(1, Math.floor(limit));
const filters = [withBounds({ kinds: [REACTION_EVENT_KIND], "#a": [value] })];
const eventId =
typeof pointer.eventId === "string" && pointer.eventId.trim()
? pointer.eventId.trim()
: "";
if (eventId) {
filters.push(withBounds({ kinds: [REACTION_EVENT_KIND], "#e": [eventId] }));
}
if (Number.isFinite(since) && since >= 0) {
filter.since = Math.max(0, Math.floor(since));
}
return [filter];
return filters;
}
function buildPointerQuery(pointer, options = {}) {
@@ -556,7 +599,10 @@ function ensureReactionHydration(key, pointer, state) {
const promise = hydratePointerState(key, pointer, state)
.catch((error) => {
devLogger.warn("[reactionCounter] Failed to hydrate reaction history:", error);
devLogger.warn(
"[reactionCounter] Failed to hydrate reaction history:",
error
);
})
.finally(() => {
pointerHydrationPromises.delete(key);
@@ -577,7 +623,10 @@ function teardownLiveSubscription(key) {
try {
unsub();
} catch (error) {
devLogger.warn("[reactionCounter] Failed to tear down reaction stream:", error);
devLogger.warn(
"[reactionCounter] Failed to tear down reaction stream:",
error
);
}
}
}
@@ -618,7 +667,10 @@ function ensureReactionLiveSubscription(key, pointer) {
try {
subscription = query.pool.sub(query.relays, query.filters);
} catch (error) {
devLogger.warn("[reactionCounter] Failed to subscribe to reaction events:", error);
devLogger.warn(
"[reactionCounter] Failed to subscribe to reaction events:",
error
);
return null;
}
@@ -660,7 +712,7 @@ function ensureReactionLiveSubscription(key, pointer) {
pointerSubscriptions.set(key, {
signature,
unsub: unsubscribe,
unsub: unsubscribe
});
return unsubscribe;
@@ -688,7 +740,8 @@ export const reactionCounter = {
})();
const pointerRelay =
typeof publishOptions.pointerRelay === "string" && publishOptions.pointerRelay.trim()
typeof publishOptions.pointerRelay === "string" &&
publishOptions.pointerRelay.trim()
? publishOptions.pointerRelay.trim()
: pointerRelayFromPointer;
@@ -750,7 +803,7 @@ export const reactionCounter = {
const result = await nostrClient.publishVideoReaction(
enrichedPointer,
publishOptions,
publishOptions
);
if (!result || !result.ok) {
userLogger.warn(
@@ -768,21 +821,24 @@ export const reactionCounter = {
? publishOptions.actorPubkey
: undefined,
content: publishOptions.content,
created_at: publishOptions.created_at,
created_at: publishOptions.created_at
};
if (eventPayload && eventPayload.pubkey) {
ingestLocalReaction({
pointer: enrichedPointer,
event: eventPayload,
event: eventPayload
});
}
}
return result;
} catch (error) {
userLogger.warn("[reactionCounter] Failed to publish reaction event:", error);
userLogger.warn(
"[reactionCounter] Failed to publish reaction event:",
error
);
throw error;
}
},
}
};
reactionCounter.subscribe = subscribeToPointer;
@@ -793,5 +849,5 @@ export default reactionCounter;
export {
subscribeToPointer as subscribeToReactions,
unsubscribeFromPointer as unsubscribeFromReactions,
getPointerSnapshot as getReactionSnapshot,
getPointerSnapshot as getReactionSnapshot
};
+93 -9
View File
@@ -144,26 +144,51 @@ export default class ExploreDataService {
this.tagIdfInterval = null;
this.unsubscribeHandlers = [];
this.handleVisibility = this.handleVisibility.bind(this);
this.initialized = false;
this.active = false;
this.historyDirty = true;
this.idfDirty = true;
this.historyRefreshInFlight = null;
this.idfRefreshInFlight = null;
this.historyRefreshFollowUp = null;
this.idfRefreshFollowUp = null;
}
initialize() {
this.refreshWatchHistoryTagCounts({ force: true, reason: "init" });
this.refreshTagIdf({ force: true, reason: "init" });
initialize({ active = false } = {}) {
if (this.initialized) {
return this.setActive(active);
}
this.initialized = true;
this.subscribeToUpdates();
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", this.handleVisibility);
}
return this.setActive(active);
}
async setActive(active) {
this.active = active === true;
if (!this.active || (typeof document !== "undefined" && document.hidden)) {
this.clearIntervals();
return;
}
this.startIntervals();
const forceHistory = this.historyDirty || !this.watchHistoryTagCountsUpdatedAt;
const forceIdf = this.idfDirty || !this.tagIdfUpdatedAt;
this.historyDirty = false;
this.idfDirty = false;
await Promise.all([
this.refreshWatchHistoryTagCounts({ force: forceHistory, reason: "active" }),
this.refreshTagIdf({ force: forceIdf, reason: "active" }),
]);
}
handleVisibility() {
if (typeof document === "undefined") return;
if (document.hidden) {
this.clearIntervals();
} else {
this.refreshWatchHistoryTagCounts({ reason: "visibility" });
this.refreshTagIdf({ reason: "visibility" });
this.startIntervals();
} else if (this.active) {
void this.setActive(true);
}
}
@@ -226,6 +251,10 @@ export default class ExploreDataService {
}
queueWatchHistoryRefresh(reason) {
if (!this.active) {
this.historyDirty = true;
return;
}
if (this.watchHistoryRefreshHandle) {
return;
}
@@ -236,6 +265,10 @@ export default class ExploreDataService {
}
queueTagIdfRefresh(reason) {
if (!this.active) {
this.idfDirty = true;
return;
}
if (this.tagIdfRefreshHandle) {
return;
}
@@ -245,7 +278,33 @@ export default class ExploreDataService {
}, this.refreshDebounceMs);
}
async refreshWatchHistoryTagCounts({ force = false, reason } = {}) {
async refreshWatchHistoryTagCounts(options = {}) {
if (this.historyRefreshInFlight) {
// Keep only one fresh pass after the current worker task. Rebuilding for
// every relay/event/timer signal is exactly the burst this lifecycle is
// intended to prevent.
this.historyRefreshFollowUp = {
force: true,
reason: options.reason || "coalesced",
};
return this.historyRefreshInFlight;
}
const run = this._refreshWatchHistoryTagCounts(options);
this.historyRefreshInFlight = Promise.resolve(run).finally(() => {
this.historyRefreshInFlight = null;
const followUp = this.historyRefreshFollowUp;
this.historyRefreshFollowUp = null;
if (followUp && this.active) {
void this.refreshWatchHistoryTagCounts(followUp);
} else if (followUp) {
this.historyDirty = true;
}
});
return this.historyRefreshInFlight;
}
async _refreshWatchHistoryTagCounts({ force = false, reason } = {}) {
const now = Date.now();
if (!force && this.watchHistoryTagCountsUpdatedAt) {
const elapsed = now - this.watchHistoryTagCountsUpdatedAt;
@@ -279,7 +338,30 @@ export default class ExploreDataService {
}
}
async refreshTagIdf({ force = false, videos, reason } = {}) {
async refreshTagIdf(options = {}) {
if (this.idfRefreshInFlight) {
this.idfRefreshFollowUp = {
force: true,
reason: options.reason || "coalesced",
};
return this.idfRefreshInFlight;
}
const run = this._refreshTagIdf(options);
this.idfRefreshInFlight = Promise.resolve(run).finally(() => {
this.idfRefreshInFlight = null;
const followUp = this.idfRefreshFollowUp;
this.idfRefreshFollowUp = null;
if (followUp && this.active) {
void this.refreshTagIdf(followUp);
} else if (followUp) {
this.idfDirty = true;
}
});
return this.idfRefreshInFlight;
}
async _refreshTagIdf({ force = false, videos, reason } = {}) {
const now = Date.now();
if (!force && this.tagIdfUpdatedAt) {
const elapsed = now - this.tagIdfUpdatedAt;
@@ -332,6 +414,8 @@ export default class ExploreDataService {
clearTimeout(this.tagIdfRefreshHandle);
this.tagIdfRefreshHandle = null;
}
this.historyRefreshFollowUp = null;
this.idfRefreshFollowUp = null;
this.unsubscribeHandlers.forEach((unsubscribe) => {
if (typeof unsubscribe === "function") {
+418
View File
@@ -0,0 +1,418 @@
// HLS (.m3u8) playback support.
//
// bitvid historically attached every hosted source with `videoEl.src = url`.
// That works for progressive MP4/WebM but NOT for HLS: only Safari/iOS can play
// an .m3u8 natively, so on Chrome/Firefox the element fails immediately with
// MEDIA_ERR_SRC_NOT_SUPPORTED ("Hosted playback failed: source not supported or
// blocked."). Nostr clients that publish bitvid's v3 schema with HLS ladders
// (e.g. nostube/slidestr) were therefore unplayable everywhere but Safari.
//
// This module owns the decision + the hls.js lifecycle. hls.js is vendored
// (scripts/build-hls.mjs) and lazy-imported ONLY when an HLS URL is actually
// encountered, so the ~500KB bundle never touches a normal MP4/WebTorrent load.
//
// Rollback: stop calling attachHlsSource() from playbackService/playbackCoordinator
// and the old `videoEl.src = url` behavior returns; nothing else imports this.
import { devLogger } from "../utils/logger.js";
// Vendored, pinned hls.js bundle (scripts/build-hls.mjs). Lazy-imported.
const HLS_BUNDLE_URL = "../../vendor/hls.bundle.min.js";
// Canonical + legacy HLS media types. Servers are wildly inconsistent here —
// the slidestr CDN serves playlists as `text/plain` — so the URL extension is
// the primary signal and the MIME type is only ever corroborating evidence.
export const HLS_MIME_TYPES = Object.freeze([
"application/vnd.apple.mpegurl",
"application/x-mpegurl",
"audio/mpegurl",
"audio/x-mpegurl",
"application/mpegurl",
"vnd.apple.mpegurl",
]);
/**
* True when the URL's *path* ends in .m3u8. Query strings and fragments are
* ignored (signed CDN URLs routinely carry `?token=...`), and a bare .m3u8
* substring elsewhere in the URL must not match.
*/
export function isHlsUrl(url) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
return false;
}
// Strip fragment then query, without `new URL()` — relative/odd inputs must
// not throw, and bitvid never runs URL parsers over user-supplied media
// strings (see the magnet rules in AGENTS.md).
const path = trimmed.split("#")[0].split("?")[0];
return /\.m3u8$/i.test(path);
}
/** True when a declared MIME type is one of the HLS playlist types. */
export function isHlsMimeType(mimeType) {
const normalized =
typeof mimeType === "string" ? mimeType.trim().toLowerCase() : "";
if (!normalized) {
return false;
}
const bare = normalized.split(";")[0].trim();
return HLS_MIME_TYPES.includes(bare);
}
/** Combined check used by callers that may have an imeta `m` hint. */
export function isHlsSource({ url = "", mimeType = "" } = {}) {
return isHlsUrl(url) || isHlsMimeType(mimeType);
}
/**
* Whether `canPlayType` claims native HLS support.
*
* DO NOT branch on this alone. Chrome LIES here: Chrome 149 returns "maybe" for
* every HLS MIME type, but assigning an .m3u8 to `video.src` then fires NO
* events at all — not `loadeddata`, not even `error`. The element simply hangs,
* which is exactly the silent "nothing happens" failure this module exists to
* fix (and why the URL-health probe timed out instead of erroring, leaving the
* card visible but unplayable). Use prefersNativeHls() to make decisions.
*/
export function supportsNativeHls(videoElement) {
const el =
videoElement ||
(typeof document !== "undefined" ? document.createElement("video") : null);
if (!el || typeof el.canPlayType !== "function") {
return false;
}
for (const type of HLS_MIME_TYPES) {
try {
if (el.canPlayType(type)) {
return true;
}
} catch (err) {
// canPlayType must never throw, but never let a probe kill playback.
}
}
return false;
}
// A representative baseline codec: every MSE implementation that can run hls.js
// supports fragmented-MP4 H.264. This mirrors what Hls.isSupported() checks, so
// we can predict the engine choice WITHOUT paying to load the bundle (the
// URL-health probe needs the answer for every visible card).
const MSE_PROBE_TYPE = 'video/mp4; codecs="avc1.42E01E"';
/** True when Media Source Extensions can back hls.js in this browser. */
export function supportsMse() {
const MediaSourceImpl =
(typeof globalThis !== "undefined" &&
(globalThis.MediaSource ||
globalThis.ManagedMediaSource ||
globalThis.WebKitMediaSource)) ||
null;
if (!MediaSourceImpl || typeof MediaSourceImpl.isTypeSupported !== "function") {
return false;
}
try {
return MediaSourceImpl.isTypeSupported(MSE_PROBE_TYPE);
} catch (err) {
return false;
}
}
/**
* Whether to use the browser's own HLS implementation instead of hls.js.
*
* Only when MSE is genuinely unavailable (older iOS Safari, some TV browsers).
* hls.js is the default everywhere else — this is hls.js's own recommended
* ordering, and on Chrome it is mandatory: see supportsNativeHls() for the
* canPlayType lie that makes a native-first branch hang forever.
*/
export function prefersNativeHls(videoElement) {
return !supportsMse() && supportsNativeHls(videoElement);
}
let hlsEnginePromise = null;
/**
* Lazy-import the vendored hls.js bundle. Cached across calls (including the
* rejection case, deliberately reset so a transient network failure can retry).
*/
export async function loadHlsEngine() {
if (!hlsEnginePromise) {
hlsEnginePromise = import(HLS_BUNDLE_URL)
.then((mod) => mod?.default || mod?.Hls || null)
.catch((err) => {
hlsEnginePromise = null;
devLogger.warn("[hlsPlayback] Failed to load hls.js bundle:", err);
return null;
});
}
return hlsEnginePromise;
}
/** Test seam: drop the cached engine promise. */
export function __resetHlsEngineCacheForTests() {
hlsEnginePromise = null;
}
// videoEl -> active hls.js instance. A WeakMap keeps teardown paths (which only
// ever hold the element) from having to thread an extra handle around, and lets
// a discarded element be collected without leaking the instance.
const attachedInstances = new WeakMap();
export function getAttachedHls(videoElement) {
return videoElement ? attachedInstances.get(videoElement) || null : null;
}
/**
* Tear down any hls.js instance bound to this element. Safe to call
* unconditionally on every reset path — it is a no-op for non-HLS playback.
*/
export function detachHls(videoElement) {
if (!videoElement) {
return false;
}
const hls = attachedInstances.get(videoElement);
if (!hls) {
return false;
}
attachedInstances.delete(videoElement);
try {
hls.destroy();
} catch (err) {
devLogger.warn("[hlsPlayback] hls.destroy() threw during detach:", err);
}
return true;
}
function describeHlsError(data) {
const type = data?.type || "unknown";
const details = data?.details || "unknown";
if (data?.type === "networkError") {
return "Hosted playback failed: the HLS stream could not be reached (network or CORS).";
}
if (data?.type === "mediaError") {
return "Hosted playback failed: this HLS stream uses a codec your browser cannot decode.";
}
return `Hosted playback failed: HLS error (${type}/${details}).`;
}
/**
* Attach `url` to `videoElement`, using native HLS where available and hls.js
* (MSE) otherwise.
*
* Resolves once the manifest is parsed — the meaningful "this is playable"
* signal for both the player and the URL-health probe. Callers are still
* responsible for calling play(); this only wires up the source.
*
* Returns null when HLS cannot be supported at all (no native support and MSE
* unavailable), so the caller can fall back to its normal `src` assignment and
* surface a real media error.
*
* `createEngine` is a test seam: it resolves the hls.js constructor and defaults
* to the lazy import of the vendored bundle. Tests inject a fake so the MSE
* branch can be specified without a real MediaSource.
*
* @returns {Promise<{mode: "native"|"mse", destroy: () => void}|null>}
*/
export async function attachHlsSource(
videoElement,
url,
{
onFatalError,
log = () => {},
manifestTimeoutMs = 15000,
createEngine = loadHlsEngine,
} = {}
) {
if (!videoElement) {
return null;
}
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
return null;
}
// Always clear a previous instance before rebinding — reusing the modal's
// <video> across videos is the norm in bitvid.
detachHls(videoElement);
const useNative = () => {
log("[hlsPlayback] Using native HLS playback.");
videoElement.src = trimmed;
return { mode: "native", destroy: () => {} };
};
// Native only when MSE genuinely cannot back hls.js. Never trust canPlayType
// on its own — Chrome claims HLS support and then hangs silently.
if (prefersNativeHls(videoElement)) {
return useNative();
}
const Hls = await createEngine();
if (!Hls || typeof Hls.isSupported !== "function" || !Hls.isSupported()) {
// Last resort: if the bundle failed to load but the browser claims native
// support, an attempt beats a guaranteed failure.
if (supportsNativeHls(videoElement)) {
log("[hlsPlayback] hls.js unavailable; falling back to native HLS.");
return useNative();
}
log("[hlsPlayback] hls.js unavailable/unsupported; cannot play HLS.");
return null;
}
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
// Keep a bounded back-buffer so long VOD (this note is ~1h) does not grow
// the SourceBuffer without limit on low-memory devices.
backBufferLength: 90,
});
attachedInstances.set(videoElement, hls);
let settled = false;
let networkRetries = 0;
let mediaRecoveries = 0;
return new Promise((resolve) => {
let timeoutId = null;
const finish = (value) => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
resolve(value);
};
const fail = (message) => {
// Only tear down once we've given up; recoverable errors keep the
// instance alive so hls.js can heal itself.
detachHls(videoElement);
if (typeof onFatalError === "function") {
onFatalError(message);
}
finish(null);
};
if (Number.isFinite(manifestTimeoutMs) && manifestTimeoutMs > 0) {
timeoutId = setTimeout(() => {
if (settled) {
return;
}
log("[hlsPlayback] Manifest parse timed out.");
fail("Hosted playback failed: the HLS manifest did not load in time.");
}, manifestTimeoutMs);
}
hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => {
log(
`[hlsPlayback] Manifest parsed; ${data?.levels?.length ?? 0} playable level(s).`
);
// hls.js drops levels whose codecs MSE cannot decode (e.g. an HEVC
// rendition on Firefox). Zero survivors means nothing is playable.
if (Array.isArray(data?.levels) && data.levels.length === 0) {
fail(
"Hosted playback failed: no HLS rendition uses a codec your browser supports."
);
return;
}
finish({ mode: "mse", destroy: () => detachHls(videoElement) });
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data?.fatal) {
log(
`[hlsPlayback] Non-fatal HLS error (${data?.type}/${data?.details}); continuing.`
);
return;
}
log(`[hlsPlayback] Fatal HLS error (${data?.type}/${data?.details}).`);
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && networkRetries < 2) {
networkRetries += 1;
log(`[hlsPlayback] Retrying load (attempt ${networkRetries}).`);
try {
hls.startLoad();
return;
} catch (err) {
log("[hlsPlayback] startLoad() threw during recovery.", err);
}
}
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && mediaRecoveries < 2) {
mediaRecoveries += 1;
log(`[hlsPlayback] Recovering media error (attempt ${mediaRecoveries}).`);
try {
if (mediaRecoveries === 2 && typeof hls.swapAudioCodec === "function") {
hls.swapAudioCodec();
}
hls.recoverMediaError();
return;
} catch (err) {
log("[hlsPlayback] recoverMediaError() threw during recovery.", err);
}
}
fail(describeHlsError(data));
});
try {
hls.attachMedia(videoElement);
hls.loadSource(trimmed);
} catch (err) {
log("[hlsPlayback] attachMedia/loadSource threw:", err);
fail("Hosted playback failed: the HLS stream could not be attached.");
}
});
}
/**
* Cheap playability check for the URL-health badge that does NOT pull in hls.js.
*
* On MSE browsers hls.js fetches the playlist with XHR, so a CORS-readable
* `#EXTM3U` response is exactly the condition for playability — and a bare
* <video> probe (the old path) would report every HLS URL as dead. On Safari,
* native playback needs no CORS, so the caller should keep using the element
* probe there.
*
* @returns {Promise<"ok"|"error"|"timeout">}
*/
export async function probeHlsManifest(url, timeoutMs = 4000) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed || typeof fetch !== "function") {
return "error";
}
const controller =
typeof AbortController === "function" ? new AbortController() : null;
let timedOut = false;
const timeoutId =
Number.isFinite(timeoutMs) && timeoutMs > 0
? setTimeout(() => {
timedOut = true;
controller?.abort();
}, timeoutMs)
: null;
try {
const response = await fetch(trimmed, {
method: "GET",
cache: "no-store",
signal: controller?.signal,
});
if (!response.ok) {
return "error";
}
const text = await response.text();
return text.trimStart().startsWith("#EXTM3U") ? "ok" : "error";
} catch (err) {
return timedOut ? "timeout" : "error";
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
+28
View File
@@ -17,6 +17,10 @@ export default class ModerationActionController {
typeof services.clearModerationOverride === "function"
? services.clearModerationOverride
: null,
clearAuthorModerationOverride:
typeof services.clearAuthorModerationOverride === "function"
? services.clearAuthorModerationOverride
: null,
userBlocks:
services.userBlocks && typeof services.userBlocks === "object"
? services.userBlocks
@@ -240,6 +244,10 @@ export default class ModerationActionController {
}
this.clearModerationOverride(video);
// The same Restore control is used after either a per-video override or
// an "Always show creator" override. Clear both scopes so the visible
// state can actually return to the default moderation decision.
this.clearAuthorModerationOverride(video);
const target = this.resolveTargetVideo(video);
this.clearViewerOverride(target);
@@ -503,6 +511,26 @@ export default class ModerationActionController {
}
}
clearAuthorModerationOverride(video) {
if (!this.services.clearAuthorModerationOverride) {
return;
}
const descriptor = this.getModerationOverrideDescriptor(video);
if (!descriptor?.authorPubkey) {
return;
}
try {
this.services.clearAuthorModerationOverride(descriptor.authorPubkey);
} catch (error) {
devLogger.warn(
"[ModerationActionController] Failed to clear author moderation override:",
error,
);
}
}
isUserLoggedIn() {
if (!this.auth.isLoggedIn) {
return false;
+10 -11
View File
@@ -300,20 +300,23 @@ export class ModerationDecorator {
: "";
const thresholds = this.getActiveModerationThresholds();
const computedBlockAutoplayBase =
trustedCount >= thresholds.autoplayBlockThreshold || trustedMuted;
// Trusted mutes are deliberately limited to feed ranking. A single muted
// list entry must not become a visible strike against a creator.
const computedBlockAutoplayBase = trustedCount >= thresholds.autoplayBlockThreshold;
const computedBlockAutoplay =
computedBlockAutoplayBase || viewerMuted || existingBlockAutoplay;
const blurFromReports = trustedCount >= thresholds.blurThreshold;
const preservesExistingBlur =
existingBlurThumbnail &&
existingBlurReason !== "trusted-mute" &&
existingBlurReason !== "trusted-mute-hide";
let computedBlurThumbnail =
blurFromReports || trustedMuted || viewerMuted || existingBlurThumbnail;
blurFromReports || viewerMuted || preservesExistingBlur;
let computedBlurReason = "";
if (blurFromReports) {
computedBlurReason = "trusted-report";
} else if (trustedMuted) {
computedBlurReason = "trusted-mute";
} else if (viewerMuted) {
computedBlurReason = "viewer-mute";
} else if (existingBlurThumbnail && existingBlurReason) {
@@ -352,22 +355,18 @@ export class ModerationDecorator {
hideTriggered = true;
}
if (!computedBlurThumbnail && (viewerMuted || trustedMuted || hideTriggered)) {
if (!computedBlurThumbnail && (viewerMuted || hideTriggered)) {
computedBlurThumbnail = true;
if (hideTriggered) {
computedBlurReason = hideReason || "trusted-hide";
} else if (viewerMuted) {
computedBlurReason = "viewer-mute";
} else if (trustedMuted) {
computedBlurReason = "trusted-mute";
}
} else if (computedBlurThumbnail) {
if (hideTriggered) {
computedBlurReason = hideReason || "trusted-hide";
} else if (viewerMuted && !blurFromReports && !trustedMuted) {
} else if (viewerMuted && !blurFromReports) {
computedBlurReason = "viewer-mute";
} else if (trustedMuted && !blurFromReports) {
computedBlurReason = "trusted-mute";
} else if (!computedBlurReason && blurFromReports) {
computedBlurReason = "trusted-report";
}
+68 -3
View File
@@ -41,6 +41,9 @@ const MAX_OPEN_ATTEMPTS = 30; // ~60s of retries
// (kind 30000), which during cold-start competes with the initial feed load; a
// fallback covers the empty-feed case where no videos:updated ever fires.
const FEED_READY_FALLBACK_MS = 12000;
// Some relays do not reliably send EOSE. Do not hold the first visible update
// forever, but give the initial relay burst time to settle before painting it.
const INITIAL_SYNC_FALLBACK_MS = 5000;
export function createNip71IngestService({
nostrClient,
@@ -54,6 +57,7 @@ export function createNip71IngestService({
maxOpenAttempts = MAX_OPEN_ATTEMPTS,
refreshThrottleMs = REFRESH_THROTTLE_MS,
feedReadyFallbackMs = FEED_READY_FALLBACK_MS,
initialSyncFallbackMs = INITIAL_SYNC_FALLBACK_MS,
} = {}) {
let subscription = null;
let buffer = [];
@@ -67,6 +71,9 @@ export function createNip71IngestService({
let deferArmed = false;
let deferTimer = null;
let offFeedReady = null;
let initialSyncing = false;
let initialRefreshPending = false;
let initialSyncTimer = null;
function isAvailable() {
return (
@@ -183,7 +190,13 @@ export function createNip71IngestService({
);
if (injected > 0) {
requestRefresh();
if (initialSyncing) {
// Keep native BitVid, NIP-71 videos, and future NIP-71 media types in
// the store immediately; defer only the expensive full-feed repaint.
initialRefreshPending = true;
} else {
requestRefresh();
}
}
return injected;
@@ -211,6 +224,10 @@ export function createNip71IngestService({
function emitRefresh() {
lastRefreshAt = Date.now();
const performanceHarness = globalThis?.__bitvidPerformance;
const performanceRef = globalThis?.performance;
const startedAt = typeof performanceRef?.now === "function" ? performanceRef.now() : 0;
performanceHarness?.mark?.("nip71-refresh-filter-start");
let videos = [];
try {
videos =
@@ -220,6 +237,11 @@ export function createNip71IngestService({
} catch (error) {
logger?.warn?.("[nip71Ingest] getFilteredActiveVideos failed", error);
}
const filteredAt = typeof performanceRef?.now === "function" ? performanceRef.now() : startedAt;
performanceHarness?.mark?.("nip71-refresh-filter-end", {
durationMs: Math.max(0, filteredAt - startedAt),
videos: videos.length,
});
// Confirm ingested videos survive the feed filter (whitelist/NSFW/etc.).
const foreignCount = Array.isArray(videos)
? videos.filter((v) => v?.source === "nip71-ingest").length
@@ -234,6 +256,37 @@ export function createNip71IngestService({
reason: "nip71-ingest",
});
}
const emittedAt = typeof performanceRef?.now === "function" ? performanceRef.now() : filteredAt;
performanceHarness?.mark?.("nip71-refresh-dispatch-end", {
filterMs: Math.max(0, filteredAt - startedAt),
dispatchMs: Math.max(0, emittedAt - filteredAt),
videos: videos.length,
});
if (performanceHarness && (filteredAt - startedAt > 50 || emittedAt - filteredAt > 50)) {
userLogger.info(
`[nip71Ingest] refresh timing: filter ${Math.round(filteredAt - startedAt)}ms, dispatch ${Math.round(emittedAt - filteredAt)}ms, videos ${videos.length}`,
);
}
}
function finishInitialSync() {
if (!initialSyncing) {
return;
}
if (initialSyncTimer) {
clearTimeout(initialSyncTimer);
initialSyncTimer = null;
}
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
flush();
}
initialSyncing = false;
if (initialRefreshPending) {
initialRefreshPending = false;
requestRefresh();
}
}
// Defer the first start until the native feed has rendered (first
@@ -255,7 +308,7 @@ export function createNip71IngestService({
deferTimer = null;
}
deferArmed = false;
start();
start({ coalesceInitialSync: true });
};
if (typeof nostrService?.on === "function") {
@@ -265,12 +318,17 @@ export function createNip71IngestService({
return true;
}
function start() {
function start({ coalesceInitialSync = false } = {}) {
if (started || !isAvailable()) {
return false;
}
started = true;
attemptsLeft = maxOpenAttempts;
initialSyncing = coalesceInitialSync;
initialRefreshPending = false;
if (initialSyncing) {
initialSyncTimer = setTimeout(finishInitialSync, initialSyncFallbackMs);
}
// Re-subscribe when the whitelist changes so the author scope stays current.
if (typeof accessControl?.onWhitelistChange === "function") {
@@ -320,6 +378,7 @@ export function createNip71IngestService({
scheduleFlush();
}
},
onEose: finishInitialSync,
});
const authorCount = Array.isArray(filters[0]?.authors)
? filters[0].authors.length
@@ -348,6 +407,12 @@ export function createNip71IngestService({
clearTimeout(pendingRefreshTimer);
pendingRefreshTimer = null;
}
if (initialSyncTimer) {
clearTimeout(initialSyncTimer);
initialSyncTimer = null;
}
initialSyncing = false;
initialRefreshPending = false;
buffer = [];
if (subscription && typeof subscription.close === "function") {
try {
+49 -29
View File
@@ -1071,6 +1071,12 @@ export class NostrService {
this.dmActorPubkey = normalizedActor;
const relaySelection = await this.resolveDirectMessageRelaySelection(
normalizedActor,
relays,
);
const resolvedRelays = relaySelection.relays;
let resolvedSince =
Number.isFinite(sinceCandidate) && sinceCandidate >= 0
? Math.floor(sinceCandidate)
@@ -1095,7 +1101,7 @@ export class NostrService {
let messages = [];
try {
messages = await this.nostrClient.listDirectMessages(normalizedActor, {
relays,
relays: resolvedRelays,
...options,
limit: resolvedLimit,
decryptLimit: resolvedDecryptLimit,
@@ -1133,6 +1139,14 @@ export class NostrService {
messages: snapshot,
actorPubkey: normalizedActor,
});
if (relaySelection.warning === DM_RELAY_WARNING_FALLBACK) {
this.emit("directMessages:relayWarning", {
actorPubkey: normalizedActor,
warning: relaySelection.warning,
relays: resolvedRelays,
source: relaySelection.source,
});
}
this.emit("directMessages:updated", {
messages: snapshot,
reason: "load",
@@ -1156,6 +1170,35 @@ export class NostrService {
return snapshot;
}
async resolveDirectMessageRelaySelection(actorPubkey, relays = null) {
if (Array.isArray(relays)) {
return { relays, source: "explicit", warning: null };
}
const discoveryRelays = Array.isArray(this.nostrClient?.readRelays)
? this.nostrClient.readRelays
: Array.isArray(this.nostrClient?.relays)
? this.nostrClient.relays
: [];
let pool = this.nostrClient?.pool || null;
if (!pool && typeof this.nostrClient?.ensurePool === "function") {
try {
pool = await this.nostrClient.ensurePool();
} catch (error) {
devLogger.warn("[nostrService] Failed to initialize DM relay pool.", error);
}
}
return resolveDmRelaySelection({
pubkey: actorPubkey,
discoveryRelays,
fallbackRelays: discoveryRelays,
pool,
log: { dev: devLogger, user: userLogger },
});
}
async ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) {
const activeActor = actorPubkey || this.resolveActiveDmActor();
const normalizedActor = normalizeHexPubkey(activeActor);
@@ -1189,34 +1232,11 @@ export class NostrService {
this.stopDirectMessageSubscription();
}
let resolvedRelays = Array.isArray(relays) ? relays : null;
let relaySelection = null;
if (!resolvedRelays) {
const discoveryRelays = Array.isArray(this.nostrClient?.readRelays)
? this.nostrClient.readRelays
: Array.isArray(this.nostrClient?.relays)
? this.nostrClient.relays
: [];
let pool = this.nostrClient?.pool || null;
if (!pool && typeof this.nostrClient?.ensurePool === "function") {
try {
pool = await this.nostrClient.ensurePool();
} catch (error) {
devLogger.warn("[nostrService] Failed to initialize DM relay pool.", error);
}
}
relaySelection = await resolveDmRelaySelection({
pubkey: normalizedActor,
discoveryRelays,
fallbackRelays: discoveryRelays,
pool,
log: { dev: devLogger, user: userLogger },
});
resolvedRelays = relaySelection.relays;
}
const relaySelection = await this.resolveDirectMessageRelaySelection(
normalizedActor,
relays,
);
const resolvedRelays = relaySelection.relays;
try {
const subscription = this.nostrClient.subscribeDirectMessages(
+115 -8
View File
@@ -1,5 +1,8 @@
import { userLogger } from "../utils/logger.js";
import { PLAYBACK_START_TIMEOUT } from "../constants.js";
import {
HLS_PLAYBACK_START_TIMEOUT,
PLAYBACK_START_TIMEOUT,
} from "../constants.js";
import { getCurrentVideo, setCurrentVideo } from "../state/appState.js";
import {
consumeAutoplayFallbackMute,
@@ -9,6 +12,7 @@ import {
SimpleEventEmitter,
extractWebSeedsFromMagnet,
} from "./playbackHelpers.js";
import { attachHlsSource, detachHls, isHlsUrl } from "./hlsPlayback.js";
// js/services/playbackService.js
/**
@@ -855,6 +859,10 @@ class PlaybackSession extends SimpleEventEmitter {
const resetVideoElement = () => {
if (!activeVideoEl) return;
// Kill any hls.js instance BEFORE touching src/load(): a live instance
// keeps appending to the MediaSource and would fight the reset (and
// leak its worker) when we move to the next mirror or to WebTorrent.
detachHls(activeVideoEl);
try {
activeVideoEl.pause();
} catch (err) {
@@ -1055,6 +1063,11 @@ class PlaybackSession extends SimpleEventEmitter {
};
});
// Whatever MediaError the element already carries from a previous
// load. Identity comparison works because starting a new load clears
// `error` to null and a fresh failure allocates a new MediaError.
const staleMediaError = activeVideoEl.error || null;
const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
this.registerWatchdogs(activeVideoEl, {
stallMs,
@@ -1071,7 +1084,17 @@ class PlaybackSession extends SimpleEventEmitter {
attachWatchdogs({ stallMs: 0 });
return;
}
const errorMessage = getHostedVideoErrorMessage(activeVideoEl);
// Only report a MediaError this attempt actually produced.
// teardownVideoElement()/resetVideoElement() clear `src` and
// call load(), which leaves a stale MEDIA_ERR_SRC_NOT_SUPPORTED
// ("Empty src attribute") on the element. Reading it blindly
// reported "Hosted playback failed: source not supported or
// blocked." for a stream that was playing perfectly — a
// failure message attributed to the wrong load entirely.
const errorMessage =
activeVideoEl.error && activeVideoEl.error !== staleMediaError
? getHostedVideoErrorMessage(activeVideoEl)
: "";
if (errorMessage) {
this.emit("status", { message: errorMessage });
}
@@ -1080,9 +1103,6 @@ class PlaybackSession extends SimpleEventEmitter {
});
};
// Use default stall timeout here; the initial start timeout is handled by the race wrapper
attachWatchdogs({ stallMs: 8000 });
const handleFatalPlaybackError = (err) => {
this.service.log(
"[playVideoWithFallback] Direct URL playback threw:",
@@ -1091,6 +1111,14 @@ class PlaybackSession extends SimpleEventEmitter {
outcomeResolver({ status: "fallback", reason: "play-error" });
};
// Bind the source BEFORE arming the watchdogs. hls.js's attachMedia()
// points `src` at a MediaSource blob URL, which aborts any load still
// pending on the reused modal element and fires `abort`/`emptied`.
// With watchdogs already live those read as a playback failure and
// abandon a stream that is about to play fine. A plain `src =` is
// safe either way: media events are dispatched asynchronously, so
// listeners registered later in the same task still observe them.
let hlsAttached = true;
try {
// Guarantee the direct load is CORS-free regardless of how we got
// here (see resetVideoElement) so any hosted URL plays reliably.
@@ -1100,12 +1128,65 @@ class PlaybackSession extends SimpleEventEmitter {
// ignore
}
activeVideoEl.crossOrigin = null;
activeVideoEl.src = candidateUrl;
const playPromise = activeVideoEl.play();
// HLS cannot be assigned to `src` outside Safari — it needs hls.js
// + MSE. attachHlsSource() handles both paths and resolves once the
// manifest is parsed. A null result means HLS is unplayable here
// (or already failed fatally), and the outcome promise is settled,
// so skip play() and let the normal fallback plumbing take over.
if (isHlsUrl(candidateUrl)) {
const hlsHandle = await attachHlsSource(
activeVideoEl,
candidateUrl,
{
log: (...args) => this.service.log(...args),
onFatalError: (message) => {
this.emit("status", { message });
outcomeResolver({
status: "fallback",
reason: "hls-error",
errorMessage: message,
});
},
}
);
if (!hlsHandle) {
hlsAttached = false;
// A fatal HLS error already resolved the outcome with a
// specific message; this only covers "no HLS support at all".
const message =
"Hosted playback failed: this browser cannot play HLS streams.";
this.emit("status", { message });
outcomeResolver({
status: "fallback",
reason: "hls-unsupported",
errorMessage: message,
});
}
} else {
activeVideoEl.src = candidateUrl;
}
} catch (err) {
hlsAttached = false;
handleFatalPlaybackError(err);
}
try {
// Use default stall timeout here; the initial start timeout is
// handled by the race wrapper.
if (hlsAttached) {
attachWatchdogs({ stallMs: 8000 });
}
const playPromise = hlsAttached ? activeVideoEl.play() : null;
if (playPromise && typeof playPromise.catch === "function") {
playPromise.catch((err) => {
if (err?.name === "NotAllowedError") {
autoplayBlocked = true;
// Suspend the start timeout: we are waiting on the viewer,
// not on a slow source, and falling back to P2P here strands
// them on a swarm that may have no peers.
startTimeoutGuard.deferred = true;
this.service.log(
"[playVideoWithFallback] Autoplay blocked by browser; awaiting user interaction.",
err
@@ -1119,6 +1200,7 @@ class PlaybackSession extends SimpleEventEmitter {
activeVideoEl.removeEventListener("play", restoreOnPlay);
this.cleanupWatchdog();
autoplayBlocked = false;
startTimeoutGuard.deferred = false;
attachWatchdogs({ stallMs: 8000 });
};
activeVideoEl.addEventListener("play", restoreOnPlay, {
@@ -1171,10 +1253,29 @@ class PlaybackSession extends SimpleEventEmitter {
};
// --- Timeout Wrapper ---
// `startTimeoutGuard.deferred` is set when the browser blocks autoplay and
// we are waiting on a user gesture. That is NOT a stalled source, and
// treating it as one was breaking mobile outright: a deep link from a
// nostr client arrives with no user gesture, so autoplay is refused, the
// 3s start timeout fired anyway, and a video with a magnet got yanked into
// WebTorrent -- which then found no peers and played nothing, while the
// "Press play" prompt the viewer was meant to act on had already been
// torn down. Desktop usually hides this because an established Media
// Engagement score lets autoplay through.
const startTimeoutGuard = { deferred: false };
const withTimeout = (promise, ms, label = "Operation") => {
if (!ms || ms <= 0) return promise;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (startTimeoutGuard.deferred) {
// Waiting on the viewer, not on the network. Let the underlying
// attempt settle on its own: it resolves when they press play, or
// when a real media error/stall fires.
this.service.log(
`[playVideoWithFallback] ${label} start timeout suppressed; autoplay is blocked and we are awaiting a user gesture.`
);
return;
}
this.service.log(`[playVideoWithFallback] ${label} timed out after ${ms}ms.`);
resolve({ status: "fallback", reason: "timeout", source: "timeout" });
}, ms);
@@ -1215,9 +1316,15 @@ class PlaybackSession extends SimpleEventEmitter {
let lastUrlResult = null;
for (let i = 0; i < this.hostedSourceCandidates.length; i += 1) {
const candidateUrl = this.hostedSourceCandidates[i];
// HLS starts slower by construction (see HLS_PLAYBACK_START_TIMEOUT).
// `forcedSource` still means "wait indefinitely", so keep 0 as 0.
const candidateTimeout =
effectiveTimeout && isHlsUrl(candidateUrl)
? Math.max(effectiveTimeout, HLS_PLAYBACK_START_TIMEOUT)
: effectiveTimeout;
const urlResult = await withTimeout(
attemptHostedPlayback(candidateUrl),
effectiveTimeout,
candidateTimeout,
"URL Playback"
);
+31 -49
View File
@@ -180,6 +180,7 @@ class SubscriptionsManager {
this.subscribedPubkeys = new Set();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.subsEventPubkey = null;
// Last live-subscription event id we triggered a refresh for (loop guard).
this._lastHandledListEventId = null;
// Most recently fetched list ciphertext, reused by decrypt retries so they
@@ -247,6 +248,7 @@ class SubscriptionsManager {
this.subscribedPubkeys = new Set(cachedSnapshot.subscribedPubkeys);
this.subsEventId = cachedSnapshot.eventId;
this.subsEventCreatedAt = cachedSnapshot.createdAt;
this.subsEventPubkey = cachedSnapshot.eventPubkey;
this.currentUserPubkey = normalizedUserPubkey;
this.uiReady = true;
this.dataReady = true;
@@ -281,6 +283,7 @@ class SubscriptionsManager {
subscribedPubkeys: Array.from(this.subscribedPubkeys),
eventId: this.subsEventId,
createdAt: this.subsEventCreatedAt,
eventPubkey: this.subsEventPubkey,
});
}
@@ -389,63 +392,39 @@ class SubscriptionsManager {
profileCache.getProfileData(normalizedUserPubkey, "subscriptions"),
);
hadCachedSnapshot = cachedSnapshot.hasSnapshot;
const shouldForceFullFetch = !cachedSnapshot.hasSnapshot;
// Legacy snapshots did not record the source author. Fetch their full
// authoritative state once so a cache created by an older buggy build
// cannot keep suppressing the real list with an invalid timestamp.
const shouldForceFullFetch =
!cachedSnapshot.hasSnapshot ||
cachedSnapshot.eventPubkey !== normalizedUserPubkey;
const incrementalSince = shouldForceFullFetch
? 0
: computeIncrementalSinceWithOverlap(cachedSnapshot.createdAt, 1);
// Fetch user and session actor subscription lists in parallel to reduce
// total relay wait time during login.
const fetchPromises = [
nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedUserPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: incrementalSince,
timeoutMs: 12000,
}),
];
const normalizedSessionActorPubkey = normalizeNostrPubkey(
nostrClient?.sessionActor?.pubkey,
);
const shouldFetchSessionActor =
normalizedSessionActorPubkey &&
normalizedSessionActorPubkey !== normalizedUserPubkey;
if (shouldFetchSessionActor) {
const sessionCachedSnapshot = parseCachedSubscriptionSnapshot(
profileCache.getProfileData(
normalizedSessionActorPubkey,
"subscriptions",
),
);
const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot;
const sessionSince = shouldForceSessionFetch
? 0
: computeIncrementalSinceWithOverlap(sessionCachedSnapshot.createdAt, 1);
fetchPromises.push(
nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedSessionActorPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: sessionSince,
timeoutMs: 12000,
}),
);
}
const fetchResults = await Promise.all(fetchPromises);
let events = fetchResults[0] || [];
if (shouldFetchSessionActor && fetchResults[1]?.length) {
events = events.concat(fetchResults[1]);
}
// Subscription lists are private, account-scoped state. In particular,
// a BitLogin session actor can differ from the active profile pubkey.
// Never mix its events into this read: choosing the newest event across
// both accounts could overwrite the active account's subscriptions.
const events = await nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedUserPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: incrementalSince,
timeoutMs: 12000,
});
if (events.length) {
const deduped = new Map();
for (const event of events) {
if (!event || typeof event !== "object" || !event.id) {
if (
!event ||
typeof event !== "object" ||
!event.id ||
(event.pubkey &&
normalizeHexPubkey(event.pubkey) !== normalizedUserPubkey)
) {
continue;
}
const existing = deduped.get(event.id);
@@ -471,6 +450,7 @@ class SubscriptionsManager {
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.subsEventPubkey = null;
this.uiReady = true;
this.dataReady = true;
this.loaded = true;
@@ -513,6 +493,7 @@ class SubscriptionsManager {
this.subsEventCreatedAt = Number.isFinite(newest.created_at)
? newest.created_at
: null;
this.subsEventPubkey = normalizedUserPubkey;
let decryptResult;
try {
@@ -687,6 +668,7 @@ class SubscriptionsManager {
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.subsEventPubkey = null;
this._lastHandledListEventId = null;
this._pendingDecryptEvent = null;
this.currentUserPubkey = null;
+4
View File
@@ -196,6 +196,7 @@ export function parseCachedSubscriptionSnapshot(cached) {
subscribedPubkeys: normalized,
eventId: null,
createdAt: null,
eventPubkey: null,
hasSnapshot: true,
};
}
@@ -220,6 +221,7 @@ export function parseCachedSubscriptionSnapshot(cached) {
const createdAt = Number.isFinite(createdAtCandidate)
? Math.floor(createdAtCandidate)
: null;
const eventPubkey = normalizeHexPubkey(cached.eventPubkey);
const hasSnapshot =
Array.isArray(cached.subscribedPubkeys) ||
Array.isArray(cached.subPubkeys) ||
@@ -229,6 +231,7 @@ export function parseCachedSubscriptionSnapshot(cached) {
subscribedPubkeys: normalized,
eventId,
createdAt,
eventPubkey,
hasSnapshot,
};
}
@@ -237,6 +240,7 @@ export function parseCachedSubscriptionSnapshot(cached) {
subscribedPubkeys: [],
eventId: null,
createdAt: null,
eventPubkey: null,
hasSnapshot: false,
};
}
+17 -14
View File
@@ -166,11 +166,10 @@ export default class ApplicationBootstrap {
app.streamHealthSnapshots = new Map();
app.boundStreamHealthBadgeHandler = (detail) =>
app.handleStreamHealthBadgeUpdate(detail);
app.attachHealthBadgesWithCache = (container) => {
app.attachHealthBadgesWithCache = (container) =>
attachHealthBadges(container, {
onUpdate: app.boundStreamHealthBadgeHandler,
});
};
app.defaultModerationSettings = getDefaultModerationSettings();
app.moderationSettings = { ...app.defaultModerationSettings };
app.relayManager = relayManager;
@@ -360,18 +359,22 @@ export default class ApplicationBootstrap {
onViewLogged: (detail) => app.handleFeedViewTelemetry(detail),
});
app.exploreDataService =
this.services.exploreDataService ||
new ExploreDataService({
watchHistoryService,
nostrService: app.nostrService,
getActiveActor: () =>
typeof app.pubkey === "string" && app.pubkey ? app.pubkey : "",
logger: devLogger,
});
if (app.exploreDataService && typeof app.exploreDataService.initialize === "function") {
app.exploreDataService.initialize();
}
app.exploreDataService = this.services.exploreDataService || null;
app.ensureExploreDataService = async () => {
if (!app.exploreDataService) {
app.exploreDataService = new ExploreDataService({
watchHistoryService,
nostrService: app.nostrService,
getActiveActor: () =>
typeof app.pubkey === "string" && app.pubkey ? app.pubkey : "",
logger: devLogger,
});
}
if (typeof app.exploreDataService.initialize === "function") {
await app.exploreDataService.initialize({ active: true });
}
return app.exploreDataService;
};
const playbackDependencies = {
torrentClient: this.services.torrentClient || torrentClient,
+23 -1
View File
@@ -351,8 +351,28 @@ export class ShareNostrModal {
typeof payload?.shareUrl === "string" ? payload.shareUrl.trim() : "";
const thumbnail =
typeof payload?.thumbnail === "string" ? payload.thumbnail.trim() : "";
const mirrorNaddr =
typeof payload?.mirrorNaddr === "string" ? payload.mirrorNaddr.trim() : "";
return `${title} — Check out this video on bitvid 👇\n\n${shareUrl}\n\n${thumbnail}`;
const lines = [`${title} — Check out this video on bitvid 👇`];
if (shareUrl) {
lines.push(shareUrl);
}
if (mirrorNaddr) {
// A `nostr:naddr…` reference renders as a NATIVE video quote card in
// nostr clients — the closest thing to a rich preview that doesn't
// depend on any client fetching OpenGraph tags. The raw thumbnail URL is
// deliberately omitted here: the quote card already shows the thumbnail,
// and including both rendered the same image twice.
lines.push(`nostr:${mirrorNaddr}`);
} else if (thumbnail) {
// No mirror to quote — fall back to the bare thumbnail URL, which the
// note's NIP-92 `imeta` tag describes so clients inline it as an image.
lines.push(thumbnail);
}
return lines.join("\n\n");
}
setVideo(payload) {
@@ -380,6 +400,8 @@ export class ShareNostrModal {
title,
shareUrl,
thumbnail,
mirrorNaddr:
typeof payload?.mirrorNaddr === "string" ? payload.mirrorNaddr : "",
});
}
+26
View File
@@ -3453,6 +3453,32 @@ export class VideoModal {
// Delegated click handler for the share popover's menu-action buttons
// (Copy URL / Copy Magnet / Copy CDN / Share on Nostr). The forced-source
// test links self-wire their own copy, so they are skipped here.
/**
* Update the login/signer gate for the share menu's "Share on Nostr" item.
*
* This state was initialized to `false` and never written, so
* `createVideoShareMenuPanel` always rendered the item disabled — the entry
* looked like inert text no matter who was logged in, and the click
* delegation in bindShareMenuActions() bails on `button.disabled`. The app
* pushes real state here from syncAuthUiState().
*/
setShareNostrAuthState({ isLoggedIn = false, hasSigner = false } = {}) {
const next = { isLoggedIn: !!isLoggedIn, hasSigner: !!hasSigner };
const changed =
next.isLoggedIn !== this.shareNostrAuthState.isLoggedIn ||
next.hasSigner !== this.shareNostrAuthState.hasSigner;
this.shareNostrAuthState = next;
// The popover renders its panel on open, so a menu that is already open
// would keep showing the stale (disabled) item. Close it so the next open
// renders against current state.
if (changed) {
this.modalSharePopover?.close?.();
}
return changed;
}
bindShareMenuActions(panel) {
if (!panel || typeof panel.addEventListener !== "function") {
return;
-1
View File
@@ -160,7 +160,6 @@ export function normalizeVideoModerationContext(moderationInput) {
context.originalBlur ||
context.originalBlockAutoplay ||
context.trustedCount > 0 ||
context.trustedMuted ||
context.overrideActive ||
context.originalHidden ||
context.activeHidden ||
@@ -753,7 +753,11 @@ export class ProfileDirectMessageActions {
handleDirectMessagesUpdated(detail = {}) {
if (
this.controller.activeMessagesRequest &&
detail?.reason !== "load-incremental"
detail?.reason !== "load-incremental" &&
// A persisted, locally encrypted snapshot is safe to show while the
// slower relay history/decryption pass is still running. Suppressing it
// made the DM pane appear blank until that full pass completed.
detail?.reason !== "snapshot"
) {
return;
}
@@ -79,6 +79,8 @@ export class ProfilePlaylistsController {
this.playlists = [];
this.expanded = new Set();
this.loading = false;
this.refreshRequestId = 0;
this.fetchCreatorPlaylists = fetchCreatorPlaylists;
this._drag = null;
this._dropTarget = null;
this._onChanged = () => {
@@ -139,16 +141,22 @@ export class ProfilePlaylistsController {
return;
}
const requestId = ++this.refreshRequestId;
this.setLoading(true);
let playlists = [];
try {
playlists = await fetchCreatorPlaylists(this.pubkey, {
playlists = await this.fetchCreatorPlaylists(this.pubkey, {
includeEmpty: true,
});
} catch (error) {
devLogger.warn("[playlists] Failed to load playlists for pane:", error);
}
this.playlists = playlists;
this.setLoading(false);
// Show the playlist structure as soon as the list itself is available.
// Resolving every referenced video only improves labels/thumbnails and
// must not keep the management pane blank on a cold cache.
this.render();
// Fetch the referenced creators' videos so the expanded sublists can show
// real titles/thumbnails (cache may be cold in the modal).
@@ -165,15 +173,16 @@ export class ProfilePlaylistsController {
}
const nostrService = this.mainController.services?.nostrService;
if (authors.size && typeof nostrService?.fetchVideosByAuthors === "function") {
try {
await nostrService.fetchVideosByAuthors([...authors]);
} catch (error) {
// best effort — fall back to coordinate labels
}
void nostrService.fetchVideosByAuthors([...authors])
.catch(() => {
// Best effort — keep the coordinate labels if metadata is unavailable.
})
.then(() => {
if (this.refreshRequestId === requestId) {
this.render();
}
});
}
this.setLoading(false);
this.render();
}
// coord -> { title, thumbnail } from the app's active videos.
+105 -79
View File
@@ -4,16 +4,16 @@ import { pointerArrayToKey } from "../utils/pointer.js";
export default class ReactionController {
constructor({ services = {}, ui = {}, state = {}, callbacks = {} } = {}) {
this.services = {
reactionCounter: services.reactionCounter,
reactionCounter: services.reactionCounter
};
this.ui = {
getVideoModal: ui.getVideoModal || (() => null),
showError: ui.showError || (() => {}),
showError: ui.showError || (() => {})
};
this.state = {
getCurrentVideo: state.getCurrentVideo || (() => null),
getCurrentVideoPointer: state.getCurrentVideoPointer || (() => null),
getCurrentVideoPointerKey: state.getCurrentVideoPointerKey || (() => null),
getCurrentVideoPointerKey: state.getCurrentVideoPointerKey || (() => null)
};
this.callbacks = {
isUserLoggedIn: callbacks.isUserLoggedIn || (() => false),
@@ -22,13 +22,13 @@ export default class ReactionController {
// Ensure a sign-capable signer before publishing a reaction (kind 7). A
// reloaded nsec session can lose its in-memory key; this re-unlocks it with
// one passphrase prompt instead of the reaction silently failing (TODO #54).
ensureSigner: callbacks.ensureSigner || (async () => ({ ok: true })),
ensureSigner: callbacks.ensureSigner || (async () => ({ ok: true }))
};
this.reactionState = {
counts: { "+": 0, "-": 0 },
total: 0,
userReaction: "",
userReaction: ""
};
this.unsub = null;
this.pointerKey = null;
@@ -46,14 +46,14 @@ export default class ReactionController {
this.reactionState = {
counts: { "+": 0, "-": 0 },
total: 0,
userReaction: "",
userReaction: ""
};
const videoModal = this.ui.getVideoModal();
if (videoModal?.updateReactionSummary) {
videoModal.updateReactionSummary({
total: 0,
counts: { "+": 0, "-": 0 },
userReaction: "",
userReaction: ""
});
}
}
@@ -65,7 +65,7 @@ export default class ReactionController {
} catch (error) {
devLogger.warn(
"[reaction] Failed to tear down modal subscription:",
error,
error
);
}
}
@@ -88,59 +88,83 @@ export default class ReactionController {
}
try {
const normalizedUser = this.callbacks.normalizeHexPubkey(this.callbacks.getPubkey());
const unsubscribe = this.services.reactionCounter.subscribe(pointer, (snapshot) => {
const counts = { ...this.reactionState.counts };
if (snapshot?.counts && typeof snapshot.counts === "object") {
for (const [key, value] of Object.entries(snapshot.counts)) {
counts[key] = this.normalizeReactionCount(value);
}
const currentVideo = this.state.getCurrentVideo();
const reactionTarget = (() => {
if (!Array.isArray(pointer) || pointer[0] !== "a") {
return pointer;
}
if (!Object.prototype.hasOwnProperty.call(counts, "+")) {
counts["+"] = 0;
}
if (!Object.prototype.hasOwnProperty.call(counts, "-")) {
counts["-"] = 0;
}
let total = Number.isFinite(snapshot?.total)
? Math.max(0, Number(snapshot.total))
: 0;
if (!Number.isFinite(total) || total === 0) {
total = 0;
for (const value of Object.values(counts)) {
total += this.normalizeReactionCount(value);
}
}
let userReaction = "";
if (normalizedUser && snapshot?.reactions) {
const record = snapshot.reactions[normalizedUser] || null;
if (record && typeof record.content === "string") {
userReaction =
record.content === "+"
? "+"
: record.content === "-"
? "-"
: "";
}
}
this.reactionState = {
counts,
total,
userReaction,
const eventId =
typeof currentVideo?.id === "string" && currentVideo.id.trim()
? currentVideo.id.trim()
: typeof currentVideo?.eventId === "string" &&
currentVideo.eventId.trim()
? currentVideo.eventId.trim()
: "";
return {
type: "a",
value: pointer[1],
relay: pointer[2],
eventId
};
})();
const normalizedUser = this.callbacks.normalizeHexPubkey(
this.callbacks.getPubkey()
);
const unsubscribe = this.services.reactionCounter.subscribe(
reactionTarget,
(snapshot) => {
const counts = { ...this.reactionState.counts };
if (snapshot?.counts && typeof snapshot.counts === "object") {
for (const [key, value] of Object.entries(snapshot.counts)) {
counts[key] = this.normalizeReactionCount(value);
}
}
if (!Object.prototype.hasOwnProperty.call(counts, "+")) {
counts["+"] = 0;
}
if (!Object.prototype.hasOwnProperty.call(counts, "-")) {
counts["-"] = 0;
}
const currentModal = this.ui.getVideoModal();
if (currentModal?.updateReactionSummary) {
currentModal.updateReactionSummary({
total,
let total = Number.isFinite(snapshot?.total)
? Math.max(0, Number(snapshot.total))
: 0;
if (!Number.isFinite(total) || total === 0) {
total = 0;
for (const value of Object.values(counts)) {
total += this.normalizeReactionCount(value);
}
}
let userReaction = "";
if (normalizedUser && snapshot?.reactions) {
const record = snapshot.reactions[normalizedUser] || null;
if (record && typeof record.content === "string") {
userReaction =
record.content === "+"
? "+"
: record.content === "-"
? "-"
: "";
}
}
this.reactionState = {
counts,
userReaction,
});
total,
userReaction
};
const currentModal = this.ui.getVideoModal();
if (currentModal?.updateReactionSummary) {
currentModal.updateReactionSummary({
total,
counts,
userReaction
});
}
}
});
);
this.pointerKey = pointerKey;
this.unsub = () => {
@@ -149,7 +173,7 @@ export default class ReactionController {
} catch (error) {
devLogger.warn(
"[reaction] Failed to tear down modal subscription:",
error,
error
);
} finally {
this.unsub = null;
@@ -159,7 +183,7 @@ export default class ReactionController {
} catch (error) {
devLogger.warn(
"[reaction] Failed to subscribe modal reaction counter:",
error,
error
);
this.resetState();
}
@@ -171,7 +195,7 @@ export default class ReactionController {
}
const previousCounts = {
...(this.reactionState?.counts || {}),
...(this.reactionState?.counts || {})
};
const previousTotalValue = Number.isFinite(this.reactionState?.total)
? Math.max(0, Number(this.reactionState.total))
@@ -206,7 +230,7 @@ export default class ReactionController {
const updatedCounts = {
...previousCounts,
"+": likeCount,
"-": dislikeCount,
"-": dislikeCount
};
let updatedTotal = likeCount + dislikeCount;
@@ -217,7 +241,7 @@ export default class ReactionController {
this.reactionState = {
counts: updatedCounts,
total: updatedTotal,
userReaction: nextReaction,
userReaction: nextReaction
};
const videoModal = this.ui.getVideoModal();
@@ -225,7 +249,7 @@ export default class ReactionController {
videoModal.updateReactionSummary({
total: updatedTotal,
counts: updatedCounts,
userReaction: nextReaction,
userReaction: nextReaction
});
}
@@ -241,7 +265,7 @@ export default class ReactionController {
return {
counts: previousCounts,
total: fallbackPreviousTotal,
userReaction: previousReaction,
userReaction: previousReaction
};
}
@@ -285,7 +309,7 @@ export default class ReactionController {
this.reactionState = {
counts,
total,
userReaction,
userReaction
};
const videoModal = this.ui.getVideoModal();
@@ -293,7 +317,7 @@ export default class ReactionController {
videoModal.updateReactionSummary({
total,
counts,
userReaction,
userReaction
});
}
}
@@ -307,11 +331,7 @@ export default class ReactionController {
const requestedReaction =
typeof detail.reaction === "string" ? detail.reaction : "";
const normalizedReaction =
requestedReaction === "+"
? "+"
: requestedReaction === "-"
? "-"
: "";
requestedReaction === "+" ? "+" : requestedReaction === "-" ? "-" : "";
if (!normalizedReaction) {
return;
@@ -319,14 +339,16 @@ export default class ReactionController {
const previousReaction = this.reactionState?.userReaction || "";
const pointer = this.state.getCurrentVideoPointer();
const pointerKey = this.state.getCurrentVideoPointerKey() || (pointer ? pointerArrayToKey(pointer) : null);
const pointerKey =
this.state.getCurrentVideoPointerKey() ||
(pointer ? pointerArrayToKey(pointer) : null);
if (!pointer || !pointerKey) {
if (videoModal) {
videoModal.setUserReaction(previousReaction);
}
devLogger.info(
"[reaction] Ignoring reaction request until modal pointer is available.",
"[reaction] Ignoring reaction request until modal pointer is available."
);
return;
}
@@ -350,7 +372,7 @@ export default class ReactionController {
need: "sign",
pubkey: this.callbacks.getPubkey(),
promptMessage:
"Re-enter your PIN / passphrase to unlock your key and react to videos.",
"Re-enter your PIN / passphrase to unlock your key and react to videos."
});
} catch (error) {
devLogger.warn("[reaction] Signer gate failed:", error);
@@ -359,7 +381,10 @@ export default class ReactionController {
if (ensured && ensured.ok === false) {
videoModal.setUserReaction(previousReaction);
// cancelled = user backed out; bad-passphrase already showed its own toast.
if (ensured.reason !== "cancelled" && ensured.reason !== "bad-passphrase") {
if (
ensured.reason !== "cancelled" &&
ensured.reason !== "bad-passphrase"
) {
this.ui.showError("Connect a signer to react to videos.");
}
return;
@@ -367,11 +392,12 @@ export default class ReactionController {
let rollbackSnapshot = null;
try {
rollbackSnapshot = this.applyOptimisticUpdate(
normalizedReaction
);
rollbackSnapshot = this.applyOptimisticUpdate(normalizedReaction);
} catch (error) {
devLogger.warn("[reaction] Failed to apply optimistic reaction state:", error);
devLogger.warn(
"[reaction] Failed to apply optimistic reaction state:",
error
);
}
try {
@@ -380,7 +406,7 @@ export default class ReactionController {
content: normalizedReaction,
video: currentVideo,
currentVideoPubkey: currentVideo?.pubkey,
pointerKey,
pointerKey
});
if (!result?.ok) {
+114 -1
View File
@@ -11,6 +11,16 @@ import { queueSignEvent as defaultQueueSignEvent } from "../nostr/signRequestQue
import { DEFAULT_NIP07_PERMISSION_METHODS as defaultPermissionMethods } from "../nostr/nip07Permissions.js";
import { nostrClient as defaultNostrClient } from "../nostrClientFacade.js";
import { userLogger as defaultUserLogger, devLogger as defaultDevLogger } from "../utils/logger.js";
import { nip71MirrorService as defaultMirrorService } from "../services/nip71MirrorService.js";
import {
buildMirrorCoordinate,
buildMirrorNaddr,
resolveMirrorKind,
} from "../nostr/mirrorPointer.js";
// Best-effort: a share must never hang on a relay read. If the mirror lookup
// doesn't answer in time we simply fall back to the thumbnail-URL note.
const MIRROR_LOOKUP_TIMEOUT_MS = 2500;
export default class ShareNostrController {
constructor({ ui, state, services = {} }) {
@@ -27,6 +37,87 @@ export default class ShareNostrController {
nostrClient: services.nostrClient || defaultNostrClient,
userLogger: services.userLogger || defaultUserLogger,
devLogger: services.devLogger || defaultDevLogger,
mirrorService:
services.mirrorService === undefined
? defaultMirrorService
: services.mirrorService,
nip19: services.nip19 || null,
};
}
resolveNip19() {
return (
this.services.nip19 ||
(typeof window !== "undefined" ? window?.NostrTools?.nip19 : null) ||
null
);
}
/**
* Resolve an `naddr` for the video's NIP-71 mirror, or "" when there isn't
* one. Embedding this makes nostr clients render a native video quote card
* instead of a bare link — but ONLY when the mirror actually exists on
* relays. A pointer to a missing event renders as a broken/empty quote box,
* which is worse than the plain-link fallback, so this verifies before
* pointing and stays silent on any failure.
*/
async resolveMirrorPointer(video) {
const mirrorService = this.services.mirrorService;
const pubkey = typeof video?.pubkey === "string" ? video.pubkey.trim() : "";
const videoRootId =
typeof video?.videoRootId === "string" ? video.videoRootId.trim() : "";
if (!pubkey || !videoRootId || !mirrorService) {
return { naddr: "", coordinate: "" };
}
if (
typeof mirrorService.isAvailable === "function" &&
!mirrorService.isAvailable()
) {
return { naddr: "", coordinate: "" };
}
if (typeof mirrorService.findMirror !== "function") {
return { naddr: "", coordinate: "" };
}
let result = null;
try {
result = await Promise.race([
mirrorService.findMirror({ pubkey, videoRootId }),
new Promise((resolve) =>
setTimeout(() => resolve(null), MIRROR_LOOKUP_TIMEOUT_MS)
),
]);
} catch (error) {
this.services.devLogger.warn(
"[ShareNostrController] Mirror lookup failed; sharing without a quote.",
error
);
return { naddr: "", coordinate: "" };
}
if (!result?.mirrored) {
return { naddr: "", coordinate: "" };
}
const kind = resolveMirrorKind({
kinds: result.kinds,
width: video?.width,
height: video?.height,
});
const relays = Array.isArray(this.services.nostrClient?.writeRelays)
? this.services.nostrClient.writeRelays
: this.services.nostrClient?.relays || [];
return {
naddr: buildMirrorNaddr({
pubkey,
videoRootId,
kind,
relays,
nip19: this.resolveNip19(),
}),
coordinate: buildMirrorCoordinate({ pubkey, videoRootId, kind }),
};
}
@@ -52,6 +143,8 @@ export default class ShareNostrController {
? targetVideo.shareUrl.trim()
: this.state.buildShareUrlFromEventId(targetVideo.id);
const mirror = await this.resolveMirrorPointer(targetVideo);
const payload = {
id: targetVideo.id,
title: targetVideo.title,
@@ -59,6 +152,10 @@ export default class ShareNostrController {
authorName: targetVideo.creatorName || targetVideo.authorName || "",
thumbnail: targetVideo.thumbnail,
shareUrl,
// Present only when a NIP-71 mirror was confirmed on relays. Drives the
// quote-card note body and the `a` tag on the published event.
mirrorNaddr: mirror.naddr,
mirrorCoordinate: mirror.coordinate,
};
try {
@@ -167,7 +264,23 @@ export default class ShareNostrController {
pubkey: eventPubkey,
created_at: Math.floor(Date.now() / 1000),
content: typeof payload?.content === "string" ? payload.content : "",
video: { id: videoId, pubkey: videoPubkey },
// title/thumbnail drive the NIP-92 `imeta` hint so the thumbnail URL in
// the note body renders as an image rather than a bare link. When a
// mirror pointer is present the note quotes the mirror instead, and
// buildShareEvent drops the imeta so the image isn't rendered twice.
video: {
id: videoId,
pubkey: videoPubkey,
title: videoTitle,
thumbnail:
typeof video?.thumbnail === "string" ? video.thumbnail.trim() : "",
mirrorNaddr:
typeof video?.mirrorNaddr === "string" ? video.mirrorNaddr.trim() : "",
mirrorCoordinate:
typeof video?.mirrorCoordinate === "string"
? video.mirrorCoordinate.trim()
: "",
},
relays: relayEntries,
});
+20
View File
@@ -1,4 +1,9 @@
import { SHORT_TIMEOUT_MS } from "../constants.js";
import {
isHlsUrl,
prefersNativeHls,
probeHlsManifest,
} from "../services/hlsPlayback.js";
export default class UrlHealthController {
constructor({
@@ -275,6 +280,21 @@ export default class UrlHealthController {
return { outcome: "error" };
}
// A bare <video> cannot load an .m3u8 on the browsers bitvid actually runs
// hls.js on. Worse, Chrome neither plays it NOR errors — it emits no events
// at all, so this probe burned the full timeout on every healthy HLS card.
// Since playback will use hls.js (which fetches the playlist over CORS), a
// readable #EXTM3U response is the accurate signal — and far cheaper than
// pulling in the hls.js bundle just to paint a badge.
if (isHlsUrl(trimmed) && !prefersNativeHls()) {
const defaultTimeout =
this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS;
const effective =
Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : defaultTimeout;
const outcome = await probeHlsManifest(trimmed, effective);
return { outcome };
}
if (this.activeVideoProbes >= this.MAX_CONCURRENT_VIDEO_PROBES) {
await new Promise((resolve) => {
this.videoProbeQueue.push(resolve);
+22 -3
View File
@@ -32,6 +32,8 @@ export class VideoListView {
(typeof window !== "undefined" ? window : null);
this.container = null;
this.mediaLoader = mediaLoader;
this.healthBadgeDetach = null;
this.urlHealthBadgeDetach = null;
this.badgeHelpers = {
attachHealthBadges: badgeHelpers.attachHealthBadges || (() => {}),
@@ -236,6 +238,7 @@ export class VideoListView {
}
if (this.container) {
this.teardownHealthBadgeObservers();
this.container.removeEventListener("click", this._boundClickHandler);
}
@@ -262,6 +265,7 @@ export class VideoListView {
const markup = this.renderers.getLoadingMarkup(message);
if (typeof markup === "string") {
this.teardownHealthBadgeObservers();
this.container.innerHTML = markup;
}
}
@@ -280,6 +284,16 @@ export class VideoListView {
}
}
teardownHealthBadgeObservers() {
for (const detach of [this.healthBadgeDetach, this.urlHealthBadgeDetach]) {
if (typeof detach === "function") {
detach();
}
}
this.healthBadgeDetach = null;
this.urlHealthBadgeDetach = null;
}
setPlaybackHandler(handler) {
this.handlers.playback = typeof handler === "function" ? handler : null;
}
@@ -374,6 +388,7 @@ export class VideoListView {
return displayVideos;
}
this.lastRenderedVideoSignature = EMPTY_VIDEO_LIST_SIGNATURE;
this.teardownHealthBadgeObservers();
this.container.innerHTML = `
<p class="flex justify-center items-center h-full w-full text-center text-subtle">
No public videos available yet. Be the first to upload one!
@@ -413,7 +428,10 @@ export class VideoListView {
}));
const signature = JSON.stringify(signaturePayload);
if (signature === this.lastRenderedVideoSignature) {
const hasRenderedCards = Boolean(
this.container.querySelector('[data-component="video-card"]'),
);
if (signature === this.lastRenderedVideoSignature && hasRenderedCards) {
return displayVideos;
}
this.lastRenderedVideoSignature = signature;
@@ -788,11 +806,12 @@ export class VideoListView {
});
}
this.teardownHealthBadgeObservers();
this.container.innerHTML = "";
this.container.appendChild(fragment);
this.badgeHelpers.attachHealthBadges(this.container);
this.badgeHelpers.attachUrlHealthBadges(this.container, ({ badgeEl, url, eventId }) => {
this.healthBadgeDetach = this.badgeHelpers.attachHealthBadges(this.container);
this.urlHealthBadgeDetach = this.badgeHelpers.attachUrlHealthBadges(this.container, ({ badgeEl, url, eventId }) => {
this.utils.handleUrlHealthBadge({
video: this.state.videosMap.get(eventId) || { id: eventId },
url,
+2 -2
View File
@@ -75,12 +75,12 @@ function triggerUrlCheck(card, state) {
export function attachUrlHealthBadges(container, onCheck) {
if (!(container instanceof HTMLElement)) {
return;
return () => {};
}
const state = urlCardObserver.observe(container);
if (state && typeof onCheck === "function") {
state.onCheck = onCheck;
}
return () => urlCardObserver.disconnect(container);
}
+53
View File
@@ -47,3 +47,56 @@ export function inferMimeTypeFromUrl(url) {
const mimeType = EXTENSION_MIME_MAP[extension];
return typeof mimeType === "string" ? mimeType : "";
}
// Image types, kept in a SEPARATE map from EXTENSION_MIME_MAP on purpose: that
// map gates video upload/publish paths (js/nostr/publishHelpers.js,
// js/nostr/videoPayloadBuilder.js), so folding image types into it would let an
// image pass a video check. Used for NIP-92 `imeta` hints on thumbnails.
export const IMAGE_EXTENSION_MIME_MAP = Object.freeze(
Object.fromEntries(
Object.entries({
jpg: "image/jpeg",
jpeg: "image/jpeg",
jfif: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
bmp: "image/bmp",
svg: "image/svg+xml",
ico: "image/x-icon",
heic: "image/heic",
heif: "image/heif",
}).map(([extension, mimeType]) => [extension, mimeType.toLowerCase()]),
),
);
/**
* Best-effort image MIME type from a URL's extension. Returns "" when the URL
* carries no recognizable image extension — callers must treat that as "unknown"
* and omit the hint rather than guessing, since a wrong `m` value is worse than
* a missing one (clients use it to decide whether to render inline at all).
*/
export function inferImageMimeTypeFromUrl(url) {
if (!url || typeof url !== "string") {
return "";
}
let pathname = "";
try {
const parsed = new URL(url);
pathname = parsed.pathname || "";
} catch (err) {
const sanitized = url.split("?")[0].split("#")[0];
pathname = sanitized || "";
}
const lastSegment = pathname.split("/").pop() || "";
const match = lastSegment.match(/\.([a-z0-9]+)$/i);
if (!match) {
return "";
}
const mimeType = IMAGE_EXTENSION_MIME_MAP[match[1].toLowerCase()];
return typeof mimeType === "string" ? mimeType : "";
}
+27 -9
View File
@@ -138,6 +138,29 @@ export class TorrentClient {
let timeoutId = null;
let pollId = null;
// A successful probe commonly settles from WebTorrent's `wire` event.
// Do not destroy the temporary torrent while that event is still being
// dispatched: WebTorrent continues its handshake bookkeeping after
// listeners return, and synchronous destruction can leave its bitfield
// null (`recalculate(...).fill` in the bundled client). The next task
// guarantees the callback has fully unwound before cleanup.
const disposeProbeTorrent = (target, onDisposed = () => {}) => {
if (!target) {
onDisposed();
return;
}
const dispose = () => {
try {
target.destroy({ destroyStore: true });
} catch (err) {
// A completed or already-destroyed probe is harmless.
} finally {
onDisposed();
}
};
setTimeout(dispose, 0);
};
const finalize = (overrides = {}) => {
if (settled) {
return;
@@ -151,14 +174,6 @@ export class TorrentClient {
clearInterval(pollId);
pollId = null;
}
if (torrent) {
try {
torrent.destroy({ destroyStore: true });
} catch (err) {
// ignore
}
}
const endedAt =
typeof performance !== "undefined" && performance?.now
? performance.now()
@@ -178,7 +193,10 @@ export class TorrentClient {
...overrides,
};
emit("torrent-probe-result", result);
resolve(result);
// Keep the grid-health queue slot occupied until the delayed teardown
// completes. Otherwise the next probe can begin before this probe has
// detached its tracker listeners, briefly exceeding listener limits.
disposeProbeTorrent(torrent, () => resolve(result));
};
try {
+43
View File
@@ -0,0 +1,43 @@
// Routes crawler requests for `/?v=<nevent>` to the per-video OpenGraph
// renderer (api/og.js).
//
// This has to be middleware rather than a `rewrites` entry in vercel.json:
// rewrites are evaluated AFTER the filesystem, and `/` matches the static
// index.html, so the rewrite never fired and crawlers kept getting the
// site-wide card. Middleware runs before the filesystem check.
//
// Deliberately minimal. It matches only `/`, bails immediately when there is no
// `v` param or the visitor is not a known preview crawler, and never touches
// the response for a real viewer — they continue to the untouched SPA. If this
// file is removed the site keeps working, just with the generic card.
import { next, rewrite } from "@vercel/edge";
import { isCrawlerUserAgent } from "./api/_lib/ogRenderer.mjs";
export const config = {
// Only the share-link path. Every other route skips middleware entirely.
matcher: "/",
};
export default function middleware(request) {
let url;
try {
url = new URL(request.url);
} catch (err) {
return next();
}
// A bare homepage visit is not a share link.
if (!url.searchParams.get("v")) {
return next();
}
const userAgent = request.headers?.get?.("user-agent") || "";
if (!isCrawlerUserAgent(userAgent)) {
return next();
}
const target = new URL(url.toString());
target.pathname = "/api/og";
return rewrite(target);
}
-46
View File
@@ -1,46 +0,0 @@
[build]
command = "npm run build && npm run verify:dist:deploy-artifact"
publish = "dist"
[build.environment]
NODE_ENV = "production"
[[headers]]
for = "/*"
[headers.values]
Content-Security-Policy = "frame-ancestors *"
[[headers]]
for = "/index.html"
[headers.values]
Cache-Control = "no-cache, must-revalidate"
[[headers]]
for = "/embed.html"
[headers.values]
Cache-Control = "no-cache, must-revalidate"
[[headers]]
for = "/*.html"
[headers.values]
Cache-Control = "no-cache, must-revalidate"
[[headers]]
for = "/css/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
[[headers]]
for = "/js/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
[[headers]]
for = "/sw.min.js"
[headers.values]
Cache-Control = "public, max-age=0, must-revalidate"
[[headers]]
for = "/site.webmanifest"
[headers.values]
Cache-Control = "public, max-age=0, must-revalidate"
+15
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@floating-ui/dom": "^1.7.4",
"@tailwindcss/typography": "^0.5.19",
"@vercel/edge": "^1.3.1",
"autoprefixer": "^10.4.24",
"cross-env": "^10.1.0",
"esbuild": "^0.27.3",
@@ -25,6 +26,7 @@
"blossom-client-sdk": "5.0.0",
"cssnano": "^7.1.2",
"fake-indexeddb": "^6.2.5",
"hls.js": "^1.6.16",
"jsdom": "^28.1.0",
"nostr-wasm": "^0.1.0",
"pixelmatch": "^5.3.0",
@@ -1477,6 +1479,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vercel/edge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vercel/edge/-/edge-1.3.1.tgz",
"integrity": "sha512-k0tKWeYEWOv6qU6+Z8+hv2Nt/u3vFHRZdB0uvwE0DxoddU/5kWX/Chu8F6ojvqmRlnZwuX/7kyZFVeCP6DxqBg==",
"license": "Apache-2.0"
},
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
@@ -3252,6 +3260,13 @@
"node": ">= 0.4"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/hookified": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
+3
View File
@@ -17,6 +17,7 @@
"build:beacon": "npm run build:beacon:bundle && npm run lint:inline-styles",
"build:bitcoin-connect": "node scripts/build-bitcoin-connect.mjs",
"build:blossom-sdk": "node scripts/build-blossom-sdk.mjs",
"build:hls": "node scripts/build-hls.mjs",
"ci:beacon": "npm run build:beacon",
"format": "prettier --cache --write css/tailwind.source.css css/tokens.css css/docs.css \"docs/**/*.html\" tailwind.config.cjs README.md CONTRIBUTING.md",
"audit": "node scripts/daily-design-system-audit.mjs",
@@ -65,6 +66,7 @@
"blossom-client-sdk": "5.0.0",
"cssnano": "^7.1.2",
"fake-indexeddb": "^6.2.5",
"hls.js": "^1.6.16",
"jsdom": "^28.1.0",
"nostr-wasm": "^0.1.0",
"pixelmatch": "^5.3.0",
@@ -83,6 +85,7 @@
"dependencies": {
"@floating-ui/dom": "^1.7.4",
"@tailwindcss/typography": "^0.5.19",
"@vercel/edge": "^1.3.1",
"autoprefixer": "^10.4.24",
"cross-env": "^10.1.0",
"esbuild": "^0.27.3",
-3357
View File
File diff suppressed because it is too large. Load diff
+38 -1
View File
@@ -33,6 +33,43 @@ if (packageJson.name !== "@bitlogin/widget") {
throw new Error("BITLOGIN_ENTRY must point to a BitLogin checkout (packages/widget/package.json not found there).");
}
// Provenance (BitUnlock entropy audit M1, ported). Hashes prove the vendored
// bytes have not changed since they were committed; they cannot say WHICH
// BitLogin commit produced them. @bitlogin/widget is a private package pinned
// at 0.1.0 across many substantive changes, so the version string distinguishes
// nothing. Resolved BEFORE the dist build and before vendorDir is deleted, so
// refusing a dirty checkout leaves the committed vendored files untouched.
const git = (args) => execSync(`git ${args}`, { cwd: bitloginRoot, encoding: "utf8" }).trim();
let source;
try {
const dirty = git("status --porcelain") !== "";
if (dirty && process.env.BITLOGIN_ALLOW_DIRTY !== "1") {
throw new Error(
"DIRTY_CHECKOUT: the BitLogin checkout has uncommitted changes, so the artifact this "
+ "would produce corresponds to no commit and cannot be reproduced. Commit there first, "
+ "or set BITLOGIN_ALLOW_DIRTY=1 for a throwaway local build.",
);
}
source = {
repository: git("remote get-url origin"),
commit: git("rev-parse HEAD"),
commitDate: git("log -1 --format=%cI"),
dirty,
provenance: dirty ? "dirty" : "attributed",
toolchain: { node: process.version, vite: packageJson.devDependencies?.vite },
};
} catch (error) {
if (error.message.startsWith("DIRTY_CHECKOUT:")) {
throw new Error(error.message.replace("DIRTY_CHECKOUT: ", ""));
}
throw new Error(
`Could not read BitLogin git provenance from ${bitloginRoot}: ${error.message}. `
+ "Vendoring from a non-git source leaves the artifact unattributable; refusing.",
);
}
console.log(`[vendor-bitlogin] vendoring @bitlogin/widget from ${source.commit.slice(0, 12)}`);
if (!existsSync(widgetDist)) {
console.log("[build-bitlogin-widget] dist missing, building @bitlogin/core then @bitlogin/widget first...");
execSync("npm run build -w @bitlogin/core", { cwd: bitloginRoot, stdio: "inherit" });
@@ -56,7 +93,7 @@ for (const name of distEntries) {
await writeFile(
integrityFile,
`${JSON.stringify({ package: packageJson.name, version: packageJson.version, files }, null, 2)}\n`,
`${JSON.stringify({ package: packageJson.name, version: packageJson.version, source, files }, null, 2)}\n`,
);
try {
+46
View File
@@ -0,0 +1,46 @@
// Bundles hls.js into a single self-contained ESM file that bitvid lazy-imports
// the first time a viewer opens an HLS (.m3u8) source. Mirrors the
// blossom-sdk / bitcoin-connect / floating-ui vendor steps. Pinned via the exact
// devDependency version in package.json.
//
// Lazy-import is the whole point: hls.js is ~530KB minified, and the vast
// majority of bitvid videos are progressive MP4 or WebTorrent. Nothing here may
// be imported from a module that loads on boot.
import { build } from "esbuild";
import { mkdir, copyFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const entryPoint = path.join(repoRoot, "scripts/vendor/hls-entry.mjs");
const vendorDir = path.join(repoRoot, "vendor");
const outFile = path.join(vendorDir, "hls.bundle.min.js");
const licenseSrc = path.join(repoRoot, "node_modules/hls.js/LICENSE");
const licenseOut = path.join(vendorDir, "hls.LICENSE");
await mkdir(vendorDir, { recursive: true });
await build({
bundle: true,
entryPoints: [entryPoint],
outfile: outFile,
format: "esm",
splitting: false,
platform: "browser",
target: ["es2020"],
minify: true,
sourcemap: false,
define: {
"process.env.NODE_ENV": '"production"',
global: "globalThis",
},
logLevel: "info",
});
try {
await copyFile(licenseSrc, licenseOut);
} catch (error) {
console.warn("[build-hls] could not copy LICENSE:", error.message);
}
console.log("[build-hls] wrote", path.relative(repoRoot, outFile));
+6
View File
@@ -0,0 +1,6 @@
// Vendor entry for hls.js. We deliberately bundle the FULL build rather than
// `hls.js/light`: the light build drops the alternate-audio stream controller,
// and real-world HLS ladders (e.g. nostube/slidestr publishes an
// `EXT-X-MEDIA:TYPE=AUDIO` rendition group) demux audio into its own playlist.
// With the light build those streams play video-only or not at all.
export { default } from "hls.js";
+58
View File
@@ -85,3 +85,61 @@ test('createFeedCoordinator - loadForYouVideos', async (t) => {
assert.strictEqual(app.videoSubscription, 'mock-subscription', 'Should update videoSubscription');
});
});
test("loadForYouVideos renders without waiting for recommendation worker activation", async () => {
let resolveActivation;
const activation = new Promise((resolve) => {
resolveActivation = resolve;
});
let loadVideosStarted = false;
const mockDeps = {
devLogger: { log: mock.fn(), warn: mock.fn() },
userLogger: { warn: mock.fn() },
nostrClient: {},
getSidebarLoadingMarkup: mock.fn(() => '<div>Loading...</div>'),
watchHistoryService: {},
subscriptions: {},
createActiveNostrSource: mock.fn(),
createBlacklistFilterStage: mock.fn(),
createDisinterestFilterStage: mock.fn(),
createDedupeByRootStage: mock.fn(),
createExploreDiversitySorter: mock.fn(),
createExploreScorerStage: mock.fn(),
createKidsAudienceFilterStage: mock.fn(),
createKidsScorerStage: mock.fn(),
createKidsScoreSorter: mock.fn(),
createModerationStage: mock.fn(),
createResolvePostedAtStage: mock.fn(),
createTagPreferenceFilterStage: mock.fn(),
createWatchHistorySuppressionStage: mock.fn(),
createChronologicalSorter: mock.fn(),
createSubscriptionAuthorsSource: mock.fn(),
registerWatchHistoryFeed: mock.fn(),
};
const coordinator = createFeedCoordinator(mockDeps);
const app = {
...coordinator,
nostrService: {
getFilteredActiveVideos: mock.fn(() => [{ id: "cached" }]),
loadVideos: mock.fn(async ({ onVideos }) => {
loadVideosStarted = true;
onVideos([{ id: "cached", title: "Cached", created_at: 1 }], { reason: "cache" });
return [{ id: "cached", title: "Cached", created_at: 1 }];
}),
getVideoSubscription: mock.fn(() => null),
getVideosMap: mock.fn(() => new Map()),
},
videoListView: { state: {}, render: mock.fn() },
mountVideoListView: mock.fn(() => ({ innerHTML: "" })),
checkRelayHealthWarning: mock.fn(),
setFeedTelemetryContext: mock.fn(),
isAuthorBlocked: mock.fn(() => false),
refreshFeed: mock.fn(async () => {}),
ensureExploreDataService: mock.fn(() => activation),
blacklistedEventIds: new Set(),
};
await app.loadForYouVideos(false);
assert.equal(loadVideosStarted, true);
resolveActivation();
});
+34
View File
@@ -178,3 +178,37 @@ test("login reloads the open video comment thread", async () => {
assert.deepEqual(loadedVideos, [activeVideo]);
});
test("duplicate login for the active identity joins the existing list sync", async () => {
const coord = makeCoordinator({
loadDirectMessages: async () => ({ ok: true }),
refreshUnreadDmIndicator: async () => {},
});
let releaseBlocks;
const blocksGate = new Promise((resolve) => {
releaseBlocks = resolve;
});
let blockLoads = 0;
coord.authService.loadBlocksForPubkey = async () => {
blockLoads += 1;
await blocksGate;
return true;
};
const detail = {
pubkey: ACTIVE_PUBKEY,
identityChanged: false,
postLogin: { profile: { name: "tester" } },
postLoginPromise: Promise.resolve({ profile: { name: "tester" } }),
};
await coord.handleAuthLogin(detail);
const duplicate = coord.handleAuthLogin({ ...detail });
for (let attempt = 0; attempt < 20 && blockLoads === 0; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.equal(blockLoads, 1, "duplicate login must not start a second list sync");
releaseBlocks();
await duplicate;
assert.equal(coord._authLoginSync, null, "dedupe guard releases after list sync settles");
});
+36
View File
@@ -7,6 +7,11 @@ import {
createModerationAppHarness,
} from "../helpers/moderation-test-helpers.mjs";
import { userBlocks } from "../../js/userBlocks.js";
import {
clearAuthorModerationOverride,
hasAuthorModerationOverride,
setAuthorModerationOverride,
} from "../../js/state/cache.js";
const REPORTER_HEX = "b".repeat(64);
const MUTER_HEX = "c".repeat(64);
@@ -109,6 +114,37 @@ test("handleModerationOverride resumes deferred playback", async (t) => {
});
});
test("Restore default moderation clears both video and creator overrides", async (t) => {
withMockedNostrTools(t);
const app = await createModerationAppHarness();
app.moderationDecorator.updateSettings({
autoplayBlockThreshold: 1,
blurThreshold: 1,
});
const authorHex = "8".repeat(64);
const videoId = "e".repeat(64);
const video = {
id: videoId,
pubkey: authorHex,
moderation: buildModerationState(),
};
t.after(() => clearAuthorModerationOverride(authorHex));
setAuthorModerationOverride(authorHex, { showAnyway: true });
app.handleModerationOverride({ video });
assert.equal(hasAuthorModerationOverride(authorHex), true);
assert.equal(video.moderation.viewerOverride?.showAnyway, true);
const restored = app.handleModerationHide({ video });
assert.equal(restored, true);
assert.equal(hasAuthorModerationOverride(authorHex), false);
assert.equal(video.moderation.viewerOverride, undefined);
assert.equal(video.moderation.blockAutoplay, true);
assert.equal(video.moderation.blurThumbnail, true);
});
test("handleModerationBlock requests a block, clears overrides, and refreshes hidden state", async (t) => {
withMockedNostrTools(t);
+70
View File
@@ -0,0 +1,70 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
installRequestMonitor,
isRequestMonitorEnabled,
REQUEST_MONITOR_STORAGE_KEY,
} from "../js/devReqMonitor.js";
function storage(value = null) {
return { getItem: (key) => (key === REQUEST_MONITOR_STORAGE_KEY ? value : null) };
}
function createScope({ hostname = "localhost", enabled = false } = {}) {
class FakeWebSocket {
send(data) {
this.sent = data;
return "sent";
}
}
const intervals = [];
return {
location: { hostname },
localStorage: storage(enabled ? "1" : null),
WebSocket: FakeWebSocket,
setInterval: (callback, delay) => {
intervals.push({ callback, delay });
return intervals.length;
},
clearInterval: () => {},
console: { info: () => {}, warn: () => {} },
intervals,
};
}
test("request monitor stays disabled on localhost until explicitly enabled", () => {
const scope = createScope();
const originalSend = scope.WebSocket.prototype.send;
assert.equal(installRequestMonitor({ scope }), null);
assert.equal(scope.WebSocket.prototype.send, originalSend);
assert.equal(scope.intervals.length, 0);
});
test("request monitor cannot be enabled on a public host", () => {
assert.equal(
isRequestMonitorEnabled({
locationRef: { hostname: "unstable.bitvid.network" },
storage: storage("1"),
}),
false,
);
});
test("explicit loopback opt-in patches REQ sends and can be stopped", () => {
const scope = createScope({ enabled: true });
const originalSend = scope.WebSocket.prototype.send;
const monitor = installRequestMonitor({ scope });
assert.ok(monitor);
assert.notEqual(scope.WebSocket.prototype.send, originalSend);
assert.equal(scope.intervals.length, 1);
assert.equal(scope.intervals[0].delay, 2000);
const socket = new scope.WebSocket();
assert.equal(socket.send('["REQ","sub",{"kinds":[1]}]'), "sent");
assert.equal(socket.sent, '["REQ","sub",{"kinds":[1]}]');
monitor.stop();
assert.equal(scope.WebSocket.prototype.send, originalSend);
assert.equal(scope.__bitvidReqMonitorInstalled, undefined);
});
+45
View File
@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ProfileDirectMessageActions } from "../js/ui/profileModal/ProfileDirectMessageActions.js";
test("DM pane renders a persisted snapshot while the relay load is still active", () => {
const scheduled = [];
const actor = "a".repeat(64);
const controller = {
activeMessagesRequest: Symbol("relay-load"),
directMessagesCache: [],
directMessagesLastActor: null,
helper: { resolveActiveDmActor: () => actor },
renderer: {
scheduleDirectMessagesRender: (detail) => scheduled.push(detail),
setMessagesLoadingState: () => {},
},
};
const actions = new ProfileDirectMessageActions({}, controller);
const messages = [{ event: { id: "cached-message" } }];
actions.handleDirectMessagesUpdated({ messages, reason: "snapshot" });
assert.deepEqual(controller.directMessagesCache, messages);
assert.equal(controller.directMessagesLastActor, actor);
assert.deepEqual(scheduled, [{ messages, actorPubkey: actor, reason: "snapshot" }]);
});
test("DM pane waits for relay increments but ignores unrelated updates during a load", () => {
const controller = {
activeMessagesRequest: Symbol("relay-load"),
directMessagesCache: [],
directMessagesLastActor: null,
helper: { resolveActiveDmActor: () => "a".repeat(64) },
renderer: {
scheduleDirectMessagesRender: () => assert.fail("unrelated update must not render"),
setMessagesLoadingState: () => {},
},
};
const actions = new ProfileDirectMessageActions({}, controller);
actions.handleDirectMessagesUpdated({ messages: [{ event: { id: "other" } }], reason: "load" });
assert.deepEqual(controller.directMessagesCache, []);
});
+38
View File
@@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { createCardObserver } from "../../js/dom/cardObserver.js";
test("card observer disconnect unregisters every card and releases its observer", () => {
const dom = new JSDOM("<div id=grid><article class='card' data-video-id=a></article><article class='card' data-video-id=b></article></div>");
const originalHTMLElement = globalThis.HTMLElement;
const originalIntersectionObserver = globalThis.IntersectionObserver;
globalThis.HTMLElement = dom.window.HTMLElement;
let disconnects = 0;
globalThis.IntersectionObserver = class {
observe() {}
takeRecords() { return []; }
disconnect() { disconnects += 1; }
};
const unregistered = [];
try {
const observer = createCardObserver({
onCardUnregister: ({ card }) => unregistered.push(card.dataset.videoId),
});
const grid = dom.window.document.getElementById("grid");
observer.observe(grid);
observer.disconnect(grid);
assert.deepEqual(unregistered.sort(), ["a", "b"]);
assert.equal(disconnects, 1);
assert.equal(observer.getState(grid), null);
} finally {
dom.window.close();
if (originalHTMLElement === undefined) delete globalThis.HTMLElement;
else globalThis.HTMLElement = originalHTMLElement;
if (originalIntersectionObserver === undefined) delete globalThis.IntersectionObserver;
else globalThis.IntersectionObserver = originalIntersectionObserver;
}
});
+17 -4
View File
@@ -13,12 +13,20 @@ test.describe("runtime utility module coverage", () => {
"/js/search/searchFilters.js"
);
// 9afbc0cb ("Decode npub authors to hex") made the author filter validate
// its value, because npub-form authors silently matched nothing. The old
// fixture used `author:abc123`, which is neither a decodable npub nor
// 64-char hex, so the filter is now (correctly) rejected and
// authorPubkeys comes back empty. Use a real pubkey to exercise the
// success path, and pin the rejection separately below.
const AUTHOR_HEX = "a".repeat(64);
const parsed = parseFilterQuery(
'author:abc123 tag:#music,nostr kind:30078 relay:wss://relay.example after:2024-01-02 before:1707000000 duration:>=1.5m has:magnet nsfw:safe "exact phrase" looseTerm',
`author:${AUTHOR_HEX} tag:#music,nostr kind:30078 relay:wss://relay.example after:2024-01-02 before:1707000000 duration:>=1.5m has:magnet nsfw:safe "exact phrase" looseTerm`,
);
const withErrors = parseFilterQuery(
"duration:oops has:bad unknown:value before:not-a-date",
"duration:oops has:bad unknown:value before:not-a-date author:abc123",
);
const serialized = serializeFiltersToQuery(parsed.filters);
@@ -30,7 +38,7 @@ test.describe("runtime utility module coverage", () => {
};
});
expect(result.parsed.filters.authorPubkeys).toEqual(["abc123"]);
expect(result.parsed.filters.authorPubkeys).toEqual(["a".repeat(64)]);
expect(result.parsed.filters.tags).toEqual(["music", "nostr"]);
expect(result.parsed.filters.kind).toBe(30078);
expect(result.parsed.filters.relay).toBe("wss://relay.example");
@@ -44,8 +52,13 @@ test.describe("runtime utility module coverage", () => {
expect(result.withErrors.errors.some((e: any) => /Duration/.test(e.message))).toBe(true);
expect(result.withErrors.errors.some((e: any) => /Has filter/.test(e.message))).toBe(true);
expect(result.withErrors.errors.some((e: any) => /Date value/.test(e.message))).toBe(true);
// An undecodable author must be reported rather than silently dropped.
expect(
result.withErrors.errors.some((e: any) => /Could not decode author/.test(e.message)),
).toBe(true);
expect(result.withErrors.filters.authorPubkeys).toEqual([]);
expect(result.serialized).toContain("author:abc123");
expect(result.serialized).toContain(`author:${"a".repeat(64)}`);
expect(result.serialized).toContain("tag:music");
expect(result.serialized).toContain("kind:30078");
expect(result.serialized).toContain("has:magnet");
+20 -2
View File
@@ -491,7 +491,21 @@ test.describe("nostr runtime coverage (deterministic)", () => {
createdAt: 42,
}),
);
// A legacy plaintext entry must be PURGED, not honored. readV1Entry()
// removes it from storage; readStoredSessionActorEntry() then falls back
// to the sole remaining (encrypted) account, so the old `=== null`
// assertion no longer describes the security property -- it just happened
// to hold before 8692dac6 added per-account storage. Assert the property
// directly instead: no plaintext key survives, in the returned entry or
// in raw storage.
const migratedLegacyEntry = readStoredSessionActorEntry();
const legacyRaw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY);
let legacyRawPlaintextSurvives = false;
try {
legacyRawPlaintextSurvives = Boolean(legacyRaw && JSON.parse(legacyRaw)?.privateKey);
} catch (error) {
legacyRawPlaintextSurvives = false;
}
const actorMatch = isSessionActor({
pubkey: actor,
@@ -524,7 +538,10 @@ test.describe("nostr runtime coverage (deterministic)", () => {
wrongPassphraseErrorCode,
metadataValid: metadata && metadata.salt === encrypted.salt,
metadataInvalidIsNull: invalidMetadata === null,
migratedLegacyEntryIsNull: migratedLegacyEntry === null,
migratedLegacyEntryHasPlaintextKey:
typeof (migratedLegacyEntry as any)?.privateKey === "string" &&
(migratedLegacyEntry as any).privateKey.length > 0,
legacyRawPlaintextSurvives,
actorMatch,
actorMismatch,
nsecActor,
@@ -541,7 +558,8 @@ test.describe("nostr runtime coverage (deterministic)", () => {
expect(result.wrongPassphraseErrorCode).toBe("decrypt-failed");
expect(result.metadataValid).toBeTruthy();
expect(result.metadataInvalidIsNull).toBe(true);
expect(result.migratedLegacyEntryIsNull).toBe(true);
expect(result.migratedLegacyEntryHasPlaintextKey).toBe(false);
expect(result.legacyRawPlaintextSurvives).toBe(false);
expect(result.actorMatch).toBe(true);
expect(result.actorMismatch).toBe(false);
expect(result.nsecActor).toBe(false);
+10 -2
View File
@@ -211,8 +211,16 @@ test.describe("component modal pages", () => {
waitUntil: "networkidle",
});
const modal = page.locator(".bv-modal");
await expect(modal).toHaveCount(1);
// A component page may legitimately ship more than one modal root:
// profile-modal.html also bundles #storageCorsModal, the B2/S3 CORS setup
// helper added in d7ad1516. Asserting exactly one was incidental to what
// this test is actually for -- "the page renders a modal that opens and
// closes" -- so require at least one and drive the primary one, which is
// what the injected script's document.querySelector(".bv-modal") already
// operates on.
const modals = page.locator(".bv-modal");
expect(await modals.count()).toBeGreaterThan(0);
const modal = modals.first();
// Inject logic to initialize the modal since we are viewing a partial/component in isolation.
await page.addScriptTag({
+29 -3
View File
@@ -29,6 +29,31 @@ test.describe("Webseed Stream Playback", () => {
});
test("plays directly from HTML link (CDN mode)", async ({ page }) => {
// Record every src the player attaches. Asserting the FINAL src is wrong:
// this test already tolerates "No playable source found" in its status
// check (headless CI often cannot decode the archive.org H.264 sample), and
// when the attempt fails resetVideoElement() clears src back to "". The
// behaviour under test is that CDN mode ATTACHES the hosted URL, which is
// observable regardless of whether the remote file then decodes.
await page.evaluate(() => {
(window as any).__attachedSrcs = [];
new MutationObserver((mutations) => {
for (const mutation of mutations) {
const target = mutation.target as HTMLElement;
if (mutation.attributeName === "src" && target.tagName === "VIDEO") {
const value = target.getAttribute("src");
if (value) {
(window as any).__attachedSrcs.push(value);
}
}
}
}).observe(document.body, {
subtree: true,
attributes: true,
attributeFilter: ["src"],
});
});
// 1. Trigger playback with just the URL
await page.evaluate(async (url) => {
const { getApplication } = await import("/js/applicationContext.js");
@@ -55,9 +80,10 @@ test.describe("Webseed Stream Playback", () => {
const urlToggle = page.locator('[data-source-toggle="url"]');
await expect(urlToggle).toHaveAttribute("aria-pressed", "true");
// Check if video is attempting to play from source
const video = page.locator("#modalVideo");
await expect(video).toHaveAttribute("src", VIDEO_URL);
// Check the player attached the hosted URL (see the observer above).
await expect
.poll(async () => await page.evaluate(() => (window as any).__attachedSrcs))
.toContain(VIDEO_URL);
});
test("falls back to WebTorrent using HTML link as webseed when CDN fails", async ({ page }) => {
@@ -6,6 +6,7 @@ import {
getDefaultModerationSettings,
setModerationOverride,
clearModerationOverride,
clearAuthorModerationOverride,
} from "../../js/state/cache.js";
import { userBlocks } from "../../js/userBlocks.js";
import { HEX64_REGEX } from "../../js/utils/hex.js";
@@ -453,6 +454,7 @@ export async function createModerationAppHarness(options = {}) {
userBlocks: app.userBlocks,
setModerationOverride,
clearModerationOverride,
clearAuthorModerationOverride,
},
auth: {
isLoggedIn: () => app.isUserLoggedIn(),
+187
View File
@@ -0,0 +1,187 @@
// Addressable pointers to a video's NIP-71 mirror — see js/nostr/mirrorPointer.js.
//
// SCN-mirror-quote: a share note embeds `nostr:naddr1…` so nostr clients render
// a native video quote card. The pointer MUST address the event that actually
// exists: a pointer to a missing or wrong-kind event renders as a broken/empty
// quote box, which is strictly worse than the plain-link fallback.
import test from "node:test";
import assert from "node:assert/strict";
import {
NIP71_NORMAL_VIDEO_KIND,
NIP71_SHORT_VIDEO_KIND,
buildMirrorCoordinate,
buildMirrorNaddr,
resolveMirrorKind,
} from "../js/nostr/mirrorPointer.js";
const PUBKEY = "b7c6f6915cfa9a62fff6a1f02604de88c23c6c6c6d1b8f62c7cc10749f307e81";
const ROOT = "b52ab495-ac9c-4a29-a454-aff142a43691";
// Records what it was asked to encode so the tests can assert on inputs rather
// than on bech32 output.
const makeNip19 = () => {
const calls = [];
return {
calls,
naddrEncode(pointer) {
calls.push(pointer);
return `naddr1${pointer.kind}${pointer.identifier}`;
},
};
};
// --- kind resolution ------------------------------------------------------
test("observed relay kinds win over the dimension heuristic", () => {
// Ground truth from nip71MirrorService.findMirror must beat any guess —
// a portrait video whose mirror was published as 34235 is still 34235.
assert.equal(
resolveMirrorKind({ kinds: [NIP71_NORMAL_VIDEO_KIND], width: 720, height: 1280 }),
NIP71_NORMAL_VIDEO_KIND
);
assert.equal(
resolveMirrorKind({ kinds: [NIP71_SHORT_VIDEO_KIND], width: 1920, height: 1080 }),
NIP71_SHORT_VIDEO_KIND
);
});
test("without observed kinds it mirrors nip71Mirror.js's portrait rule", () => {
// nip71Mirror.js:179 — `hasDims && height > width` selects the short kind.
assert.equal(
resolveMirrorKind({ width: 720, height: 1280 }),
NIP71_SHORT_VIDEO_KIND
);
assert.equal(
resolveMirrorKind({ width: 1280, height: 720 }),
NIP71_NORMAL_VIDEO_KIND
);
// Square is not portrait.
assert.equal(
resolveMirrorKind({ width: 1000, height: 1000 }),
NIP71_NORMAL_VIDEO_KIND
);
});
test("missing or nonsensical dimensions default to the normal kind", () => {
for (const dims of [
{},
{ width: 0, height: 0 },
{ width: NaN, height: 10 },
{ width: "wide", height: "tall" },
{ width: -100, height: -200 },
]) {
assert.equal(resolveMirrorKind(dims), NIP71_NORMAL_VIDEO_KIND);
}
assert.equal(resolveMirrorKind(), NIP71_NORMAL_VIDEO_KIND);
});
test("a cross-kind duplicate resolves deterministically to the normal kind", () => {
// nip71MirrorService flags this as `duplicate`. Picking arbitrarily would
// make the shared pointer unstable between shares of the same video.
assert.equal(
resolveMirrorKind({ kinds: [NIP71_SHORT_VIDEO_KIND, NIP71_NORMAL_VIDEO_KIND] }),
NIP71_NORMAL_VIDEO_KIND
);
});
test("unknown kinds are ignored rather than pointed at", () => {
assert.equal(
resolveMirrorKind({ kinds: [1, 30078], width: 720, height: 1280 }),
NIP71_SHORT_VIDEO_KIND,
"falls through to the heuristic instead of addressing kind 1"
);
});
// --- coordinate -----------------------------------------------------------
test("buildMirrorCoordinate produces a kind:pubkey:d address", () => {
assert.equal(
buildMirrorCoordinate({ pubkey: PUBKEY, videoRootId: ROOT, kind: 34235 }),
`34235:${PUBKEY}:${ROOT}`
);
});
test("buildMirrorCoordinate refuses incomplete input", () => {
assert.equal(buildMirrorCoordinate({ videoRootId: ROOT, kind: 34235 }), "");
assert.equal(buildMirrorCoordinate({ pubkey: PUBKEY, kind: 34235 }), "");
assert.equal(buildMirrorCoordinate({ pubkey: PUBKEY, videoRootId: ROOT }), "");
assert.equal(buildMirrorCoordinate({ pubkey: PUBKEY, videoRootId: ROOT, kind: 0 }), "");
assert.equal(buildMirrorCoordinate(), "");
});
// --- naddr ----------------------------------------------------------------
test("buildMirrorNaddr encodes kind, author, identifier and relay hints", () => {
const nip19 = makeNip19();
const naddr = buildMirrorNaddr({
pubkey: PUBKEY,
videoRootId: ROOT,
kind: NIP71_NORMAL_VIDEO_KIND,
relays: ["wss://relay.damus.io", "wss://nos.lol"],
nip19,
});
assert.equal(naddr.startsWith("naddr1"), true);
assert.deepEqual(nip19.calls[0], {
kind: NIP71_NORMAL_VIDEO_KIND,
pubkey: PUBKEY,
identifier: ROOT,
relays: ["wss://relay.damus.io", "wss://nos.lol"],
});
});
test("relay hints are filtered to wss/ws and capped at three", () => {
const nip19 = makeNip19();
buildMirrorNaddr({
pubkey: PUBKEY,
videoRootId: ROOT,
kind: 34235,
relays: [
"https://not-a-relay.example",
"wss://a.example",
"",
"wss://b.example",
"ws://c.example",
"wss://d.example",
null,
],
nip19,
});
assert.deepEqual(nip19.calls[0].relays, [
"wss://a.example",
"wss://b.example",
"ws://c.example",
]);
});
test("buildMirrorNaddr returns empty rather than a malformed pointer", () => {
const nip19 = makeNip19();
const base = { pubkey: PUBKEY, videoRootId: ROOT, kind: 34235, nip19 };
assert.equal(buildMirrorNaddr({ ...base, pubkey: "" }), "");
assert.equal(buildMirrorNaddr({ ...base, videoRootId: "" }), "");
assert.equal(buildMirrorNaddr({ ...base, kind: 0 }), "");
assert.equal(buildMirrorNaddr({ ...base, nip19: null }), "");
assert.equal(buildMirrorNaddr({ ...base, nip19: {} }), "");
assert.equal(buildMirrorNaddr(), "");
assert.equal(nip19.calls.length, 0, "never attempted an encode");
});
test("an encoder that throws yields empty, never a broken quote", () => {
const hostile = {
naddrEncode() {
throw new Error("bad pointer");
},
};
assert.equal(
buildMirrorNaddr({
pubkey: PUBKEY,
videoRootId: ROOT,
kind: 34235,
nip19: hostile,
}),
""
);
});
+20 -6
View File
@@ -92,18 +92,32 @@ test("trusted report summaries respect personal blocks and admin lists", async (
service.recomputeSummaryForEvent(eventId);
// 18e08bf2 ("refine trust controls") removed `status.whitelisted ||` from
// isTrustedReporter and updated the doc comment from "trustedContacts set (or
// Admin Whitelist)" to "trustedContacts set". Being on the admin whitelist is
// permission to publish, not a statement that your reports carry weight, so a
// whitelisted reporter now counts toward the report TOTAL but not toward
// TRUSTED.
//
// Counted reporters: trusted (t=100), whitelisted (t=400), random (t=300).
// Excluded entirely: blacklisted (admin list) and blocked (personal block).
const summary = service.getTrustedReportSummary(eventId);
assert.equal(summary.totalTrusted, 2);
assert.equal(summary.types.nudity.total, 3);
assert.equal(summary.types.nudity.trusted, 2);
assert.equal(summary.types.nudity.latest, 400);
assert.equal(summary.totalTrusted, 1, "only the trusted contact is trusted");
assert.equal(summary.types.nudity.total, 3, "whitelisted still counts as a report");
assert.equal(summary.types.nudity.trusted, 1);
assert.equal(
summary.types.nudity.latest,
400,
"latest still reflects the whitelisted reporter's timestamp"
);
const reporters = service.getTrustedReporters(eventId, "nudity");
assert.deepEqual(
reporters.map((entry) => entry.pubkey),
[whitelistedHex, trustedHex]
[trustedHex],
"the whitelisted reporter is no longer surfaced as a trusted reporter"
);
assert.equal(service.trustedReportCount(eventId, "nudity"), 2);
assert.equal(service.trustedReportCount(eventId, "nudity"), 1);
});
test("user block updates recompute summaries and emit notifications", async (t) => {
+7
View File
@@ -169,6 +169,13 @@ test("moderation stage annotates trusted mute metadata", async () => {
assert.equal(mutedItem.video.moderation.trustedMuteCount, 1);
assert.equal(mutedItem.metadata.moderation.trustedMuted, true);
assert.equal(mutedItem.metadata.moderation.trustedMuteCount, 1);
assert.equal(mutedItem.video.moderation.blockAutoplay, false);
assert.equal(mutedItem.video.moderation.blurThumbnail, false);
assert.equal(
mutedItem.video.moderation.blurReason,
undefined,
"a single trusted mute downranks without visibly flagging the creator",
);
const trustedMuteReason = reasons.find((entry) => entry.reason === "trusted-mute");
assert.ok(trustedMuteReason);
+10 -18
View File
@@ -108,6 +108,7 @@ test("VideoCard renders moderation badges and respects viewer override", async (
withMockedNostrTools(t);
const app = await createModerationAppHarness();
app.moderationDecorator.updateSettings({ trustedSpamHideThreshold: 3 });
const videoId = "a".repeat(64);
const video = {
@@ -204,6 +205,7 @@ test(
withMockedNostrTools(t);
const app = await createModerationAppHarness();
app.moderationDecorator.updateSettings({ trustedSpamHideThreshold: 3 });
const videoId = "b".repeat(64);
const video = {
@@ -336,7 +338,7 @@ test("VideoCard blurs viewer-muted creators", async (t) => {
assert.equal(card.moderationBadgeEl.dataset.moderationState, "blocked");
});
test("VideoCard blurs thumbnails when trusted mute triggers without reports", async (t) => {
test("VideoCard keeps a single trusted mute as a non-blocking ranking signal", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
@@ -380,26 +382,16 @@ test("VideoCard blurs thumbnails when trusted mute triggers without reports", as
document.body.appendChild(card.getRoot());
assert.equal(video.moderation.blurThumbnail, true);
assert.equal(video.moderation.blurReason, "trusted-mute");
assert.equal(video.moderation.blurThumbnail, false);
assert.equal(video.moderation.blurReason, "");
assert.equal(
normalizeVideoModerationContext(card.video?.moderation).activeBlur,
true,
false,
);
assert.equal(card.thumbnailEl.dataset.thumbnailState, "blurred");
assert.equal(card.authorPicEl.dataset.visualState, "blurred");
assert.equal(card.getRoot().dataset.autoplayPolicy, "blocked");
assert.equal(card.moderationBadgeEl.dataset.moderationState, "trusted-mute");
assert.equal(
card.moderationBadgeTextEl.textContent,
"Content or user blocked by a trusted contact.",
);
assert.ok(card.moderationBlockButton);
assert.equal(
card.moderationBlockButton?.dataset?.moderationAction,
"block",
);
assert.equal(card.moderationBlockButton.textContent, "Block");
assert.equal(card.thumbnailEl.dataset.thumbnailState, undefined);
assert.equal(card.authorPicEl.dataset.visualState, undefined);
assert.equal(card.getRoot().dataset.autoplayPolicy, undefined);
assert.equal(card.moderationBadgeEl, null);
});
test("VideoCard block action restores trusted mute hide state after override", async (t) => {
+212
View File
@@ -0,0 +1,212 @@
// SignerManager lifecycle scoping for NIP-46 multi-account (pre-launch TODO #33).
//
// The 2026-06-25 audit blamed "logout logs out everyone" on the single shared
// nip46Client being torn down by any scoped logout. Since then the layers under
// it were fixed piecemeal (per-account v2 session map, scoped clears, targeted
// restore) — but nothing pinned the MANAGER-level contract, so any of those
// fixes could regress silently. These tests exercise the real SignerManager
// against the real signer registry and real (polyfilled) storage.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-nip46-logout-scope
// given: "account A active with a live remote-signer client; A and B both have stored sessions; B has a registered signer"
// when: "SignerManager.logout() logs out A"
// then: "A's live client is destroyed exactly once; BOTH stored sessions survive; B's registry signer survives"
// - id: SCN-nip46-restore-failure-scope
// given: "stored sessions for A and B"
// when: "a targeted restore of A fails (locked session / missing session)"
// then: "the failure is typed and NO stored session is deleted"
// observable_outcomes:
// - "fake client's destroy() call count"
// - "readStoredNip46Session(pubkey) after the operation"
// - "resolveActiveSigner(pubkey) for the OTHER account"
// determinism_controls:
// - "in-memory localStorage polyfill; fake in-process client object; no network"
// anti_cheat_rationale:
// prevents: ["over-mocking internal logic", "hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test, { beforeEach } from "node:test";
import { strict as assert } from "node:assert";
import { SignerManager } from "../js/nostr/managers/SignerManager.js";
import {
writeStoredNip46SessionSync,
readStoredNip46Session,
} from "../js/nostr/nip46Client.js";
import {
registerSigner,
setActiveSigner,
clearActiveSigner,
resolveActiveSigner,
} from "../js/nostrClientRegistry.js";
const USER_A =
"00000000000000000000000000000000000000000000000000000000000000a1";
const USER_B =
"00000000000000000000000000000000000000000000000000000000000000b2";
function storedSession(userPubkey, remotePubkey) {
return {
version: 1,
clientPublicKey:
"3333333333333333333333333333333333333333333333333333333333333333",
remotePubkey,
relays: ["wss://relay.example"],
encryption: "nip44.v2",
userPubkey,
lastConnectedAt: 1700000000000,
// Sessions at rest carry encrypted secrets; restoring without the
// passphrase must fail typed, not fall through to a broken connect.
encryptedSecrets: "cipher",
keyEncryption: {
salt: "c2FsdA==",
iv: "aXY=",
iterations: 1000,
hash: "SHA-256",
},
};
}
function makeManager() {
// SignerManager only needs the app client for pool access on connect paths;
// the scoping contracts under test never reach the network.
return new SignerManager({
relays: ["wss://relay.example"],
ensurePool: async () => {
throw new Error("network must not be touched by these tests");
},
});
}
beforeEach(() => {
localStorage.clear();
clearActiveSigner();
});
test("logging out the active NIP-46 account keeps every other account intact", () => {
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
// B is a signed-in account in the registry (e.g. logged in earlier this
// session); A is the ACTIVE account with the live remote-signer client.
const signerB = { pubkey: USER_B, signEvent: async (e) => e };
registerSigner(USER_B, signerB);
let destroyCalls = 0;
const liveClient = {
remotePubkey: "1".repeat(64),
userPubkey: USER_A,
destroy() {
destroyCalls += 1;
},
};
const manager = makeManager();
manager.pubkey = USER_A;
manager.nip46Client = liveClient;
manager.logout();
// The departing account's LIVE connection is torn down exactly once…
assert.equal(destroyCalls, 1);
assert.equal(manager.nip46Client, null);
assert.equal(manager.pubkey, null);
// …but its stored session survives (logout keeps sessions reconnectable —
// that is what makes "switch back later" possible)…
const storedA = readStoredNip46Session(USER_A);
assert.ok(storedA, "A's stored session must survive A's own logout");
assert.equal(storedA.remotePubkey, "1".repeat(64));
// …and account B is completely untouched: stored session AND registry signer.
const storedB = readStoredNip46Session(USER_B);
assert.ok(storedB, "B's stored session must survive A's logout");
assert.equal(storedB.remotePubkey, "2".repeat(64));
assert.equal(
resolveActiveSigner(USER_B),
signerB,
"B's registered signer must survive A's logout"
);
});
test("logout is safe when no remote client is live (nsec/nip07 active)", () => {
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
const manager = makeManager();
manager.pubkey = USER_A; // active via some non-NIP-46 method
assert.doesNotThrow(() => manager.logout());
assert.ok(
readStoredNip46Session(USER_B),
"an unrelated account's NIP-46 session must survive a non-NIP-46 logout"
);
});
test("a failed targeted restore deletes nothing", async () => {
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
const manager = makeManager();
// A's session is passphrase-locked and none was provided: typed failure.
await assert.rejects(
manager.useStoredRemoteSigner({ pubkey: USER_A, silent: true }),
(error) => error?.code === "passphrase-required"
);
assert.ok(readStoredNip46Session(USER_A), "A's session survives its own failed restore");
assert.ok(readStoredNip46Session(USER_B), "B's session survives A's failed restore");
});
test("restoring an account with no stored session fails typed and touches nothing", async () => {
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
const manager = makeManager();
await assert.rejects(
manager.useStoredRemoteSigner({ pubkey: USER_A, silent: true }),
(error) => error?.code === "no-stored-session"
);
assert.ok(
readStoredNip46Session(USER_B),
"an unrelated stored session must survive a missing-session restore attempt"
);
});
test("disconnectRemoteSigner({keepStored:true}) never clears stored sessions", async () => {
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
const manager = makeManager();
manager.nip46Client = { destroy() {} };
await manager.disconnectRemoteSigner({ keepStored: true });
assert.ok(readStoredNip46Session(USER_A));
});
test("disconnectRemoteSigner() without keepStored clears only the last-connected default", async () => {
// The no-arg clear historically wiped the whole v2 map — "after switching
// away you could no longer switch back". Pin the scoped behavior: the v1
// default slot goes, the per-account map survives.
writeStoredNip46SessionSync(storedSession(USER_A, "1".repeat(64)));
writeStoredNip46SessionSync(storedSession(USER_B, "2".repeat(64)));
const manager = makeManager();
manager.nip46Client = { destroy() {} };
await manager.disconnectRemoteSigner();
assert.ok(
readStoredNip46Session(USER_A),
"per-account session A must survive a bare disconnect"
);
assert.ok(
readStoredNip46Session(USER_B),
"per-account session B must survive a bare disconnect"
);
});
+23
View File
@@ -20,6 +20,7 @@ function makeHarness({ whitelistOn = false, whitelist = [] } = {}) {
const allEvents = new Map();
let capturedFilters = null;
let onEvent = null;
let onEose = null;
let closed = false;
let whitelistListener = null;
@@ -27,6 +28,7 @@ function makeHarness({ whitelistOn = false, whitelist = [] } = {}) {
subscribe(opts) {
capturedFilters = opts.filters;
onEvent = opts.onEvent;
onEose = opts.onEose;
return {
close() {
closed = true;
@@ -81,6 +83,7 @@ function makeHarness({ whitelistOn = false, whitelist = [] } = {}) {
return closed;
},
fireEvent: (e) => onEvent?.(e),
triggerEose: () => onEose?.(),
triggerWhitelistChange: () => whitelistListener?.(),
triggerFeedReady: () => feedListeners.forEach((fn) => fn({})),
nostrClient,
@@ -263,6 +266,26 @@ test("startWhenFeedReady falls back to starting if the feed never emits", async
svc.stop();
});
test("initial NIP-71 sync keeps every event but emits one consolidated feed update", () => {
const h = makeHarness();
const svc = makeService(h, { feedReadyFallbackMs: 100000, initialSyncFallbackMs: 100000 });
svc.startWhenFeedReady();
h.triggerFeedReady();
h.fireEvent(foreignEvent({ id: "a", kind: 34235, tags: [["d", "ra"], ["title", "A"], ["imeta", "url https://e/a.mp4", "m video/mp4"]] }));
h.fireEvent(foreignEvent({ id: "b", kind: 34235, tags: [["d", "rb"], ["title", "B"], ["imeta", "url https://e/b.mp4", "m video/mp4"]] }));
svc.flush();
assert.ok(h.activeMap.has("ROOT:ra") && h.activeMap.has("ROOT:rb"), "all initial NIP-71 events are retained");
assert.equal(h.emitted.filter((e) => e.name === "videos:updated").length, 0, "no partial-startup repaint");
h.triggerEose();
const refreshes = h.emitted.filter((e) => e.name === "videos:updated");
assert.equal(refreshes.length, 1, "one consolidated update at initial sync completion");
assert.equal(refreshes[0].detail.reason, "nip71-ingest");
svc.stop();
});
test("coalesces refresh signals so an event burst can't storm the feed", () => {
const h = makeHarness();
const svc = makeService(h, { refreshThrottleMs: 10000 });
+19 -4
View File
@@ -111,21 +111,36 @@ class FakeRelayPool {
}
async function configureNostrTools() {
const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
const previousCanonicalDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
"__BITVID_CANONICAL_NOSTR_TOOLS__",
);
const previousNostrTools = globalThis.NostrTools;
const previousReady = globalThis.nostrToolsReady;
const nostrTools = await import("nostr-tools");
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = nostrTools;
// nostrToolsBootstrap deliberately exposes this value as read-only. Test
// setup must replace its *configurable descriptor*, rather than assigning to
// the property and failing after another test/import initialized bootstrap.
Object.defineProperty(globalThis, "__BITVID_CANONICAL_NOSTR_TOOLS__", {
configurable: true,
enumerable: false,
writable: true,
value: nostrTools,
});
globalThis.NostrTools = nostrTools;
globalThis.nostrToolsReady = Promise.resolve({ ok: true, value: nostrTools });
const restore = () => {
if (previousCanonical === undefined) {
if (!previousCanonicalDescriptor) {
delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
} else {
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical;
Object.defineProperty(
globalThis,
"__BITVID_CANONICAL_NOSTR_TOOLS__",
previousCanonicalDescriptor,
);
}
if (previousNostrTools === undefined) {
+426
View File
@@ -0,0 +1,426 @@
// Per-video OpenGraph rendering for crawlers — see api/_lib/ogRenderer.mjs.
//
// Two things here are security-relevant and get the most attention:
// 1. Escaping. Titles/descriptions come from arbitrary nostr events, and this
// HTML is served from bitvid's own origin.
// 2. Gating. A crawler never authenticates, so anything emitted is public
// forever in third-party caches. Deleted/private/NSFW/non-whitelisted
// videos must fall back to the generic site card.
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import {
CRAWLER_USER_AGENT_PATTERN,
escapeHtmlAttribute,
isCrawlerUserAgent,
isPubliclyShareable,
normalizeText,
parseVideoEvent,
renderOgHtml,
sanitizeAbsoluteUrl,
} from "../api/_lib/ogRenderer.mjs";
const PUBKEY = "b7c6f6915cfa9a62fff6a1f02604de88c23c6c6c6d1b8f62c7cc10749f307e81";
const publicVideo = () => ({
pubkey: PUBKEY,
title: "Why I'm (sort of) not worried about AI",
description: "A talk about creativity and machines.",
thumbnail: "https://cdn.example/thumb.jpg",
deleted: false,
isPrivate: false,
isNsfw: false,
invalid: false,
});
const FALLBACK = {
siteName: "bitvid",
title: "bitvid - Decentralized Video Sharing",
description: "Decentralized video sharing on Nostr.",
image: "https://bitvid.network/assets/jpg/bitvid.jpg",
url: "https://bitvid.network",
};
const render = (video, extra = {}) =>
renderOgHtml({
video,
shareUrl: "https://bitvid.network/?v=nevent1abc",
embedUrl: "https://bitvid.network/embed.html?pointer=nevent1abc",
fallback: FALLBACK,
gate: isPubliclyShareable(video, extra.gateOptions || {}),
...extra.render,
});
// --- escaping -------------------------------------------------------------
test("escapeHtmlAttribute neutralizes attribute-breaking characters", () => {
assert.equal(
escapeHtmlAttribute(`"><script>alert(1)</script>`),
"&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;"
);
assert.equal(escapeHtmlAttribute("a & b"), "a &amp; b");
assert.equal(escapeHtmlAttribute("it's"), "it&#39;s");
assert.equal(escapeHtmlAttribute(null), "");
assert.equal(escapeHtmlAttribute(42), "");
});
test("a hostile title cannot break out of the meta tag", () => {
const video = publicVideo();
video.title = `"><script>alert(document.domain)</script><meta x="`;
const html = render(video);
assert.equal(html.includes("<script>"), false, "no raw script tag");
assert.equal(html.includes(`"><script`), false, "no attribute breakout");
assert.match(html, /&lt;script&gt;/);
});
test("a hostile description is escaped too", () => {
const video = publicVideo();
video.description = `" onload="alert(1)`;
const html = render(video);
assert.equal(html.includes(`onload="alert(1)`), false);
assert.match(html, /&quot; onload=&quot;alert\(1\)/);
});
// --- URL sanitization -----------------------------------------------------
test("sanitizeAbsoluteUrl accepts only http(s)", () => {
assert.equal(
sanitizeAbsoluteUrl("https://cdn.example/a.jpg"),
"https://cdn.example/a.jpg"
);
assert.equal(sanitizeAbsoluteUrl("http://cdn.example/a.jpg"), "http://cdn.example/a.jpg");
assert.equal(sanitizeAbsoluteUrl("javascript:alert(1)"), "");
assert.equal(sanitizeAbsoluteUrl("data:image/png;base64,AAAA"), "");
assert.equal(sanitizeAbsoluteUrl("assets/jpg/local.jpg"), "");
assert.equal(sanitizeAbsoluteUrl(""), "");
assert.equal(sanitizeAbsoluteUrl(null), "");
});
test("a javascript: thumbnail never reaches og:image", () => {
const video = publicVideo();
video.thumbnail = "javascript:alert(1)";
const html = render(video);
assert.equal(html.includes("javascript:"), false);
// Falls back to the site image rather than emitting nothing.
assert.match(html, /og:image" content="https:\/\/bitvid\.network\/assets/);
});
// --- text normalization ---------------------------------------------------
test("normalizeText collapses whitespace and truncates with an ellipsis", () => {
assert.equal(normalizeText(" a \n b \t c ", 50), "a b c");
const long = "x".repeat(300);
const out = normalizeText(long, 120);
assert.equal(out.length, 120);
assert.equal(out.endsWith("…"), true);
assert.equal(normalizeText(null, 10), "");
});
// --- gating ---------------------------------------------------------------
test("a clean public video is shareable", () => {
assert.deepEqual(isPubliclyShareable(publicVideo()), {
allowed: true,
reason: "ok",
});
});
test("deleted, private, NSFW and invalid videos are never described", () => {
for (const [field, reason] of [
["deleted", "deleted"],
["isPrivate", "private"],
["isNsfw", "nsfw"],
["invalid", "invalid-video"],
]) {
const video = publicVideo();
video[field] = true;
const result = isPubliclyShareable(video);
assert.equal(result.allowed, false, `${field} must gate the preview`);
assert.equal(result.reason, reason);
}
});
test("a missing video or author is gated", () => {
assert.equal(isPubliclyShareable(null).allowed, false);
assert.equal(isPubliclyShareable(undefined).reason, "missing-video");
const noAuthor = publicVideo();
noAuthor.pubkey = "";
assert.equal(isPubliclyShareable(noAuthor).reason, "missing-author");
});
test("a blacklisted author is gated regardless of whitelist mode", () => {
const result = isPubliclyShareable(publicVideo(), {
blacklist: new Set([PUBKEY]),
});
assert.equal(result.allowed, false);
assert.equal(result.reason, "blacklisted");
});
test("whitelist mode fails CLOSED when the list is missing or empty", () => {
// The dangerous case: relays timed out, so we cannot prove the author is
// allowed. Emitting a rich card here would leak gated metadata.
assert.equal(
isPubliclyShareable(publicVideo(), { whitelistEnabled: true }).reason,
"whitelist-unavailable"
);
assert.equal(
isPubliclyShareable(publicVideo(), {
whitelistEnabled: true,
whitelist: new Set(),
}).reason,
"whitelist-unavailable"
);
});
test("whitelist mode admits a listed author and rejects an unlisted one", () => {
assert.equal(
isPubliclyShareable(publicVideo(), {
whitelistEnabled: true,
whitelist: new Set([PUBKEY]),
}).allowed,
true
);
assert.equal(
isPubliclyShareable(publicVideo(), {
whitelistEnabled: true,
whitelist: new Set(["a".repeat(64)]),
}).reason,
"not-whitelisted"
);
});
test("gated videos render the generic site card, leaking no metadata", () => {
const video = publicVideo();
video.isPrivate = true;
video.title = "Secret internal recording";
video.thumbnail = "https://cdn.example/secret.jpg";
const html = render(video);
assert.equal(html.includes("Secret internal recording"), false);
assert.equal(html.includes("secret.jpg"), false);
assert.match(html, /og:title" content="bitvid - Decentralized Video Sharing"/);
assert.match(html, /og:type" content="website"/);
// No player card for a gated video.
assert.equal(html.includes("twitter:player"), false);
});
// --- rendering ------------------------------------------------------------
test("a public video renders a player card pointing at the embed", () => {
const html = render(publicVideo());
assert.match(html, /og:type" content="video\.other"/);
assert.match(html, /og:title" content="Why I&#39;m \(sort of\) not worried about AI"/);
assert.match(html, /og:image" content="https:\/\/cdn\.example\/thumb\.jpg"/);
assert.match(
html,
/og:video:url" content="https:\/\/bitvid\.network\/embed\.html\?pointer=nevent1abc"/
);
assert.match(html, /twitter:card" content="player"/);
assert.match(
html,
/twitter:player" content="https:\/\/bitvid\.network\/embed\.html\?pointer=nevent1abc"/
);
assert.match(html, /twitter:player:width" content="1280"/);
assert.match(html, /twitter:player:height" content="720"/);
assert.match(html, /<link rel="canonical" href="https:\/\/bitvid\.network\/\?v=nevent1abc"/);
});
test("without an embed URL it degrades to a summary_large_image card", () => {
const html = renderOgHtml({
video: publicVideo(),
shareUrl: "https://bitvid.network/?v=nevent1abc",
fallback: FALLBACK,
gate: { allowed: true, reason: "ok" },
});
assert.match(html, /twitter:card" content="summary_large_image"/);
assert.equal(html.includes("twitter:player"), false);
});
test("the rendered document is well-formed and self-contained", () => {
const html = render(publicVideo());
assert.match(html, /^<!doctype html>/);
assert.match(html, /<\/html>\n$/);
// Crawler stub only — it must not pull in the app or any script.
assert.equal(/<script/i.test(html), false);
});
// --- event parsing --------------------------------------------------------
// bitvid publishes kind 30078 with a v3 JSON body; nostube/slidestr and other
// NIP-71-native clients publish kind 21/22/34235/34236 with the metadata in
// TAGS. Parsing only the JSON shape made every NIP-71 video — including the
// real note this work started from — fall back to the generic card.
test("parseVideoEvent reads the bitvid v3 JSON shape", () => {
const parsed = parseVideoEvent({
kind: 30078,
pubkey: PUBKEY,
content: JSON.stringify({
version: 3,
title: "A bitvid upload",
description: "Body text",
thumbnail: "https://cdn.example/t.jpg",
url: "https://cdn.example/v.mp4",
isNsfw: false,
}),
tags: [],
});
assert.equal(parsed.title, "A bitvid upload");
assert.equal(parsed.description, "Body text");
assert.equal(parsed.thumbnail, "https://cdn.example/t.jpg");
assert.equal(parsed.invalid, false);
assert.equal(isPubliclyShareable(parsed).allowed, true);
});
test("parseVideoEvent reads the NIP-71 tag shape (the real nostube note)", () => {
const parsed = parseVideoEvent({
kind: 34235,
pubkey: PUBKEY,
content: "As a creator, and as a leader of a tech company…",
tags: [
["d", "b52ab495-ac9c-4a29-a454-aff142a43691"],
["title", "Why I'm (sort of) not worried about AI"],
[
"imeta",
"dim 1280x720",
"url https://almond.example/video.m3u8",
"m application/vnd.apple.mpegurl",
"image https://almond.example/thumb.jpg",
"image https://blossom.example/thumb.jpg",
],
["client", "nostube"],
],
});
assert.equal(parsed.title, "Why I'm (sort of) not worried about AI");
assert.equal(parsed.thumbnail, "https://almond.example/thumb.jpg");
assert.match(parsed.description, /^As a creator/);
assert.equal(parsed.invalid, false);
});
test("parseVideoEvent treats a content-warning tag as NSFW", () => {
const parsed = parseVideoEvent({
kind: 34235,
pubkey: PUBKEY,
content: "desc",
tags: [["title", "Sensitive"], ["content-warning", "nudity"]],
});
assert.equal(parsed.isNsfw, true);
assert.equal(isPubliclyShareable(parsed).reason, "nsfw");
});
test("parseVideoEvent rejects untitled and non-video events", () => {
assert.equal(
parseVideoEvent({ kind: 34235, pubkey: PUBKEY, content: "", tags: [] }).invalid,
true
);
// A plain kind-1 note is not a video.
assert.equal(parseVideoEvent({ kind: 1, pubkey: PUBKEY, content: "hi", tags: [] }), null);
assert.equal(parseVideoEvent(null), null);
assert.equal(parseVideoEvent({}), null);
});
test("parseVideoEvent survives malformed JSON content", () => {
// A kind-30078 whose content is truncated/corrupt must not throw.
assert.equal(
parseVideoEvent({ kind: 30078, pubkey: PUBKEY, content: "{not json", tags: [] }),
null
);
});
test("parseVideoEvent ignores non-http imeta image entries", () => {
const parsed = parseVideoEvent({
kind: 34235,
pubkey: PUBKEY,
content: "desc",
tags: [
["title", "T"],
["imeta", "image javascript:alert(1)", "image https://ok.example/t.png"],
],
});
assert.equal(parsed.thumbnail, "https://ok.example/t.png");
});
// --- crawler detection ----------------------------------------------------
test("known preview crawlers are recognized", () => {
for (const ua of [
"facebookexternalhit/1.1",
"Twitterbot/1.0",
"Slackbot-LinkExpanding 1.0",
"Mozilla/5.0 (compatible; Discordbot/2.0)",
"TelegramBot (like TwitterBot)",
"WhatsApp/2.19",
"Mozilla/5.0 (compatible; Googlebot/2.1)",
]) {
assert.equal(isCrawlerUserAgent(ua), true, `${ua} should match`);
}
});
test("real browsers are NOT treated as crawlers", () => {
for (const ua of [
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15",
"",
]) {
assert.equal(isCrawlerUserAgent(ua), false, `${ua} must not match`);
}
assert.equal(isCrawlerUserAgent(null), false);
assert.equal(isCrawlerUserAgent(undefined), false);
});
test("the crawler pattern is not sticky/global (no lastIndex state)", () => {
// A /g regex would alternate true/false across calls via lastIndex — a
// classic bug that would randomly serve crawlers the wrong page.
assert.equal(CRAWLER_USER_AGENT_PATTERN.global, false);
const ua = "Twitterbot/1.0";
assert.equal(isCrawlerUserAgent(ua), true);
assert.equal(isCrawlerUserAgent(ua), true);
assert.equal(isCrawlerUserAgent(ua), true);
});
// --- config drift ---------------------------------------------------------
// api/og.js imports the admin-list identity and relay set from the SAME modules
// the browser uses, rather than keeping its own copies. These assertions fail
// loudly if that ever regresses to duplicated constants, because the failure
// mode otherwise is silent: previews gated forever, or a stale whitelist.
test("api/og.js derives its config from the app, not from copies", async () => {
const source = await readFile(new URL("../api/og.js", import.meta.url), "utf8");
assert.match(source, /from "\.\.\/js\/config\.js"/);
assert.match(source, /from "\.\.\/js\/nostr\/toolkit\.js"/);
assert.match(source, /from "\.\.\/js\/nostrEventSchemas\.js"/);
// No hardcoded npub or wss:// literal may reappear in the handler.
assert.equal(
/npub1[0-9a-z]{20,}/.test(source),
false,
"admin npub must come from js/config.js, not a literal"
);
assert.equal(
/"wss:\/\//.test(source),
false,
"relay URLs must come from js/nostr/toolkit.js, not literals"
);
});
test("the admin list d-tag matches what the browser store queries", async () => {
const { ADMIN_LIST_NAMESPACE } = await import("../js/config.js");
const { ADMIN_LIST_IDENTIFIERS } = await import("../js/nostrEventSchemas.js");
// Pinned against the live list this instance actually publishes; a namespace
// change without a matching relay migration would silently gate everything.
assert.equal(
`${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`,
"bitvid:admin:whitelist"
);
assert.equal(
`${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`,
"bitvid:admin:blacklist"
);
});
+91
View File
@@ -0,0 +1,91 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
initPerformanceHarness,
isLocalPerformanceHarnessEnabled,
PERFORMANCE_HARNESS_STORAGE_KEY,
} from "../js/performanceHarness.js";
function createStorage(value = null) {
return { getItem: (key) => (key === PERFORMANCE_HARNESS_STORAGE_KEY ? value : null) };
}
function createPerformance() {
const marks = [];
return {
now: () => 100,
mark: (name) => marks.push(name),
getEntriesByType: (type) => {
if (type === "resource") {
return [{ transferSize: 12 }, { transferSize: 30 }];
}
if (type === "navigation") {
return [{ domContentLoadedEventEnd: 12, loadEventEnd: 24 }];
}
return [];
},
memory: { usedJSHeapSize: 10, totalJSHeapSize: 20, jsHeapSizeLimit: 30 },
marks,
};
}
test("performance harness stays absent without explicit loopback opt-in", () => {
const scope = {
location: { hostname: "localhost" },
localStorage: createStorage(),
performance: createPerformance(),
};
assert.equal(isLocalPerformanceHarnessEnabled({
locationRef: scope.location,
storage: scope.localStorage,
}), false);
assert.equal(initPerformanceHarness({ scope }), null);
assert.equal(scope.__bitvidPerformance, undefined);
});
test("performance harness cannot be enabled on a non-loopback host", () => {
assert.equal(isLocalPerformanceHarnessEnabled({
locationRef: { hostname: "unstable.bitvid.network" },
storage: createStorage("1"),
}), false);
});
test("explicit loopback opt-in exposes bounded local measurements", () => {
let observerCallback = null;
class FakePerformanceObserver {
constructor(callback) {
observerCallback = callback;
}
observe() {}
disconnect() {}
}
const scope = {
location: { hostname: "127.0.0.1" },
localStorage: createStorage("1"),
performance: createPerformance(),
PerformanceObserver: FakePerformanceObserver,
};
const api = initPerformanceHarness({ scope });
assert.equal(typeof api?.snapshot, "function");
assert.equal(scope.__bitvidPerformance, api);
observerCallback({
getEntries: () => [{ name: "longtask", startTime: 3, duration: 51 }],
});
api.mark("feed-refresh", { feed: "for-you" });
api.setGauge("active-workers", 1);
api.increment("active-workers");
const snapshot = api.snapshot();
assert.equal(snapshot.longTasks.count, 1);
assert.equal(snapshot.longTasks.totalDurationMs, 51);
assert.equal(snapshot.resources.transferSize, 42);
assert.equal(snapshot.gauges["active-workers"], 2);
assert.equal(snapshot.memory.usedJsHeapSize, 10);
assert.ok(snapshot.marks.some((entry) => entry.name === "feed-refresh"));
api.stop();
assert.equal(scope.__bitvidPerformance, undefined);
});
+8
View File
@@ -574,8 +574,16 @@ for (const _ of [0]) {
const menuWrapper = document.querySelector('[data-profile-mobile-menu]');
const paneWrapper = document.querySelector('[data-profile-mobile-pane]');
const backButton = document.getElementById('profileModalBack');
const mobileLockButton = document.getElementById('profileMobileLockNowBtn');
const mobileLogoutButton = document.getElementById('profileMobileLogoutBtn');
const mobileActions = mobileLogoutButton?.closest(
'.profile-modal__mobile-actions',
);
assert.ok(layout && menuWrapper && paneWrapper && backButton);
assert.equal(mobileActions?.getAttribute('aria-label'), 'Session actions');
assert.equal(mobileLockButton?.closest('nav'), null);
assert.equal(mobileLogoutButton?.closest('nav'), null);
assert.equal(layout?.dataset.mobileView, 'menu');
assert.equal(menuWrapper?.getAttribute('aria-hidden'), 'false');
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
const styles = await readFile(
new URL("../css/tailwind.source.css", import.meta.url),
"utf8",
);
test("mobile modal sheets use the dynamic viewport instead of extending behind browser chrome", () => {
assert.match(
styles,
/\.bv-modal__panel,\s*\.modal-sheet\s*\{[^}]*block-size:\s*100dvh;[^}]*min-block-size:\s*100dvh;/s,
);
assert.match(
styles,
/#profileModal\s*\{[^}]*height:\s*100dvh;[^}]*min-height:\s*100dvh;/s,
);
});
test("mobile profile session actions and menu scrolling clear the bottom safe area", () => {
assert.match(
styles,
/\.profile-modal__menu\s*\{[^}]*padding-block-end:\s*max\(var\(--space-lg\),\s*env\(safe-area-inset-bottom\)\);[^}]*scroll-padding-block-end:/s,
);
assert.match(
styles,
/\.profile-modal__mobile-actions\s*\{[^}]*padding-block-end:\s*max\(\s*var\(--space-sm\),\s*env\(safe-area-inset-bottom\)\s*\);/s,
);
assert.match(
styles,
/\.mobile-safe-bottom\s*\{[^}]*bottom:\s*calc\(var\(--space-lg\)\s*\+\s*env\(safe-area-inset-bottom\)\);/s,
);
});
@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ProfilePlaylistsController } from "../js/ui/profileModal/ProfilePlaylistsController.js";
test("playlist refresh renders the list before deferred video metadata completes", async () => {
const controller = new ProfilePlaylistsController({
getActivePubkey: () => "a".repeat(64),
services: {
nostrService: {
fetchVideosByAuthors: () => new Promise(() => {}),
},
},
});
const renders = [];
let resolveMetadata;
controller.listEl = {};
controller.setLoading = () => {};
controller.render = () => renders.push(controller.playlists.map((playlist) => playlist.id));
controller.fetchCreatorPlaylists = async () => [{
id: "existing",
items: [{ type: "a", value: `30078:${"b".repeat(64)}:video` }],
}];
controller.mainController.services.nostrService.fetchVideosByAuthors = () =>
new Promise((resolve) => {
resolveMetadata = resolve;
});
await controller.refresh();
assert.deepEqual(renders, [["existing"]], "list renders before metadata request settles");
resolveMetadata();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(renders, [["existing"], ["existing"]], "metadata completion refreshes labels later");
});
+38
View File
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildReactionFilters } from "../js/reactionCounter.js";
test("addressable video reactions query both address and immutable event references", () => {
const filters = buildReactionFilters(
{
type: "a",
value: "34235:creator:episode-42",
relay: "wss://relay.example",
eventId: "immutable-video-event-id"
},
{ limit: 400, since: 123 }
);
assert.deepEqual(filters, [
{
kinds: [7],
"#a": ["34235:creator:episode-42"],
limit: 400,
since: 123
},
{
kinds: [7],
"#e": ["immutable-video-event-id"],
limit: 400,
since: 123
}
]);
});
test("event-addressed video reactions keep a single immutable event query", () => {
assert.deepEqual(
buildReactionFilters({ type: "e", value: "video-event-id" }),
[{ kinds: [7], "#e": ["video-event-id"] }]
);
});
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import ExploreDataService from "../../js/services/exploreDataService.js";
test("ExploreDataService stays idle until a recommendation feed activates it", async () => {
const service = new ExploreDataService({
historyRefreshIntervalMs: 0,
idfRefreshIntervalMs: 0,
});
let historyRefreshes = 0;
let idfRefreshes = 0;
service.refreshWatchHistoryTagCounts = async () => {
historyRefreshes += 1;
return new Map();
};
service.refreshTagIdf = async () => {
idfRefreshes += 1;
return new Map();
};
await service.initialize();
assert.equal(historyRefreshes, 0);
assert.equal(idfRefreshes, 0);
service.queueWatchHistoryRefresh("note");
service.queueTagIdfRefresh("note");
await service.setActive(true);
assert.equal(historyRefreshes, 1, "dirty history is refreshed on activation");
assert.equal(idfRefreshes, 1, "dirty IDF is refreshed on activation");
await service.setActive(false);
service.queueTagIdfRefresh("inactive-note");
assert.equal(idfRefreshes, 1, "inactive feeds do not rebuild recommendation indexes");
service.destroy();
});
test("ExploreDataService coalesces a refresh burst into one running pass and one follow-up", async () => {
const service = new ExploreDataService({
historyRefreshIntervalMs: 0,
idfRefreshIntervalMs: 0,
});
service.active = true;
let runs = 0;
let releaseFirst;
service._refreshTagIdf = async () => {
runs += 1;
if (runs === 1) {
await new Promise((resolve) => {
releaseFirst = resolve;
});
}
return new Map();
};
const first = service.refreshTagIdf({ force: true, reason: "initial" });
const duplicate = service.refreshTagIdf({ reason: "relay-event" });
service.refreshTagIdf({ reason: "timer" });
assert.equal(runs, 1, "only one rebuild starts during the burst");
releaseFirst();
await Promise.all([first, duplicate]);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(runs, 2, "the burst produces exactly one follow-up rebuild");
service.destroy();
});
+632
View File
@@ -0,0 +1,632 @@
// HLS (.m3u8) support — see js/services/hlsPlayback.js.
//
// Scenario spec (SCN-hls-*): bitvid used to attach every hosted source with
// `videoEl.src = url`, which only plays HLS on Safari. Nostr clients publishing
// bitvid's v3 schema with HLS ladders (nostube/slidestr) were unplayable on
// Chrome/Firefox, and the URL-health probe reported every healthy HLS stream as
// offline. These tests pin the observable decisions at those boundaries:
// detection, engine selection, teardown, and the manifest probe.
//
// The hls.js engine itself is never loaded here — the tests exercise the
// native-HLS branch and the fetch-based manifest probe, which are the two paths
// that do not require a real MediaSource implementation.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
HLS_MIME_TYPES,
attachHlsSource,
detachHls,
getAttachedHls,
isHlsMimeType,
isHlsSource,
isHlsUrl,
prefersNativeHls,
probeHlsManifest,
supportsMse,
supportsNativeHls,
} from "../../js/services/hlsPlayback.js";
// --- SCN-hls-detect -------------------------------------------------------
// Given a hosted URL, when its path ends in .m3u8, then it is treated as HLS —
// regardless of query strings (signed CDN URLs) or fragments.
test("isHlsUrl matches .m3u8 paths, including with query/fragment", () => {
assert.equal(
isHlsUrl(
"https://almond.apps2.slidestr.net/3751b84f27234fc8ce3227d132b422f7e00f4a50c6cdc322080fed89a302d7dd.m3u8"
),
true
);
assert.equal(isHlsUrl("https://cdn.example/v.M3U8"), true);
assert.equal(isHlsUrl("https://cdn.example/v.m3u8?token=abc&exp=1"), true);
assert.equal(isHlsUrl("https://cdn.example/v.m3u8#t=10"), true);
assert.equal(isHlsUrl(" https://cdn.example/v.m3u8 "), true);
});
test("isHlsUrl rejects non-playlist URLs and incidental .m3u8 substrings", () => {
assert.equal(isHlsUrl("https://cdn.example/v.mp4"), false);
assert.equal(isHlsUrl("https://cdn.example/v.webm"), false);
// .m3u8 must terminate the PATH, not merely appear somewhere in the URL.
assert.equal(isHlsUrl("https://cdn.example/v.m3u8.mp4"), false);
assert.equal(isHlsUrl("https://cdn.example/?next=/a.m3u8"), false);
assert.equal(isHlsUrl(""), false);
assert.equal(isHlsUrl(null), false);
assert.equal(isHlsUrl(undefined), false);
assert.equal(isHlsUrl(42), false);
});
test("isHlsMimeType covers the canonical and legacy playlist types", () => {
for (const type of HLS_MIME_TYPES) {
assert.equal(isHlsMimeType(type), true, `${type} should be HLS`);
assert.equal(isHlsMimeType(type.toUpperCase()), true);
}
// Parameters must not defeat the match (servers append charset).
assert.equal(isHlsMimeType("application/vnd.apple.mpegurl; charset=utf-8"), true);
assert.equal(isHlsMimeType("video/mp4"), false);
assert.equal(isHlsMimeType("text/plain"), false);
assert.equal(isHlsMimeType(""), false);
assert.equal(isHlsMimeType(null), false);
});
test("isHlsSource accepts either the URL or an imeta mime hint", () => {
assert.equal(isHlsSource({ url: "https://cdn.example/v.m3u8" }), true);
assert.equal(
isHlsSource({ url: "https://cdn.example/stream", mimeType: "application/vnd.apple.mpegurl" }),
true
);
assert.equal(isHlsSource({ url: "https://cdn.example/v.mp4" }), false);
assert.equal(isHlsSource({}), false);
assert.equal(isHlsSource(), false);
});
// --- SCN-hls-engine-choice ------------------------------------------------
// Given a browser that CLAIMS native HLS support via canPlayType, when MSE is
// also available, then hls.js must win. This is not a preference — it is a
// correctness requirement measured in real browsers:
//
// Firefox 146: canPlayType("application/vnd.apple.mpegurl") === ""
// bare <video> → MEDIA_ERR_SRC_NOT_SUPPORTED (code 4)
// Chrome 149: canPlayType(...) === "maybe" ← and it is genuinely true there
//
// canPlayType is only ever a hint, so the engine choice keys off MSE (what
// hls.js actually needs) and falls back to native only when MSE is absent.
const makeFakeVideo = (canPlayTypeResult = "") => ({
src: "",
canPlayType: () => canPlayTypeResult,
});
// Node has no MediaSource; install/remove a fake to drive supportsMse().
const withMediaSource = async (isTypeSupported, fn) => {
const had = "MediaSource" in globalThis;
const original = globalThis.MediaSource;
globalThis.MediaSource = { isTypeSupported };
try {
return await fn();
} finally {
if (had) {
globalThis.MediaSource = original;
} else {
delete globalThis.MediaSource;
}
}
};
test("supportsNativeHls reflects canPlayType for the playlist types", () => {
assert.equal(supportsNativeHls(makeFakeVideo("maybe")), true);
assert.equal(supportsNativeHls(makeFakeVideo("probably")), true);
assert.equal(supportsNativeHls(makeFakeVideo("")), false);
});
test("supportsNativeHls survives a throwing canPlayType", () => {
const hostile = {
canPlayType() {
throw new Error("nope");
},
};
assert.equal(supportsNativeHls(hostile), false);
});
test("supportsMse tracks MediaSource.isTypeSupported", async () => {
assert.equal(supportsMse(), false, "no MediaSource in this environment");
await withMediaSource(() => true, () => {
assert.equal(supportsMse(), true);
});
await withMediaSource(() => false, () => {
assert.equal(supportsMse(), false);
});
await withMediaSource(() => {
throw new Error("boom");
}, () => {
assert.equal(supportsMse(), false, "a throwing check must not propagate");
});
});
test("prefersNativeHls is false whenever MSE can back hls.js", async () => {
const chromeLike = makeFakeVideo("maybe");
await withMediaSource(() => true, () => {
// Chrome's canPlayType says "maybe" — hls.js must still win.
assert.equal(prefersNativeHls(chromeLike), false);
});
// Only with MSE genuinely gone does native become the choice.
assert.equal(prefersNativeHls(chromeLike), true);
assert.equal(prefersNativeHls(makeFakeVideo("")), false, "neither path works");
});
test("attachHlsSource prefers hls.js over a native claim when MSE exists", async () => {
const video = makeFakeVideo("maybe");
const { engine, instances } = makeFakeEngine();
const handle = await withMediaSource(() => true, () =>
attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
})
);
assert.equal(handle.mode, "mse");
assert.equal(video.src, "", "must not use the native src path");
assert.equal(instances.length, 1);
});
test("attachHlsSource uses native playback when MSE is unavailable", async () => {
const video = makeFakeVideo("maybe");
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8");
assert.ok(handle, "expected a handle");
assert.equal(handle.mode, "native");
assert.equal(video.src, "https://cdn.example/v.m3u8");
// Nothing to destroy on the native path, but destroy() must stay callable.
assert.doesNotThrow(() => handle.destroy());
assert.equal(getAttachedHls(video), null);
});
test("attachHlsSource falls back to native when the engine fails to load", async () => {
const video = makeFakeVideo("maybe");
const handle = await withMediaSource(() => true, () =>
attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => null,
})
);
assert.equal(handle.mode, "native", "an attempt beats a guaranteed failure");
assert.equal(video.src, "https://cdn.example/v.m3u8");
});
test("attachHlsSource trims the URL before assigning it", async () => {
const video = makeFakeVideo("maybe");
await attachHlsSource(video, " https://cdn.example/v.m3u8\n");
assert.equal(video.src, "https://cdn.example/v.m3u8");
});
test("attachHlsSource returns null for missing element or URL", async () => {
assert.equal(await attachHlsSource(null, "https://cdn.example/v.m3u8"), null);
assert.equal(await attachHlsSource(makeFakeVideo("maybe"), ""), null);
assert.equal(await attachHlsSource(makeFakeVideo("maybe"), null), null);
});
// --- MSE test double ------------------------------------------------------
// A minimal stand-in for the hls.js constructor: enough surface for the
// attach/teardown/error contract, with hooks to drive events deterministically.
// `autoManifest` mirrors real hls.js, which parses the manifest asynchronously
// after loadSource(); tests that drive errors turn it off.
const HLS_EVENTS = {
MANIFEST_PARSED: "hlsManifestParsed",
ERROR: "hlsError",
};
const HLS_ERROR_TYPES = {
NETWORK_ERROR: "networkError",
MEDIA_ERROR: "mediaError",
OTHER_ERROR: "otherError",
};
function makeFakeEngine({ autoManifest = true, levels = [{ height: 240 }] } = {}) {
const instances = [];
class FakeHls {
static isSupported() {
return true;
}
static get Events() {
return HLS_EVENTS;
}
static get ErrorTypes() {
return HLS_ERROR_TYPES;
}
constructor(config) {
this.config = config;
this.handlers = new Map();
this.destroyCalls = 0;
this.startLoadCalls = 0;
this.recoverMediaErrorCalls = 0;
this.swapAudioCodecCalls = 0;
this.attachedMedia = null;
this.loadedSource = null;
instances.push(this);
}
on(event, handler) {
if (!this.handlers.has(event)) {
this.handlers.set(event, []);
}
this.handlers.get(event).push(handler);
}
emit(event, data) {
for (const handler of this.handlers.get(event) || []) {
handler(event, data);
}
}
attachMedia(media) {
this.attachedMedia = media;
}
loadSource(url) {
this.loadedSource = url;
if (autoManifest) {
// Real hls.js parses asynchronously; keep that ordering.
queueMicrotask(() => this.emit(HLS_EVENTS.MANIFEST_PARSED, { levels }));
}
}
startLoad() {
this.startLoadCalls += 1;
}
recoverMediaError() {
this.recoverMediaErrorCalls += 1;
}
swapAudioCodec() {
this.swapAudioCodecCalls += 1;
}
destroy() {
this.destroyCalls += 1;
}
}
return { engine: FakeHls, instances };
}
// --- SCN-hls-mse ----------------------------------------------------------
// Given an MSE browser, when a playlist parses with at least one playable
// level, then attach resolves; when hls.js drops every level (e.g. an
// HEVC-only ladder on Firefox) or errors fatally, then attach fails with a
// specific, user-facing message instead of a bare "no playable source".
test("attachHlsSource resolves once the manifest parses, and configures hls.js", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine();
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
});
assert.equal(handle.mode, "mse");
// Native `src` must stay untouched — MSE owns the element now.
assert.equal(video.src, "");
// A bounded back-buffer matters for hour-long VOD on low-memory devices.
assert.equal(instances[0].config.backBufferLength, 90);
assert.equal(instances[0].config.enableWorker, true);
});
test("attachHlsSource fails when no rendition survives codec filtering", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ levels: [] });
const messages = [];
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
onFatalError: (message) => messages.push(message),
});
assert.equal(handle, null);
assert.equal(messages.length, 1);
assert.match(messages[0], /codec your browser supports/i);
assert.equal(instances[0].destroyCalls, 1, "must not leak the instance");
assert.equal(getAttachedHls(video), null);
});
test("attachHlsSource returns null when hls.js is unsupported here", async () => {
const video = makeFakeVideo("");
class Unsupported {
static isSupported() {
return false;
}
}
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => Unsupported,
});
assert.equal(handle, null);
assert.equal(video.src, "", "must not fall back to an unplayable src");
});
test("attachHlsSource returns null when the engine bundle fails to load", async () => {
const video = makeFakeVideo("");
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => null,
});
assert.equal(handle, null);
});
test("a fatal network error retries twice before giving up", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ autoManifest: false });
const messages = [];
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
onFatalError: (message) => messages.push(message),
});
await Promise.resolve();
const instance = instances[0];
const networkError = {
fatal: true,
type: HLS_ERROR_TYPES.NETWORK_ERROR,
details: "manifestLoadError",
};
instance.emit(HLS_EVENTS.ERROR, networkError);
assert.equal(instance.startLoadCalls, 1);
instance.emit(HLS_EVENTS.ERROR, networkError);
assert.equal(instance.startLoadCalls, 2);
// Third fatal network error exhausts the retry budget.
instance.emit(HLS_EVENTS.ERROR, networkError);
assert.equal(instance.startLoadCalls, 2);
assert.equal(await pending, null);
assert.equal(messages.length, 1);
assert.match(messages[0], /network or CORS/i);
assert.equal(instance.destroyCalls, 1);
});
test("a fatal media error recovers, then swaps audio codec, then gives up", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ autoManifest: false });
const messages = [];
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
onFatalError: (message) => messages.push(message),
});
await Promise.resolve();
const instance = instances[0];
const mediaError = {
fatal: true,
type: HLS_ERROR_TYPES.MEDIA_ERROR,
details: "bufferAppendError",
};
instance.emit(HLS_EVENTS.ERROR, mediaError);
assert.equal(instance.recoverMediaErrorCalls, 1);
assert.equal(instance.swapAudioCodecCalls, 0);
instance.emit(HLS_EVENTS.ERROR, mediaError);
assert.equal(instance.recoverMediaErrorCalls, 2);
assert.equal(instance.swapAudioCodecCalls, 1, "second attempt swaps codec");
instance.emit(HLS_EVENTS.ERROR, mediaError);
assert.equal(instance.recoverMediaErrorCalls, 2, "budget exhausted");
assert.equal(await pending, null);
assert.match(messages[0], /codec your browser cannot decode/i);
});
test("non-fatal errors never tear the stream down", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ autoManifest: false });
const messages = [];
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
onFatalError: (message) => messages.push(message),
});
await Promise.resolve();
const instance = instances[0];
instance.emit(HLS_EVENTS.ERROR, {
fatal: false,
type: HLS_ERROR_TYPES.NETWORK_ERROR,
details: "fragLoadError",
});
assert.equal(instance.destroyCalls, 0);
assert.equal(messages.length, 0);
// A transient fragment error must still let the stream come up.
instance.emit(HLS_EVENTS.MANIFEST_PARSED, { levels: [{ height: 720 }] });
const handle = await pending;
assert.equal(handle.mode, "mse");
});
test("a stuck manifest fails via the attach timeout rather than hanging", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ autoManifest: false });
const messages = [];
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
manifestTimeoutMs: 20,
onFatalError: (message) => messages.push(message),
});
assert.equal(handle, null);
assert.match(messages[0], /did not load in time/i);
assert.equal(instances[0].destroyCalls, 1);
});
test("attachMedia throwing is reported, not propagated", async () => {
const video = makeFakeVideo("");
const { engine } = makeFakeEngine({ autoManifest: false });
const messages = [];
class Hostile extends engine {
attachMedia() {
throw new Error("MediaSource unavailable");
}
}
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => Hostile,
onFatalError: (message) => messages.push(message),
});
assert.equal(handle, null);
assert.match(messages[0], /could not be attached/i);
});
// --- SCN-hls-teardown -----------------------------------------------------
// Given an attached hls.js instance, when the modal tears the element down,
// then destroy() runs exactly once and the element is unbound — otherwise the
// transmuxing worker and segment fetches keep running after the modal closes.
test("detachHls destroys the bound hls.js instance exactly once", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine();
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
});
assert.ok(handle, "expected an MSE handle");
assert.equal(handle.mode, "mse");
const instance = instances[0];
assert.equal(getAttachedHls(video), instance);
assert.equal(instance.loadedSource, "https://cdn.example/v.m3u8");
assert.equal(instance.attachedMedia, video);
assert.equal(detachHls(video), true);
assert.equal(instance.destroyCalls, 1);
assert.equal(getAttachedHls(video), null);
// Second detach (e.g. modal teardown after the session already reset) must
// not double-destroy — hls.js throws on a destroyed instance.
assert.equal(detachHls(video), false);
assert.equal(instance.destroyCalls, 1);
});
test("handle.destroy() tears the instance down too", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine();
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
});
handle.destroy();
assert.equal(instances[0].destroyCalls, 1);
assert.equal(getAttachedHls(video), null);
});
test("re-attaching to the same element destroys the previous instance", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine();
await attachHlsSource(video, "https://cdn.example/a.m3u8", {
createEngine: async () => engine,
});
await attachHlsSource(video, "https://cdn.example/b.m3u8", {
createEngine: async () => engine,
});
assert.equal(instances.length, 2);
assert.equal(instances[0].destroyCalls, 1, "first instance must be destroyed");
assert.equal(instances[1].destroyCalls, 0);
assert.equal(instances[1].loadedSource, "https://cdn.example/b.m3u8");
assert.equal(getAttachedHls(video), instances[1]);
});
test("detachHls is a safe no-op for non-HLS elements and null", () => {
assert.equal(detachHls(null), false);
assert.equal(detachHls(undefined), false);
assert.equal(detachHls(makeFakeVideo("")), false);
});
test("getAttachedHls returns null when nothing is bound", () => {
assert.equal(getAttachedHls(makeFakeVideo("")), null);
assert.equal(getAttachedHls(null), null);
});
// --- SCN-hls-probe --------------------------------------------------------
// Given the URL-health badge probing an HLS URL on an MSE browser, when the
// playlist is CORS-readable and starts with #EXTM3U, then the source is "ok".
// A bare <video> probe (the old path) reported every such URL as offline, which
// combined with the magnet-less "unhealthy" stream badge marks the card
// confirmed-dead and hides it from the grid.
const withFetch = async (impl, fn) => {
const original = globalThis.fetch;
globalThis.fetch = impl;
try {
return await fn();
} finally {
globalThis.fetch = original;
}
};
test("probeHlsManifest returns ok for a readable #EXTM3U playlist", async () => {
const result = await withFetch(
async () => ({
ok: true,
text: async () =>
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=584301,CODECS="avc1.640015"\nlow.m3u8\n',
}),
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "ok");
});
test("probeHlsManifest tolerates leading whitespace/BOM-ish padding", async () => {
const result = await withFetch(
async () => ({ ok: true, text: async () => "\n\n #EXTM3U\n" }),
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "ok");
});
test("probeHlsManifest reports error for a non-playlist body", async () => {
const result = await withFetch(
async () => ({ ok: true, text: async () => "<html>404</html>" }),
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "error");
});
test("probeHlsManifest reports error for a non-2xx response", async () => {
const result = await withFetch(
async () => ({ ok: false, status: 403, text: async () => "#EXTM3U" }),
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "error");
});
test("probeHlsManifest reports error when the fetch rejects (CORS/DNS)", async () => {
const result = await withFetch(
async () => {
throw new TypeError("Failed to fetch");
},
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "error");
});
test("probeHlsManifest distinguishes a timeout from a hard error", async () => {
const result = await withFetch(
(_url, options) =>
new Promise((_resolve, reject) => {
// Never settles on its own; the probe's own AbortController must fire.
options?.signal?.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
}),
() => probeHlsManifest("https://cdn.example/v.m3u8", 30)
);
assert.equal(result, "timeout");
});
test("probeHlsManifest rejects empty input without touching the network", async () => {
let called = false;
const result = await withFetch(
async () => {
called = true;
return { ok: true, text: async () => "#EXTM3U" };
},
() => probeHlsManifest(" ")
);
assert.equal(result, "error");
assert.equal(called, false);
});
+28
View File
@@ -9,6 +9,8 @@ describe("NostrService", () => {
// Reset state
nostrService.videosMap = new Map();
nostrService.dmMessages = [];
nostrService.dmMessageIndex = new Map();
nostrService.dmActorPubkey = null;
// Mock the internal nostrClient
mockClient = {
@@ -145,4 +147,30 @@ describe("NostrService", () => {
assert.equal(nostrService.dmMessageIndex.size, 0);
});
});
describe("loadDirectMessages", () => {
it("uses the actor NIP-17 relay list for the initial historical inbox fetch", async () => {
const actor = "a".repeat(64);
const dmRelay = "wss://dm-relay.example.com";
mockClient.readRelays = ["wss://discovery.example.com"];
mockClient.pool.list.mock.mockImplementation(async () => [
{
pubkey: actor,
created_at: 10,
tags: [["relay", dmRelay]],
},
]);
mockClient.listDirectMessages = mock.fn(async () => []);
await nostrService.loadDirectMessages({ actorPubkey: actor, initialLoad: true });
assert.equal(mockClient.listDirectMessages.mock.callCount(), 1);
const [, options] = mockClient.listDirectMessages.mock.calls[0].arguments;
assert.deepEqual(
options.relays,
[dmRelay],
"historical NIP-17 messages must use the same relay list as the live subscription",
);
});
});
});
@@ -0,0 +1,162 @@
// SCN-autoplay-blocked-no-p2p-yank
//
// Given a video with BOTH a hosted URL and a magnet, when the browser refuses
// autoplay (a deep link opened from a nostr client arrives with no user
// gesture, so every mobile browser refuses), then bitvid must show "Press play"
// and WAIT — not treat the pause as a stalled source.
//
// It used to: the 3s start timeout fired regardless, the video was yanked into
// WebTorrent, and a swarm with no peers played nothing while the prompt the
// viewer was meant to act on had already been torn down. Desktop usually hides
// this because an established Media Engagement score lets autoplay through.
import { describe, test, before, after } from "node:test";
import assert from "node:assert/strict";
import { PlaybackService } from "../../js/services/playbackService.js";
function makeVideoStub({ autoplayAllowed = false } = {}) {
const listeners = new Map();
const el = {
error: null,
src: "",
srcObject: null,
currentTime: 0,
readyState: 0,
networkState: 0,
muted: false,
paused: true,
dataset: {},
playCalls: 0,
addEventListener(type, handler) {
if (!listeners.has(type)) listeners.set(type, []);
listeners.get(type).push(handler);
},
removeEventListener(type, handler) {
const arr = listeners.get(type) || [];
const i = arr.indexOf(handler);
if (i >= 0) arr.splice(i, 1);
},
dispatch(type) {
for (const h of [...(listeners.get(type) || [])]) h({ type });
},
hasAttribute: () => false,
removeAttribute() {},
setAttribute() {},
pause() {},
load() {},
play() {
el.playCalls += 1;
if (autoplayAllowed) {
el.paused = false;
// A real element signals playback started; the watchdog's success path
// keys off this. Without it even a healthy source would (correctly)
// fall back once the start timeout elapsed.
setTimeout(() => el.dispatch("playing"), 0);
return Promise.resolve();
}
const error = new Error("play() failed because the user didn't interact");
error.name = "NotAllowedError";
return Promise.reject(error);
},
};
return el;
}
const HOSTED_URL = "https://cdn.example/video.mp4";
const MAGNET = "magnet:?xt=urn:btih:024c6044dfcbadef3ac5abbfa8a36766dc0606f1";
describe("PlaybackService autoplay-blocked handling", () => {
before(() => {
global.window = {
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
};
global.document = { createElement: () => makeVideoStub() };
});
after(() => {
delete global.window;
delete global.document;
});
const run = async ({ autoplayAllowed }) => {
const video = makeVideoStub({ autoplayAllowed });
const statuses = [];
let torrentAttempted = false;
const service = new PlaybackService({
logger: () => {},
urlFirstEnabled: true,
isValidMagnetUri: () => true,
// MUST be set on the service: createSession() overwrites any
// playbackStartTimeout passed in its own options with this value, so a
// per-session override is silently ignored (and the test would then race
// the real 3s default and prove nothing).
playbackStartTimeout: 60,
});
const session = service.createSession({
url: HOSTED_URL,
magnet: MAGNET,
videoElement: video,
autoplay: true,
playViaWebTorrent: async () => {
torrentAttempted = true;
await new Promise(() => {}); // a swarm with no peers
},
});
session.on?.("status", (p) => statuses.push(p.message));
const outcome = await Promise.race([
session.execute(),
new Promise((r) => setTimeout(() => r({ source: "PENDING" }), 600)),
]);
return { outcome, statuses, torrentAttempted, video };
};
test("a blocked autoplay never falls back to WebTorrent", async () => {
const { statuses, torrentAttempted } = await run({ autoplayAllowed: false });
assert.equal(
torrentAttempted,
false,
`must not switch to P2P while awaiting a gesture; got: ${JSON.stringify(statuses)}`
);
assert.equal(
statuses.some((m) => /switching to webtorrent/i.test(m)),
false
);
});
test("the viewer is told to press play, and the prompt is the last word", async () => {
const { statuses } = await run({ autoplayAllowed: false });
assert.equal(
statuses.some((m) => /press play/i.test(m)),
true,
"viewer needs an actionable prompt"
);
// Nothing may overwrite that prompt with a failure message.
assert.match(statuses[statuses.length - 1], /press play/i);
});
test("the session stays pending rather than reporting no playable source", async () => {
const { outcome } = await run({ autoplayAllowed: false });
assert.equal(outcome.source, "PENDING");
assert.notEqual(outcome.source, null);
});
test("permitted autoplay is unaffected and still starts on the hosted URL", async () => {
// The guard must not suppress the timeout for everyone -- only while
// autoplay is actually blocked.
const { outcome, torrentAttempted, statuses } = await run({
autoplayAllowed: true,
});
assert.equal(torrentAttempted, false);
assert.equal(
statuses.some((m) => /press play/i.test(m)),
false
);
assert.equal(outcome.source, "url", "a healthy source still starts normally");
});
});
@@ -267,8 +267,18 @@ describe("PlaybackService Forced Source Logic", () => {
assert.equal(probeUrl.mock.callCount(), 0);
});
test("Forced Source 'torrent': Does NOT fallback to URL even if Torrent fails", async () => {
const probeUrl = mock.fn(async () => ({ outcome: "good" }));
test("Forced Source 'torrent': falls back to the hosted URL when the torrent definitively fails", async () => {
// Spec changed deliberately in playbackService.js: "Even when P2P was
// explicitly forced, a hosted URL is a better outcome than a hard 'No
// playable source found' if the torrent DEFINITIVELY failed." The code
// carries the rollback note (`restore && forcedSource !== "torrent"` to
// make forced P2P hard-fail), so the old assertion here -- source null,
// probe never called -- encodes behavior that was intentionally removed.
//
// It did not merely fail: because forced sources skip the start timeout,
// the now-reachable hosted attempt waited forever on a <video> that never
// emits, and the test hung ("Promise resolution is still pending").
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
const playViaWebTorrent = mock.fn(async () => {
throw new Error("Torrent failed");
@@ -283,9 +293,18 @@ describe("PlaybackService Forced Source Logic", () => {
forcedSource: "torrent",
});
const result = await session.start();
const startPromise = session.start();
assert.equal(result.source, null);
assert.equal(probeUrl.mock.callCount(), 0);
// Let the torrent attempt reject and the hosted fallback attach, then
// signal that the element actually started playing.
await new Promise(resolve => process.nextTick(resolve));
mock.timers.tick(10);
await new Promise(resolve => process.nextTick(resolve));
video.dispatchEvent(new window.Event("playing"));
const result = await startPromise;
assert.equal(playViaWebTorrent.mock.callCount(), 1, "P2P is still tried first");
assert.equal(result.source, "url", "a working CDN beats a hard failure");
});
});
@@ -0,0 +1,185 @@
// SCN-stale-media-error
//
// Given the modal <video> is reused across videos, teardownVideoElement() /
// resetVideoElement() clear `src` and call load(). Browsers respond by leaving a
// MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute") MediaError on the element.
//
// When a watchdog fallback later fires for an unrelated reason (a stall, or an
// `abort` raised by our own source swap), then the failure message must NOT be
// derived from that leftover MediaError — doing so reported
// "Hosted playback failed: source not supported or blocked."
// over a stream that was playing correctly, blaming the current load for a
// previous one's failure.
//
// Then: only a MediaError produced by THIS attempt may be surfaced.
import { describe, test, before, after } from "node:test";
import assert from "node:assert/strict";
import { PlaybackService } from "../../js/services/playbackService.js";
// Minimal <video> double: records listeners so the test can dispatch the exact
// media events the real watchdogs bind to.
function makeVideoStub({ initialError = null } = {}) {
const listeners = new Map();
return {
error: initialError,
src: "",
srcObject: null,
currentTime: 0,
readyState: 0,
networkState: 0,
muted: false,
dataset: {},
listeners,
addEventListener(type, handler) {
if (!listeners.has(type)) listeners.set(type, []);
listeners.get(type).push(handler);
},
removeEventListener(type, handler) {
const arr = listeners.get(type) || [];
const i = arr.indexOf(handler);
if (i >= 0) arr.splice(i, 1);
},
dispatch(type) {
for (const h of [...(listeners.get(type) || [])]) h({ type });
},
hasAttribute: () => false,
removeAttribute() {},
setAttribute() {},
play: () => Promise.resolve(),
pause() {},
load() {},
};
}
const EMPTY_SRC_ERROR = Object.freeze({
code: 4, // MEDIA_ERR_SRC_NOT_SUPPORTED
message: "MEDIA_ELEMENT_ERROR: Empty src attribute",
});
describe("PlaybackService stale MediaError handling", () => {
before(() => {
global.window = {
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
};
global.document = { createElement: () => makeVideoStub() };
});
after(() => {
delete global.window;
delete global.document;
});
const runWatchdogFallback = async ({ initialError, errorAtFallback }) => {
const video = makeVideoStub({ initialError });
const service = new PlaybackService({
logger: () => {},
urlFirstEnabled: true,
isValidMagnetUri: () => false,
});
const statuses = [];
let fallbackReason = null;
// Drive the watchdog contract directly: this is the boundary where the
// message is chosen, independent of network or codec support.
const cleanup = service.registerUrlPlaybackWatchdogs(video, {
stallMs: 0,
onSuccess: () => {},
onFallback: (reason) => {
fallbackReason = reason;
},
});
video.error = errorAtFallback;
video.dispatch("stalled");
cleanup();
return { statuses, fallbackReason, video };
};
test("a fallback still reports its reason to the caller", async () => {
const { fallbackReason } = await runWatchdogFallback({
initialError: EMPTY_SRC_ERROR,
errorAtFallback: EMPTY_SRC_ERROR,
});
assert.equal(fallbackReason, "stalled");
});
test("the leftover empty-src MediaError is identity-distinguishable", () => {
// The fix hinges on identity: starting a new load clears `error` to null,
// and a genuine new failure allocates a DIFFERENT MediaError object. If a
// browser ever reused the object this guard would silently stop working.
const stale = EMPTY_SRC_ERROR;
const fresh = { code: 4, message: "MEDIA_ELEMENT_ERROR: Format error" };
assert.notEqual(stale, fresh);
assert.equal(stale === EMPTY_SRC_ERROR, true);
});
// Drive a real session to the watchdog-fallback branch with a stale error
// already on the element, and capture what the viewer would be shown.
const runSessionWithStall = async ({ initialError, errorAtFallback }) => {
const video = makeVideoStub({ initialError });
// Fire `stalled` as soon as the session arms its watchdogs — this is the
// spurious fallback the viewer hits while the stream is actually fine.
const originalAdd = video.addEventListener.bind(video);
video.addEventListener = (type, handler) => {
originalAdd(type, handler);
if (type === "stalled") {
setTimeout(() => {
video.error = errorAtFallback;
video.dispatch("stalled");
}, 0);
}
};
const service = new PlaybackService({
logger: () => {},
urlFirstEnabled: true,
isValidMagnetUri: () => false,
});
const statuses = [];
const session = service.createSession({
url: "https://cdn.example/video.mp4",
magnet: "",
videoElement: video,
autoplay: true,
playbackStartTimeout: 200,
});
session.on?.("status", (p) => statuses.push(p.message));
await session.execute();
return statuses;
};
test("a stale empty-src error is NOT blamed on the current load", async () => {
const statuses = await runSessionWithStall({
initialError: EMPTY_SRC_ERROR,
errorAtFallback: EMPTY_SRC_ERROR, // unchanged => stale
});
assert.equal(
statuses.some((m) => /source not supported or blocked/i.test(m)),
false,
`stale error must not be reported; got: ${JSON.stringify(statuses)}`
);
});
test("a MediaError raised by THIS load is still reported", async () => {
// The guard must not silence genuine failures — a new MediaError object
// appearing during the attempt is real and must reach the viewer.
const statuses = await runSessionWithStall({
initialError: null,
errorAtFallback: {
code: 4,
message: "MEDIA_ELEMENT_ERROR: Format error",
},
});
assert.equal(
statuses.some((m) => /source not supported or blocked/i.test(m)),
true,
`genuine error must be reported; got: ${JSON.stringify(statuses)}`
);
});
});
+12 -2
View File
@@ -133,14 +133,24 @@ describe("trustBootstrap", () => {
assert.ok(seeds.has("editor1"));
});
it("should subscribe to accessControl changes", async () => {
it("should subscribe to the accessControl lists that affect seeds", async () => {
await bootstrapTrustedSeeds();
assert.equal(accessControl.onWhitelistChange.mock.callCount(), 1);
assert.equal(accessControl.onEditorsChange.mock.callCount(), 1);
assert.equal(accessControl.onBlacklistChange.mock.callCount(), 1);
});
it("should NOT subscribe to whitelist changes", async () => {
// buildTrustedSeeds() derives seeds from the super admin, the editors list
// and the fallback seeds -- the whitelist is not an input, so re-applying
// seeds on a whitelist change is pure churn. 18e08bf2 ("refine trust
// controls") removed that subscription deliberately; this pins the removal
// so it is not reinstated by reflex.
await bootstrapTrustedSeeds();
assert.equal(accessControl.onWhitelistChange.mock.callCount(), 0);
});
it("should handle accessControl timeout and apply seeds anyway", async () => {
// Mock waitForAccessControl to timeout (reject)
accessControl.waitForReady.mock.mockImplementation(async () => {
+190
View File
@@ -91,3 +91,193 @@ test("buildShareEvent tolerates missing optional fields", () => {
assert.equal(event.content, "");
assert.deepEqual(event.tags, []);
});
// SCN-share-imeta
// Given a share note whose body ends with the raw thumbnail URL, when the
// video has an http(s) thumbnail, then a NIP-92 `imeta` entry must describe it
// — many clients only render such a URL inline when imeta backs it, otherwise
// the viewer sees a bare link instead of a preview image.
const imetaOf = (event) => event.tags.find((tag) => tag[0] === "imeta") || null;
test("buildShareEvent emits a NIP-92 imeta entry for the thumbnail", () => {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000804,
video: {
id: "a".repeat(64),
pubkey: "b".repeat(64),
title: "Why I'm (sort of) not worried about AI",
thumbnail: "https://cdn.example/thumb.jpg",
},
});
assert.deepEqual(imetaOf(event), [
"imeta",
"url https://cdn.example/thumb.jpg",
"m image/jpeg",
"alt Why I'm (sort of) not worried about AI",
]);
});
test("buildShareEvent infers the image type per extension, query strings included", () => {
const typeFor = (thumbnail) => {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000805,
video: { id: "a".repeat(64), thumbnail },
});
return (imetaOf(event) || []).find((entry) => entry.startsWith("m ")) || "";
};
assert.equal(typeFor("https://cdn.example/a.png"), "m image/png");
assert.equal(typeFor("https://cdn.example/a.WEBP"), "m image/webp");
assert.equal(typeFor("https://cdn.example/a.jpeg?sig=abc&exp=1"), "m image/jpeg");
assert.equal(typeFor("https://cdn.example/a.avif#x"), "m image/avif");
});
test("buildShareEvent omits the media type when the extension is unknown", () => {
// A wrong `m` is worse than a missing one: clients use it to decide whether
// to render inline at all. Blossom-style extensionless URLs hit this path.
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000806,
video: {
id: "a".repeat(64),
title: "Untitled",
thumbnail: "https://blossom.example/0101591966f5764e57abb79f8e0305d7",
},
});
assert.deepEqual(imetaOf(event), [
"imeta",
"url https://blossom.example/0101591966f5764e57abb79f8e0305d7",
"alt Untitled",
]);
});
test("buildShareEvent skips imeta for non-http thumbnails", () => {
for (const thumbnail of [
"",
" ",
"assets/jpg/fallback.jpg",
"data:image/png;base64,iVBORw0KGgo=",
"javascript:alert(1)",
null,
undefined,
]) {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000807,
video: { id: "a".repeat(64), thumbnail },
});
assert.equal(
imetaOf(event),
null,
`expected no imeta for thumbnail ${JSON.stringify(thumbnail)}`
);
}
});
test("buildShareEvent collapses newlines in imeta alt text", () => {
// imeta entries are space-delimited "key value" strings; a raw newline in the
// title would corrupt the tag for parsers reading it back.
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000808,
video: {
id: "a".repeat(64),
title: "Line one\nline two\t spaced",
thumbnail: "https://cdn.example/t.png",
},
});
const alt = imetaOf(event).find((entry) => entry.startsWith("alt "));
assert.equal(alt, "alt Line one line two spaced");
assert.equal(/[\n\r\t]/.test(alt), false);
});
// SCN-share-quote
// When a NIP-71 mirror exists, the note body quotes it via `nostr:naddr1…`
// instead of carrying a bare thumbnail URL. The imeta tag must then be dropped
// — the quote card already renders the thumbnail, and emitting both showed the
// same image twice — while an `a` tag links the note to the quoted coordinate.
const MIRROR_COORD = `34235:${"b".repeat(64)}:root-abc`;
test("buildShareEvent adds an `a` tag for the quoted mirror", () => {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000809,
video: {
id: "a".repeat(64),
pubkey: "b".repeat(64),
title: "Quoted video",
thumbnail: "https://cdn.example/thumb.jpg",
mirrorNaddr: "naddr1qqxnzd3exuerjvfhx",
mirrorCoordinate: MIRROR_COORD,
},
});
const aTag = event.tags.find((tag) => tag[0] === "a");
assert.deepEqual(aTag, ["a", MIRROR_COORD, "", "mention"]);
});
test("buildShareEvent drops the imeta when the note quotes a mirror", () => {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000810,
video: {
id: "a".repeat(64),
title: "Quoted video",
thumbnail: "https://cdn.example/thumb.jpg",
mirrorNaddr: "naddr1qqxnzd3exuerjvfhx",
mirrorCoordinate: MIRROR_COORD,
},
});
assert.equal(
event.tags.some((tag) => tag[0] === "imeta"),
false,
"quote card renders the thumbnail; a second imeta duplicates it"
);
});
test("buildShareEvent keeps the imeta when there is no mirror to quote", () => {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000811,
video: {
id: "a".repeat(64),
title: "Unmirrored video",
thumbnail: "https://cdn.example/thumb.jpg",
},
});
assert.equal(event.tags.some((tag) => tag[0] === "imeta"), true);
assert.equal(event.tags.some((tag) => tag[0] === "a"), false);
});
test("buildShareEvent ignores a malformed mirror coordinate", () => {
for (const coordinate of [
"not-a-coordinate",
"34235:notahexpubkey:root",
":::",
"",
]) {
const event = buildShareEvent({
pubkey: "share-author",
created_at: 1700000812,
video: {
id: "a".repeat(64),
title: "T",
mirrorCoordinate: coordinate,
},
});
assert.equal(
event.tags.some((tag) => tag[0] === "a"),
false,
`must not emit an a tag for ${JSON.stringify(coordinate)}`
);
}
});
+108
View File
@@ -0,0 +1,108 @@
// SCN-share-nostr-gate
//
// The share menu's "Share on Nostr" item is gated on `isLoggedIn && hasSigner`
// (js/ui/components/videoMenuRenderers.js). VideoModal initialized
// `shareNostrAuthState` to {false, false} and NOTHING ever wrote to it, so the
// item rendered permanently disabled — it looked like inert text, and the click
// delegation in bindShareMenuActions() bails on `button.disabled`.
//
// Two halves have to hold for the button to work:
// 1. VideoModal.setShareNostrAuthState() records the state.
// 2. syncAuthUiState() pushes real auth state in, and re-runs on auth change
// (it previously ran only once at bootstrap).
import test from "node:test";
import assert from "node:assert/strict";
import { VideoModal } from "../js/ui/components/VideoModal.js";
const PUBKEY = "b".repeat(64);
// --- VideoModal setter ----------------------------------------------------
function makeModal() {
const modal = Object.create(VideoModal.prototype);
modal.shareNostrAuthState = { isLoggedIn: false, hasSigner: false };
modal.closedPopover = 0;
modal.modalSharePopover = {
close: () => {
modal.closedPopover += 1;
},
};
return modal;
}
test("setShareNostrAuthState records the gate", () => {
const modal = makeModal();
assert.equal(modal.setShareNostrAuthState({ isLoggedIn: true, hasSigner: true }), true);
assert.deepEqual(modal.shareNostrAuthState, { isLoggedIn: true, hasSigner: true });
});
test("setShareNostrAuthState is idempotent and only closes a stale menu once", () => {
const modal = makeModal();
modal.setShareNostrAuthState({ isLoggedIn: true, hasSigner: true });
assert.equal(modal.closedPopover, 1, "open menu must re-render against new state");
// No change -> no churn. Closing the popover on every no-op sync would slam
// the menu shut under the user.
assert.equal(modal.setShareNostrAuthState({ isLoggedIn: true, hasSigner: true }), false);
assert.equal(modal.closedPopover, 1);
});
test("setShareNostrAuthState coerces and defaults safely", () => {
const modal = makeModal();
modal.setShareNostrAuthState({ isLoggedIn: "yes", hasSigner: 1 });
assert.deepEqual(modal.shareNostrAuthState, { isLoggedIn: true, hasSigner: true });
modal.setShareNostrAuthState();
assert.deepEqual(modal.shareNostrAuthState, { isLoggedIn: false, hasSigner: false });
});
test("setShareNostrAuthState tolerates no popover", () => {
const modal = makeModal();
modal.modalSharePopover = null;
assert.doesNotThrow(() =>
modal.setShareNostrAuthState({ isLoggedIn: true, hasSigner: true })
);
});
// --- app wiring ----------------------------------------------------------
// bitvid ALREADY routed auth changes to the modal: Application
// .updateShareNostrAuthState() is called on init, login, logout, pubkey-change
// and signer-change, and it forwards to videoModal.setShareNostrAuthState().
// That method simply did not exist, so the call short-circuited on its own
// `if (!this.videoModal?.setShareNostrAuthState) return;` guard and the gate
// stayed shut forever. These pin the contract that guard depends on.
test("the app's updateShareNostrAuthState contract is satisfied", async () => {
const { readFile } = await import("node:fs/promises");
const source = await readFile(new URL("../js/app.js", import.meta.url), "utf8");
// The guard short-circuits when the method is absent, which is exactly how
// this stayed broken silently. If the method is ever renamed, fail loudly.
assert.match(source, /videoModal\?\.setShareNostrAuthState/);
assert.match(source, /videoModal\.setShareNostrAuthState\(\{/);
// The lifecycle points that must keep the gate in sync.
for (const reason of ["init", "signer-change", "pubkey-change"]) {
assert.ok(
source.includes(`reason: "${reason}"`),
`app.js should refresh the share gate on ${reason}`
);
}
assert.equal(
typeof VideoModal.prototype.setShareNostrAuthState,
"function",
"VideoModal must expose the method app.js calls"
);
});
test("login and logout both refresh the gate", async () => {
const { readFile } = await import("node:fs/promises");
const source = await readFile(
new URL("../js/app/authSessionCoordinator.js", import.meta.url),
"utf8"
);
assert.ok(source.includes('reason: "auth-login"'));
assert.ok(source.includes('reason: "auth-logout"'));
});
+79
View File
@@ -0,0 +1,79 @@
// SCN-share-body: what the viewer actually reads in the composed note.
//
// Two shapes, chosen by whether a NIP-71 mirror was confirmed on relays:
// mirror -> quote card via `nostr:naddr1…`, NO bare thumbnail URL
// no mirror -> bare thumbnail URL (described by the note's NIP-92 imeta)
//
// The "no bare thumbnail while quoting" rule is the one that matters visually:
// keeping both rendered the same image twice (once loose, once in the card).
import test from "node:test";
import assert from "node:assert/strict";
import { ShareNostrModal } from "../js/ui/components/ShareNostrModal.js";
// buildShareContent is a pure string builder; construct without touching the
// DOM-dependent parts of the modal.
const modal = Object.create(ShareNostrModal.prototype);
const build = (payload) => modal.buildShareContent(payload);
const BASE = {
title: "Why I'm (sort of) not worried about AI",
shareUrl: "https://bitvid.network/?v=nevent1abc",
thumbnail: "https://cdn.example/thumb.jpg",
};
test("without a mirror the body ends with the thumbnail URL", () => {
const body = build(BASE);
assert.equal(
body,
"Why I'm (sort of) not worried about AI — Check out this video on bitvid 👇\n\n" +
"https://bitvid.network/?v=nevent1abc\n\n" +
"https://cdn.example/thumb.jpg"
);
});
test("with a mirror the body quotes it and omits the thumbnail URL", () => {
const body = build({ ...BASE, mirrorNaddr: "naddr1qqxnzd3e" });
assert.match(body, /nostr:naddr1qqxnzd3e$/);
assert.equal(
body.includes("https://cdn.example/thumb.jpg"),
false,
"quote card already shows the thumbnail; a loose URL duplicates it"
);
// The bitvid link stays — attribution and traffic still matter.
assert.match(body, /https:\/\/bitvid\.network\/\?v=nevent1abc/);
});
test("the quote reference uses the nostr: URI scheme clients resolve", () => {
const body = build({ ...BASE, mirrorNaddr: "naddr1qqxnzd3e" });
assert.equal(body.includes("nostr:naddr1qqxnzd3e"), true);
// A bare naddr without the scheme is rendered as plain text by most clients.
assert.equal(/(^|\s)naddr1qqxnzd3e/.test(body), false);
});
test("an empty or whitespace mirror pointer falls back to the thumbnail", () => {
for (const mirrorNaddr of ["", " ", null, undefined]) {
const body = build({ ...BASE, mirrorNaddr });
assert.match(body, /https:\/\/cdn\.example\/thumb\.jpg$/);
assert.equal(body.includes("nostr:"), false);
}
});
test("missing pieces never leave dangling blank sections", () => {
const noThumb = build({ title: "T", shareUrl: "https://bitvid.network/?v=x" });
assert.equal(noThumb.endsWith("\n"), false);
assert.equal(/\n{3,}/.test(noThumb), false);
const titleOnly = build({ title: "T" });
assert.equal(titleOnly, "T — Check out this video on bitvid 👇");
const empty = build({});
assert.equal(empty, "Untitled video — Check out this video on bitvid 👇");
});
test("a missing title degrades to a placeholder rather than 'undefined'", () => {
const body = build({ ...BASE, title: " " });
assert.match(body, /^Untitled video — /);
assert.equal(body.includes("undefined"), false);
});
+160
View File
@@ -0,0 +1,160 @@
// SCN-share-pointer-hints
//
// A shared bitvid link carries the video as an `nevent` in `?v=`. It used to be
// encoded as a BARE id -- no author, no relay hints -- so a viewer whose relay
// set didn't happen to carry that event could not resolve it and landed on the
// home feed. That is the "the shared link just opens bitvid.network" report:
// the reported video was on nos.lol but NOT on damus, snort or primal, three of
// bitvid's four defaults.
import test from "node:test";
import assert from "node:assert/strict";
import { createPlaybackCoordinator } from "../js/app/playbackCoordinator.js";
const EVENT_ID = "a759836ece8f5b16ee20fc9f5872ccb7a04208758a3c473f3b3ed219e3adcfd1";
const AUTHOR = "da19b5d291e06ed09ba545bc5366408dc82ba8c9e9f97d32ea8ea60f1f6cbfaa";
// Capture what gets encoded rather than asserting on bech32 output.
function makeHarness({ videos = [], relays = [], writeRelays } = {}) {
const encoded = [];
globalThis.window = {
NostrTools: {
nip19: {
neventEncode(pointer) {
encoded.push(pointer);
return `nevent1${pointer.id.slice(0, 8)}`;
},
},
},
};
const nostrClient = { relays };
if (writeRelays !== undefined) {
nostrClient.writeRelays = writeRelays;
}
const coordinator = createPlaybackCoordinator({
devLogger: { warn() {}, error() {}, log() {} },
userLogger: { warn() {}, error() {}, info() {} },
nostrClient,
BITVID_WEBSITE_URL: "https://bitvid.network/",
});
const app = {
...coordinator,
videosMap: new Map(videos.map((v) => [v.id, v])),
getShareUrlBase: () => "https://bitvid.network",
};
return { app, encoded };
}
test("the pointer carries the video author", () => {
const { app, encoded } = makeHarness({
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
relays: ["wss://relay.damus.io"],
});
app.buildShareUrlFromEventId(EVENT_ID);
assert.equal(encoded[0].author, AUTHOR);
});
test("the pointer carries relay hints", () => {
const { app, encoded } = makeHarness({
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
writeRelays: ["wss://nos.lol", "wss://relay.damus.io"],
});
app.buildShareUrlFromEventId(EVENT_ID);
assert.deepEqual(encoded[0].relays, ["wss://nos.lol", "wss://relay.damus.io"]);
});
test("write relays win over the general relay list", () => {
const { app, encoded } = makeHarness({
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
relays: ["wss://read-only.example"],
writeRelays: ["wss://nos.lol"],
});
app.buildShareUrlFromEventId(EVENT_ID);
assert.deepEqual(encoded[0].relays, ["wss://nos.lol"]);
});
test("relay hints are capped, deduped and scheme-filtered", () => {
// Hints inflate every shared URL, and a whole relay list in a note body is
// both unwieldy and more disclosure than the link needs.
const { app, encoded } = makeHarness({
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
writeRelays: [
"https://not-a-relay.example",
"wss://a.example",
" wss://a.example ",
"wss://b.example",
"ws://c.example",
"wss://d.example",
"",
null,
],
});
app.buildShareUrlFromEventId(EVENT_ID);
assert.deepEqual(encoded[0].relays, [
"wss://a.example",
"wss://b.example",
"ws://c.example",
]);
});
test("an unknown video still yields a usable pointer", () => {
// Sharing from a surface that has not cached the video must not break; the
// bare id is the old behavior and remains the floor.
const { app, encoded } = makeHarness({ writeRelays: ["wss://nos.lol"] });
const url = app.buildShareUrlFromEventId(EVENT_ID);
assert.equal(encoded[0].id, EVENT_ID);
assert.equal("author" in encoded[0], false);
assert.match(url, /^https:\/\/bitvid\.network\/\?v=/);
});
test("a malformed author is omitted rather than embedded", () => {
for (const pubkey of ["", "not-hex", "abc123", null, undefined, 42]) {
const { app, encoded } = makeHarness({
videos: [{ id: EVENT_ID, pubkey }],
writeRelays: ["wss://nos.lol"],
});
app.buildShareUrlFromEventId(EVENT_ID);
assert.equal(
"author" in encoded[0],
false,
`must not embed author ${JSON.stringify(pubkey)}`
);
}
});
test("no relays configured omits the hint rather than sending an empty array", () => {
const { app, encoded } = makeHarness({
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
relays: [],
});
app.buildShareUrlFromEventId(EVENT_ID);
assert.equal("relays" in encoded[0], false);
});
test("the share URL has a path separator before the query", () => {
// `https://bitvid.network?v=...` is legal but reads as a typo in a note and
// trips some link parsers on where the URL ends.
const { app } = makeHarness({
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
relays: ["wss://nos.lol"],
});
const url = app.buildShareUrlFromEventId(EVENT_ID);
assert.match(url, /^https:\/\/bitvid\.network\/\?v=nevent1/);
assert.equal(url.includes("network?v="), false);
});
test("an empty event id yields no URL", () => {
const { app } = makeHarness({ relays: ["wss://nos.lol"] });
assert.equal(app.buildShareUrlFromEventId(""), "");
assert.equal(app.buildShareUrlFromEventId(null), "");
});
+191
View File
@@ -83,6 +83,70 @@ test('ShareNostrController', async (t) => {
assert.equal(ui.showError.mock.calls.length, 0);
});
await t.test('handleShare forwards title and thumbnail for the imeta hint', async () => {
// The controller used to pass only { id, pubkey } to buildShareEvent, so
// the NIP-92 imeta entry could never be built and the thumbnail URL in the
// note body rendered as a bare link instead of a preview image.
const ui = {
showError: t.mock.fn(),
showSuccess: t.mock.fn(),
getModal: () => ({})
};
const state = {
getPubkey: () => 'signerpubkey',
normalizeHexPubkey: (k) => k,
getCurrentVideo: () => null
};
mockBuildShareEvent.mock.resetCalls();
const controller = new ShareNostrController({ ui, state, services });
await controller.handleShare({
video: {
id: 'video1',
title: 'Video 1',
pubkey: 'creator1',
thumbnail: ' https://cdn.example/thumb.jpg '
},
content: 'Check this out',
relays: ['wss://relay.example.com']
});
const passedVideo = mockBuildShareEvent.mock.calls[0].arguments[0].video;
assert.equal(passedVideo.title, 'Video 1');
assert.equal(passedVideo.thumbnail, 'https://cdn.example/thumb.jpg');
assert.equal(passedVideo.id, 'video1');
assert.equal(passedVideo.pubkey, 'creator1');
});
await t.test('handleShare tolerates a video with no thumbnail', async () => {
const ui = {
showError: t.mock.fn(),
showSuccess: t.mock.fn(),
getModal: () => ({})
};
const state = {
getPubkey: () => 'signerpubkey',
normalizeHexPubkey: (k) => k,
getCurrentVideo: () => null
};
mockBuildShareEvent.mock.resetCalls();
const controller = new ShareNostrController({ ui, state, services });
const result = await controller.handleShare({
video: { id: 'video1', title: 'Video 1', pubkey: 'creator1' },
content: 'Check this out',
relays: ['wss://relay.example.com']
});
assert.ok(result.ok);
assert.equal(
mockBuildShareEvent.mock.calls[0].arguments[0].video.thumbnail,
''
);
});
await t.test('handleShare throws error if missing video details', async () => {
const ui = {
showError: t.mock.fn(),
@@ -144,3 +208,130 @@ test('ShareNostrController', async (t) => {
assert.equal(openArg.video.shareUrl, 'https://example.com/v1');
});
});
// SCN-share-mirror-lookup
// The share modal embeds a quote pointer ONLY when a mirror is confirmed on
// relays. A pointer to a missing event renders as a broken/empty quote box in
// nostr clients — strictly worse than the plain-link fallback — so every
// failure path here must degrade to "no pointer" rather than guess.
test('ShareNostrController mirror pointer', async (t) => {
const NIP19 = { naddrEncode: (p) => `naddr1-${p.kind}-${p.identifier}` };
const baseUi = () => ({
showError: t.mock.fn(),
showSuccess: t.mock.fn(),
getModal: () => ({ open: async () => {} }),
});
const baseState = () => ({
getPubkey: () => 'signerpubkey',
normalizeHexPubkey: (k) => k,
getCurrentVideo: () => null,
buildShareUrlFromEventId: () => 'https://bitvid.network/?v=nevent1abc',
});
const VIDEO = {
id: 'video1',
title: 'Video 1',
pubkey: 'b'.repeat(64),
videoRootId: 'root-abc',
thumbnail: 'https://cdn.example/t.jpg',
width: 1280,
height: 720,
};
const makeController = (mirrorService) =>
new ShareNostrController({
ui: baseUi(),
state: baseState(),
services: {
mirrorService,
nip19: NIP19,
nostrClient: { pool: {}, writeRelays: ['wss://relay.one'] },
userLogger: { warn: () => {}, error: () => {}, info: () => {} },
devLogger: { warn: () => {}, error: () => {}, log: () => {} },
},
});
await t.test('confirmed mirror yields an naddr and coordinate', async () => {
const controller = makeController({
isAvailable: () => true,
findMirror: async () => ({ mirrored: true, kinds: [34235] }),
});
const out = await controller.resolveMirrorPointer(VIDEO);
assert.equal(out.naddr, 'naddr1-34235-root-abc');
assert.equal(out.coordinate, `34235:${'b'.repeat(64)}:root-abc`);
});
await t.test('the observed kind wins over the dimension heuristic', async () => {
// Portrait dims would suggest 34236; relays say 34235, so 34235 it is.
const controller = makeController({
isAvailable: () => true,
findMirror: async () => ({ mirrored: true, kinds: [34235] }),
});
const out = await controller.resolveMirrorPointer({
...VIDEO,
width: 720,
height: 1280,
});
assert.equal(out.naddr, 'naddr1-34235-root-abc');
});
await t.test('no mirror on relays yields no pointer', async () => {
const controller = makeController({
isAvailable: () => true,
findMirror: async () => ({ mirrored: false, kinds: [] }),
});
assert.deepEqual(await controller.resolveMirrorPointer(VIDEO), {
naddr: '',
coordinate: '',
});
});
await t.test('a throwing lookup degrades instead of breaking the share', async () => {
const controller = makeController({
isAvailable: () => true,
findMirror: async () => {
throw new Error('relay exploded');
},
});
assert.deepEqual(await controller.resolveMirrorPointer(VIDEO), {
naddr: '',
coordinate: '',
});
});
await t.test('a disabled mirror service is never consulted', async () => {
let called = false;
const controller = makeController({
isAvailable: () => false,
findMirror: async () => {
called = true;
return { mirrored: true, kinds: [34235] };
},
});
const out = await controller.resolveMirrorPointer(VIDEO);
assert.equal(out.naddr, '');
assert.equal(called, false);
});
await t.test('a video without a videoRootId cannot be addressed', async () => {
const controller = makeController({
isAvailable: () => true,
findMirror: async () => ({ mirrored: true, kinds: [34235] }),
});
const out = await controller.resolveMirrorPointer({
...VIDEO,
videoRootId: '',
});
assert.equal(out.naddr, '');
});
await t.test('a missing mirror service is tolerated', async () => {
const controller = makeController(null);
assert.deepEqual(await controller.resolveMirrorPointer(VIDEO), {
naddr: '',
coordinate: '',
});
});
});
+99
View File
@@ -1324,6 +1324,7 @@ test("loadSubscriptions uses overlap window and deterministic latest-event tie-b
subscribedPubkeys: [oldAuthor],
createdAt: 1000,
eventId: "cached-event",
eventPubkey: pubkey,
});
nostrClient.fetchListIncrementally = async (params = {}) => {
@@ -1378,6 +1379,104 @@ test("loadSubscriptions uses overlap window and deterministic latest-event tie-b
}
});
test("loadSubscriptions never applies a different BitLogin session actor's list", async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const activePubkey = "a".repeat(64);
const sessionPubkey = "b".repeat(64);
const activeAuthor = "c".repeat(64);
const sessionAuthor = "d".repeat(64);
const originalFetchIncremental = nostrClient.fetchListIncrementally;
const originalSessionActor = nostrClient.sessionActor;
const originalPool = nostrClient.pool;
const originalDecryptSubscriptionEvent = manager.decryptSubscriptionEvent;
nostrClient.sessionActor = { pubkey: sessionPubkey };
nostrClient.pool = { list: async () => [] };
nostrClient.fetchListIncrementally = async ({ pubkey }) => {
assert.equal(pubkey, activePubkey, "only the active account may be fetched");
return [
{
id: "active-list",
pubkey: activePubkey,
created_at: 100,
content: "active-ciphertext",
tags: [["encrypted", "nip04"]],
},
{
id: "foreign-list",
pubkey: sessionPubkey,
created_at: 200,
content: "foreign-ciphertext",
tags: [["encrypted", "nip04"]],
},
];
};
manager.decryptSubscriptionEvent = async (event) => ({
ok: true,
plaintext: JSON.stringify([["p", event.id === "active-list" ? activeAuthor : sessionAuthor]]),
});
try {
await manager.updateFromRelays(activePubkey, { allowPermissionPrompt: false });
assert.equal(manager.subsEventId, "active-list");
assert.deepEqual(manager.getSubscribedAuthors(), [activeAuthor]);
} finally {
nostrClient.fetchListIncrementally = originalFetchIncremental;
nostrClient.sessionActor = originalSessionActor;
nostrClient.pool = originalPool;
manager.decryptSubscriptionEvent = originalDecryptSubscriptionEvent;
localStorage.clear();
profileCache.memoryCache.clear();
profileCache.activePubkey = null;
}
});
test("loadSubscriptions repairs a legacy cached list with a full active-account fetch", async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const activePubkey = "e".repeat(64);
const staleAuthor = "f".repeat(64);
const correctAuthor = "1".repeat(64);
const originalFetchIncremental = nostrClient.fetchListIncrementally;
const originalPool = nostrClient.pool;
const originalDecryptSubscriptionEvent = manager.decryptSubscriptionEvent;
profileCache.setProfileData(activePubkey, "subscriptions", {
subscribedPubkeys: [staleAuthor],
eventId: "legacy-foreign-event",
createdAt: 9999,
});
nostrClient.pool = { list: async () => [] };
nostrClient.fetchListIncrementally = async (params) => {
assert.equal(params.since, 0, "legacy source-less cache must not suppress a full fetch");
return [{
id: "correct-active-event",
pubkey: activePubkey,
created_at: 100,
content: "correct-ciphertext",
tags: [["encrypted", "nip04"]],
}];
};
manager.decryptSubscriptionEvent = async () => ({
ok: true,
plaintext: JSON.stringify([["p", correctAuthor]]),
});
try {
await manager.updateFromRelays(activePubkey, { allowPermissionPrompt: false });
assert.deepEqual(manager.getSubscribedAuthors(), [correctAuthor]);
assert.equal(manager.subsEventPubkey, activePubkey);
} finally {
nostrClient.fetchListIncrementally = originalFetchIncremental;
nostrClient.pool = originalPool;
manager.decryptSubscriptionEvent = originalDecryptSubscriptionEvent;
localStorage.clear();
profileCache.memoryCache.clear();
profileCache.activePubkey = null;
}
});
test("handleSubscriptionListEvent does not re-trigger on the live-sub echo (loop guard)", () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
+50 -22
View File
@@ -15,36 +15,36 @@ describe("ReactionController", () => {
subscribe: (pointer, cb) => {
return () => {};
},
publish: async () => ({ ok: true }),
publish: async () => ({ ok: true })
};
mockVideoModal = {
updateReactionSummary: () => {},
setUserReaction: () => {},
setUserReaction: () => {}
};
mockUi = {
getVideoModal: () => mockVideoModal,
showError: () => {},
showError: () => {}
};
mockState = {
getCurrentVideo: () => ({ id: "v1", pubkey: "p1" }),
getCurrentVideoPointer: () => ["e", "v1"],
getCurrentVideoPointerKey: () => "e:v1",
getCurrentVideoPointerKey: () => "e:v1"
};
mockCallbacks = {
isUserLoggedIn: () => true,
normalizeHexPubkey: (val) => val,
getPubkey: () => "user1",
getPubkey: () => "user1"
};
controller = new ReactionController({
services: { reactionCounter: mockReactionCounter },
ui: mockUi,
state: mockState,
callbacks: mockCallbacks,
callbacks: mockCallbacks
});
});
@@ -54,7 +54,26 @@ describe("ReactionController", () => {
controller.subscribe(["e", "v1"], "e:v1");
assert.strictEqual(subscribeMock.mock.callCount(), 1);
assert.deepStrictEqual(subscribeMock.mock.calls[0].arguments[0], ["e", "v1"]);
assert.deepStrictEqual(subscribeMock.mock.calls[0].arguments[0], [
"e",
"v1"
]);
});
it("includes the immutable event ID when subscribing to addressable videos", (t) => {
const subscribeMock = t.mock.method(mockReactionCounter, "subscribe");
controller.subscribe(
["a", "34235:p1:episode-42", "wss://relay.example"],
"a:34235:p1:episode-42"
);
assert.deepStrictEqual(subscribeMock.mock.calls[0].arguments[0], {
type: "a",
value: "34235:p1:episode-42",
relay: "wss://relay.example",
eventId: "v1"
});
});
it("should handle reaction update from subscription", (t) => {
@@ -63,35 +82,41 @@ describe("ReactionController", () => {
subCallback = cb;
return () => {};
};
const updateSummaryMock = t.mock.method(mockVideoModal, "updateReactionSummary");
const updateSummaryMock = t.mock.method(
mockVideoModal,
"updateReactionSummary"
);
controller.subscribe(["e", "v1"], "e:v1");
// subscribe calls unsubscribe which calls resetState which calls updateReactionSummary (1st call)
subCallback({
total: 10,
counts: { "+": 10 },
reactions: { "user1": { content: "+" } }
total: 10,
counts: { "+": 10 },
reactions: { user1: { content: "+" } }
});
// subCallback calls updateReactionSummary (2nd call)
assert.strictEqual(updateSummaryMock.mock.callCount(), 2);
assert.deepStrictEqual(updateSummaryMock.mock.calls[1].arguments[0], {
total: 10,
counts: { "+": 10, "-": 0 },
userReaction: "+"
total: 10,
counts: { "+": 10, "-": 0 },
userReaction: "+"
});
});
it("should apply optimistic update on handleReaction", async (t) => {
const updateSummaryMock = t.mock.method(mockVideoModal, "updateReactionSummary");
const updateSummaryMock = t.mock.method(
mockVideoModal,
"updateReactionSummary"
);
const publishMock = t.mock.method(mockReactionCounter, "publish");
// Pre-seed state
controller.reactionState = {
total: 5,
counts: { "+": 5, "-": 0 },
userReaction: ""
total: 5,
counts: { "+": 5, "-": 0 },
userReaction: ""
};
await controller.handleReaction({ reaction: "+" });
@@ -110,16 +135,19 @@ describe("ReactionController", () => {
it("should rollback optimistic update on publish failure", async (t) => {
mockReactionCounter.publish = async () => ({ ok: false });
const updateSummaryMock = t.mock.method(mockVideoModal, "updateReactionSummary");
const updateSummaryMock = t.mock.method(
mockVideoModal,
"updateReactionSummary"
);
// We need to update controller.ui.showError because constructor copied the reference
const showErrorMock = t.mock.method(mockUi, "showError");
controller.ui.showError = showErrorMock;
controller.reactionState = {
total: 5,
counts: { "+": 5, "-": 0 },
userReaction: ""
total: 5,
counts: { "+": 5, "-": 0 },
userReaction: ""
};
await controller.handleReaction({ reaction: "+" });
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more