mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b975c17dc | ||
|
|
87b3012e34 | ||
|
|
e8e2b04de7 | ||
|
|
2f3028f2e4 | ||
|
|
f0a3ede3e5 | ||
|
|
d8e5113efd | ||
|
|
04c8511a50 | ||
|
|
ce14004725 | ||
|
|
4458b269be | ||
|
|
f373bb9ce7 | ||
|
|
2cda5025d4 | ||
|
|
7739a934f9 | ||
|
|
98c623c85a | ||
|
|
0513c7fcd8 | ||
|
|
855022e911 | ||
|
|
589bda8cb0 | ||
|
|
18e08bf2cb | ||
|
|
f25c2ac242 | ||
|
|
4d1116530b | ||
|
|
f2d390ff18 | ||
|
|
ca7b711b8a | ||
|
|
88c906fd34 |
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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*.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
} from "./sorters.js";
|
||||
export {
|
||||
createActiveNostrSource,
|
||||
createWhitelistedAuthorsNostrSource,
|
||||
createSubscriptionAuthorsSource,
|
||||
createWatchHistoryPointerSource,
|
||||
} from "./sources.js";
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -49,7 +49,6 @@ export default {
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
description: PROVIDER_DESCRIPTION,
|
||||
eyebrow: "Recommended",
|
||||
tone: "accent",
|
||||
badgeVariant: "info",
|
||||
capabilities: PROVIDER_CAPABILITIES,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)`);
|
||||
@@ -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({
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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 () => {} };
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()), {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+21
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+10837
File diff suppressed because it is too large.
Load diff
Vendored
+1307
File diff suppressed because it is too large.
Load diff
Vendored
+3835
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user