Compare commits

...
Author SHA1 Message Date
thePR0M3TH3AN 4b975c17dc Merge unstable into beta before NIP-71 promotion 2026-07-26 12:53:47 -04:00
thePR0M3TH3AN 87b3012e34 Merge main into beta before NIP-71 promotion 2026-07-26 12:53:44 -04:00
thePR0M3TH3AN e8e2b04de7 fix(nip71): keep upload mirrors in sync 2026-07-26 12:51:00 -04:00
thePR0M3TH3AN 2f3028f2e4 fix: keep autoplay fallback from saving mute
(cherry picked from commit f373bb9ce7)
2026-07-26 12:34:15 -04:00
thePR0M3TH3AN f0a3ede3e5 Preserve autoplay preference on fallback; reload open comment thread after login
### Motivation

- Prevent a browser-policy muted-autoplay retry from overwriting a viewer's explicit mute/unmute preference and ensure fallback mutes are transient.
- Ensure that when a user logs in while a video comment modal is open, the open thread is reloaded (not just refreshed) so comments continue to display correctly after relay-backed services are reconfigured.

### Description

- Add `js/utils/autoplayPreference.js` with `muteForAutoplayFallback` and `consumeAutoplayFallbackMute` helpers to mark and consume transient fallback mutes.
- Update `js/services/playbackService.js` to treat sound as default unless the viewer explicitly chose mute, persist explicit choices via `localStorage`, ignore fallback-generated volume changes using `consumeAutoplayFallbackMute`, and expose `muteForAutoplayFallback` delegating to the new util.
- Replace direct `videoElement.muted = true` calls with `playbackService.muteForAutoplayFallback` in `js/app/playbackCoordinator.js` and `js/webtorrent.js` so fallback mutes are marked as transient.
- Change `js/app/authSessionCoordinator.js` to reload the currently open video comment thread with `commentController.load(activeModalVideo)` after login if a modal video is active, instead of only calling `refreshAuthState`.
- Update tests in `tests/services/playbackService.test.mjs` and add a test in `tests/app/login-defers-dms.test.mjs` to cover the login behavior that reloads an open comment thread.

### Testing

- Ran the `PlaybackService` unit tests (`tests/services/playbackService.test.mjs`) which assert `prepareVideoElement` behavior, explicit mute persistence, and the transient nature of fallback mute, and they passed.
- Ran the auth login unit tests including `tests/app/login-defers-dms.test.mjs` which verifies DM deferral and reload of an open comment thread, and they passed.

(cherry picked from commit ce14004725)
2026-07-26 12:33:57 -04:00
thePR0M3TH3AN d8e5113efd fix: keep autoplay fallback from saving mute
(cherry picked from commit f373bb9ce7)
2026-07-26 12:33:57 -04:00
thePR0M3TH3AN 04c8511a50 Merge pull request #2787 from PR0M3TH3AN/codex/enable-sound-by-default-in-player-2ks9wb
Whitelist-scoped Most Zapped feed, autoplay-fallback mute handling, BitLogin DOM hardening, and auth-thread reload
2026-07-26 09:23:35 -04:00
thePR0M3TH3AN ce14004725 Preserve autoplay preference on fallback; reload open comment thread after login
### Motivation

- Prevent a browser-policy muted-autoplay retry from overwriting a viewer's explicit mute/unmute preference and ensure fallback mutes are transient.
- Ensure that when a user logs in while a video comment modal is open, the open thread is reloaded (not just refreshed) so comments continue to display correctly after relay-backed services are reconfigured.

### Description

- Add `js/utils/autoplayPreference.js` with `muteForAutoplayFallback` and `consumeAutoplayFallbackMute` helpers to mark and consume transient fallback mutes.
- Update `js/services/playbackService.js` to treat sound as default unless the viewer explicitly chose mute, persist explicit choices via `localStorage`, ignore fallback-generated volume changes using `consumeAutoplayFallbackMute`, and expose `muteForAutoplayFallback` delegating to the new util.
- Replace direct `videoElement.muted = true` calls with `playbackService.muteForAutoplayFallback` in `js/app/playbackCoordinator.js` and `js/webtorrent.js` so fallback mutes are marked as transient.
- Change `js/app/authSessionCoordinator.js` to reload the currently open video comment thread with `commentController.load(activeModalVideo)` after login if a modal video is active, instead of only calling `refreshAuthState`.
- Update tests in `tests/services/playbackService.test.mjs` and add a test in `tests/app/login-defers-dms.test.mjs` to cover the login behavior that reloads an open comment thread.

### Testing

- Ran the `PlaybackService` unit tests (`tests/services/playbackService.test.mjs`) which assert `prepareVideoElement` behavior, explicit mute persistence, and the transient nature of fallback mute, and they passed.
- Ran the auth login unit tests including `tests/app/login-defers-dms.test.mjs` which verifies DM deferral and reload of an open comment thread, and they passed.
2026-07-26 09:23:05 -04:00
thePR0M3TH3AN 4458b269be Merge pull request #2786 from PR0M3TH3AN/codex/enable-sound-by-default-in-player
Preserve autoplay preference on fallback; reload open comment thread after login
2026-07-26 08:59:35 -04:00
thePR0M3TH3AN f373bb9ce7 fix: keep autoplay fallback from saving mute 2026-07-26 08:58:58 -04:00
thePR0M3TH3AN 2cda5025d4 fix(zaps): scope discovery to approved creators 2026-07-25 23:24:49 -04:00
thePR0M3TH3AN 7739a934f9 docs: update zap progress tracking 2026-07-25 23:18:09 -04:00
thePR0M3TH3AN 98c623c85a Promote beta to main: trust controls and hardened BitLogin 2026-07-25 22:48:54 -04:00
thePR0M3TH3AN 0513c7fcd8 test(moderation): align channel blur fixture with threshold 2026-07-25 22:48:46 -04:00
thePR0M3TH3AN 855022e911 Promote unstable to beta: trust controls and hardened BitLogin 2026-07-25 22:43:27 -04:00
thePR0M3TH3AN 589bda8cb0 feat(auth): enable BitLogin on unstable 2026-07-25 22:33:32 -04:00
thePR0M3TH3AN 18e08bf2cb feat: refine trust controls and harden BitLogin 2026-07-25 22:25:50 -04:00
thePR0M3TH3ANandClaude Sonnet 5 f25c2ac242 Re-vendor bitlogin widget: local session persistence
Pulls in bitlogin's session-persistence feature -- after a successful
sign-in, the everyday private key is cached in IndexedDB for this
origin, so a page reload restores the session instead of asking for
the login name + password again. No changes needed in
bitloginModalIntegration.js: the existing "bitlogin-login" listener
fires the same way whether the sign-in was just typed or silently
restored. See bitlogin's CHANGELOG.md (2026-07-22 entry).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
2026-07-22 07:50:06 -04:00
thePR0M3TH3ANandClaude Sonnet 5 4d1116530b Surface BitLogin sign-in rejections instead of silently swallowing them
devLogger.warn is a no-op outside dev mode (IS_DEV_MODE = false on
this deployment), so when AuthService.requestLogin() rejected a
BitLogin sign-in -- e.g. the invite-only access-control check
("Access restricted to admins and moderators users only.") -- nothing
ever told the user why. The widget itself doesn't know bitvid rejected
it, so it kept showing its own "Signed in" screen, and the login modal
never closed, looking exactly like a hung/broken close button.

On rejection: route the error through app.handleLoginModalError (the
same visible-banner path every other sign-in method already uses) and
reset the widget via widget.logout() so it doesn't keep claiming a
session bitvid never actually granted.

Also added a WeakSet guard so wireBitloginLogin() can never attach two
"bitlogin-login" listeners to the same element, regardless of how
attachWidget() ends up getting invoked -- cheap insurance, no observed
bug currently triggers it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
2026-07-22 00:19:43 -04:00
thePR0M3TH3ANandClaude Sonnet 5 f2d390ff18 Wire up NIP-04 in the BitLogin adapter, flip capabilities.nip04 to true
bitlogin's widget now implements NIP-04 (nip04Encrypt/nip04Decrypt),
closing the one gap left relative to a real NIP-07 extension. bitvid's
own nip07Adapter/nsecAdapter/nip46Adapter all already wire this up and
report it via signerCapabilities.nip04 -- BitLogin's adapter was the
one exception, hardcoded to nip04: false because the widget genuinely
didn't support it before. js/nostr/client.js's DM encrypt/decrypt path
only offers NIP-04 to a caller when that capability flag is true, so a
BitLogin-signed-in user previously had no working legacy-DM path at
all. Re-vendored the widget bundle (bitlogin's CHANGELOG.md, 2026-07-21
entry, third one) and updated the one test that hardcoded the old
(now-incorrect) capabilities shape.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
2026-07-21 23:32:27 -04:00
thePR0M3TH3ANandClaude Sonnet 5 ca7b711b8a Widen pinned BitLogin relay set for redundancy against a single dead relay
relay.nostr.band (shared by both relay roles pinned here) is currently
unreachable, and BitLogin's publishAndVerify() enforces a fixed
ack/readback floor of 2 regardless of relay count -- so pinning to
only 3 vault relays left zero margin the moment one went down,
surfacing as a registration/login quorum failure.

Widen the <bitlogin-auth> vault-relays/discovery-relays attributes in
components/login-modal.html to match bitlogin's own updated built-in
defaults, and re-vendor the widget bundle (see bitlogin's CHANGELOG.md
2026-07-21 entry). No CSP change needed -- this branch's connect-src
already allows wss: broadly.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
2026-07-21 21:44:40 -04:00
thePR0M3TH3AN 88c906fd34 Add BitLogin as the primary sign-in method (gated, unstable)
The login modal now shows <bitlogin-auth> -- a portable Nostr identity
behind a login name and password -- as the default sign-in path,
styled with bitvid's own brand tokens (accent-strong, surfaces, radii).
The existing NIP-07 extension, nsec, and NIP-46 remote-signer options
still work exactly as before, just collapsed under a new "Other login
options" fold instead of a flat "Available methods" grid; nip07 no
longer carries the stale "Recommended" eyebrow now that BitLogin has
that role.

BitLogin isn't a single button-click-then-await-a-promise flow like
the other providers -- it's a whole multi-screen sign-in/create/recover
UI -- so it gets a permanent, statically-declared widget in
components/login-modal.html (js/ui/bitloginModalIntegration.js relocates
it, never destroys and recreates it, since removal tears down its crypto
Web Worker) instead of a grid button. It still goes through the exact
same session-establishment path as every other signer: a new "bitlogin"
provider (js/services/authProviders/bitlogin.js) lets AuthService
resolve and activate it via the normal requestLogin -> handleAuthLogin
path, while providersForModal excludes it from the grid so it isn't
rendered twice.

Two things this needed beyond the wiring itself:
- js/app.js is one line from its lint/file-size ceiling, so the only
  change there is renaming an import binding (providers -> providersForModal),
  a net-zero line change. The actual widget/event wiring lives in a new,
  unconstrained module instead.
- The login modal's HTML (including the permanent widget element) loads
  asynchronously after bootstrap runs, so wireBitloginLogin() waits via
  MutationObserver rather than assuming a specific call order.

Vendored via scripts/build-bitlogin-widget.mjs (mirrors the existing
Bitcoin Connect/Blossom vendor steps): BitLogin isn't published to npm,
so the widget build is copied verbatim from the sibling ../bitlogin
checkout into committed vendor/bitlogin/, then lazy-imported (like
Bitcoin Connect) so its ~350KB never weighs on visitors who never open
the login modal.

Gated behind FEATURE_BITLOGIN (config/instance-config.js), default on
for this unstable-branch experiment per AGENTS.md §1 -- reset to false
before this reaches main until verified end-to-end against production
relays.

Verified: npm run lint (clean), the full auth/login test suite (43/43,
including new coverage for the provider + adapter), and a manual
Chrome pass against the built dist/ -- widget renders themed correctly,
"Other login options" still shows the original four methods unchanged.
2026-07-21 20:56:01 -04:00
68 changed files with 17741 additions and 180 deletions

No files matched your search

+35 -5
View File
@@ -47,18 +47,48 @@
>
<div class="space-y-2 text-left">
<p class="text-sm text-text-muted">
Choose how you'd like to sign in. Connect a browser extension, link a
remote signer, or work directly with your keys when you need to.
Use BitLogin for a password-style sign-in, or connect the Nostr
identity you already use. Both keep you in control of your keys.
</p>
</div>
<!--
Permanent, statically-declared mount for <bitlogin-auth> -- never
written by an innerHTML re-render (this markup never changes after
the page loads), so the element is only ever hidden/shown, never
destroyed and recreated. Removing it from the DOM tears down its
crypto Web Worker (see BitLogin's README).
-->
<div
id="bitloginMount"
class="rounded-3xl border border-border bg-overlay-panel-soft/90 p-4 shadow-lg backdrop-blur-sm sm:p-5"
>
<div class="mb-3 space-y-1">
<h3 class="text-base font-semibold text-text">BitLogin</h3>
<p class="text-sm text-text-muted">
A portable login name and password for BitLogin-enabled sites.
</p>
</div>
<bitlogin-auth
id="bitloginWidget"
vault-relays="wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band,wss://nostr.wine,wss://relay.snort.social"
discovery-relays="wss://purplepag.es,wss://relay.nostr.band,wss://nostr-pub.wellorder.net"
></bitlogin-auth>
</div>
<section
class="space-y-5 rounded-3xl border border-border bg-overlay-panel-soft/90 p-4 shadow-lg backdrop-blur-sm sm:p-5"
class="space-y-5 rounded-3xl border border-accent/40 bg-overlay-panel-soft/90 p-4 shadow-lg backdrop-blur-sm sm:p-5"
aria-labelledby="nostrLoginOptionsTitle"
>
<div class="space-y-1">
<h3 class="text-base font-semibold text-text">Available methods</h3>
<p class="text-xs font-semibold uppercase tracking-wide text-accent">
Nostr sign-in
</p>
<h3 id="nostrLoginOptionsTitle" class="text-base font-semibold text-text">
Use your existing Nostr identity
</h3>
<p class="text-sm text-text-muted">
Pick the option that matches how you manage your Nostr identity.
Browser extension (NIP-07), remote signer (NIP-46), or your own key.
</p>
</div>
+1 -1
View File
@@ -1179,7 +1179,7 @@ https://another.blossom.example"></textarea>
></p>
<label id="profileMyVideosAutoShareRow" class="hidden flex items-start gap-2 text-sm text-muted-strong">
<input id="profileMyVideosAutoShare" type="checkbox" class="checkbox mt-0.5" />
<span>Auto-share new public videos to other Nostr apps (NIP-71)</span>
<span>Mirror new public videos to other Nostr apps by default (NIP-71)</span>
</label>
<p
id="profileMyVideosLoading"
+4
View File
@@ -309,6 +309,10 @@
<input type="checkbox" id="check-kids" class="checkbox" />
<span class="text-sm text-text">For Kids</span>
</label>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" id="check-no-nip71-mirror" class="checkbox" />
<span class="text-sm text-text">Do not share this video to other Nostr apps</span>
</label>
</div>
<!-- Advanced Options -->
+20 -6
View File
@@ -276,6 +276,20 @@ export const FEATURE_AUDIO_INGEST = false;
*/
export const FEATURE_BITCOIN_CONNECT = true;
/**
* Whether the login modal offers BitLogin (a portable Nostr identity behind a
* login name/password, https://github.com/PR0M3TH3AN/bitlogin) as the primary
* sign-in method, with the NIP-07 extension, nsec, and NIP-46 options
* collapsed under "Other login options" (js/ui/bitloginModalIntegration.js).
* The vendored widget bundle (vendor/bitlogin/, scripts/build-bitlogin-widget.mjs)
* only lazy-loads once the login modal's HTML is present in the DOM.
*
* Enabled on unstable for the final production-relay smoke test. Keep this
* gated from beta/main until that manual login, reload, logout, and recovery
* pass is confirmed.
*/
export const FEATURE_BITLOGIN = true;
/**
* Whether this deployment offers Blossom (nostr-native blob storage) as a
* first-class upload provider alongside Cloudflare R2 / generic S3 / Backblaze B2
@@ -359,9 +373,9 @@ export const DEFAULT_TRUST_SEED_NPUBS = [
* When a video receives at least this many trusted reports for the active
* category (for example, `nudity`), thumbnails render with the blurred preview
* by default. Align this value with your moderation policy — the upstream
* deployment targets 1 trusted report before blurring.
* deployment requires 3 trusted reports before blurring.
*/
export const DEFAULT_BLUR_THRESHOLD = 1;
export const DEFAULT_BLUR_THRESHOLD = 3;
/**
* Baseline trusted-report count that blocks autoplay during browsing.
@@ -369,9 +383,9 @@ export const DEFAULT_BLUR_THRESHOLD = 1;
* Videos that meet or exceed this count will not autoplay in feeds unless the
* viewer explicitly opts in. Increase the number for a more permissive stance
* or decrease it if you want autoplay to stop sooner. The upstream deployment
* targets 1 trusted report before blocking autoplay.
* requires 3 trusted reports before blocking autoplay.
*/
export const DEFAULT_AUTOPLAY_BLOCK_THRESHOLD = 1;
export const DEFAULT_AUTOPLAY_BLOCK_THRESHOLD = 3;
/**
* Trusted mute threshold that hides creators globally.
@@ -388,10 +402,10 @@ export const DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD = 20;
*
* When a video's trusted report count reaches this number, bitvid treats it as
* spam or policy violating content and removes it from default feeds. Align
* the value with your moderation policy — 1 trusted report is the upstream
* the value with your moderation policy — 5 trusted reports is the current
* baseline.
*/
export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD = 1;
export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD = 5;
/**
* Optional theme accent overrides for light and dark mode.
@@ -56,7 +56,7 @@ To maintain a functional and ethical platform, the following types of content ar
bitvid enforces these guidelines through client-side visibility rules that match the current `moreMenuController` and `userBlocks` behavior:
- **Blurred previews:** When trusted NIP-56 `nudity` reports reach the configured blur threshold (`DEFAULT_BLUR_THRESHOLD` in [`config/instance-config.js`](../config/instance-config.js)), thumbnails render blurred and autoplay previews pause. Every blur includes a “show anyway” override. The upstream configuration currently uses one trusted report, but adjust the export to match your policy.
- **Blurred previews:** When trusted NIP-56 `nudity` reports reach the configured blur threshold (`DEFAULT_BLUR_THRESHOLD` in [`config/instance-config.js`](../config/instance-config.js)), thumbnails render blurred and autoplay previews pause. Every blur includes a “show anyway” override. The hosted default requires three trusted reports.
- **Downranked authors:** Muting a creator adds them to your NIP-51 mute list (kind `10000`). Videos from muted creators are deprioritized but still visible with clear badges so you can reverse the action.
- **Personal blocks:** Blocking a creator adds them to your private block list (`userBlocks`). Videos from blocked creators are hidden from feeds, carousels, and modals unless you explicitly unblock them.
- **Admin hard hides:** Moderator-maintained blacklists (NIP-51 kind `30000`) remove creators from default feeds for everyone using the hosted client. Moderators can still review the content through “show anyway” controls when auditing decisions.
@@ -88,7 +88,7 @@ You can manage moderation directly from the client using the existing menus rend
### **5.5 Safety & Moderation thresholds**
- Open your profile avatar → **Profile** → **Safety & Moderation** to adjust blur and autoplay thresholds. Leave a field blank to return to the defaults exported from [`config/instance-config.js`](../config/instance-config.js) (`DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`). The upstream repo ships example values of 1 trusted report for blur, 1 for autoplay blocking, 20 trusted mutes to hide authors, and 1 trusted spam report to hide videos—tune the config file before deployment if your community needs different baselines.
- Open your profile avatar → **Profile** → **Safety & Moderation** to adjust blur and autoplay thresholds. Leave a field blank to return to the defaults exported from [`config/instance-config.js`](../config/instance-config.js) (`DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`). The hosted defaults require 3 trusted reports for blur and autoplay blocking, 20 trusted mutes to hide authors, and 5 trusted spam reports to hide videos—tune the config file before deployment if your community needs different baselines.
- These controls update immediately and sync with the moderation overlays applied by `moreMenuController` and `VideoCard` components. Operators can edit the config file to change the baseline before deploying their own instance.
## **6. Appeals and Feedback**
+72
View File
@@ -751,6 +751,22 @@
transition-property: border-color, box-shadow, transform;
}
.site-update-notice {
@apply fixed bottom-5 right-5 z-50 flex max-w-sm flex-col items-end gap-3;
}
.site-update-notice__button {
@apply btn focus-ring rounded-full px-4 py-3 text-sm shadow-lg;
}
.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;
}
.site-update-notice__close {
@apply btn-ghost focus-ring -mr-2 -mt-2 h-9 w-9 rounded-full p-0 text-lg leading-none;
}
.login-option::after {
content: "";
position: absolute;
@@ -880,6 +896,29 @@
position: relative;
}
/* Maps the <bitlogin-auth> widget's own theming hooks onto bitvid's design
tokens (components/login-modal.html), so it reads as part of this site
rather than a foreign embed, and follows the light/dark theme toggle
automatically since every token below is itself theme-aware. */
bitlogin-auth {
display: block;
--bl-accent: var(--color-accent-strong);
--bl-accent-hover: var(--color-accent);
--bl-accent-fg: rgb(255 255 255);
--bl-bg: var(--surface-panel);
--bl-fg: var(--text-primary);
--bl-muted: var(--text-muted);
--bl-border: var(--border-default);
--bl-input-bg: var(--surface-sunken);
--bl-danger: var(--status-danger);
--bl-danger-bg: var(--status-danger-surface);
--bl-warn: var(--status-warning);
--bl-warn-bg: var(--status-warning-surface);
--bl-radius: var(--radius-lg);
--bl-font-family: inherit;
--bl-max-width: none;
}
.btn-ghost[data-state="active"],
.btn-ghost[aria-pressed="true"] {
@apply border-transparent bg-primary text-white shadow-md;
@@ -4491,6 +4530,39 @@
-webkit-overflow-scrolling: touch;
}
/* The player owns a single scroll region. On mobile, `100vh` can extend
underneath the browser controls/home indicator, which previously made the
final composer action hard to reach. Prefer the dynamic viewport and keep
scrolling on the panel (where the sticky player navigation listens). */
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
#playerModal {
height: 100vh;
height: 100dvh;
min-height: 100vh;
min-height: 100dvh;
overflow: hidden;
}
#playerModal > .bv-modal__panel {
block-size: 100%;
min-block-size: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
#playerModal .player-modal__container {
block-size: auto;
min-block-size: 100%;
}
#playerModal .comment-composer {
padding-bottom: max(var(--space-lg), env(safe-area-inset-bottom));
scroll-margin-block-end: max(var(--space-lg), env(safe-area-inset-bottom));
}
}
#modalVideo {
width: 100%;
aspect-ratio: 16 / 9;
+1 -1
View File
@@ -9,7 +9,7 @@ Instead of a centralized moderation authority, the service builds a local "trust
- **Viewer**: The currently logged-in user.
- **Trusted Contacts**: Users followed by the Viewer (Kind 3 contact list).
- **Trusted Seeds**: An optional set of admin-defined pubkeys that provide a baseline of trust (e.g., project maintainers).
- **Admin Whitelist/Blacklist**: Hardcoded lists in `accessControl` that override dynamic trust.
- **Admin Whitelist/Blacklist**: Nostr-backed lists in `accessControl`. The creator whitelist controls access only; a blacklist always filters first and suppresses its members' content and reports.
### 2. Trust Aggregation
When a piece of content (Event) is reported (Kind 1984), the service aggregates these reports but *only counts reports from trusted users*.
+4 -4
View File
@@ -6,7 +6,7 @@ bitvid is follow-centric. Your Home feed comes from people you follow (F1). Disc
## Core principles
- **Freedom to choose**: User picks filters. Defaults are safe but reversible.
- **Explain decisions**: Every blur/hide shows a “why” badge and a “show anyway” control.
- **Blacklist > whitelist**: Admin blacklists and moderation gates always win; whitelists only influence Discovery ranking.
- **Separate access and trust**: The creator whitelist controls site and content access only; moderation uses the viewer's trust graph and admin blacklists.
- **Minimal central power**: Admin lists are opt-in; users can unsubscribe.
## Threat model (short)
@@ -47,10 +47,10 @@ Thread new moderation behaviors through the same service → stage → app → U
- Hide videos when `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` trusted contacts mute the author. Cards render with `data-moderation-hidden="true"`, the badge reads `Hidden · {count} trusted mute(s)`, and a "Show anyway" button becomes available alongside a "Restore default moderation" control once the override is active.
- Hide videos when `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` trusted contacts file spam reports. The badge copy escalates to `Hidden · {count} trusted spam report(s)` and the card stays hidden until the viewer overrides it.
- Downrank author when any F1 has them in mute list (10000).
- Opt-in admin lists (30000 with `d=bitvid:admin:*`) can hard-hide content, but whitelist entries no longer bypass moderation gates—they only influence Discovery rankings when a viewer opts into that list.
- Opt-in admin lists (30000 with `d=bitvid:admin:*`) can hard-hide content through the blacklist. The creator whitelist is access-only and does not change web-of-trust blur, autoplay, or hide decisions.
- Trust seeds now come from the Super Admin plus every active moderator, so their reports shape anonymous/default visitor filters automatically. The `DEFAULT_TRUST_SEED_NPUBS` export only activates as an emergency fallback when those live lists cannot be loaded (toggle via `FEATURE_TRUST_SEEDS`).
> Default thresholds now come directly from [`config/instance-config.js`](../../config/instance-config.js). Look for the exports `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` to adjust the instance-wide behavior before deploying. The upstream repo currently publishes example values of 1 trusted report for blur, 1 for autoplay blocking, 20 trusted mutes to hide authors, and 1 trusted spam report to hide videos.
> Default thresholds now come directly from [`config/instance-config.js`](../../config/instance-config.js). Look for the exports `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` to adjust the instance-wide behavior before deploying. The hosted defaults require 3 trusted reports for blur and autoplay blocking, 20 trusted mutes to hide authors, and 5 trusted spam reports to hide videos.
> Operators can still override these thresholds on a per-viewer basis in **Settings → Safety & Moderation**; leaving the UI fields blank restores the config-driven defaults. Update [`config/instance-config.js`](../../config/instance-config.js) to change what new viewers receive out of the box.
@@ -89,7 +89,7 @@ The trusted mute hide helper copy clarifies the escalation path: once the truste
### Runtime flags
- `TRUSTED_MUTE_HIDE_THRESHOLD` — numeric default for the trusted mute hide control. Initialized from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the upstream config sets this to `20`, but adjust the export to match your policy.
- `TRUSTED_SPAM_HIDE_THRESHOLD` — numeric default for the trusted spam hide control. Initialized from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the upstream config example is `1`.
- `TRUSTED_SPAM_HIDE_THRESHOLD` — numeric default for the trusted spam hide control. Initialized from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the hosted default is `5`.
- `FEATURE_TRUSTED_HIDE_CONTROLS` — boolean toggle to hide/show the new trusted hide controls in the UI (default `true`).
## Files in this folder
+2 -2
View File
@@ -40,8 +40,8 @@ Create three distinct F1 reporter keys; each sends:
Expected:
* After 1 report → no blur; autoplay allowed.
* Once reports reach `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` → autoplay blocked (the upstream configuration currently sets this to 2, but rely on your `config/instance-config.js` value).
* Once reports reach `DEFAULT_BLUR_THRESHOLD` → thumbnail blurred; reason chip shown (upstream example: 1). At the spam threshold (`TRUSTED_SPAM_HIDE_THRESHOLD` sourced from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js)) the card hides entirely and the badge copy switches to "Hidden · {count} trusted spam report(s)" until the viewer overrides it.
* Once reports reach `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` → autoplay blocked (the hosted configuration currently sets this to 3, but rely on your `config/instance-config.js` value).
* Once reports reach `DEFAULT_BLUR_THRESHOLD` → thumbnail blurred; reason chip shown (the hosted default is 3). At the spam threshold (`TRUSTED_SPAM_HIDE_THRESHOLD` sourced from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js), currently 5) the card hides entirely and the badge copy switches to "Hidden · {count} trusted spam report(s)" until the viewer overrides it.
**Exercises:** [`ModerationService.ingestReportEvent()`](../../js/services/moderationService.js) and [`ModerationService.getTrustedReportSummary()`](../../js/services/moderationService.js) wire through [`createModerationStage()`](../../js/feedEngine/stages.js) so [`bitvidApp.decorateVideoModeration()`](../../js/app.js) can hand the summary to [`VideoCard.refreshModerationUi()`](../../js/ui/components/VideoCard.js).
+2 -2
View File
@@ -54,8 +54,8 @@ list. Blocking still follows the precedence rules above.
- A video has 3 `nudity` reports from those seed accounts.
Result:
- Thumbnail is blurred if `DEFAULT_BLUR_THRESHOLD` is 1 (met).
- Autoplay is blocked if `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` is 1 (met).
- Thumbnail is blurred because the 3 trusted reports meet `DEFAULT_BLUR_THRESHOLD`.
- Autoplay is blocked because the 3 trusted reports meet `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`.
- No hard hide unless trusted mute/spam thresholds are met.
### Example 2: Logged-in viewer with personal blocks
+3 -3
View File
@@ -20,7 +20,7 @@
- `hideIfTrustedMuteCount(author, category) >= trustedMuteHideThresholds[category] ?? DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`
- `hideIfTrustedSpamReports(event) >= DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`
Threshold constants are exported from [`config/instance-config.js`](../../config/instance-config.js) so operators can change the defaults without touching moderation code. Inspect the `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` exports to set your policy. The upstream repo includes example values (blur at 1, autoplay block at 1, trusted mute hide at 20, trusted spam hide at 1), but treat those as guidance rather than hard-coded requirements. Per-category trusted mute hide thresholds now live in moderation settings as `trustedMuteHideThresholds` (e.g., `{ spam: 5, nudity: 10 }`), and any category not present falls back to the default trusted mute hide threshold. When evaluating trusted mutes we use the content's report type if available, otherwise we fall back to any category metadata attached to trusted mute list entries.
Threshold constants are exported from [`config/instance-config.js`](../../config/instance-config.js) so operators can change the defaults without touching moderation code. The hosted baseline requires 3 trusted reports to blur or block autoplay, 20 trusted mutes to hide an author, and 5 trusted spam reports to hide a video. Per-category trusted mute hide thresholds now live in moderation settings as `trustedMuteHideThresholds` (e.g., `{ spam: 5, nudity: 10 }`), and any category not present falls back to the default trusted mute hide threshold. When evaluating trusted mutes we use the content's report type if available, otherwise we fall back to any category metadata attached to trusted mute list entries.
### Trusted mute decay window
- Trusted mute counts are **time-bounded**: only mute lists updated within the rolling window (currently 60 days) contribute to `trustedMuteCount`.
@@ -34,7 +34,7 @@ Threshold constants are exported from [`config/instance-config.js`](../../config
## Admin lists (opt-in)
- We recognize curated lists using `30000` events:
- `['d','bitvid:admin:blacklist']` → hard-hide when subscribed.
- `['d','bitvid:admin:whitelist']` → improves Discovery ranking when subscribed but no longer bypasses moderation gates.
- `['d','bitvid:admin:whitelist']` → controls creator access only; it does not add a reporter to the trust graph or alter moderation decisions.
- `['d','bitvid:admin:editors']` → trusted channel editors.
- Users can subscribe/unsubscribe any time.
@@ -42,7 +42,7 @@ Threshold constants are exported from [`config/instance-config.js`](../../config
1. **Personal blocks win first.** If a viewer blocks an author or reporter, we ignore their content and reports regardless of admin lists.
2. **Admin blacklist applies next.** Entries on `bitvid:admin:blacklist` are hard-hidden and their reports suppressed before looking at thresholds.
3. **F1 thresholds run last.** Blur/autoplay gating only evaluates trusted-report counts after personal blocks and admin blacklists. Admin whitelists contribute to Discovery ranking when a viewer subscribes, but they never override moderation gates or a viewer block.
3. **F1 thresholds run last.** Blur/autoplay gating evaluates trusted-report counts after personal blocks and admin blacklists. The creator whitelist is not an input to these decisions.
## Pseudocode
+3 -3
View File
@@ -20,7 +20,7 @@ Instead of a central authority deciding what is "safe," the service aggregates r
- Users can report content for reasons like `nudity`, `spam`, `illegal`, etc.
- Reports are public events signed by the reporter.
- The service subscribes to reports for active content and aggregates them locally.
- **Crucially**: Reports from users *outside* the trusted contact list are ignored (unless whitelisted by admin).
- **Crucially**: Reports from users *outside* the trusted contact list are ignored. Creator whitelist membership does not establish moderation trust.
### NIP-51 Mutes (Kind 10000)
- Users can mute other users to hide their content entirely.
@@ -28,7 +28,7 @@ Instead of a central authority deciding what is "safe," the service aggregates r
- It also supports "Trusted Mutes" where mutes from trusted contacts can influence visibility (though this feature is evolving).
### Admin Overrides
- The service integrates with `AccessControl` to respect an instance-wide **Blacklist** (always hidden) and **Whitelist** (always trusted).
- The service integrates with `AccessControl` to respect an instance-wide **Blacklist** (always hidden). The creator whitelist is access-only and never establishes moderation trust.
## Architecture & Data Flow
@@ -89,7 +89,7 @@ await moderationService.submitReport({
## Invariants
- **Trust is Subjective**: A report only counts if the *viewer* follows the reporter. Changing the viewer changes all trust scores immediately.
- **Admin Trumps Trust**: Blacklisted users are ignored even if followed. Whitelisted users are trusted even if not followed.
- **Admin Trumps Trust**: Blacklisted users are ignored even if followed. Creator whitelist membership neither grants nor removes trust.
- **Reactive**: The service emits events (`summary`, `contacts`, `user-blocks`) to drive UI updates; it does not directly manipulate the DOM.
## When to Change
+1 -1
View File
@@ -38,7 +38,7 @@ Bootstrapping is intentionally defensive so the UI can initialize quickly:
- **Access control hydration**: `waitForAccessControl()` races `accessControl.ensureReady()` against a 3.5s timeout. If hydration times out, bootstrapping logs a dev warning and falls back to defaults.
- **Immediate apply**: Seeds are applied immediately after the first hydration attempt, even if the admin list hydration failed, so moderation does not block on relay availability.
- **Retry when relays are ready**: If hydration failed, `bootstrapTrustedSeeds()` checks for relay readiness via `nostrService.nostrClient.relays` (and pool existence). If relays become available within 3.5s, it retries admin list hydration once. If relays never become ready, it logs a dev warning and re-applies the current seeds to keep moderation functional.
- **Change listeners**: `accessControl.onWhitelistChange()` and `accessControl.onEditorsChange()` are registered so any subsequent admin/editor updates re-run seed application and recompute moderation summaries.
- **Change listeners**: `accessControl.onEditorsChange()` and `accessControl.onBlacklistChange()` are registered so changes affecting the moderator roster or eligibility re-run seed application and recompute moderation summaries. Creator-whitelist changes do not affect trust seeds.
## Relay list persistence and refresh
+1 -1
View File
@@ -67,7 +67,7 @@ import {
normalizeVideoNotePayload,
} from "./services/videoNotePayload.js";
import getAuthProvider, {
providers as authProviders,
providersForModal as authProviders,
} from "./services/authProviders/index.js";
import hashtagPreferences, {
HASHTAG_PREFERENCES_EVENTS,
+12 -1
View File
@@ -232,7 +232,18 @@ export function createAuthSessionCoordinator(deps) {
}
this.applyAuthenticatedUiState();
this.commentController?.refreshAuthState?.();
const activeModalVideo =
typeof this.videoModal?.getCurrentVideo === "function"
? this.videoModal.getCurrentVideo()
: this.commentController?.currentVideo || null;
if (this.commentController && activeModalVideo) {
// Login can reconfigure relay-backed services. Reload the active thread
// instead of only enabling its composer so readable comments survive
// the transition from guest to authenticated state.
this.commentController.load(activeModalVideo);
} else {
this.commentController?.refreshAuthState?.();
}
this.updateShareNostrAuthState({ reason: "auth-login" });
if (typeof this.refreshUnreadDmIndicator === "function") {
void this.refreshUnreadDmIndicator({ reason: "auth-login" });
+1 -1
View File
@@ -237,7 +237,7 @@ export function createPlaybackCoordinator(deps) {
this.log("Autoplay failed:", err);
if (!this.modalVideo.muted) {
this.log("Falling back to muted autoplay.");
this.modalVideo.muted = true;
this.playbackService.muteForAutoplayFallback(this.modalVideo);
this.modalVideo.play().catch((err2) => {
this.log("Muted autoplay also failed:", err2);
});
+2
View File
@@ -27,6 +27,7 @@ import {
FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT,
FEATURE_BITLOGIN,
FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA,
CARD_LIVENESS_POLICY,
@@ -127,6 +128,7 @@ export { ALLOW_NSFW_CONTENT };
export { FEATURE_NIP71_INGEST };
export { FEATURE_AUDIO_INGEST };
export { FEATURE_BITCOIN_CONNECT };
export { FEATURE_BITLOGIN };
export { FEATURE_BLOSSOM_STORAGE };
export { FEATURE_BLOSSOM_TORRENT_METADATA };
export { CARD_LIVENESS_POLICY };
+10
View File
@@ -9,6 +9,7 @@ import {
FEATURE_NIP71_INGEST as CONFIG_FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST as CONFIG_FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT as CONFIG_FEATURE_BITCOIN_CONNECT,
FEATURE_BITLOGIN as CONFIG_FEATURE_BITLOGIN,
FEATURE_BLOSSOM_STORAGE as CONFIG_FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA as CONFIG_FEATURE_BLOSSOM_TORRENT_METADATA,
CARD_LIVENESS_POLICY as CONFIG_CARD_LIVENESS_POLICY,
@@ -205,6 +206,9 @@ const DEFAULT_FLAGS = Object.freeze({
// Bitcoin Connect NWC "Connect wallet" flow (docs/bitcoin-connect-plan.md, #61).
// Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BITCOIN_CONNECT: CONFIG_FEATURE_BITCOIN_CONNECT === true,
// BitLogin primary sign-in method (js/ui/bitloginModalIntegration.js).
// Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BITLOGIN: CONFIG_FEATURE_BITLOGIN === true,
// Blossom (nostr-native blob storage) provider (docs/blossom-plan.md, #30).
// Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BLOSSOM_STORAGE: CONFIG_FEATURE_BLOSSOM_STORAGE === true,
@@ -241,6 +245,7 @@ const runtimeFlags = (() => {
FEATURE_NIP71_INGEST: DEFAULT_FLAGS.FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST: DEFAULT_FLAGS.FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT: DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT,
FEATURE_BITLOGIN: DEFAULT_FLAGS.FEATURE_BITLOGIN,
FEATURE_BLOSSOM_STORAGE: DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA:
DEFAULT_FLAGS.FEATURE_BLOSSOM_TORRENT_METADATA,
@@ -299,6 +304,11 @@ export let FEATURE_BITCOIN_CONNECT = coerceBoolean(
DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT
);
export let FEATURE_BITLOGIN = coerceBoolean(
runtimeFlags.FEATURE_BITLOGIN,
DEFAULT_FLAGS.FEATURE_BITLOGIN
);
export let FEATURE_BLOSSOM_STORAGE = coerceBoolean(
runtimeFlags.FEATURE_BLOSSOM_STORAGE,
DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE
+1
View File
@@ -21,6 +21,7 @@ export {
} from "./sorters.js";
export {
createActiveNostrSource,
createWhitelistedAuthorsNostrSource,
createSubscriptionAuthorsSource,
createWatchHistoryPointerSource,
} from "./sources.js";
+20 -5
View File
@@ -1,8 +1,9 @@
// js/feedEngine/mostZappedFeed.js
//
// The "Most Zapped" tab (#47): the recently-added (active) source ranked by
// ZAP TOTAL (sats from kind-9735 receipts). Mirrors trendingFeed.js — lives
// outside feedCoordinator.js (size cap) and is wired in via thin delegations.
// The "Most Zapped" tab (#47): the catalog of approved creators ranked by
// ZAP TOTAL (sats from kind-9735 receipts). It is intentionally scoped to the
// creator whitelist for discovery, but still runs the ordinary moderation
// stages — whitelisting does not waive Web-of-Trust handling.
// The metric getter uses requestVideoZapTotal, which returns the cached total
// AND schedules a batched receipt fetch for unknown pointers; the Most Zapped
// view re-runs the feed on zapTotals' change signal so the order settles as
@@ -11,7 +12,7 @@
import { FEED_TYPES } from "../constants.js";
import { requestVideoZapTotal } from "../zapTotals.js";
import {
createActiveNostrSource,
createWhitelistedAuthorsNostrSource,
createBlacklistFilterStage,
createDedupeByRootStage,
createModerationStage,
@@ -43,6 +44,19 @@ function makeGetZapTotal(app) {
};
}
function getWhitelistedAuthors(app) {
const accessControl = app?.nostrService?.accessControl;
if (typeof accessControl?.getWhitelistPubkeys !== "function") {
return [];
}
try {
const authors = accessControl.getWhitelistPubkeys();
return Array.isArray(authors) ? authors : [];
} catch (error) {
return [];
}
}
export function buildMostZappedFeedRuntime(app) {
const blacklist =
app.blacklistedEventIds instanceof Set
@@ -59,6 +73,7 @@ export function buildMostZappedFeedRuntime(app) {
? { ...moderationThresholds }
: undefined,
getZapTotal: makeGetZapTotal(app),
whitelistedAuthors: getWhitelistedAuthors(app),
};
}
@@ -94,7 +109,7 @@ export function registerMostZappedFeed(app) {
try {
return app.feedEngine.registerFeed(FEED_TYPES.MOST_ZAPPED, {
source: createActiveNostrSource({ service: app.nostrService }),
source: createWhitelistedAuthorsNostrSource({ service: app.nostrService }),
stages: [
createBlacklistFilterStage({
shouldIncludeVideo: (video, options) =>
+83
View File
@@ -62,6 +62,89 @@ export function createActiveNostrSource({ service } = {}) {
};
}
// Bounded discovery source for curated surfaces such as Most Zapped. It never
// falls back to an unscoped relay request: the caller must provide approved
// creator pubkeys in runtime.whitelistedAuthors. The successful remote result
// is retained briefly so an asynchronous ranking update does not turn into a
// second full relay scan for the same author set.
export function createWhitelistedAuthorsNostrSource({
service,
refreshTtlMs = 2 * 60 * 1000,
now = () => Date.now(),
} = {}) {
const resolvedService = resolveService(service, nostrService);
let cachedAuthorsKey = "";
let cachedVideos = [];
let fetchedAt = 0;
let inFlight = null;
const normalizeAuthors = (input) =>
Array.from(
new Set(
toArray(input)
.map(normalizeAuthor)
.filter(Boolean),
),
).sort();
return async function whitelistedAuthorsNostrSource(context = {}) {
const authors = normalizeAuthors(context?.runtime?.whitelistedAuthors);
const authorsKey = authors.join(",");
if (!authors.length) {
cachedAuthorsKey = "";
cachedVideos = [];
fetchedAt = 0;
return [];
}
const cacheIsCurrent =
authorsKey === cachedAuthorsKey &&
now() - fetchedAt < Math.max(0, Number(refreshTtlMs) || 0);
if (!cacheIsCurrent && !inFlight) {
const fetchByAuthors = resolvedService?.fetchVideosByAuthors;
inFlight = Promise.resolve(
typeof fetchByAuthors === "function"
? fetchByAuthors.call(resolvedService, authors)
: [],
)
.then((videos) => {
const allowed = new Set(authors);
cachedAuthorsKey = authorsKey;
cachedVideos = (Array.isArray(videos) ? videos : []).filter(
(video) => allowed.has(normalizeAuthor(video?.pubkey)),
);
fetchedAt = now();
return cachedVideos;
})
.catch((error) => {
context?.log?.(
"[whitelisted-authors-source] Failed to fetch approved creators",
error,
);
return authorsKey === cachedAuthorsKey ? cachedVideos : [];
})
.finally(() => {
inFlight = null;
});
}
const videos = cacheIsCurrent ? cachedVideos : await inFlight;
// The whitelist may change while a prior author-scoped request is still in
// flight. Filter again at return time so that stale results can never
// briefly render after an administrator removes a creator.
const allowed = new Set(authors);
return (Array.isArray(videos) ? videos : [])
.filter((video) => allowed.has(normalizeAuthor(video?.pubkey)))
.map((video) => ({
video,
metadata: {
source: "nostr:whitelisted-authors",
},
}));
};
}
export function createSubscriptionAuthorsSource({ service } = {}) {
const resolvedService = resolveService(service, nostrService);
+3 -2
View File
@@ -1135,13 +1135,14 @@ export function createModerationStage({
trustedMuteCategory,
);
const adminWhitelist = adminStatus?.whitelisted === true;
const adminWhitelistBypass = false;
const blockAutoplay =
trustedCount >= normalizedAutoplayThreshold || trustedMuted || viewerMuted;
const blurFromReports = trustedCount >= normalizedBlurThreshold;
let blurThumbnail = blurFromReports;
let blurReason = blurThumbnail ? "trusted-report" : "";
const adminWhitelist = adminStatus?.whitelisted === true;
const adminWhitelistBypass = false;
if (!item.metadata || typeof item.metadata !== "object") {
item.metadata = {};
+2
View File
@@ -54,6 +54,7 @@ import { userBlocks } from "./userBlocks.js";
import { relayManager } from "./relayManager.js";
import createApplication from "./bootstrap.js";
import SearchFilterModal from "./ui/components/SearchFilterModal.js";
import { initializeSiteUpdateNotice } from "./ui/siteUpdateNotice.js";
import { installTestHarness } from "./testHarness.js";
validateInstanceConfig();
@@ -1320,6 +1321,7 @@ async function bootstrapInterface() {
}
async function initializeInterface() {
initializeSiteUpdateNotice();
startApplication();
try {
+42
View File
@@ -0,0 +1,42 @@
// js/nostr/adapters/bitloginAdapter.js
//
// Wraps a live <bitlogin-auth> element (already signed in) as a signer matching
// the same shape as nip07Adapter/nsecAdapter/nip46Adapter. Never touches
// window.nostr and never sees the private key -- every call is proxied through
// the element's own crypto Web Worker (see BitLogin's README, "Embedding
// BitLogin" -> element-scoped methods).
export function createBitloginAdapter(widgetElement, pubkey) {
const signer = {
type: "bitlogin",
pubkey: typeof pubkey === "string" ? pubkey : "",
metadata: async () => null,
relays: async () => null,
signEvent: async (event) => widgetElement.signEvent(event),
nip44Encrypt: async (peerPubkey, plaintext) =>
widgetElement.nip44Encrypt(peerPubkey, plaintext),
nip44Decrypt: async (peerPubkey, payload) =>
widgetElement.nip44Decrypt(peerPubkey, payload),
nip04Encrypt: async (peerPubkey, plaintext) =>
widgetElement.nip04Encrypt(peerPubkey, plaintext),
nip04Decrypt: async (peerPubkey, payload) =>
widgetElement.nip04Decrypt(peerPubkey, payload),
requestPermissions: async () => ({ ok: true }),
destroy: async () => {
try {
await widgetElement.logout();
} catch (error) {
// The element is permanently mounted and reused for the next sign-in;
// a failed logout here shouldn't block switching to another signer.
}
},
canSign: () => true,
capabilities: {
sign: true,
nip44: true,
nip04: true,
},
};
return signer;
}
+85
View File
@@ -0,0 +1,85 @@
import { accessControl } from "../../accessControl.js";
const PROVIDER_ID = "bitlogin";
const PROVIDER_LABEL = "BitLogin";
const PROVIDER_DESCRIPTION =
"A portable Nostr identity behind a familiar login name and password.";
const PROVIDER_CAPABILITIES = Object.freeze([
Object.freeze({
id: "signing",
label: "Signs Nostr events",
variant: "info",
}),
Object.freeze({
id: "portable",
label: "Works on every BitLogin site",
variant: "neutral",
}),
]);
const PROVIDER_BUTTON = Object.freeze({
variant: "primary",
});
const PROVIDER_MESSAGES = Object.freeze({
loading: "Signing in with BitLogin…",
slow: "Still waiting on BitLogin…",
error: "Failed to sign in with BitLogin. Please try again.",
});
// Set by the widget's own "bitlogin-login" listener (js/ui/applicationBootstrap.js)
// right before it calls authService.requestLogin({ providerId: "bitlogin" }): by
// the time that reaches this provider's login(), the widget has already run its
// own multi-screen sign-in/create/recover flow and produced a pubkey + signer.
// This provider exists so that result goes through the exact same session-
// establishment path (AuthService.login -> handleAuthLogin) as every other
// signer, without BitLogin also rendering as a redundant button in the login
// grid -- see providersForModal in ./index.js.
let pendingResult = null;
export function setPendingBitloginResult(result) {
pendingResult = result && typeof result === "object" ? result : null;
}
export default {
id: PROVIDER_ID,
label: PROVIDER_LABEL,
description: PROVIDER_DESCRIPTION,
eyebrow: "Recommended",
tone: "accent",
badgeVariant: "info",
capabilities: PROVIDER_CAPABILITIES,
button: PROVIDER_BUTTON,
messages: PROVIDER_MESSAGES,
errorMessage: PROVIDER_MESSAGES.error,
async login({ nostrClient } = {}) {
const result = pendingResult;
pendingResult = null;
if (!result || typeof result.pubkey !== "string" || !result.pubkey.trim()) {
const error = new Error("BitLogin sign-in hasn't completed yet.");
error.code = "provider-unavailable";
throw error;
}
const pubkey = result.pubkey.trim();
if (!accessControl.canAccess(pubkey)) {
if (accessControl.isBlacklisted(pubkey)) {
throw new Error("Your account has been blocked on this platform.");
}
throw new Error("Access restricted to admins and moderators users only.");
}
if (
nostrClient?.signerManager &&
typeof nostrClient.signerManager.setActiveSigner === "function"
) {
nostrClient.signerManager.setActiveSigner(result.signer);
}
return {
authType: PROVIDER_ID,
pubkey,
signer: result.signer,
};
},
};
+15 -1
View File
@@ -2,6 +2,7 @@ import nip07 from "./nip07.js";
import nip46 from "./nip46.js";
import nsec from "./nsec.js";
import generate from "./generate.js";
import bitlogin from "./bitlogin.js";
function normalizeProviderId(provider, fallbackId) {
if (provider && typeof provider.id === "string") {
@@ -39,7 +40,7 @@ function normalizeProviderMetadata(provider, fallbackId) {
return Object.freeze({ id, label, description, badgeVariant });
}
const providerList = [nip07, nip46, nsec, generate].filter(Boolean);
const providerList = [nip07, nip46, nsec, generate, bitlogin].filter(Boolean);
const providerMap = {};
const metadataMap = {};
@@ -97,6 +98,19 @@ builtInMetadata.forEach((entry) => {
export const providers = Object.freeze({ ...providerMap });
export const providerMetadata = Object.freeze({ ...metadataMap });
// BitLogin gets its own permanent, always-visible widget in the login modal
// (components/login-modal.html) rather than a grid button -- it isn't a single
// button-click-then-await-a-promise flow like the others, it's a whole
// multi-screen sign-in/create/recover UI. Excluding it here keeps the modal's
// provider grid ("Other login options") free of a redundant, do-nothing button
// for it, while `providers.bitlogin` above still resolves normally for
// AuthService.requestLogin({ providerId: "bitlogin" }).
export const providersForModal = Object.freeze(
Object.fromEntries(
Object.entries(providerMap).filter(([id]) => id !== "bitlogin"),
),
);
const unknownMetadataCache = new Map();
function getFallbackMetadata(providerId) {
-1
View File
@@ -49,7 +49,6 @@ export default {
id: PROVIDER_ID,
label: PROVIDER_LABEL,
description: PROVIDER_DESCRIPTION,
eyebrow: "Recommended",
tone: "accent",
badgeVariant: "info",
capabilities: PROVIDER_CAPABILITIES,
+3 -3
View File
@@ -1910,7 +1910,7 @@ export class ModerationService {
*
* Algorithm:
* 1. Iterate over all reports for the event.
* 2. Check if the reporter is in the `trustedContacts` set (or Admin Whitelist).
* 2. Check if the reporter is in the `trustedContacts` set.
* 3. If trusted, increment the count for that report type (e.g., "nudity").
* 4. Update the summary state and emit change events if thresholds are crossed.
*
@@ -1966,7 +1966,7 @@ export class ModerationService {
continue;
}
const isTrustedReporter = status.whitelisted || this.trustedContacts.has(reporterHex);
const isTrustedReporter = this.trustedContacts.has(reporterHex);
for (const [type, detail] of typeMap.entries()) {
let stats = typeStats.get(type);
@@ -2092,7 +2092,7 @@ export class ModerationService {
continue;
}
const isTrustedReporter = status.whitelisted || this.trustedContacts.has(reporterHex);
const isTrustedReporter = this.trustedContacts.has(reporterHex);
if (!isTrustedReporter) {
continue;
}
+9 -11
View File
@@ -75,9 +75,10 @@ export function resolveMirrorToggle({ enabled, eligibility } = {}) {
return { action: "publish" };
}
// Account-level "auto-share new public videos" preference (per pubkey, off by
// default). When on, newly published eligible public videos are mirrored
// automatically — no per-video opt-in needed.
// Account-level default for new public videos (per pubkey, on by default). A
// missing value means "share" so existing users receive the new default; an
// explicit false is the durable account-level opt-out. An individual upload can
// still opt out without changing this preference.
function readAutoShare() {
try {
if (typeof localStorage === "undefined") {
@@ -93,7 +94,7 @@ function readAutoShare() {
export function isAutoShareEnabled(pubkey) {
const pk = norm(pubkey);
return pk ? readAutoShare()[pk] === true : false;
return pk ? readAutoShare()[pk] !== false : false;
}
export function setAutoShareEnabled(pubkey, enabled) {
@@ -102,11 +103,7 @@ export function setAutoShareEnabled(pubkey, enabled) {
return;
}
const map = readAutoShare();
if (enabled === true) {
map[pk] = true;
} else {
delete map[pk];
}
map[pk] = enabled === true;
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(AUTO_SHARE_KEY, JSON.stringify(map));
@@ -116,8 +113,9 @@ export function setAutoShareEnabled(pubkey, enabled) {
}
}
// On publish of a NEW video: mirror it only when auto-share is on AND it's
// eligible (public + hosted URL + allowed). Otherwise do nothing.
// On publish of a NEW video: mirror it only when the account default and the
// per-upload choice permit it, and it is eligible (public + hosted URL +
// allowed). Otherwise do nothing.
export function resolvePublishSync({ featureOn, autoShare, eligible } = {}) {
if (featureOn !== true || autoShare !== true) {
return { action: "none" };
+74 -37
View File
@@ -72,6 +72,36 @@ export function createNip71MirrorService({
return [];
}
},
// One relay query for all roots in the My Videos view. Kept separate from the
// single-root lookup so publish/remove can remain narrowly scoped.
fetchMirrorsByRoots = async ({ pubkey, roots }) => {
const sm = getSubscriptionManager();
const relays = getWriteRelays() || [];
const uniqueRoots = [...new Set((Array.isArray(roots) ? roots : []).map(str).filter(Boolean))];
if (!sm || typeof sm.list !== "function" || !relays.length || !uniqueRoots.length) {
return [];
}
try {
const events = await sm.list({
filters: [
{
kinds: [NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND],
authors: [pubkey],
"#d": uniqueRoots,
},
],
relays,
});
return (Array.isArray(events) ? events : []).map((event) => ({
root: str(event?.tags?.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1]),
kind: event?.kind,
created_at: Number(event?.created_at) || 0,
}));
} catch (error) {
userLogger.warn("[nip71Mirror] mirror batch lookup failed:", error);
return [];
}
},
publishEventToRelays = defaultPublishEventToRelays,
summarizePublishResults = defaultSummarize,
signEvent = async (template) => {
@@ -131,26 +161,37 @@ export function createNip71MirrorService({
let forcedShort = options.short; // explicit caller override always wins
let staleKind = null;
let reusedExistingKind = false;
if (forcedShort === undefined && pubkey && root) {
let newestExisting = null;
if (pubkey && root) {
const existing = await fetchExistingMirrors({ pubkey, root });
if (Array.isArray(existing) && existing.length) {
const newest = existing.reduce((a, b) =>
(b.created_at || 0) > (a.created_at || 0) ? b : a,
);
forcedShort = newest.kind === NIP71_SHORT_VIDEO_KIND;
reusedExistingKind = true;
const otherKind = forcedShort
? NIP71_NORMAL_VIDEO_KIND
: NIP71_SHORT_VIDEO_KIND;
if (existing.some((e) => e.kind === otherKind)) {
staleKind = otherKind;
newestExisting = newest;
if (forcedShort === undefined) {
forcedShort = newest.kind === NIP71_SHORT_VIDEO_KIND;
reusedExistingKind = true;
const otherKind = forcedShort
? NIP71_NORMAL_VIDEO_KIND
: NIP71_SHORT_VIDEO_KIND;
if (existing.some((e) => e.kind === otherKind)) {
staleKind = otherKind;
}
}
}
}
// Addressable events only replace an older coordinate. Match the canonical
// edit path: a fast second edit or a clock behind an existing relay event
// must still advance the mirror.
const createdAt = Math.max(
Math.floor(now() / 1000),
(Number(newestExisting?.created_at) || 0) + 1,
);
const built = buildMirrorEvent(
{ ...video, pubkey },
{ ...options, short: forcedShort, createdAt: Math.floor(now() / 1000) },
{ ...options, short: forcedShort, createdAt },
);
if (!built.ok) {
return { ok: false, reason: built.reason };
@@ -181,7 +222,7 @@ export function createNip71MirrorService({
const del = await signEvent({
kind: 5,
pubkey,
created_at: Math.floor(now() / 1000),
created_at: createdAt,
content: "Removed duplicate NIP-71 mirror",
tags: [
["a", `${staleKind}:${pubkey}:${root}`],
@@ -220,7 +261,13 @@ export function createNip71MirrorService({
return { ok: false, error: "invalid" };
}
const createdAt = Math.floor(now() / 1000);
const existing = await fetchExistingMirrors({ pubkey, root });
const newestCreatedAt = Array.isArray(existing)
? existing.reduce((max, event) => Math.max(max, Number(event?.created_at) || 0), 0)
: 0;
// NIP-09 only deletes addressable versions at or before the delete event,
// and empty replacements must likewise win their coordinate.
const createdAt = Math.max(Math.floor(now() / 1000), newestCreatedAt + 1);
const reason = str(options.reason) || "Removed NIP-71 mirror";
const deleteEvent = {
@@ -236,20 +283,17 @@ export function createNip71MirrorService({
],
};
const width = Number(video?.width);
const height = Number(video?.height);
const portrait =
Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 && height > width;
const tombstoneKind =
options.short === true || (options.short !== false && portrait) ? 34236 : 34235;
const tombstone = {
kind: tombstoneKind,
// Empty-replace both coordinates. The NIP-09 delete is the canonical
// teardown, but clients that ignore it must not retain an old mirror when
// a source replacement changed portrait/landscape orientation.
const tombstones = [NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND].map((kind) => ({
kind,
pubkey,
created_at: createdAt,
// No imeta → nothing plays; title kept so the entry reads as removed.
tags: [["d", root], ["title", str(video?.title) || "[removed]"], ["client", "bitvid"]],
content: "",
};
}));
const relays = getWriteRelays() || [];
const pool = getPool();
@@ -259,7 +303,7 @@ export function createNip71MirrorService({
let publishedOk = 0;
let total = 0;
for (const template of [deleteEvent, tombstone]) {
for (const template of [deleteEvent, ...tombstones]) {
total += 1;
let signed;
try {
@@ -307,22 +351,15 @@ export function createNip71MirrorService({
if (!pubkey || !roots.length) {
return out;
}
// fetchExistingMirrors filters on a single #d; query each root but in one
// round via the shared lookup (the SubscriptionManager batches internally).
const results = await Promise.all(
roots.map(async (root) => {
const existing = await fetchExistingMirrors({ pubkey, root });
const kinds = [
...new Set(
(Array.isArray(existing) ? existing : [])
.map((e) => e.kind)
.filter(Boolean),
),
];
return [root, kinds];
}),
);
for (const [root, kinds] of results) {
const events = await fetchMirrorsByRoots({ pubkey, roots });
const kindsByRoot = new Map(roots.map((root) => [root, new Set()]));
for (const event of Array.isArray(events) ? events : []) {
if (kindsByRoot.has(event?.root) && event?.kind) {
kindsByRoot.get(event.root).add(event.kind);
}
}
for (const [root, kindsSet] of kindsByRoot) {
const kinds = [...kindsSet];
out.set(root, {
mirrored: kinds.length > 0,
kinds,
+42 -11
View File
@@ -17,9 +17,9 @@ import {
} from "./nip71MirrorFlags.js";
import { devLogger } from "../utils/logger.js";
// On publish of a NEW video, auto-mirror it when the account opted into
// "auto-share new public videos" and it's eligible. Sets the per-video flag so
// later edits/deletes keep it in sync. Best-effort.
// On publish of a NEW video, mirror it when the account default permits it and
// this upload has not explicitly opted out. Sets the per-video flag so later
// edits/deletes keep it in sync. Best-effort.
export async function syncNip71MirrorAfterPublish(detail = {}) {
const pubkey = typeof detail?.pubkey === "string" ? detail.pubkey : "";
const legacyEvent = detail?.result?.legacy;
@@ -38,7 +38,8 @@ export async function syncNip71MirrorAfterPublish(detail = {}) {
const withPubkey = { ...video, pubkey };
const decision = resolvePublishSync({
featureOn: FEATURE_NIP71_MIRROR,
autoShare: isAutoShareEnabled(pubkey),
autoShare:
isAutoShareEnabled(pubkey) && detail?.payload?.mirrorNip71 !== false,
eligible: nip71MirrorService.canMirror(withPubkey).ok,
});
if (decision.action !== "publish") {
@@ -54,17 +55,38 @@ export async function syncNip71MirrorAfterPublish(detail = {}) {
}
}
export async function syncNip71MirrorAfterEdit({ updatedData, pubkey } = {}) {
export async function syncNip71MirrorAfterEdit({
updatedData,
originalEvent,
result,
pubkey,
} = {}) {
if (!updatedData || typeof updatedData !== "object") {
return;
}
let publishedVideo = null;
try {
if (result && typeof result === "object") {
const parsed = convertEventToVideo(result);
if (parsed && !parsed.invalid) {
publishedVideo = parsed;
}
}
} catch (error) {
devLogger.warn("[nip71MirrorSync] couldn't parse edited video", error);
}
const videoRootId =
typeof updatedData.videoRootId === "string" ? updatedData.videoRootId : "";
(typeof publishedVideo?.videoRootId === "string" && publishedVideo.videoRootId) ||
(typeof updatedData.videoRootId === "string" && updatedData.videoRootId) ||
(typeof originalEvent?.videoRootId === "string" && originalEvent.videoRootId) ||
"";
const authorPubkey = typeof pubkey === "string" ? pubkey : "";
if (!videoRootId || !authorPubkey) {
return;
}
const video = { ...updatedData, pubkey: authorPubkey, videoRootId };
// Prefer the actual signed canonical result: it includes fields preserved by
// the edit builder (root, dimensions, hashes) that the form does not resend.
const video = { ...updatedData, ...publishedVideo, pubkey: authorPubkey, videoRootId };
const decision = resolveEditSync({
featureOn: FEATURE_NIP71_MIRROR,
enabled: isMirrorEnabled(authorPubkey, videoRootId),
@@ -78,8 +100,12 @@ export async function syncNip71MirrorAfterEdit({ updatedData, pubkey } = {}) {
await nip71MirrorService.publish(video);
} else {
// "unshare": no longer eligible (e.g. flipped private) — pull it down.
await nip71MirrorService.remove(video);
setMirrorEnabled(authorPubkey, videoRootId, false);
const removal = await nip71MirrorService.remove(video);
// Keep the flag when the relay operation failed so a later edit can retry
// rather than silently orphaning a previously public mirror.
if (removal?.ok) {
setMirrorEnabled(authorPubkey, videoRootId, false);
}
}
} catch (error) {
devLogger.warn("[nip71MirrorSync] edit sync failed", error);
@@ -95,8 +121,13 @@ export function initNip71MirrorSync(nostrService) {
return () => {};
}
registered = true;
const offEdit = nostrService.on("videos:edited", ({ updatedData, pubkey } = {}) => {
void syncNip71MirrorAfterEdit({ updatedData, pubkey });
const offEdit = nostrService.on("videos:edited", ({
updatedData,
originalEvent,
result,
pubkey,
} = {}) => {
void syncNip71MirrorAfterEdit({ updatedData, originalEvent, result, pubkey });
});
const offDelete = nostrService.on("videos:deleted", ({ videoRootId, video, pubkey } = {}) => {
void syncNip71MirrorAfterDelete({ videoRootId, video, pubkey });
+15 -1
View File
@@ -1,6 +1,10 @@
import { userLogger } from "../utils/logger.js";
import { PLAYBACK_START_TIMEOUT } from "../constants.js";
import { getCurrentVideo, setCurrentVideo } from "../state/appState.js";
import {
consumeAutoplayFallbackMute,
muteForAutoplayFallback,
} from "../utils/autoplayPreference.js";
import {
SimpleEventEmitter,
extractWebSeedsFromMagnet,
@@ -330,11 +334,17 @@ export class PlaybackService {
return;
}
const storedUnmuted = localStorage.getItem("unmutedAutoplay");
const userWantsUnmuted = storedUnmuted === "true";
// Sound is the default for an explicit watch action. Browsers may still
// reject unmuted autoplay, in which case the coordinator retries muted.
// Preserve a viewer's explicit mute choice across subsequent videos.
const userWantsUnmuted = storedUnmuted !== "false";
videoElement.muted = !userWantsUnmuted;
if (!videoElement.dataset.autoplayBound) {
videoElement.addEventListener("volumechange", () => {
if (consumeAutoplayFallbackMute(videoElement)) {
return;
}
localStorage.setItem(
"unmutedAutoplay",
(!videoElement.muted).toString()
@@ -344,6 +354,10 @@ export class PlaybackService {
}
}
muteForAutoplayFallback(videoElement) {
return muteForAutoplayFallback(videoElement);
}
/**
* Registers a set of event listeners ("watchdogs") on the video element to detect
* when playback has stalled or failed.
+1 -5
View File
@@ -70,7 +70,7 @@ async function waitForAccessControl({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS }
// Cached admin lists (hydrated synchronously from localStorage at
// construction) are enough to seed trust — don't stall boot behind the relay
// refresh. If the refresh later changes editors/whitelist/blacklist, the
// refresh. If the refresh later changes editors/blacklist, the
// change listeners registered in bootstrapTrustedSeeds re-apply the seeds.
if (
typeof accessControl.isHydrated === "function" &&
@@ -189,10 +189,6 @@ export async function bootstrapTrustedSeeds() {
applySeeds();
};
if (accessControl && typeof accessControl.onWhitelistChange === "function") {
accessControl.onWhitelistChange(applyOnChange);
}
if (accessControl && typeof accessControl.onEditorsChange === "function") {
accessControl.onEditorsChange(applyOnChange);
}
+5
View File
@@ -459,6 +459,11 @@ export function normalizeVideoNotePayload(input) {
const publishPayload = {
legacyFormData,
// This is publish-session control metadata, not canonical video content.
// Keep it outside legacyFormData so it is never serialized into the 30078
// event, but preserve the uploader's explicit NIP-71 opt-out for the
// post-publish mirror hook.
mirrorNip71: rawPayload.mirrorNip71 !== false,
};
if (normalizedNip71) {
+2
View File
@@ -41,6 +41,7 @@ import ProfileIdentityController from "./profileIdentityController.js";
import VideoListViewController from "./videoListViewController.js";
import ProfileModalController from "./profileModalController.js";
import LoginModalController from "./loginModalController.js";
import { wireBitloginLogin } from "./bitloginModalIntegration.js";
import { VideoListView } from "./views/VideoListView.js";
import MoreMenuController from "./moreMenuController.js";
import VideoSettingsMenuController from "./videoSettingsMenuController.js";
@@ -429,6 +430,7 @@ export default class ApplicationBootstrap {
authProviders: this.services.authProviders || authProviders,
getAuthProvider: this.services.getAuthProvider || getAuthProvider,
});
wireBitloginLogin(app);
app.authEventUnsubscribes.push(
app.authService.on("auth:login", (detail) => {
const maybePromise = app.handleAuthLogin(detail);
+198
View File
@@ -0,0 +1,198 @@
// js/ui/bitloginModalIntegration.js
//
// Wires the permanent <bitlogin-auth id="bitloginWidget"> element -- declared
// once, statically, in components/login-modal.html, never written by an
// innerHTML re-render -- into the exact same session-establishment path every
// other sign-in method uses (AuthService.requestLogin -> handleAuthLogin).
// BitLogin isn't a single button-click-then-await-a-promise flow like the
// other providers, it's a whole multi-screen sign-in/create/recover UI, so it
// gets its own widget in the modal instead of a grid button (see
// providersForModal in js/services/authProviders/index.js) and its own small
// integration module here rather than special-casing LoginModalController.
import { devLogger, userLogger } from "../utils/logger.js";
import { closeStaticModal } from "./components/staticModalAccessibility.js";
import { setModalState as setGlobalModalState } from "../state/appState.js";
import { createBitloginAdapter } from "../nostr/adapters/bitloginAdapter.js";
import { setPendingBitloginResult } from "../services/authProviders/bitlogin.js";
import bitloginProvider from "../services/authProviders/bitlogin.js";
import { FEATURE_BITLOGIN } from "../constants.js";
// Vendored, pinned BitLogin widget bundle (scripts/build-bitlogin-widget.mjs).
// Lazy-imported so its ~350KB never weighs on the main bundle for visitors who
// never open the login modal, mirroring the existing Bitcoin Connect vendor
// step (js/ui/profileModal/ProfileWalletController.js). A plain dynamic
// import is enough: the bundle's only job is the customElements.define("bitlogin-auth")
// side effect, which retroactively upgrades the already-present <bitlogin-auth>
// tag in the modal's static HTML once it resolves.
const BITLOGIN_WIDGET_BUNDLE_URL = "../../vendor/bitlogin/bitlogin.js";
const PUBKEY_PATTERN = /^[0-9a-f]{64}$/i;
let widgetLoadPromise = null;
function loadBitloginWidgetOnce() {
if (!widgetLoadPromise) {
widgetLoadPromise = import(BITLOGIN_WIDGET_BUNDLE_URL).catch((error) => {
widgetLoadPromise = null;
throw error;
});
}
return widgetLoadPromise;
}
function closeLoginModal() {
const modal = document.getElementById("loginModal");
if (modal && closeStaticModal(modal)) {
setGlobalModalState("login", false);
}
}
function isHtmlElement(value, documentRef = document) {
const HTMLElementConstructor =
documentRef?.defaultView?.HTMLElement || globalThis.HTMLElement;
return typeof HTMLElementConstructor === "function"
? value instanceof HTMLElementConstructor
: Boolean(value && value.nodeType === 1);
}
// Cheap insurance against wireBitloginLogin() ever running twice for the same
// element (e.g. a future bootstrap change that calls it more than once): the
// same <bitlogin-auth> instance should only ever get one "bitlogin-login"
// listener, since two would each race the shared, single-read `pendingResult`
// and each independently call requestLogin() for one real sign-in.
const wiredWidgets = new WeakSet();
/**
* A CustomEvent's detail is not an authentication boundary: any same-origin
* script can dispatch one. Re-read the key from the widget's crypto worker and
* require it to match before BitVid adopts the identity or its signer.
*/
export async function resolveVerifiedBitloginPubkey(widget, claimedPubkey) {
const claimed = typeof claimedPubkey === "string" ? claimedPubkey.trim() : "";
if (!PUBKEY_PATTERN.test(claimed) || typeof widget?.getPublicKey !== "function") {
return "";
}
try {
const actual = await widget.getPublicKey();
const normalizedActual = typeof actual === "string" ? actual.trim() : "";
if (!PUBKEY_PATTERN.test(normalizedActual) || normalizedActual.toLowerCase() !== claimed.toLowerCase()) {
return "";
}
return normalizedActual.toLowerCase();
} catch (error) {
devLogger.warn("[BitLogin] Unable to verify the widget signer:", error);
return "";
}
}
function attachWidget(app, widget) {
const mount = document.getElementById("bitloginMount");
if (!FEATURE_BITLOGIN) {
// Unstable-branch experiment (AGENTS.md §1, config/instance-config.js) --
// leave the permanent, statically-declared markup in place but never load
// the vendored bundle or show the (otherwise-empty, unupgraded) element.
if (isHtmlElement(mount)) {
mount.hidden = true;
}
return;
}
if (isHtmlElement(mount)) {
mount.hidden = false;
}
if (wiredWidgets.has(widget)) {
return;
}
wiredWidgets.add(widget);
loadBitloginWidgetOnce().catch((error) => {
devLogger.warn("[BitLogin] Failed to load the widget bundle:", error);
});
widget.addEventListener("bitlogin-login", async (event) => {
const claimedPubkey =
event?.detail && typeof event.detail.publicKey === "string"
? event.detail.publicKey.trim()
: "";
const pubkey = await resolveVerifiedBitloginPubkey(widget, claimedPubkey);
if (!pubkey) {
userLogger.warn("[BitLogin] Ignored an unverifiable login event.");
return;
}
setPendingBitloginResult({
pubkey,
signer: createBitloginAdapter(widget, pubkey),
});
try {
await app.authService.requestLogin({ providerId: "bitlogin" });
closeLoginModal();
} catch (error) {
// bitvid rejecting the sign-in (e.g. the invite-only access-control
// check) leaves the widget itself still showing its own "Signed in"
// screen -- BitLogin succeeded on its own terms, bitvid just didn't
// accept the identity. Reset the widget and surface the real reason
// through the same error-banner path every other sign-in method uses
// (devLogger.warn is a silent no-op outside dev mode -- see
// IS_DEV_MODE in config/instance-config.js -- so relying on it here
// meant a rejected user saw no feedback at all).
userLogger.warn("[BitLogin] Sign-in failed to apply:", error);
const maybePromise = app?.handleLoginModalError?.({
error,
provider: bitloginProvider,
});
if (maybePromise && typeof maybePromise.then === "function") {
maybePromise.catch((handlerError) => {
devLogger.warn(
"[BitLogin] handleLoginModalError threw:",
handlerError,
);
});
}
try {
await widget.logout();
} catch (logoutError) {
devLogger.warn(
"[BitLogin] Failed to reset widget after a rejected sign-in:",
logoutError,
);
}
}
});
}
/**
* Call once during app bootstrap, after `app.authService` exists. The login
* modal's HTML (including the permanent #bitloginWidget element) loads
* asynchronously (js/index.js bootstrapInterface()) and isn't guaranteed to be
* in the DOM yet at this point, so this waits for it rather than requiring a
* specific call order between the two.
* @param {{ authService: { requestLogin: Function } }} app
*/
export function wireBitloginLogin(app) {
if (!app?.authService || typeof document === "undefined") {
return;
}
const documentRef = document;
const existing = documentRef.getElementById("bitloginWidget");
if (isHtmlElement(existing, documentRef)) {
attachWidget(app, existing);
return;
}
const Observer =
documentRef.defaultView?.MutationObserver || globalThis.MutationObserver;
if (typeof Observer !== "function" || !documentRef.body) {
return;
}
const observer = new Observer(() => {
const widget = documentRef.getElementById("bitloginWidget");
if (isHtmlElement(widget, documentRef)) {
observer.disconnect();
attachWidget(app, widget);
}
});
observer.observe(documentRef.body, { childList: true, subtree: true });
}
+5
View File
@@ -318,6 +318,7 @@ export class UploadModal {
nsfw: $("#check-nsfw"),
kids: $("#check-kids"),
comments: $("#check-comments"),
noNip71Mirror: $("#check-no-nip71-mirror"),
summaryUnlock: $("#check-summary-unlock"),
advanced: $("#btn-advanced-toggle"),
@@ -1212,6 +1213,10 @@ export class UploadModal {
this.inputs.thumbnail?.value?.trim() ||
"",
enableComments: this.toggles.comments?.checked || true,
// Public hosted videos mirror to NIP-71 by default. This per-upload
// checkbox is an explicit opt-out; eligibility still enforces the
// private/NSFW/HTTPS policy at the mirror boundary.
mirrorNip71: this.toggles.noNip71Mirror?.checked !== true,
...audienceFlags,
// Auto-captured at file-select (best-effort; absent ⇒ omitted). Drives
+8 -4
View File
@@ -601,10 +601,14 @@ export class MyVideosController {
}
} else {
const result = await nip71MirrorService.remove(video);
setMirrorEnabled(this.pubkey, video.videoRootId, false);
this.mainController.showSuccess?.(
result?.ok ? "Removed from other Nostr apps." : "Stopped sharing locally.",
);
if (result?.ok) {
setMirrorEnabled(this.pubkey, video.videoRootId, false);
this.mainController.showSuccess?.("Removed from other Nostr apps.");
} else {
this.mainController.showError?.(
"Couldn't remove the shared mirror. Please try again.",
);
}
}
} catch (error) {
devLogger.warn("[myVideos] mirror toggle failed:", error);
@@ -857,7 +857,7 @@ export class ProfileModerationController {
if (isBlocked(status.hex)) {
return false;
}
return Boolean(status.whitelisted || trustedContacts.has(status.hex));
return trustedContacts.has(status.hex);
};
if (service.trustedMuteLists instanceof Map) {
+179
View File
@@ -0,0 +1,179 @@
export const SITE_UPDATE_LAST_SEEN_KEY = "bitvid:site-update:last-seen";
export const SITE_UPDATE_DISMISSED_KEY = "bitvid:site-update:dismissed";
export const GITHUB_COMMITS_ENDPOINT =
"https://api.github.com/repos/PR0M3TH3AN/bitvid/commits?sha=unstable&per_page=5";
export const LATEST_UPDATE_ITEMS = Object.freeze([
"Clearer Nostr sign-in choices alongside BitLogin.",
"Creator approval is now separate from web-of-trust moderation.",
"Trusted-report thresholds now require broader agreement before action.",
]);
function readStorage(storage, key) {
try {
return storage?.getItem?.(key) || "";
} catch {
return "";
}
}
function writeStorage(storage, key, value) {
try {
storage?.setItem?.(key, value);
} catch {
// Privacy mode or quota errors should never block the application.
}
}
function createElement(doc, tagName, className, text = "") {
const element = doc.createElement(tagName);
if (className) {
element.className = className;
}
if (text) {
element.textContent = text;
}
return element;
}
function renderUpdateItems(doc, list, items) {
list.replaceChildren();
for (const item of Array.isArray(items) ? items : []) {
if (typeof item === "string" && item.trim()) {
list.appendChild(createElement(doc, "li", "", item.trim()));
}
}
}
function normalizeCommitItems(payload) {
if (!Array.isArray(payload)) {
return [];
}
return payload
.map((entry) => {
const sha = typeof entry?.sha === "string" ? entry.sha.slice(0, 8) : "";
const message =
typeof entry?.commit?.message === "string"
? entry.commit.message.split("\n")[0].trim()
: "";
return sha && message ? `${sha} — ${message}` : "";
})
.filter(Boolean);
}
async function loadGitHubCommitItems({ doc, list, fetchImpl, fallbackItems }) {
if (typeof fetchImpl !== "function") {
return;
}
try {
const response = await fetchImpl(GITHUB_COMMITS_ENDPOINT, {
headers: { Accept: "application/vnd.github+json" },
});
if (!response?.ok) {
throw new Error("github-commits-unavailable");
}
const commits = normalizeCommitItems(await response.json());
if (!commits.length) {
throw new Error("github-commits-empty");
}
renderUpdateItems(doc, list, commits);
} catch {
renderUpdateItems(doc, list, fallbackItems);
}
}
export function initializeSiteUpdateNotice({
doc = typeof document === "undefined" ? null : document,
storage = typeof window === "undefined" ? null : window.localStorage,
updateItems = LATEST_UPDATE_ITEMS,
fetchImpl = typeof fetch === "function" ? fetch : null,
} = {}) {
if (!doc?.body) {
return { shown: false, reason: "document-unavailable" };
}
const versionMarker = doc.querySelector("[data-site-version]");
const version = versionMarker?.dataset?.siteVersion?.trim() || "";
const date = versionMarker?.dataset?.siteVersionDate?.trim() || "";
if (!version) {
return { shown: false, reason: "version-unavailable" };
}
const lastSeen = readStorage(storage, SITE_UPDATE_LAST_SEEN_KEY);
const dismissed = readStorage(storage, SITE_UPDATE_DISMISSED_KEY);
if (!lastSeen) {
writeStorage(storage, SITE_UPDATE_LAST_SEEN_KEY, version);
return { shown: false, reason: "first-visit" };
}
if (lastSeen === version || dismissed === version) {
return { shown: false, reason: "already-seen" };
}
const root = createElement(doc, "aside", "site-update-notice");
root.setAttribute("aria-label", "Latest site update");
const panel = createElement(doc, "section", "site-update-notice__panel hidden");
panel.id = "siteUpdateNoticePanel";
panel.setAttribute("role", "status");
panel.setAttribute("aria-live", "polite");
const panelHeader = createElement(doc, "div", "flex items-start justify-between gap-3");
const title = createElement(doc, "h2", "text-base font-semibold", "Latest update");
const closeButton = createElement(doc, "button", "site-update-notice__close", "×");
closeButton.type = "button";
closeButton.setAttribute("aria-label", "Dismiss this update");
panelHeader.append(title, closeButton);
const intro = createElement(
doc,
"p",
"mt-2 text-sm leading-relaxed text-text-muted",
"BitVid has been updated. Here is what changed:",
);
const list = createElement(doc, "ul", "mt-3 list-disc space-y-1 pl-5 text-sm text-text-muted");
renderUpdateItems(doc, list, updateItems);
const build = createElement(
doc,
"p",
"mt-3 text-xs font-mono text-text-muted",
`Build ${version}${date ? ` • ${date}` : ""}`,
);
panel.append(panelHeader, intro, list, build);
const toggleButton = createElement(doc, "button", "site-update-notice__button", "Latest update");
toggleButton.type = "button";
toggleButton.setAttribute("aria-expanded", "false");
toggleButton.setAttribute("aria-controls", panel.id);
const setPanelOpen = (open) => {
panel.classList.toggle("hidden", !open);
toggleButton.setAttribute("aria-expanded", String(open));
};
let commitLoadStarted = false;
toggleButton.addEventListener("click", () => {
const nextOpen = panel.classList.contains("hidden");
setPanelOpen(nextOpen);
if (nextOpen && !commitLoadStarted) {
commitLoadStarted = true;
void loadGitHubCommitItems({
doc,
list,
fetchImpl,
fallbackItems: updateItems,
});
}
});
closeButton.addEventListener("click", () => {
writeStorage(storage, SITE_UPDATE_LAST_SEEN_KEY, version);
writeStorage(storage, SITE_UPDATE_DISMISSED_KEY, version);
root.remove();
});
root.append(panel, toggleButton);
doc.body.appendChild(root);
return { shown: true, version, root };
}
+27
View File
@@ -0,0 +1,27 @@
const FALLBACK_MUTE_DATASET_KEY = "bitvidAutoplayFallbackMute";
/**
* Mute playback after an unmuted autoplay rejection without recording the
* browser-policy fallback as a viewer preference.
*/
export function muteForAutoplayFallback(videoElement) {
if (!videoElement || videoElement.muted) {
return false;
}
if (videoElement.dataset) {
videoElement.dataset[FALLBACK_MUTE_DATASET_KEY] = "true";
}
videoElement.muted = true;
return true;
}
/** Consume the marker attached to the next fallback-generated volume change. */
export function consumeAutoplayFallbackMute(videoElement) {
if (videoElement?.dataset?.[FALLBACK_MUTE_DATASET_KEY] !== "true") {
return false;
}
delete videoElement.dataset[FALLBACK_MUTE_DATASET_KEY];
return true;
}
+2 -1
View File
@@ -30,6 +30,7 @@ import {
waitForActiveController as waitForActiveControllerHelper,
} from "./webtorrentHelpers.js";
import { devLogger, userLogger } from "./utils/logger.js";
import { muteForAutoplayFallback } from "./utils/autoplayPreference.js";
import { emit } from "./embedDiagnostics.js";
const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]);
@@ -561,7 +562,7 @@ export class TorrentClient {
return;
}
this.log(`Retrying with muted autoplay (${context} path).`);
videoElement.muted = true;
muteForAutoplayFallback(videoElement);
videoElement.play().catch((err2) => {
this.log(`Muted autoplay also failed (${context} path):`, err2);
});
+68
View File
@@ -0,0 +1,68 @@
// Vendor the sibling @bitlogin/widget build into a pinned, same-origin bundle.
//
// bitvid's CSP is script-src 'self' (plus a couple of pinned CDN fallbacks for
// nostr-tools) -- the same reasoning as the existing bitcoin-connect/blossom-sdk
// vendor steps. Unlike those, BitLogin ships its own Vite-built ESM output
// directly (bitlogin.js + cryptoWorker.js + a shared chunk they both import),
// so this script copies that output verbatim instead of re-bundling it with
// esbuild -- the worker's relative self-URL resolution depends on the three
// files staying exactly as built and sitting side by side (see BitRoad's and
// BitUnlock's identical vendor steps for this same reasoning).
//
// node scripts/build-bitlogin-widget.mjs
// BITLOGIN_ENTRY=/path/to/bitlogin node scripts/build-bitlogin-widget.mjs
import { createHash } from "node:crypto";
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const bitloginRoot = process.env.BITLOGIN_ENTRY
? path.resolve(process.env.BITLOGIN_ENTRY)
: path.resolve(repoRoot, "../bitlogin");
const widgetDir = path.join(bitloginRoot, "packages/widget");
const widgetDist = path.join(widgetDir, "dist");
const vendorDir = path.join(repoRoot, "vendor/bitlogin");
const integrityFile = path.join(repoRoot, "vendor/bitlogin.integrity.json");
const packageJson = JSON.parse(await readFile(path.join(widgetDir, "package.json"), "utf8"));
if (packageJson.name !== "@bitlogin/widget") {
throw new Error("BITLOGIN_ENTRY must point to a BitLogin checkout (packages/widget/package.json not found there).");
}
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" });
execSync("npm run build -w @bitlogin/widget", { cwd: bitloginRoot, stdio: "inherit" });
}
await rm(vendorDir, { recursive: true, force: true });
await mkdir(vendorDir, { recursive: true });
const distEntries = (await readdir(widgetDist)).filter((name) => name.endsWith(".js"));
if (!distEntries.includes("bitlogin.js") || !distEntries.includes("cryptoWorker.js")) {
throw new Error("Expected bitlogin.js and cryptoWorker.js in @bitlogin/widget dist -- did the build layout change?");
}
const files = {};
for (const name of distEntries) {
await copyFile(path.join(widgetDist, name), path.join(vendorDir, name));
const bytes = await readFile(path.join(widgetDist, name));
files[name] = { sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.byteLength };
}
await writeFile(
integrityFile,
`${JSON.stringify({ package: packageJson.name, version: packageJson.version, files }, null, 2)}\n`,
);
try {
await copyFile(path.join(bitloginRoot, "LICENSE"), path.join(repoRoot, "vendor/bitlogin.LICENSE"));
} catch (error) {
console.warn("[build-bitlogin-widget] could not copy LICENSE:", error.message);
}
console.log("[build-bitlogin-widget] wrote", path.relative(repoRoot, vendorDir), `(${distEntries.length} files)`);
+1 -1
View File
@@ -167,7 +167,7 @@ function injectVersionInfo() {
let content = fs.readFileSync(indexHtmlPath, 'utf8');
const versionHtml = `
<div class="mt-4 text-xs text-muted opacity-50 font-mono">
<div class="mt-4 text-xs text-muted opacity-50 font-mono" data-site-version="${hash.slice(0, 8)}" data-site-version-date="${date}">
v: ${hash.slice(0, 8)} • ${date}
</div>`;
@@ -296,9 +296,10 @@ test("moderation override clears channel blur via event wiring", async (t) => {
const container = document.getElementById("channelVideoList");
const moderationState = {
trustedCount: 2,
// BitVid's current default blur threshold is three trusted reports.
trustedCount: 3,
summary: {
types: { nudity: { trusted: 2 } },
types: { nudity: { trusted: 3 } },
},
};
const video = createVideoFixture({
+27
View File
@@ -151,3 +151,30 @@ test("login does not eagerly load DM history; it is deferred", async () => {
"the lightweight unread DM indicator should still run so the badge stays accurate",
);
});
test("login reloads the open video comment thread", async () => {
globalThis.window.location = { search: "", href: "http://localhost/" };
const coord = makeCoordinator({
loadDirectMessages: async () => ({ ok: true }),
refreshUnreadDmIndicator: async () => {},
});
const activeVideo = { id: "b".repeat(64), title: "Open video" };
const loadedVideos = [];
coord.videoModal = { getCurrentVideo: () => activeVideo };
coord.commentController = {
load: (video) => loadedVideos.push(video),
refreshAuthState: () => {
throw new Error("an open thread should be reloaded, not only refreshed");
},
};
await coord.handleAuthLogin({
pubkey: ACTIVE_PUBKEY,
identityChanged: true,
postLogin: { profile: { name: "tester" } },
postLoginPromise: Promise.resolve({ profile: { name: "tester" } }),
});
assert.deepEqual(loadedVideos, [activeVideo]);
});
+160
View File
@@ -0,0 +1,160 @@
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import { describe, it, beforeEach, afterEach } from "node:test";
import {
providers,
providersForModal,
} from "../js/services/authProviders/index.js";
import bitloginProvider, {
setPendingBitloginResult,
} from "../js/services/authProviders/bitlogin.js";
import { createBitloginAdapter } from "../js/nostr/adapters/bitloginAdapter.js";
import { accessControl } from "../js/accessControl.js";
const HEX_PUBKEY = "a".repeat(64);
describe("BitLogin auth provider", () => {
const originalCanAccess = accessControl.canAccess;
const originalIsBlacklisted = accessControl.isBlacklisted;
afterEach(() => {
setPendingBitloginResult(null);
accessControl.canAccess = originalCanAccess;
accessControl.isBlacklisted = originalIsBlacklisted;
});
it("registers in the resolvable provider registry but not the modal grid", () => {
// AuthService.resolveAuthProvider() must be able to find "bitlogin" ...
assert.equal(providers.bitlogin, bitloginProvider);
// ... but the login modal's grid (LoginModalController) must never render
// a redundant button for it -- BitLogin gets its own permanent widget in
// components/login-modal.html instead.
assert.equal(providersForModal.bitlogin, undefined);
assert.equal(providersForModal.nip07, providers.nip07);
});
it("refuses to log in before the widget has actually produced a pubkey", async () => {
accessControl.canAccess = () => true;
setPendingBitloginResult(null);
await assert.rejects(
() => bitloginProvider.login({ nostrClient: {} }),
/hasn't completed yet/i,
);
});
it("activates the signer and resolves once the widget's result is pending", async () => {
accessControl.canAccess = () => true;
let activated = null;
const fakeSigner = { type: "bitlogin", pubkey: HEX_PUBKEY };
const fakeNostrClient = {
signerManager: {
setActiveSigner: (signer) => {
activated = signer;
},
},
};
setPendingBitloginResult({ pubkey: HEX_PUBKEY, signer: fakeSigner });
const result = await bitloginProvider.login({ nostrClient: fakeNostrClient });
assert.equal(result.authType, "bitlogin");
assert.equal(result.pubkey, HEX_PUBKEY);
assert.equal(result.signer, fakeSigner);
assert.equal(activated, fakeSigner);
});
it("consumes the pending result exactly once (no replay across calls)", async () => {
accessControl.canAccess = () => true;
const fakeNostrClient = { signerManager: { setActiveSigner: () => {} } };
setPendingBitloginResult({ pubkey: HEX_PUBKEY, signer: {} });
await bitloginProvider.login({ nostrClient: fakeNostrClient });
await assert.rejects(
() => bitloginProvider.login({ nostrClient: fakeNostrClient }),
/hasn't completed yet/i,
);
});
it("never activates a signer for a blacklisted pubkey", async () => {
accessControl.canAccess = () => false;
accessControl.isBlacklisted = () => true;
let activated = null;
const fakeNostrClient = {
signerManager: {
setActiveSigner: (signer) => {
activated = signer;
},
},
};
setPendingBitloginResult({ pubkey: HEX_PUBKEY, signer: { type: "bitlogin" } });
await assert.rejects(
() => bitloginProvider.login({ nostrClient: fakeNostrClient }),
/blocked/i,
);
assert.equal(activated, null);
});
});
describe("createBitloginAdapter", () => {
it("delegates signing and NIP-44/NIP-04 calls to the live widget element, never window.nostr", async () => {
const calls = [];
const fakeWidget = {
signEvent: async (event) => {
calls.push(["signEvent", event]);
return { ...event, id: "signed" };
},
nip44Encrypt: async (peer, plaintext) => {
calls.push(["nip44Encrypt", peer, plaintext]);
return "ciphertext";
},
nip44Decrypt: async (peer, payload) => {
calls.push(["nip44Decrypt", peer, payload]);
return "plaintext";
},
nip04Encrypt: async (peer, plaintext) => {
calls.push(["nip04Encrypt", peer, plaintext]);
return "legacy-ciphertext";
},
nip04Decrypt: async (peer, payload) => {
calls.push(["nip04Decrypt", peer, payload]);
return "legacy-plaintext";
},
logout: async () => {
calls.push(["logout"]);
},
};
const adapter = createBitloginAdapter(fakeWidget, HEX_PUBKEY);
assert.equal(adapter.type, "bitlogin");
assert.equal(adapter.pubkey, HEX_PUBKEY);
assert.equal(adapter.canSign(), true);
// NIP-04 is real, load-bearing capability info: js/nostr/client.js only offers a
// nip04Encrypt/Decrypt code path to callers when signerCapabilities.nip04 is true.
assert.deepEqual(adapter.capabilities, { sign: true, nip44: true, nip04: true });
const signed = await adapter.signEvent({ kind: 1, content: "hi" });
assert.equal(signed.id, "signed");
await adapter.nip44Encrypt("peer-pubkey", "hello");
await adapter.nip44Decrypt("peer-pubkey", "ciphertext");
await adapter.nip04Encrypt("peer-pubkey", "hello");
await adapter.nip04Decrypt("peer-pubkey", "legacy-ciphertext");
await adapter.destroy();
assert.deepEqual(calls, [
["signEvent", { kind: 1, content: "hi" }],
["nip44Encrypt", "peer-pubkey", "hello"],
["nip44Decrypt", "peer-pubkey", "ciphertext"],
["nip04Encrypt", "peer-pubkey", "hello"],
["nip04Decrypt", "peer-pubkey", "legacy-ciphertext"],
["logout"],
]);
});
});
+43
View File
@@ -0,0 +1,43 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
resolveVerifiedBitloginPubkey,
wireBitloginLogin,
} from "../js/ui/bitloginModalIntegration.js";
const PUBKEY = "a".repeat(64);
test("BitLogin accepts a login identity only when the event and widget worker agree", async () => {
const widget = { getPublicKey: async () => PUBKEY.toUpperCase() };
assert.equal(await resolveVerifiedBitloginPubkey(widget, PUBKEY), PUBKEY);
});
test("BitLogin rejects a forged event pubkey even when its claimed account has access", async () => {
const widget = { getPublicKey: async () => "b".repeat(64) };
assert.equal(await resolveVerifiedBitloginPubkey(widget, PUBKEY), "");
});
test("BitLogin rejects malformed claims and widgets without a worker-backed public key", async () => {
assert.equal(await resolveVerifiedBitloginPubkey({}, PUBKEY), "");
assert.equal(await resolveVerifiedBitloginPubkey({ getPublicKey: async () => PUBKEY }, "npub1nope"), "");
});
test("BitLogin bootstrap safely defers when MutationObserver is unavailable", () => {
const originalDocument = globalThis.document;
const originalObserver = globalThis.MutationObserver;
globalThis.document = {
body: {},
defaultView: {},
getElementById: () => null,
};
try {
delete globalThis.MutationObserver;
assert.doesNotThrow(() => wireBitloginLogin({ authService: {} }));
} finally {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalObserver === undefined) delete globalThis.MutationObserver;
else globalThis.MutationObserver = originalObserver;
}
});
+8
View File
@@ -163,6 +163,14 @@ function createController(options = {}) {
});
}
test('login modal keeps Nostr sign-in options visible beside BitLogin', () => {
const nostrSection = container.querySelector('[aria-labelledby="nostrLoginOptionsTitle"]');
assert.ok(nostrSection, 'Nostr login options should be visible without expanding a disclosure');
assert.ok(nostrSection.querySelector('[data-login-providers]'));
assert.equal(container.querySelector('details'), null);
assert.match(container.querySelector('#bitloginMount').textContent, /BitLogin/);
});
test('LoginModalController shows custom error for empty nsec input', async (t) => {
const providers = [{ id: 'nsec', label: 'Secret Key', login: async () => {} }];
const authService = { requestLogin: async () => {} };
+10 -4
View File
@@ -6,7 +6,7 @@ import {
DEFAULT_BLUR_THRESHOLD,
} from "../js/constants.js";
test("moderation stage enforces admin lists without whitelist bypass", async () => {
test("moderation stage keeps web-of-trust actions separate from creator access", async () => {
const whitelistedHex = "1".repeat(64);
const blockedHex = "2".repeat(64);
const blacklistedHex = "3".repeat(64);
@@ -496,17 +496,22 @@ test("moderation stage propagates whitelist, muters, and threshold updates", asy
assert.equal(updatedMuted.video.moderation.blurReason, undefined);
});
test("moderation stage blurs viewer-muted authors", async () => {
test("moderation stage preserves a viewer mute for a whitelisted creator", async () => {
const mutedHex = "f".repeat(64);
const service = {
async refreshViewerFromClient() {},
async setActiveEventIds() {},
getAdminListSnapshot() {
return { whitelist: new Set(), whitelistHex: new Set(), blacklist: new Set(), blacklistHex: new Set() };
return {
whitelist: new Set([`npub${mutedHex}`]),
whitelistHex: new Set([mutedHex]),
blacklist: new Set(),
blacklistHex: new Set(),
};
},
getAccessControlStatus(identifier) {
return { hex: identifier, whitelisted: false, blacklisted: false };
return { hex: identifier, whitelisted: identifier === mutedHex, blacklisted: false };
},
getTrustedReportSummary() {
return null;
@@ -551,6 +556,7 @@ test("moderation stage blurs viewer-muted authors", async () => {
assert.equal(entry.video.moderation.viewerMuted, true);
assert.equal(entry.metadata.moderation.viewerMuted, true);
assert.equal(entry.video.moderation.adminWhitelistBypass, false);
assert.equal(entry.video.moderation.blockAutoplay, true);
assert.equal(entry.video.moderation.blurThumbnail, true);
assert.equal(entry.video.moderation.blurReason, "viewer-mute");
+1 -2
View File
@@ -329,7 +329,7 @@ test("moderation stage bypasses hard hides on home feed", async () => {
assert.equal(hideWhy.hideBypass, "feed-policy");
});
test("moderation stage hides admin-whitelisted videos once thresholds fire", async () => {
test("moderation stage hides admin-whitelisted videos when web-of-trust thresholds fire", async () => {
const whitelistedHex = hex("f");
const videoId = hex("4");
@@ -400,4 +400,3 @@ test("moderation stage hides admin-whitelisted videos once thresholds fire", asy
assert.equal(hideWhy.adminWhitelist, true);
assert.equal(hideWhy.trustedReportCount, 6);
});
+3 -18
View File
@@ -224,22 +224,7 @@ test("bootstrap seeds track editor roster and ignore whitelist-only changes", as
"initial editor roster should seed trust"
);
const whitelistListener = Array.from(whitelistListeners)[0];
if (typeof whitelistListener === "function") {
whitelistListener(["npub1whitelistentrywhitelistentrywhitelistentry"]);
}
assert.equal(
setTrustedSeedsCalls.length,
2,
"whitelist change should retrigger trusted seed derivation"
);
const [, whitelistSeeds] = setTrustedSeedsCalls;
assert.deepEqual(
new Set(whitelistSeeds),
new Set(initialSeeds),
"whitelist updates should not introduce new trusted seeds"
);
assert.equal(whitelistListeners.size, 0, "creator whitelist changes must not affect trust seeds");
currentEditors = [
"npub1newmoderatorexamplemoderatorexamplemoderator",
@@ -251,10 +236,10 @@ test("bootstrap seeds track editor roster and ignore whitelist-only changes", as
assert.equal(
setTrustedSeedsCalls.length,
3,
2,
"editor change should recompute trusted seed roster"
);
const latestSeeds = setTrustedSeedsCalls[2];
const latestSeeds = setTrustedSeedsCalls[1];
assert.deepEqual(
new Set(latestSeeds),
new Set([ADMIN_SUPER_NPUB, ...currentEditors]),
@@ -167,7 +167,7 @@ test("blocking and unblocking reporters recomputes trusted summaries", async (t)
assert.deepEqual(setActiveCalls, [[eventId], [eventId]]);
});
test("trustedReportCount only counts eligible F1 reporters and admin whitelist", (t) => {
test("trustedReportCount only counts eligible F1 reporters, not creator whitelist entries", (t) => {
withMockedNostrTools(t);
const eventId = "c".repeat(64);
@@ -217,14 +217,14 @@ test("trustedReportCount only counts eligible F1 reporters and admin whitelist",
}
const summary = service.getTrustedReportSummary(eventId);
assert.equal(summary.totalTrusted, 2);
assert.equal(summary.totalTrusted, 1);
assert.equal(summary.types.nudity.total, 3);
assert.equal(summary.types.nudity.trusted, 2);
assert.equal(service.trustedReportCount(eventId, "nudity"), 2);
assert.equal(summary.types.nudity.trusted, 1);
assert.equal(service.trustedReportCount(eventId, "nudity"), 1);
const reporters = service.getTrustedReporters(eventId, "nudity");
assert.deepEqual(
reporters.map((entry) => entry.pubkey).sort(),
[adminWhitelisted, f1Reporter].sort(),
[f1Reporter],
);
});
+73
View File
@@ -0,0 +1,73 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createWhitelistedAuthorsNostrSource } from "../js/feedEngine/sources.js";
test("Most Zapped discovery fetches only whitelisted creators and caches the catalog", async () => {
const calls = [];
const approved = "a".repeat(64);
const unapproved = "b".repeat(64);
let now = 1_000;
const source = createWhitelistedAuthorsNostrSource({
now: () => now,
service: {
async fetchVideosByAuthors(authors) {
calls.push(authors);
return [
{ id: "approved", pubkey: approved, created_at: 20 },
{ id: "unapproved", pubkey: unapproved, created_at: 30 },
];
},
},
});
const runtime = { whitelistedAuthors: [approved.toUpperCase()] };
const first = await source({ runtime });
const rerank = await source({ runtime });
assert.deepEqual(calls, [[approved]], "relay lookup is author-scoped once");
assert.deepEqual(first.map((entry) => entry.video.id), ["approved"]);
assert.deepEqual(rerank.map((entry) => entry.video.id), ["approved"]);
now += 2 * 60 * 1000;
await source({ runtime });
assert.equal(calls.length, 2, "catalog refreshes after its bounded TTL");
});
test("Most Zapped discovery is empty without approved creators and never queries globally", async () => {
let calls = 0;
const source = createWhitelistedAuthorsNostrSource({
service: {
async fetchVideosByAuthors() {
calls += 1;
return [{ id: "must-not-leak", pubkey: "c".repeat(64) }];
},
},
});
const result = await source({ runtime: { whitelistedAuthors: [] } });
assert.deepEqual(result, []);
assert.equal(calls, 0);
});
test("Most Zapped never leaks a removed creator while an earlier lookup is in flight", async () => {
const removed = "d".repeat(64);
const remaining = "e".repeat(64);
let resolveFetch;
const source = createWhitelistedAuthorsNostrSource({
service: {
fetchVideosByAuthors() {
return new Promise((resolve) => {
resolveFetch = resolve;
});
},
},
});
const first = source({ runtime: { whitelistedAuthors: [removed] } });
const afterRemoval = source({ runtime: { whitelistedAuthors: [remaining] } });
resolveFetch([{ id: "removed-video", pubkey: removed }]);
assert.deepEqual((await first).map((entry) => entry.video.id), ["removed-video"]);
assert.deepEqual(await afterRemoval, []);
});
+4 -4
View File
@@ -85,14 +85,14 @@ test("resolveDeleteSync: attempts teardown whenever the feature is on, regardles
assert.deepEqual(resolveDeleteSync({ featureOn: false, enabled: false }), { action: "none" });
});
test("auto-share preference persists per pubkey, off by default", () => {
test("auto-share preference is on by default and supports a durable opt-out", () => {
localStorage.clear();
assert.equal(isAutoShareEnabled(PK), false);
setAutoShareEnabled(PK, true);
assert.equal(isAutoShareEnabled(PK), true);
assert.equal(isAutoShareEnabled("c".repeat(64)), false, "scoped per pubkey");
setAutoShareEnabled(PK, false);
assert.equal(isAutoShareEnabled(PK), false);
assert.equal(isAutoShareEnabled("c".repeat(64)), true, "default is scoped per pubkey");
setAutoShareEnabled(PK, true);
assert.equal(isAutoShareEnabled(PK), true);
});
test("resolvePublishSync: auto-mirror a new video only when auto-share on AND eligible", () => {
+54 -13
View File
@@ -106,11 +106,11 @@ test("publish is unavailable without a signer / pubkey", async () => {
assert.equal(result.error, "unavailable");
});
test("remove() publishes a NIP-09 delete (both addressable kinds) AND a tombstone", async () => {
test("remove() publishes a NIP-09 delete and tombstones both addressable kinds", async () => {
const { service, relay } = makeService();
const result = await service.remove(baseVideo());
assert.equal(result.ok, true);
assert.equal(relay.events.length, 2, "delete + empty-replace tombstone");
assert.equal(relay.events.length, 3, "delete + empty replacements for both kinds");
const del = relay.events.find((e) => e.kind === 5);
assert.ok(del, "must publish a NIP-09 kind-5 delete");
@@ -122,21 +122,19 @@ test("remove() publishes a NIP-09 delete (both addressable kinds) AND a tombston
const kTags = del.tags.filter((t) => t[0] === "k").map((t) => t[1]).sort();
assert.deepEqual(kTags, ["34235", "34236"]);
const tomb = relay.events.find((e) => e.kind === 34235 || e.kind === 34236);
assert.ok(tomb, "must publish an empty-replace tombstone");
assert.equal(tomb.tags.find((t) => t[0] === "d")[1], "root-1", "same d-tag");
assert.equal(
tomb.tags.some((t) => t[0] === "imeta"),
false,
"tombstone has no playable imeta",
);
const tombs = relay.events.filter((e) => e.kind === 34235 || e.kind === 34236);
assert.deepEqual(tombs.map((event) => event.kind).sort(), [34235, 34236]);
for (const tomb of tombs) {
assert.equal(tomb.tags.find((t) => t[0] === "d")[1], "root-1", "same d-tag");
assert.equal(tomb.tags.some((t) => t[0] === "imeta"), false, "not playable");
}
});
test("remove() tombstone uses the short kind for portrait videos", async () => {
const { service, relay } = makeService();
await service.remove(baseVideo({ width: 1080, height: 1920 }));
const tomb = relay.events.find((e) => e.kind === 34235 || e.kind === 34236);
assert.equal(tomb.kind, 34236, "portrait => 34236 tombstone");
const tombs = relay.events.filter((e) => e.kind === 34235 || e.kind === 34236);
assert.deepEqual(tombs.map((event) => event.kind).sort(), [34235, 34236]);
});
test("remove() is unavailable without a signer", async () => {
@@ -150,7 +148,7 @@ test("remove() is unavailable without a signer", async () => {
// (34236 short for portrait, else 34235), but (kind,pubkey,d) is the addressable
// identity — so a kind flip between mirror attempts produced a DUPLICATE in NIP-71
// clients. publish() must reuse the kind of any existing mirror instead.
function makeServiceWithExisting(existing) {
function makeServiceWithExisting(existing, { now = () => Date.now() } = {}) {
const relay = makeRelay();
const signer = { signEvent: async (tpl) => ({ ...tpl, id: "id", sig: "sig" }) };
const service = createNip71MirrorService({
@@ -163,6 +161,7 @@ function makeServiceWithExisting(existing) {
signEvent: signer.signEvent,
allowNsfw: () => false,
fetchExistingMirrors: async () => existing,
now,
});
return { service, relay };
}
@@ -208,6 +207,48 @@ test("an explicit options.short override is respected over any existing mirror",
assert.equal(relay.events[0].kind, 34235, "explicit override wins");
});
test("re-mirror advances past an existing addressable timestamp", async () => {
const { service, relay } = makeServiceWithExisting(
[{ kind: 34235, created_at: 500 }],
{ now: () => 500_000 },
);
const result = await service.publish(baseVideo());
assert.equal(result.ok, true);
assert.equal(relay.events[0].created_at, 501, "mirror replacement must win its coordinate");
});
test("remove advances its delete and tombstones past an existing mirror", async () => {
const { service, relay } = makeServiceWithExisting(
[{ kind: 34235, created_at: 500 }],
{ now: () => 500_000 },
);
const result = await service.remove(baseVideo());
assert.equal(result.ok, true);
assert.deepEqual(relay.events.map((event) => event.created_at), [501, 501, 501]);
});
test("findMirrors resolves a library in one batched relay lookup", async () => {
let batchCalls = 0;
const service = createNip71MirrorService({
getActivePubkey: () => PUBKEY,
fetchMirrorsByRoots: async ({ roots }) => {
batchCalls += 1;
assert.deepEqual(roots.sort(), ["root-a", "root-b"]);
return [
{ root: "root-a", kind: 34235, created_at: 10 },
{ root: "root-b", kind: 34236, created_at: 11 },
];
},
});
const states = await service.findMirrors([
{ videoRootId: "root-a" },
{ videoRootId: "root-b" },
]);
assert.equal(batchCalls, 1);
assert.deepEqual(states.get("root-a"), { mirrored: true, kinds: [34235], duplicate: false });
assert.deepEqual(states.get("root-b"), { mirrored: true, kinds: [34236], duplicate: false });
});
test("findMirror derives mirror state from relays (truth), flagging duplicates", async () => {
const dup = makeServiceWithExisting([{ kind: 34235 }, { kind: 34236 }]).service;
assert.deepEqual(await dup.findMirror(baseVideo()), {
+103 -1
View File
@@ -6,10 +6,16 @@ import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test, { mock } from "node:test";
import {
syncNip71MirrorAfterEdit,
syncNip71MirrorAfterPublish,
syncNip71MirrorAfterDelete,
initNip71MirrorSync,
} from "../js/services/nip71MirrorSync.js";
import { isMirrorEnabled, setMirrorEnabled } from "../js/services/nip71MirrorFlags.js";
import {
isMirrorEnabled,
setMirrorEnabled,
setAutoShareEnabled,
} from "../js/services/nip71MirrorFlags.js";
import { nip71MirrorService } from "../js/services/nip71MirrorService.js";
const PK = "a".repeat(64);
@@ -63,6 +69,102 @@ test("delete sync never throws when teardown fails (best-effort)", async () => {
}
});
test("editing a mirrored video uses the original root when the form omits it", async () => {
localStorage.clear();
setMirrorEnabled(PK, "root-edit", true);
const canMirror = mock.method(nip71MirrorService, "canMirror", () => ({ ok: true }));
const publishSpy = mock.method(nip71MirrorService, "publish", async () => ({ ok: true }));
try {
await syncNip71MirrorAfterEdit({
updatedData: { title: "Edited", url: "https://cdn.example/video.mp4" },
originalEvent: { videoRootId: "root-edit" },
pubkey: PK,
});
assert.equal(publishSpy.mock.callCount(), 1, "mirror is republished after a normal edit");
assert.equal(publishSpy.mock.calls[0].arguments[0].videoRootId, "root-edit");
} finally {
canMirror.mock.restore();
publishSpy.mock.restore();
}
});
test("failed edit-time unshare keeps the flag so a later edit retries cleanup", async () => {
localStorage.clear();
setMirrorEnabled(PK, "root-retry", true);
const canMirror = mock.method(nip71MirrorService, "canMirror", () => ({ ok: false, reason: "private" }));
const removeSpy = mock.method(nip71MirrorService, "remove", async () => ({ ok: false }));
try {
await syncNip71MirrorAfterEdit({
updatedData: { isPrivate: true },
originalEvent: { videoRootId: "root-retry" },
pubkey: PK,
});
assert.equal(removeSpy.mock.callCount(), 1);
assert.equal(isMirrorEnabled(PK, "root-retry"), true, "failed cleanup stays retryable");
} finally {
canMirror.mock.restore();
removeSpy.mock.restore();
}
});
test("a per-upload opt-out prevents the default NIP-71 mirror", async () => {
localStorage.clear();
setAutoShareEnabled(PK, true);
const publishSpy = mock.method(nip71MirrorService, "publish", async () => ({ ok: true }));
try {
await syncNip71MirrorAfterPublish({
pubkey: PK,
payload: { mirrorNip71: false },
result: {
legacy: {
id: "event-id",
pubkey: PK,
created_at: 1,
tags: [["d", "root-opt-out"]],
content: JSON.stringify({
videoRootId: "root-opt-out",
title: "No mirror",
url: "https://cdn.example/video.mp4",
}),
},
},
});
assert.equal(publishSpy.mock.callCount(), 0);
} finally {
publishSpy.mock.restore();
}
});
test("a new eligible upload mirrors by default", async () => {
localStorage.clear();
const canMirror = mock.method(nip71MirrorService, "canMirror", () => ({ ok: true }));
const publishSpy = mock.method(nip71MirrorService, "publish", async () => ({ ok: true }));
try {
await syncNip71MirrorAfterPublish({
pubkey: PK,
payload: { mirrorNip71: true },
result: {
legacy: {
id: "event-id-default",
pubkey: PK,
created_at: 1,
tags: [["d", "root-default"]],
content: JSON.stringify({
videoRootId: "root-default",
title: "Default mirror",
url: "https://cdn.example/video.mp4",
}),
},
},
});
assert.equal(publishSpy.mock.callCount(), 1);
assert.equal(isMirrorEnabled(PK, "root-default"), true);
} finally {
canMirror.mock.restore();
publishSpy.mock.restore();
}
});
test("initNip71MirrorSync subscribes to edit+delete events, idempotently", () => {
const events = {};
const svc = {
+12 -6
View File
@@ -44,21 +44,27 @@ describe("PlaybackService", () => {
const service = new PlaybackService();
const video = document.createElement("video");
// Default: muted (unless localStorage says unmuted)
// Default: sound on unless the viewer explicitly chose mute.
localStorage.removeItem("unmutedAutoplay");
service.prepareVideoElement(video);
assert.equal(video.muted, true);
assert.equal(video.muted, false);
assert.equal(video.dataset.autoplayBound, "true");
// Simulate volume change
video.muted = false;
// Simulate an explicit mute choice.
video.muted = true;
video.dispatchEvent(new window.Event("volumechange"));
assert.equal(localStorage.getItem("unmutedAutoplay"), "true");
assert.equal(localStorage.getItem("unmutedAutoplay"), "false");
// Test respecting stored value
const video2 = document.createElement("video");
service.prepareVideoElement(video2);
assert.equal(video2.muted, false);
assert.equal(video2.muted, true);
// A browser-policy fallback is temporary and must not become a saved mute.
localStorage.setItem("unmutedAutoplay", "true");
video2.muted = false;
service.muteForAutoplayFallback(video2);
assert.equal(localStorage.getItem("unmutedAutoplay"), "true");
});
test("registerUrlPlaybackWatchdogs triggers onFallback on error", () => {
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import {
SITE_UPDATE_DISMISSED_KEY,
SITE_UPDATE_LAST_SEEN_KEY,
initializeSiteUpdateNotice,
} from "../js/ui/siteUpdateNotice.js";
function createDocument(version = "deadbeef") {
return new JSDOM(
`<!doctype html><html><body><div data-site-version="${version}" data-site-version-date="2026-07-26"></div></body></html>`,
{ url: "https://bitvid.example" },
);
}
test("site update notice records a first visit without interrupting it", () => {
const dom = createDocument();
const result = initializeSiteUpdateNotice({
doc: dom.window.document,
storage: dom.window.localStorage,
});
assert.deepEqual(result, { shown: false, reason: "first-visit" });
assert.equal(dom.window.localStorage.getItem(SITE_UPDATE_LAST_SEEN_KEY), "deadbeef");
assert.equal(dom.window.document.querySelector(".site-update-notice"), null);
});
test("site update notice shows a dismissible build summary after an update", async () => {
const dom = createDocument("cafebabe");
dom.window.localStorage.setItem(SITE_UPDATE_LAST_SEEN_KEY, "deadbeef");
const result = initializeSiteUpdateNotice({
doc: dom.window.document,
storage: dom.window.localStorage,
fetchImpl: async () => ({
ok: true,
json: async () => [
{ sha: "1234567890abcdef", commit: { message: "Make Nostr login obvious\n\nMore detail" } },
],
}),
});
assert.equal(result.shown, true);
const button = dom.window.document.querySelector(".site-update-notice__button");
const panel = dom.window.document.querySelector(".site-update-notice__panel");
assert.ok(button);
assert.ok(panel.classList.contains("hidden"));
button.click();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(button.getAttribute("aria-expanded"), "true");
assert.equal(panel.classList.contains("hidden"), false);
assert.match(panel.textContent, /cafebabe/);
assert.match(panel.textContent, /12345678 — Make Nostr login obvious/);
dom.window.document.querySelector("[aria-label='Dismiss this update']").click();
assert.equal(dom.window.document.querySelector(".site-update-notice"), null);
assert.equal(dom.window.localStorage.getItem(SITE_UPDATE_LAST_SEEN_KEY), "cafebabe");
assert.equal(dom.window.localStorage.getItem(SITE_UPDATE_DISMISSED_KEY), "cafebabe");
});
test("site update notice keeps its curated summary when GitHub is unavailable", async () => {
const dom = createDocument("cafebabe");
dom.window.localStorage.setItem(SITE_UPDATE_LAST_SEEN_KEY, "deadbeef");
initializeSiteUpdateNotice({
doc: dom.window.document,
storage: dom.window.localStorage,
updateItems: ["Fallback update copy."],
fetchImpl: async () => ({ ok: false }),
});
dom.window.document.querySelector(".site-update-notice__button").click();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.match(
dom.window.document.querySelector(".site-update-notice__panel").textContent,
/Fallback update copy/,
);
});
+11
View File
@@ -1,6 +1,11 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
DEFAULT_BLUR_THRESHOLD,
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
} from "../config/instance-config.js";
import { validateInstanceConfig } from "../config/validate-config.js";
// Common valid overrides to use as a base
@@ -10,6 +15,12 @@ const VALID_OVERRIDES = {
PLATFORM_LUD16_OVERRIDE: "[email protected]",
};
test("instance moderation defaults require multiple trusted reports", () => {
assert.equal(DEFAULT_BLUR_THRESHOLD, 3);
assert.equal(DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, 3);
assert.equal(DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, 5);
});
test("validateInstanceConfig succeeds with valid configuration", () => {
assert.doesNotThrow(() => validateInstanceConfig(VALID_OVERRIDES));
});
+26
View File
@@ -0,0 +1,26 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
const playerStyles = await readFile(
new URL("../css/tailwind.source.css", import.meta.url),
"utf8"
);
test("mobile player modal uses the dynamic viewport and keeps the comment action above browser chrome", () => {
assert.match(
playerStyles,
/#playerModal\s*\{[^}]*height:\s*100dvh;[^}]*min-height:\s*100dvh;[^}]*overflow:\s*hidden;/s,
"the player root should use the dynamic viewport instead of scrolling beneath browser chrome"
);
assert.match(
playerStyles,
/#playerModal\s*>\s*\.bv-modal__panel\s*\{[^}]*overflow-y:\s*auto;/s,
"the modal panel should remain the single scroll region used by sticky navigation"
);
assert.match(
playerStyles,
/#playerModal\s+\.comment-composer\s*\{[^}]*padding-bottom:\s*max\(var\(--space-lg\),\s*env\(safe-area-inset-bottom\)\);/s,
"the comment action row should clear the mobile safe area"
);
});
+16
View File
@@ -28,6 +28,22 @@ test("normalizes minimal payload with hosted URL", () => {
assert.equal(payload.legacyFormData.enableComments, true);
assert.equal(payload.legacyFormData.isNsfw, false);
assert.equal(payload.legacyFormData.isForKids, false);
assert.equal(payload.mirrorNip71, true, "public uploads mirror by default");
});
test("preserves an explicit per-upload NIP-71 mirror opt-out outside canonical content", () => {
const { payload, errors } = normalizeVideoNotePayload({
title: "Private distribution choice",
url: "https://cdn.example.com/video.mp4",
mirrorNip71: false,
});
assert.deepEqual(errors, []);
assert.equal(payload.mirrorNip71, false);
assert.equal(
Object.hasOwn(payload.legacyFormData, "mirrorNip71"),
false,
"opt-out does not leak into the canonical 30078 payload",
);
});
test("normalizes mode and boolean flags", () => {
+17 -8
View File
@@ -615,7 +615,7 @@ toward freshness and looked identical. Gave each a structural identity:
handled).
### 47. "Most Zapped" sidebar tab (trending-by-zaps)
- [ ] **NEXT (planned, not started): bitvid-native zap tally.** Full spec +
- [x] **DONE 2026-07-26: bitvid-native zap tally.** Full spec +
ordered task checklist in `docs/zap-tally-plan.md`. Problem: NIP-57 zap
receipts (9735) are published by the RECIPIENT's LNURL server, and
custodial wallets (Strike) don't — so paid zaps leave no on-relay record
@@ -624,12 +624,13 @@ toward freshness and looked identical. Gave each a structural identity:
the preimage→payment_hash + description_hash→zapRequest binding, reusing
the existing bolt11 decode in `zapReceiptValidator.js`), counted alongside
real 9735s (deduped by payment_hash, 9735-preferred). Behind
`FEATURE_ZAP_TALLY` (default off). Plan also covers a
`FEATURE_ZAP_TALLY` (default on). Plan also covers a
SECOND orange zaps-over-time line on the per-video Popularity chart with a
legend (red=Views/orange=Zaps) and visible date labels (docs §5.9). See the
plan for the file-by-file changes. Current state (commit `cbc62306`): 9735 counting + optimistic
bump + durable local ledger (`bitvid:sentZaps:v1`) already shipped; the
tally is the missing global/cross-user source.
plan for the file-by-file changes. Current state: kind-9735 counting,
optimistic bump + durable local ledger (`bitvid:sentZaps:v1`), and the
verified tally source are shipped; receipts and tallies are deduped by
payment hash.
- [x] **DONE 2026-07-03.** Full Trending-pattern clone ranked by SATS:
- `js/zapTotals.js` — per-pointer kind-9735 aggregation. The feed's
getZapTotal reads the cached total AND schedules a BATCHED one-shot
@@ -647,18 +648,26 @@ toward freshness and looked identical. Gave each a structural identity:
link, viewManager registry, feedCoordinator case/loader,
`FEED_TYPES.MOST_ZAPPED`, `FEATURE_MOST_ZAPPED_FEED` flag (default
on; off hides the tab like Trending's flag).
- **Discovery hardened 2026-07-26:** fetches the bounded catalog for
creator-whitelist pubkeys only (never an unscoped relay query), then
ranks those videos by zaps. The normal blacklist and Web-of-Trust
stages still run; whitelist membership is not a moderation override.
The catalog is briefly cached so zap-total re-ranks do not repeatedly
scan relays, and an in-flight result is re-filtered after whitelist
changes so removed creators cannot briefly appear.
Tests: `tests/zap-totals.test.mjs` (5). Lint + build green.
- [x] **Follow-up DONE 2026-07-03 — per-card zap badges.** Video cards now show
the zap total in bitcoin ORANGE (new `--color-zap` token, both themes),
RIGHT-ALIGNED in the engagement row (views/comments stay left; CSS
`margin-inline-start: auto` on `.video-card__zaps`). Hidden until a
nonzero total is known (no "0 sats" noise). Binder
`margin-inline-start: auto` on `.video-card__zaps`). The badge is visible
from bind (showing `0 sats` until receipts arrive). Binder
(`js/ui/views/videoCardZapTotals.js`, VideoListView file-size budget)
fills `[data-zap-total]` from the shared zapTotals cache — bind() also
schedules the batched receipt fetch, and one change listener updates
every bound card as batches land. Test:
`tests/video-card-zap-totals.test.mjs` (drives the REAL store).
- [ ] **VERIFY on unstable:** open Most Zapped → zapped videos rise as receipts
- [ ] **VERIFY on unstable:** open Most Zapped → only whitelisted creators are
discovered; zapped videos rise as receipts
load (unzapped feed reads as Recent at first) and their cards show the
orange right-aligned sats badge; unzapped cards show no badge; zap a
video → within ~2min of tab re-entry its rank + badge reflect the new
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 PR0M3TH3AN
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+18
View File
@@ -0,0 +1,18 @@
{
"package": "@bitlogin/widget",
"version": "0.1.0",
"files": {
"bitlogin-shared-QIBe5Omw.js": {
"sha256": "7ce4ed0a5c3c7f744e864f55745f7fa78482cbfa19cd30eb551f67a415f55191",
"bytes": 288275
},
"bitlogin.js": {
"sha256": "55312cef3462a2e41043b57bd3c87a80bad4e077dd6e529faf25fa55db4ba7a8",
"bytes": 67232
},
"cryptoWorker.js": {
"sha256": "49a421e1026ad94243ceb0653f3761820d4e98e19b8fbe469307cd692f9a8489",
"bytes": 79173
}
}
}
File diff suppressed because it is too large. Load diff
+1307
View File
File diff suppressed because it is too large. Load diff
+3835
View File
File diff suppressed because it is too large. Load diff