mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 02:42:28 +00:00
update
This commit is contained in:
1 parent
b85a101749
commit
281a6511f1
165 files changed
+48618
-14019
No files matched your search
@@ -76,6 +76,13 @@ Operators can tune thresholds and lists from the Profile modal:
|
||||
- **Report** to issue a NIP-56 moderation report.
|
||||
- When a rebroadcast hits rate limits, the app surfaces cooldown guidance (e.g., “Rebroadcast is cooling down. Try again in 30s.”) so operators know when to retry without spamming relays.
|
||||
|
||||
### Hashtag interests & disinterests
|
||||
|
||||
- The Profile modal’s **Hashtags** tab lets you maintain interests (tags you want surfaced) and disinterests (tags to downrank) with dedicated input fields and per-tag remove actions. Preferences are cached locally so list edits render instantly.
|
||||
- bitvid persists the lists by publishing a replaceable `kind 30005` event with the identifier `bitvid:tag-preferences`. The `HashtagPreferencesService` normalizes tags, keeps interests and disinterests mutually exclusive, and emits change events so UI stays in sync.【F:js/services/hashtagPreferencesService.js†L206-L276】【F:js/services/hashtagPreferencesService.js†L304-L324】【F:docs/nostr-event-schemas.md†L150-L168】
|
||||
- Preference payloads are encrypted before leaving the browser. The service attempts NIP-44 v2 first, falls back to NIP-44 or NIP-04 based on the signer/browser capabilities, records the active scheme in the `['encrypted', <scheme>]` tag, and decrypts in the same priority order when loading from relays.【F:js/services/hashtagPreferencesService.js†L520-L657】【F:js/services/hashtagPreferencesService.js†L356-L511】
|
||||
- Published ciphertext is stored on every configured write relay and accepted ones are tracked so operators can diagnose relay failures without exposing the plaintext list.【F:js/services/hashtagPreferencesService.js†L658-L711】
|
||||
|
||||
---
|
||||
|
||||
## For Developers
|
||||
@@ -192,6 +199,19 @@ fresh output so third-party dependencies cannot sneak inline style mutations
|
||||
into production. Use `npm run build:beacon:bundle` only if you need the raw
|
||||
esbuild output for debugging.
|
||||
|
||||
### Nostr facade migration
|
||||
|
||||
Nostr helpers now ship behind dedicated facades so downstream plugins can pick
|
||||
the entry point that matches their feature set:
|
||||
|
||||
1. **Default client ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)):** `import { nostrClient, requestDefaultExtensionPermissions } from './nostrClientFacade.js';`
|
||||
2. **View counters ([NIP-71](https://github.com/nostr-protocol/nips/blob/master/71.md)):** `import { recordVideoView, subscribeVideoViewEvents } from './nostrViewEventsFacade.js';`
|
||||
3. **Watch-history lists ([NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) + [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) encryption):** `import { updateWatchHistoryListWithDefaultClient } from './nostrWatchHistoryFacade.js';`
|
||||
|
||||
`js/nostr.js` remains as a compatibility shim while existing plugins migrate, but
|
||||
it will be removed in a future release. Move any legacy `import { ... } from './nostr.js';`
|
||||
calls over to the facades above so upgrades stay painless.
|
||||
|
||||
The build command compiles Tailwind with `tailwind.config.cjs`, runs it through
|
||||
the PostCSS pipeline defined in `postcss.config.cjs` (for autoprefixing), and
|
||||
emits the purged, minified stylesheet at `css/tailwind.generated.css`. Commit the
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<!-- Link to your main stylesheet -->
|
||||
|
||||
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
|
||||
<!-- Load nostr‑tools v2.10.4 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]0.4/lib/nostr.bundle.min.js"></script>
|
||||
<!-- Load nostr‑tools v2.17.0 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]7.0/lib/nostr.bundle.min.js"></script>
|
||||
</head>
|
||||
<body class="iframe-form" data-ds="new">
|
||||
<div class="container">
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<script type="module" src="./theme-sync.js"></script>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
|
||||
<!-- Load nostr‑tools v2.10.4 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]0.4/lib/nostr.bundle.min.js"></script>
|
||||
<!-- Load nostr‑tools v2.17.0 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]7.0/lib/nostr.bundle.min.js"></script>
|
||||
</head>
|
||||
<body class="iframe-form" data-ds="new">
|
||||
<div class="container">
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<script type="module" src="./theme-sync.js"></script>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
|
||||
<!-- Load nostr‑tools v2.10.4 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]0.4/lib/nostr.bundle.min.js"></script>
|
||||
<!-- Load nostr‑tools v2.17.0 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]7.0/lib/nostr.bundle.min.js"></script>
|
||||
</head>
|
||||
<body class="iframe-form" data-ds="new">
|
||||
<div class="container">
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<script type="module" src="./theme-sync.js"></script>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
|
||||
<!-- Load nostr‑tools v2.10.4 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]0.4/lib/nostr.bundle.min.js"></script>
|
||||
<!-- Load nostr‑tools v2.17.0 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]7.0/lib/nostr.bundle.min.js"></script>
|
||||
</head>
|
||||
<body class="iframe-form" data-ds="new">
|
||||
<div class="container">
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<script type="module" src="./theme-sync.js"></script>
|
||||
<!-- Link to your main stylesheet -->
|
||||
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
|
||||
<!-- Load nostr‑tools v2.10.4 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]0.4/lib/nostr.bundle.min.js"></script>
|
||||
<!-- Load nostr‑tools v2.17.0 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]7.0/lib/nostr.bundle.min.js"></script>
|
||||
</head>
|
||||
<body class="iframe-form" data-ds="new">
|
||||
<div class="container">
|
||||
|
||||
+317
-29
@@ -9,7 +9,10 @@
|
||||
>
|
||||
<div class="bv-modal-backdrop" data-dismiss></div>
|
||||
|
||||
<div class="modal-sheet max-h-modal-shell overflow-hidden" tabindex="-1">
|
||||
<div
|
||||
class="modal-sheet max-h-modal-shell flex min-h-0 flex-col overflow-hidden"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Header with the special exit button (unchanged) -->
|
||||
<div class="modal-header">
|
||||
<h2 id="loginModalTitle" class="text-xl font-bold text-text">
|
||||
@@ -36,36 +39,321 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body with buttons (unchanged) -->
|
||||
<div id="loginModalDescription" class="modal-body space-y-4 overflow-y-auto">
|
||||
<!-- Application Form button -->
|
||||
<button
|
||||
id="openApplicationModal"
|
||||
class="btn w-full"
|
||||
type="button"
|
||||
>
|
||||
Application Form
|
||||
</button>
|
||||
<!-- Body -->
|
||||
<div
|
||||
id="loginModalDescription"
|
||||
class="modal-body flex-1 space-y-6 overflow-y-auto"
|
||||
>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Simple horizontal rule -->
|
||||
<div class="my-2 border-t border-border/60"></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"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-semibold text-text">Available methods</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
Pick the option that matches how you manage your Nostr identity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Existing login buttons -->
|
||||
<button
|
||||
id="loginNIP07"
|
||||
class="btn w-full"
|
||||
type="button"
|
||||
>
|
||||
Login with Extension (NIP-07)
|
||||
</button>
|
||||
<button
|
||||
id="loginNSEC"
|
||||
class="btn-ghost w-full"
|
||||
disabled
|
||||
type="button"
|
||||
>
|
||||
Login with NSEC (Coming Soon)
|
||||
</button>
|
||||
<!-- Login provider buttons are rendered dynamically -->
|
||||
<div
|
||||
data-login-providers
|
||||
class="grid auto-rows-fr gap-4 sm:grid-cols-2 xl:grid-cols-3"
|
||||
></div>
|
||||
</section>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1 text-left">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-text-muted">
|
||||
Need an invite?
|
||||
</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
Submit an application and we'll send you updates when new slots open.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="openApplicationModal"
|
||||
class="btn w-full"
|
||||
type="button"
|
||||
>
|
||||
Application Form
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template data-login-provider-template>
|
||||
<button
|
||||
class="login-option"
|
||||
type="button"
|
||||
data-provider-button
|
||||
>
|
||||
<span class="login-option__header">
|
||||
<span class="login-option__copy">
|
||||
<span
|
||||
data-provider-eyebrow
|
||||
class="login-option__eyebrow hidden"
|
||||
></span>
|
||||
<span data-provider-label class="login-option__label"></span>
|
||||
<span
|
||||
data-provider-description
|
||||
class="login-option__description"
|
||||
></span>
|
||||
</span>
|
||||
<span aria-hidden="true" class="login-option__chevron">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
<div data-provider-status-row class="login-option__status hidden">
|
||||
<span data-provider-status class="truncate"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link text-sm hidden"
|
||||
data-provider-disconnect
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
data-provider-capabilities
|
||||
class="login-option__capabilities hidden"
|
||||
></span>
|
||||
</button>
|
||||
</template>
|
||||
<template data-login-nsec-dialog>
|
||||
<form
|
||||
data-nsec-form
|
||||
class="space-y-4 rounded-xl border border-border bg-overlay-panel-soft px-5 py-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold text-text">Direct key login</h3>
|
||||
<p class="text-sm text-text-muted" data-nsec-warning>
|
||||
"Unsafe by default" — imported keys stay decrypted only for this session
|
||||
unless you protect them with a passphrase.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
data-nsec-error
|
||||
class="hidden rounded-lg border border-danger/60 bg-danger/10 px-4 py-2 text-sm text-danger"
|
||||
role="alert"
|
||||
></div>
|
||||
<fieldset data-nsec-unlock-section class="space-y-3 hidden">
|
||||
<legend class="text-sm font-semibold text-text">Unlock saved key</legend>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-text">
|
||||
<input
|
||||
type="radio"
|
||||
name="nsec-mode"
|
||||
value="unlock"
|
||||
class="radio"
|
||||
data-nsec-mode="unlock"
|
||||
/>
|
||||
<span>Use the encrypted key stored on this device</span>
|
||||
</label>
|
||||
<div class="space-y-2 pl-6" data-nsec-unlock-fields>
|
||||
<label class="block text-sm font-medium text-text" for="nsecUnlockPassphrase">
|
||||
Passphrase
|
||||
</label>
|
||||
<input
|
||||
id="nsecUnlockPassphrase"
|
||||
type="password"
|
||||
class="input"
|
||||
autocomplete="current-password"
|
||||
data-nsec-unlock-passphrase
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset data-nsec-import-section class="space-y-3">
|
||||
<legend class="text-sm font-semibold text-text">Import a new secret</legend>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-text">
|
||||
<input
|
||||
type="radio"
|
||||
name="nsec-mode"
|
||||
value="import"
|
||||
class="radio"
|
||||
data-nsec-mode="import"
|
||||
checked
|
||||
/>
|
||||
<span>Paste an nsec, hex key, or mnemonic seed</span>
|
||||
</label>
|
||||
<div class="space-y-3 pl-6" data-nsec-import-fields>
|
||||
<label class="block text-sm font-medium text-text" for="nsecSecretInput">
|
||||
Secret
|
||||
</label>
|
||||
<textarea
|
||||
id="nsecSecretInput"
|
||||
class="input"
|
||||
rows="3"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
data-nsec-secret
|
||||
required
|
||||
></textarea>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-text">
|
||||
<input type="checkbox" class="checkbox" data-nsec-remember />
|
||||
<span>Remember this key on this device</span>
|
||||
</label>
|
||||
<div class="space-y-2" data-nsec-passphrase-fields hidden>
|
||||
<label class="block text-sm font-medium text-text" for="nsecPassphraseInput">
|
||||
Passphrase
|
||||
</label>
|
||||
<input
|
||||
id="nsecPassphraseInput"
|
||||
type="password"
|
||||
class="input"
|
||||
autocomplete="new-password"
|
||||
data-nsec-passphrase
|
||||
/>
|
||||
<label class="block text-sm font-medium text-text" for="nsecPassphraseConfirm">
|
||||
Confirm passphrase
|
||||
</label>
|
||||
<input
|
||||
id="nsecPassphraseConfirm"
|
||||
type="password"
|
||||
class="input"
|
||||
autocomplete="new-password"
|
||||
data-nsec-passphrase-confirm
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn-ghost" data-nsec-cancel>Cancel</button>
|
||||
<button type="submit" class="btn" data-nsec-submit>Continue</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template data-login-nip46-dialog>
|
||||
<form
|
||||
data-nip46-form
|
||||
class="space-y-4 rounded-xl border border-border bg-overlay-panel-soft px-5 py-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold text-text">Pair a remote signer</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
Scan the QR code with your signer or share the connect link to start a
|
||||
Nostr Connect session.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
data-nip46-error
|
||||
class="hidden rounded-lg border border-danger/60 bg-danger/10 px-4 py-2 text-sm text-danger"
|
||||
role="alert"
|
||||
></div>
|
||||
<div data-nip46-handshake-panel class="space-y-4">
|
||||
<div class="flex flex-col items-center gap-5 sm:items-stretch">
|
||||
<div
|
||||
class="flex aspect-square w-full max-w-sm items-center justify-center rounded-2xl border border-border-strong bg-white p-5 shadow-sm sm:max-w-md sm:p-8"
|
||||
data-nip46-qr
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="w-full text-center text-sm text-text-muted">
|
||||
Generating connect code…
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full space-y-3 sm:max-w-xl">
|
||||
<label class="block text-sm font-medium text-text" for="nip46HandshakeUri">
|
||||
Connect URI
|
||||
</label>
|
||||
<textarea
|
||||
id="nip46HandshakeUri"
|
||||
class="input"
|
||||
rows="3"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
data-nip46-handshake-uri
|
||||
readonly
|
||||
></textarea>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost w-full text-sm sm:w-auto"
|
||||
data-nip46-copy-uri
|
||||
disabled
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
data-nip46-secret
|
||||
class="hidden break-words text-xs text-text-muted"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-nip46-status
|
||||
class="hidden rounded-lg border border-info/40 bg-info/10 px-4 py-2 text-sm text-info"
|
||||
role="status"
|
||||
></div>
|
||||
<div
|
||||
data-nip46-auth
|
||||
class="hidden rounded-lg border border-warning/50 bg-warning/10 px-4 py-2 text-sm text-warning"
|
||||
role="alert"
|
||||
>
|
||||
<p data-nip46-auth-message class="mb-2"></p>
|
||||
<button type="button" class="btn-link text-sm" data-nip46-auth-open>
|
||||
Open signer challenge
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<button type="button" class="btn-link text-sm" data-nip46-toggle-manual>
|
||||
Paste a signer-provided bunker link instead
|
||||
</button>
|
||||
<div class="hidden space-y-2" data-nip46-manual-fields>
|
||||
<label class="block text-sm font-medium text-text" for="nip46LegacyUri">
|
||||
Signer link
|
||||
</label>
|
||||
<textarea
|
||||
id="nip46LegacyUri"
|
||||
class="input"
|
||||
rows="2"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder="bunker://…"
|
||||
data-nip46-legacy-uri
|
||||
></textarea>
|
||||
<p class="text-xs text-text-muted">
|
||||
Use this fallback when your signer cannot scan QR codes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-text">
|
||||
<input type="checkbox" class="checkbox" data-nip46-remember checked />
|
||||
<span>Remember this signer for future logins</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link text-sm hidden"
|
||||
data-nip46-reuse
|
||||
>
|
||||
Reconnect using the saved session
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn-ghost" data-nip46-cancel>Cancel</button>
|
||||
<button type="submit" class="btn" data-nip46-submit>Connect</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,6 +100,15 @@
|
||||
>
|
||||
Wallet
|
||||
</button>
|
||||
<button
|
||||
id="profileNavHashtags"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneHashtags"
|
||||
aria-selected="false"
|
||||
>
|
||||
Hashtags
|
||||
</button>
|
||||
<button
|
||||
id="profileNavBlocked"
|
||||
type="button"
|
||||
@@ -452,6 +461,111 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneHashtags"
|
||||
class="profile-pane hidden space-y-6"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavHashtags"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-muted">
|
||||
Follow or mute hashtags to personalize the videos we show you.
|
||||
</p>
|
||||
<p
|
||||
id="profileHashtagStatus"
|
||||
class="text-sm text-muted"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<div class="card space-y-4 p-5">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-semibold text-text">Interests</h3>
|
||||
<p class="text-sm text-muted">
|
||||
Videos tagged with these hashtags will be highlighted for you.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label
|
||||
class="flex-1 text-sm text-muted-strong"
|
||||
for="profileHashtagInterestInput"
|
||||
>
|
||||
<span class="mb-2 block font-medium">Add a hashtag</span>
|
||||
<input
|
||||
id="profileHashtagInterestInput"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
placeholder="nostr"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
id="profileAddHashtagInterestBtn"
|
||||
type="button"
|
||||
class="btn focus-ring"
|
||||
>
|
||||
Add interest
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="profileHashtagInterestEmpty"
|
||||
class="card border border-dashed border-border/60 p-4 text-center text-sm text-muted"
|
||||
>
|
||||
You haven’t added any interest hashtags yet.
|
||||
</p>
|
||||
<ul
|
||||
id="profileHashtagInterestList"
|
||||
class="profile-hashtag-list"
|
||||
data-orientation="horizontal"
|
||||
></ul>
|
||||
</div>
|
||||
|
||||
<div class="card space-y-4 p-5">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-semibold text-text">Disinterests</h3>
|
||||
<p class="text-sm text-muted">
|
||||
We’ll de-emphasize or hide videos using these hashtags.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label
|
||||
class="flex-1 text-sm text-muted-strong"
|
||||
for="profileHashtagDisinterestInput"
|
||||
>
|
||||
<span class="mb-2 block font-medium">Add a hashtag</span>
|
||||
<input
|
||||
id="profileHashtagDisinterestInput"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
placeholder="spam"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
id="profileAddHashtagDisinterestBtn"
|
||||
type="button"
|
||||
class="btn focus-ring"
|
||||
>
|
||||
Add disinterest
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="profileHashtagDisinterestEmpty"
|
||||
class="card border border-dashed border-border/60 p-4 text-center text-sm text-muted"
|
||||
>
|
||||
You haven’t muted any hashtags yet.
|
||||
</p>
|
||||
<ul
|
||||
id="profileHashtagDisinterestList"
|
||||
class="profile-hashtag-list"
|
||||
data-orientation="horizontal"
|
||||
></ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneBlocked"
|
||||
class="profile-pane hidden space-y-4"
|
||||
|
||||
+554
-242
@@ -12,29 +12,39 @@
|
||||
<div class="player-modal__container video-modal__container">
|
||||
<div id="modalNav" class="modal-nav modal-nav--visible">
|
||||
<div class="watch-container modal-nav__bar">
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
id="closeModal"
|
||||
type="button"
|
||||
class="icon-button focus-ring accent-action-button"
|
||||
<div class="modal-nav__rail">
|
||||
<div class="modal-nav__slot modal-nav__slot--primary">
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
id="closeModal"
|
||||
type="button"
|
||||
class="icon-button focus-ring accent-action-button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
<span class="sr-only">Close player</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="modal-nav__slot modal-nav__slot--context"
|
||||
data-modal-nav-context
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
<span class="sr-only">Close player</span>
|
||||
</button>
|
||||
<div id="modalNavContext" class="modal-nav__context" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,8 +52,66 @@
|
||||
<div class="player-modal__content video-modal__panel flex flex-col gap-6">
|
||||
<div class="video-modal__layout">
|
||||
<div class="video-modal__primary flex flex-col gap-6">
|
||||
<div class="watch-container">
|
||||
<div class="video-modal__video">
|
||||
<div class="watch-container video-modal__stage">
|
||||
<div class="video-modal__ambient" aria-hidden="true">
|
||||
<canvas
|
||||
id="ambientCanvas"
|
||||
class="video-modal__ambient-canvas"
|
||||
></canvas>
|
||||
</div>
|
||||
<div class="video-modal__video" data-video-surface>
|
||||
<div
|
||||
class="video-modal__moderation-overlay"
|
||||
data-moderation-overlay
|
||||
hidden
|
||||
>
|
||||
<div
|
||||
class="moderation-badge"
|
||||
data-moderation-badge="true"
|
||||
data-variant="warning"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<span class="moderation-badge__label inline-flex items-center gap-xs">
|
||||
<span class="moderation-badge__icon" aria-hidden="true">
|
||||
<svg
|
||||
class="moderation-badge__icon-mark"
|
||||
viewBox="0 0 20 20"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm-.75-11.75a.75.75 0 011.5 0v4.5a.75.75 0 01-1.5 0v-4.5zm.75 8.5a1 1 0 100-2 1 1 0 000 2z"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span
|
||||
class="moderation-badge__text"
|
||||
data-moderation-text
|
||||
>
|
||||
Content or user blocked by a trusted contact.
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
class="moderation-badge__actions"
|
||||
data-moderation-actions
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="moderation-badge__action flex-shrink-0"
|
||||
data-moderation-action="override"
|
||||
aria-pressed="false"
|
||||
aria-label="Show anyway"
|
||||
>
|
||||
Show anyway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<video
|
||||
id="modalVideo"
|
||||
class="video-modal__media"
|
||||
@@ -52,210 +120,163 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="watch-container"
|
||||
data-video-stats-container
|
||||
hidden
|
||||
>
|
||||
<div
|
||||
class="video-modal__stats rounded-2xl border border-border border-opacity-20 bg-surface px-6 py-5 shadow-sm"
|
||||
data-video-stats
|
||||
>
|
||||
<div class="video-modal__progress">
|
||||
<progress
|
||||
id="modalProgress"
|
||||
class="progress"
|
||||
max="100"
|
||||
value="0"
|
||||
data-variant="accent"
|
||||
aria-label="Download progress"
|
||||
></progress>
|
||||
<p
|
||||
id="modalProgressStatus"
|
||||
class="sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
</div>
|
||||
<div class="video-modal__stat-metrics flex flex-wrap items-center justify-between gap-3 text-sm text-muted">
|
||||
<span id="modalPeers">Peers: 0</span>
|
||||
<span id="modalSpeed">Speed: 0 KB/s</span>
|
||||
<span id="modalDownloaded">Downloaded: 0 MB / 0 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="watch-container">
|
||||
<div class="modal-info-panel">
|
||||
<div class="video-modal__details flex flex-col gap-6">
|
||||
<div
|
||||
class="video-modal__headline flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"
|
||||
>
|
||||
<h2 id="videoTitle" class="video-modal__title"></h2>
|
||||
<div
|
||||
id="modalStatus"
|
||||
class="video-modal__status text-sm text-muted lg:text-right"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="video-modal__headline flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"
|
||||
>
|
||||
Initializing... Just give it a sec.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="video-modal__meta flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="video-modal__meta-info flex flex-wrap items-center gap-3">
|
||||
<span id="videoTimestamp" class="modal-meta">Posted just now</span>
|
||||
<span id="videoEditedTimestamp" class="modal-meta hidden">Last edited just now</span>
|
||||
<span id="videoViewCount" class="modal-meta modal-view-count">– views</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-modal__actions-row flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
id="copyMagnetBtn"
|
||||
type="button"
|
||||
class="video-modal__action-button focus-ring accent-action-button"
|
||||
aria-label="Copy magnet"
|
||||
>
|
||||
<svg
|
||||
class="video-modal__action-icon icon-image"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M17.374 20.235c2.444-2.981 6.626-8.157 6.626-8.157l-3.846-3.092s-2.857 3.523-6.571 8.097c-4.312 5.312-11.881-2.41-6.671-6.671 4.561-3.729 8.097-6.57 8.097-6.57l-3.092-3.842s-5.173 4.181-8.157 6.621c-2.662 2.175-3.76 4.749-3.76 7.24 0 5.254 4.867 10.139 10.121 10.139 2.487 0 5.064-1.095 7.253-3.765zm4.724-7.953l-1.699 2.111-1.74-1.397 1.701-2.114 1.738 1.4zm-10.386-10.385l1.4 1.738-2.113 1.701-1.397-1.74 2.11-1.699z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="video-modal__action-label">Copy magnet</span>
|
||||
</button>
|
||||
<div class="popover" data-modal-zap-wrapper>
|
||||
<button
|
||||
id="modalZapBtn"
|
||||
type="button"
|
||||
class="video-modal__action-button focus-ring accent-action-button"
|
||||
aria-label="Send a zap"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
aria-controls="modalZapDialog"
|
||||
>
|
||||
<svg
|
||||
class="video-modal__action-icon icon-image"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<polygon
|
||||
points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span class="video-modal__action-label">Zap</span>
|
||||
</button>
|
||||
<h2 id="videoTitle" class="video-modal__title"></h2>
|
||||
<div
|
||||
id="modalZapDialog"
|
||||
class="popover__panel card w-72 max-w-popover-safe px-5 py-4"
|
||||
data-variant="zap"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
data-state="closed"
|
||||
hidden
|
||||
id="modalStatus"
|
||||
class="video-modal__status text-sm text-muted lg:text-right"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="grid gap-4">
|
||||
<div class="mb-4 flex items-center justify-between gap-4">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Send a zap
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
id="modalZapCloseBtn"
|
||||
class="focus-ring inline-flex h-8 w-8 items-center justify-center rounded-full text-muted transition hover:text-text"
|
||||
aria-label="Close zap dialog"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="modalZapWalletPrompt"
|
||||
class="mb-3 text-sm text-muted"
|
||||
aria-hidden="true"
|
||||
hidden
|
||||
>
|
||||
Connect a wallet in
|
||||
<button
|
||||
type="button"
|
||||
id="modalZapWalletLink"
|
||||
class="focus-ring inline-flex items-center gap-1 text-sm font-semibold text-info underline decoration-dotted underline-offset-2 hover:text-info-strong"
|
||||
>
|
||||
Wallet Connect settings
|
||||
</button>
|
||||
to send zaps.
|
||||
</p>
|
||||
<form id="modalZapForm" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="modalZapAmountInput"
|
||||
class="text-xs font-semibold uppercase tracking-wide text-muted"
|
||||
>
|
||||
Zap amount (sats)
|
||||
</label>
|
||||
<input
|
||||
id="modalZapAmountInput"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
class="input"
|
||||
placeholder="Enter sats"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="modalZapCommentInput"
|
||||
class="text-xs font-semibold uppercase tracking-wide text-muted"
|
||||
>
|
||||
Comment (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="modalZapCommentInput"
|
||||
rows="2"
|
||||
maxlength="280"
|
||||
class="input resize-y"
|
||||
placeholder="Include a note with your zap"
|
||||
></textarea>
|
||||
</div>
|
||||
<p
|
||||
id="modalZapSplitSummary"
|
||||
class="text-sm text-muted"
|
||||
aria-live="polite"
|
||||
>
|
||||
Enter an amount to view the split.
|
||||
</p>
|
||||
<p
|
||||
id="modalZapStatus"
|
||||
class="text-xs text-muted"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
<ul
|
||||
id="modalZapReceipts"
|
||||
class="space-y-2 text-xs text-muted"
|
||||
></ul>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
id="modalZapSendBtn"
|
||||
class="btn"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
Initializing... Just give it a sec.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="shareBtn"
|
||||
type="button"
|
||||
class="video-modal__action-button focus-ring accent-action-button"
|
||||
aria-label="Share video"
|
||||
<div
|
||||
class="video-modal__meta flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<svg
|
||||
class="video-modal__action-icon icon-image"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
<div class="video-modal__meta-info flex flex-wrap items-center gap-3">
|
||||
<span id="videoTimestamp" class="modal-meta">Posted just now</span>
|
||||
<span id="videoEditedTimestamp" class="modal-meta hidden">Last edited just now</span>
|
||||
<span id="videoViewCount" class="modal-meta modal-view-count">– views</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-modal__actions-row">
|
||||
<div class="video-modal__reactions">
|
||||
<div class="video-modal__reaction-buttons inline-flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
id="modalLikeBtn"
|
||||
type="button"
|
||||
class="video-modal__action-button video-modal__reaction-button focus-ring accent-action-button"
|
||||
data-reaction="+"
|
||||
aria-pressed="false"
|
||||
aria-label="Like this video"
|
||||
>
|
||||
<svg
|
||||
class="video-modal__action-icon icon-image"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M7 10v12H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M7 10 11.34 2.67A2 2 0 0 1 13.14 2H14a2 2 0 0 1 2 2v4h3.37a2 2 0 0 1 1.97 2.5l-1.1 5A2 2 0 0 1 18.3 18H11"
|
||||
/>
|
||||
</svg>
|
||||
<span class="video-modal__action-label">Like</span>
|
||||
<span class="video-modal__reaction-count" data-reaction-like-count>0</span>
|
||||
</button>
|
||||
<button
|
||||
id="modalDislikeBtn"
|
||||
type="button"
|
||||
class="video-modal__action-button video-modal__reaction-button focus-ring accent-action-button"
|
||||
data-reaction="-"
|
||||
aria-pressed="false"
|
||||
aria-label="Dislike this video"
|
||||
>
|
||||
<svg
|
||||
class="video-modal__action-icon icon-image"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17 14V2h3a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17 14 12.66 21.33A2 2 0 0 1 10.86 22H10a2 2 0 0 1-2-2v-4H4.63a2 2 0 0 1-1.97-2.5l1.1-5A2 2 0 0 1 4.73 8H13"
|
||||
/>
|
||||
</svg>
|
||||
<span class="video-modal__action-label">Dislike</span>
|
||||
<span class="video-modal__reaction-count" data-reaction-dislike-count>0</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="video-modal__reaction-meter"
|
||||
data-reaction-meter
|
||||
role="meter"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="0"
|
||||
aria-valuetext="No reactions yet"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5 7a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm11.122 12.065A4 4 0 1 1 24 20a4 4 0 0 1-7.878-.935l-5.488-2.927a6.009 6.009 0 0 1-.943 1.764l5.488 2.927ZM24 4a4 4 0 1 1-7.878.935l-5.488 2.927a6.01 6.01 0 0 1-.943-1.764l5.488-2.927A4 4 0 0 1 24 4Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="video-modal__action-label">Share</span>
|
||||
</button>
|
||||
<div class="relative" data-more-menu-wrapper="true">
|
||||
<span class="video-modal__reaction-meter-label" data-reaction-meter-label>No reactions yet</span>
|
||||
<div class="video-modal__reaction-meter-track" aria-hidden="true">
|
||||
<div
|
||||
class="video-modal__reaction-meter-fill"
|
||||
data-reaction-meter-fill
|
||||
style="width: 0%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="sr-only" data-reaction-meter-sr>No reactions yet</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-modal__actions-group">
|
||||
<button
|
||||
id="modalMoreBtn"
|
||||
id="copyMagnetBtn"
|
||||
type="button"
|
||||
class="video-modal__action-button focus-ring accent-action-button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="More actions"
|
||||
data-more-dropdown="modal"
|
||||
aria-label="Copy magnet"
|
||||
>
|
||||
<svg
|
||||
class="video-modal__action-icon icon-image"
|
||||
@@ -263,12 +284,184 @@
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<circle cx="5" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="19" cy="12" r="2" fill="currentColor" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M17.374 20.235c2.444-2.981 6.626-8.157 6.626-8.157l-3.846-3.092s-2.857 3.523-6.571 8.097c-4.312 5.312-11.881-2.41-6.671-6.671 4.561-3.729 8.097-6.57 8.097-6.57l-3.092-3.842s-5.173 4.181-8.157 6.621c-2.662 2.175-3.76 4.749-3.76 7.24 0 5.254 4.867 10.139 10.121 10.139 2.487 0 5.064-1.095 7.253-3.765zm4.724-7.953l-1.699 2.111-1.74-1.397 1.701-2.114 1.738 1.4zm-10.386-10.385l1.4 1.738-2.113 1.701-1.397-1.74 2.11-1.699z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="video-modal__action-label">More</span>
|
||||
<span class="video-modal__action-label">Copy magnet</span>
|
||||
</button>
|
||||
<div class="popover" data-modal-zap-wrapper>
|
||||
<button
|
||||
id="modalZapBtn"
|
||||
type="button"
|
||||
class="video-modal__action-button focus-ring accent-action-button"
|
||||
aria-label="Send a zap"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
aria-controls="modalZapDialog"
|
||||
>
|
||||
<svg
|
||||
class="video-modal__action-icon icon-image"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<polygon
|
||||
points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span class="video-modal__action-label">Zap</span>
|
||||
</button>
|
||||
<div
|
||||
id="modalZapDialog"
|
||||
class="popover__panel card w-72 max-w-popover-safe px-5 py-4"
|
||||
data-variant="zap"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
data-state="closed"
|
||||
hidden
|
||||
>
|
||||
<div class="grid gap-4">
|
||||
<div class="mb-4 flex items-center justify-between gap-4">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Send a zap
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
id="modalZapCloseBtn"
|
||||
class="focus-ring inline-flex h-8 w-8 items-center justify-center rounded-full text-muted transition hover:text-text"
|
||||
aria-label="Close zap dialog"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="modalZapWalletPrompt"
|
||||
class="mb-3 text-sm text-muted"
|
||||
aria-hidden="true"
|
||||
hidden
|
||||
>
|
||||
Connect a wallet in
|
||||
<button
|
||||
type="button"
|
||||
id="modalZapWalletLink"
|
||||
class="focus-ring inline-flex items-center gap-1 text-sm font-semibold text-info underline decoration-dotted underline-offset-2 hover:text-info-strong"
|
||||
>
|
||||
Wallet Connect settings
|
||||
</button>
|
||||
to send zaps.
|
||||
</p>
|
||||
<form id="modalZapForm" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="modalZapAmountInput"
|
||||
class="text-xs font-semibold uppercase tracking-wide text-muted"
|
||||
>
|
||||
Zap amount (sats)
|
||||
</label>
|
||||
<input
|
||||
id="modalZapAmountInput"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
class="input"
|
||||
placeholder="Enter sats"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="modalZapCommentInput"
|
||||
class="text-xs font-semibold uppercase tracking-wide text-muted"
|
||||
>
|
||||
Comment (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="modalZapCommentInput"
|
||||
rows="2"
|
||||
maxlength="280"
|
||||
class="input resize-y"
|
||||
placeholder="Include a note with your zap"
|
||||
></textarea>
|
||||
</div>
|
||||
<p
|
||||
id="modalZapSplitSummary"
|
||||
class="text-sm text-muted"
|
||||
aria-live="polite"
|
||||
>
|
||||
Enter an amount to view the split.
|
||||
</p>
|
||||
<p
|
||||
id="modalZapStatus"
|
||||
class="text-xs text-muted"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
<ul
|
||||
id="modalZapReceipts"
|
||||
class="space-y-2 text-xs text-muted"
|
||||
></ul>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
id="modalZapSendBtn"
|
||||
class="btn"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="shareBtn"
|
||||
type="button"
|
||||
class="video-modal__action-button focus-ring accent-action-button"
|
||||
aria-label="Share video"
|
||||
>
|
||||
<svg
|
||||
class="video-modal__action-icon icon-image"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5 7a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm11.122 12.065A4 4 0 1 1 24 20a4 4 0 0 1-7.878-.935l-5.488-2.927a6.009 6.009 0 0 1-.943 1.764l5.488 2.927ZM24 4a4 4 0 1 1-7.878.935l-5.488 2.927a6.01 6.01 0 0 1-.943-1.764l5.488-2.927A4 4 0 0 1 24 4Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="video-modal__action-label">Share</span>
|
||||
</button>
|
||||
<div class="relative" data-more-menu-wrapper="true">
|
||||
<button
|
||||
id="modalMoreBtn"
|
||||
type="button"
|
||||
class="video-modal__action-button focus-ring accent-action-button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="More actions"
|
||||
data-more-dropdown="modal"
|
||||
>
|
||||
<svg
|
||||
class="video-modal__action-icon icon-image"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<circle cx="5" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="19" cy="12" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
<span class="video-modal__action-label">More</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -297,36 +490,155 @@
|
||||
No description available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="comment-thread__disabled"
|
||||
data-comments-disabled-placeholder
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
hidden
|
||||
>
|
||||
Comments have been turned off for this video.
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="video-modal__comments comment-thread"
|
||||
aria-labelledby="videoModalCommentsHeading"
|
||||
role="region"
|
||||
data-comments-root
|
||||
>
|
||||
<div class="comment-thread__header">
|
||||
<h2
|
||||
id="videoModalCommentsHeading"
|
||||
class="comment-thread__title"
|
||||
>
|
||||
Comments
|
||||
</h2>
|
||||
<p
|
||||
class="comment-thread__count"
|
||||
data-comments-count
|
||||
aria-live="polite"
|
||||
>
|
||||
0 comments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="comment-thread__empty"
|
||||
data-comments-empty
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
No comments yet. Be the first to share your thoughts.
|
||||
</p>
|
||||
|
||||
<ol
|
||||
class="comment-thread__list"
|
||||
data-comments-list
|
||||
role="list"
|
||||
aria-live="polite"
|
||||
></ol>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="comment-thread__load-more focus-ring"
|
||||
data-comments-load-more
|
||||
hidden
|
||||
>
|
||||
Load more comments
|
||||
</button>
|
||||
|
||||
<form
|
||||
class="comment-composer"
|
||||
data-comments-composer
|
||||
aria-labelledby="videoModalCommentsHeading"
|
||||
novalidate
|
||||
>
|
||||
<div class="comment-composer__field">
|
||||
<label
|
||||
for="commentComposerInput"
|
||||
class="comment-composer__label"
|
||||
>
|
||||
Add a comment
|
||||
</label>
|
||||
<textarea
|
||||
id="commentComposerInput"
|
||||
name="comment"
|
||||
class="textarea comment-composer__textarea"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
placeholder="Share your thoughts"
|
||||
aria-describedby="commentComposerHelp commentComposerCount"
|
||||
data-comments-input
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="comment-composer__footer">
|
||||
<p
|
||||
id="commentComposerHelp"
|
||||
class="comment-composer__hint"
|
||||
>
|
||||
Keep it respectful and follow the community guidelines.
|
||||
</p>
|
||||
<div class="comment-composer__actions">
|
||||
<p
|
||||
id="commentComposerCount"
|
||||
class="comment-composer__char-count"
|
||||
data-comments-char-count
|
||||
aria-live="polite"
|
||||
>
|
||||
0 / 500
|
||||
</p>
|
||||
<button
|
||||
type="submit"
|
||||
class="comment-composer__submit btn"
|
||||
data-comments-submit
|
||||
disabled
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="comment-composer__status sr-only"
|
||||
data-comments-status
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="video-modal__secondary flex flex-col gap-4">
|
||||
<div class="watch-container">
|
||||
<div class="video-modal__stats rounded-2xl border border-border border-opacity-20 bg-surface px-6 py-5 shadow-sm">
|
||||
<div class="video-modal__progress">
|
||||
<progress
|
||||
id="modalProgress"
|
||||
class="progress"
|
||||
max="100"
|
||||
value="0"
|
||||
data-variant="accent"
|
||||
aria-label="Download progress"
|
||||
></progress>
|
||||
<p
|
||||
id="modalProgressStatus"
|
||||
class="sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
</div>
|
||||
<div class="video-modal__stat-metrics flex flex-wrap items-center justify-between gap-3 text-sm text-muted">
|
||||
<span id="modalPeers">Peers: 0</span>
|
||||
<span id="modalSpeed">Speed: 0 KB/s</span>
|
||||
<span id="modalDownloaded">Downloaded: 0 MB / 0 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="watch-container video-modal__similar">
|
||||
<div
|
||||
id="videoTags"
|
||||
class="video-modal__tags"
|
||||
hidden
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<section
|
||||
class="player-modal__module"
|
||||
aria-labelledby="playerModalSimilarContentHeading"
|
||||
>
|
||||
<h2
|
||||
id="playerModalSimilarContentHeading"
|
||||
class="player-modal__module-title"
|
||||
>
|
||||
Similar Content
|
||||
</h2>
|
||||
<ul
|
||||
id="playerModalSimilarContentList"
|
||||
class="player-modal__module-list player-modal__module-scroll"
|
||||
role="list"
|
||||
></ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,6 +191,63 @@ export const DEFAULT_WHITELIST_MODE_ENABLED = true;
|
||||
*/
|
||||
export const ALLOW_NSFW_CONTENT = false;
|
||||
|
||||
/**
|
||||
* Emergency fallback accounts for seeding the moderation graph.
|
||||
*
|
||||
* Runtime moderation now derives its trust seeds from the Super Admin and the
|
||||
* active moderator roster, ensuring their reports shape anonymous/default
|
||||
* visitor filters automatically. The array below is kept only as a last-resort
|
||||
* fallback when the live moderator lists cannot be loaded (for example,
|
||||
* bootstrapping a brand-new deployment or recovering from relay outages).
|
||||
* Populate it with the operator keys you want to lean on during that rare
|
||||
* failure mode; under normal conditions these values are ignored. bitvid will
|
||||
* freeze and sanitize the npubs at runtime when reading from `js/config.js`.
|
||||
*/
|
||||
export const DEFAULT_TRUST_SEED_NPUBS = [
|
||||
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx",
|
||||
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe",
|
||||
];
|
||||
|
||||
/**
|
||||
* Baseline trusted-report count that triggers thumbnail blurring.
|
||||
*
|
||||
* 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 3 trusted reports before blurring.
|
||||
*/
|
||||
export const DEFAULT_BLUR_THRESHOLD = 1;
|
||||
|
||||
/**
|
||||
* Baseline trusted-report count that blocks autoplay during browsing.
|
||||
*
|
||||
* 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 2 trusted reports before blocking autoplay.
|
||||
*/
|
||||
export const DEFAULT_AUTOPLAY_BLOCK_THRESHOLD = 1;
|
||||
|
||||
/**
|
||||
* Trusted mute threshold that hides creators globally.
|
||||
*
|
||||
* Once this many trusted accounts mute a creator, their videos disappear from
|
||||
* feeds until an operator intervenes. Keep the value low (1–2) to rapidly
|
||||
* enforce community standards or raise it to tolerate more reports before
|
||||
* hiding.
|
||||
*/
|
||||
export const DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD = 1;
|
||||
|
||||
/**
|
||||
* Trusted spam/report threshold that hides videos globally.
|
||||
*
|
||||
* 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 — 3 trusted reports is the upstream
|
||||
* baseline.
|
||||
*/
|
||||
export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD = 1;
|
||||
|
||||
/**
|
||||
* 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 at least three F1 contacts file trusted NIP-56 `nudity` reports, thumbnails render blurred and autoplay previews are paused. Every blur includes a “show anyway” override.
|
||||
- **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 three trusted reporters, but adjust the export to match your policy.
|
||||
- **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,8 +88,8 @@ 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 default (blur at ≥3 trusted reports, autoplay block at ≥2).
|
||||
- These controls update immediately and sync with the moderation overlays applied by `moreMenuController` and `VideoCard` components.
|
||||
- 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 3 trusted reports for blur, 2 for autoplay blocking, 1 trusted mute to hide authors, and 3 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**
|
||||
|
||||
|
||||
@@ -40,15 +40,18 @@ To implement a decentralized comment system for videos shared on a Nostr-based p
|
||||
Each component (comments, reactions, live chat) is represented as a Nostr event.
|
||||
|
||||
##### **Comment Event**:
|
||||
bitvid uses standard kind `1` text note replies so they interoperate with the moderation flows documented in [`docs/moderation/nips.md`](../../docs/moderation/nips.md). Each reply keeps both the video event ID and the root video definition tag so downstream services can resolve lineage.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1311,
|
||||
"kind": 1,
|
||||
"pubkey": "abcdef1234567890...",
|
||||
"created_at": 1675000000,
|
||||
"tags": [
|
||||
["e", "video-event-id"], // Reference to the video
|
||||
["e", "parent-comment-id"], // Reference to the parent comment (optional, for replies)
|
||||
["p", "commenter-pubkey"] // Optional: commenter pubkey
|
||||
["e", "video-event-id"], // Reference to the specific video event
|
||||
["a", "30078:video-author-pubkey:video-root-id"], // Reference to the video definition (kind 30078)
|
||||
["e", "parent-comment-id"], // Optional: reference to the parent comment for replies
|
||||
["p", "commenter-pubkey"] // Optional: commenter or video author pubkey for threading UX
|
||||
],
|
||||
"content": "This is a great video!"
|
||||
}
|
||||
@@ -86,13 +89,14 @@ Each component (comments, reactions, live chat) is represented as a Nostr event.
|
||||
Metadata tags are added to comments or live chat events as needed:
|
||||
```json
|
||||
{
|
||||
"kind": 1311,
|
||||
"kind": 1,
|
||||
"pubkey": "abcdef1234567890...",
|
||||
"created_at": 1675000000,
|
||||
"tags": [
|
||||
["e", "video-event-id"],
|
||||
["a", "30078:video-author-pubkey:video-root-id"],
|
||||
["m", "featured-comment"], // Example metadata tag
|
||||
["a", "admin-tag"] // Administrative tag
|
||||
["m", "admin-tag"] // Administrative tag
|
||||
],
|
||||
"content": "Highlighted comment for this video."
|
||||
}
|
||||
@@ -104,19 +108,24 @@ Metadata tags are added to comments or live chat events as needed:
|
||||
|
||||
#### **1. Posting a Comment**
|
||||
To post a comment:
|
||||
1. The client constructs a comment event using NIP-22 for `created_at` validation.
|
||||
2. The event includes references to the video (mandatory) and parent comments (optional).
|
||||
1. The client constructs a kind `1` comment event using NIP-22 for `created_at` validation.
|
||||
2. The event includes both the `['e', <videoEventId>]` and `['a', <30078:pubkey:videoRootId>]` tags (mandatory) plus parent comment references when replying. This mirrors the implementation in `js/services/discussionCountService.js` so reply counts and moderation filters remain aligned.
|
||||
3. The event is signed and published to relays.
|
||||
|
||||
##### API Example:
|
||||
```javascript
|
||||
async function postComment(videoId, commentText, parentCommentId = null) {
|
||||
async function postComment(
|
||||
{ videoEventId, videoAuthorPubkey, videoRootId },
|
||||
commentText,
|
||||
parentCommentId = null
|
||||
) {
|
||||
const event = {
|
||||
kind: 1311,
|
||||
kind: 1,
|
||||
pubkey: userPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['e', videoId],
|
||||
['e', videoEventId],
|
||||
['a', `30078:${videoAuthorPubkey}:${videoRootId}`],
|
||||
...(parentCommentId ? [['e', parentCommentId]] : []),
|
||||
],
|
||||
content: commentText
|
||||
@@ -151,10 +160,11 @@ Comments are retrieved using `REQ` messages filtered by the `e` tag for the vide
|
||||
|
||||
##### API Example:
|
||||
```javascript
|
||||
async function fetchComments(videoId) {
|
||||
async function fetchComments({ videoEventId, videoAuthorPubkey, videoRootId }) {
|
||||
const filter = {
|
||||
kinds: [1311],
|
||||
'#e': [videoId],
|
||||
kinds: [1],
|
||||
'#e': [videoEventId],
|
||||
'#a': [`30078:${videoAuthorPubkey}:${videoRootId}`],
|
||||
limit: 100
|
||||
};
|
||||
|
||||
@@ -192,7 +202,7 @@ Use NIP-24 to attach metadata to events for administrative or user-specific tags
|
||||
- User creates a comment event.
|
||||
- Client signs and publishes the event to relays.
|
||||
2. **Fetching Comments**:
|
||||
- Client requests comments for a video by filtering events with the `e` tag.
|
||||
- Client requests comments for a video by filtering events with the combined `#e` and `#a` tags so both the specific video event and its root definition are represented.
|
||||
3. **Reacting to Comments**:
|
||||
- Users react to comments by posting reaction events.
|
||||
4. **Live Discussions**:
|
||||
@@ -200,6 +210,8 @@ Use NIP-24 to attach metadata to events for administrative or user-specific tags
|
||||
5. **Metadata Management**:
|
||||
- Metadata is attached during event creation or editing.
|
||||
|
||||
> **Tagging Requirement:** Clients must keep the combined `#e`/`#a` tagging pattern shown above. `discussionCountService.buildDiscussionCountFilters` depends on those tags when querying relays so UI components can surface accurate comment totals.
|
||||
|
||||
---
|
||||
|
||||
### **UI/UX Considerations**
|
||||
@@ -220,6 +232,9 @@ Use NIP-24 to attach metadata to events for administrative or user-specific tags
|
||||
4. **Nested Threading**:
|
||||
- Indent replies to show comment hierarchy.
|
||||
|
||||
5. **Comment Gate**:
|
||||
- UI components such as `VideoCard.buildDiscussionCount` already hide comment affordances when `enableComments` is false. Future enhancements must honor the same feature flag so comment entry points remain consistent with existing gating.
|
||||
|
||||
---
|
||||
|
||||
### **Testing and Validation**
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
:root{--surface-base:var(--color-surface);--surface-alt:var(--color-surface-alt);--surface-raised:var(--color-card);--surface-raised-private:var(--color-card-private);--surface-raised-critical:var(--color-card-critical);--surface-panel:var(--color-panel);--surface-panel-hover:var(--color-panel-hover);--surface-sunken:var(--color-surface-sunken);--surface-muted:var(--color-surface-muted);--surface-veil:var(--color-overlay-veil);--surface-inverse:var(--color-page);--surface-overlay:var(--color-overlay);--surface-overlay-muted:var(--color-overlay-muted);--surface-overlay-strong:var(--color-overlay-strong);--surface-overlay-darker:var(--color-overlay-darker);--surface-overlay-black:var(--color-overlay-black);--surface-overlay-banner-base:var(--color-page);--surface-overlay-banner:var(--surface-overlay-banner-base);--surface-overlay-banner-opaque:var(--surface-overlay-banner-base);--surface-overlay-banner-strong:color-mix(in srgb,var(--surface-overlay-banner-base) 82%,transparent);--surface-overlay-banner-bottom:color-mix(in srgb,var(--surface-overlay-banner-base) 50%,transparent);--surface-overlay-banner-channel-mid:color-mix(in srgb,var(--surface-overlay-banner-base) 24%,transparent);--surface-overlay-banner-channel-faint:color-mix(in srgb,var(--surface-overlay-banner-base) 10%,transparent);--surface-overlay-banner-soft:color-mix(in srgb,var(--surface-overlay-banner-base) 56%,transparent);--surface-overlay-banner-faint:color-mix(in srgb,var(--surface-overlay-banner-base) 18%,transparent);--surface-overlay-banner-transparent:transparent;--surface-overlay-banner-gradient-header:linear-gradient(to bottom,var(--surface-overlay-banner-opaque) 0%,var(--surface-overlay-banner-strong) 38%,var(--surface-overlay-banner-soft) 70%,var(--surface-overlay-banner-faint) 88%,var(--surface-overlay-banner-transparent) 100%);--surface-overlay-banner-gradient-channel:linear-gradient(to top,var(--surface-overlay-banner-bottom) 0%,var(--surface-overlay-banner-channel-mid) 28%,var(--surface-overlay-banner-channel-faint) 42%,var(--surface-overlay-banner-transparent) 50%);--surface-overlay-banner-gradient:var(
|
||||
--surface-overlay-banner-gradient-header
|
||||
);--surface-overlay-panel:var(--color-overlay-panel);--surface-overlay-panel-soft:var(--color-overlay-panel-soft);--surface-overlay-panel-tint:var(--color-overlay-panel-tint);--surface-overlay-panel-glass:var(--color-overlay-panel-glass);--surface-overlay-panel-glass-soft:var(--color-overlay-panel-glass-soft);--surface-modal-backdrop:var(--color-modal-backdrop);--surface-modal:var(--color-modal-surface);--blur-modal-backdrop:18px;--blur-modal-alert:14px;--blur-thumbnail-blur:18px;--modal-nav-backdrop-blur:6px;--app-header-centerline-offset:calc(var(--space-sm) + var(--space-2xl-plus)/2);--border-default:var(--color-border);--border-subtle:var(--color-border-subtle);--border-strong:var(--color-border-translucent-stronger);--border-overlay:var(--color-border-translucent);--border-overlay-strong:var(--color-border-translucent-strongest);--border-overlay-glow:var(--color-border-translucent-glow);--border-neutral-soft:var(--color-border-neutral-soft);--border-info-soft:var(--color-border-info-soft);--border-sidebar-strong:var(--color-border-sidebar-strong);--border-width-hairline:1px;--border-width-fine:0.5px;--text-primary:var(--color-text);--text-inverse:var(--color-text-inverse);--text-muted:var(--color-text-muted);--text-muted-strong:var(--color-muted-strong);--text-subtle:var(--color-subtle-text);--text-frosted-primary:var(--color-text-frosted);--text-frosted-strong:var(--color-text-frosted-strong);--text-frosted-soft:var(--color-text-frosted-soft);--status-info:var(--color-info);--status-info-strong:var(--color-info-strong);--status-info-surface:var(--color-info-glass);--status-info-border:var(--color-info-border);--status-info-on:var(--color-info-on);--status-success:var(--color-success);--status-success-strong:var(--color-success-strong);--status-success-surface:var(--color-success-surface);--status-success-border:var(--color-success-border);--status-success-on:var(--color-success-on);--status-warning:var(--color-warning);--status-warning-strong:var(--color-warning-strong);--status-warning-surface:var(--color-warning-surface);--status-warning-border:var(--color-warning-border);--status-warning-on:var(--color-warning-on);--status-danger:var(--color-critical);--status-danger-strong:var(--color-critical-strong);--status-danger-surface:var(--color-danger-surface);--status-danger-border:var(--color-danger-border);--status-danger-on:var(--color-danger-on);--status-private:var(--color-private);--status-private-strong:var(--color-private-strong);--status-private-surface:var(--color-private-surface);--status-private-border:var(--color-private-border);--status-private-on:var(--color-private-on);--status-neutral:var(--color-muted);--status-neutral-strong:var(--color-muted-strong);--status-neutral-surface:var(--color-neutral-surface);--status-neutral-border:var(--color-neutral-border);--status-neutral-on:var(--color-neutral-on);--scrollbar-width:0.5rem;--scrollbar-track:var(--color-white-soft);--scrollbar-thumb:var(--color-white-softer);--scrollbar-thumb-hover:var(--color-white-softest);--z-app-header:40;--z-overlay-nav:60;--z-overlay-floating:70;--z-overlay-popover:140;--z-overlay-toast:180;--z-sidebar-overlay:10;--z-sidebar:20;--z-modal-root:100;--z-modal-overlay:110;--z-modal-content:120;--z-modal-player:130;--z-modal-top-root:140;--z-modal-top-overlay:150;--z-modal-top-content:160;--overlay-backdrop-color:var(--color-overlay-backdrop);--overlay-backdrop-blur:12px;--sidebar-width-expanded-base:16rem;--sidebar-dropup-content-width:0px;--sidebar-width-expanded:max(var(--sidebar-width-expanded-base),var(--sidebar-dropup-content-width));--sidebar-width-collapsed:4rem;--sidebar-width:var(--sidebar-width-expanded);--sidebar-control-size:2.75rem;--sidebar-content-gap:1rem;--space-sidebar-shell-inline:var(--space-lg);--space-sidebar-shell-block-start:var(--space-sm);--space-sidebar-shell-block-end:1.75rem;--space-sidebar-scroll-gap:1.75rem;--space-sidebar-panel-gap:1.35rem;--space-sidebar-panel-padding-block:1.35rem;--space-sidebar-panel-padding-inline:1.1rem;--space-sidebar-footer-gap:0.85rem;--space-sidebar-trigger-padding-block:0.78rem;--space-sidebar-popover-offset:0.85rem;--space-sidebar-popover-padding-block:0.85rem;--space-sidebar-popover-padding-inline:0.65rem;--space-sidebar-popover-gap:0.45rem;--space-sidebar-dropup-scroll-reserve:calc(var(--scrollbar-thin-width) + var(--space-sm) + var(--space-2xs));--sidebar-dropup-max-height:18rem;--space-sidebar-nav-padding-block:0.7rem;--space-sidebar-nav-padding-inline-start:var(--space-xs);--space-sidebar-nav-padding-inline-end:calc(var(--space-lg) + var(--space-2xs));--space-sidebar-subnav-padding-block:0.55rem;--space-sidebar-nav-translate:0.35rem;--grid-card-min-width:20rem;--popover-backdrop-blur:12px;--overlay-panel-radius:var(--radius-lg);--overlay-panel-shadow:var(--shadow-lg);--overlay-panel-padding-inline:var(--popup-padding-inline);--overlay-panel-padding-block:var(--popup-padding-block);--toast-translate:-1.5rem;--sidebar-mobile-gutter:3rem;--popup-padding-Line truncated
|
||||
/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-family-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--font-family-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{font-size:80%}@media (min-width:768px){html{font-size:100%}}@media (min-width:1024px){html{font-size:75%}}*,:after,:before{scrollbar-width:thin;scrollbar-color:var(--scrollbar-thumb) var(--scrollbar-track)}::-webkit-scrollbar{width:var(--scrollbar-width);height:var(--scrollbar-width)}::-webkit-scrollbar-track{background:var(--scrollbar-track)}::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);border-radius:var(--radius-sm)}::-webkit-scrollbar-thumb:hover{background:var(--scrollbar-thumb-hover)}body{--app-header-centerline:calc(env(safe-area-inset-top, 0) + var(--app-header-centerline-offset));font-family:var(--font-family-sans);background-color:var(--color-page);color:var(--color-text-strong);line-height:1.5;margin:0;padding:0;overflow-x:hidden}#uiOverlay{position:fixed;inset:0;pointer-events:none;z-index:var(--z-overlay-popover)}#uiOverlay>*{pointer-events:auto}#editNip71TagsFieldset>legend,#nip71TagsFieldset>legend{margin-left:0;padding:0}#app{background-color:var(--color-page);color:inherit}body.modal-open,html.modal-open{overflow:hidden;height:100%}body.modal-open #app{overflow:hidden}body.modal-open #sidebar{pointer-events:none;opacity:0;visibility:hidden;z-index:var(--z-sidebar-overlay)}.ng-cloak,.ng-hide:not(.ng-hide-animate),.x-ng-cloak,[data-ng-cloak],[ng-cloak],[ng\:cloak],[x-ng-cloak]{display:none!important}ng\:form{display:block}.\!container{width:100%!important}.container{width:100%}@media (min-width:25rem){.\!container{max-width:25rem!important}.container{max-width:25rem}}@media (min-width:30rem){.\!container{max-width:30rem!important}.container{max-width:30rem}}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}#playerModal.ds-header{display:block}.ds-header{display:flex;align-items:center;justify-content:flex-start;padding-top:var(--space-sm);padding-bottom:var(--space-sm)}.app-shell__header{position:sticky;top:env(safe-area-inset-top,0);z-index:var(--z-app-header);padding-top:calc(var(--space-sm) + env(safe-area-inset-top, 0));padding-bottom:var(--space-sm);background-color:transparent;background-image:var(--surface-overlay-banner-gradient-header);background-repeat:no-repeat;background-size:100% 100%}.ds-metric-probe{position:absolute;tLine truncated
|
||||
--player-modal-inline-padding,clamp(var(--space-md-plus),4vw,var(--space-2xl))
|
||||
)}#playerModal .video-modal__primary>.watch-container:first-child{padding-block-start:var(--player-modal-vertical-padding)}#playerModal .video-modal__secondary>.watch-container:last-child{padding-block-end:var(--player-modal-vertical-padding)}#playerModal .modal-nav .watch-container{max-width:none;margin-inline:0}#playerModal .modal-info-panel{display:flex;flex-direction:column;gap:var(--space-lg)}#playerModal h2#videoTitle{margin:0;font-size:clamp(var(--font-size-xl),calc(var(--font-size-modal-title-base) + .5vw),var(--font-size-xl-plus));font-weight:600;line-height:1.3;color:var(--color-text)}#playerModal .modal-meta{font-size:var(--font-size-sm);line-height:1.4;color:var(--color-text-muted)}#playerModal .modal-view-count{font-weight:600;color:var(--color-text)}#playerModal .channel-card{display:flex;align-items:center;gap:var(--space-md);padding:var(--space-md-plus) var(--space-lg);border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-surface);box-shadow:var(--shadow-sm)}@media (max-width:30rem){#playerModal .modal-nav .icon-button{width:var(--space-xl);height:var(--space-xl);min-width:var(--space-xl);min-height:var(--space-xl)}#playerModal .watch-container{padding-inline:var(--space-sm)}#playerModal .channel-card{gap:var(--space-sm);padding:var(--space-sm) var(--space-md)}}#playerModal .channel-card__avatar{flex-shrink:0;width:var(--avatar-size-lg);height:var(--avatar-size-lg);border-radius:var(--radius-full);overflow:hidden;background-color:var(--color-border-subtle)}#playerModal .channel-card__avatar img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}#playerModal .channel-card__name{margin:0;font-size:var(--font-size-lg);font-weight:600;color:var(--color-text)}#playerModal .channel-card__npub{margin:0;font-size:var(--font-size-sm);color:var(--color-text-muted)}#playerModal .description-panel{padding:var(--space-md-plus) var(--space-lg);border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-surface);box-shadow:var(--shadow-sm)}#playerModal .description-panel p{margin:0;font-size:var(--font-size-sm);line-height:1.6;color:var(--color-text)}.player-modal__content{position:relative;z-index:var(--z-modal-content);display:flex;flex:1 1 auto;width:100%;min-height:100%;flex-direction:column;overflow:visible;padding:0;margin:0;border:none;border-radius:0;background-color:var(--color-surface);box-shadow:none}@media (prefers-reduced-motion:reduce){.player-modal__content{transition-property:none}}#playerModal .modal-nav{position:sticky;top:0;z-index:var(--z-overlay-nav);--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;background-color:var(--color-overlay-darker);background-image:linear-gradient(180deg,var(--color-overlay-panel-gradient-start) 0,var(--color-overlay-panel-gradient-end) 100%);border-bottom:var(--border-width-hairline) solid var(--color-border-translucent);backdrop-filter:blur(var(--modal-nav-backdrop-blur));-webkit-backdrop-filter:blur(var(--modal-nav-backdrop-blur))}#playerModal#playerModal .modal-nav__bar{display:block}#playerModal .modal-nav__bar{display:flex;width:100%;align-items:center;gap:.75rem;padding:.5rem}@media (min-width:640px){#playerModal .modal-nav__bar{padding-left:1rem;padding-right:1rem}}#playerModal .modal-nav__bar{padding-inline:var(
|
||||
--player-modal-inline-padding,clamp(var(--space-md-plus),4vw,var(--space-2xl))
|
||||
)}#playerModal#playerModal .modal-nav .modal-actions{display:block}#playerModal .modal-nav .modal-actions{display:flex;align-items:center;gap:.5rem;margin-left:auto}#playerModal .modal-nav .icon-button{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--radius-full);color:var(--text-muted);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-timing-function:cubic-bezier(0,0,.2,1);width:var(--icon-size-lg);height:var(--icon-size-lg);background:var(--color-surface-tint-subtle);border:var(--border-width-hairline) solid var(--color-border-translucent-medium);color:var(--color-text-muted)}#playerModal .modal-nav .icon-button:focus-visible,#playerModal .modal-nav .icon-button:hover{background:var(--color-border-translucent-bright);border-color:var(--color-border-translucent-strong);color:var(--color-text)}.modal-nav--hidden{--tw-translate-y:-100%}.modal-nav--hidden,.modal-nav--visible{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-nav--visible{--tw-translate-y:0px}.video-modal__layout{display:grid;gap:1.5rem}@media (min-width:1024px){.video-modal__layout{gap:2rem;grid-template-columns:minmax(0,var(--video-modal-primary-fr)) minmax(var(--video-modal-secondary-min),var(--video-modal-secondary-fr,1fr))}}.revert-modal__layout{display:grid;gap:1.5rem}@media (min-width:1024px){.revert-modal__layout{grid-template-columns:minmax(var(--revert-modal-sidebar-min),var(--revert-modal-sidebar-width)) minmax(0,1fr)}}#playerModal.video-modal__primary{display:block}.video-modal__primary{display:flex;flex-direction:column;gap:1.5rem}#playerModal.video-modal__secondary{display:block}.video-modal__secondary{display:flex;flex-direction:column;gap:1rem}.video-modal__video{position:relative;background-color:var(--color-black);padding-block:clamp(var(--space-xs),2.5vw,var(--space-md-plus))}@media (min-width:1024px){.video-modal__video{position:sticky;top:calc(var(--space-2xl) + var(--space-xl))}}#playerModal .video-modal__video video{border-radius:var(--radius-lg);box-shadow:var(--shadow-video-modal);max-height:min(80vh,56.25vw);-o-object-fit:contain;object-fit:contain}.video-modal__media{display:block;aspect-ratio:16/9;width:100%;-o-object-fit:contain;object-fit:contain}#playerModal.video-modal__details{display:block}.video-modal__details{display:flex;flex-direction:column;gap:1.5rem}.video-modal__meta{border-bottom-width:1px;border-color:var(--border-default);--tw-border-opacity:0.2;padding-bottom:1rem}#playerModal.video-modal__actions-row{display:block}.video-modal__actions-row{display:flex;flex-wrap:wrap;gap:.75rem}.video-modal__action-button,.video-modal__actions-row{align-items:center}.video-modal__action-button{display:inline-flex;height:2.75rem;justify-content:center;gap:.5rem;border-radius:var(--radius-full);font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);min-width:var(--video-modal-action-min-width);padding-inline:var(--space-md);border-width:1px;border-style:solid}.bv-modal.video-modal__action-label,.profile-pane.video-modal__action-label,.video-modal__action-label{display:none}@media (min-width:640px){.video-modal__action-label{display:inline}}.video-modal__action-icon{height:1.25rem;width:1.25rem}.video-modal__channel,.video-modal__description,.video-modal__stats{border-radius:1rem;border-width:1px;border-color:var(--border-default);--tw-border-opacity:0.2;background-color:var(--surface-base);padding:1.25rem 1.5rem;--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.video-modal__description-body{overflow-wrap:anywhere;word-break:break-word}.video-modal__description-body a{color:var(--color-accent-strong);text-decoration:underline;text-decoration-color:currentColor;word-break:break-word}.video-modal__description-body a:focus-visible,.video-modal__description-body a:hover{color:var(--color-accent-pressed)}.video-modal__progress{height:.5rem;width:100%;overflow:hidden;border-radius:var(--radius-full);background-color:var(--border-subtle)}#playerModal.video-modal__stat-metrics{display:block}.video-modal__stat-metrics{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:.75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted)}.profile-modal{display:flexLine truncated
|
||||
);--surface-overlay-panel:var(--color-overlay-panel);--surface-overlay-panel-soft:var(--color-overlay-panel-soft);--surface-overlay-panel-tint:var(--color-overlay-panel-tint);--surface-overlay-panel-glass:var(--color-overlay-panel-glass);--surface-overlay-panel-glass-soft:var(--color-overlay-panel-glass-soft);--surface-modal-backdrop:var(--color-modal-backdrop);--surface-modal:var(--color-modal-surface);--blur-modal-backdrop:18px;--blur-modal-alert:14px;--blur-thumbnail-blur:18px;--modal-nav-backdrop-blur:6px;--app-header-centerline-offset:calc(var(--space-sm) + var(--space-2xl-plus)/2);--border-default:var(--color-border);--border-subtle:var(--color-border-subtle);--border-strong:var(--color-border-translucent-stronger);--border-overlay:var(--color-border-translucent);--border-overlay-strong:var(--color-border-translucent-strongest);--border-overlay-glow:var(--color-border-translucent-glow);--border-neutral-soft:var(--color-border-neutral-soft);--border-info-soft:var(--color-border-info-soft);--border-sidebar-strong:var(--color-border-sidebar-strong);--border-width-hairline:1px;--border-width-fine:0.5px;--text-primary:var(--color-text);--text-inverse:var(--color-text-inverse);--text-muted:var(--color-text-muted);--text-muted-strong:var(--color-muted-strong);--text-subtle:var(--color-subtle-text);--text-frosted-primary:var(--color-text-frosted);--text-frosted-strong:var(--color-text-frosted-strong);--text-frosted-soft:var(--color-text-frosted-soft);--status-info:var(--color-info);--status-info-strong:var(--color-info-strong);--status-info-surface:var(--color-info-glass);--status-info-border:var(--color-info-border);--status-info-on:var(--color-info-on);--status-success:var(--color-success);--status-success-strong:var(--color-success-strong);--status-success-surface:var(--color-success-surface);--status-success-border:var(--color-success-border);--status-success-on:var(--color-success-on);--status-warning:var(--color-warning);--status-warning-strong:var(--color-warning-strong);--status-warning-surface:var(--color-warning-surface);--status-warning-border:var(--color-warning-border);--status-warning-on:var(--color-warning-on);--status-danger:var(--color-critical);--status-danger-strong:var(--color-critical-strong);--status-danger-surface:var(--color-danger-surface);--status-danger-border:var(--color-danger-border);--status-danger-on:var(--color-danger-on);--status-private:var(--color-private);--status-private-strong:var(--color-private-strong);--status-private-surface:var(--color-private-surface);--status-private-border:var(--color-private-border);--status-private-on:var(--color-private-on);--status-neutral:var(--color-muted);--status-neutral-strong:var(--color-muted-strong);--status-neutral-surface:var(--color-neutral-surface);--status-neutral-border:var(--color-neutral-border);--status-neutral-on:var(--color-neutral-on);--scrollbar-width:0.5rem;--scrollbar-track:var(--color-white-soft);--scrollbar-thumb:var(--color-white-softer);--scrollbar-thumb-hover:var(--color-white-softest);--z-app-header:40;--z-overlay-nav:60;--z-overlay-floating:70;--z-overlay-popover:140;--z-overlay-toast:180;--z-sidebar-overlay:10;--z-sidebar:20;--z-modal-root:100;--z-modal-overlay:110;--z-modal-content:120;--z-modal-player:130;--z-modal-top-root:140;--z-modal-top-overlay:150;--z-modal-top-content:160;--overlay-backdrop-color:var(--color-overlay-backdrop);--overlay-backdrop-blur:12px;--sidebar-width-expanded-base:16rem;--sidebar-dropup-content-width:0px;--sidebar-width-expanded:max(var(--sidebar-width-expanded-base),var(--sidebar-dropup-content-width));--sidebar-width-collapsed:4rem;--sidebar-width:var(--sidebar-width-expanded);--sidebar-control-size:2.75rem;--sidebar-content-gap:1rem;--space-sidebar-shell-inline:var(--space-lg);--space-sidebar-shell-block-start:var(--space-sm);--space-sidebar-shell-block-end:1.75rem;--space-sidebar-scroll-gap:1.75rem;--space-sidebar-panel-gap:1.35rem;--space-sidebar-panel-padding-block:1.35rem;--space-sidebar-panel-padding-inline:1.1rem;--space-sidebar-footer-gap:0.85rem;--space-sidebar-trigger-padding-block:0.78rem;--space-sidebar-popover-offset:0.85rem;--space-sidebar-popover-padding-block:0.85rem;--space-sidebar-popover-padding-inline:0.65rem;--space-sidebar-popover-gap:0.45rem;--space-sidebar-dropup-scroll-reserve:calc(var(--scrollbar-thin-width) + var(--space-sm) + var(--space-2xs));--sidebar-dropup-max-height:18rem;--space-sidebar-nav-padding-block:0.7rem;--space-sidebar-nav-padding-inline-start:var(--space-xs);--space-sidebar-nav-padding-inline-end:calc(var(--space-lg) + var(--space-2xs));--space-sidebar-subnav-padding-block:0.55rem;--space-sidebar-nav-translate:0.35rem;--grid-card-min-width:20rem;--popover-backdrop-blur:12px;--overlay-panel-radius:var(--radius-lg);--overlay-panel-shadow:var(--shadow-lg);--overlay-panel-padding-inline:var(--popup-padding-inline);--overlay-panel-padding-block:var(--popup-padding-block);--toast-translate:-1.5rem;--sidebar-mobile-gutter:3rem;--popup-padding-Line truncated
|
||||
/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-family-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--font-family-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{font-size:80%}@media (min-width:768px){html{font-size:100%}}@media (min-width:1024px){html{font-size:75%}}*,:after,:before{scrollbar-width:thin;scrollbar-color:var(--scrollbar-thumb) var(--scrollbar-track)}::-webkit-scrollbar{width:var(--scrollbar-width);height:var(--scrollbar-width)}::-webkit-scrollbar-track{background:var(--scrollbar-track)}::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);border-radius:var(--radius-sm)}::-webkit-scrollbar-thumb:hover{background:var(--scrollbar-thumb-hover)}body{--app-header-centerline:calc(env(safe-area-inset-top, 0) + var(--app-header-centerline-offset));font-family:var(--font-family-sans);background-color:var(--color-page);color:var(--color-text-strong);line-height:1.5;margin:0;padding:0;overflow-x:hidden}#uiOverlay{position:fixed;inset:0;pointer-events:none;z-index:var(--z-overlay-popover)}#uiOverlay>*{pointer-events:auto}#editNip71TagsFieldset>legend,#nip71TagsFieldset>legend{margin-left:0;padding:0}#app{background-color:var(--color-page);color:inherit}body.modal-open,html.modal-open{overflow:hidden;height:100%}body.modal-open #app{overflow:hidden}body.modal-open #sidebar{pointer-events:none;opacity:0;visibility:hidden;z-index:var(--z-sidebar-overlay)}.ng-cloak,.ng-hide:not(.ng-hide-animate),.x-ng-cloak,[data-ng-cloak],[ng-cloak],[ng\:cloak],[x-ng-cloak]{display:none!important}ng\:form{display:block}.\!container{width:100%!important}.container{width:100%}@media (min-width:25rem){.\!container{max-width:25rem!important}.container{max-width:25rem}}@media (min-width:30rem){.\!container{max-width:30rem!important}.container{max-width:30rem}}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}#playerModal.ds-header{display:block}.ds-header{display:flex;align-items:center;justify-content:flex-start;padding-top:var(--space-sm);padding-bottom:var(--space-sm)}.app-shell__header{position:sticky;top:env(safe-area-inset-top,0);z-index:var(--z-app-header);padding-top:calc(var(--space-sm) + env(safe-area-inset-top, 0));padding-bottom:var(--space-sm);background-color:transparent;background-image:var(--surface-overlay-banner-gradient-header);background-repeat:no-repeat;background-size:100% 100%}.ds-metric-probe{position:absolute;tLine truncated
|
||||
--player-modal-inline-padding,clamp(var(--space-md),4vw,var(--space-xl-plus))
|
||||
);overflow:visible}#playerModal .watch-container.video-modal__similar{padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .video-modal__primary>.watch-container:first-child{padding-block-start:var(--player-modal-vertical-padding)}#playerModal .video-modal__secondary>.watch-container:last-child{padding-block-end:var(--player-modal-vertical-padding)}#playerModal .modal-nav .watch-container{max-width:none;margin-inline:0}#playerModal .modal-info-panel{display:flex;flex-direction:column;gap:var(--space-md-plus)}#playerModal h2#videoTitle{margin:0;font-size:clamp(var(--font-size-xl),calc(var(--font-size-modal-title-base) + .5vw),var(--font-size-xl-plus));font-weight:600;line-height:1.3;color:var(--color-text)}#playerModal .modal-meta{font-size:var(--font-size-sm);line-height:1.4;color:var(--color-text-muted)}#playerModal .modal-view-count{font-weight:600;color:var(--color-text)}#playerModal .channel-card{align-items:center;gap:var(--space-md);padding:var(--space-md) var(--space-lg)}#playerModal .channel-card,#playerModal .player-modal__module{display:flex;border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-surface);box-shadow:var(--shadow-sm)}#playerModal .player-modal__module{flex-direction:column;gap:clamp(var(--space-2xs),1vw,var(--space-sm));width:100%;padding:clamp(var(--space-xs),1vw,var(--space-sm))}#playerModal .player-modal__module-title{margin:0;font-size:var(--font-size-lg);font-weight:600;line-height:1.3;color:var(--color-text)}#playerModal .player-modal__module-list{display:flex;flex-direction:column;gap:var(--space-sm);width:100%;margin:0;padding:0;list-style:none}#playerModal .video-modal__similar .player-modal__module{gap:clamp(var(--space-3xs),.6vw,var(--space-xs));padding:clamp(var(--space-2xs),.6vw,var(--space-xs));border-width:0;box-shadow:none;background-color:transparent}#playerModal .video-modal__similar .player-modal__module-title{padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .video-modal__similar .player-modal__module-list{gap:clamp(var(--space-3xs),.6vw,var(--space-xs));padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs));padding-bottom:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .player-modal__module-scroll{max-height:clamp(14rem,calc(100vh - var(--player-modal-vertical-padding)*2 - var(--space-2xl)),28rem);overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;scrollbar-gutter:stable;padding-inline-end:var(--space-xs)}#playerModal .video-modal__similar .player-modal__module-scroll{max-height:none;overflow:visible;padding-inline-end:0;scrollbar-gutter:auto}#playerModal .player-modal__module-scroll>li{scroll-margin-block:var(--space-xs)}@media (max-width:30rem){#playerModal .modal-nav .icon-button{width:var(--space-xl);height:var(--space-xl);min-width:var(--space-xl);min-height:var(--space-xl)}#playerModal .watch-container{padding-inline:var(--space-sm)}#playerModal .channel-card{gap:var(--space-sm);padding:var(--space-sm) var(--space-md)}}#playerModal .channel-card__avatar{flex-shrink:0;width:var(--avatar-size-lg);height:var(--avatar-size-lg);border-radius:var(--radius-full);overflow:hidden;background-color:var(--color-border-subtle)}#playerModal .channel-card__avatar img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}#playerModal .channel-card__name{margin:0;font-size:var(--font-size-lg);font-weight:600;color:var(--color-text)}#playerModal .channel-card__npub{margin:0;font-size:var(--font-size-sm);color:var(--color-text-muted)}#playerModal .description-panel{padding:var(--space-md-plus) var(--space-lg);border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-surface);box-shadow:var(--shadow-sm)}#playerModal .description-panel p{margin:0;font-size:var(--font-size-sm);line-height:1.6;color:var(--color-text)}.player-modal__content{position:relative;z-index:var(--z-modal-content);display:flex;flex:1 1 auto;width:100%;min-height:100%;flex-direction:column;overflow:visible;padding:0;margin:0;border:none;border-radius:0;background-color:var(--color-surface);box-shadow:none}@media (prefers-reduced-motion:reduce){.player-modal__content{transition-property:none}}#playerModal .modal-nav{position:sticky;top:0;z-index:var(--z-overlay-nav);--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;background-color:var(--surface-overlay-panel);background-image:linear-gradient(180deg,color-mix(in srgb,var(--surface-overlay-panel) 94%,transparent) 0,color-mix(in srgb,var(--surface-overlay-panel) 88%,transparent) 100%);border-bottom:var(--border-width-hairline) solid var(--border-oveLine truncated
|
||||
--player-modal-primary-inline-padding-lg,clamp(var(--space-sm),2.75vw,var(--space-lg))
|
||||
)}}.revert-modal__layout{display:grid;gap:1.5rem}@media (min-width:1024px){.revert-modal__layout{grid-template-columns:minmax(var(--revert-modal-sidebar-min),var(--revert-modal-sidebar-width)) minmax(0,1fr)}}#playerModal.video-modal__primary{display:block}.video-modal__primary{display:flex;flex-direction:column;gap:1.5rem;min-width:0;min-height:0}#playerModal.video-modal__secondary{display:block}.video-modal__secondary{display:flex;flex-direction:column;gap:1rem;min-width:0;min-height:0;width:100%;align-self:stretch}.video-modal__similar{display:none}@media (min-width:1024px){#playerModal.video-modal__similar{display:block}.video-modal__similar{display:flex;flex-direction:column;gap:1.5rem;align-self:stretch;flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module{height:100%;display:flex;flex:1 1 auto;min-height:0;flex-direction:column}#playerModal .video-modal__similar .player-modal__module-list{flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module-scroll{height:100%;max-height:none;min-height:0}}#playerModal .player-modal__module-item{width:100%}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{width:clamp(5.75rem,18vw,8rem)}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media{width:clamp(5.75rem,18vw,8rem)!important}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media,#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))!important}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal .video-modal__similar .player-modal__module-list{gap:clamp(var(--space-2xs),.85vw,var(--space-sm))}.player-modal__similar-card{--player-modal-similar-thumb:clamp(11rem,calc(var(--video-modal-secondary-min, 320px)*0.62),17rem);--player-modal-similar-gap:clamp(var(--space-4xs),0.65vw,var(--space-2xs));--player-modal-similar-padding:clamp(var(--space-4xs),0.7vw,var(--space-sm));position:relative;display:grid;grid-template-columns:var(--player-modal-similar-thumb) minmax(0,1fr);align-items:stretch;gap:var(--player-modal-similar-gap);padding:var(--player-modal-similar-padding);width:100%;min-width:0;transform:translate3d(0,var(--video-card-lift-translate-y,0),0)}.player-modal__similar-card:before{content:"";position:absolute;inset:0;z-index:0;opacity:var(--video-card-backdrop-opacity,0);background-image:var(
|
||||
--similar-card-thumb-url,var(--video-card-thumb-url,none)
|
||||
);background-position:50%;background-repeat:no-repeat;background-size:cover;transform:translate3d(0,var(--video-card-backdrop-translate-y,0),0) scale(var(--video-card-backdrop-scale,1));filter:blur(var(--video-card-backdrop-blur,0));transition:opacity var(--motion-duration-base) var(--motion-ease-in-out),transform var(--motion-duration-base) var(--motion-ease-in-out),filter var(--motion-duration-base) var(--motion-ease-in-out);pointer-events:none;will-change:opacity,transform,filter}.player-modal__similar-card>*{position:relative;z-index:1}.player-modal__similar-card:focus-within,.player-modal__similar-card:hover{--video-card-backdrop-opacity:0.425;--video-card-backdrop-translate-y:0.15rem;--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:24px}.player-modal__similar-card-media{display:block;position:relative;inline-size:var(--player-modal-similar-thumb);block-size:100%;max-inline-size:clamp(calc(var(--space-2xl-plus)*1.1),28vw,calc(var(--space-2xl-plus)*2.1));border-radius:var(--radius-lg);overflow:hidden;isolation:isolate}.player-modal__similar-card-media img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover;border-radius:inherit}.player-modal__similar-card-content{display:flex;flex-direction:column;gap:clamp(var(--space-4xs),.6vw,var(--space-2xs));min-width:0}.player-modal__similar-card-title{display:-webkit-box;margin:0;font-size:clamp(var(--font-size-sm),1.05vw,var(--font-size-base));font-weight:600;line-height:1.35;color:var(--color-text);text-decoration:none;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;transition:color var(--motion-duration-base) var(--motion-ease-in-out)}.player-modal__similar-card-title:focus-visible,.player-modal__similar-card-title:hover{color:var(--color-accent)}.player-modal__similar-card-author{display:flex;align-items:center;-moz-column-gap:clamp(var(--space-5xs),.6vw,var(--space-3xs));column-gap:clamp(var(--space-5xs),.6vw,var(--space-3xs));font-size:var(--font-size-xs);color:var(--color-text-muted);min-width:0}.player-modal__similar-card-avatar{flex:0 0 auto;display:block;inline-size:clamp(2.5rem,.8vw + 2.1rem,3rem);block-size:clamp(2.5rem,.8vw + 2.1rem,3rem);border-radius:var(--radius-full);overflow:hidden;background-color:var(--color-surface-alt);box-shadow:0 0 0 1px color-mix(in srgb,var(--color-border-strong) 65%,transparent)}.player-modal__similar-card-avatar-img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}.player-modal__similar-card-author-meta{display:flex;flex-direction:column;align-items:flex-start;gap:clamp(var(--space-6xs),.35vw,var(--space-5xs));min-width:0}.player-modal__similar-card-author-name{font-weight:500;color:var(--color-text);min-width:0}.player-modal__similar-card-author-npub{display:inline-flex;padding-inline:var(--space-6xs);border-radius:var(--radius-full);font-family:var(--font-family-mono);line-height:1.2;background-color:color-mix(in srgb,var(--color-surface-alt) 80%,transparent)}.player-modal__similar-card-author-npub,.player-modal__similar-card-meta{align-items:center;font-size:var(--font-size-2xs,.75rem);color:var(--color-muted-strong)}.player-modal__similar-card-meta{display:flex;flex-wrap:wrap;gap:clamp(var(--space-5xs),.5vw,var(--space-4xs));min-width:0}.player-modal__similar-card-timestamp{white-space:nowrap}.player-modal__similar-card-separator{color:inherit;opacity:.8}.player-modal__similar-card-views{white-space:nowrap}.video-modal__stage{position:relative;isolation:isolate;overflow:visible;--video-modal-ambient-bleed:clamp(var(--space-2xl),12vw,var(--space-5xl));--video-modal-ambient-scale:1.38;--video-modal-ambient-blur:clamp(128px,20vw,256px);--video-modal-ambient-opacity:1}.video-modal__video{position:relative;z-index:1;border-radius:var(--radius-xl);padding-block:clamp(var(--space-xs),2.5vw,var(--space-md-plus));background-color:transparent;box-shadow:0 0 0 1px color-mix(in srgb,var(--color-border-strong) 8%,transparent) inset;overflow:visible}.video-modal__ambient{position:absolute;inset:calc(var(--video-modal-ambient-bleed)*-1);border-radius:calc(var(--radius-3xl) + var(--video-modal-ambient-bleed));pointer-events:none;z-index:0;display:block;overflow:visible}.video-modal__ambient:before{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--color-overlay-strong) 18%,transparent) 0,color-mix(in srgb,var(--color-overlay) 34%,transparent) 62%,transparent 100%);opacity:.9;mix-blend-mode:screen;pointer-events:none}.video-modal__ambient-canvas{position:relative;width:100%;height:100%;display:block;transform:scale(var(--video-modal-ambient-scale));filter:blur(var(--video-modal-ambient-blur)) saturate(2) brightness(1.24);opacity:var(--video-modal-ambient-opacity)}@media (min-width:1024px){.video-modal__video{position:sticky;top:calc(var(--space-2xl) + var(--space-xl))}}#playerModal .video-modal__video video{position:relative;z-index:1;background-color:var(--color-black);border-Line truncated
|
||||
+1499
-302
File diff suppressed because it is too large.
Load diff
+63
-23
@@ -81,7 +81,9 @@
|
||||
--blur-thumbnail-blur: 18px;
|
||||
--modal-nav-backdrop-blur: 6px;
|
||||
|
||||
--app-header-centerline-offset: calc(var(--space-sm) + (var(--space-2xl-plus) / 2));
|
||||
--app-header-centerline-offset: calc(
|
||||
var(--space-sm) + (var(--space-2xl-plus) / 2)
|
||||
);
|
||||
--border-default: var(--color-border);
|
||||
--border-subtle: var(--color-border-subtle);
|
||||
--border-strong: var(--color-border-translucent-stronger);
|
||||
@@ -205,8 +207,13 @@
|
||||
--popup-padding-inline: 1rem;
|
||||
--popup-padding-block: 0.75rem;
|
||||
--video-modal-primary-fr: 1.85fr;
|
||||
--video-modal-secondary-min: 260px;
|
||||
--video-modal-secondary-min: 320px;
|
||||
--video-modal-secondary-max: 44rem;
|
||||
--video-modal-secondary-fr: 1fr;
|
||||
--video-modal-layout-max: calc(
|
||||
var(--layout-player-max-width) + var(--video-modal-secondary-max) +
|
||||
var(--space-2xl-plus)
|
||||
);
|
||||
--revert-modal-sidebar-min: 240px;
|
||||
--revert-modal-sidebar-width: 280px;
|
||||
--popover-inline-safe-max: calc(100vw - var(--space-xl));
|
||||
@@ -301,7 +308,8 @@
|
||||
--shadow-sidebar-trigger: 0 1.25rem 2.5rem -1.35rem var(--color-shadow-panel);
|
||||
--shadow-sidebar-dropup: 0 1.75rem 3.25rem -1.45rem var(--color-sidebar-glow);
|
||||
--shadow-sidebar-panel: 0 2.25rem 4.5rem -1.85rem var(--color-shadow-sidebar);
|
||||
--shadow-sidebar-focus-ring: 0 0 0 0.125rem var(--color-sidebar-glow),
|
||||
--shadow-sidebar-focus-ring:
|
||||
0 0 0 0.125rem var(--color-sidebar-glow),
|
||||
0 0 0 0.25rem var(--color-sidebar-hover-strong);
|
||||
--shadow-popover-zap: 0 22px 48px -18px var(--color-shadow-intense);
|
||||
--shadow-video-modal: 0 1rem 2.25rem var(--color-shadow-elevated);
|
||||
@@ -473,11 +481,7 @@
|
||||
var(--color-sidebar-border-strong) 40%,
|
||||
transparent
|
||||
);
|
||||
--scrollbar-thumb: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 64%,
|
||||
transparent
|
||||
);
|
||||
--scrollbar-thumb: color-mix(in srgb, var(--color-accent) 64%, transparent);
|
||||
--scrollbar-thumb-hover: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent-strong) 76%,
|
||||
@@ -488,11 +492,31 @@
|
||||
--color-sidebar-surface-alt: rgb(236 240 247 / 0.72);
|
||||
--color-sidebar-border: rgb(184 194 212 / 0.28);
|
||||
--color-sidebar-border-strong: rgb(148 163 184 / 0.36);
|
||||
--color-sidebar-hover: color-mix(in srgb, var(--color-accent) 16%, transparent);
|
||||
--color-sidebar-hover-strong: color-mix(in srgb, var(--color-accent-strong) 24%, transparent);
|
||||
--color-sidebar-glow: color-mix(in srgb, var(--color-accent) 36%, transparent);
|
||||
--color-sidebar-accent-start: color-mix(in srgb, var(--color-accent-strong) 88%, transparent);
|
||||
--color-sidebar-accent-end: color-mix(in srgb, var(--color-accent) 82%, transparent);
|
||||
--color-sidebar-hover: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 16%,
|
||||
transparent
|
||||
);
|
||||
--color-sidebar-hover-strong: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent-strong) 24%,
|
||||
transparent
|
||||
);
|
||||
--color-sidebar-glow: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 36%,
|
||||
transparent
|
||||
);
|
||||
--color-sidebar-accent-start: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent-strong) 88%,
|
||||
transparent
|
||||
);
|
||||
--color-sidebar-accent-end: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 82%,
|
||||
transparent
|
||||
);
|
||||
--color-sidebar-text-muted: var(--color-text-muted);
|
||||
--sidebar-icon-filter: brightness(0) saturate(100%);
|
||||
|
||||
@@ -641,11 +665,7 @@
|
||||
var(--color-sidebar-surface-alt) 54%,
|
||||
transparent
|
||||
);
|
||||
--scrollbar-thumb: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 72%,
|
||||
transparent
|
||||
);
|
||||
--scrollbar-thumb: color-mix(in srgb, var(--color-accent) 72%, transparent);
|
||||
--scrollbar-thumb-hover: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent-strong) 84%,
|
||||
@@ -656,11 +676,31 @@
|
||||
--color-sidebar-surface-alt: rgb(10 18 32 / 0.8);
|
||||
--color-sidebar-border: rgb(86 106 142 / 0.26);
|
||||
--color-sidebar-border-strong: rgb(112 133 170 / 0.42);
|
||||
--color-sidebar-hover: color-mix(in srgb, var(--color-accent) 22%, transparent);
|
||||
--color-sidebar-hover-strong: color-mix(in srgb, var(--color-accent-strong) 32%, transparent);
|
||||
--color-sidebar-glow: color-mix(in srgb, var(--color-accent) 42%, transparent);
|
||||
--color-sidebar-accent-start: color-mix(in srgb, var(--color-accent) 92%, transparent);
|
||||
--color-sidebar-accent-end: color-mix(in srgb, var(--color-accent-strong) 88%, transparent);
|
||||
--color-sidebar-hover: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 22%,
|
||||
transparent
|
||||
);
|
||||
--color-sidebar-hover-strong: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent-strong) 32%,
|
||||
transparent
|
||||
);
|
||||
--color-sidebar-glow: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 42%,
|
||||
transparent
|
||||
);
|
||||
--color-sidebar-accent-start: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 92%,
|
||||
transparent
|
||||
);
|
||||
--color-sidebar-accent-end: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent-strong) 88%,
|
||||
transparent
|
||||
);
|
||||
--color-sidebar-text-muted: var(--color-text-muted);
|
||||
--sidebar-icon-filter: brightness(0) invert(1);
|
||||
|
||||
|
||||
+33
-11
@@ -17,33 +17,49 @@ bitvid is follow-centric. Your Home feed comes from people you follow (F1). Disc
|
||||
- **Lists**: NIP-51 (mute list 10000, categorized people 30000, bookmarks 30001).
|
||||
- **Replies/threads**: NIP-10 (comments).
|
||||
- **Counts**: NIP-45 (relay COUNT; optional fallbacks).
|
||||
- **View metrics**: [`js/nostr/viewEventBindings.js`](../../js/nostr/viewEventBindings.js) wraps the shared client so moderation
|
||||
toggles hit the same record/list/subscribe/count helpers that enforce our NIP-71/NIP-78 guard rails when relays disable optional
|
||||
methods.
|
||||
|
||||
## Runtime flow (blur, hide, override)
|
||||
- [`ModerationService`](../../js/services/moderationService.js) orchestrates ingest and scoring. Review the service for the trusted-report math and helper entry points.
|
||||
- [`createModerationStage`](../../js/feedEngine/stages.js) wires the service into the feed engine where moderation summaries decorate timeline items.
|
||||
- The moderation stage now resolves blur/autoplay/hide thresholds on every execution using `context.runtime.moderationThresholds` (when provided) or dynamic resolver functions, so feed refreshes immediately respect updated viewer settings.
|
||||
- [`bitvidApp.decorateVideoModeration`](../../js/app.js) connects stage output to the UI layer alongside feature-flag plumbing.
|
||||
- [`VideoCard.refreshModerationUi`](../../js/ui/components/VideoCard.js) applies badges, blur states, hide metadata (`data-moderation-hidden`), and the "show anyway" toggles.
|
||||
|
||||
### Moderation badge pipeline
|
||||
|
||||
- Every moderation payload now includes a `blurReason` that records why a thumbnail was blurred (`trusted-report`, `trusted-mute`, or a hide reason such as `trusted-mute-hide`). This travels with both the metadata stage output and the decorated `video.moderation.original` snapshot so UI can explain the decision even after a viewer override.
|
||||
- When a trusted mute forces a blur (without a report threshold firing) the badge copy shortens to **"Muted by a trusted contact"** so we don't repeat the blur state in the label. Reports still surface as "Blurred · {reason}" until the viewer taps "Show anyway".
|
||||
- The moderation badge now renders as a dedicated yellow chip with an icon and "Show anyway" pill that matches the rest of the design system tokens. Override states flip to the neutral palette, expose a "Restore default moderation" pill, and swap the icon for a confirmation check so operators can tell the difference at a glance.
|
||||
- The badge tooltip and `aria-label` continue to list the specific trusted contacts who muted or reported the content when that metadata is available.
|
||||
- Viewers who override a trusted mute hide keep a **Block** pill on the same badge so they can block the creator without hunting through another menu.
|
||||
- Selecting **Block** immediately publishes an account block for the creator, clears any temporary overrides, and reloads the feed so the author disappears from subsequent fetches.
|
||||
- Trusted mute decisions now block autoplay even when report thresholds have not fired, and any associated avatars or channel banners adopt the blurred state alongside the primary thumbnail so the entire card reflects the moderation choice.
|
||||
|
||||
## Where to extend
|
||||
Thread new moderation behaviors through the same service → stage → app → UI flow above. Extending the existing layers keeps overrides, feature flags, and QA hooks consistent—avoid spinning up parallel moderation modules unless the architecture document explicitly calls for it.
|
||||
|
||||
## Defaults (policy)
|
||||
- Blur video thumbnails if **≥ 3** F1 friends report `nudity`.
|
||||
- Disable autoplay preview if **≥ 2** F1 friends report `nudity`.
|
||||
- Hide videos when **≥ 1** trusted contact mutes the author (`TRUSTED_MUTE_HIDE_THRESHOLD`). Cards render with `data-moderation-hidden="true"`, the badge reads `Hidden · 1 trusted mute`, and a "Show anyway" button becomes available.
|
||||
- Hide videos when **≥ 3** trusted contacts file spam reports (`TRUSTED_SPAM_HIDE_THRESHOLD`). The badge copy escalates to `Hidden · 3 trusted spam reports` and the card stays hidden until the viewer overrides it.
|
||||
- Blur video thumbnails when trusted `nudity` reports meet `DEFAULT_BLUR_THRESHOLD`.
|
||||
- Disable autoplay preview when the trusted `nudity` report count reaches `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`.
|
||||
- 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.
|
||||
- Trust seeds from `DEFAULT_TRUST_SEED_NPUBS` count as baseline F1 contacts (toggle via `FEATURE_TRUST_SEEDS`).
|
||||
- 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.
|
||||
- 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`).
|
||||
|
||||
> You can override all defaults in **Settings → Safety & Moderation**.
|
||||
> 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 3 trusted reports for blur, 2 for autoplay blocking, 1 trusted mute to hide authors, and 3 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.
|
||||
|
||||
### Hide thresholds & feature flags
|
||||
|
||||
- `TRUSTED_MUTE_HIDE_THRESHOLD` and `TRUSTED_SPAM_HIDE_THRESHOLD` determine when moderation metadata marks an item as hidden. Both feed the `original.hide*` fields that `VideoCard` inspects before deciding whether to render the hidden summary container.
|
||||
- `FEATURE_TRUSTED_HIDE_CONTROLS` shows the additional threshold inputs in the profile modal so operators can raise/lower the hide triggers client-side.
|
||||
- When any hide threshold fires we persist the active counts (`hideCounts.trustedMuteCount`, `hideCounts.trustedReportCount`) so analytics/debugging tools can show why an item disappeared.
|
||||
- Cards that arrive with `original.hidden === true` automatically expose the "Show anyway" chip so viewers can inspect hidden content. Activating the override clears blur/autoplay restrictions alongside the hidden state.
|
||||
- Cards that arrive with `original.hidden === true` automatically expose the "Show anyway" chip so viewers can inspect hidden content. Activating the override clears blur/autoplay restrictions alongside the hidden state and replaces the CTA with "Restore default moderation" for easy rollback.
|
||||
|
||||
## Community blacklist federation
|
||||
|
||||
@@ -59,12 +75,18 @@ New operators should also note that fresh viewer accounts automatically inherit
|
||||
|
||||
### Safety & Moderation controls
|
||||
|
||||
The profile modal now exposes blur, autoplay, and hide thresholds so operators can dial in stricter or more permissive behavior. Enter a non-negative whole number to override the default or leave the field blank to fall back to the baseline values above. Adjustments are stored locally, applied immediately to the active feed, and rehydrate on every load.
|
||||
The profile modal now exposes blur, autoplay, and hide thresholds so operators can dial in stricter or more permissive behavior. Enter a non-negative whole number to override the default or leave the field blank to fall back to the baseline values 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`). Adjustments are stored locally, applied immediately to the active feed, and rehydrate on every load. Update the config file to change the per-instance defaults that new viewers see.
|
||||
|
||||
### Personal hashtag preferences
|
||||
|
||||
- The Profile modal’s Hashtags tab is wired to [`HashtagPreferencesService`](../../js/services/hashtagPreferencesService.js), which maintains a local snapshot of the viewer’s interests and disinterests, normalizes every tag, and guarantees that the two sets stay mutually exclusive before broadcasting UI change events.【F:js/services/hashtagPreferencesService.js†L206-L276】【F:js/services/hashtagPreferencesService.js†L304-L324】
|
||||
- `bitvidApp` resolves the active pubkey’s preferences at login, listens for `CHANGE` events, and forwards the normalized snapshot to controllers so moderation and discovery logic can react without querying relays again.【F:js/app.js†L3811-L3906】【F:js/app.js†L3940-L4050】
|
||||
- Publishing writes a replaceable `kind 30005` list with `d=bitvid:tag-preferences`, encrypting the `{ version, interests, disinterests }` payload via the best available NIP-44/NIP-04 scheme before hitting every configured write relay.【F:docs/nostr-event-schemas.md†L150-L168】【F:js/services/hashtagPreferencesService.js†L520-L711】
|
||||
|
||||
### Runtime flags
|
||||
|
||||
- `TRUSTED_MUTE_HIDE_THRESHOLD` — numeric default for the trusted mute hide control (default `1`).
|
||||
- `TRUSTED_SPAM_HIDE_THRESHOLD` — numeric default for the trusted spam hide control (default `3`).
|
||||
- `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 `1`, 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 `3`.
|
||||
- `FEATURE_TRUSTED_HIDE_CONTROLS` — boolean toggle to hide/show the new trusted hide controls in the UI (default `true`).
|
||||
|
||||
## Files in this folder
|
||||
|
||||
@@ -50,10 +50,13 @@
|
||||
import moderationService from "/js/services/moderationService.js";
|
||||
|
||||
const STORAGE_KEY = "bitvid:moderation:fixture-overrides";
|
||||
// These fixtures mirror the upstream defaults in config/instance-config.js.
|
||||
// Update the numbers alongside DEFAULT_BLUR_THRESHOLD, DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
// DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, and DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD when they change.
|
||||
const fixtures = [
|
||||
{
|
||||
key: "blur-threshold",
|
||||
heading: "Blur threshold (≥3 trusted nudity reports)",
|
||||
heading: "Blur threshold (config default: ≥3 trusted nudity reports)",
|
||||
description:
|
||||
"Card should be blurred and autoplay disabled until the viewer overrides it.",
|
||||
thresholds: { blur: 3, autoplay: 2 },
|
||||
@@ -81,7 +84,7 @@
|
||||
},
|
||||
{
|
||||
key: "autoplay-threshold",
|
||||
heading: "Autoplay threshold (≥2 trusted nudity reports)",
|
||||
heading: "Autoplay threshold (config default: ≥2 trusted nudity reports)",
|
||||
description:
|
||||
"Card should block autoplay but stay unblurred so users can inspect the thumbnail.",
|
||||
video: {
|
||||
@@ -127,7 +130,7 @@
|
||||
},
|
||||
{
|
||||
key: "trusted-report-hide",
|
||||
heading: "Trusted report hide (≥3 spam reports)",
|
||||
heading: "Trusted report hide (config default: ≥3 spam reports)",
|
||||
description:
|
||||
"Card should render hidden with spam copy until the viewer clicks \"Show anyway\".",
|
||||
enableOverride: true,
|
||||
@@ -399,7 +402,11 @@
|
||||
}
|
||||
|
||||
const computedBlock = initialBlock && trustedCount >= autoplayThreshold;
|
||||
const computedBlur = initialBlur && trustedCount >= blurThreshold;
|
||||
let computedBlur = initialBlur && trustedCount >= blurThreshold;
|
||||
|
||||
if (!computedBlur && (trustedMuted || hidden)) {
|
||||
computedBlur = true;
|
||||
}
|
||||
|
||||
const originalState = {
|
||||
blockAutoplay: computedBlock,
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
## Quick checklist
|
||||
- [ ] F1 set cached and used for “trusted reports”.
|
||||
- Automated coverage: `tests/moderation/trusted-report-count.test.mjs` (run via `npm run test:unit`).
|
||||
- [ ] Blur at ≥3 F1 reports of `nudity`; autoplay blocked at ≥2.
|
||||
- [ ] Blur once trusted `nudity` reports meet `DEFAULT_BLUR_THRESHOLD`; autoplay blocks when the count reaches `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` (check [`config/instance-config.js`](../../config/instance-config.js) for your instance’s baseline exports).
|
||||
- Automated coverage: `tests/visual/moderation.spec.ts` blur and autoplay scenarios (run via `npm run test:visual`).
|
||||
- [ ] Hide videos once trusted mute/spam thresholds are met.
|
||||
- Automated coverage: `tests/visual/moderation.spec.ts` trusted hide scenarios (run via `npm run test:visual`).
|
||||
- Manual: adjust thresholds in the profile modal, simulate trusted mutes/reports, and verify cards disappear until "show anyway" is used.
|
||||
- [ ] “Show anyway” works for blurred and hidden cards and records only local pref.
|
||||
- [ ] “Show anyway” works for blurred and hidden cards, surfaces a “Restore default moderation” control, and records only local pref.
|
||||
- Automated coverage: `tests/visual/moderation.spec.ts` show-anyway persistence and trusted hide scenarios (run via `npm run test:visual`).
|
||||
- [ ] Mute list (10000) downranks/hides consistently.
|
||||
- Manual: subscribe to fixture B and verify muted author disappears from feeds after refresh.
|
||||
@@ -40,8 +40,8 @@ Create three distinct F1 reporter keys; each sends:
|
||||
Expected:
|
||||
|
||||
* After 1 report → no blur; autoplay allowed.
|
||||
* After 2 reports → autoplay blocked.
|
||||
* After 3 reports → thumbnail blurred; reason chip shown. At the spam threshold (`TRUSTED_SPAM_HIDE_THRESHOLD`) the card hides entirely and the badge copy switches to "Hidden · 3 trusted spam reports" until the viewer overrides it.
|
||||
* 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: 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)) 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).
|
||||
|
||||
@@ -56,7 +56,7 @@ Expected:
|
||||
}
|
||||
```
|
||||
|
||||
Expected: author’s items are downranked/hidden for this viewer. When the mute threshold (`TRUSTED_MUTE_HIDE_THRESHOLD`) is met, cards render with "Hidden · 1 trusted mute" and require a "Show anyway" override to display content.
|
||||
Expected: author’s items are downranked/hidden for this viewer. When the mute threshold (`TRUSTED_MUTE_HIDE_THRESHOLD`, sourced from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); upstream example: 1) is met, cards render with "Hidden · {count} trusted mute(s)" and require a "Show anyway" override to display content plus a "Restore default moderation" control to revert.
|
||||
|
||||
**Exercises:** [`ModerationService.ingestTrustedMuteEvent()`](../../js/services/moderationService.js) updates [`ModerationService.isAuthorMutedByTrusted()`](../../js/services/moderationService.js) / [`getTrustedMutersForAuthor()`](../../js/services/moderationService.js), which [`createModerationStage()`](../../js/feedEngine/stages.js) propagates to the video item before [`VideoCard.refreshModerationUi()`](../../js/ui/components/VideoCard.js) renders the mute state.
|
||||
|
||||
|
||||
@@ -11,13 +11,16 @@
|
||||
- 10000 mute list → downrank/hide author content.
|
||||
- 30000 categorized people → optional admin lists (see below).
|
||||
- (Optional) reputation score from a reputation source (e.g., PageRank/DVM) for **Discovery** only.
|
||||
- Super Admin + active moderators are always treated as trust seeds for anonymous/default visitors. `DEFAULT_TRUST_SEED_NPUBS` in [`config/instance-config.js`](../../config/instance-config.js) only activates when those live moderator lists cannot be fetched.
|
||||
|
||||
## Default thresholds (can be tuned)
|
||||
- `blurThumbnail = trustedReportCount(event,'nudity') >= 3`
|
||||
- `hideAutoplay = trustedReportCount(event,'nudity') >= 2`
|
||||
- `blurThumbnail = trustedReportCount(event,'nudity') >= DEFAULT_BLUR_THRESHOLD`
|
||||
- `hideAutoplay = trustedReportCount(event,'nudity') >= DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`
|
||||
- `downrankIfMutedByF1 = true`
|
||||
- `hideIfTrustedMuteCount(author) >= 1`
|
||||
- `hideIfTrustedSpamReports(event) >= 3`
|
||||
- `hideIfTrustedMuteCount(author) >= 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 3, autoplay block at 2, trusted mute hide at 1, trusted spam hide at 3), but treat those as guidance rather than hard-coded requirements.
|
||||
|
||||
### Why these numbers?
|
||||
- F1-only reports resist Sybil attacks.
|
||||
@@ -26,7 +29,7 @@
|
||||
## Admin lists (opt-in)
|
||||
- We recognize curated lists using `30000` events:
|
||||
- `['d','bitvid:admin:blacklist']` → hard-hide when subscribed.
|
||||
- `['d','bitvid:admin:whitelist']` → always show in Discovery.
|
||||
- `['d','bitvid:admin:whitelist']` → improves Discovery ranking when subscribed but no longer bypasses moderation gates.
|
||||
- `['d','bitvid:admin:editors']` → trusted channel editors.
|
||||
- Users can subscribe/unsubscribe any time.
|
||||
|
||||
@@ -34,7 +37,7 @@
|
||||
|
||||
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 may bypass Discovery gating, but never override a viewer block.
|
||||
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.
|
||||
|
||||
## Pseudocode
|
||||
|
||||
@@ -52,19 +55,19 @@ function trustedReportCount(eventId: string, type: Report['type'], viewerFollows
|
||||
}
|
||||
|
||||
function shouldBlurThumb(eventId: string, ctx: Ctx): boolean {
|
||||
return trustedReportCount(eventId, 'nudity', ctx.viewerFollows, ctx.reports) >= 3;
|
||||
return trustedReportCount(eventId, 'nudity', ctx.viewerFollows, ctx.reports) >= DEFAULT_BLUR_THRESHOLD;
|
||||
}
|
||||
|
||||
function shouldHideAutoplay(eventId: string, ctx: Ctx): boolean {
|
||||
return trustedReportCount(eventId, 'nudity', ctx.viewerFollows, ctx.reports) >= 2;
|
||||
return trustedReportCount(eventId, 'nudity', ctx.viewerFollows, ctx.reports) >= DEFAULT_AUTOPLAY_BLOCK_THRESHOLD;
|
||||
}
|
||||
|
||||
function shouldHideForTrustedMute(authorHex: string, ctx: Ctx): boolean {
|
||||
return ctx.trustedMuteCount(authorHex) >= 1;
|
||||
return ctx.trustedMuteCount(authorHex) >= DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD;
|
||||
}
|
||||
|
||||
function shouldHideForTrustedSpam(eventId: string, ctx: Ctx): boolean {
|
||||
return trustedReportCount(eventId, 'spam', ctx.viewerFollows, ctx.reports) >= 3;
|
||||
return trustedReportCount(eventId, 'spam', ctx.viewerFollows, ctx.reports) >= DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# NIP-46 Remote Signer Integration
|
||||
|
||||
bitvid can delegate event signing to a remote NIP-46 compatible signer. This
|
||||
flow lets operators keep long-lived keys on hardened devices while approving
|
||||
publish/edit/delete actions from a separate browser session.
|
||||
|
||||
## Connection workflow
|
||||
|
||||
### Client-initiated handshake (preferred)
|
||||
|
||||
When an operator chooses the remote signer option, bitvid now generates a fresh
|
||||
`nostrconnect://` URI and displays it alongside a QR code. The URI encodes:
|
||||
|
||||
- A short-lived client key pair dedicated to the session.
|
||||
- The default WSS relay bundle (`wss://relay.damus.io`, `wss://nos.lol`,
|
||||
`wss://relay.snort.social`, `wss://relay.primal.net`,
|
||||
`wss://relay.nostr.band`).
|
||||
- A random `secret` value that must be echoed back by the signer to prevent
|
||||
spoofed acknowledgements.
|
||||
- Optional metadata (`name`, `url`, `image`) so the signer can display the
|
||||
client branding during pairing.
|
||||
- Optional `perms` requested by the client.
|
||||
|
||||
Remote signers should scan the QR code or otherwise receive the
|
||||
`nostrconnect://` link, subscribe to the advertised relays, and respond with a
|
||||
`connect` acknowledgement signed by the remote signer key. The response must
|
||||
include the `secret` value that was present in the URI. bitvid listens for this
|
||||
acknowledgement before attempting the RPC `connect` call so operators see a
|
||||
clear “waiting for signer” status in the modal.
|
||||
|
||||
### Fallback bunker links
|
||||
|
||||
Some signers still distribute `bunker://` URIs that point to the remote signer
|
||||
pubkey. The modal keeps a “paste bunker link” toggle so operators can supply the
|
||||
URI manually when QR pairing isn’t available. These URIs can advertise the same
|
||||
`relay=`, `secret=`, `perms=`, `name`, `url`, and `image` parameters described
|
||||
above. If no relays are specified the default bundle listed earlier is used.
|
||||
|
||||
Recent remote signers also append a `remote-signer-key=` parameter that
|
||||
identifies the key the bunker uses for encrypted RPC traffic. The hostname/path
|
||||
may now refer to the *user* pubkey instead of the signer key, so clients must:
|
||||
|
||||
1. Decrypt handshake acknowledgements with the `remote-signer-key` when
|
||||
provided, falling back to the event author as a legacy escape hatch.
|
||||
2. Continue calling `get_public_key` after the `connect` RPC to learn the user
|
||||
pubkey rather than trusting the bunker URI host.
|
||||
|
||||
bitvid automatically persists both the signer key and the user pubkey returned
|
||||
by `get_public_key` so reconnect flows display accurate metadata.
|
||||
|
||||
## Relay requirements
|
||||
|
||||
The remote signer must subscribe to `kind 24133` events targeted at the client
|
||||
public key (included in the connection URI). bitvid publishes RPC requests to
|
||||
whichever relay list resolves from the URI + local fallback:
|
||||
|
||||
1. Explicit `relay=` parameters from the bunker URI (WSS only, duplicates
|
||||
removed and sanitized).
|
||||
2. The relays persisted in a previous session, if any.
|
||||
3. The default bitvid relay set listed above.
|
||||
|
||||
For reliable operation:
|
||||
|
||||
- Keep at least two relays online so publish attempts succeed within the
|
||||
8 second relay publish timeout.
|
||||
- Ensure every relay allows inbound events from both the browser’s IP and the
|
||||
remote signer. Corporate VPNs and Tor bridges frequently block public WSS
|
||||
relays.
|
||||
- Avoid private relays that block unsigned events from unknown clients; the
|
||||
browser session publishes unsigned requests that the signer decrypts with the
|
||||
shared secret.
|
||||
|
||||
## RPC expectations and timeouts
|
||||
|
||||
The RPC client issues the following methods:
|
||||
|
||||
- `connect` (optionally with `secret` and `perms`)
|
||||
- `get_public_key`
|
||||
- `sign_event` for publish/edit/delete flows
|
||||
|
||||
Each request is encrypted to the signer and published to the resolved relay set.
|
||||
bitvid waits up to **8 seconds** for a relay to accept the event and up to
|
||||
**15 seconds** for a response. `sign_event` calls allow up to
|
||||
**20 seconds** before timing out. On transient failures the client retries
|
||||
once (except when the signer returns an authentication URL).
|
||||
|
||||
If the signer responds with `error: auth_url`, the UI surfaces the provided URL
|
||||
so the user can complete any additional verification the signer requires.
|
||||
|
||||
## Session persistence and restoration
|
||||
|
||||
Successful connections write a session blob to
|
||||
`localStorage` under the key `bitvid:nip46:session:v1`. The payload includes the
|
||||
client key pair, remote pubkey, relay list, metadata, permissions, and the most
|
||||
recent user pubkey returned by the signer.
|
||||
|
||||
On page load bitvid reads the stored session, updates the login modal to show a
|
||||
saved signer, and silently attempts to reconnect in the background. If the
|
||||
attempt fails due to network timeouts or temporary relay outages the stored
|
||||
session remains intact so the user can retry manually. Fatal errors (for example
|
||||
shared secret mismatches) clear the saved session automatically. Users can also
|
||||
invoke “Disconnect” from the login modal to remove the stored credentials.
|
||||
|
||||
## Operational checklist
|
||||
|
||||
- Issue bunker URIs that list WSS relays reachable by both the signer and the
|
||||
browser session.
|
||||
- Keep the signer online and subscribed to `kind 24133` events for the advertised
|
||||
relays.
|
||||
- Respond to `connect`, `get_public_key`, and `sign_event` requests within the
|
||||
timeouts above.
|
||||
- Rotate secrets or permissions as needed—bitvid surfaces signer metadata so
|
||||
operators can confirm which device they are connecting to before approving new
|
||||
sessions.
|
||||
+12
-2
@@ -12,11 +12,11 @@ Most operators can ship with the defaults—24-hour deduplication, a 90-day back
|
||||
|
||||
### Anonymous session keys
|
||||
|
||||
Playback telemetry runs even when a viewer has not connected a Nostr account. The client first calls [`ensureSessionActor`](../js/nostr.js#L2011-L2105) to mint or restore an ephemeral keypair stored in `localStorage`. That session actor signs view events until the user authenticates, ensuring relays still see consistent pubkeys without blocking anonymous playback.
|
||||
Playback telemetry runs even when a viewer has not connected a Nostr account. The client first calls [`ensureSessionActor`](../js/nostr/client.js#L2445-L2504) to mint or restore an ephemeral keypair stored in `localStorage`. That session actor signs view events until the user authenticates, ensuring relays still see consistent pubkeys without blocking anonymous playback.
|
||||
|
||||
### Publish latency expectations
|
||||
|
||||
When a player crosses the 12-second watch threshold, bitvid invokes [`publishViewEvent`](../js/nostr.js#L2159-L2323) through the view-only [`recordVideoView`](../js/nostr.js#L4516-L4555) contract. The helper considers the publish successful as soon as any configured relay acknowledges the event, which in practice keeps latency under a second on public relays. The UI optimistically increments totals by feeding the signed event into [`ingestLocalViewEvent`](../js/viewCounter.js#L608-L640), so cards and modals reflect the new view immediately even while other relays finish syncing.
|
||||
When a player crosses the 12-second watch threshold, bitvid invokes [`publishViewEvent`](../js/nostr/viewEvents.js#L662-L838) through the view-only [`recordVideoView`](../js/nostr/viewEvents.js#L854-L879) contract. The helper considers the publish successful as soon as any configured relay acknowledges the event, which in practice keeps latency under a second on public relays. The UI optimistically increments totals by feeding the signed event into [`ingestLocalViewEvent`](../js/viewCounter.js#L619-L633), so cards and modals reflect the new view immediately even while other relays finish syncing.
|
||||
|
||||
The watch history pipeline has been retired; `recordVideoView` now focuses solely on analytics publishing.
|
||||
|
||||
@@ -24,6 +24,16 @@ To avoid double-counting the same session, the playback stack respects the coold
|
||||
|
||||
Every view event includes a unique `#d` tag derived from the pointer scope, the signing actor, and high-entropy randomness. Because kind `30079` is parameterized, this `#d` tag is what keeps individual view notes permanent—without it, a fresh event would overwrite the previous one from the same actor. Leave the tag intact (or provide one when overriding the schema) so relays retain the full stream of view history.
|
||||
|
||||
### Client bindings & feature toggles
|
||||
|
||||
When wiring moderation or playback toggles into view analytics, import helpers from the [NIP-71](https://github.com/nostr-protocol/nips/blob/master/71.md) facade and let it reach into the binding layer for you:
|
||||
|
||||
```js
|
||||
import { recordVideoView, subscribeVideoViewEvents } from "../nostrViewEventsFacade.js";
|
||||
```
|
||||
|
||||
The facade wraps [`js/nostr/viewEventBindings.js`](../js/nostr/viewEventBindings.js) so feature flags, relay discovery, and COUNT/LIST fallbacks all run through the same guard clauses that enforce the NIP-71/NIP-78 schemas. `js/nostr.js` still exports shimmed versions for legacy bundles, but new code should migrate to the facades to keep the eventual removal painless. Modules that need to no-op when relays disable optional methods can rely on the binding’s "unavailable" errors to short-circuit cleanly.
|
||||
|
||||
### Hydration, COUNT, and UI sync
|
||||
|
||||
Background hydration keeps optimistic counts honest. The view counter subscribes to live events while simultaneously fetching historical lists and issuing a `COUNT` request. Whenever the authoritative total returned by `COUNT` exceeds the locally accumulated sum, the hydration routine overwrites the optimistic value so the UI stays aligned with relay truth. If `COUNT` falls back (because relays reject it or the browser is offline), the locally deduped history still anchors totals, and the next successful hydration pass reconciles any drift. This interplay means cards, modals, and dashboards settle on the same number without requiring a full page reload.
|
||||
|
||||
@@ -42,6 +42,36 @@ When managing relay metadata, use `buildRelayListEvent` so the resulting
|
||||
replaceable event follows NIP-65 (`kind:10002`) with `"r"` tags describing the
|
||||
read/write split.
|
||||
|
||||
### Active signer registry
|
||||
|
||||
Authentication providers should register their capabilities with the
|
||||
`nostrClient` after login so every publish helper can reuse them:
|
||||
|
||||
```js
|
||||
import { nostrClient } from "./nostrClientFacade.js";
|
||||
import { setActiveSigner } from "./nostr/client.js";
|
||||
|
||||
setActiveSigner({
|
||||
type: "extension", // optional label, used to request NIP-07 permissions
|
||||
pubkey, // hex or npub of the active account
|
||||
signEvent: (event) => extension.signEvent(event),
|
||||
nip04Encrypt: (targetHex, plaintext) => extension.nip04.encrypt(targetHex, plaintext),
|
||||
nip04Decrypt: (actorHex, ciphertext) => extension.nip04.decrypt(actorHex, ciphertext),
|
||||
nip44Encrypt: (targetHex, plaintext) => extension.nip44.encrypt(targetHex, plaintext),
|
||||
nip44Decrypt: (actorHex, ciphertext) => extension.nip44.decrypt(actorHex, ciphertext),
|
||||
});
|
||||
|
||||
await nostrClient.ensureExtensionPermissions();
|
||||
```
|
||||
|
||||
`setActiveSigner` accepts any object that implements the subset of capabilities
|
||||
you support. `nostrClient` (imported from the
|
||||
[NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)-aligned
|
||||
`nostrClientFacade.js`) will prefer the registered signer for signing and
|
||||
encryption before falling back to session actors. Call `clearActiveSigner()` on
|
||||
logout if your integration manages session state manually; the built-in logout
|
||||
handler already does this for the default extension flow.
|
||||
|
||||
### Accessing raw events
|
||||
|
||||
`NostrClient` now keeps a lightweight cache of parsed videos alongside a
|
||||
@@ -68,6 +98,39 @@ client can republish exactly what was signed, including the original
|
||||
payload actually changes; this avoids accidental drift in dedupe tags or
|
||||
timestamps.
|
||||
|
||||
### Default client bootstrap
|
||||
|
||||
[`js/nostr/defaultClient.js`](../js/nostr/defaultClient.js) builds the shared
|
||||
`nostrClient` instance, registers it with the runtime via
|
||||
`registerNostrClient`, and exposes helpers like
|
||||
`requestDefaultExtensionPermissions`. Import the singleton or permission helper
|
||||
through the dedicated facade when you need to run a NIP-07 handshake:
|
||||
|
||||
```js
|
||||
import { nostrClient, requestDefaultExtensionPermissions } from "./nostrClientFacade.js";
|
||||
```
|
||||
|
||||
`js/nostr.js` remains as a compatibility shim while downstream packages migrate;
|
||||
it forwards to the new facades but is slated for removal once legacy imports
|
||||
are retired.【F:js/nostr/defaultClient.js†L1-L25】【F:js/nostr.js†L1-L92】
|
||||
|
||||
For analytics, route through the
|
||||
[NIP-71](https://github.com/nostr-protocol/nips/blob/master/71.md) helpers in
|
||||
`nostrViewEventsFacade.js`:
|
||||
|
||||
```js
|
||||
import { recordVideoView } from "./nostrViewEventsFacade.js";
|
||||
```
|
||||
|
||||
Watch-history list management layers
|
||||
[NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) semantics on
|
||||
encrypted snapshots—import from `nostrWatchHistoryFacade.js` to stay aligned
|
||||
with the chunk/index lifecycle:
|
||||
|
||||
```js
|
||||
import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFacade.js";
|
||||
```
|
||||
|
||||
## Event catalogue
|
||||
|
||||
| Note | Kind (default) | Tags | Content format |
|
||||
@@ -75,6 +138,7 @@ timestamps.
|
||||
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `isNsfw`, `isForKids`, `enableComments`, `ws`, `xs`) |
|
||||
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
|
||||
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | Empty content |
|
||||
| Video comment (`NOTE_TYPES.VIDEO_COMMENT`) | `1` | Mandatory `['e', <video event id>, <relay?>?]` and `['a', <kind:pubkey:identifier>, <relay?>?]` tags, optional parent pointers `['e', <parent comment id>, <relay?>?]`, and optional participant markers `['p', <pubkey>, <relay?>?]`; inherits schema append tags | Plain text body sanitized to valid UTF-8 |
|
||||
| NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', <title>]`, optional `['published_at', <unix seconds>]`, optional `['alt', <text>]`, repeated `['imeta', ...]` entries describing NIP-92 media variants, optional `['duration', <seconds>]`, repeated `['text-track', <url>, <kind>, <language>]`, optional `['content-warning', <reason>]`, repeated `['segment', <start>, <end>, <title>, <thumbnail>]`, repeated hashtags `['t', <tag>]`, repeated participants `['p', <pubkey>, <relay?>]`, repeated references `['r', <url>]` | Plain text summary carried in the content field. Publishing is gated by the `FEATURE_PUBLISH_NIP71` runtime flag while the rollout stabilizes. |
|
||||
| NIP-71 short video (`NOTE_TYPES.NIP71_SHORT_VIDEO`) | `22` | Same as `NOTE_TYPES.NIP71_VIDEO`; the kind differentiates short-form presentations. | Plain text summary; gated by `FEATURE_PUBLISH_NIP71`. |
|
||||
| Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content |
|
||||
@@ -83,6 +147,7 @@ timestamps.
|
||||
| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', <snapshotId:index>]`, `['encrypted','nip04']`, `['snapshot', <id>]`, `['chunk', <index>, <total>]`, optional leading `['head','1']` on the first chunk, pointer tags for each item, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) |
|
||||
| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` |
|
||||
| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `30002` | `['d', 'user-blocks']` | NIP-04 encrypted JSON `{ blockedPubkeys: string[] }` |
|
||||
| Hashtag preferences (`NOTE_TYPES.HASHTAG_PREFERENCES`) | `30005` | `['d', 'bitvid:tag-preferences']` plus schema-appended `['encrypted','nip44_v2']` | NIP-44 encrypted JSON `{ version, interests: string[], disinterests: string[] }` |
|
||||
| Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
@@ -93,6 +158,24 @@ If you introduce a new Nostr feature, add its schema to
|
||||
`js/nostrEventSchemas.js` so that the catalogue stays complete and so existing
|
||||
builders inherit the same debugging knobs.
|
||||
|
||||
### Hashtag preference lists
|
||||
|
||||
Hashtag preference events (`NOTE_TYPES.HASHTAG_PREFERENCES`) live on a
|
||||
replaceable `30005` list with a stable identifier tag of
|
||||
`['d','bitvid:tag-preferences']`. The builder appends `['encrypted','nip44_v2']`
|
||||
so downstream clients can detect that the payload is encrypted. The `content`
|
||||
field must be a NIP-44 ciphertext representing the JSON shape
|
||||
`{ version, interests: string[], disinterests: string[] }`, allowing clients to
|
||||
share their preferred and muted hashtags without exposing the raw preferences on
|
||||
relays.
|
||||
|
||||
When decrypting, the client inspects both `['encrypted', ...]` and
|
||||
`['encryption', ...]` hints on the event to prioritize NIP-44 v2 payloads,
|
||||
falling back to legacy NIP-44 or NIP-04 decryptors when required. Publishing
|
||||
performs the same capability probe, updating the existing `encrypted` tag with
|
||||
the negotiated scheme before signing so other readers know which cipher to try
|
||||
first.【F:js/services/hashtagPreferencesService.js†L356-L511】【F:js/services/hashtagPreferencesService.js†L520-L657】
|
||||
|
||||
### NIP-71 rollout
|
||||
|
||||
bitvid now emits a paired NIP-71 event (kind `21` for long-form, `22` for short
|
||||
@@ -118,7 +201,7 @@ plus an optional leading `['head','1']` marker so relays can prioritize the firs
|
||||
ciphertext. All chunk content is encrypted with NIP-04 and stores only pointer
|
||||
entries; richer metadata remains on-device via the
|
||||
[`WatchHistoryService`](../js/watchHistoryService.js) APIs, which default to
|
||||
pointer-only writes and local-only metadata caches.【F:config/instance-config.js†L60-L78】【F:js/nostrEventSchemas.js†L157-L189】【F:js/nostr.js†L2329-L2369】【F:js/watchHistoryService.js†L331-L376】
|
||||
pointer-only writes and local-only metadata caches.【F:config/instance-config.js†L60-L78】【F:js/nostrEventSchemas.js†L157-L189】【F:js/nostr/watchHistory.js†L1343-L1400】【F:js/watchHistoryService.js†L331-L376】
|
||||
|
||||
Refer to the [`WatchHistoryService`](../js/watchHistoryService.js) for queue
|
||||
management hooks, manual snapshot helpers, and metadata toggle controls that
|
||||
|
||||
@@ -42,4 +42,12 @@ Automated regression coverage now exercises these flows on the unified design-sy
|
||||
- Play the same video until a view event is logged, then log out (or switch to a different pubkey) and repeat the playback.
|
||||
- Confirm two distinct view events appear (e.g., via relay logs or UI counters) and the aggregated view count increments twice.
|
||||
|
||||
8. **Direct key login (nsec)**
|
||||
- Choose the "Direct key" option in the login modal and confirm the warning copy reads `"Unsafe by default" — imported keys stay decrypted only for this session unless you protect them with a passphrase.`
|
||||
- Sign in with a new nsec or mnemonic, opt into remembering it, and verify the passphrase prompt blocks submission until both passphrase fields match.
|
||||
- Reload the page, select "Unlock saved key", enter the passphrase, and confirm the account unlocks without re-entering the secret.
|
||||
- Publish a video while logged in with the direct-key provider and confirm the resulting event is signed (relays accept it) and the UI reports success.
|
||||
- While still signed in via the direct-key provider, send a direct message to a second account and confirm it decrypts successfully on the recipient before release.
|
||||
- Log out and ensure the session clears decrypted key material; stored keys should require the passphrase to unlock again.
|
||||
|
||||
Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes.
|
||||
@@ -16,16 +16,16 @@ Snapshots publish two event types to the shared `WATCH_HISTORY_KIND` stream:
|
||||
`['encrypted','nip04']`. The first chunk is additionally marked as
|
||||
`['head','1']` and may append `"a"` tags that reference previous chunk
|
||||
addresses, allowing readers to reconstruct the full set even when relays
|
||||
deduplicate aggressively.【F:js/nostrEventSchemas.js†L175-L189】【F:js/nostr.js†L2329-L2369】
|
||||
deduplicate aggressively.【F:js/nostrEventSchemas.js†L175-L189】【F:js/nostr/watchHistory.js†L1343-L1400】
|
||||
2. **Pointer event** — After chunks are accepted, bitvid emits a compact index
|
||||
event containing the canonical list identifier (`['d', WATCH_HISTORY_LIST_IDENTIFIER]`),
|
||||
the `snapshot` label, the total chunk count, and an `a` tag for each chunk
|
||||
address.【F:js/nostrEventSchemas.js†L157-L170】【F:js/nostr.js†L2425-L2441】
|
||||
address.【F:js/nostrEventSchemas.js†L157-L170】【F:js/nostr/watchHistory.js†L1434-L1462】
|
||||
|
||||
Chunk content is a NIP-04 encrypted JSON envelope of `{ version: 2, snapshot,
|
||||
chunkIndex, totalChunks, items[] }`. Items are pointer descriptors (`e` or `a`
|
||||
references plus optional relay hints) so that relays never learn which titles
|
||||
were played—only which events or addresses the client can dereference later.【F:js/nostr.js†L2337-L2364】
|
||||
were played—only which events or addresses the client can dereference later.【F:js/nostr/watchHistory.js†L1362-L1375】
|
||||
|
||||
## Batching configuration
|
||||
|
||||
@@ -34,7 +34,7 @@ optionally cap each response by defining `WATCH_HISTORY_BATCH_PAGE_SIZE` in
|
||||
`config/instance-config.js`. Leaving the page size unset returns the full
|
||||
`WATCH_HISTORY_MAX_ITEMS` window while still letting the UI paginate locally,
|
||||
but setting a positive integer keeps API payloads in lockstep with batched
|
||||
renderers like `historyView`.【F:config/instance-config.js†L101-L123】【F:js/nostr.js†L2987-L2998】【F:js/historyView.js†L1-L38】
|
||||
renderers like `historyView`.【F:config/instance-config.js†L101-L123】【F:js/nostr/watchHistory.js†L2218-L2256】【F:js/historyView.js†L1-L38】
|
||||
|
||||
## Snapshot cadence
|
||||
|
||||
@@ -52,7 +52,7 @@ When a snapshot publish fails but the response is retryable, the service stores
|
||||
the returned `snapshotId`, records the originating reason, and delegates to the
|
||||
Nostr client to retry with exponential backoff. Retries start after two seconds,
|
||||
double per attempt with 25% jitter, cap at five minutes, and stop after eight
|
||||
attempts unless a publish succeeds sooner.【F:js/watchHistoryService.js†L948-L985】【F:js/watchHistoryService.js†L723-L777】【F:js/nostr.js†L1988-L2087】
|
||||
attempts unless a publish succeeds sooner.【F:js/watchHistoryService.js†L948-L985】【F:js/watchHistoryService.js†L723-L777】【F:js/nostr/watchHistory.js†L929-L999】
|
||||
|
||||
## Legacy payload handling
|
||||
|
||||
@@ -60,7 +60,7 @@ Readers hydrate history by combining the pointer event, any encrypted chunks,
|
||||
and fallback metadata embedded in legacy watch-history lists. The resolver still
|
||||
accepts the `watch-history:v2:index` identifier, merges pointer tags from the
|
||||
index event, and falls back to plaintext content when decryption fails so older
|
||||
relays remain compatible.【F:config/instance-config.js†L60-L78】【F:js/nostr.js†L2704-L2779】【F:js/nostr.js†L5216-L5264】
|
||||
relays remain compatible.【F:config/instance-config.js†L60-L78】【F:js/nostr/watchHistory.js†L2120-L2170】【F:js/nostr/watchHistory.js†L660-L689】
|
||||
|
||||
## Local metadata toggles
|
||||
|
||||
|
||||
+115
-1
@@ -31,6 +31,27 @@ function dedupeNpubs(values) {
|
||||
);
|
||||
}
|
||||
|
||||
function areSetsEqual(first, second) {
|
||||
if (first === second) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const a = first instanceof Set ? first : new Set();
|
||||
const b = second instanceof Set ? second : new Set();
|
||||
|
||||
if (a.size !== b.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const value of a) {
|
||||
if (!b.has(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidNpub(npub) {
|
||||
if (typeof npub !== "string") {
|
||||
return false;
|
||||
@@ -59,8 +80,27 @@ class AccessControl {
|
||||
this._isRefreshing = false;
|
||||
this._refreshPromise = Promise.resolve();
|
||||
this._hydratedFromCache = false;
|
||||
this._whitelistListeners = new Set();
|
||||
this._editorListeners = new Set();
|
||||
|
||||
this._hydrateFromCache();
|
||||
this._scheduleHydrateFromCache();
|
||||
}
|
||||
|
||||
_scheduleHydrateFromCache() {
|
||||
const runHydrate = () => {
|
||||
try {
|
||||
this._hydrateFromCache();
|
||||
} catch (error) {
|
||||
userLogger.error("accessControl failed to hydrate from cache", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof queueMicrotask === "function") {
|
||||
queueMicrotask(runHydrate);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve().then(runHydrate);
|
||||
}
|
||||
|
||||
_hydrateFromCache() {
|
||||
@@ -78,11 +118,18 @@ class AccessControl {
|
||||
const whitelist = Array.isArray(state?.whitelist) ? state.whitelist : [];
|
||||
const blacklist = Array.isArray(state?.blacklist) ? state.blacklist : [];
|
||||
|
||||
const previousEditors =
|
||||
this.editors instanceof Set ? new Set(this.editors) : new Set();
|
||||
const previousWhitelist =
|
||||
this.whitelist instanceof Set ? new Set(this.whitelist) : new Set();
|
||||
|
||||
this.editors = new Set(
|
||||
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
|
||||
);
|
||||
const editorsChanged = !areSetsEqual(previousEditors, this.editors);
|
||||
const normalizedWhitelist = dedupeNpubs(whitelist);
|
||||
this.whitelist = new Set(normalizedWhitelist);
|
||||
const whitelistChanged = !areSetsEqual(previousWhitelist, this.whitelist);
|
||||
|
||||
const blacklistDedupe = dedupeNpubs(blacklist);
|
||||
const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub));
|
||||
@@ -112,6 +159,49 @@ class AccessControl {
|
||||
this.hasLoaded = true;
|
||||
this._hydratedFromCache = false;
|
||||
}
|
||||
|
||||
if (whitelistChanged) {
|
||||
this._emitWhitelistChange(Array.from(this.whitelist));
|
||||
}
|
||||
if (editorsChanged) {
|
||||
this._emitEditorsChange(Array.from(this.editors));
|
||||
}
|
||||
}
|
||||
|
||||
_emitWhitelistChange(whitelistValues) {
|
||||
if (!this._whitelistListeners.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = Array.isArray(whitelistValues)
|
||||
? [...whitelistValues]
|
||||
: this.getWhitelist();
|
||||
|
||||
for (const listener of Array.from(this._whitelistListeners)) {
|
||||
try {
|
||||
listener(snapshot);
|
||||
} catch (error) {
|
||||
userLogger.error("accessControl whitelist listener failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_emitEditorsChange(editorsValues) {
|
||||
if (!this._editorListeners.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = Array.isArray(editorsValues)
|
||||
? [...editorsValues]
|
||||
: this.getEditors();
|
||||
|
||||
for (const listener of Array.from(this._editorListeners)) {
|
||||
try {
|
||||
listener(snapshot);
|
||||
} catch (error) {
|
||||
userLogger.error("accessControl editors listener failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_performRefresh() {
|
||||
@@ -209,6 +299,30 @@ class AccessControl {
|
||||
return Array.from(this.editors);
|
||||
}
|
||||
|
||||
onWhitelistChange(listener) {
|
||||
if (typeof listener !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
this._whitelistListeners.add(listener);
|
||||
|
||||
return () => {
|
||||
this._whitelistListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
onEditorsChange(listener) {
|
||||
if (typeof listener !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
this._editorListeners.add(listener);
|
||||
|
||||
return () => {
|
||||
this._editorListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async addModerator(requestorNpub, moderatorNpub) {
|
||||
if (!this.isSuperAdmin(requestorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
|
||||
+19
-6
@@ -9,10 +9,8 @@ import {
|
||||
ADMIN_COMMUNITY_BLACKLIST_PREFIX,
|
||||
isDevMode,
|
||||
} from "./config.js";
|
||||
import {
|
||||
nostrClient,
|
||||
requestDefaultExtensionPermissions,
|
||||
} from "./nostr.js";
|
||||
import { getRegisteredNostrClient } from "./nostrClientRegistry.js";
|
||||
import { requestDefaultExtensionPermissions } from "./nostr/defaultClient.js";
|
||||
import { devLogger, userLogger } from "./utils/logger.js";
|
||||
import {
|
||||
buildAdminListEvent,
|
||||
@@ -40,6 +38,17 @@ function createError(code, message, cause) {
|
||||
return error;
|
||||
}
|
||||
|
||||
function requireNostrClient() {
|
||||
const client = getRegisteredNostrClient();
|
||||
if (!client) {
|
||||
throw createError(
|
||||
"nostr-unavailable",
|
||||
"Nostr relay pool is not initialized.",
|
||||
);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function normalizeNpub(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
@@ -350,10 +359,12 @@ function clearLegacyStorageFor(listKey) {
|
||||
}
|
||||
|
||||
function ensureNostrReady() {
|
||||
if (!nostrClient || !nostrClient.pool) {
|
||||
const nostrClient = requireNostrClient();
|
||||
|
||||
if (!nostrClient.pool) {
|
||||
throw createError(
|
||||
"nostr-unavailable",
|
||||
"Nostr relay pool is not initialized."
|
||||
"Nostr relay pool is not initialized.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -578,6 +589,7 @@ function extractNpubsFromEvent(event) {
|
||||
}
|
||||
|
||||
async function fetchLatestListEvent(filter, contextLabel = "admin-list") {
|
||||
const nostrClient = requireNostrClient();
|
||||
const relays = ensureNostrReady();
|
||||
|
||||
const normalizedFilter = {};
|
||||
@@ -876,6 +888,7 @@ function publishListWithFirstAcceptance(pool, relays, event, options = {}) {
|
||||
}
|
||||
|
||||
async function persistNostrState(actorNpub, updates = {}) {
|
||||
const nostrClient = requireNostrClient();
|
||||
ensureNostrReady();
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult.ok) {
|
||||
|
||||
Vendored
+85
-7
@@ -5,25 +5,100 @@ import {
|
||||
DEFAULT_TRUST_SEED_NPUBS,
|
||||
FEATURE_TRUST_SEEDS,
|
||||
} from "./constants.js";
|
||||
import { ADMIN_SUPER_NPUB } from "./config.js";
|
||||
import { accessControl } from "./accessControl.js";
|
||||
import moderationService from "./services/moderationService.js";
|
||||
import nostrService from "./services/nostrService.js";
|
||||
import r2Service from "./services/r2Service.js";
|
||||
import { loadView } from "./viewManager.js";
|
||||
import hashtagPreferences from "./services/hashtagPreferencesService.js";
|
||||
|
||||
function normalizeNpub(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function buildTrustedSeeds({ superAdmin, editors, fallbackSeeds }) {
|
||||
const seeds = new Set();
|
||||
|
||||
const addSeed = (value) => {
|
||||
const normalized = normalizeNpub(value);
|
||||
if (normalized) {
|
||||
seeds.add(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
addSeed(superAdmin);
|
||||
|
||||
if (editors && typeof editors[Symbol.iterator] === "function") {
|
||||
for (const value of editors) {
|
||||
addSeed(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!seeds.size && fallbackSeeds && typeof fallbackSeeds[Symbol.iterator] === "function") {
|
||||
for (const value of fallbackSeeds) {
|
||||
addSeed(value);
|
||||
}
|
||||
}
|
||||
|
||||
return seeds;
|
||||
}
|
||||
|
||||
async function bootstrapTrustedSeeds() {
|
||||
if (
|
||||
!(
|
||||
FEATURE_TRUST_SEEDS &&
|
||||
moderationService &&
|
||||
typeof moderationService.setTrustedSeeds === "function"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applySeeds = () => {
|
||||
try {
|
||||
const editors =
|
||||
accessControl && typeof accessControl.getEditors === "function"
|
||||
? accessControl.getEditors()
|
||||
: [];
|
||||
const seeds = buildTrustedSeeds({
|
||||
superAdmin: ADMIN_SUPER_NPUB,
|
||||
editors,
|
||||
fallbackSeeds: DEFAULT_TRUST_SEED_NPUBS,
|
||||
});
|
||||
moderationService.setTrustedSeeds(seeds);
|
||||
} catch {
|
||||
// Swallow bootstrap errors so login flow can continue; flag enables quick rollback.
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
FEATURE_TRUST_SEEDS &&
|
||||
moderationService &&
|
||||
typeof moderationService.setTrustedSeeds === "function"
|
||||
) {
|
||||
try {
|
||||
moderationService.setTrustedSeeds(DEFAULT_TRUST_SEED_NPUBS);
|
||||
if (accessControl && typeof accessControl.ensureReady === "function") {
|
||||
await accessControl.ensureReady();
|
||||
}
|
||||
} catch {
|
||||
// Swallow bootstrap errors so login flow can continue; flag enables quick rollback.
|
||||
}
|
||||
|
||||
applySeeds();
|
||||
|
||||
const applyOnChange = () => {
|
||||
applySeeds();
|
||||
};
|
||||
|
||||
if (accessControl && typeof accessControl.onWhitelistChange === "function") {
|
||||
accessControl.onWhitelistChange(applyOnChange);
|
||||
}
|
||||
|
||||
if (accessControl && typeof accessControl.onEditorsChange === "function") {
|
||||
accessControl.onEditorsChange(applyOnChange);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrapTrustedSeeds();
|
||||
|
||||
function mergeServices(overrides = {}) {
|
||||
const merged = { nostrService, r2Service };
|
||||
const merged = { nostrService, r2Service, hashtagPreferences };
|
||||
|
||||
if (overrides && typeof overrides === "object") {
|
||||
if (overrides.nostrService) {
|
||||
@@ -32,6 +107,9 @@ function mergeServices(overrides = {}) {
|
||||
if (overrides.r2Service) {
|
||||
merged.r2Service = overrides.r2Service;
|
||||
}
|
||||
if (overrides.hashtagPreferences) {
|
||||
merged.hashtagPreferences = overrides.hashtagPreferences;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
|
||||
+1166
-17
File diff suppressed because it is too large.
Load diff
@@ -20,6 +20,11 @@ import {
|
||||
ADMIN_WHITELIST_MODE_STORAGE_KEY,
|
||||
DEFAULT_WHITELIST_MODE_ENABLED,
|
||||
ALLOW_NSFW_CONTENT,
|
||||
DEFAULT_TRUST_SEED_NPUBS,
|
||||
DEFAULT_BLUR_THRESHOLD,
|
||||
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
WATCH_HISTORY_KIND,
|
||||
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS,
|
||||
@@ -69,6 +74,11 @@ export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists
|
||||
export const ADMIN_COMMUNITY_BLACKLIST_SOURCES = "community:blacklist:sources";
|
||||
export const ADMIN_COMMUNITY_BLACKLIST_PREFIX = "community:blacklist";
|
||||
export { ALLOW_NSFW_CONTENT };
|
||||
export { DEFAULT_TRUST_SEED_NPUBS };
|
||||
export { DEFAULT_BLUR_THRESHOLD };
|
||||
export { DEFAULT_AUTOPLAY_BLOCK_THRESHOLD };
|
||||
export { DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD };
|
||||
export { DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD };
|
||||
|
||||
const WHITELIST_MODE_KEY = ADMIN_WHITELIST_MODE_STORAGE_KEY;
|
||||
const DEFAULT_WHITELIST_ENABLED = DEFAULT_WHITELIST_MODE_ENABLED;
|
||||
|
||||
+152
-67
@@ -1,51 +1,24 @@
|
||||
export const DEFAULT_TRUST_SEED_NPUBS = Object.freeze([
|
||||
// Rollout: trust seeds count as F1 contacts. Rollback: flip FEATURE_TRUST_SEEDS to false.
|
||||
"npub1424242424242424242424242424242424242424242424242424qamrcaj",
|
||||
"npub1hwamhwamhwamhwamhwamhwamhwamhwamhwamhwamhwamhwamhwasxw04hu",
|
||||
]);
|
||||
import {
|
||||
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD as CONFIG_DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
DEFAULT_BLUR_THRESHOLD as CONFIG_DEFAULT_BLUR_THRESHOLD,
|
||||
DEFAULT_TRUST_SEED_NPUBS as CONFIG_DEFAULT_TRUST_SEED_NPUBS,
|
||||
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD as CONFIG_DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD as CONFIG_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
} from "./config.js";
|
||||
|
||||
const DEFAULT_FLAGS = Object.freeze({
|
||||
URL_FIRST_ENABLED: true, // try URL before magnet in the player
|
||||
ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false,
|
||||
FEATURE_WATCH_HISTORY_V2: true,
|
||||
FEATURE_PUBLISH_NIP71: false,
|
||||
FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes.
|
||||
FEATURE_TRUSTED_HIDE_CONTROLS: true,
|
||||
TRUSTED_MUTE_HIDE_THRESHOLD: 1,
|
||||
TRUSTED_SPAM_HIDE_THRESHOLD: 3,
|
||||
WSS_TRACKERS: Object.freeze([
|
||||
"wss://tracker.openwebtorrent.com",
|
||||
"wss://tracker.fastcast.nz",
|
||||
"wss://tracker.webtorrent.dev",
|
||||
"wss://tracker.sloppyta.co:443/announce",
|
||||
]),
|
||||
});
|
||||
|
||||
const globalScope = typeof globalThis === "object" && globalThis ? globalThis : undefined;
|
||||
|
||||
const runtimeFlags = (() => {
|
||||
if (globalScope && typeof globalScope.__BITVID_RUNTIME_FLAGS__ === "object") {
|
||||
return globalScope.__BITVID_RUNTIME_FLAGS__;
|
||||
function coerceNonNegativeInteger(value, fallback) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return fallback;
|
||||
}
|
||||
const initial = {
|
||||
URL_FIRST_ENABLED: DEFAULT_FLAGS.URL_FIRST_ENABLED,
|
||||
ACCEPT_LEGACY_V1: DEFAULT_FLAGS.ACCEPT_LEGACY_V1,
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO:
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
|
||||
FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2,
|
||||
FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71,
|
||||
FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS,
|
||||
FEATURE_TRUSTED_HIDE_CONTROLS: DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS,
|
||||
TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
|
||||
};
|
||||
if (globalScope) {
|
||||
globalScope.__BITVID_RUNTIME_FLAGS__ = initial;
|
||||
|
||||
const sanitized = Math.max(0, Math.floor(numeric));
|
||||
if (!Number.isFinite(sanitized)) {
|
||||
return fallback;
|
||||
}
|
||||
return initial;
|
||||
})();
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function coerceBoolean(value, fallback) {
|
||||
if (typeof value === "boolean") {
|
||||
@@ -69,8 +42,13 @@ function coerceBoolean(value, fallback) {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function sanitizeTrackerList(candidate) {
|
||||
const input = Array.isArray(candidate) ? candidate : DEFAULT_FLAGS.WSS_TRACKERS;
|
||||
function freezeTrackers(list) {
|
||||
return Object.freeze([...list]);
|
||||
}
|
||||
|
||||
function sanitizeTrackerList(candidate, fallbackTrackers) {
|
||||
const fallback = Array.isArray(fallbackTrackers) ? fallbackTrackers : [];
|
||||
const input = Array.isArray(candidate) ? candidate : fallback;
|
||||
const seen = new Set();
|
||||
const sanitized = [];
|
||||
|
||||
@@ -94,30 +72,115 @@ function sanitizeTrackerList(candidate) {
|
||||
}
|
||||
|
||||
if (!sanitized.length) {
|
||||
return [...DEFAULT_FLAGS.WSS_TRACKERS];
|
||||
return [...fallback];
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function freezeTrackers(list) {
|
||||
return Object.freeze([...list]);
|
||||
}
|
||||
|
||||
function coerceNonNegativeInteger(value, fallback) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return fallback;
|
||||
function sanitizeTrustSeedList(candidate) {
|
||||
if (!Array.isArray(candidate)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sanitized = Math.max(0, Math.floor(numeric));
|
||||
if (!Number.isFinite(sanitized)) {
|
||||
return fallback;
|
||||
const seen = new Set();
|
||||
const sanitized = [];
|
||||
|
||||
for (const npub of candidate) {
|
||||
if (typeof npub !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = npub.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
sanitized.push(trimmed);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
const DEFAULT_WSS_TRACKERS = Object.freeze([
|
||||
"wss://tracker.openwebtorrent.com",
|
||||
"wss://tracker.fastcast.nz",
|
||||
"wss://tracker.webtorrent.dev",
|
||||
"wss://tracker.sloppyta.co:443/announce",
|
||||
]);
|
||||
|
||||
const SANITIZED_DEFAULT_BLUR_THRESHOLD = coerceNonNegativeInteger(
|
||||
CONFIG_DEFAULT_BLUR_THRESHOLD,
|
||||
1,
|
||||
);
|
||||
const SANITIZED_DEFAULT_AUTOPLAY_BLOCK_THRESHOLD = coerceNonNegativeInteger(
|
||||
CONFIG_DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
1,
|
||||
);
|
||||
const SANITIZED_DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD = coerceNonNegativeInteger(
|
||||
CONFIG_DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
1,
|
||||
);
|
||||
const SANITIZED_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD = coerceNonNegativeInteger(
|
||||
CONFIG_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
3,
|
||||
);
|
||||
|
||||
const SANITIZED_DEFAULT_TRUST_SEED_NPUBS = Object.freeze(
|
||||
sanitizeTrustSeedList(CONFIG_DEFAULT_TRUST_SEED_NPUBS)
|
||||
);
|
||||
|
||||
export const DEFAULT_BLUR_THRESHOLD = SANITIZED_DEFAULT_BLUR_THRESHOLD;
|
||||
export const DEFAULT_AUTOPLAY_BLOCK_THRESHOLD =
|
||||
SANITIZED_DEFAULT_AUTOPLAY_BLOCK_THRESHOLD;
|
||||
export const DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD =
|
||||
SANITIZED_DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD;
|
||||
export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD =
|
||||
SANITIZED_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD;
|
||||
export const DEFAULT_TRUST_SEED_NPUBS = SANITIZED_DEFAULT_TRUST_SEED_NPUBS;
|
||||
|
||||
const DEFAULT_FLAGS = Object.freeze({
|
||||
URL_FIRST_ENABLED: true, // try URL before magnet in the player
|
||||
ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false,
|
||||
FEATURE_WATCH_HISTORY_V2: true,
|
||||
FEATURE_PUBLISH_NIP71: false,
|
||||
FEATURE_HASHTAG_PREFERENCES: false,
|
||||
FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes.
|
||||
FEATURE_TRUSTED_HIDE_CONTROLS: true,
|
||||
TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
WSS_TRACKERS: DEFAULT_WSS_TRACKERS,
|
||||
});
|
||||
|
||||
const globalScope = typeof globalThis === "object" && globalThis ? globalThis : undefined;
|
||||
|
||||
const runtimeFlags = (() => {
|
||||
if (globalScope && typeof globalScope.__BITVID_RUNTIME_FLAGS__ === "object") {
|
||||
return globalScope.__BITVID_RUNTIME_FLAGS__;
|
||||
}
|
||||
const initial = {
|
||||
URL_FIRST_ENABLED: DEFAULT_FLAGS.URL_FIRST_ENABLED,
|
||||
ACCEPT_LEGACY_V1: DEFAULT_FLAGS.ACCEPT_LEGACY_V1,
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO:
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
|
||||
FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2,
|
||||
FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71,
|
||||
FEATURE_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES,
|
||||
FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS,
|
||||
FEATURE_TRUSTED_HIDE_CONTROLS: DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS,
|
||||
TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
|
||||
};
|
||||
if (globalScope) {
|
||||
globalScope.__BITVID_RUNTIME_FLAGS__ = initial;
|
||||
}
|
||||
return initial;
|
||||
})();
|
||||
|
||||
export let URL_FIRST_ENABLED = coerceBoolean(
|
||||
runtimeFlags.URL_FIRST_ENABLED,
|
||||
DEFAULT_FLAGS.URL_FIRST_ENABLED
|
||||
@@ -143,6 +206,11 @@ export let FEATURE_PUBLISH_NIP71 = coerceBoolean(
|
||||
DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71
|
||||
);
|
||||
|
||||
export let FEATURE_HASHTAG_PREFERENCES = coerceBoolean(
|
||||
runtimeFlags.FEATURE_HASHTAG_PREFERENCES,
|
||||
DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES
|
||||
);
|
||||
|
||||
export let FEATURE_TRUST_SEEDS = coerceBoolean(
|
||||
runtimeFlags.FEATURE_TRUST_SEEDS,
|
||||
DEFAULT_FLAGS.FEATURE_TRUST_SEEDS
|
||||
@@ -154,7 +222,7 @@ export let FEATURE_TRUSTED_HIDE_CONTROLS = coerceBoolean(
|
||||
);
|
||||
|
||||
export let WSS_TRACKERS = freezeTrackers(
|
||||
sanitizeTrackerList(runtimeFlags.WSS_TRACKERS)
|
||||
sanitizeTrackerList(runtimeFlags.WSS_TRACKERS, DEFAULT_FLAGS.WSS_TRACKERS)
|
||||
);
|
||||
|
||||
export let TRUSTED_MUTE_HIDE_THRESHOLD = coerceNonNegativeInteger(
|
||||
@@ -167,12 +235,6 @@ export let TRUSTED_SPAM_HIDE_THRESHOLD = coerceNonNegativeInteger(
|
||||
DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD
|
||||
);
|
||||
|
||||
export const DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD =
|
||||
DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD;
|
||||
|
||||
export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD =
|
||||
DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD;
|
||||
|
||||
Object.defineProperty(runtimeFlags, "URL_FIRST_ENABLED", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
@@ -237,6 +299,20 @@ Object.defineProperty(runtimeFlags, "FEATURE_PUBLISH_NIP71", {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "FEATURE_HASHTAG_PREFERENCES", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return FEATURE_HASHTAG_PREFERENCES;
|
||||
},
|
||||
set(next) {
|
||||
FEATURE_HASHTAG_PREFERENCES = coerceBoolean(
|
||||
next,
|
||||
DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "FEATURE_TRUST_SEEDS", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
@@ -272,7 +348,9 @@ Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
|
||||
return [...WSS_TRACKERS];
|
||||
},
|
||||
set(next) {
|
||||
WSS_TRACKERS = freezeTrackers(sanitizeTrackerList(next));
|
||||
WSS_TRACKERS = freezeTrackers(
|
||||
sanitizeTrackerList(next, DEFAULT_FLAGS.WSS_TRACKERS)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -310,6 +388,7 @@ runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1;
|
||||
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
|
||||
runtimeFlags.FEATURE_WATCH_HISTORY_V2 = FEATURE_WATCH_HISTORY_V2;
|
||||
runtimeFlags.FEATURE_PUBLISH_NIP71 = FEATURE_PUBLISH_NIP71;
|
||||
runtimeFlags.FEATURE_HASHTAG_PREFERENCES = FEATURE_HASHTAG_PREFERENCES;
|
||||
runtimeFlags.FEATURE_TRUST_SEEDS = FEATURE_TRUST_SEEDS;
|
||||
runtimeFlags.FEATURE_TRUSTED_HIDE_CONTROLS = FEATURE_TRUSTED_HIDE_CONTROLS;
|
||||
runtimeFlags.WSS_TRACKERS = WSS_TRACKERS;
|
||||
@@ -350,6 +429,11 @@ export function setTrustedHideControlsEnabled(next) {
|
||||
return FEATURE_TRUSTED_HIDE_CONTROLS;
|
||||
}
|
||||
|
||||
export function setHashtagPreferencesEnabled(next) {
|
||||
runtimeFlags.FEATURE_HASHTAG_PREFERENCES = next;
|
||||
return FEATURE_HASHTAG_PREFERENCES;
|
||||
}
|
||||
|
||||
export function resetRuntimeFlags() {
|
||||
setUrlFirstEnabled(DEFAULT_FLAGS.URL_FIRST_ENABLED);
|
||||
setAcceptLegacyV1(DEFAULT_FLAGS.ACCEPT_LEGACY_V1);
|
||||
@@ -357,6 +441,7 @@ export function resetRuntimeFlags() {
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
|
||||
);
|
||||
setWatchHistoryV2Enabled(DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2);
|
||||
setHashtagPreferencesEnabled(DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES);
|
||||
setTrustSeedsEnabled(DEFAULT_FLAGS.FEATURE_TRUST_SEEDS);
|
||||
setTrustedHideControlsEnabled(DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS);
|
||||
setTrustedMuteHideThreshold(DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD);
|
||||
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
createBlacklistFilterStage,
|
||||
createWatchHistorySuppressionStage,
|
||||
createModerationStage,
|
||||
createResolvePostedAtStage,
|
||||
} from "./stages.js";
|
||||
export { createChronologicalSorter } from "./sorters.js";
|
||||
export {
|
||||
|
||||
@@ -1,14 +1,95 @@
|
||||
// js/feedEngine/sorters.js
|
||||
|
||||
export function createChronologicalSorter({ direction = "desc" } = {}) {
|
||||
export function createChronologicalSorter({
|
||||
direction = "desc",
|
||||
postedAtResolver,
|
||||
} = {}) {
|
||||
const normalizedDirection = direction === "asc" ? "asc" : "desc";
|
||||
|
||||
return function chronologicalSorter(items = []) {
|
||||
return function chronologicalSorter(items = [], context = {}) {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const copy = [...items];
|
||||
const getTimestamp = (entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
const video = entry.video;
|
||||
if (!video || typeof video !== "object") {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
const resolvePostedAtHook = () => {
|
||||
if (typeof postedAtResolver === "function") {
|
||||
try {
|
||||
const resolved = postedAtResolver({ entry, context, video });
|
||||
if (Number.isFinite(resolved)) {
|
||||
return Math.floor(resolved);
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.("[chronological-sorter] postedAtResolver threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
const hook = context?.hooks?.timestamps;
|
||||
if (!hook || typeof hook !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
hook.getKnownVideoPostedAt,
|
||||
hook.getKnownPostedAt,
|
||||
hook.getVideoPostedAt,
|
||||
hook.resolveVideoPostedAt,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "function") {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = candidate(video, { entry, context });
|
||||
if (Number.isFinite(value)) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[chronological-sorter] timestamps hook threw",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const hookTimestamp = resolvePostedAtHook();
|
||||
if (Number.isFinite(hookTimestamp)) {
|
||||
return hookTimestamp;
|
||||
}
|
||||
|
||||
const rootCreatedAt = Number(video.rootCreatedAt);
|
||||
if (Number.isFinite(rootCreatedAt)) {
|
||||
return Math.floor(rootCreatedAt);
|
||||
}
|
||||
|
||||
const nip71CreatedAt = Number(video?.nip71Source?.created_at);
|
||||
if (Number.isFinite(nip71CreatedAt)) {
|
||||
return Math.floor(nip71CreatedAt);
|
||||
}
|
||||
|
||||
const createdAt = Number(video.created_at);
|
||||
if (Number.isFinite(createdAt)) {
|
||||
return Math.floor(createdAt);
|
||||
}
|
||||
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
};
|
||||
|
||||
copy.sort((a, b) => {
|
||||
const aMuted =
|
||||
a?.metadata?.moderation?.trustedMuted === true ||
|
||||
@@ -21,8 +102,8 @@ export function createChronologicalSorter({ direction = "desc" } = {}) {
|
||||
return aMuted ? 1 : -1;
|
||||
}
|
||||
|
||||
const aTs = Number(a?.video?.created_at);
|
||||
const bTs = Number(b?.video?.created_at);
|
||||
const aTs = getTimestamp(a);
|
||||
const bTs = getTimestamp(b);
|
||||
const normalizedATs = Number.isFinite(aTs) ? aTs : Number.NEGATIVE_INFINITY;
|
||||
const normalizedBTs = Number.isFinite(bTs) ? bTs : Number.NEGATIVE_INFINITY;
|
||||
|
||||
|
||||
+321
-22
@@ -2,6 +2,10 @@
|
||||
|
||||
import { getApplication } from "../applicationContext.js";
|
||||
import { IS_DEV_MODE } from "../config.js";
|
||||
import {
|
||||
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
DEFAULT_BLUR_THRESHOLD,
|
||||
} from "../constants.js";
|
||||
import nostrService from "../services/nostrService.js";
|
||||
import moderationService from "../services/moderationService.js";
|
||||
import logger from "../utils/logger.js";
|
||||
@@ -75,6 +79,185 @@ export function createDedupeByRootStage({
|
||||
};
|
||||
}
|
||||
|
||||
function gatherTimestampFunctions({
|
||||
context,
|
||||
knownOverride,
|
||||
resolveOverride,
|
||||
}) {
|
||||
const knownFns = [];
|
||||
const resolveFns = [];
|
||||
|
||||
const addKnown = (fn) => {
|
||||
if (typeof fn === "function") {
|
||||
knownFns.push(fn);
|
||||
}
|
||||
};
|
||||
|
||||
const addResolver = (fn) => {
|
||||
if (typeof fn === "function") {
|
||||
resolveFns.push(fn);
|
||||
}
|
||||
};
|
||||
|
||||
addKnown(knownOverride);
|
||||
addResolver(resolveOverride);
|
||||
|
||||
const hookCandidates = [context?.hooks, context?.runtime];
|
||||
for (const candidate of hookCandidates) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
continue;
|
||||
}
|
||||
const timestamps = candidate.timestamps;
|
||||
if (!timestamps || typeof timestamps !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
addKnown(timestamps.getKnownVideoPostedAt);
|
||||
addKnown(timestamps.getKnownPostedAt);
|
||||
addResolver(timestamps.resolveVideoPostedAt);
|
||||
addResolver(timestamps.getVideoPostedAt);
|
||||
}
|
||||
|
||||
return { knownFns, resolveFns };
|
||||
}
|
||||
|
||||
function applyResolvedTimestamp(video, timestamp) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = Math.floor(timestamp);
|
||||
if (!Number.isFinite(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number.isFinite(video.rootCreatedAt)) {
|
||||
const existing = Math.floor(video.rootCreatedAt);
|
||||
if (Number.isFinite(existing) && existing <= normalized) {
|
||||
video.rootCreatedAt = existing;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
video.rootCreatedAt = normalized;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function resolveTimestampWithResolvers({
|
||||
video,
|
||||
detail,
|
||||
resolvers,
|
||||
stageName,
|
||||
context,
|
||||
}) {
|
||||
if (!Array.isArray(resolvers) || resolvers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const resolver of resolvers) {
|
||||
if (typeof resolver !== "function") {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await resolver(video, detail);
|
||||
if (Number.isFinite(value)) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(`[${stageName}] resolve timestamp hook threw`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createResolvePostedAtStage({
|
||||
stageName = "resolve-posted-at",
|
||||
getKnownPostedAt,
|
||||
resolvePostedAt,
|
||||
} = {}) {
|
||||
return async function resolvePostedAtStage(items = [], context = {}) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const { knownFns, resolveFns } = gatherTimestampFunctions({
|
||||
context,
|
||||
knownOverride: getKnownPostedAt,
|
||||
resolveOverride: resolvePostedAt,
|
||||
});
|
||||
|
||||
if (knownFns.length === 0 && resolveFns.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const tasks = [];
|
||||
|
||||
for (const item of items) {
|
||||
const video = item?.video;
|
||||
if (!video || typeof video !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Number.isFinite(video.rootCreatedAt)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const detail = { entry: item, context };
|
||||
|
||||
let knownValue = null;
|
||||
for (const candidate of knownFns) {
|
||||
try {
|
||||
const value = candidate(video, detail);
|
||||
if (Number.isFinite(value)) {
|
||||
knownValue = Math.floor(value);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(`[${stageName}] known timestamp hook threw`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (knownValue !== null && Number.isFinite(knownValue)) {
|
||||
applyResolvedTimestamp(video, knownValue);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!resolveFns.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tasks.push(async () => {
|
||||
const resolved = await resolveTimestampWithResolvers({
|
||||
video,
|
||||
detail,
|
||||
resolvers: resolveFns,
|
||||
stageName,
|
||||
context,
|
||||
});
|
||||
if (resolved !== null && Number.isFinite(resolved)) {
|
||||
applyResolvedTimestamp(video, resolved);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
await task();
|
||||
} catch (error) {
|
||||
context?.log?.(`[${stageName}] resolution task failed`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
}
|
||||
|
||||
export function createBlacklistFilterStage({
|
||||
stageName = "blacklist-filter",
|
||||
shouldIncludeVideo,
|
||||
@@ -93,6 +276,10 @@ export function createBlacklistFilterStage({
|
||||
|
||||
const options = { blacklistedEventIds: blacklist, isAuthorBlocked };
|
||||
|
||||
// TODO(tag-preferences): incorporate context.runtime.tagPreferences once the
|
||||
// filtering helpers are in place so this stage can drop disinterested tags
|
||||
// without disturbing existing blacklist logic.
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const item of items) {
|
||||
@@ -196,34 +383,112 @@ export function createWatchHistorySuppressionStage({
|
||||
|
||||
export function createModerationStage({
|
||||
stageName = "moderation",
|
||||
autoplayThreshold = 2,
|
||||
blurThreshold = 3,
|
||||
autoplayThreshold,
|
||||
blurThreshold,
|
||||
reportType = "nudity",
|
||||
service = null,
|
||||
getService,
|
||||
trustedMuteHideThreshold = Number.POSITIVE_INFINITY,
|
||||
trustedReportHideThreshold = Number.POSITIVE_INFINITY,
|
||||
trustedMuteHideThreshold,
|
||||
trustedReportHideThreshold,
|
||||
} = {}) {
|
||||
const sanitizeThreshold = (value, fallback) => {
|
||||
const sanitizeThreshold = (value, { fallback, allowInfinity = false } = {}) => {
|
||||
if (Number.isFinite(value)) {
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
if (allowInfinity && value === Number.POSITIVE_INFINITY) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const normalizedAutoplayThreshold = Number.isFinite(autoplayThreshold)
|
||||
? Math.max(0, Math.floor(autoplayThreshold))
|
||||
: 2;
|
||||
const normalizedBlurThreshold = Number.isFinite(blurThreshold)
|
||||
? Math.max(0, Math.floor(blurThreshold))
|
||||
: 3;
|
||||
const normalizedMuteHideThreshold = sanitizeThreshold(
|
||||
const createThresholdResolver = (
|
||||
candidate,
|
||||
{ runtimeKey, defaultValue, fallbackValue, allowInfinity = false },
|
||||
) => {
|
||||
return (context) => {
|
||||
const runtimeThresholds =
|
||||
context?.runtime && typeof context.runtime === "object"
|
||||
? context.runtime.moderationThresholds
|
||||
: null;
|
||||
|
||||
const runtimeValue =
|
||||
runtimeThresholds && typeof runtimeThresholds === "object"
|
||||
? runtimeThresholds[runtimeKey]
|
||||
: undefined;
|
||||
|
||||
const runtimeValid =
|
||||
Number.isFinite(runtimeValue) ||
|
||||
(allowInfinity && runtimeValue === Number.POSITIVE_INFINITY);
|
||||
|
||||
let value;
|
||||
|
||||
if (typeof candidate === "function") {
|
||||
try {
|
||||
value = candidate({
|
||||
context,
|
||||
runtimeThresholds,
|
||||
runtimeValue,
|
||||
defaultValue,
|
||||
});
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
`[${stageName}] threshold resolver threw for ${runtimeKey}`,
|
||||
error,
|
||||
);
|
||||
value = undefined;
|
||||
}
|
||||
} else if (Number.isFinite(candidate)) {
|
||||
value = candidate;
|
||||
} else if (allowInfinity && candidate === Number.POSITIVE_INFINITY) {
|
||||
value = Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
const hasValidValue =
|
||||
Number.isFinite(value) ||
|
||||
(allowInfinity && value === Number.POSITIVE_INFINITY);
|
||||
|
||||
if (!hasValidValue) {
|
||||
if (runtimeValid) {
|
||||
value = runtimeValue;
|
||||
} else {
|
||||
value = defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizeThreshold(value, {
|
||||
fallback: fallbackValue,
|
||||
allowInfinity,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const resolveAutoplayThreshold = createThresholdResolver(autoplayThreshold, {
|
||||
runtimeKey: "autoplayBlockThreshold",
|
||||
defaultValue: DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
fallbackValue: DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
});
|
||||
const resolveBlurThreshold = createThresholdResolver(blurThreshold, {
|
||||
runtimeKey: "blurThreshold",
|
||||
defaultValue: DEFAULT_BLUR_THRESHOLD,
|
||||
fallbackValue: DEFAULT_BLUR_THRESHOLD,
|
||||
});
|
||||
const resolveMuteHideThreshold = createThresholdResolver(
|
||||
trustedMuteHideThreshold,
|
||||
Number.POSITIVE_INFINITY,
|
||||
{
|
||||
runtimeKey: "trustedMuteHideThreshold",
|
||||
defaultValue: Number.POSITIVE_INFINITY,
|
||||
fallbackValue: Number.POSITIVE_INFINITY,
|
||||
allowInfinity: true,
|
||||
},
|
||||
);
|
||||
const normalizedReportHideThreshold = sanitizeThreshold(
|
||||
const resolveReportHideThreshold = createThresholdResolver(
|
||||
trustedReportHideThreshold,
|
||||
Number.POSITIVE_INFINITY,
|
||||
{
|
||||
runtimeKey: "trustedSpamHideThreshold",
|
||||
defaultValue: Number.POSITIVE_INFINITY,
|
||||
fallbackValue: Number.POSITIVE_INFINITY,
|
||||
allowInfinity: true,
|
||||
},
|
||||
);
|
||||
const normalizedReportType = typeof reportType === "string" ? reportType.trim().toLowerCase() : "nudity";
|
||||
|
||||
@@ -251,6 +516,11 @@ export function createModerationStage({
|
||||
context?.log?.(`[${stageName}] Failed to refresh viewer context`, error);
|
||||
}
|
||||
|
||||
const normalizedAutoplayThreshold = resolveAutoplayThreshold(context);
|
||||
const normalizedBlurThreshold = resolveBlurThreshold(context);
|
||||
const normalizedMuteHideThreshold = resolveMuteHideThreshold(context);
|
||||
const normalizedReportHideThreshold = resolveReportHideThreshold(context);
|
||||
|
||||
const activeIds = new Set();
|
||||
for (const item of items) {
|
||||
const videoId = typeof item?.video?.id === "string" ? item.video.id : "";
|
||||
@@ -579,8 +849,11 @@ export function createModerationStage({
|
||||
trustedMuters = [];
|
||||
}
|
||||
|
||||
const blockAutoplay = trustedCount >= normalizedAutoplayThreshold;
|
||||
const blurThumbnail = trustedCount >= normalizedBlurThreshold;
|
||||
const blockAutoplay =
|
||||
trustedCount >= normalizedAutoplayThreshold || trustedMuted;
|
||||
const blurFromReports = trustedCount >= normalizedBlurThreshold;
|
||||
let blurThumbnail = blurFromReports;
|
||||
let blurReason = blurThumbnail ? "trusted-report" : "";
|
||||
const adminWhitelist = adminStatus?.whitelisted === true;
|
||||
const adminWhitelistBypass = false;
|
||||
|
||||
@@ -594,7 +867,6 @@ export function createModerationStage({
|
||||
: {};
|
||||
|
||||
metadataModeration.blockAutoplay = blockAutoplay;
|
||||
metadataModeration.blurThumbnail = blurThumbnail;
|
||||
metadataModeration.summary = summary;
|
||||
metadataModeration.trustedCount = trustedCount;
|
||||
metadataModeration.reportType = normalizedReportType;
|
||||
@@ -612,7 +884,6 @@ export function createModerationStage({
|
||||
}
|
||||
|
||||
video.moderation.blockAutoplay = blockAutoplay;
|
||||
video.moderation.blurThumbnail = blurThumbnail;
|
||||
video.moderation.trustedCount = trustedCount;
|
||||
video.moderation.reportType = normalizedReportType;
|
||||
video.moderation.adminWhitelist = adminWhitelist;
|
||||
@@ -660,9 +931,7 @@ export function createModerationStage({
|
||||
|
||||
let hideBypass = "";
|
||||
if (hideTriggered) {
|
||||
if (adminWhitelist) {
|
||||
hideBypass = "admin-whitelist";
|
||||
} else if (viewerOverrideActive) {
|
||||
if (viewerOverrideActive) {
|
||||
hideBypass = "viewer-override";
|
||||
} else if (feedPolicyBypass) {
|
||||
hideBypass = "feed-policy";
|
||||
@@ -711,6 +980,36 @@ export function createModerationStage({
|
||||
}
|
||||
}
|
||||
|
||||
if (!blurThumbnail && (trustedMuted || hideTriggered)) {
|
||||
blurThumbnail = true;
|
||||
if (hideTriggered) {
|
||||
blurReason = hideReason || "trusted-hide";
|
||||
} else if (trustedMuted) {
|
||||
blurReason = "trusted-mute";
|
||||
}
|
||||
} else if (blurThumbnail) {
|
||||
if (hideTriggered) {
|
||||
blurReason = hideReason || "trusted-hide";
|
||||
} else if (trustedMuted && !blurFromReports) {
|
||||
blurReason = "trusted-mute";
|
||||
} else if (!blurReason && blurFromReports) {
|
||||
blurReason = "trusted-report";
|
||||
}
|
||||
}
|
||||
|
||||
metadataModeration.blurThumbnail = blurThumbnail;
|
||||
if (blurThumbnail) {
|
||||
metadataModeration.blurReason = blurReason;
|
||||
} else if (metadataModeration.blurReason) {
|
||||
delete metadataModeration.blurReason;
|
||||
}
|
||||
video.moderation.blurThumbnail = blurThumbnail;
|
||||
if (blurThumbnail) {
|
||||
video.moderation.blurReason = blurReason;
|
||||
} else if (video.moderation.blurReason) {
|
||||
delete video.moderation.blurReason;
|
||||
}
|
||||
|
||||
item.metadata.moderation = metadataModeration;
|
||||
|
||||
if (IS_DEV_MODE) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// js/feedEngine/watchHistoryFeed.js
|
||||
|
||||
import { pointerKey, normalizePointerInput } from "../nostr.js";
|
||||
import { pointerKey, normalizePointerInput } from "../nostr/watchHistory.js";
|
||||
import watchHistoryService from "../watchHistoryService.js";
|
||||
import nostrService from "../services/nostrService.js";
|
||||
import { createWatchHistoryMetadataResolver } from "../watchHistoryMetadata.js";
|
||||
|
||||
+940
-6
File diff suppressed because it is too large.
Load diff
+20
-1
@@ -29,8 +29,11 @@ import {
|
||||
isLockdownMode,
|
||||
} from "./config.js";
|
||||
import AuthService from "./services/authService.js";
|
||||
import getAuthProvider, {
|
||||
providers as authProviders,
|
||||
} from "./services/authProviders/index.js";
|
||||
import { accessControl } from "./accessControl.js";
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import { nostrClient } from "./nostrClientFacade.js";
|
||||
import { userBlocks } from "./userBlocks.js";
|
||||
import { relayManager } from "./relayManager.js";
|
||||
|
||||
@@ -106,6 +109,8 @@ function getLockdownAuthService() {
|
||||
relayManager,
|
||||
logger: devLogger,
|
||||
accessControl,
|
||||
authProviders,
|
||||
getAuthProvider,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -510,6 +515,20 @@ async function bootstrapInterface() {
|
||||
|
||||
devLogger.log("Modals loaded.");
|
||||
|
||||
if (
|
||||
application &&
|
||||
typeof application.initializeLoginModalController === "function"
|
||||
) {
|
||||
try {
|
||||
application.initializeLoginModalController();
|
||||
} catch (error) {
|
||||
devLogger.error(
|
||||
"[Interface] Failed to initialize login modal controller after loading markup:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
[
|
||||
"loginModal",
|
||||
"nostrFormModal",
|
||||
|
||||
+119
-9548
File diff suppressed because it is too large.
Load diff
+5488
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,650 @@
|
||||
import {
|
||||
buildCommentEvent,
|
||||
getNostrEventSchema,
|
||||
NOTE_TYPES,
|
||||
} from "../nostrEventSchemas.js";
|
||||
import { publishEventToRelay } from "../nostrPublish.js";
|
||||
import { RELAY_URLS } from "./toolkit.js";
|
||||
import { normalizePointerInput } from "./watchHistory.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
const COMMENT_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
|
||||
export const COMMENT_EVENT_KIND = Number.isFinite(COMMENT_EVENT_SCHEMA?.kind)
|
||||
? COMMENT_EVENT_SCHEMA.kind
|
||||
: 1;
|
||||
|
||||
function sanitizeRelayList(primary, fallback) {
|
||||
if (Array.isArray(primary) && primary.length) {
|
||||
return primary;
|
||||
}
|
||||
if (Array.isArray(fallback) && fallback.length) {
|
||||
return fallback;
|
||||
}
|
||||
return RELAY_URLS;
|
||||
}
|
||||
|
||||
function normalizeRelay(candidate) {
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
if (Array.isArray(candidate)) {
|
||||
const relayCandidate = candidate[2];
|
||||
if (typeof relayCandidate === "string" && relayCandidate.trim()) {
|
||||
return relayCandidate.trim();
|
||||
}
|
||||
}
|
||||
if (candidate && typeof candidate === "object") {
|
||||
if (typeof candidate.relay === "string" && candidate.relay.trim()) {
|
||||
return candidate.relay.trim();
|
||||
}
|
||||
if (typeof candidate.url === "string" && candidate.url.trim()) {
|
||||
return candidate.url.trim();
|
||||
}
|
||||
if (Array.isArray(candidate.relays)) {
|
||||
for (const relayCandidate of candidate.relays) {
|
||||
if (typeof relayCandidate === "string" && relayCandidate.trim()) {
|
||||
return relayCandidate.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidate.tag) {
|
||||
return normalizeRelay(candidate.tag);
|
||||
}
|
||||
if (candidate.pointer) {
|
||||
return normalizeRelay(candidate.pointer);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizePointerCandidate(candidate, expectedType) {
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(candidate)) {
|
||||
if (
|
||||
candidate[0] === expectedType &&
|
||||
typeof candidate[1] === "string" &&
|
||||
candidate[1].trim()
|
||||
) {
|
||||
return {
|
||||
value: candidate[1].trim(),
|
||||
relay: normalizeRelay(candidate),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof candidate === "string") {
|
||||
const pointer = normalizePointerInput(candidate);
|
||||
if (pointer?.type === expectedType && pointer.value) {
|
||||
return {
|
||||
value: pointer.value.trim(),
|
||||
relay: normalizeRelay(pointer),
|
||||
};
|
||||
}
|
||||
if (expectedType === "e" && candidate.trim() && !candidate.includes(":")) {
|
||||
return { value: candidate.trim(), relay: "" };
|
||||
}
|
||||
if (expectedType === "a" && candidate.trim() && candidate.includes(":")) {
|
||||
return { value: candidate.trim(), relay: "" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidate && typeof candidate === "object") {
|
||||
if (
|
||||
typeof candidate.type === "string" &&
|
||||
candidate.type === expectedType &&
|
||||
typeof candidate.value === "string" &&
|
||||
candidate.value.trim()
|
||||
) {
|
||||
return {
|
||||
value: candidate.value.trim(),
|
||||
relay: normalizeRelay(candidate),
|
||||
};
|
||||
}
|
||||
if (
|
||||
expectedType === "e" &&
|
||||
typeof candidate.id === "string" &&
|
||||
candidate.id.trim()
|
||||
) {
|
||||
return {
|
||||
value: candidate.id.trim(),
|
||||
relay: normalizeRelay(candidate),
|
||||
};
|
||||
}
|
||||
if (
|
||||
expectedType === "a" &&
|
||||
typeof candidate.address === "string" &&
|
||||
candidate.address.trim()
|
||||
) {
|
||||
return {
|
||||
value: candidate.address.trim(),
|
||||
relay: normalizeRelay(candidate),
|
||||
};
|
||||
}
|
||||
if (candidate.tag) {
|
||||
return normalizePointerCandidate(candidate.tag, expectedType);
|
||||
}
|
||||
if (candidate.pointer) {
|
||||
return normalizePointerCandidate(candidate.pointer, expectedType);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickString(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeCommentTarget(targetInput = {}, overrides = {}) {
|
||||
const target = targetInput && typeof targetInput === "object" ? targetInput : {};
|
||||
const options = overrides && typeof overrides === "object" ? overrides : {};
|
||||
|
||||
const videoEventPointer =
|
||||
normalizePointerCandidate(options.videoEventPointer, "e") ||
|
||||
normalizePointerCandidate(options.videoEvent, "e") ||
|
||||
normalizePointerCandidate(target.videoEventPointer, "e") ||
|
||||
normalizePointerCandidate(target.videoEvent, "e") ||
|
||||
normalizePointerCandidate(target.eventPointer, "e") ||
|
||||
normalizePointerCandidate(target.event, "e");
|
||||
|
||||
const videoDefinitionPointer =
|
||||
normalizePointerCandidate(options.videoDefinitionPointer, "a") ||
|
||||
normalizePointerCandidate(options.videoDefinition, "a") ||
|
||||
normalizePointerCandidate(target.videoDefinitionPointer, "a") ||
|
||||
normalizePointerCandidate(target.videoDefinition, "a") ||
|
||||
normalizePointerCandidate(target.definitionPointer, "a") ||
|
||||
normalizePointerCandidate(target.definition, "a");
|
||||
|
||||
const parentCommentPointer =
|
||||
normalizePointerCandidate(options.parentCommentPointer, "e") ||
|
||||
normalizePointerCandidate(options.parentComment, "e") ||
|
||||
normalizePointerCandidate(target.parentCommentPointer, "e") ||
|
||||
normalizePointerCandidate(target.parentComment, "e") ||
|
||||
normalizePointerCandidate(target.parentPointer, "e");
|
||||
|
||||
const videoEventId = pickString(
|
||||
options.videoEventId,
|
||||
target.videoEventId,
|
||||
target.eventId,
|
||||
videoEventPointer?.value,
|
||||
);
|
||||
const videoEventRelay = pickString(
|
||||
options.videoEventRelay,
|
||||
target.videoEventRelay,
|
||||
target.eventRelay,
|
||||
videoEventPointer?.relay,
|
||||
);
|
||||
|
||||
const videoDefinitionAddress = pickString(
|
||||
options.videoDefinitionAddress,
|
||||
target.videoDefinitionAddress,
|
||||
target.definitionAddress,
|
||||
videoDefinitionPointer?.value,
|
||||
);
|
||||
const videoDefinitionRelay = pickString(
|
||||
options.videoDefinitionRelay,
|
||||
target.videoDefinitionRelay,
|
||||
target.definitionRelay,
|
||||
videoDefinitionPointer?.relay,
|
||||
);
|
||||
|
||||
const parentCommentId = pickString(
|
||||
options.parentCommentId,
|
||||
target.parentCommentId,
|
||||
target.parentId,
|
||||
parentCommentPointer?.value,
|
||||
);
|
||||
const parentCommentRelay = pickString(
|
||||
options.parentCommentRelay,
|
||||
target.parentCommentRelay,
|
||||
target.parentRelay,
|
||||
parentCommentPointer?.relay,
|
||||
);
|
||||
|
||||
const threadParticipantPubkey = pickString(
|
||||
options.threadParticipantPubkey,
|
||||
target.threadParticipantPubkey,
|
||||
target.participantPubkey,
|
||||
target.authorPubkey,
|
||||
);
|
||||
const threadParticipantRelay = pickString(
|
||||
options.threadParticipantRelay,
|
||||
target.threadParticipantRelay,
|
||||
target.participantRelay,
|
||||
);
|
||||
|
||||
if (!videoEventId || !videoDefinitionAddress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
videoEventId,
|
||||
videoEventRelay,
|
||||
videoDefinitionAddress,
|
||||
videoDefinitionRelay,
|
||||
parentCommentId,
|
||||
parentCommentRelay,
|
||||
threadParticipantPubkey,
|
||||
threadParticipantRelay,
|
||||
};
|
||||
}
|
||||
|
||||
function createVideoCommentFilters(targetInput, options = {}) {
|
||||
const descriptor = normalizeCommentTarget(targetInput, options);
|
||||
if (!descriptor) {
|
||||
throw new Error("Invalid video comment target supplied.");
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [COMMENT_EVENT_KIND],
|
||||
"#e": [descriptor.videoEventId],
|
||||
"#a": [descriptor.videoDefinitionAddress],
|
||||
};
|
||||
|
||||
if (descriptor.parentCommentId) {
|
||||
if (!filter["#e"].includes(descriptor.parentCommentId)) {
|
||||
filter["#e"].push(descriptor.parentCommentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof options.limit === "number" &&
|
||||
Number.isFinite(options.limit) &&
|
||||
options.limit > 0
|
||||
) {
|
||||
filter.limit = Math.floor(options.limit);
|
||||
}
|
||||
|
||||
if (typeof options.since === "number" && Number.isFinite(options.since)) {
|
||||
filter.since = Math.floor(options.since);
|
||||
}
|
||||
|
||||
if (typeof options.until === "number" && Number.isFinite(options.until)) {
|
||||
filter.until = Math.floor(options.until);
|
||||
}
|
||||
|
||||
return { descriptor, filters: [filter] };
|
||||
}
|
||||
|
||||
function flattenListResults(input) {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const flat = [];
|
||||
for (const chunk of input) {
|
||||
if (Array.isArray(chunk)) {
|
||||
for (const item of chunk) {
|
||||
if (item && typeof item === "object") {
|
||||
flat.push(item);
|
||||
}
|
||||
}
|
||||
} else if (chunk && typeof chunk === "object") {
|
||||
flat.push(chunk);
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
function isVideoCommentEvent(event, descriptor) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number(event.kind) !== COMMENT_EVENT_KIND) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tags = Array.isArray(event.tags) ? event.tags : [];
|
||||
let hasEventTag = false;
|
||||
let hasDefinitionTag = false;
|
||||
let hasParentTag = !descriptor.parentCommentId;
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const [name, value] = tag;
|
||||
if (name === "e" && typeof value === "string") {
|
||||
if (value === descriptor.videoEventId) {
|
||||
hasEventTag = true;
|
||||
}
|
||||
if (descriptor.parentCommentId && value === descriptor.parentCommentId) {
|
||||
hasParentTag = true;
|
||||
}
|
||||
} else if (name === "a" && typeof value === "string") {
|
||||
if (value === descriptor.videoDefinitionAddress) {
|
||||
hasDefinitionTag = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasEventTag && hasDefinitionTag && hasParentTag;
|
||||
}
|
||||
|
||||
export async function publishComment(
|
||||
client,
|
||||
targetInput,
|
||||
options = {},
|
||||
{
|
||||
resolveActiveSigner,
|
||||
shouldRequestExtensionPermissions,
|
||||
signEventWithPrivateKey,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
} = {},
|
||||
) {
|
||||
if (!client?.pool) {
|
||||
return { ok: false, error: "nostr-uninitialized" };
|
||||
}
|
||||
|
||||
const descriptor = normalizeCommentTarget(targetInput, options);
|
||||
if (!descriptor) {
|
||||
return { ok: false, error: "invalid-target" };
|
||||
}
|
||||
|
||||
const actorPubkey = await client.ensureSessionActor();
|
||||
if (!actorPubkey) {
|
||||
return { ok: false, error: "missing-actor" };
|
||||
}
|
||||
|
||||
const createdAt =
|
||||
typeof options.created_at === "number" && options.created_at > 0
|
||||
? Math.floor(options.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const additionalTags = Array.isArray(options.additionalTags)
|
||||
? options.additionalTags.filter(
|
||||
(tag) => Array.isArray(tag) && typeof tag[0] === "string",
|
||||
)
|
||||
: [];
|
||||
|
||||
let content = "";
|
||||
if (typeof options.content === "string") {
|
||||
content = options.content;
|
||||
} else if (
|
||||
options.content &&
|
||||
typeof options.content === "object" &&
|
||||
!Array.isArray(options.content)
|
||||
) {
|
||||
try {
|
||||
content = JSON.stringify(options.content);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to serialize comment content:", error);
|
||||
content = "";
|
||||
}
|
||||
} else if (options.content !== undefined && options.content !== null) {
|
||||
content = String(options.content);
|
||||
}
|
||||
|
||||
const event = buildCommentEvent({
|
||||
pubkey: actorPubkey,
|
||||
created_at: createdAt,
|
||||
videoEventId: descriptor.videoEventId,
|
||||
videoEventRelay: descriptor.videoEventRelay,
|
||||
videoDefinitionAddress: descriptor.videoDefinitionAddress,
|
||||
videoDefinitionRelay: descriptor.videoDefinitionRelay,
|
||||
parentCommentId: descriptor.parentCommentId,
|
||||
parentCommentRelay: descriptor.parentCommentRelay,
|
||||
threadParticipantPubkey: descriptor.threadParticipantPubkey,
|
||||
threadParticipantRelay: descriptor.threadParticipantRelay,
|
||||
additionalTags,
|
||||
content,
|
||||
});
|
||||
|
||||
let signedEvent = null;
|
||||
|
||||
const normalizedActor =
|
||||
typeof actorPubkey === "string" ? actorPubkey.toLowerCase() : "";
|
||||
const normalizedLogged =
|
||||
typeof client.pubkey === "string" ? client.pubkey.toLowerCase() : "";
|
||||
|
||||
const resolveSignerFn =
|
||||
typeof resolveActiveSigner === "function" ? resolveActiveSigner : null;
|
||||
const signer = resolveSignerFn ? resolveSignerFn(actorPubkey) : null;
|
||||
|
||||
const canUseActiveSigner =
|
||||
normalizedActor &&
|
||||
normalizedActor === normalizedLogged &&
|
||||
signer &&
|
||||
typeof signer.signEvent === "function";
|
||||
|
||||
if (canUseActiveSigner) {
|
||||
let permissionResult = { ok: true };
|
||||
const shouldRequestPermissions =
|
||||
typeof shouldRequestExtensionPermissions === "function"
|
||||
? shouldRequestExtensionPermissions(signer)
|
||||
: false;
|
||||
|
||||
if (shouldRequestPermissions) {
|
||||
permissionResult = await client.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
}
|
||||
|
||||
if (permissionResult.ok) {
|
||||
try {
|
||||
signedEvent = await signer.signEvent(event);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostr] Failed to sign comment event with active signer:",
|
||||
error,
|
||||
);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
} else {
|
||||
userLogger.warn(
|
||||
"[nostr] Active signer permissions missing; signing comment with session key.",
|
||||
permissionResult.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!signedEvent) {
|
||||
if (typeof signEventWithPrivateKey !== "function") {
|
||||
return { ok: false, error: "signing-unavailable" };
|
||||
}
|
||||
try {
|
||||
if (!client.sessionActor || client.sessionActor.pubkey !== actorPubkey) {
|
||||
await client.ensureSessionActor(true);
|
||||
}
|
||||
if (!client.sessionActor || client.sessionActor.pubkey !== actorPubkey) {
|
||||
throw new Error("session-actor-mismatch");
|
||||
}
|
||||
signedEvent = signEventWithPrivateKey(
|
||||
event,
|
||||
client.sessionActor.privateKey,
|
||||
);
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostr] Failed to sign comment event with session key:", error);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(options.relays, client.relays);
|
||||
|
||||
const publishResults = await Promise.all(
|
||||
relayList.map((url) => publishEventToRelay(client.pool, url, signedEvent)),
|
||||
);
|
||||
|
||||
const acceptedRelays = publishResults
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.url)
|
||||
.filter((url) => typeof url === "string" && url);
|
||||
|
||||
const success = acceptedRelays.length > 0;
|
||||
|
||||
if (success) {
|
||||
devLogger.info(
|
||||
`[nostr] Comment event accepted by ${acceptedRelays.length} relay(s):`,
|
||||
acceptedRelays.join(", "),
|
||||
);
|
||||
} else {
|
||||
userLogger.warn("[nostr] Comment event rejected by relays:", publishResults);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: success,
|
||||
event: signedEvent,
|
||||
results: publishResults,
|
||||
acceptedRelays,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listVideoComments(client, targetInput, options = {}) {
|
||||
const pool = client?.pool;
|
||||
const canQueryPool = pool && typeof pool.list === "function";
|
||||
|
||||
if (!canQueryPool) {
|
||||
devLogger.warn("[nostr] Unable to list video comments: pool missing.");
|
||||
return [];
|
||||
}
|
||||
|
||||
let descriptor;
|
||||
let filters;
|
||||
try {
|
||||
({ descriptor, filters } = createVideoCommentFilters(targetInput, options));
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to build comment filters:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(options.relays, client.relays);
|
||||
|
||||
let rawResults;
|
||||
try {
|
||||
rawResults = await pool.list(relayList, filters);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to list video comments:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const flattened = flattenListResults(rawResults);
|
||||
const dedupe = new Map();
|
||||
const order = [];
|
||||
|
||||
for (const event of flattened) {
|
||||
if (!isVideoCommentEvent(event, descriptor)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventId = typeof event.id === "string" ? event.id : null;
|
||||
if (!eventId) {
|
||||
order.push({ type: "raw", event });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = dedupe.get(eventId);
|
||||
if (!existing) {
|
||||
dedupe.set(eventId, event);
|
||||
order.push({ type: "id", key: eventId });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingCreated = Number.isFinite(existing?.created_at)
|
||||
? existing.created_at
|
||||
: 0;
|
||||
const incomingCreated = Number.isFinite(event.created_at)
|
||||
? event.created_at
|
||||
: 0;
|
||||
if (incomingCreated > existingCreated) {
|
||||
dedupe.set(eventId, event);
|
||||
}
|
||||
}
|
||||
|
||||
return order
|
||||
.map((entry) => {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (entry.type === "raw") {
|
||||
return entry.event || null;
|
||||
}
|
||||
if (entry.type === "id") {
|
||||
return dedupe.get(entry.key) || null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function subscribeVideoComments(client, targetInput, options = {}) {
|
||||
const pool = client?.pool;
|
||||
const canSubscribe = pool && typeof pool.sub === "function";
|
||||
|
||||
if (!canSubscribe) {
|
||||
devLogger.warn("[nostr] Unable to subscribe to video comments: pool missing.");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let descriptor;
|
||||
let filters;
|
||||
try {
|
||||
({ descriptor, filters } = createVideoCommentFilters(targetInput, options));
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to build comment subscription filters:", error);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(options.relays, client.relays);
|
||||
const onEvent = typeof options.onEvent === "function" ? options.onEvent : null;
|
||||
|
||||
let subscription;
|
||||
try {
|
||||
subscription = pool.sub(relayList, filters);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to open video comment subscription:", error);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (onEvent && subscription && typeof subscription.on === "function") {
|
||||
try {
|
||||
subscription.on("event", (event) => {
|
||||
if (isVideoCommentEvent(event, descriptor)) {
|
||||
try {
|
||||
onEvent(event);
|
||||
} catch (handlerError) {
|
||||
devLogger.warn("[nostr] Comment subscription handler threw:", handlerError);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to attach comment subscription handler:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const originalUnsub =
|
||||
subscription && typeof subscription.unsub === "function"
|
||||
? subscription.unsub.bind(subscription)
|
||||
: null;
|
||||
|
||||
let unsubscribed = false;
|
||||
return () => {
|
||||
if (unsubscribed) {
|
||||
return;
|
||||
}
|
||||
unsubscribed = true;
|
||||
if (originalUnsub) {
|
||||
try {
|
||||
originalUnsub();
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to unsubscribe from video comments:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const __testExports = {
|
||||
normalizeCommentTarget,
|
||||
createVideoCommentFilters,
|
||||
isVideoCommentEvent,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// js/nostr/defaultClient.js
|
||||
|
||||
import { NostrClient } from "./client.js";
|
||||
import {
|
||||
DEFAULT_NIP07_ENCRYPTION_METHODS,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
} from "./nip07Permissions.js";
|
||||
import {
|
||||
registerNostrClient,
|
||||
requestDefaultExtensionPermissions as requestRegisteredPermissions,
|
||||
} from "../nostrClientRegistry.js";
|
||||
|
||||
export const nostrClient = new NostrClient();
|
||||
|
||||
registerNostrClient(nostrClient, {
|
||||
requestPermissions: (
|
||||
methods = DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
) => nostrClient.ensureExtensionPermissions(methods),
|
||||
});
|
||||
|
||||
export function requestDefaultExtensionPermissions(
|
||||
methods = DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
) {
|
||||
return requestRegisteredPermissions(methods);
|
||||
}
|
||||
|
||||
export {
|
||||
NostrClient,
|
||||
DEFAULT_NIP07_ENCRYPTION_METHODS,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
export {
|
||||
__testExports,
|
||||
NostrClient,
|
||||
getActiveSigner,
|
||||
setActiveSigner,
|
||||
clearActiveSigner,
|
||||
resolveActiveSigner,
|
||||
shouldRequestExtensionPermissions,
|
||||
} from "./client.js";
|
||||
|
||||
export {
|
||||
DEFAULT_NIP07_ENCRYPTION_METHODS,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
} from "./nip07Permissions.js";
|
||||
|
||||
export {
|
||||
buildNip71MetadataTags,
|
||||
buildNip71VideoEvent,
|
||||
collectNip71PointerRequests,
|
||||
convertEventToVideo,
|
||||
extractNip71MetadataFromTags,
|
||||
getDTagValueFromTags,
|
||||
mergeNip71MetadataIntoVideo,
|
||||
populateNip71MetadataForVideos,
|
||||
processNip71Events,
|
||||
buildVideoPointerValue,
|
||||
stringFromInput,
|
||||
} from "./nip71.js";
|
||||
|
||||
export {
|
||||
normalizePointerInput,
|
||||
pointerKey,
|
||||
chunkWatchHistoryPayloadItems,
|
||||
normalizeActorKey,
|
||||
parseWatchHistoryContentWithFallback,
|
||||
isNip04EncryptedWatchHistoryEvent,
|
||||
watchHistoryHelpers,
|
||||
} from "./watchHistory.js";
|
||||
|
||||
export {
|
||||
updateWatchHistoryList,
|
||||
removeWatchHistoryItem,
|
||||
} from "./watchHistoryBindings.js";
|
||||
|
||||
export {
|
||||
deriveViewEventBucketIndex,
|
||||
deriveViewEventPointerScope,
|
||||
hasRecentViewPublish,
|
||||
rememberViewPublish,
|
||||
createVideoViewEventFilters,
|
||||
getViewEventGuardWindowMs,
|
||||
listVideoViewEvents,
|
||||
subscribeVideoViewEvents,
|
||||
countVideoViewEvents,
|
||||
publishViewEvent,
|
||||
recordVideoView,
|
||||
} from "./viewEvents.js";
|
||||
|
||||
export { publishVideoReaction } from "./reactionEvents.js";
|
||||
export {
|
||||
COMMENT_EVENT_KIND,
|
||||
publishComment,
|
||||
listVideoComments,
|
||||
subscribeVideoComments,
|
||||
} from "./commentEvents.js";
|
||||
@@ -0,0 +1,312 @@
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
export const NIP07_LOGIN_TIMEOUT_MS = 60_000; // 60 seconds
|
||||
export const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE =
|
||||
"Timed out waiting for the NIP-07 extension. Confirm the extension prompt in your browser toolbar and try again.";
|
||||
const NIP07_PERMISSIONS_STORAGE_KEY = "bitvid:nip07:permissions";
|
||||
|
||||
// Give the NIP-07 extension enough time to surface its approval prompt and let
|
||||
// users unlock/authorize it. Seven seconds proved too aggressive once vendors
|
||||
// started requiring an unlock step, so we extend the window substantially while
|
||||
// still allowing manual overrides via __BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__.
|
||||
const DEFAULT_ENABLE_VARIANT_TIMEOUT_MS = 45_000;
|
||||
|
||||
export const DEFAULT_NIP07_ENCRYPTION_METHODS = Object.freeze([
|
||||
// Encryption helpers — request both legacy NIP-04 and modern NIP-44 upfront
|
||||
"nip04.encrypt",
|
||||
"nip04.decrypt",
|
||||
"nip44.encrypt",
|
||||
"nip44.decrypt",
|
||||
"nip44.v2.encrypt",
|
||||
"nip44.v2.decrypt",
|
||||
]);
|
||||
|
||||
export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([
|
||||
// Core auth + relay metadata
|
||||
"get_public_key",
|
||||
"sign_event",
|
||||
"read_relays",
|
||||
"write_relays",
|
||||
...DEFAULT_NIP07_ENCRYPTION_METHODS,
|
||||
]);
|
||||
|
||||
export function getEnableVariantTimeoutMs() {
|
||||
const overrideValue =
|
||||
typeof globalThis !== "undefined" &&
|
||||
globalThis !== null &&
|
||||
Number.isFinite(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__)
|
||||
? Math.floor(globalThis.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__)
|
||||
: null;
|
||||
|
||||
if (overrideValue !== null && overrideValue > 0) {
|
||||
return Math.max(50, overrideValue);
|
||||
}
|
||||
|
||||
return DEFAULT_ENABLE_VARIANT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
export function normalizePermissionMethod(method) {
|
||||
return typeof method === "string" && method.trim() ? method.trim() : "";
|
||||
}
|
||||
|
||||
function getNip07PermissionStorage() {
|
||||
const scope =
|
||||
typeof globalThis !== "undefined" && globalThis ? globalThis : undefined;
|
||||
const browserWindow =
|
||||
typeof window !== "undefined" && window ? window : undefined;
|
||||
|
||||
if (browserWindow?.localStorage) {
|
||||
return browserWindow.localStorage;
|
||||
}
|
||||
|
||||
if (scope?.localStorage) {
|
||||
return scope.localStorage;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readStoredNip07Permissions() {
|
||||
const storage = getNip07PermissionStorage();
|
||||
if (!storage) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
let rawValue = null;
|
||||
try {
|
||||
rawValue = storage.getItem(NIP07_PERMISSIONS_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
if (!rawValue) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
const storedMethods = Array.isArray(parsed)
|
||||
? parsed
|
||||
: Array.isArray(parsed?.grantedMethods)
|
||||
? parsed.grantedMethods
|
||||
: Array.isArray(parsed?.methods)
|
||||
? parsed.methods
|
||||
: [];
|
||||
return new Set(
|
||||
storedMethods
|
||||
.map((method) => normalizePermissionMethod(method))
|
||||
.filter(Boolean),
|
||||
);
|
||||
} catch (error) {
|
||||
clearStoredNip07Permissions();
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredNip07Permissions(methods) {
|
||||
const storage = getNip07PermissionStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = Array.from(
|
||||
new Set(
|
||||
Array.from(methods || [])
|
||||
.map((method) => normalizePermissionMethod(method))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
if (!normalized.length) {
|
||||
storage.removeItem(NIP07_PERMISSIONS_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
storage.setItem(
|
||||
NIP07_PERMISSIONS_STORAGE_KEY,
|
||||
JSON.stringify({ grantedMethods: normalized }),
|
||||
);
|
||||
} catch (error) {
|
||||
// ignore persistence failures
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredNip07Permissions() {
|
||||
const storage = getNip07PermissionStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
storage.removeItem(NIP07_PERMISSIONS_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
// ignore cleanup issues
|
||||
}
|
||||
}
|
||||
|
||||
export function withNip07Timeout(
|
||||
operation,
|
||||
{
|
||||
timeoutMs = NIP07_LOGIN_TIMEOUT_MS,
|
||||
message = NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
|
||||
} = {},
|
||||
) {
|
||||
const numericTimeout = Number(timeoutMs);
|
||||
const effectiveTimeout =
|
||||
Number.isFinite(numericTimeout) && numericTimeout > 0
|
||||
? numericTimeout
|
||||
: NIP07_LOGIN_TIMEOUT_MS;
|
||||
|
||||
let timeoutId;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(message));
|
||||
}, effectiveTimeout);
|
||||
});
|
||||
|
||||
let operationResult;
|
||||
try {
|
||||
operationResult = operation();
|
||||
} catch (err) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const operationPromise = Promise.resolve(operationResult);
|
||||
|
||||
return Promise.race([operationPromise, timeoutPromise]).finally(() => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function runNip07WithRetry(
|
||||
operation,
|
||||
{
|
||||
label = "NIP-07 operation",
|
||||
timeoutMs = NIP07_LOGIN_TIMEOUT_MS,
|
||||
retryMultiplier = 2,
|
||||
} = {},
|
||||
) {
|
||||
let hasStarted = false;
|
||||
let cachedPromise = null;
|
||||
|
||||
const getOrStartOperation = () => {
|
||||
if (!hasStarted) {
|
||||
hasStarted = true;
|
||||
try {
|
||||
cachedPromise = Promise.resolve(operation());
|
||||
} catch (error) {
|
||||
hasStarted = false;
|
||||
cachedPromise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return cachedPromise;
|
||||
};
|
||||
|
||||
try {
|
||||
return await withNip07Timeout(getOrStartOperation, {
|
||||
timeoutMs,
|
||||
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
|
||||
});
|
||||
} catch (error) {
|
||||
const isTimeoutError =
|
||||
error instanceof Error &&
|
||||
error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE;
|
||||
|
||||
if (!isTimeoutError || retryMultiplier <= 1) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const extendedTimeout = Math.max(
|
||||
timeoutMs,
|
||||
Math.round(timeoutMs * retryMultiplier),
|
||||
);
|
||||
|
||||
devLogger.warn(
|
||||
`[nostr] ${label} taking longer than ${timeoutMs}ms. Waiting up to ${extendedTimeout}ms for extension response.`,
|
||||
);
|
||||
|
||||
return withNip07Timeout(getOrStartOperation, {
|
||||
timeoutMs: extendedTimeout,
|
||||
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestEnablePermissions(
|
||||
extension,
|
||||
outstandingMethods,
|
||||
{ isDevMode = false } = {},
|
||||
) {
|
||||
if (!extension) {
|
||||
return { ok: false, error: new Error("extension-unavailable") };
|
||||
}
|
||||
|
||||
if (typeof extension.enable !== "function") {
|
||||
return { ok: true, code: "enable-unavailable" };
|
||||
}
|
||||
|
||||
const normalized = Array.isArray(outstandingMethods)
|
||||
? outstandingMethods
|
||||
.map((method) => normalizePermissionMethod(method))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const permissionVariants = [];
|
||||
if (normalized.length) {
|
||||
permissionVariants.push({
|
||||
permissions: normalized.map((method) => ({ method })),
|
||||
});
|
||||
permissionVariants.push({ permissions: normalized });
|
||||
}
|
||||
permissionVariants.push(null);
|
||||
|
||||
let lastError = null;
|
||||
for (const options of permissionVariants) {
|
||||
const variantTimeoutOverrides = options
|
||||
? {
|
||||
timeoutMs: Math.min(NIP07_LOGIN_TIMEOUT_MS, getEnableVariantTimeoutMs()),
|
||||
retryMultiplier: 1,
|
||||
}
|
||||
: { retryMultiplier: 1 };
|
||||
|
||||
try {
|
||||
await runNip07WithRetry(
|
||||
() => (options ? extension.enable(options) : extension.enable()),
|
||||
{ label: "extension.enable", ...variantTimeoutOverrides },
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (options && isDevMode) {
|
||||
userLogger.warn(
|
||||
"[nostr] extension.enable request with explicit permissions failed:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: lastError || new Error("permission-denied"),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testExports = {
|
||||
requestEnablePermissions,
|
||||
runNip07WithRetry,
|
||||
withNip07Timeout,
|
||||
getEnableVariantTimeoutMs,
|
||||
readStoredNip07Permissions,
|
||||
writeStoredNip07Permissions,
|
||||
clearStoredNip07Permissions,
|
||||
normalizePermissionMethod,
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
+1320
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,195 @@
|
||||
import { buildReactionEvent } from "../nostrEventSchemas.js";
|
||||
import { publishEventToRelay } from "../nostrPublish.js";
|
||||
import { RELAY_URLS } from "./toolkit.js";
|
||||
import { normalizePointerInput } from "./watchHistory.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
function sanitizeRelayList(primary, fallback) {
|
||||
if (Array.isArray(primary) && primary.length) {
|
||||
return primary;
|
||||
}
|
||||
if (Array.isArray(fallback) && fallback.length) {
|
||||
return fallback;
|
||||
}
|
||||
return RELAY_URLS;
|
||||
}
|
||||
|
||||
export async function publishVideoReaction(
|
||||
client,
|
||||
pointerInput,
|
||||
options = {},
|
||||
{
|
||||
resolveActiveSigner,
|
||||
shouldRequestExtensionPermissions,
|
||||
signEventWithPrivateKey,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
}
|
||||
) {
|
||||
if (!client?.pool) {
|
||||
return { ok: false, error: "nostr-uninitialized" };
|
||||
}
|
||||
|
||||
const pointer = normalizePointerInput(pointerInput);
|
||||
if (!pointer) {
|
||||
return { ok: false, error: "invalid-pointer" };
|
||||
}
|
||||
|
||||
const actorPubkey = await client.ensureSessionActor();
|
||||
if (!actorPubkey) {
|
||||
return { ok: false, error: "missing-actor" };
|
||||
}
|
||||
|
||||
const createdAt =
|
||||
typeof options.created_at === "number" && options.created_at > 0
|
||||
? Math.floor(options.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const additionalTags = Array.isArray(options.additionalTags)
|
||||
? options.additionalTags.filter(
|
||||
(tag) => Array.isArray(tag) && typeof tag[0] === "string"
|
||||
)
|
||||
: [];
|
||||
|
||||
const optionRelay =
|
||||
typeof options.pointerRelay === "string" && options.pointerRelay.trim()
|
||||
? options.pointerRelay.trim()
|
||||
: "";
|
||||
|
||||
const pointerRelay =
|
||||
typeof pointer.relay === "string" && pointer.relay.trim()
|
||||
? pointer.relay.trim()
|
||||
: optionRelay;
|
||||
|
||||
const pointerTag =
|
||||
pointer.type === "a"
|
||||
? pointerRelay
|
||||
? ["a", pointer.value, pointerRelay]
|
||||
: ["a", pointer.value]
|
||||
: pointerRelay
|
||||
? ["e", pointer.value, pointerRelay]
|
||||
: ["e", pointer.value];
|
||||
|
||||
const normalizedPointer = {
|
||||
type: pointer.type,
|
||||
value: pointer.value,
|
||||
relay: pointerRelay || null,
|
||||
};
|
||||
|
||||
const explicitTargetAuthor =
|
||||
typeof options.targetAuthorPubkey === "string" && options.targetAuthorPubkey.trim()
|
||||
? options.targetAuthorPubkey.trim()
|
||||
: typeof options.authorPubkey === "string" && options.authorPubkey.trim()
|
||||
? options.authorPubkey.trim()
|
||||
: "";
|
||||
|
||||
let content = "+";
|
||||
if (typeof options.content === "string") {
|
||||
content = options.content;
|
||||
} else if (
|
||||
options.content &&
|
||||
typeof options.content === "object" &&
|
||||
!Array.isArray(options.content)
|
||||
) {
|
||||
try {
|
||||
content = JSON.stringify(options.content);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to serialize custom reaction content:", error);
|
||||
content = "+";
|
||||
}
|
||||
} else if (options.content !== undefined && options.content !== null) {
|
||||
content = String(options.content);
|
||||
}
|
||||
|
||||
const event = buildReactionEvent({
|
||||
pubkey: actorPubkey,
|
||||
created_at: createdAt,
|
||||
pointerValue: pointer.value,
|
||||
pointerTag,
|
||||
targetPointer: normalizedPointer,
|
||||
targetAuthorPubkey: explicitTargetAuthor,
|
||||
additionalTags,
|
||||
content,
|
||||
});
|
||||
|
||||
let signedEvent = null;
|
||||
|
||||
const normalizedActor =
|
||||
typeof actorPubkey === "string" ? actorPubkey.toLowerCase() : "";
|
||||
const normalizedLogged =
|
||||
typeof client.pubkey === "string" ? client.pubkey.toLowerCase() : "";
|
||||
const signer = resolveActiveSigner(actorPubkey);
|
||||
const canUseActiveSigner =
|
||||
normalizedActor &&
|
||||
normalizedActor === normalizedLogged &&
|
||||
signer &&
|
||||
typeof signer.signEvent === "function";
|
||||
|
||||
if (canUseActiveSigner) {
|
||||
let permissionResult = { ok: true };
|
||||
if (shouldRequestExtensionPermissions(signer)) {
|
||||
permissionResult = await client.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS
|
||||
);
|
||||
}
|
||||
if (permissionResult.ok) {
|
||||
try {
|
||||
signedEvent = await signer.signEvent(event);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostr] Failed to sign reaction event with active signer:",
|
||||
error,
|
||||
);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
} else {
|
||||
userLogger.warn(
|
||||
"[nostr] Active signer permissions missing; signing reaction with session key.",
|
||||
permissionResult.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!signedEvent) {
|
||||
try {
|
||||
if (!client.sessionActor || client.sessionActor.pubkey !== actorPubkey) {
|
||||
await client.ensureSessionActor(true);
|
||||
}
|
||||
if (!client.sessionActor || client.sessionActor.pubkey !== actorPubkey) {
|
||||
throw new Error("session-actor-mismatch");
|
||||
}
|
||||
const privateKey = client.sessionActor.privateKey;
|
||||
signedEvent = signEventWithPrivateKey(event, privateKey);
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostr] Failed to sign reaction event with session key:", error);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(options.relays, client.relays);
|
||||
|
||||
const publishResults = await Promise.all(
|
||||
relayList.map((url) => publishEventToRelay(client.pool, url, signedEvent))
|
||||
);
|
||||
|
||||
const acceptedRelays = publishResults
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.url)
|
||||
.filter((url) => typeof url === "string" && url);
|
||||
const success = acceptedRelays.length > 0;
|
||||
|
||||
if (success) {
|
||||
devLogger.info(
|
||||
`[nostr] Reaction event accepted by ${acceptedRelays.length} relay(s):`,
|
||||
acceptedRelays.join(", ")
|
||||
);
|
||||
} else {
|
||||
userLogger.warn("[nostr] Reaction event rejected by relays:", publishResults);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: success,
|
||||
event: signedEvent,
|
||||
results: publishResults,
|
||||
acceptedRelays,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
export const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1";
|
||||
export const SESSION_ACTOR_ENCRYPTION_VERSION = 1;
|
||||
export const SESSION_ACTOR_KDF_ITERATIONS = 250_000;
|
||||
export const SESSION_ACTOR_KDF_HASH = "SHA-256";
|
||||
export const SESSION_ACTOR_ENCRYPTION_ALGORITHM = "AES-GCM";
|
||||
export const SESSION_ACTOR_SALT_BYTES = 16;
|
||||
export const SESSION_ACTOR_IV_BYTES = 12;
|
||||
|
||||
function arrayBufferToBase64(buffer) {
|
||||
if (!buffer) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let view;
|
||||
if (buffer instanceof ArrayBuffer) {
|
||||
view = new Uint8Array(buffer);
|
||||
} else if (ArrayBuffer.isView(buffer)) {
|
||||
view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof globalThis?.btoa === "function") {
|
||||
let binary = "";
|
||||
for (let index = 0; index < view.length; index += 1) {
|
||||
binary += String.fromCharCode(view[index]);
|
||||
}
|
||||
return globalThis.btoa(binary);
|
||||
}
|
||||
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Buffer.from(view).toString("base64");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function base64ToUint8Array(base64) {
|
||||
if (typeof base64 !== "string" || !base64.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let binary;
|
||||
try {
|
||||
if (typeof globalThis?.atob === "function") {
|
||||
binary = globalThis.atob(base64);
|
||||
} else if (typeof Buffer !== "undefined") {
|
||||
binary = Buffer.from(base64, "base64").toString("binary");
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function generateRandomBytes(length) {
|
||||
const size = Number.isFinite(length) ? Math.max(0, Math.floor(length)) : 0;
|
||||
if (size <= 0) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
if (globalThis?.crypto?.getRandomValues) {
|
||||
const array = new Uint8Array(size);
|
||||
globalThis.crypto.getRandomValues(array);
|
||||
return array;
|
||||
}
|
||||
|
||||
throw new Error("secure-random-unavailable");
|
||||
}
|
||||
|
||||
function isSubtleCryptoAvailable() {
|
||||
return !!(
|
||||
globalThis?.crypto?.subtle &&
|
||||
typeof globalThis.crypto.subtle.importKey === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function deriveSessionEncryptionKey(passphrase, saltBytes, iterations, hash) {
|
||||
if (!isSubtleCryptoAvailable()) {
|
||||
throw new Error("webcrypto-unavailable");
|
||||
}
|
||||
|
||||
if (!(saltBytes instanceof Uint8Array)) {
|
||||
throw new Error("invalid-salt");
|
||||
}
|
||||
|
||||
const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null;
|
||||
if (!encoder) {
|
||||
throw new Error("text-encoder-unavailable");
|
||||
}
|
||||
|
||||
const passphraseBytes = encoder.encode(passphrase);
|
||||
|
||||
const baseKey = await globalThis.crypto.subtle.importKey(
|
||||
"raw",
|
||||
passphraseBytes,
|
||||
{ name: "PBKDF2" },
|
||||
false,
|
||||
["deriveKey"],
|
||||
);
|
||||
|
||||
const normalizedIterations = Number.isFinite(iterations)
|
||||
? Math.max(1, Math.floor(iterations))
|
||||
: SESSION_ACTOR_KDF_ITERATIONS;
|
||||
const normalizedHash = typeof hash === "string" && hash.trim()
|
||||
? hash.trim()
|
||||
: SESSION_ACTOR_KDF_HASH;
|
||||
|
||||
return globalThis.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: saltBytes,
|
||||
iterations: normalizedIterations,
|
||||
hash: normalizedHash,
|
||||
},
|
||||
baseKey,
|
||||
{ name: SESSION_ACTOR_ENCRYPTION_ALGORITHM, length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptSessionPrivateKey(privateKey, passphrase) {
|
||||
if (typeof privateKey !== "string" || !privateKey.trim()) {
|
||||
throw new Error("invalid-private-key");
|
||||
}
|
||||
|
||||
if (typeof passphrase !== "string" || !passphrase.trim()) {
|
||||
throw new Error("passphrase-required");
|
||||
}
|
||||
|
||||
if (!isSubtleCryptoAvailable()) {
|
||||
throw new Error("webcrypto-unavailable");
|
||||
}
|
||||
|
||||
const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null;
|
||||
if (!encoder) {
|
||||
throw new Error("text-encoder-unavailable");
|
||||
}
|
||||
|
||||
const payload = encoder.encode(privateKey);
|
||||
const salt = generateRandomBytes(SESSION_ACTOR_SALT_BYTES);
|
||||
const iv = generateRandomBytes(SESSION_ACTOR_IV_BYTES);
|
||||
const key = await deriveSessionEncryptionKey(
|
||||
passphrase,
|
||||
salt,
|
||||
SESSION_ACTOR_KDF_ITERATIONS,
|
||||
SESSION_ACTOR_KDF_HASH,
|
||||
);
|
||||
|
||||
const ciphertext = await globalThis.crypto.subtle.encrypt(
|
||||
{ name: SESSION_ACTOR_ENCRYPTION_ALGORITHM, iv },
|
||||
key,
|
||||
payload,
|
||||
);
|
||||
|
||||
return {
|
||||
ciphertext: arrayBufferToBase64(ciphertext),
|
||||
salt: arrayBufferToBase64(salt),
|
||||
iv: arrayBufferToBase64(iv),
|
||||
iterations: SESSION_ACTOR_KDF_ITERATIONS,
|
||||
hash: SESSION_ACTOR_KDF_HASH,
|
||||
algorithm: SESSION_ACTOR_ENCRYPTION_ALGORITHM,
|
||||
version: SESSION_ACTOR_ENCRYPTION_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStoredEncryptionMetadata(metadata) {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const salt = typeof metadata.salt === "string" ? metadata.salt.trim() : "";
|
||||
const iv = typeof metadata.iv === "string" ? metadata.iv.trim() : "";
|
||||
if (!salt || !iv) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iterations = Number.isFinite(metadata.iterations)
|
||||
? Math.max(1, Math.floor(metadata.iterations))
|
||||
: SESSION_ACTOR_KDF_ITERATIONS;
|
||||
const version = Number.isFinite(metadata.version)
|
||||
? Math.max(1, Math.floor(metadata.version))
|
||||
: SESSION_ACTOR_ENCRYPTION_VERSION;
|
||||
const algorithm =
|
||||
typeof metadata.algorithm === "string" && metadata.algorithm.trim()
|
||||
? metadata.algorithm.trim()
|
||||
: SESSION_ACTOR_ENCRYPTION_ALGORITHM;
|
||||
const hash =
|
||||
typeof metadata.hash === "string" && metadata.hash.trim()
|
||||
? metadata.hash.trim()
|
||||
: SESSION_ACTOR_KDF_HASH;
|
||||
|
||||
return { version, algorithm, salt, iv, iterations, hash };
|
||||
}
|
||||
|
||||
export async function decryptSessionPrivateKey(payload, passphrase) {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("encrypted-session-invalid");
|
||||
}
|
||||
|
||||
if (typeof passphrase !== "string" || !passphrase.trim()) {
|
||||
throw new Error("passphrase-required");
|
||||
}
|
||||
|
||||
if (!isSubtleCryptoAvailable()) {
|
||||
throw new Error("webcrypto-unavailable");
|
||||
}
|
||||
|
||||
const ciphertext =
|
||||
typeof payload.privateKeyEncrypted === "string"
|
||||
? payload.privateKeyEncrypted.trim()
|
||||
: "";
|
||||
const encryption = normalizeStoredEncryptionMetadata(payload.encryption);
|
||||
|
||||
if (!ciphertext || !encryption) {
|
||||
throw new Error("encrypted-session-invalid");
|
||||
}
|
||||
|
||||
const cipherBytes = base64ToUint8Array(ciphertext);
|
||||
const saltBytes = base64ToUint8Array(encryption.salt);
|
||||
const ivBytes = base64ToUint8Array(encryption.iv);
|
||||
if (!cipherBytes || !saltBytes || !ivBytes) {
|
||||
throw new Error("encrypted-session-invalid");
|
||||
}
|
||||
|
||||
const key = await deriveSessionEncryptionKey(
|
||||
passphrase,
|
||||
saltBytes,
|
||||
encryption.iterations,
|
||||
encryption.hash,
|
||||
);
|
||||
|
||||
let decrypted;
|
||||
try {
|
||||
decrypted = await globalThis.crypto.subtle.decrypt(
|
||||
{ name: encryption.algorithm || SESSION_ACTOR_ENCRYPTION_ALGORITHM, iv: ivBytes },
|
||||
key,
|
||||
cipherBytes,
|
||||
);
|
||||
} catch (error) {
|
||||
const failure = new Error("Failed to decrypt the stored private key.");
|
||||
failure.code = "decrypt-failed";
|
||||
failure.cause = error;
|
||||
throw failure;
|
||||
}
|
||||
|
||||
const decoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||
if (!decoder) {
|
||||
throw new Error("text-decoder-unavailable");
|
||||
}
|
||||
|
||||
return decoder.decode(decrypted);
|
||||
}
|
||||
|
||||
export function readStoredSessionActorEntry() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let raw = null;
|
||||
try {
|
||||
raw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to read session actor from storage:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const pubkey =
|
||||
typeof parsed?.pubkey === "string" ? parsed.pubkey.trim() : "";
|
||||
const privateKey =
|
||||
typeof parsed?.privateKey === "string" ? parsed.privateKey.trim() : "";
|
||||
const privateKeyEncrypted =
|
||||
typeof parsed?.privateKeyEncrypted === "string"
|
||||
? parsed.privateKeyEncrypted.trim()
|
||||
: "";
|
||||
const encryption = normalizeStoredEncryptionMetadata(parsed?.encryption);
|
||||
const createdAt = Number.isFinite(parsed?.createdAt)
|
||||
? parsed.createdAt
|
||||
: Date.now();
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
privateKey,
|
||||
privateKeyEncrypted,
|
||||
encryption,
|
||||
createdAt,
|
||||
};
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to parse stored session actor:", error);
|
||||
try {
|
||||
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
|
||||
} catch (cleanupError) {
|
||||
devLogger.warn(
|
||||
"[nostr] Failed to clear corrupt session actor entry:",
|
||||
cleanupError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function persistSessionActor(actor) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!actor ||
|
||||
typeof actor.pubkey !== "string" ||
|
||||
!actor.pubkey ||
|
||||
((typeof actor.privateKey !== "string" || !actor.privateKey) &&
|
||||
(typeof actor.privateKeyEncrypted !== "string" || !actor.privateKeyEncrypted))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createdAt = Number.isFinite(actor.createdAt)
|
||||
? actor.createdAt
|
||||
: Date.now();
|
||||
|
||||
const payload = {
|
||||
pubkey: actor.pubkey,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
if (typeof actor.privateKey === "string" && actor.privateKey) {
|
||||
payload.privateKey = actor.privateKey;
|
||||
} else if (
|
||||
typeof actor.privateKeyEncrypted === "string" &&
|
||||
actor.privateKeyEncrypted &&
|
||||
actor.encryption &&
|
||||
typeof actor.encryption === "object"
|
||||
) {
|
||||
const normalizedEncryption = normalizeStoredEncryptionMetadata(actor.encryption);
|
||||
if (!normalizedEncryption) {
|
||||
return;
|
||||
}
|
||||
payload.privateKeyEncrypted = actor.privateKeyEncrypted;
|
||||
payload.encryption = normalizedEncryption;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SESSION_ACTOR_STORAGE_KEY,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to persist session actor:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredSessionActor() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to clear stored session actor:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export const __testExports = {
|
||||
arrayBufferToBase64,
|
||||
base64ToUint8Array,
|
||||
deriveSessionEncryptionKey,
|
||||
generateRandomBytes,
|
||||
isSubtleCryptoAvailable,
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
// js/nostr/toolkit.js
|
||||
|
||||
import { isDevMode } from "../config.js";
|
||||
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
/**
|
||||
* The default relay set bitvid bootstraps with before loading a user's
|
||||
* preferences.
|
||||
*/
|
||||
export const DEFAULT_RELAY_URLS = Object.freeze([
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.snort.social",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nostr.band",
|
||||
]);
|
||||
|
||||
export const RELAY_URLS = Array.from(DEFAULT_RELAY_URLS);
|
||||
|
||||
const globalScope =
|
||||
typeof window !== "undefined"
|
||||
? window
|
||||
: typeof globalThis !== "undefined"
|
||||
? globalThis
|
||||
: null;
|
||||
|
||||
const nostrToolsReadySource =
|
||||
globalScope &&
|
||||
globalScope.nostrToolsReady &&
|
||||
typeof globalScope.nostrToolsReady.then === "function"
|
||||
? globalScope.nostrToolsReady
|
||||
: nostrToolsReady;
|
||||
|
||||
export function normalizeToolkitCandidate(candidate) {
|
||||
if (
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
candidate.ok !== false &&
|
||||
typeof candidate.then !== "function"
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readToolkitFromScope(scope = globalScope) {
|
||||
if (!scope || typeof scope !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
|
||||
const canonical = scope.__BITVID_CANONICAL_NOSTR_TOOLS__;
|
||||
if (canonical) {
|
||||
candidates.push(canonical);
|
||||
}
|
||||
|
||||
const direct = scope.NostrTools;
|
||||
if (direct) {
|
||||
candidates.push(direct);
|
||||
}
|
||||
|
||||
const nestedWindow =
|
||||
scope.window && scope.window !== scope && typeof scope.window === "object"
|
||||
? scope.window
|
||||
: null;
|
||||
if (nestedWindow) {
|
||||
if (nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__) {
|
||||
candidates.push(nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__);
|
||||
}
|
||||
if (nestedWindow.NostrTools) {
|
||||
candidates.push(nestedWindow.NostrTools);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeToolkitCandidate(candidate);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const __nostrToolsBootstrapResult = await (async () => {
|
||||
try {
|
||||
const result = await nostrToolsReadySource;
|
||||
if (result && typeof result === "object" && result.ok === false) {
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: result,
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = normalizeToolkitCandidate(result);
|
||||
if (normalized) {
|
||||
return { toolkit: normalized, failure: null };
|
||||
}
|
||||
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
|
||||
failure: error,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
let cachedNostrTools = __nostrToolsBootstrapResult.toolkit || null;
|
||||
export const nostrToolsBootstrapFailure =
|
||||
__nostrToolsBootstrapResult.failure || null;
|
||||
|
||||
if (!cachedNostrTools && nostrToolsBootstrapFailure && isDevMode) {
|
||||
userLogger.warn(
|
||||
"[nostr] nostr-tools helpers unavailable after bootstrap.",
|
||||
nostrToolsBootstrapFailure
|
||||
);
|
||||
}
|
||||
|
||||
export function rememberNostrTools(candidate) {
|
||||
const normalized = normalizeToolkitCandidate(candidate);
|
||||
if (normalized) {
|
||||
cachedNostrTools = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedNostrTools() {
|
||||
const fallback = readToolkitFromScope();
|
||||
if (cachedNostrTools && fallback && fallback !== cachedNostrTools) {
|
||||
rememberNostrTools(fallback);
|
||||
} else if (!cachedNostrTools && fallback) {
|
||||
rememberNostrTools(fallback);
|
||||
}
|
||||
return cachedNostrTools || fallback || null;
|
||||
}
|
||||
|
||||
export async function ensureNostrTools() {
|
||||
if (cachedNostrTools) {
|
||||
return cachedNostrTools;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await nostrToolsReadySource;
|
||||
rememberNostrTools(result);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to resolve nostr-tools helpers.", error);
|
||||
}
|
||||
|
||||
if (!cachedNostrTools) {
|
||||
rememberNostrTools(readToolkitFromScope());
|
||||
}
|
||||
|
||||
return cachedNostrTools || null;
|
||||
}
|
||||
|
||||
function isSimplePoolConstructor(candidate) {
|
||||
if (typeof candidate !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prototype = candidate.prototype;
|
||||
if (!prototype || typeof prototype !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
typeof prototype.sub === "function" &&
|
||||
typeof prototype.close === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function unwrapSimplePool(candidate) {
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isSimplePoolConstructor(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (typeof candidate === "object") {
|
||||
if (isSimplePoolConstructor(candidate.SimplePool)) {
|
||||
return candidate.SimplePool;
|
||||
}
|
||||
if (isSimplePoolConstructor(candidate.default)) {
|
||||
return candidate.default;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveSimplePoolConstructor(tools, scope = globalScope) {
|
||||
const candidates = [
|
||||
tools?.SimplePool,
|
||||
tools?.pool?.SimplePool,
|
||||
tools?.pool,
|
||||
tools?.SimplePool?.SimplePool,
|
||||
tools?.SimplePool?.default,
|
||||
tools?.pool?.default,
|
||||
tools?.default?.SimplePool,
|
||||
tools?.default?.pool?.SimplePool,
|
||||
tools?.default?.pool,
|
||||
];
|
||||
|
||||
if (scope && typeof scope === "object") {
|
||||
candidates.push(scope?.SimplePool);
|
||||
candidates.push(scope?.pool?.SimplePool);
|
||||
candidates.push(scope?.pool);
|
||||
const scopedTools =
|
||||
scope?.NostrTools && scope.NostrTools !== tools ? scope.NostrTools : null;
|
||||
if (scopedTools) {
|
||||
candidates.push(scopedTools.SimplePool);
|
||||
candidates.push(scopedTools.pool?.SimplePool);
|
||||
candidates.push(scopedTools.pool);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const resolved = unwrapSimplePool(candidate);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export { isSimplePoolConstructor, unwrapSimplePool };
|
||||
@@ -0,0 +1,42 @@
|
||||
// js/nostr/viewEventBindings.js
|
||||
|
||||
import { nostrClient } from "./defaultClient.js";
|
||||
|
||||
function ensureOptionalBinding(methodName, errorMessage) {
|
||||
const handler =
|
||||
nostrClient && typeof nostrClient[methodName] === "function"
|
||||
? nostrClient[methodName]
|
||||
: null;
|
||||
if (!handler) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
||||
export function recordVideoView(...args) {
|
||||
return nostrClient.recordVideoView(...args);
|
||||
}
|
||||
|
||||
export function listVideoViewEvents(...args) {
|
||||
const handler = ensureOptionalBinding(
|
||||
"listVideoViewEvents",
|
||||
"Video view listing is unavailable in this build."
|
||||
);
|
||||
return handler.apply(nostrClient, args);
|
||||
}
|
||||
|
||||
export function subscribeVideoViewEvents(...args) {
|
||||
const handler = ensureOptionalBinding(
|
||||
"subscribeVideoViewEvents",
|
||||
"Video view subscriptions are unavailable in this build."
|
||||
);
|
||||
return handler.apply(nostrClient, args);
|
||||
}
|
||||
|
||||
export function countVideoViewEvents(...args) {
|
||||
const handler = ensureOptionalBinding(
|
||||
"countVideoViewEvents",
|
||||
"Video view counting is unavailable in this build."
|
||||
);
|
||||
return handler.apply(nostrClient, args);
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
import {
|
||||
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
|
||||
VIEW_COUNT_BACKFILL_MAX_DAYS,
|
||||
} from "../config.js";
|
||||
import { VIEW_FILTER_INCLUDE_LEGACY_VIDEO } from "../constants.js";
|
||||
import {
|
||||
buildViewEvent,
|
||||
getNostrEventSchema,
|
||||
NOTE_TYPES,
|
||||
} from "../nostrEventSchemas.js";
|
||||
import { publishEventToRelay } from "../nostrPublish.js";
|
||||
import { RELAY_URLS } from "./toolkit.js";
|
||||
import { normalizePointerInput } from "./watchHistory.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
|
||||
|
||||
const viewEventPublishMemory = new Map();
|
||||
|
||||
const VIEW_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
|
||||
export const VIEW_EVENT_KIND = Number.isFinite(VIEW_EVENT_SCHEMA?.kind)
|
||||
? VIEW_EVENT_SCHEMA.kind
|
||||
: 30079;
|
||||
|
||||
function resolveVideoViewPointer(pointer) {
|
||||
const normalized = normalizePointerInput(pointer);
|
||||
if (!normalized || typeof normalized.value !== "string") {
|
||||
throw new Error("Invalid video pointer supplied for view lookup.");
|
||||
}
|
||||
|
||||
const value = normalized.value.trim();
|
||||
if (!value) {
|
||||
throw new Error("Invalid video pointer supplied for view lookup.");
|
||||
}
|
||||
|
||||
const type = normalized.type === "a" ? "a" : "e";
|
||||
const descriptor = { type, value };
|
||||
|
||||
if (typeof normalized.relay === "string" && normalized.relay.trim()) {
|
||||
descriptor.relay = normalized.relay.trim();
|
||||
}
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
export function createVideoViewEventFilters(pointer) {
|
||||
let resolved;
|
||||
|
||||
if (
|
||||
pointer &&
|
||||
typeof pointer === "object" &&
|
||||
(pointer.type === "a" || pointer.type === "e") &&
|
||||
typeof pointer.value === "string"
|
||||
) {
|
||||
const value = pointer.value.trim();
|
||||
if (!value) {
|
||||
throw new Error("Invalid video pointer supplied for view lookup.");
|
||||
}
|
||||
resolved = { type: pointer.type === "a" ? "a" : "e", value };
|
||||
if (typeof pointer.relay === "string" && pointer.relay.trim()) {
|
||||
resolved.relay = pointer.relay.trim();
|
||||
}
|
||||
} else {
|
||||
resolved = resolveVideoViewPointer(pointer);
|
||||
}
|
||||
|
||||
const pointerFilter = {
|
||||
kinds: [VIEW_EVENT_KIND],
|
||||
"#t": ["view"],
|
||||
};
|
||||
|
||||
if (resolved.type === "a") {
|
||||
pointerFilter["#a"] = [resolved.value];
|
||||
} else {
|
||||
pointerFilter["#e"] = [resolved.value];
|
||||
}
|
||||
|
||||
const filters = [pointerFilter];
|
||||
|
||||
if (VIEW_FILTER_INCLUDE_LEGACY_VIDEO) {
|
||||
filters.push({
|
||||
kinds: [VIEW_EVENT_KIND],
|
||||
"#t": ["view"],
|
||||
"#video": [resolved.value],
|
||||
});
|
||||
}
|
||||
|
||||
return { pointer: resolved, filters };
|
||||
}
|
||||
|
||||
export function deriveViewEventBucketIndex(createdAtSeconds) {
|
||||
const timestamp = Number.isFinite(createdAtSeconds)
|
||||
? Math.floor(createdAtSeconds)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
const windowSize = Math.max(
|
||||
1,
|
||||
Number(VIEW_COUNT_DEDUPE_WINDOW_SECONDS) || 0
|
||||
);
|
||||
return Math.floor(timestamp / windowSize);
|
||||
}
|
||||
|
||||
export function getViewEventGuardWindowMs() {
|
||||
const windowSeconds = Math.max(
|
||||
1,
|
||||
Number(VIEW_COUNT_DEDUPE_WINDOW_SECONDS) || 0
|
||||
);
|
||||
return windowSeconds * 1000;
|
||||
}
|
||||
|
||||
export function deriveViewEventPointerScope(pointer) {
|
||||
const pointerValue =
|
||||
typeof pointer?.value === "string" ? pointer.value.trim().toLowerCase() : "";
|
||||
if (!pointerValue) {
|
||||
return "";
|
||||
}
|
||||
const pointerType = pointer?.type === "a" ? "a" : "e";
|
||||
return `${pointerType}:${pointerValue}`;
|
||||
}
|
||||
|
||||
function generateViewEventEntropy() {
|
||||
const cryptoRef =
|
||||
(typeof globalThis !== "undefined" &&
|
||||
/** @type {Crypto | undefined} */ (globalThis.crypto)) ||
|
||||
null;
|
||||
|
||||
if (cryptoRef && typeof cryptoRef.getRandomValues === "function") {
|
||||
try {
|
||||
const buffer = new Uint32Array(2);
|
||||
cryptoRef.getRandomValues(buffer);
|
||||
return Array.from(buffer, (value) =>
|
||||
value.toString(16).padStart(8, "0")
|
||||
).join("");
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to gather crypto entropy for view event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackA = Math.floor(Math.random() * 0xffffffff)
|
||||
.toString(16)
|
||||
.padStart(8, "0");
|
||||
const fallbackB = Math.floor(Math.random() * 0xffffffff)
|
||||
.toString(16)
|
||||
.padStart(8, "0");
|
||||
return `${fallbackA}${fallbackB}`;
|
||||
}
|
||||
|
||||
function generateViewEventDedupeTag(actorPubkey, pointer, createdAtSeconds) {
|
||||
const scope = deriveViewEventPointerScope(pointer) || "unknown";
|
||||
const normalizedActor =
|
||||
typeof actorPubkey === "string" && actorPubkey.trim()
|
||||
? actorPubkey.trim().toLowerCase()
|
||||
: "anon";
|
||||
const timestamp = Number.isFinite(createdAtSeconds)
|
||||
? Math.max(0, Math.floor(createdAtSeconds))
|
||||
: Math.floor(Date.now() / 1000);
|
||||
const entropy = generateViewEventEntropy();
|
||||
return `${scope}:${normalizedActor}:${timestamp}:${entropy}`;
|
||||
}
|
||||
|
||||
export function hasRecentViewPublish(scope, bucketIndex) {
|
||||
if (!scope || !Number.isFinite(bucketIndex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const windowMs = getViewEventGuardWindowMs();
|
||||
const now = Date.now();
|
||||
const entry = viewEventPublishMemory.get(scope);
|
||||
|
||||
if (entry) {
|
||||
const age = now - Number(entry.seenAt);
|
||||
if (!Number.isFinite(entry.seenAt) || age >= windowMs) {
|
||||
viewEventPublishMemory.delete(scope);
|
||||
} else if (Number(entry.bucket) === bucketIndex) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`;
|
||||
let rawValue = null;
|
||||
try {
|
||||
rawValue = localStorage.getItem(storageKey);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to read view guard entry:", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof rawValue !== "string" || !rawValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [storedBucketRaw, storedSeenRaw] = rawValue.split(":", 2);
|
||||
const storedBucket = Number(storedBucketRaw);
|
||||
const storedSeenAt = Number(storedSeenRaw);
|
||||
|
||||
if (!Number.isFinite(storedBucket) || !Number.isFinite(storedSeenAt)) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to clear corrupt view guard entry:", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (now - storedSeenAt >= windowMs) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to remove expired view guard entry:", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
viewEventPublishMemory.set(scope, {
|
||||
bucket: storedBucket,
|
||||
seenAt: storedSeenAt,
|
||||
});
|
||||
|
||||
return storedBucket === bucketIndex;
|
||||
}
|
||||
|
||||
export function rememberViewPublish(scope, bucketIndex) {
|
||||
if (!scope || !Number.isFinite(bucketIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const windowMs = getViewEventGuardWindowMs();
|
||||
const entry = viewEventPublishMemory.get(scope);
|
||||
if (entry && Number.isFinite(entry.seenAt) && now - entry.seenAt >= windowMs) {
|
||||
viewEventPublishMemory.delete(scope);
|
||||
}
|
||||
|
||||
viewEventPublishMemory.set(scope, {
|
||||
bucket: bucketIndex,
|
||||
seenAt: now,
|
||||
});
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`;
|
||||
try {
|
||||
localStorage.setItem(storageKey, `${bucketIndex}:${now}`);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to persist view guard entry:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function isVideoViewEvent(event, pointer) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(event.kind) || event.kind !== VIEW_EVENT_KIND) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tags = Array.isArray(event.tags) ? event.tags : [];
|
||||
let hasViewTag = false;
|
||||
let matchesPointer = false;
|
||||
|
||||
const pointerValueRaw =
|
||||
typeof pointer?.value === "string" ? pointer.value.trim() : "";
|
||||
const pointerValueLower = pointerValueRaw.toLowerCase();
|
||||
const pointerType = pointer?.type === "a" ? "a" : "e";
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = typeof tag[0] === "string" ? tag[0].toLowerCase() : "";
|
||||
const tagValue = typeof tag[1] === "string" ? tag[1].trim().toLowerCase() : "";
|
||||
|
||||
if (!tagName || !tagValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tagName === "t" && tagValue === "view") {
|
||||
hasViewTag = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pointerType === "a" && tagName === "a" && tagValue === pointerValueLower) {
|
||||
matchesPointer = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pointerType === "e" && tagName === "e" && tagValue === pointerValueLower) {
|
||||
matchesPointer = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO &&
|
||||
tagName === "video" &&
|
||||
tagValue === pointerValueLower
|
||||
) {
|
||||
matchesPointer = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasViewTag && matchesPointer;
|
||||
}
|
||||
|
||||
const VIEW_EVENT_COUNT_SECONDS_PER_DAY = 86_400;
|
||||
|
||||
function flattenListResults(input) {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const flat = [];
|
||||
for (const chunk of input) {
|
||||
if (Array.isArray(chunk)) {
|
||||
for (const item of chunk) {
|
||||
if (item && typeof item === "object") {
|
||||
flat.push(item);
|
||||
}
|
||||
}
|
||||
} else if (chunk && typeof chunk === "object") {
|
||||
flat.push(chunk);
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
function sanitizeRelayList(primary, fallback) {
|
||||
if (Array.isArray(primary) && primary.length) {
|
||||
return primary;
|
||||
}
|
||||
if (Array.isArray(fallback) && fallback.length) {
|
||||
return fallback;
|
||||
}
|
||||
return RELAY_URLS;
|
||||
}
|
||||
|
||||
export async function listVideoViewEvents(client, pointer, options = {}) {
|
||||
const pool = client?.pool;
|
||||
const canQueryPool = pool && typeof pool.list === "function";
|
||||
|
||||
if (!canQueryPool) {
|
||||
if (
|
||||
client &&
|
||||
Object.prototype.hasOwnProperty.call(client, "listVideoViewEvents") &&
|
||||
typeof client.listVideoViewEvents === "function"
|
||||
) {
|
||||
return client.listVideoViewEvents(pointer, options);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters(
|
||||
pointer
|
||||
);
|
||||
const { since, until, limit, relays } = options || {};
|
||||
|
||||
for (const filter of filters) {
|
||||
if (!filter || typeof filter !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (Number.isFinite(since)) {
|
||||
filter.since = Math.floor(since);
|
||||
}
|
||||
if (Number.isFinite(until)) {
|
||||
filter.until = Math.floor(until);
|
||||
}
|
||||
if (Number.isFinite(limit) && limit > 0) {
|
||||
filter.limit = Math.floor(limit);
|
||||
}
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(relays, client.relays);
|
||||
|
||||
let rawResults;
|
||||
try {
|
||||
rawResults = await pool.list(relayList, filters);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to list video view events:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const flattened = flattenListResults(rawResults);
|
||||
const dedupe = new Map();
|
||||
const order = [];
|
||||
|
||||
for (const event of flattened) {
|
||||
if (!isVideoViewEvent(event, pointerDescriptor)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventId = typeof event.id === "string" ? event.id : null;
|
||||
if (!eventId) {
|
||||
order.push({ type: "raw", event });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = dedupe.get(eventId);
|
||||
if (!existing) {
|
||||
dedupe.set(eventId, event);
|
||||
order.push({ type: "id", key: eventId });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingCreated = Number.isFinite(existing?.created_at)
|
||||
? existing.created_at
|
||||
: 0;
|
||||
const incomingCreated = Number.isFinite(event.created_at)
|
||||
? event.created_at
|
||||
: 0;
|
||||
if (incomingCreated > existingCreated) {
|
||||
dedupe.set(eventId, event);
|
||||
}
|
||||
}
|
||||
|
||||
return order
|
||||
.map((entry) => {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (entry.type === "raw") {
|
||||
return entry.event || null;
|
||||
}
|
||||
if (entry.type === "id") {
|
||||
return dedupe.get(entry.key) || null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function subscribeVideoViewEvents(client, pointer, options = {}) {
|
||||
const pool = client?.pool;
|
||||
const canSubscribe = pool && typeof pool.sub === "function";
|
||||
|
||||
if (!canSubscribe) {
|
||||
devLogger.warn("[nostr] Unable to subscribe to view events: pool missing.");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters(
|
||||
pointer
|
||||
);
|
||||
|
||||
if (Number.isFinite(options?.since)) {
|
||||
for (const filter of filters) {
|
||||
if (filter && typeof filter === "object") {
|
||||
filter.since = Math.floor(options.since);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(options?.relays, client.relays);
|
||||
const onEvent = typeof options?.onEvent === "function" ? options.onEvent : null;
|
||||
|
||||
let subscription;
|
||||
try {
|
||||
subscription = pool.sub(relayList, filters);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to open video view subscription:", error);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (onEvent) {
|
||||
subscription.on("event", (event) => {
|
||||
if (isVideoViewEvent(event, pointerDescriptor)) {
|
||||
try {
|
||||
onEvent(event);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Video view event handler threw:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const originalUnsub =
|
||||
typeof subscription.unsub === "function"
|
||||
? subscription.unsub.bind(subscription)
|
||||
: null;
|
||||
|
||||
let unsubscribed = false;
|
||||
return () => {
|
||||
if (unsubscribed) {
|
||||
return;
|
||||
}
|
||||
unsubscribed = true;
|
||||
if (originalUnsub) {
|
||||
try {
|
||||
originalUnsub();
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[nostr] Failed to unsubscribe from video view events:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function countVideoViewEvents(client, pointer, options = {}) {
|
||||
const relayList =
|
||||
Array.isArray(options?.relays) && options.relays.length
|
||||
? options.relays
|
||||
: undefined;
|
||||
|
||||
const fallbackListOptions = (() => {
|
||||
const listOptions = {};
|
||||
|
||||
if (relayList) {
|
||||
listOptions.relays = relayList;
|
||||
}
|
||||
|
||||
if (Number.isFinite(options?.since)) {
|
||||
listOptions.since = Math.floor(options.since);
|
||||
} else {
|
||||
const horizonDaysRaw = Number(VIEW_COUNT_BACKFILL_MAX_DAYS);
|
||||
const horizonDays = Number.isFinite(horizonDaysRaw)
|
||||
? Math.max(0, Math.floor(horizonDaysRaw))
|
||||
: 0;
|
||||
if (horizonDays > 0) {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
listOptions.since = Math.max(
|
||||
0,
|
||||
nowSeconds - horizonDays * VIEW_EVENT_COUNT_SECONDS_PER_DAY
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isFinite(options?.until)) {
|
||||
listOptions.until = Math.floor(options.until);
|
||||
}
|
||||
|
||||
if (Number.isFinite(options?.limit) && options.limit > 0) {
|
||||
listOptions.limit = Math.floor(options.limit);
|
||||
}
|
||||
|
||||
return listOptions;
|
||||
})();
|
||||
|
||||
const canAttemptCount =
|
||||
typeof client?.countEventsAcrossRelays === "function";
|
||||
|
||||
if (!canAttemptCount) {
|
||||
const events = await listVideoViewEvents(client, pointer, {
|
||||
...fallbackListOptions,
|
||||
});
|
||||
return {
|
||||
total: Array.isArray(events) ? events.length : 0,
|
||||
perRelay: [],
|
||||
best: null,
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { filters } = createVideoViewEventFilters(pointer);
|
||||
const pointerFilter = Array.isArray(filters) && filters.length ? filters[0] : null;
|
||||
if (!pointerFilter || typeof pointerFilter !== "object") {
|
||||
throw new Error("Invalid video pointer supplied for view lookup.");
|
||||
}
|
||||
|
||||
const signal = options?.signal;
|
||||
const normalizeAbortError = () => {
|
||||
if (signal?.reason instanceof Error) {
|
||||
return signal.reason;
|
||||
}
|
||||
if (typeof DOMException === "function") {
|
||||
return new DOMException("Operation aborted", "AbortError");
|
||||
}
|
||||
const error = new Error("Operation aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
throw normalizeAbortError();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.countEventsAcrossRelays([pointerFilter], {
|
||||
relays: sanitizeRelayList(relayList, client.relays),
|
||||
timeoutMs: options?.timeoutMs,
|
||||
});
|
||||
|
||||
if (result?.perRelay?.some((entry) => entry && entry.ok)) {
|
||||
return { ...result, fallback: false };
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== "count-unsupported") {
|
||||
devLogger.warn("[nostr] COUNT view request failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw normalizeAbortError();
|
||||
}
|
||||
|
||||
const abortPromise =
|
||||
signal &&
|
||||
typeof signal === "object" &&
|
||||
typeof signal.addEventListener === "function"
|
||||
? new Promise((_, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
reject(normalizeAbortError());
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
})
|
||||
: null;
|
||||
|
||||
const listPromise = listVideoViewEvents(client, pointer, {
|
||||
...fallbackListOptions,
|
||||
});
|
||||
|
||||
const events = abortPromise
|
||||
? await Promise.race([listPromise, abortPromise])
|
||||
: await listPromise;
|
||||
|
||||
const uniqueCount = Array.isArray(events)
|
||||
? (() => {
|
||||
const withIds = events
|
||||
.filter((event) => event && typeof event.id === "string")
|
||||
.map((event) => event.id);
|
||||
if (withIds.length === 0) {
|
||||
return events.length;
|
||||
}
|
||||
return new Set(withIds).size;
|
||||
})()
|
||||
: 0;
|
||||
|
||||
return {
|
||||
total: uniqueCount,
|
||||
perRelay: [],
|
||||
best: null,
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
let ingestLocalViewEventRef = null;
|
||||
|
||||
async function loadIngestLocalViewEvent() {
|
||||
if (typeof ingestLocalViewEventRef === "function") {
|
||||
return ingestLocalViewEventRef;
|
||||
}
|
||||
try {
|
||||
const module = await import("../viewCounter.js");
|
||||
if (typeof module?.ingestLocalViewEvent === "function") {
|
||||
ingestLocalViewEventRef = module.ingestLocalViewEvent;
|
||||
return ingestLocalViewEventRef;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to load view counter ingest helper:", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function publishViewEvent(
|
||||
client,
|
||||
videoPointer,
|
||||
options = {},
|
||||
{
|
||||
resolveActiveSigner,
|
||||
shouldRequestExtensionPermissions,
|
||||
signEventWithPrivateKey,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
}
|
||||
) {
|
||||
if (!client?.pool) {
|
||||
return { ok: false, error: "nostr-uninitialized" };
|
||||
}
|
||||
|
||||
const pointer = normalizePointerInput(videoPointer);
|
||||
if (!pointer) {
|
||||
return { ok: false, error: "invalid-pointer" };
|
||||
}
|
||||
|
||||
const actorPubkey = await client.ensureSessionActor();
|
||||
if (!actorPubkey) {
|
||||
return { ok: false, error: "missing-actor" };
|
||||
}
|
||||
|
||||
const createdAt =
|
||||
typeof options.created_at === "number" && options.created_at > 0
|
||||
? Math.floor(options.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const guardScope = deriveViewEventPointerScope(pointer);
|
||||
const guardBucket = deriveViewEventBucketIndex(createdAt);
|
||||
if (guardScope && hasRecentViewPublish(guardScope, guardBucket)) {
|
||||
devLogger.info("[nostr] Skipping duplicate view publish for scope", guardScope);
|
||||
return {
|
||||
ok: true,
|
||||
duplicate: true,
|
||||
event: null,
|
||||
results: [],
|
||||
acceptedRelays: [],
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedActor =
|
||||
typeof actorPubkey === "string" ? actorPubkey.toLowerCase() : "";
|
||||
const normalizedLogged =
|
||||
typeof client.pubkey === "string" ? client.pubkey.toLowerCase() : "";
|
||||
const usingSessionActor =
|
||||
normalizedActor && normalizedActor !== normalizedLogged;
|
||||
|
||||
const additionalTags = Array.isArray(options.additionalTags)
|
||||
? options.additionalTags.filter(
|
||||
(tag) => Array.isArray(tag) && typeof tag[0] === "string"
|
||||
)
|
||||
: [];
|
||||
|
||||
const pointerTag =
|
||||
pointer.type === "a"
|
||||
? pointer.relay
|
||||
? ["a", pointer.value, pointer.relay]
|
||||
: ["a", pointer.value]
|
||||
: pointer.relay
|
||||
? ["e", pointer.value, pointer.relay]
|
||||
: ["e", pointer.value];
|
||||
|
||||
let content = "";
|
||||
if (typeof options.content === "string") {
|
||||
content = options.content;
|
||||
} else if (
|
||||
options.content &&
|
||||
typeof options.content === "object" &&
|
||||
!Array.isArray(options.content)
|
||||
) {
|
||||
try {
|
||||
content = JSON.stringify(options.content);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to serialize custom view event content:", error);
|
||||
content = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
const payload = {
|
||||
target: {
|
||||
type: pointer.type,
|
||||
value: pointer.value,
|
||||
},
|
||||
created_at: createdAt,
|
||||
};
|
||||
if (pointer.relay) {
|
||||
payload.target.relay = pointer.relay;
|
||||
}
|
||||
try {
|
||||
content = JSON.stringify(payload);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to serialize default view event content:", error);
|
||||
content = "";
|
||||
}
|
||||
}
|
||||
|
||||
const event = buildViewEvent({
|
||||
pubkey: actorPubkey,
|
||||
created_at: createdAt,
|
||||
pointerValue: pointer.value,
|
||||
pointerTag,
|
||||
dedupeTag: generateViewEventDedupeTag(actorPubkey, pointer, createdAt),
|
||||
includeSessionTag: usingSessionActor,
|
||||
additionalTags,
|
||||
content,
|
||||
});
|
||||
|
||||
let signedEvent = null;
|
||||
|
||||
const signer = resolveActiveSigner(actorPubkey);
|
||||
const canUseActiveSigner =
|
||||
normalizedActor &&
|
||||
normalizedActor === normalizedLogged &&
|
||||
signer &&
|
||||
typeof signer.signEvent === "function";
|
||||
|
||||
if (canUseActiveSigner) {
|
||||
let permissionResult = { ok: true };
|
||||
if (shouldRequestExtensionPermissions(signer)) {
|
||||
permissionResult = await client.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS
|
||||
);
|
||||
}
|
||||
if (permissionResult.ok) {
|
||||
try {
|
||||
signedEvent = await signer.signEvent(event);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostr] Failed to sign view event with active signer:",
|
||||
error,
|
||||
);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
} else {
|
||||
userLogger.warn(
|
||||
"[nostr] Active signer permissions missing; signing view event with session key.",
|
||||
permissionResult.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!signedEvent) {
|
||||
try {
|
||||
if (!client.sessionActor || client.sessionActor.pubkey !== actorPubkey) {
|
||||
await client.ensureSessionActor(true);
|
||||
}
|
||||
if (!client.sessionActor || client.sessionActor.pubkey !== actorPubkey) {
|
||||
throw new Error("session-actor-mismatch");
|
||||
}
|
||||
const privateKey = client.sessionActor.privateKey;
|
||||
signedEvent = signEventWithPrivateKey(event, privateKey);
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostr] Failed to sign view event with session key:", error);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(options.relays, client.relays);
|
||||
|
||||
const publishResults = await Promise.all(
|
||||
relayList.map((url) => publishEventToRelay(client.pool, url, signedEvent))
|
||||
);
|
||||
|
||||
const acceptedRelays = publishResults
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.url)
|
||||
.filter((url) => typeof url === "string" && url);
|
||||
const success = acceptedRelays.length > 0;
|
||||
if (success) {
|
||||
if (guardScope) {
|
||||
rememberViewPublish(guardScope, guardBucket);
|
||||
}
|
||||
devLogger.info(
|
||||
`[nostr] View event accepted by ${acceptedRelays.length} relay(s):`,
|
||||
acceptedRelays.join(", ")
|
||||
);
|
||||
} else {
|
||||
userLogger.warn("[nostr] View event rejected by relays:", publishResults);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: success,
|
||||
event: signedEvent,
|
||||
results: publishResults,
|
||||
acceptedRelays,
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordVideoView(
|
||||
client,
|
||||
videoPointer,
|
||||
options = {},
|
||||
helpers
|
||||
) {
|
||||
const pointer = normalizePointerInput(videoPointer);
|
||||
if (!pointer) {
|
||||
return { ok: false, error: "invalid-pointer" };
|
||||
}
|
||||
|
||||
const view = await publishViewEvent(client, pointer, options, helpers);
|
||||
|
||||
if (view?.ok && view.event) {
|
||||
try {
|
||||
const ingest = await loadIngestLocalViewEvent();
|
||||
if (typeof ingest === "function") {
|
||||
ingest({ event: view.event, pointer });
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to ingest optimistic view event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
export const __testExports = {
|
||||
resolveVideoViewPointer,
|
||||
generateViewEventEntropy,
|
||||
generateViewEventDedupeTag,
|
||||
isVideoViewEvent,
|
||||
loadIngestLocalViewEvent,
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
||||
// js/nostr/watchHistoryBindings.js
|
||||
|
||||
import { nostrClient } from "./defaultClient.js";
|
||||
import {
|
||||
updateWatchHistoryList as updateWatchHistoryListWithManager,
|
||||
removeWatchHistoryItem as removeWatchHistoryItemWithManager,
|
||||
} from "./watchHistory.js";
|
||||
|
||||
function getWatchHistoryManager() {
|
||||
return nostrClient?.watchHistory || null;
|
||||
}
|
||||
|
||||
function assertWatchHistoryManager(operation) {
|
||||
const manager = getWatchHistoryManager();
|
||||
if (!manager || typeof manager[operation] !== "function") {
|
||||
throw new Error("Watch history manager is unavailable in this build.");
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
export function updateWatchHistoryList(...args) {
|
||||
const manager = assertWatchHistoryManager("updateList");
|
||||
return updateWatchHistoryListWithManager(manager, ...args);
|
||||
}
|
||||
|
||||
export function removeWatchHistoryItem(...args) {
|
||||
const manager = assertWatchHistoryManager("removeItem");
|
||||
return removeWatchHistoryItemWithManager(manager, ...args);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// js/nostrClientFacade.js
|
||||
//
|
||||
// Compatibility-forwarding module that exposes the default Nostr client
|
||||
// singleton alongside the convenience permission helper.
|
||||
// Future modules should import from this facade instead of reaching into the
|
||||
// nested default client implementation directly.
|
||||
|
||||
export {
|
||||
nostrClient,
|
||||
requestDefaultExtensionPermissions,
|
||||
} from "./nostr/defaultClient.js";
|
||||
@@ -0,0 +1,50 @@
|
||||
// js/nostrClientRegistry.js
|
||||
|
||||
let registeredNostrClient = null;
|
||||
let requestPermissionsDelegate = null;
|
||||
|
||||
export function registerNostrClient(client, options = {}) {
|
||||
registeredNostrClient = client || null;
|
||||
|
||||
if (typeof options.requestPermissions === "function") {
|
||||
requestPermissionsDelegate = options.requestPermissions;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
registeredNostrClient &&
|
||||
typeof registeredNostrClient.ensureExtensionPermissions === "function"
|
||||
) {
|
||||
requestPermissionsDelegate = (...args) =>
|
||||
registeredNostrClient.ensureExtensionPermissions(...args);
|
||||
return;
|
||||
}
|
||||
|
||||
requestPermissionsDelegate = null;
|
||||
}
|
||||
|
||||
export function getRegisteredNostrClient() {
|
||||
return registeredNostrClient;
|
||||
}
|
||||
|
||||
export function clearNostrClientRegistration() {
|
||||
registeredNostrClient = null;
|
||||
requestPermissionsDelegate = null;
|
||||
}
|
||||
|
||||
export function requestDefaultExtensionPermissions(...args) {
|
||||
if (typeof requestPermissionsDelegate === "function") {
|
||||
try {
|
||||
return requestPermissionsDelegate(...args);
|
||||
} catch (error) {
|
||||
return Promise.resolve({ ok: false, error });
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Error("Nostr client is not registered.");
|
||||
return Promise.resolve({ ok: false, error });
|
||||
}
|
||||
|
||||
export const __nostrClientRegistryTestHooks = {
|
||||
clearNostrClientRegistration,
|
||||
};
|
||||
+371
-19
@@ -16,10 +16,13 @@ export const NOTE_TYPES = Object.freeze({
|
||||
REPOST: "repost",
|
||||
RELAY_LIST: "relayList",
|
||||
VIEW_EVENT: "viewEvent",
|
||||
VIDEO_REACTION: "videoReaction",
|
||||
VIDEO_COMMENT: "videoComment",
|
||||
WATCH_HISTORY_INDEX: "watchHistoryIndex",
|
||||
WATCH_HISTORY_CHUNK: "watchHistoryChunk",
|
||||
SUBSCRIPTION_LIST: "subscriptionList",
|
||||
USER_BLOCK_LIST: "userBlockList",
|
||||
HASHTAG_PREFERENCES: "hashtagPreferences",
|
||||
ADMIN_MODERATION_LIST: "adminModerationList",
|
||||
ADMIN_BLACKLIST: "adminBlacklist",
|
||||
ADMIN_WHITELIST: "adminWhitelist",
|
||||
@@ -63,6 +66,33 @@ function ensureValidUtf8Content(value) {
|
||||
normalized = String(value);
|
||||
}
|
||||
|
||||
if (normalized) {
|
||||
const builder = [];
|
||||
let mutated = false;
|
||||
for (let index = 0; index < normalized.length; index += 1) {
|
||||
const code = normalized.charCodeAt(index);
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const nextCode = normalized.charCodeAt(index + 1);
|
||||
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
|
||||
builder.push(normalized[index], normalized[index + 1]);
|
||||
index += 1;
|
||||
} else {
|
||||
mutated = true;
|
||||
}
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
mutated = true;
|
||||
} else {
|
||||
builder.push(normalized[index]);
|
||||
}
|
||||
}
|
||||
if (mutated) {
|
||||
devLogger.warn(
|
||||
"[nostrEventSchemas] Dropping unmatched surrogate characters from event content"
|
||||
);
|
||||
normalized = builder.join("");
|
||||
}
|
||||
}
|
||||
|
||||
if (!cachedUtf8Encoder && typeof TextEncoder !== "undefined") {
|
||||
cachedUtf8Encoder = new TextEncoder();
|
||||
}
|
||||
@@ -194,6 +224,31 @@ const BASE_SCHEMAS = {
|
||||
description: "Optional plaintext content used for diagnostics.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.VIDEO_REACTION]: {
|
||||
type: NOTE_TYPES.VIDEO_REACTION,
|
||||
label: "Reaction event",
|
||||
kind: 7,
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description:
|
||||
"Reaction payload for the referenced event (e.g., '+', '-', or emoji).",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.VIDEO_COMMENT]: {
|
||||
type: NOTE_TYPES.VIDEO_COMMENT,
|
||||
label: "Video comment",
|
||||
kind: 1,
|
||||
videoEventTagName: "e",
|
||||
videoDefinitionTagName: "a",
|
||||
parentCommentTagName: "e",
|
||||
participantTagName: "p",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description: "Plain text comment body sanitized for UTF-8 compatibility.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.WATCH_HISTORY_INDEX]: {
|
||||
type: NOTE_TYPES.WATCH_HISTORY_INDEX,
|
||||
label: "Watch history index",
|
||||
@@ -259,6 +314,21 @@ const BASE_SCHEMAS = {
|
||||
description: "Encrypted JSON: { blockedPubkeys: string[] }.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.HASHTAG_PREFERENCES]: {
|
||||
type: NOTE_TYPES.HASHTAG_PREFERENCES,
|
||||
label: "Hashtag preferences",
|
||||
kind: 30005,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: "bitvid:tag-preferences",
|
||||
},
|
||||
appendTags: [["encrypted", "nip44_v2"]],
|
||||
content: {
|
||||
format: "nip44-json",
|
||||
description:
|
||||
"NIP-44 encrypted JSON: { version, interests: string[], disinterests: string[] }.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.ADMIN_MODERATION_LIST]: {
|
||||
type: NOTE_TYPES.ADMIN_MODERATION_LIST,
|
||||
label: "Admin moderation list",
|
||||
@@ -384,6 +454,41 @@ function appendSchemaTags(tags, schema) {
|
||||
return tags;
|
||||
}
|
||||
|
||||
function collectPointerTags(schema, {
|
||||
pointerValue,
|
||||
pointerTag,
|
||||
pointerTags = [],
|
||||
} = {}) {
|
||||
const normalized = [];
|
||||
|
||||
const pointerTagName = schema?.pointerTagName;
|
||||
if (pointerValue && pointerTagName) {
|
||||
const normalizedValue =
|
||||
typeof pointerValue === "string" ? pointerValue : String(pointerValue);
|
||||
if (normalizedValue) {
|
||||
normalized.push([pointerTagName, normalizedValue]);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(pointerTag) && pointerTag.length >= 2) {
|
||||
normalized.push(
|
||||
pointerTag.map((value) => (typeof value === "string" ? value : String(value)))
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(pointerTags)) {
|
||||
pointerTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
normalized.push(
|
||||
tag.map((value) => (typeof value === "string" ? value : String(value)))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function buildVideoPostEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
@@ -590,25 +695,11 @@ export function buildViewEvent({
|
||||
tags.push([schema.topicTag.name, schema.topicTag.value]);
|
||||
}
|
||||
|
||||
const pointerTagName = schema?.pointerTagName;
|
||||
if (pointerValue && pointerTagName) {
|
||||
tags.push([pointerTagName, pointerValue]);
|
||||
}
|
||||
const normalizedPointerTags = [];
|
||||
if (Array.isArray(pointerTag) && pointerTag.length >= 2) {
|
||||
normalizedPointerTags.push(
|
||||
pointerTag.map((value) => (typeof value === "string" ? value : String(value)))
|
||||
);
|
||||
}
|
||||
if (Array.isArray(pointerTags)) {
|
||||
pointerTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
normalizedPointerTags.push(
|
||||
tag.map((value) => (typeof value === "string" ? value : String(value)))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
const normalizedPointerTags = collectPointerTags(schema, {
|
||||
pointerValue,
|
||||
pointerTag,
|
||||
pointerTags,
|
||||
});
|
||||
normalizedPointerTags.forEach((tag) => {
|
||||
tags.push(tag);
|
||||
});
|
||||
@@ -650,6 +741,245 @@ export function buildViewEvent({
|
||||
};
|
||||
}
|
||||
|
||||
export function buildReactionEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
pointerValue,
|
||||
pointerTag,
|
||||
pointerTags = [],
|
||||
targetPointer = null,
|
||||
targetAuthorPubkey = "",
|
||||
additionalTags = [],
|
||||
content = "",
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_REACTION);
|
||||
const tags = [];
|
||||
|
||||
const normalizedPointerTags = collectPointerTags(schema, {
|
||||
pointerValue,
|
||||
pointerTag,
|
||||
pointerTags,
|
||||
});
|
||||
normalizedPointerTags.forEach((tag) => {
|
||||
tags.push(tag);
|
||||
});
|
||||
|
||||
const resolvePointerDetails = (pointerCandidate) => {
|
||||
if (!pointerCandidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(pointerCandidate) && pointerCandidate.length >= 2) {
|
||||
const [type, value, relay] = pointerCandidate;
|
||||
const normalizedType = type === "a" ? "a" : type === "e" ? "e" : "";
|
||||
const normalizedValue = typeof value === "string" ? value.trim() : "";
|
||||
if (!normalizedType || !normalizedValue) {
|
||||
return null;
|
||||
}
|
||||
const normalizedRelay =
|
||||
typeof relay === "string" && relay.trim() ? relay.trim() : "";
|
||||
return { type: normalizedType, value: normalizedValue, relay: normalizedRelay };
|
||||
}
|
||||
|
||||
if (pointerCandidate && typeof pointerCandidate === "object") {
|
||||
const { type, value, relay } = pointerCandidate;
|
||||
const normalizedType = type === "a" ? "a" : type === "e" ? "e" : "";
|
||||
const normalizedValue = typeof value === "string" ? value.trim() : "";
|
||||
if (!normalizedType || !normalizedValue) {
|
||||
return null;
|
||||
}
|
||||
const normalizedRelay =
|
||||
typeof relay === "string" && relay.trim() ? relay.trim() : "";
|
||||
return { type: normalizedType, value: normalizedValue, relay: normalizedRelay };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const pointerDetails = (() => {
|
||||
const explicitPointer = resolvePointerDetails(targetPointer);
|
||||
if (explicitPointer) {
|
||||
return explicitPointer;
|
||||
}
|
||||
|
||||
const pointerTagEntry = normalizedPointerTags.find(
|
||||
(tag) => Array.isArray(tag) && tag.length >= 2 && (tag[0] === "a" || tag[0] === "e")
|
||||
);
|
||||
if (!pointerTagEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolvePointerDetails(pointerTagEntry);
|
||||
})();
|
||||
|
||||
const resolvedRelay = pointerDetails?.relay || "";
|
||||
|
||||
const normalizedAuthorPubkey = (() => {
|
||||
if (typeof targetAuthorPubkey === "string" && targetAuthorPubkey.trim()) {
|
||||
return targetAuthorPubkey.trim();
|
||||
}
|
||||
|
||||
if (pointerDetails?.type === "a" && pointerDetails.value) {
|
||||
const segments = pointerDetails.value.split(":");
|
||||
if (segments.length >= 2) {
|
||||
const candidate = segments[1]?.trim();
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
})();
|
||||
|
||||
if (normalizedAuthorPubkey) {
|
||||
const existingAuthorTag = tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === normalizedAuthorPubkey
|
||||
);
|
||||
if (!existingAuthorTag) {
|
||||
if (resolvedRelay) {
|
||||
tags.push(["p", normalizedAuthorPubkey, resolvedRelay]);
|
||||
} else {
|
||||
tags.push(["p", normalizedAuthorPubkey]);
|
||||
}
|
||||
} else if (resolvedRelay) {
|
||||
if (existingAuthorTag.length < 3) {
|
||||
existingAuthorTag.push(resolvedRelay);
|
||||
} else if (existingAuthorTag[2] !== resolvedRelay) {
|
||||
existingAuthorTag[2] = resolvedRelay;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(additionalTags)) {
|
||||
additionalTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
const resolvedContent = ensureValidUtf8Content(content);
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 7,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: resolvedContent,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCommentEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
videoEventId = "",
|
||||
videoEventRelay = "",
|
||||
videoDefinitionAddress = "",
|
||||
videoDefinitionRelay = "",
|
||||
parentCommentId = "",
|
||||
parentCommentRelay = "",
|
||||
threadParticipantPubkey = "",
|
||||
threadParticipantRelay = "",
|
||||
additionalTags = [],
|
||||
content = "",
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
|
||||
const tags = [];
|
||||
|
||||
const videoEventTagName = schema?.videoEventTagName || "e";
|
||||
const normalizedVideoEventId =
|
||||
typeof videoEventId === "string" ? videoEventId.trim() : "";
|
||||
const normalizedVideoEventRelay =
|
||||
typeof videoEventRelay === "string" ? videoEventRelay.trim() : "";
|
||||
if (videoEventTagName && normalizedVideoEventId) {
|
||||
if (normalizedVideoEventRelay) {
|
||||
tags.push([videoEventTagName, normalizedVideoEventId, normalizedVideoEventRelay]);
|
||||
} else {
|
||||
tags.push([videoEventTagName, normalizedVideoEventId]);
|
||||
}
|
||||
}
|
||||
|
||||
const videoDefinitionTagName = schema?.videoDefinitionTagName || "a";
|
||||
const normalizedVideoDefinitionAddress =
|
||||
typeof videoDefinitionAddress === "string"
|
||||
? videoDefinitionAddress.trim()
|
||||
: "";
|
||||
const normalizedVideoDefinitionRelay =
|
||||
typeof videoDefinitionRelay === "string" ? videoDefinitionRelay.trim() : "";
|
||||
if (videoDefinitionTagName && normalizedVideoDefinitionAddress) {
|
||||
if (normalizedVideoDefinitionRelay) {
|
||||
tags.push([
|
||||
videoDefinitionTagName,
|
||||
normalizedVideoDefinitionAddress,
|
||||
normalizedVideoDefinitionRelay,
|
||||
]);
|
||||
} else {
|
||||
tags.push([videoDefinitionTagName, normalizedVideoDefinitionAddress]);
|
||||
}
|
||||
}
|
||||
|
||||
const parentCommentTagName = schema?.parentCommentTagName || "e";
|
||||
const normalizedParentCommentId =
|
||||
typeof parentCommentId === "string" ? parentCommentId.trim() : "";
|
||||
const normalizedParentCommentRelay =
|
||||
typeof parentCommentRelay === "string" ? parentCommentRelay.trim() : "";
|
||||
if (parentCommentTagName && normalizedParentCommentId) {
|
||||
if (normalizedParentCommentRelay) {
|
||||
tags.push([
|
||||
parentCommentTagName,
|
||||
normalizedParentCommentId,
|
||||
normalizedParentCommentRelay,
|
||||
]);
|
||||
} else {
|
||||
tags.push([parentCommentTagName, normalizedParentCommentId]);
|
||||
}
|
||||
}
|
||||
|
||||
const participantTagName = schema?.participantTagName || "p";
|
||||
const normalizedThreadParticipantPubkey =
|
||||
typeof threadParticipantPubkey === "string"
|
||||
? threadParticipantPubkey.trim()
|
||||
: "";
|
||||
const normalizedThreadParticipantRelay =
|
||||
typeof threadParticipantRelay === "string"
|
||||
? threadParticipantRelay.trim()
|
||||
: "";
|
||||
if (participantTagName && normalizedThreadParticipantPubkey) {
|
||||
if (normalizedThreadParticipantRelay) {
|
||||
tags.push([
|
||||
participantTagName,
|
||||
normalizedThreadParticipantPubkey,
|
||||
normalizedThreadParticipantRelay,
|
||||
]);
|
||||
} else {
|
||||
tags.push([participantTagName, normalizedThreadParticipantPubkey]);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(additionalTags)) {
|
||||
additionalTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
const resolvedContent = ensureValidUtf8Content(content);
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 1,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: resolvedContent,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWatchHistoryIndexEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
@@ -819,6 +1149,28 @@ export function buildBlockListEvent({
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHashtagPreferenceEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
content,
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES);
|
||||
const tags = [];
|
||||
const identifierName = schema?.identifierTag?.name || "d";
|
||||
const identifierValue = schema?.identifierTag?.value || "bitvid:tag-preferences";
|
||||
if (identifierName && identifierValue) {
|
||||
tags.push([identifierName, identifierValue]);
|
||||
}
|
||||
appendSchemaTags(tags, schema);
|
||||
return {
|
||||
kind: schema?.kind ?? 30005,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof content === "string" ? content : String(content ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAdminNoteType(listKey) {
|
||||
switch (listKey) {
|
||||
case "moderation":
|
||||
|
||||
@@ -4,7 +4,7 @@ const LOCAL_IMPORT_TIMEOUT = 2500;
|
||||
const NODE_IMPORT_TIMEOUT = 1500;
|
||||
const SCRIPT_FALLBACK_TIMEOUT = 6000;
|
||||
const CDN_BUNDLE_URL =
|
||||
"https://cdn.jsdelivr.net/npm/[email protected]0.4/lib/nostr.bundle.min.js";
|
||||
"https://cdn.jsdelivr.net/npm/[email protected]7.0/lib/nostr.bundle.min.js";
|
||||
const LOCAL_BUNDLE_RELATIVE_URL = "../vendor/nostr-tools.bundle.min.js";
|
||||
|
||||
const moduleUrl =
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// js/nostrViewEventsFacade.js
|
||||
//
|
||||
// Central export for NIP-71 view event helpers. Consumers can choose between
|
||||
// helpers that accept an explicit Nostr client and thin wrappers that call the
|
||||
// default client instance configured by the app shell.
|
||||
|
||||
export {
|
||||
recordVideoView as recordVideoViewWithDefaultClient,
|
||||
listVideoViewEvents as listVideoViewEventsWithDefaultClient,
|
||||
subscribeVideoViewEvents as subscribeVideoViewEventsWithDefaultClient,
|
||||
countVideoViewEvents as countVideoViewEventsWithDefaultClient,
|
||||
} from "./nostr/viewEventBindings.js";
|
||||
|
||||
export {
|
||||
VIEW_EVENT_KIND,
|
||||
createVideoViewEventFilters,
|
||||
deriveViewEventBucketIndex,
|
||||
deriveViewEventPointerScope,
|
||||
getViewEventGuardWindowMs,
|
||||
hasRecentViewPublish,
|
||||
rememberViewPublish,
|
||||
publishViewEvent,
|
||||
recordVideoView,
|
||||
listVideoViewEvents,
|
||||
subscribeVideoViewEvents,
|
||||
countVideoViewEvents,
|
||||
} from "./nostr/viewEvents.js";
|
||||
@@ -0,0 +1,10 @@
|
||||
// js/nostrWatchHistoryFacade.js
|
||||
//
|
||||
// Wrapper exports for watch history helpers that operate on the default Nostr
|
||||
// client instance. Future consumers should import from this module rather than
|
||||
// reaching into the binding layer directly.
|
||||
|
||||
export {
|
||||
updateWatchHistoryList as updateWatchHistoryListWithDefaultClient,
|
||||
removeWatchHistoryItem as removeWatchHistoryItemWithDefaultClient,
|
||||
} from "./nostr/watchHistoryBindings.js";
|
||||
@@ -0,0 +1,796 @@
|
||||
import { publishVideoReaction } from "./nostr.js";
|
||||
import { nostrClient } from "./nostrClientFacade.js";
|
||||
import { normalizePointerInput, pointerKey } from "./nostr/watchHistory.js";
|
||||
import { devLogger, userLogger } from "./utils/logger.js";
|
||||
|
||||
const pointerStates = new Map();
|
||||
const pointerHandlers = new Map();
|
||||
const pointerHydrationPromises = new Map();
|
||||
const pointerSubscriptions = new Map();
|
||||
|
||||
const REACTION_EVENT_KIND = 7;
|
||||
const REACTION_HYDRATION_LIMIT = 400;
|
||||
|
||||
function clonePointerDescriptor(pointer) {
|
||||
if (!pointer || typeof pointer !== "object") {
|
||||
return null;
|
||||
}
|
||||
const descriptor = {
|
||||
type: pointer.type === "a" ? "a" : "e",
|
||||
value:
|
||||
typeof pointer.value === "string" && pointer.value.trim()
|
||||
? pointer.value.trim()
|
||||
: "",
|
||||
};
|
||||
if (typeof pointer.relay === "string" && pointer.relay.trim()) {
|
||||
descriptor.relay = pointer.relay.trim();
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function canonicalizePointerForState(pointerInput) {
|
||||
const normalized = normalizePointerInput(pointerInput);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const key = pointerKey(normalized);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const descriptor = clonePointerDescriptor(normalized);
|
||||
return descriptor ? { key, pointer: descriptor } : null;
|
||||
}
|
||||
|
||||
function ensurePointerState(key, pointer) {
|
||||
let state = pointerStates.get(key);
|
||||
if (!state) {
|
||||
state = {
|
||||
pointer: pointer ? clonePointerDescriptor(pointer) : null,
|
||||
totals: new Map(),
|
||||
reactionsByPubkey: new Map(),
|
||||
lastUpdatedAt: 0,
|
||||
latestCreatedAt: 0,
|
||||
lastSyncedAt: 0,
|
||||
hydrated: false,
|
||||
};
|
||||
pointerStates.set(key, state);
|
||||
return state;
|
||||
}
|
||||
if (pointer) {
|
||||
const descriptor = clonePointerDescriptor(pointer);
|
||||
if (!state.pointer) {
|
||||
state.pointer = descriptor;
|
||||
} else if (descriptor?.relay && !state.pointer.relay) {
|
||||
state.pointer = { ...state.pointer, relay: descriptor.relay };
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function normalizeReactionEvent(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return null;
|
||||
}
|
||||
const pubkey =
|
||||
typeof event.pubkey === "string" && event.pubkey.trim()
|
||||
? event.pubkey.trim().toLowerCase()
|
||||
: "";
|
||||
if (!pubkey) {
|
||||
return null;
|
||||
}
|
||||
let content = "+";
|
||||
if (typeof event.content === "string") {
|
||||
content = event.content;
|
||||
} else if (event.content !== undefined && event.content !== null) {
|
||||
try {
|
||||
content = String(event.content);
|
||||
} catch (error) {
|
||||
content = "+";
|
||||
}
|
||||
}
|
||||
if (!content) {
|
||||
content = "+";
|
||||
}
|
||||
const createdAt = Number.isFinite(event.created_at)
|
||||
? Math.max(0, Math.floor(event.created_at))
|
||||
: 0;
|
||||
const eventId =
|
||||
typeof event.id === "string" && event.id.trim() ? event.id.trim() : "";
|
||||
return { pubkey, content, created_at: createdAt, eventId };
|
||||
}
|
||||
|
||||
function incrementContentCount(state, content) {
|
||||
if (typeof content !== "string") {
|
||||
return;
|
||||
}
|
||||
const existing = state.totals.get(content) || 0;
|
||||
state.totals.set(content, existing + 1);
|
||||
}
|
||||
|
||||
function decrementContentCount(state, content) {
|
||||
if (typeof content !== "string") {
|
||||
return;
|
||||
}
|
||||
const existing = state.totals.get(content);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
if (existing <= 1) {
|
||||
state.totals.delete(content);
|
||||
} else {
|
||||
state.totals.set(content, existing - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function applyReactionToState(key, pointer, event) {
|
||||
const normalized = normalizeReactionEvent(event);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const state = ensurePointerState(key, pointer);
|
||||
const existing = state.reactionsByPubkey.get(normalized.pubkey);
|
||||
|
||||
if (existing) {
|
||||
if (existing.created_at > normalized.created_at) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
existing.created_at === normalized.created_at &&
|
||||
existing.eventId &&
|
||||
normalized.eventId &&
|
||||
existing.eventId === normalized.eventId &&
|
||||
existing.content === normalized.content
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
existing.created_at === normalized.created_at &&
|
||||
existing.eventId &&
|
||||
!normalized.eventId &&
|
||||
existing.content === normalized.content
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
if (!existing) {
|
||||
incrementContentCount(state, normalized.content);
|
||||
changed = true;
|
||||
} else {
|
||||
if (existing.content !== normalized.content) {
|
||||
decrementContentCount(state, existing.content);
|
||||
incrementContentCount(state, normalized.content);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!existing ||
|
||||
existing.created_at !== normalized.created_at ||
|
||||
existing.eventId !== normalized.eventId ||
|
||||
existing.content !== normalized.content
|
||||
) {
|
||||
state.reactionsByPubkey.set(normalized.pubkey, normalized);
|
||||
state.lastUpdatedAt = Date.now();
|
||||
if (Number.isFinite(normalized.created_at)) {
|
||||
const created = Math.max(0, Math.floor(normalized.created_at));
|
||||
if (!Number.isFinite(state.latestCreatedAt) || created > state.latestCreatedAt) {
|
||||
state.latestCreatedAt = created;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function snapshotState(state) {
|
||||
const counts = {};
|
||||
let total = 0;
|
||||
for (const [content, count] of state.totals.entries()) {
|
||||
counts[content] = count;
|
||||
total += count;
|
||||
}
|
||||
const reactions = {};
|
||||
for (const [pubkey, record] of state.reactionsByPubkey.entries()) {
|
||||
reactions[pubkey] = {
|
||||
content: record.content,
|
||||
created_at: record.created_at,
|
||||
eventId: record.eventId,
|
||||
};
|
||||
}
|
||||
const latestCreatedAt = Number.isFinite(state.latestCreatedAt)
|
||||
? Math.max(0, Math.floor(state.latestCreatedAt))
|
||||
: 0;
|
||||
return {
|
||||
pointer: state.pointer ? { ...state.pointer } : null,
|
||||
total,
|
||||
counts,
|
||||
reactions,
|
||||
lastUpdatedAt: state.lastUpdatedAt,
|
||||
latestCreatedAt,
|
||||
lastSyncedAt: state.lastSyncedAt,
|
||||
hydrated: Boolean(state.hydrated),
|
||||
};
|
||||
}
|
||||
|
||||
function notifyHandlers(key) {
|
||||
const handlers = pointerHandlers.get(key);
|
||||
if (!handlers || !handlers.size) {
|
||||
return;
|
||||
}
|
||||
const state = pointerStates.get(key);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const snapshot = snapshotState(state);
|
||||
for (const handler of handlers) {
|
||||
if (typeof handler !== "function") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
handler(snapshot);
|
||||
} catch (error) {
|
||||
userLogger.warn("[reactionCounter] Reaction handler threw:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToPointer(pointerInput, handler) {
|
||||
if (typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const canonical = canonicalizePointerForState(pointerInput);
|
||||
if (!canonical) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const { key, pointer } = canonical;
|
||||
const state = ensurePointerState(key, pointer);
|
||||
let handlers = pointerHandlers.get(key);
|
||||
if (!handlers) {
|
||||
handlers = new Set();
|
||||
pointerHandlers.set(key, handlers);
|
||||
}
|
||||
handlers.add(handler);
|
||||
|
||||
if (state) {
|
||||
try {
|
||||
handler(snapshotState(state));
|
||||
} catch (error) {
|
||||
userLogger.warn("[reactionCounter] Reaction handler threw:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const hydrationPromise = ensureReactionHydration(key, pointer, state);
|
||||
if (hydrationPromise && typeof hydrationPromise.then === "function") {
|
||||
hydrationPromise
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[reactionCounter] Failed to hydrate reaction history:",
|
||||
error,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (hasHandlers(key)) {
|
||||
ensureReactionLiveSubscription(key, pointer);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const liveToken = ensureReactionLiveSubscription(key, pointer);
|
||||
if (
|
||||
!liveToken &&
|
||||
!hydrationPromise &&
|
||||
!nostrClient?.pool &&
|
||||
typeof nostrClient?.ensurePool === "function"
|
||||
) {
|
||||
Promise.resolve()
|
||||
.then(() => nostrClient.ensurePool())
|
||||
.then(() => {
|
||||
if (!hasHandlers(key)) {
|
||||
return;
|
||||
}
|
||||
const followUp = ensureReactionHydration(key, pointer, state);
|
||||
if (followUp && typeof followUp.then === "function") {
|
||||
followUp
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[reactionCounter] Failed to hydrate reaction history after pool init:",
|
||||
error,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (hasHandlers(key)) {
|
||||
ensureReactionLiveSubscription(key, pointer);
|
||||
}
|
||||
});
|
||||
} else if (hasHandlers(key)) {
|
||||
ensureReactionLiveSubscription(key, pointer);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[reactionCounter] Failed to initialize nostr pool for reactions:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribeFromPointer(pointer, handler);
|
||||
};
|
||||
}
|
||||
|
||||
function unsubscribeFromPointer(pointerInput, handler) {
|
||||
const canonical = canonicalizePointerForState(pointerInput);
|
||||
if (!canonical) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { key } = canonical;
|
||||
const handlers = pointerHandlers.get(key);
|
||||
if (!handlers) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handler && typeof handler === "function") {
|
||||
handlers.delete(handler);
|
||||
} else {
|
||||
handlers.clear();
|
||||
}
|
||||
|
||||
if (handlers.size === 0) {
|
||||
pointerHandlers.delete(key);
|
||||
teardownLiveSubscription(key);
|
||||
}
|
||||
}
|
||||
|
||||
function getPointerSnapshot(pointerInput) {
|
||||
const canonical = canonicalizePointerForState(pointerInput);
|
||||
if (!canonical) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = pointerStates.get(canonical.key);
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return snapshotState(state);
|
||||
}
|
||||
|
||||
export function ingestLocalReaction({ event, pointer }) {
|
||||
if (!event || !pointer) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const canonical = canonicalizePointerForState(pointer);
|
||||
if (!canonical) {
|
||||
return;
|
||||
}
|
||||
const changed = applyReactionToState(canonical.key, canonical.pointer, event);
|
||||
if (changed) {
|
||||
notifyHandlers(canonical.key);
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn("[reactionCounter] Failed to ingest local reaction:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function hasHandlers(key) {
|
||||
const handlers = pointerHandlers.get(key);
|
||||
return Boolean(handlers && handlers.size);
|
||||
}
|
||||
|
||||
function getPointerSignature(pointer) {
|
||||
if (!pointer || typeof pointer !== "object") {
|
||||
return "";
|
||||
}
|
||||
const type = pointer.type === "a" ? "a" : pointer.type === "e" ? "e" : "";
|
||||
const value =
|
||||
typeof pointer.value === "string" && pointer.value.trim()
|
||||
? pointer.value.trim()
|
||||
: "";
|
||||
if (!type || !value) {
|
||||
return "";
|
||||
}
|
||||
const relay =
|
||||
typeof pointer.relay === "string" && pointer.relay.trim()
|
||||
? pointer.relay.trim()
|
||||
: "";
|
||||
return `${type}:${value}:${relay}`;
|
||||
}
|
||||
|
||||
function gatherRelayCandidates(pointer) {
|
||||
const relays = [];
|
||||
const seen = new Set();
|
||||
|
||||
const addRelay = (candidate) => {
|
||||
if (typeof candidate !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
return;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
relays.push(trimmed);
|
||||
};
|
||||
|
||||
if (pointer && typeof pointer.relay === "string") {
|
||||
addRelay(pointer.relay);
|
||||
}
|
||||
|
||||
const client = nostrClient || null;
|
||||
const candidateLists = [client?.readRelays, client?.relays];
|
||||
|
||||
for (const list of candidateLists) {
|
||||
if (!list) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(list)) {
|
||||
for (const relay of list) {
|
||||
addRelay(relay);
|
||||
}
|
||||
} else if (list instanceof Set) {
|
||||
for (const relay of list) {
|
||||
addRelay(relay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return relays;
|
||||
}
|
||||
|
||||
function buildReactionFilters(pointer, { limit, since } = {}) {
|
||||
if (!pointer || typeof pointer !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const type = pointer.type === "a" ? "a" : pointer.type === "e" ? "e" : "";
|
||||
const value =
|
||||
typeof pointer.value === "string" && pointer.value.trim()
|
||||
? pointer.value.trim()
|
||||
: "";
|
||||
if (!type || !value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filter = { kinds: [REACTION_EVENT_KIND] };
|
||||
|
||||
if (type === "a") {
|
||||
filter["#a"] = [value];
|
||||
} else {
|
||||
filter["#e"] = [value];
|
||||
}
|
||||
|
||||
if (Number.isFinite(limit) && limit > 0) {
|
||||
filter.limit = Math.max(1, Math.floor(limit));
|
||||
}
|
||||
|
||||
if (Number.isFinite(since) && since >= 0) {
|
||||
filter.since = Math.max(0, Math.floor(since));
|
||||
}
|
||||
|
||||
return [filter];
|
||||
}
|
||||
|
||||
function buildPointerQuery(pointer, options = {}) {
|
||||
const client = nostrClient || null;
|
||||
const pool = client?.pool || null;
|
||||
if (!pool || typeof pool.list !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relays = gatherRelayCandidates(pointer);
|
||||
if (!relays.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filters = buildReactionFilters(pointer, options);
|
||||
if (!filters.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { pool, relays, filters };
|
||||
}
|
||||
|
||||
async function hydratePointerState(key, pointer, state) {
|
||||
const query = buildPointerQuery(pointer, { limit: REACTION_HYDRATION_LIMIT });
|
||||
if (!query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let events = [];
|
||||
try {
|
||||
events = await query.pool.list(query.relays, query.filters);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const eventList = Array.isArray(events) ? events : [];
|
||||
const ordered = eventList
|
||||
.filter((event) => event && typeof event === "object")
|
||||
.sort((a, b) => {
|
||||
const aTime = Number.isFinite(a?.created_at) ? Number(a.created_at) : 0;
|
||||
const bTime = Number.isFinite(b?.created_at) ? Number(b.created_at) : 0;
|
||||
return aTime - bTime;
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
for (const event of ordered) {
|
||||
if (applyReactionToState(key, pointer, event)) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
state.lastSyncedAt = Date.now();
|
||||
state.hydrated = true;
|
||||
|
||||
if (changed && hasHandlers(key)) {
|
||||
notifyHandlers(key);
|
||||
} else if (!ordered.length && hasHandlers(key)) {
|
||||
notifyHandlers(key);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureReactionHydration(key, pointer, state) {
|
||||
if (!state || state.hydrated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pointerHydrationPromises.has(key)) {
|
||||
return pointerHydrationPromises.get(key);
|
||||
}
|
||||
|
||||
const query = buildPointerQuery(pointer, { limit: REACTION_HYDRATION_LIMIT });
|
||||
if (!query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const promise = hydratePointerState(key, pointer, state)
|
||||
.catch((error) => {
|
||||
devLogger.warn("[reactionCounter] Failed to hydrate reaction history:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
pointerHydrationPromises.delete(key);
|
||||
});
|
||||
|
||||
pointerHydrationPromises.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function teardownLiveSubscription(key) {
|
||||
const existing = pointerSubscriptions.get(key);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
pointerSubscriptions.delete(key);
|
||||
const unsub = existing.unsub;
|
||||
if (typeof unsub === "function") {
|
||||
try {
|
||||
unsub();
|
||||
} catch (error) {
|
||||
devLogger.warn("[reactionCounter] Failed to tear down reaction stream:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureReactionLiveSubscription(key, pointer) {
|
||||
const signature = getPointerSignature(pointer);
|
||||
if (!signature) {
|
||||
teardownLiveSubscription(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = pointerSubscriptions.get(key);
|
||||
if (existing && existing.signature === signature) {
|
||||
return existing.unsub;
|
||||
}
|
||||
|
||||
teardownLiveSubscription(key);
|
||||
|
||||
const query = buildPointerQuery(pointer, {});
|
||||
if (!query || typeof query.pool.sub !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = pointerStates.get(key);
|
||||
const sinceSeconds =
|
||||
Number.isFinite(state?.latestCreatedAt) && state.latestCreatedAt > 0
|
||||
? Math.max(0, Math.floor(state.latestCreatedAt))
|
||||
: null;
|
||||
if (Number.isFinite(sinceSeconds)) {
|
||||
for (const filter of query.filters) {
|
||||
if (filter && typeof filter === "object") {
|
||||
filter.since = sinceSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let subscription;
|
||||
try {
|
||||
subscription = query.pool.sub(query.relays, query.filters);
|
||||
} catch (error) {
|
||||
devLogger.warn("[reactionCounter] Failed to subscribe to reaction events:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleEvent = (event) => {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const changed = applyReactionToState(key, pointer, event);
|
||||
if (changed && hasHandlers(key)) {
|
||||
notifyHandlers(key);
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[reactionCounter] Reaction event handler failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (subscription && typeof subscription.on === "function") {
|
||||
subscription.on("event", handleEvent);
|
||||
}
|
||||
|
||||
const originalUnsub =
|
||||
subscription && typeof subscription.unsub === "function"
|
||||
? subscription.unsub.bind(subscription)
|
||||
: null;
|
||||
|
||||
const unsubscribe = () => {
|
||||
if (originalUnsub) {
|
||||
try {
|
||||
originalUnsub();
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[reactionCounter] Failed to unsubscribe from reaction events:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pointerSubscriptions.set(key, {
|
||||
signature,
|
||||
unsub: unsubscribe,
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
export const reactionCounter = {
|
||||
async publish(pointer, options = {}) {
|
||||
try {
|
||||
const publishOptions = { ...options };
|
||||
|
||||
const pointerRelayFromPointer = (() => {
|
||||
if (Array.isArray(pointer) && pointer.length >= 3) {
|
||||
const relay = pointer[2];
|
||||
if (typeof relay === "string" && relay.trim()) {
|
||||
return relay.trim();
|
||||
}
|
||||
}
|
||||
if (pointer && typeof pointer === "object") {
|
||||
const relay = pointer.relay;
|
||||
if (typeof relay === "string" && relay.trim()) {
|
||||
return relay.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
|
||||
const pointerRelay =
|
||||
typeof publishOptions.pointerRelay === "string" && publishOptions.pointerRelay.trim()
|
||||
? publishOptions.pointerRelay.trim()
|
||||
: pointerRelayFromPointer;
|
||||
|
||||
if (pointerRelay && !publishOptions.pointerRelay) {
|
||||
publishOptions.pointerRelay = pointerRelay;
|
||||
}
|
||||
|
||||
const enrichedPointer = (() => {
|
||||
if (!pointerRelay) {
|
||||
return pointer;
|
||||
}
|
||||
|
||||
if (Array.isArray(pointer) && pointer.length >= 2) {
|
||||
const [type, value] = pointer;
|
||||
return [type, value, pointerRelay];
|
||||
}
|
||||
|
||||
if (pointer && typeof pointer === "object") {
|
||||
return { ...pointer, relay: pointerRelay };
|
||||
}
|
||||
|
||||
return pointer;
|
||||
})();
|
||||
|
||||
const explicitAuthor =
|
||||
typeof publishOptions.targetAuthorPubkey === "string" &&
|
||||
publishOptions.targetAuthorPubkey.trim()
|
||||
? publishOptions.targetAuthorPubkey.trim()
|
||||
: "";
|
||||
|
||||
if (!explicitAuthor) {
|
||||
const fallbackAuthor = (() => {
|
||||
if (
|
||||
typeof publishOptions.authorPubkey === "string" &&
|
||||
publishOptions.authorPubkey.trim()
|
||||
) {
|
||||
return publishOptions.authorPubkey.trim();
|
||||
}
|
||||
if (
|
||||
typeof publishOptions.currentVideoPubkey === "string" &&
|
||||
publishOptions.currentVideoPubkey.trim()
|
||||
) {
|
||||
return publishOptions.currentVideoPubkey.trim();
|
||||
}
|
||||
if (
|
||||
publishOptions.video &&
|
||||
typeof publishOptions.video.pubkey === "string" &&
|
||||
publishOptions.video.pubkey.trim()
|
||||
) {
|
||||
return publishOptions.video.pubkey.trim();
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
|
||||
if (fallbackAuthor) {
|
||||
publishOptions.targetAuthorPubkey = fallbackAuthor;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await publishVideoReaction(enrichedPointer, publishOptions);
|
||||
if (!result || !result.ok) {
|
||||
userLogger.warn(
|
||||
"[reactionCounter] Reaction publish rejected by relays:",
|
||||
result?.results || result
|
||||
);
|
||||
} else if (Array.isArray(result.acceptedRelays)) {
|
||||
devLogger.info(
|
||||
`[reactionCounter] Reaction accepted by ${result.acceptedRelays.length} relay(s):`,
|
||||
result.acceptedRelays.join(", ")
|
||||
);
|
||||
const eventPayload = result.event || {
|
||||
pubkey:
|
||||
typeof publishOptions.actorPubkey === "string"
|
||||
? publishOptions.actorPubkey
|
||||
: undefined,
|
||||
content: publishOptions.content,
|
||||
created_at: publishOptions.created_at,
|
||||
};
|
||||
if (eventPayload && eventPayload.pubkey) {
|
||||
ingestLocalReaction({
|
||||
pointer: enrichedPointer,
|
||||
event: eventPayload,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
userLogger.warn("[reactionCounter] Failed to publish reaction event:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
reactionCounter.ingestLocalReaction = ingestLocalReaction;
|
||||
reactionCounter.subscribe = subscribeToPointer;
|
||||
reactionCounter.unsubscribe = unsubscribeFromPointer;
|
||||
reactionCounter.getSnapshot = getPointerSnapshot;
|
||||
|
||||
export default reactionCounter;
|
||||
export {
|
||||
subscribeToPointer as subscribeToReactions,
|
||||
unsubscribeFromPointer as unsubscribeFromReactions,
|
||||
getPointerSnapshot as getReactionSnapshot,
|
||||
};
|
||||
+23
-19
@@ -1,9 +1,10 @@
|
||||
import { isDevMode } from "./config.js";
|
||||
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
|
||||
import {
|
||||
DEFAULT_RELAY_URLS,
|
||||
nostrClient,
|
||||
requestDefaultExtensionPermissions,
|
||||
} from "./nostr.js";
|
||||
} from "./nostrClientFacade.js";
|
||||
import { getActiveSigner } from "./nostr/index.js";
|
||||
import { buildRelayListEvent } from "./nostrEventSchemas.js";
|
||||
import { devLogger, userLogger } from "./utils/logger.js";
|
||||
import {
|
||||
@@ -606,28 +607,31 @@ class RelayPreferencesManager {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[RelayPreferencesManager] Extension permissions denied while publishing relay preferences.",
|
||||
permissionResult.error,
|
||||
);
|
||||
const signer = getActiveSigner();
|
||||
if (!signer || typeof signer.signEvent !== "function") {
|
||||
const error = new Error(
|
||||
"The NIP-07 extension must allow signing before publishing relay preferences.",
|
||||
);
|
||||
error.code = "extension-permission-denied";
|
||||
error.cause = permissionResult.error;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!window?.nostr?.signEvent) {
|
||||
const error = new Error(
|
||||
"A NIP-07 extension is required to publish relay preferences."
|
||||
"An active signer with signEvent support is required to publish relay preferences."
|
||||
);
|
||||
error.code = "nostr-extension-missing";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (signer.type === "extension") {
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[RelayPreferencesManager] Signer permissions denied while publishing relay preferences.",
|
||||
permissionResult.error,
|
||||
);
|
||||
const error = new Error(
|
||||
"The active signer must allow signing before publishing relay preferences.",
|
||||
);
|
||||
error.code = "extension-permission-denied";
|
||||
error.cause = permissionResult.error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!nostrClient?.pool) {
|
||||
const error = new Error(
|
||||
"Nostr is not connected yet. Please try again once relays are ready."
|
||||
@@ -649,7 +653,7 @@ class RelayPreferencesManager {
|
||||
relays: entries,
|
||||
});
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
const signedEvent = await signer.signEvent(event);
|
||||
const targets = this.getPublishTargets(options?.relayUrls);
|
||||
|
||||
if (!targets.length) {
|
||||
|
||||
@@ -1,78 +1,146 @@
|
||||
import nip07Provider from "./nip07.js";
|
||||
import nsecProvider from "./nsec.js";
|
||||
import nip46Provider from "./nip46.js";
|
||||
import nip07 from "./nip07.js";
|
||||
import nip46 from "./nip46.js";
|
||||
import nsec from "./nsec.js";
|
||||
|
||||
const providers = [nip07Provider, nsecProvider, nip46Provider].filter(
|
||||
(provider) => provider && typeof provider.id === "string",
|
||||
);
|
||||
|
||||
const providersById = providers.reduce((acc, provider) => {
|
||||
const key = provider.id;
|
||||
if (!acc[key]) {
|
||||
acc[key] = provider;
|
||||
}
|
||||
return acc;
|
||||
}, Object.create(null));
|
||||
|
||||
export function getAuthProvider(providerId) {
|
||||
if (typeof providerId !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = providerId.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return providersById[trimmed] || null;
|
||||
}
|
||||
|
||||
export function listAuthProviders() {
|
||||
return providers.slice();
|
||||
}
|
||||
|
||||
export function initializeAuthProviders(context = {}) {
|
||||
for (const provider of providers) {
|
||||
if (provider && typeof provider.initialize === "function") {
|
||||
try {
|
||||
const result = provider.initialize(context);
|
||||
if (result && typeof result.then === "function") {
|
||||
result.catch((error) => {
|
||||
if (context?.logger) {
|
||||
try {
|
||||
context.logger(
|
||||
`[authProviders] Provider "${provider.id}" initialize rejected`,
|
||||
error,
|
||||
);
|
||||
} catch (_) {
|
||||
// ignore logger failures
|
||||
}
|
||||
} else if (typeof console !== "undefined" && console?.warn) {
|
||||
console.warn(
|
||||
`[authProviders] Provider "${provider.id}" initialize rejected`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (context?.logger) {
|
||||
try {
|
||||
context.logger(
|
||||
`[authProviders] Provider "${provider.id}" initialize failed`,
|
||||
error,
|
||||
);
|
||||
} catch (_) {
|
||||
// ignore logger failures
|
||||
}
|
||||
} else if (typeof console !== "undefined" && console?.warn) {
|
||||
console.warn(
|
||||
`[authProviders] Provider "${provider.id}" initialize failed`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
function normalizeProviderId(provider, fallbackId) {
|
||||
if (provider && typeof provider.id === "string") {
|
||||
const trimmed = provider.id.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof fallbackId === "string") {
|
||||
const trimmed = fallbackId.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return "provider";
|
||||
}
|
||||
|
||||
export { providersById };
|
||||
export default providers;
|
||||
function normalizeProviderMetadata(provider, fallbackId) {
|
||||
const id = normalizeProviderId(provider, fallbackId);
|
||||
const label =
|
||||
provider && typeof provider.label === "string" && provider.label.trim()
|
||||
? provider.label.trim()
|
||||
: "Saved profile";
|
||||
const description =
|
||||
provider && typeof provider.description === "string" && provider.description.trim()
|
||||
? provider.description.trim()
|
||||
: "";
|
||||
const badgeVariant =
|
||||
provider && typeof provider.badgeVariant === "string" && provider.badgeVariant.trim()
|
||||
? provider.badgeVariant.trim()
|
||||
: "neutral";
|
||||
|
||||
return Object.freeze({ id, label, description, badgeVariant });
|
||||
}
|
||||
|
||||
const providerList = [nip07, nip46, nsec].filter(Boolean);
|
||||
const providerMap = {};
|
||||
const metadataMap = {};
|
||||
|
||||
providerList.forEach((provider, index) => {
|
||||
const fallbackId = `provider-${index}`;
|
||||
const id = normalizeProviderId(provider, fallbackId);
|
||||
providerMap[id] = provider;
|
||||
metadataMap[id] = normalizeProviderMetadata(provider, id);
|
||||
});
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(providerMap, "nip07")) {
|
||||
providerMap.nip07 = nip07;
|
||||
metadataMap.nip07 = normalizeProviderMetadata(nip07, "nip07");
|
||||
} else if (!Object.prototype.hasOwnProperty.call(metadataMap, "nip07")) {
|
||||
metadataMap.nip07 = normalizeProviderMetadata(providerMap.nip07, "nip07");
|
||||
}
|
||||
|
||||
const builtInMetadata = [
|
||||
{
|
||||
id: "nsec",
|
||||
label: "nsec or seed (direct private key)",
|
||||
description: "Use a saved private key stored locally.",
|
||||
badgeVariant: "warning",
|
||||
},
|
||||
];
|
||||
|
||||
builtInMetadata.forEach((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const id =
|
||||
typeof entry.id === "string" && entry.id.trim() ? entry.id.trim() : null;
|
||||
if (!id || Object.prototype.hasOwnProperty.call(metadataMap, id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
metadataMap[id] = Object.freeze({
|
||||
id,
|
||||
label:
|
||||
typeof entry.label === "string" && entry.label.trim()
|
||||
? entry.label.trim()
|
||||
: "Saved profile",
|
||||
description:
|
||||
typeof entry.description === "string" && entry.description.trim()
|
||||
? entry.description.trim()
|
||||
: "",
|
||||
badgeVariant:
|
||||
typeof entry.badgeVariant === "string" && entry.badgeVariant.trim()
|
||||
? entry.badgeVariant.trim()
|
||||
: "neutral",
|
||||
});
|
||||
});
|
||||
|
||||
export const providers = Object.freeze({ ...providerMap });
|
||||
export const providerMetadata = Object.freeze({ ...metadataMap });
|
||||
|
||||
const unknownMetadataCache = new Map();
|
||||
|
||||
function getFallbackMetadata(providerId) {
|
||||
const id =
|
||||
typeof providerId === "string" && providerId.trim() ? providerId.trim() : "unknown";
|
||||
if (!unknownMetadataCache.has(id)) {
|
||||
unknownMetadataCache.set(
|
||||
id,
|
||||
Object.freeze({
|
||||
id,
|
||||
label: "Saved profile",
|
||||
description: "",
|
||||
badgeVariant: "neutral",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return unknownMetadataCache.get(id);
|
||||
}
|
||||
|
||||
export function getProviderMetadata(providerId) {
|
||||
if (typeof providerId === "string" && providerId.trim()) {
|
||||
const normalizedId = providerId.trim();
|
||||
if (Object.prototype.hasOwnProperty.call(providerMetadata, normalizedId)) {
|
||||
return providerMetadata[normalizedId];
|
||||
}
|
||||
return getFallbackMetadata(normalizedId);
|
||||
}
|
||||
|
||||
return getFallbackMetadata("saved-profile");
|
||||
}
|
||||
|
||||
export function getProvider(providerId = "nip07") {
|
||||
const id =
|
||||
typeof providerId === "string" && providerId.trim()
|
||||
? providerId.trim()
|
||||
: "nip07";
|
||||
|
||||
const provider = providers[id];
|
||||
if (!provider || typeof provider.login !== "function") {
|
||||
const error = new Error(`Unknown auth provider: ${id}`);
|
||||
error.code = "unknown-auth-provider";
|
||||
throw error;
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
export default getProvider;
|
||||
@@ -1,280 +1,78 @@
|
||||
import { isDevMode } from "../../config.js";
|
||||
import { accessControl } from "../../accessControl.js";
|
||||
import {
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
getEnableVariantTimeoutMs,
|
||||
NIP07_LOGIN_TIMEOUT_MS,
|
||||
runNip07WithRetry,
|
||||
} from "../../nip07Support.js";
|
||||
|
||||
const PROVIDER_ID = "nip07";
|
||||
const PROVIDER_LABEL = "NIP-07 browser extension";
|
||||
const PROVIDER_BUTTON_CLASS =
|
||||
"w-full bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors";
|
||||
const PROVIDER_BUTTON_LABEL = "Login with Extension (NIP-07)";
|
||||
const PROVIDER_LOADING_LABEL = "Connecting to NIP-07 extension...";
|
||||
const PROVIDER_SLOW_HINT = "Waiting for the extension prompt…";
|
||||
const PROVIDER_ERROR_MESSAGE =
|
||||
"Failed to login with NIP-07. Please try again.";
|
||||
const PROVIDER_LABEL = "extension (nip-07)";
|
||||
const PROVIDER_DESCRIPTION =
|
||||
"Authorize with a NIP-07 browser extension like Alby or nos2x.";
|
||||
const PROVIDER_CAPABILITIES = Object.freeze([
|
||||
Object.freeze({
|
||||
id: "signing",
|
||||
label: "Signs Nostr events",
|
||||
variant: "info",
|
||||
}),
|
||||
Object.freeze({
|
||||
id: "session",
|
||||
label: "Loads your public key",
|
||||
variant: "neutral",
|
||||
}),
|
||||
]);
|
||||
const PROVIDER_BUTTON = Object.freeze({
|
||||
variant: "primary",
|
||||
});
|
||||
const PROVIDER_MESSAGES = Object.freeze({
|
||||
loading: "Connecting to your extension…",
|
||||
slow: "Waiting for the extension prompt…",
|
||||
error: "Failed to login with your browser extension. Please try again.",
|
||||
});
|
||||
|
||||
async function resolveNip19Tools(nostrClient) {
|
||||
if (nostrClient && typeof nostrClient.ensureNostrTools === "function") {
|
||||
return nostrClient.ensureNostrTools();
|
||||
function normalizePubkey(result) {
|
||||
if (typeof result === "string") {
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
|
||||
if (!result || typeof result !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof result.pubkey === "string") {
|
||||
return result.pubkey;
|
||||
}
|
||||
|
||||
if (typeof result.publicKey === "string") {
|
||||
return result.publicKey;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
async function login({ nostrClient, options } = {}) {
|
||||
if (!nostrClient || typeof nostrClient !== "object") {
|
||||
throw new Error("NIP-07 login is not available.");
|
||||
}
|
||||
|
||||
const extension = typeof window !== "undefined" ? window.nostr : null;
|
||||
if (!extension) {
|
||||
throw new Error("Please install a NIP-07 compatible extension.");
|
||||
}
|
||||
|
||||
if (typeof extension.getPublicKey !== "function") {
|
||||
throw new Error(
|
||||
"This NIP-07 extension is missing getPublicKey support. Please update the extension.",
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedOptions =
|
||||
options && typeof options === "object" ? { ...options } : {};
|
||||
const allowAccountSelection = normalizedOptions.allowAccountSelection === true;
|
||||
const expectPubkey =
|
||||
typeof normalizedOptions.expectPubkey === "string"
|
||||
? normalizedOptions.expectPubkey.trim()
|
||||
: "";
|
||||
const normalizedExpectedPubkey = expectPubkey
|
||||
? expectPubkey.toLowerCase()
|
||||
: null;
|
||||
|
||||
if (typeof extension.enable === "function") {
|
||||
const requestedPermissionMethods = Array.from(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
|
||||
const permissionVariants = [null];
|
||||
const objectPermissions = requestedPermissionMethods
|
||||
.map((method) =>
|
||||
typeof method === "string" && method.trim()
|
||||
? { method: method.trim() }
|
||||
: null,
|
||||
)
|
||||
.filter(Boolean);
|
||||
if (objectPermissions.length) {
|
||||
permissionVariants.push({ permissions: objectPermissions });
|
||||
}
|
||||
const stringPermissions = Array.from(
|
||||
new Set(objectPermissions.map((entry) => entry.method)),
|
||||
);
|
||||
if (stringPermissions.length) {
|
||||
permissionVariants.push({ permissions: stringPermissions });
|
||||
}
|
||||
|
||||
let enableError = null;
|
||||
for (const variant of permissionVariants) {
|
||||
try {
|
||||
await runNip07WithRetry(
|
||||
() => (variant ? extension.enable(variant) : extension.enable()),
|
||||
{
|
||||
label: "extension.enable",
|
||||
...(variant
|
||||
? {
|
||||
timeoutMs: Math.min(
|
||||
NIP07_LOGIN_TIMEOUT_MS,
|
||||
getEnableVariantTimeoutMs(),
|
||||
),
|
||||
retryMultiplier: 1,
|
||||
}
|
||||
: { retryMultiplier: 1 }),
|
||||
},
|
||||
);
|
||||
enableError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
enableError = error;
|
||||
if (variant && isDevMode) {
|
||||
console.warn(
|
||||
"[auth:nip07] extension.enable request with explicit permissions failed:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enableError) {
|
||||
throw new Error(
|
||||
enableError && enableError.message
|
||||
? enableError.message
|
||||
: "The NIP-07 extension denied the permission request.",
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof nostrClient.markExtensionPermissions === "function") {
|
||||
try {
|
||||
nostrClient.markExtensionPermissions(requestedPermissionMethods);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[auth:nip07] Failed to mark extension permissions:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allowAccountSelection && typeof extension.selectAccounts === "function") {
|
||||
try {
|
||||
const selection = await runNip07WithRetry(
|
||||
() => extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined),
|
||||
{ label: "extension.selectAccounts" },
|
||||
);
|
||||
|
||||
const didCancelSelection =
|
||||
selection === undefined ||
|
||||
selection === null ||
|
||||
selection === false ||
|
||||
(Array.isArray(selection) && selection.length === 0);
|
||||
|
||||
if (didCancelSelection) {
|
||||
throw new Error("Account selection was cancelled.");
|
||||
}
|
||||
} catch (selectionErr) {
|
||||
const message =
|
||||
selectionErr && typeof selectionErr.message === "string"
|
||||
? selectionErr.message
|
||||
: "Account selection was cancelled.";
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), {
|
||||
label: "extension.getPublicKey",
|
||||
});
|
||||
|
||||
if (!pubkey || typeof pubkey !== "string") {
|
||||
throw new Error(
|
||||
"The NIP-07 extension did not return a public key. Please try again.",
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedExpectedPubkey && pubkey.toLowerCase() !== normalizedExpectedPubkey) {
|
||||
throw new Error(
|
||||
"The selected account doesn't match the expected profile. Please try again.",
|
||||
);
|
||||
}
|
||||
|
||||
const nip19Tools = await resolveNip19Tools(nostrClient);
|
||||
const npubEncode = nip19Tools?.nip19?.npubEncode;
|
||||
const npub = typeof npubEncode === "function" ? npubEncode(pubkey) : null;
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("[auth:nip07] Got pubkey:", pubkey);
|
||||
if (npub) {
|
||||
console.log("[auth:nip07] Converted to npub:", npub);
|
||||
}
|
||||
console.log("[auth:nip07] Whitelist:", accessControl.getWhitelist());
|
||||
console.log("[auth:nip07] Blacklist:", accessControl.getBlacklist());
|
||||
}
|
||||
|
||||
if (npub && !accessControl.canAccess(npub)) {
|
||||
if (accessControl.isBlacklisted(npub)) {
|
||||
throw new Error("Your account has been blocked on this platform.");
|
||||
}
|
||||
throw new Error("Access restricted to whitelisted users only.");
|
||||
}
|
||||
|
||||
if (typeof nostrClient.markExtensionPermissions === "function") {
|
||||
try {
|
||||
nostrClient.markExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[auth:nip07] Failed to memoize default permission set:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const signerPayload = (() => {
|
||||
const signEvent =
|
||||
typeof extension.signEvent === "function"
|
||||
? extension.signEvent.bind(extension)
|
||||
: null;
|
||||
const encrypt =
|
||||
extension.nip04 && typeof extension.nip04.encrypt === "function"
|
||||
? extension.nip04.encrypt.bind(extension.nip04)
|
||||
: null;
|
||||
const decrypt =
|
||||
extension.nip04 && typeof extension.nip04.decrypt === "function"
|
||||
? extension.nip04.decrypt.bind(extension.nip04)
|
||||
: null;
|
||||
|
||||
if (!signEvent && !encrypt && !decrypt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: PROVIDER_ID,
|
||||
pubkey,
|
||||
signEvent,
|
||||
encrypt,
|
||||
decrypt,
|
||||
metadata: { label: PROVIDER_LABEL },
|
||||
};
|
||||
})();
|
||||
|
||||
if (signerPayload && typeof nostrClient.setActiveSigner === "function") {
|
||||
try {
|
||||
nostrClient.setActiveSigner(signerPayload);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[auth:nip07] Failed to set active signer:", error);
|
||||
}
|
||||
}
|
||||
} else if (typeof nostrClient.clearActiveSigner === "function") {
|
||||
try {
|
||||
nostrClient.clearActiveSigner(PROVIDER_ID);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[auth:nip07] Failed to clear active signer after login:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof nostrClient.pubkey === "string" || nostrClient.pubkey === null) {
|
||||
nostrClient.pubkey = pubkey;
|
||||
}
|
||||
|
||||
const signerResult = signerPayload
|
||||
? {
|
||||
providerId: PROVIDER_ID,
|
||||
signEvent: signerPayload.signEvent,
|
||||
encrypt: signerPayload.encrypt,
|
||||
decrypt: signerPayload.decrypt,
|
||||
}
|
||||
: null;
|
||||
|
||||
return { authType: PROVIDER_ID, pubkey, signer: signerResult };
|
||||
}
|
||||
|
||||
export default Object.freeze({
|
||||
export default {
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
login,
|
||||
ui: Object.freeze({
|
||||
buttonClass: PROVIDER_BUTTON_CLASS,
|
||||
buttonLabel: PROVIDER_BUTTON_LABEL,
|
||||
loadingLabel: PROVIDER_LOADING_LABEL,
|
||||
slowHint: PROVIDER_SLOW_HINT,
|
||||
errorMessage: PROVIDER_ERROR_MESSAGE,
|
||||
}),
|
||||
});
|
||||
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, options } = {}) {
|
||||
if (!nostrClient || typeof nostrClient.login !== "function") {
|
||||
const error = new Error("Nostr login is not available.");
|
||||
error.code = "provider-unavailable";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await nostrClient.login(options || {});
|
||||
const pubkey = normalizePubkey(result);
|
||||
const signer =
|
||||
result && typeof result === "object" && result.signer
|
||||
? result.signer
|
||||
: null;
|
||||
|
||||
return {
|
||||
authType: PROVIDER_ID,
|
||||
pubkey,
|
||||
signer,
|
||||
rawResult: result,
|
||||
};
|
||||
},
|
||||
};
|
||||
+243
-1097
File diff suppressed because it is too large.
Load diff
+143
-496
@@ -1,501 +1,148 @@
|
||||
import { isDevMode } from "../../config.js";
|
||||
|
||||
const PROVIDER_ID = "nsec";
|
||||
const PROVIDER_LABEL = "Direct key (nsec/seed)";
|
||||
const PROVIDER_BUTTON_CLASS =
|
||||
"w-full bg-emerald-500 text-white px-4 py-2 rounded-md hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 transition-colors";
|
||||
const PROVIDER_BUTTON_LABEL = "Unlock with nsec or seed";
|
||||
const PROVIDER_LOADING_LABEL = "Decrypting your key...";
|
||||
const PROVIDER_SLOW_HINT = "Still waiting for the key input...";
|
||||
const PROVIDER_ERROR_MESSAGE =
|
||||
"Unable to unlock your key. Double-check the value and try again.";
|
||||
|
||||
const PROVIDER_LABEL = "nsec or seed (direct private key)";
|
||||
const PROVIDER_DESCRIPTION =
|
||||
"Your private key never leaves this device. We encrypt the backup with a session secret before asking to remember it.";
|
||||
const PROVIDER_DISABLED_LABEL = "Secure storage unavailable";
|
||||
const PROVIDER_DISABLED_DESCRIPTION =
|
||||
"This browser is missing WebCrypto support required to safely encrypt your key.";
|
||||
|
||||
const BACKUP_STORAGE_KEY = "bitvid:auth:nsec-backup:v1";
|
||||
const BACKUP_CONTEXT_LABEL = "bitvid:nsec-provider:v1";
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
function getRuntimeCrypto() {
|
||||
if (typeof window !== "undefined" && window?.crypto) {
|
||||
return window.crypto;
|
||||
}
|
||||
if (typeof globalThis !== "undefined" && globalThis.crypto) {
|
||||
return globalThis.crypto;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getTextEncoder() {
|
||||
if (typeof TextEncoder === "function") {
|
||||
return new TextEncoder();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeCrypto = getRuntimeCrypto();
|
||||
const textEncoder = getTextEncoder();
|
||||
|
||||
function hasWebCryptoSupport() {
|
||||
if (!runtimeCrypto) {
|
||||
return false;
|
||||
}
|
||||
const subtle = runtimeCrypto.subtle || runtimeCrypto.webkitSubtle;
|
||||
if (!subtle) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof runtimeCrypto.getRandomValues === "function" &&
|
||||
typeof subtle.importKey === "function" &&
|
||||
typeof subtle.encrypt === "function" &&
|
||||
typeof subtle.decrypt === "function" &&
|
||||
typeof TextEncoder === "function"
|
||||
);
|
||||
}
|
||||
|
||||
const WEB_CRYPTO_AVAILABLE = hasWebCryptoSupport();
|
||||
|
||||
function hexToBytes(hex) {
|
||||
if (typeof hex !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = hex.trim();
|
||||
if (!normalized || normalized.length % 2 !== 0) {
|
||||
return null;
|
||||
}
|
||||
if (!/^[0-9a-f]+$/i.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
const bytes = new Uint8Array(normalized.length / 2);
|
||||
for (let index = 0; index < normalized.length; index += 2) {
|
||||
const byte = normalized.slice(index, index + 2);
|
||||
bytes[index / 2] = parseInt(byte, 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function concatBytes(...segments) {
|
||||
const filtered = segments.filter(
|
||||
(segment) => segment && typeof segment.length === "number",
|
||||
);
|
||||
const totalLength = filtered.reduce((sum, segment) => sum + segment.length, 0);
|
||||
const combined = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const segment of filtered) {
|
||||
combined.set(segment, offset);
|
||||
offset += segment.length;
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
if (!bytes) {
|
||||
return "";
|
||||
}
|
||||
if (typeof btoa === "function") {
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
}
|
||||
// Fallback: manual base64 encoding
|
||||
const alphabet =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let output = "";
|
||||
let i;
|
||||
for (i = 0; i + 2 < bytes.length; i += 3) {
|
||||
const triple = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
|
||||
output += alphabet[(triple >> 18) & 0x3f];
|
||||
output += alphabet[(triple >> 12) & 0x3f];
|
||||
output += alphabet[(triple >> 6) & 0x3f];
|
||||
output += alphabet[triple & 0x3f];
|
||||
}
|
||||
if (i < bytes.length) {
|
||||
let remaining = bytes[i] << 16;
|
||||
output += alphabet[(remaining >> 18) & 0x3f];
|
||||
if (i + 1 < bytes.length) {
|
||||
remaining |= bytes[i + 1] << 8;
|
||||
output += alphabet[(remaining >> 12) & 0x3f];
|
||||
output += alphabet[(remaining >> 6) & 0x3f];
|
||||
output += "=";
|
||||
} else {
|
||||
output += alphabet[(remaining >> 12) & 0x3f];
|
||||
output += "==";
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function getPromptFunction(options) {
|
||||
if (options && typeof options.promptSecret === "function") {
|
||||
return options.promptSecret;
|
||||
}
|
||||
if (typeof window !== "undefined" && typeof window.prompt === "function") {
|
||||
return () =>
|
||||
window.prompt(
|
||||
"Enter your nsec (bech32) or 64-character hex seed. This never leaves your browser.",
|
||||
"",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConfirmFunction(options) {
|
||||
if (options && typeof options.promptRemember === "function") {
|
||||
return options.promptRemember;
|
||||
}
|
||||
if (typeof window !== "undefined" && typeof window.confirm === "function") {
|
||||
return (message) => window.confirm(message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearStoredBackup() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(BACKUP_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[auth:nsec] Failed to clear stored backup:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function persistEncryptedBackup({
|
||||
privateKey,
|
||||
pubkey,
|
||||
sessionPrivateKey,
|
||||
}) {
|
||||
if (!WEB_CRYPTO_AVAILABLE || typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
if (!privateKey || !pubkey || !sessionPrivateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const subtle = runtimeCrypto.subtle || runtimeCrypto.webkitSubtle;
|
||||
const pubkeyBytes = hexToBytes(pubkey);
|
||||
const sessionBytes = hexToBytes(sessionPrivateKey);
|
||||
if (!pubkeyBytes || !sessionBytes) {
|
||||
throw new Error("invalid-key-material");
|
||||
}
|
||||
if (!textEncoder) {
|
||||
throw new Error("text-encoder-missing");
|
||||
}
|
||||
const contextBytes = textEncoder.encode(BACKUP_CONTEXT_LABEL);
|
||||
const salt = concatBytes(sessionBytes, pubkeyBytes, contextBytes);
|
||||
const digest = await subtle.digest("SHA-256", salt);
|
||||
const aesKey = await subtle.importKey(
|
||||
"raw",
|
||||
digest,
|
||||
{ name: "AES-GCM" },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
const iv = new Uint8Array(12);
|
||||
runtimeCrypto.getRandomValues(iv);
|
||||
const plaintextBytes = textEncoder.encode(privateKey);
|
||||
const ciphertextBuffer = await subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
aesKey,
|
||||
plaintextBytes,
|
||||
);
|
||||
const ciphertext = new Uint8Array(ciphertextBuffer);
|
||||
|
||||
const payload = {
|
||||
version: 1,
|
||||
createdAt: Date.now(),
|
||||
pubkey,
|
||||
iv: bytesToBase64(iv),
|
||||
ciphertext: bytesToBase64(ciphertext),
|
||||
};
|
||||
|
||||
localStorage.setItem(BACKUP_STORAGE_KEY, JSON.stringify(payload));
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[auth:nsec] Failed to persist encrypted backup:", error);
|
||||
}
|
||||
const message =
|
||||
error && typeof error.message === "string" && error.message
|
||||
? error.message
|
||||
: "Failed to persist encrypted backup.";
|
||||
const failure = new Error(message);
|
||||
failure.cause = error;
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
async function derivePrivateKey(secret, tools) {
|
||||
const trimmed = typeof secret === "string" ? secret.trim() : "";
|
||||
if (!trimmed) {
|
||||
throw new Error("A private key or seed is required.");
|
||||
}
|
||||
|
||||
if (trimmed.toLowerCase().startsWith("nsec")) {
|
||||
const nip19 = tools?.nip19;
|
||||
if (!nip19 || typeof nip19.decode !== "function") {
|
||||
throw new Error("Unable to decode nsec without nostr-tools support.");
|
||||
}
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded?.type === "nsec" && typeof decoded.data === "string") {
|
||||
const normalized = decoded.data.trim().toLowerCase();
|
||||
if (HEX64_REGEX.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error("Invalid nsec value. Please double-check and try again.");
|
||||
}
|
||||
throw new Error("Unsupported nsec payload. Please try again.");
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unrecognised seed format. Please provide an nsec or 64-character hex seed.",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return "";
|
||||
}
|
||||
return pubkey.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function createSignEventFn({ tools, privateKey, pubkey }) {
|
||||
if (!tools || typeof tools.signEvent !== "function" || !tools.getEventHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return async function signEvent(event) {
|
||||
const tags = Array.isArray(event?.tags)
|
||||
? event.tags.map((tag) => (Array.isArray(tag) ? [...tag] : tag))
|
||||
: [];
|
||||
const prepared = {
|
||||
kind: event?.kind,
|
||||
pubkey: typeof event?.pubkey === "string" && event.pubkey.trim()
|
||||
? event.pubkey.trim()
|
||||
: pubkey,
|
||||
created_at: event?.created_at,
|
||||
tags,
|
||||
content: typeof event?.content === "string" ? event.content : "",
|
||||
};
|
||||
if (!prepared.pubkey || prepared.pubkey.toLowerCase() !== pubkey) {
|
||||
prepared.pubkey = pubkey;
|
||||
}
|
||||
const id = tools.getEventHash(prepared);
|
||||
const sig = tools.signEvent(prepared, privateKey);
|
||||
return { ...prepared, id, sig };
|
||||
};
|
||||
}
|
||||
|
||||
function createNip04EncryptFn({ tools, privateKey }) {
|
||||
if (!tools?.nip04 || typeof tools.nip04.encrypt !== "function") {
|
||||
return null;
|
||||
}
|
||||
return (targetPubkey, plaintext) =>
|
||||
tools.nip04.encrypt(privateKey, targetPubkey, plaintext);
|
||||
}
|
||||
|
||||
function createNip04DecryptFn({ tools, privateKey }) {
|
||||
if (!tools?.nip04 || typeof tools.nip04.decrypt !== "function") {
|
||||
return null;
|
||||
}
|
||||
return (targetPubkey, ciphertext) =>
|
||||
tools.nip04.decrypt(privateKey, targetPubkey, ciphertext);
|
||||
}
|
||||
|
||||
async function login({ nostrClient, options } = {}) {
|
||||
if (!WEB_CRYPTO_AVAILABLE) {
|
||||
throw new Error(
|
||||
"Manual key login requires WebCrypto support. Please switch to a compatible browser.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!nostrClient || typeof nostrClient.ensureNostrTools !== "function") {
|
||||
throw new Error("Manual key login is unavailable in this session.");
|
||||
}
|
||||
|
||||
const normalizedOptions =
|
||||
options && typeof options === "object" ? { ...options } : {};
|
||||
|
||||
const promptSecret = getPromptFunction(normalizedOptions);
|
||||
let secret =
|
||||
typeof normalizedOptions.secret === "string"
|
||||
? normalizedOptions.secret.trim()
|
||||
: "";
|
||||
|
||||
if (!secret) {
|
||||
if (!promptSecret) {
|
||||
throw new Error("No private key provided.");
|
||||
}
|
||||
const promptResult = await Promise.resolve(promptSecret());
|
||||
secret = typeof promptResult === "string" ? promptResult.trim() : "";
|
||||
}
|
||||
|
||||
if (!secret) {
|
||||
throw new Error("A private key is required to continue.");
|
||||
}
|
||||
|
||||
const tools = await nostrClient.ensureNostrTools();
|
||||
if (!tools || typeof tools.getPublicKey !== "function") {
|
||||
throw new Error(
|
||||
"Unable to load Nostr cryptography helpers. Please try again after reloading.",
|
||||
);
|
||||
}
|
||||
|
||||
const privateKey = await derivePrivateKey(secret, tools);
|
||||
let pubkey = "";
|
||||
try {
|
||||
pubkey = tools.getPublicKey(privateKey);
|
||||
} catch (error) {
|
||||
throw new Error("Failed to derive a public key from the provided secret.");
|
||||
}
|
||||
|
||||
const normalizedPubkey = normalizePubkey(pubkey);
|
||||
if (!normalizedPubkey || !HEX64_REGEX.test(normalizedPubkey)) {
|
||||
throw new Error("Derived public key is invalid. Please verify your secret.");
|
||||
}
|
||||
|
||||
let rememberChoice = null;
|
||||
if (normalizedOptions.remember === true) {
|
||||
rememberChoice = true;
|
||||
} else if (normalizedOptions.remember === false) {
|
||||
rememberChoice = false;
|
||||
}
|
||||
|
||||
if (rememberChoice === null) {
|
||||
const confirmRemember = getConfirmFunction(normalizedOptions);
|
||||
if (confirmRemember) {
|
||||
const confirmation = await Promise.resolve(
|
||||
confirmRemember(
|
||||
"Remember this key on this device? We'll encrypt it with a session secret and store it locally.",
|
||||
),
|
||||
);
|
||||
rememberChoice = !!confirmation;
|
||||
} else {
|
||||
rememberChoice = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await nostrClient.ensureSessionActor();
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[auth:nsec] Failed to ensure session actor before storing backup:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sessionPrivateKey =
|
||||
typeof nostrClient?.sessionActor?.privateKey === "string"
|
||||
? nostrClient.sessionActor.privateKey.trim()
|
||||
: "";
|
||||
|
||||
if (rememberChoice && !sessionPrivateKey) {
|
||||
throw new Error(
|
||||
"Secure session storage failed. Unable to remember this key in this browser.",
|
||||
);
|
||||
}
|
||||
|
||||
if (rememberChoice && sessionPrivateKey) {
|
||||
await persistEncryptedBackup({
|
||||
privateKey,
|
||||
pubkey: normalizedPubkey,
|
||||
sessionPrivateKey,
|
||||
});
|
||||
} else {
|
||||
clearStoredBackup();
|
||||
}
|
||||
|
||||
const signerPayload = (() => {
|
||||
const signEvent = createSignEventFn({
|
||||
tools,
|
||||
privateKey,
|
||||
pubkey: normalizedPubkey,
|
||||
});
|
||||
const encrypt = createNip04EncryptFn({ tools, privateKey });
|
||||
const decrypt = createNip04DecryptFn({ tools, privateKey });
|
||||
|
||||
if (!signEvent && !encrypt && !decrypt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: PROVIDER_ID,
|
||||
pubkey: normalizedPubkey,
|
||||
signEvent,
|
||||
encrypt,
|
||||
decrypt,
|
||||
metadata: { label: PROVIDER_LABEL },
|
||||
};
|
||||
})();
|
||||
|
||||
if (signerPayload && typeof nostrClient.setActiveSigner === "function") {
|
||||
try {
|
||||
nostrClient.setActiveSigner(signerPayload);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[auth:nsec] Failed to register signer:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const signerResult = signerPayload
|
||||
? {
|
||||
providerId: PROVIDER_ID,
|
||||
signEvent: signerPayload.signEvent,
|
||||
encrypt: signerPayload.encrypt,
|
||||
decrypt: signerPayload.decrypt,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
authType: PROVIDER_ID,
|
||||
pubkey: normalizedPubkey,
|
||||
signer: signerResult,
|
||||
};
|
||||
}
|
||||
|
||||
function clearStorage() {
|
||||
clearStoredBackup();
|
||||
}
|
||||
|
||||
export default Object.freeze({
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
login,
|
||||
clearStorage,
|
||||
ui: Object.freeze({
|
||||
buttonClass: PROVIDER_BUTTON_CLASS,
|
||||
buttonLabel: PROVIDER_BUTTON_LABEL,
|
||||
loadingLabel: PROVIDER_LOADING_LABEL,
|
||||
slowHint: PROVIDER_SLOW_HINT,
|
||||
errorMessage: PROVIDER_ERROR_MESSAGE,
|
||||
description: PROVIDER_DESCRIPTION,
|
||||
disabled: !WEB_CRYPTO_AVAILABLE,
|
||||
disabledLabel: PROVIDER_DISABLED_LABEL,
|
||||
disabledDescription: PROVIDER_DISABLED_DESCRIPTION,
|
||||
"Paste an nsec, hex key, or BIP-39 seed. Keep it encrypted on this device if you choose.";
|
||||
const PROVIDER_CAPABILITIES = Object.freeze([
|
||||
Object.freeze({
|
||||
id: "signing",
|
||||
label: "Signs locally",
|
||||
variant: "warning",
|
||||
}),
|
||||
Object.freeze({
|
||||
id: "session",
|
||||
label: "Encrypted backup optional",
|
||||
variant: "neutral",
|
||||
}),
|
||||
]);
|
||||
const PROVIDER_BUTTON = Object.freeze({
|
||||
variant: "ghost",
|
||||
});
|
||||
const PROVIDER_MESSAGES = Object.freeze({
|
||||
loading: "Unlocking direct key…",
|
||||
slow: "Still deriving your key…",
|
||||
error: "Failed to unlock the direct key. Check your secret and try again.",
|
||||
});
|
||||
|
||||
export { clearStorage as clearNsecBackupStorage };
|
||||
function normalizePubkey(result) {
|
||||
if (typeof result === "string") {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!result || typeof result !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof result.pubkey === "string") {
|
||||
return result.pubkey;
|
||||
}
|
||||
|
||||
if (typeof result.publicKey === "string") {
|
||||
return result.publicKey;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeOptions(options) {
|
||||
if (!options || typeof options !== "object") {
|
||||
return {
|
||||
secret: "",
|
||||
persist: false,
|
||||
passphrase: "",
|
||||
unlockStored: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
secret: typeof options.secret === "string" ? options.secret : "",
|
||||
persist: options.persist === true,
|
||||
passphrase: typeof options.passphrase === "string" ? options.passphrase : "",
|
||||
unlockStored: options.unlockStored === true,
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
description: PROVIDER_DESCRIPTION,
|
||||
eyebrow: "Advanced",
|
||||
tone: "warning",
|
||||
badgeVariant: "warning",
|
||||
capabilities: PROVIDER_CAPABILITIES,
|
||||
button: PROVIDER_BUTTON,
|
||||
messages: PROVIDER_MESSAGES,
|
||||
errorMessage: PROVIDER_MESSAGES.error,
|
||||
async login({ nostrClient, options } = {}) {
|
||||
if (!nostrClient || typeof nostrClient !== "object") {
|
||||
const error = new Error("Direct key login is not available.");
|
||||
error.code = "provider-unavailable";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof nostrClient.derivePrivateKeyFromSecret !== "function" ||
|
||||
typeof nostrClient.registerPrivateKeySigner !== "function"
|
||||
) {
|
||||
const error = new Error("Direct key login is not supported in this environment.");
|
||||
error.code = "provider-unavailable";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeOptions(options);
|
||||
|
||||
if (normalized.unlockStored) {
|
||||
if (typeof nostrClient.unlockStoredSessionActor !== "function") {
|
||||
const error = new Error("Stored key unlock is not supported.");
|
||||
error.code = "unlock-unavailable";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const suppliedPassphrase = normalized.passphrase;
|
||||
if (typeof suppliedPassphrase !== "string" || !suppliedPassphrase.trim()) {
|
||||
const error = new Error("A passphrase is required to unlock the saved key.");
|
||||
error.code = "passphrase-required";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const unlockResult = await nostrClient.unlockStoredSessionActor(suppliedPassphrase);
|
||||
const unlockedPubkey = normalizePubkey(unlockResult);
|
||||
|
||||
return {
|
||||
authType: PROVIDER_ID,
|
||||
pubkey: unlockedPubkey,
|
||||
};
|
||||
}
|
||||
|
||||
const trimmedSecret = normalized.secret.trim();
|
||||
if (!trimmedSecret) {
|
||||
const error = new Error("A private key is required to continue.");
|
||||
error.code = "secret-required";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { privateKey, pubkey } = await nostrClient.derivePrivateKeyFromSecret(trimmedSecret);
|
||||
|
||||
const shouldPersist = normalized.persist === true;
|
||||
const passphrase = shouldPersist ? normalized.passphrase : "";
|
||||
|
||||
if (shouldPersist && (!passphrase || !passphrase.trim())) {
|
||||
const error = new Error("A passphrase is required to remember this key.");
|
||||
error.code = "passphrase-required";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const registrationResult = await nostrClient.registerPrivateKeySigner({
|
||||
privateKey,
|
||||
pubkey,
|
||||
persist: shouldPersist,
|
||||
passphrase: shouldPersist ? passphrase : undefined,
|
||||
});
|
||||
|
||||
const registeredPubkey = normalizePubkey(registrationResult) || pubkey;
|
||||
|
||||
return {
|
||||
authType: PROVIDER_ID,
|
||||
pubkey: registeredPubkey,
|
||||
};
|
||||
},
|
||||
};
|
||||
+190
-29
@@ -7,7 +7,7 @@ import {
|
||||
getCurrentUserNpub,
|
||||
} from "../state/appState.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import { requestDefaultExtensionPermissions } from "../nostr.js";
|
||||
import { requestDefaultExtensionPermissions } from "../nostr/defaultClient.js";
|
||||
import {
|
||||
getSavedProfiles,
|
||||
getActiveProfilePubkey,
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
getProfileCacheEntry as getCachedProfileEntry,
|
||||
setProfileCacheEntry as setCachedProfileEntry,
|
||||
} from "../state/cache.js";
|
||||
import getDefaultAuthProvider, {
|
||||
providers as defaultAuthProviders,
|
||||
} from "./authProviders/index.js";
|
||||
|
||||
class SimpleEventEmitter {
|
||||
constructor(logger = null) {
|
||||
@@ -85,11 +88,17 @@ export default class AuthService {
|
||||
relayManager,
|
||||
logger,
|
||||
accessControl,
|
||||
authProviders,
|
||||
getAuthProvider,
|
||||
} = {}) {
|
||||
this.nostrClient = nostrClient || null;
|
||||
this.userBlocks = userBlocks || null;
|
||||
this.relayManager = relayManager || null;
|
||||
this.accessControl = accessControl || null;
|
||||
this.authProviders =
|
||||
authProviders && typeof authProviders === "object"
|
||||
? authProviders
|
||||
: defaultAuthProviders;
|
||||
|
||||
if (typeof logger === "function") {
|
||||
this.logger = logger;
|
||||
@@ -106,6 +115,73 @@ export default class AuthService {
|
||||
userLogger.warn("[AuthService] logger threw", logError);
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof getAuthProvider === "function") {
|
||||
this.resolveAuthProvider = (providerId) => {
|
||||
const normalizedId = this.normalizeProviderId(providerId);
|
||||
const provider = getAuthProvider(normalizedId);
|
||||
if (!provider || typeof provider.login !== "function") {
|
||||
const error = new Error(`Unknown auth provider: ${normalizedId}`);
|
||||
error.code = "unknown-auth-provider";
|
||||
throw error;
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
} else {
|
||||
this.resolveAuthProvider = (providerId) => {
|
||||
const normalizedId = this.normalizeProviderId(providerId);
|
||||
const registry = this.authProviders || defaultAuthProviders;
|
||||
const provider =
|
||||
registry && typeof registry === "object"
|
||||
? registry[normalizedId]
|
||||
: null;
|
||||
|
||||
if (provider && typeof provider.login === "function") {
|
||||
return provider;
|
||||
}
|
||||
|
||||
return getDefaultAuthProvider(normalizedId);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
normalizeProviderId(providerId) {
|
||||
return typeof providerId === "string" && providerId.trim()
|
||||
? providerId.trim()
|
||||
: "nip07";
|
||||
}
|
||||
|
||||
normalizeAuthType(authTypeCandidate, providerId, providerResult) {
|
||||
const candidates = [];
|
||||
|
||||
if (typeof authTypeCandidate === "string") {
|
||||
candidates.push(authTypeCandidate);
|
||||
}
|
||||
|
||||
if (providerResult && typeof providerResult === "object") {
|
||||
const resultAuthType = providerResult.authType;
|
||||
if (typeof resultAuthType === "string") {
|
||||
candidates.push(resultAuthType);
|
||||
}
|
||||
|
||||
const resultProviderId = providerResult.providerId;
|
||||
if (typeof resultProviderId === "string") {
|
||||
candidates.push(resultProviderId);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof providerId === "string") {
|
||||
candidates.push(providerId);
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const trimmed = candidate.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return "nip07";
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
@@ -252,42 +328,86 @@ export default class AuthService {
|
||||
return null;
|
||||
}
|
||||
|
||||
async requestLogin(options = {}) {
|
||||
if (!this.nostrClient || typeof this.nostrClient.login !== "function") {
|
||||
throw new Error("Nostr login is not available.");
|
||||
}
|
||||
async requestLogin(rawOptions = {}) {
|
||||
const normalizedOptions =
|
||||
rawOptions && typeof rawOptions === "object" ? { ...rawOptions } : {};
|
||||
const {
|
||||
providerId: requestedProviderId = "nip07",
|
||||
persistActive,
|
||||
autoApply,
|
||||
...providerOptions
|
||||
} = normalizedOptions;
|
||||
|
||||
const result = await this.nostrClient.login(options);
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult.ok) {
|
||||
const error = new Error(
|
||||
"The NIP-07 extension denied the permission request required to finish logging in.",
|
||||
);
|
||||
error.code = "extension-permission-denied";
|
||||
error.cause = permissionResult.error;
|
||||
throw error;
|
||||
}
|
||||
const providerId = this.normalizeProviderId(requestedProviderId);
|
||||
const provider = this.resolveAuthProvider(providerId);
|
||||
const providerResult = await provider.login({
|
||||
nostrClient: this.nostrClient,
|
||||
options: providerOptions,
|
||||
});
|
||||
|
||||
const rawPubkey =
|
||||
providerResult && typeof providerResult === "object"
|
||||
? providerResult.pubkey
|
||||
: providerResult;
|
||||
const pubkey =
|
||||
typeof result === "string"
|
||||
? result
|
||||
: result && typeof result === "object"
|
||||
? result.pubkey || result.publicKey || ""
|
||||
typeof rawPubkey === "string"
|
||||
? rawPubkey
|
||||
: providerResult && typeof providerResult === "object"
|
||||
? providerResult.publicKey || ""
|
||||
: "";
|
||||
|
||||
const authTypeCandidate =
|
||||
providerResult && typeof providerResult === "object"
|
||||
? providerResult.authType
|
||||
: null;
|
||||
const authType = this.normalizeAuthType(
|
||||
authTypeCandidate,
|
||||
providerId,
|
||||
providerResult,
|
||||
);
|
||||
const signer =
|
||||
providerResult && typeof providerResult === "object"
|
||||
? providerResult.signer || null
|
||||
: null;
|
||||
const detailAuthType =
|
||||
typeof authType === "string" && authType.trim() ? authType.trim() : "nip07";
|
||||
|
||||
if (authType === "nip07") {
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult.ok) {
|
||||
const error = new Error(
|
||||
"The NIP-07 extension denied the permission request required to finish logging in.",
|
||||
);
|
||||
error.code = "extension-permission-denied";
|
||||
error.cause = permissionResult.error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = typeof pubkey === "string" ? pubkey.trim() : "";
|
||||
if (!trimmed) {
|
||||
return { pubkey: null };
|
||||
return { pubkey: null, providerId, authType: detailAuthType, signer };
|
||||
}
|
||||
|
||||
if (options?.autoApply === false) {
|
||||
return { pubkey: trimmed };
|
||||
if (autoApply === false) {
|
||||
return { pubkey: trimmed, providerId, authType: detailAuthType, signer };
|
||||
}
|
||||
|
||||
const detail = await this.login(trimmed, {
|
||||
persistActive: options?.persistActive !== false,
|
||||
persistActive: persistActive !== false,
|
||||
providerId,
|
||||
authType: detailAuthType,
|
||||
signer,
|
||||
providerResult,
|
||||
});
|
||||
|
||||
return { pubkey: trimmed, detail };
|
||||
return {
|
||||
pubkey: trimmed,
|
||||
detail,
|
||||
providerId,
|
||||
authType: detailAuthType,
|
||||
signer,
|
||||
};
|
||||
}
|
||||
|
||||
async handleUploadSubmit(payload, { publish } = {}) {
|
||||
@@ -311,9 +431,28 @@ export default class AuthService {
|
||||
|
||||
async login(pubkey, options = {}) {
|
||||
const normalizedOptions =
|
||||
options && typeof options === "object" ? options : { persistActive: true };
|
||||
options && typeof options === "object" ? { ...options } : {};
|
||||
const persistActive =
|
||||
normalizedOptions.persistActive === false ? false : true;
|
||||
const providerId = this.normalizeProviderId(normalizedOptions.providerId);
|
||||
const providerResult = Object.prototype.hasOwnProperty.call(
|
||||
normalizedOptions,
|
||||
"providerResult",
|
||||
)
|
||||
? normalizedOptions.providerResult ?? null
|
||||
: null;
|
||||
const authTypeCandidate =
|
||||
typeof normalizedOptions.authType === "string"
|
||||
? normalizedOptions.authType
|
||||
: null;
|
||||
const authType = this.normalizeAuthType(
|
||||
authTypeCandidate,
|
||||
providerId,
|
||||
providerResult,
|
||||
);
|
||||
const signer = Object.prototype.hasOwnProperty.call(normalizedOptions, "signer")
|
||||
? normalizedOptions.signer ?? null
|
||||
: null;
|
||||
|
||||
const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
|
||||
const normalized = this.normalizeHexPubkey(pubkey);
|
||||
@@ -376,6 +515,14 @@ export default class AuthService {
|
||||
setCurrentUserNpub(npub);
|
||||
|
||||
let savedProfilesMutated = false;
|
||||
const entryAuthType = this.normalizeAuthType(
|
||||
authType,
|
||||
providerId,
|
||||
providerResult,
|
||||
);
|
||||
|
||||
const entryProviderId = this.normalizeProviderId(providerId) || entryAuthType;
|
||||
|
||||
mutateSavedProfiles((profiles) => {
|
||||
const draft = Array.isArray(profiles) ? profiles.slice() : [];
|
||||
const existingIndex = draft.findIndex((entry) => {
|
||||
@@ -391,7 +538,8 @@ export default class AuthService {
|
||||
npub: npub || (cachedProfile.npub ?? null),
|
||||
name: cachedProfile.name || draft[existingIndex]?.name || "",
|
||||
picture: cachedProfile.picture || draft[existingIndex]?.picture || "",
|
||||
authType: "nip07",
|
||||
authType: entryAuthType,
|
||||
providerId: entryProviderId,
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
@@ -400,7 +548,8 @@ export default class AuthService {
|
||||
currentEntry.npub !== nextEntry.npub ||
|
||||
currentEntry.name !== nextEntry.name ||
|
||||
currentEntry.picture !== nextEntry.picture ||
|
||||
currentEntry.authType !== nextEntry.authType;
|
||||
currentEntry.authType !== nextEntry.authType ||
|
||||
currentEntry.providerId !== nextEntry.providerId;
|
||||
if (changed) {
|
||||
draft[existingIndex] = nextEntry;
|
||||
savedProfilesMutated = true;
|
||||
@@ -438,6 +587,10 @@ export default class AuthService {
|
||||
identityChanged,
|
||||
savedProfiles: this.cloneSavedProfiles(),
|
||||
activeProfilePubkey: getActiveProfilePubkey(),
|
||||
providerId,
|
||||
authType: entryAuthType,
|
||||
signer,
|
||||
providerResult,
|
||||
postLogin: {
|
||||
pubkey: normalizedLoginPubkey,
|
||||
blocksLoaded: false,
|
||||
@@ -642,12 +795,16 @@ export default class AuthService {
|
||||
return { removed: changed };
|
||||
}
|
||||
|
||||
async switchProfile(pubkey) {
|
||||
async switchProfile(pubkey, options = {}) {
|
||||
const normalizedTarget = this.normalizeHexPubkey(pubkey);
|
||||
if (!normalizedTarget) {
|
||||
throw new Error("Unable to switch profiles: invalid account.");
|
||||
}
|
||||
|
||||
const normalizedOptions =
|
||||
options && typeof options === "object" ? { ...options } : {};
|
||||
const providerId = this.normalizeProviderId(normalizedOptions.providerId);
|
||||
|
||||
const normalizedActive = this.normalizeHexPubkey(getActiveProfilePubkey());
|
||||
if (normalizedActive && normalizedActive === normalizedTarget) {
|
||||
return { switched: false, reason: "already-active" };
|
||||
@@ -657,6 +814,7 @@ export default class AuthService {
|
||||
allowAccountSelection: true,
|
||||
expectPubkey: normalizedTarget,
|
||||
persistActive: true,
|
||||
...(providerId ? { providerId } : {}),
|
||||
};
|
||||
const autoApply = requestOptions.autoApply !== false;
|
||||
|
||||
@@ -678,7 +836,10 @@ export default class AuthService {
|
||||
if (!detail && !autoApply) {
|
||||
const fallbackTarget =
|
||||
this.normalizeHexPubkey(requestedPubkey) || requestedPubkey || normalizedTarget;
|
||||
detail = await this.login(fallbackTarget, { persistActive: true });
|
||||
detail = await this.login(fallbackTarget, {
|
||||
persistActive: true,
|
||||
providerId,
|
||||
});
|
||||
}
|
||||
|
||||
mutateSavedProfiles((profiles) => {
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
// js/services/commentThreadService.js
|
||||
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
import { buildVideoAddressPointer } from "../utils/videoPointer.js";
|
||||
|
||||
const ROOT_PARENT_KEY = "__root__";
|
||||
const DEFAULT_INITIAL_LIMIT = 40;
|
||||
const DEFAULT_HYDRATION_DEBOUNCE_MS = 25;
|
||||
|
||||
function toPositiveInteger(value, fallback) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function normalizePubkey(pubkey) {
|
||||
const normalized = normalizeString(pubkey);
|
||||
return normalized ? normalized.toLowerCase() : "";
|
||||
}
|
||||
|
||||
function toParentKey(parentId) {
|
||||
return parentId ? parentId : ROOT_PARENT_KEY;
|
||||
}
|
||||
|
||||
function fromParentKey(parentKey) {
|
||||
return parentKey === ROOT_PARENT_KEY ? null : parentKey;
|
||||
}
|
||||
|
||||
function safeCall(handler, payload) {
|
||||
if (typeof handler !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handler(payload);
|
||||
} catch (error) {
|
||||
devLogger.warn("[commentThread] callback threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
export default class CommentThreadService {
|
||||
constructor({
|
||||
nostrClient = null,
|
||||
app = null,
|
||||
fetchVideoComments = null,
|
||||
subscribeVideoComments = null,
|
||||
getProfileCacheEntry = null,
|
||||
batchFetchProfiles = null,
|
||||
limit = DEFAULT_INITIAL_LIMIT,
|
||||
hydrationDebounceMs = DEFAULT_HYDRATION_DEBOUNCE_MS,
|
||||
logger = devLogger,
|
||||
} = {}) {
|
||||
this.nostrClient = nostrClient;
|
||||
this.app = app;
|
||||
this.fetchVideoComments =
|
||||
typeof fetchVideoComments === "function"
|
||||
? fetchVideoComments
|
||||
: this.nostrClient &&
|
||||
typeof this.nostrClient.fetchVideoComments === "function"
|
||||
? (...args) => this.nostrClient.fetchVideoComments(...args)
|
||||
: null;
|
||||
this.subscribeVideoComments =
|
||||
typeof subscribeVideoComments === "function"
|
||||
? subscribeVideoComments
|
||||
: this.nostrClient &&
|
||||
typeof this.nostrClient.subscribeVideoComments === "function"
|
||||
? (...args) => this.nostrClient.subscribeVideoComments(...args)
|
||||
: null;
|
||||
this.getProfileCacheEntry =
|
||||
typeof getProfileCacheEntry === "function"
|
||||
? getProfileCacheEntry
|
||||
: this.app && typeof this.app.getProfileCacheEntry === "function"
|
||||
? (pubkey) => this.app.getProfileCacheEntry(pubkey)
|
||||
: null;
|
||||
this.batchFetchProfiles =
|
||||
typeof batchFetchProfiles === "function"
|
||||
? batchFetchProfiles
|
||||
: this.app && typeof this.app.batchFetchProfiles === "function"
|
||||
? (pubkeys) => this.app.batchFetchProfiles(pubkeys)
|
||||
: null;
|
||||
this.defaultLimit = toPositiveInteger(limit, DEFAULT_INITIAL_LIMIT);
|
||||
this.hydrationDelay = Math.max(
|
||||
0,
|
||||
toPositiveInteger(hydrationDebounceMs, DEFAULT_HYDRATION_DEBOUNCE_MS)
|
||||
);
|
||||
this.logger =
|
||||
logger && typeof logger.warn === "function" ? logger : devLogger;
|
||||
|
||||
this.callbacks = {
|
||||
onThreadReady: null,
|
||||
onCommentsAppended: null,
|
||||
onError: null,
|
||||
};
|
||||
|
||||
this.resetInternalState();
|
||||
}
|
||||
|
||||
resetInternalState() {
|
||||
this.eventsById = new Map();
|
||||
this.metaById = new Map();
|
||||
this.childrenByParent = new Map();
|
||||
this.childrenByParent.set(ROOT_PARENT_KEY, []);
|
||||
this.profileCache = new Map();
|
||||
this.profileQueue = new Set();
|
||||
this.profileHydrationTimer = null;
|
||||
this.profileHydrationPromise = null;
|
||||
this.subscriptionCleanup = null;
|
||||
this.videoEventId = "";
|
||||
this.videoAddressPointer = "";
|
||||
this.parentCommentId = "";
|
||||
this.activeRelays = null;
|
||||
}
|
||||
|
||||
setCallbacks({
|
||||
onThreadReady = this.callbacks.onThreadReady,
|
||||
onCommentsAppended = this.callbacks.onCommentsAppended,
|
||||
onError = this.callbacks.onError,
|
||||
} = {}) {
|
||||
this.callbacks.onThreadReady =
|
||||
typeof onThreadReady === "function" ? onThreadReady : null;
|
||||
this.callbacks.onCommentsAppended =
|
||||
typeof onCommentsAppended === "function" ? onCommentsAppended : null;
|
||||
this.callbacks.onError = typeof onError === "function" ? onError : null;
|
||||
}
|
||||
|
||||
async loadThread({
|
||||
video = null,
|
||||
parentCommentId = "",
|
||||
limit = this.defaultLimit,
|
||||
relays = null,
|
||||
} = {}) {
|
||||
this.teardown();
|
||||
|
||||
this.videoEventId = normalizeString(video?.id);
|
||||
this.videoAddressPointer = buildVideoAddressPointer(video);
|
||||
this.parentCommentId = normalizeString(parentCommentId);
|
||||
this.activeRelays = Array.isArray(relays) ? [...relays] : null;
|
||||
|
||||
if (!this.videoEventId || !this.videoAddressPointer) {
|
||||
this.emitError(
|
||||
new Error("Unable to resolve video pointer for comment thread."),
|
||||
);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
if (typeof this.fetchVideoComments !== "function") {
|
||||
this.emitError(
|
||||
new Error("Nostr client is missing fetchVideoComments implementation."),
|
||||
);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const fetchLimit = toPositiveInteger(limit, this.defaultLimit);
|
||||
const target = {
|
||||
videoEventId: this.videoEventId,
|
||||
videoDefinitionAddress: this.videoAddressPointer,
|
||||
parentCommentId: this.parentCommentId,
|
||||
};
|
||||
|
||||
let events = [];
|
||||
try {
|
||||
events = await this.fetchVideoComments(target, {
|
||||
limit: fetchLimit,
|
||||
relays: this.activeRelays,
|
||||
});
|
||||
} catch (error) {
|
||||
this.emitError(error);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
if (Array.isArray(events)) {
|
||||
events.forEach((event) => {
|
||||
this.processIncomingEvent(event, { isInitial: true });
|
||||
});
|
||||
}
|
||||
|
||||
this.emitThreadReady();
|
||||
this.startSubscription(target);
|
||||
|
||||
if (this.profileHydrationTimer) {
|
||||
clearTimeout(this.profileHydrationTimer);
|
||||
this.profileHydrationTimer = null;
|
||||
}
|
||||
|
||||
if (this.profileQueue.size) {
|
||||
await this.flushProfileQueue();
|
||||
}
|
||||
|
||||
return this.getSnapshot();
|
||||
}
|
||||
|
||||
startSubscription(target) {
|
||||
if (typeof this.subscribeVideoComments !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
relays: this.activeRelays,
|
||||
onEvent: (event) => this.processIncomingEvent(event),
|
||||
};
|
||||
|
||||
try {
|
||||
const cleanup = this.subscribeVideoComments(target, options);
|
||||
if (typeof cleanup === "function") {
|
||||
this.subscriptionCleanup = () => {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
if (this.logger?.warn) {
|
||||
this.logger.warn(
|
||||
"[commentThread] Failed to unsubscribe from comment feed:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
this.subscriptionCleanup = null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.emitError(error);
|
||||
}
|
||||
}
|
||||
|
||||
processIncomingEvent(event, { isInitial = false } = {}) {
|
||||
const result = this.applyEvent(event);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInitial && result.type === "insert") {
|
||||
this.emitAppend(result);
|
||||
}
|
||||
}
|
||||
|
||||
applyEvent(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventId = normalizeString(event.id);
|
||||
if (!eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentId = this.extractParentId(event);
|
||||
const parentKey = toParentKey(parentId);
|
||||
const createdAt = Number.isFinite(event.created_at) ? event.created_at : 0;
|
||||
const pubkey = normalizePubkey(event.pubkey);
|
||||
|
||||
const existingMeta = this.metaById.get(eventId);
|
||||
this.eventsById.set(eventId, event);
|
||||
this.metaById.set(eventId, { parentKey, createdAt });
|
||||
|
||||
if (!existingMeta) {
|
||||
this.insertIntoParentList(parentKey, eventId, createdAt);
|
||||
if (pubkey) {
|
||||
this.queueProfileForHydration(pubkey);
|
||||
}
|
||||
return { type: "insert", parentId, parentKey, eventId, event };
|
||||
}
|
||||
|
||||
if (existingMeta.parentKey !== parentKey) {
|
||||
this.removeFromParentList(existingMeta.parentKey, eventId);
|
||||
this.insertIntoParentList(parentKey, eventId, createdAt);
|
||||
} else if (existingMeta.createdAt !== createdAt) {
|
||||
this.reorderParentList(parentKey, eventId, createdAt);
|
||||
}
|
||||
|
||||
if (pubkey) {
|
||||
this.queueProfileForHydration(pubkey);
|
||||
}
|
||||
|
||||
return { type: "update", parentId, parentKey, eventId, event };
|
||||
}
|
||||
|
||||
extractParentId(event) {
|
||||
const tags = Array.isArray(event.tags) ? event.tags : [];
|
||||
const values = [];
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const [name, value] = tag;
|
||||
if (name !== "e") {
|
||||
continue;
|
||||
}
|
||||
const normalizedValue = normalizeString(value);
|
||||
if (!normalizedValue) {
|
||||
continue;
|
||||
}
|
||||
values.push(normalizedValue);
|
||||
}
|
||||
|
||||
for (let index = values.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = values[index];
|
||||
if (candidate && candidate !== this.videoEventId) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
insertIntoParentList(parentKey, eventId, createdAt) {
|
||||
const list = this.getParentList(parentKey);
|
||||
if (list.includes(eventId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const insertIndex = list.findIndex((existingId) => {
|
||||
const meta = this.metaById.get(existingId);
|
||||
const existingCreated = meta ? meta.createdAt : 0;
|
||||
if (createdAt === existingCreated) {
|
||||
return eventId.localeCompare(existingId) < 0;
|
||||
}
|
||||
return createdAt < existingCreated;
|
||||
});
|
||||
|
||||
if (insertIndex === -1) {
|
||||
list.push(eventId);
|
||||
} else {
|
||||
list.splice(insertIndex, 0, eventId);
|
||||
}
|
||||
}
|
||||
|
||||
removeFromParentList(parentKey, eventId) {
|
||||
const list = this.childrenByParent.get(parentKey);
|
||||
if (!Array.isArray(list)) {
|
||||
return;
|
||||
}
|
||||
const index = list.indexOf(eventId);
|
||||
if (index >= 0) {
|
||||
list.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
reorderParentList(parentKey, eventId, createdAt) {
|
||||
this.removeFromParentList(parentKey, eventId);
|
||||
this.insertIntoParentList(parentKey, eventId, createdAt);
|
||||
}
|
||||
|
||||
getParentList(parentKey) {
|
||||
if (!this.childrenByParent.has(parentKey)) {
|
||||
this.childrenByParent.set(parentKey, []);
|
||||
}
|
||||
return this.childrenByParent.get(parentKey);
|
||||
}
|
||||
|
||||
queueProfileForHydration(pubkey) {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.profileCache.has(pubkey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedProfile = this.getProfileFromCache(pubkey);
|
||||
if (cachedProfile) {
|
||||
this.profileCache.set(pubkey, cachedProfile);
|
||||
return;
|
||||
}
|
||||
|
||||
this.profileQueue.add(pubkey);
|
||||
this.scheduleProfileHydration();
|
||||
}
|
||||
|
||||
getProfileFromCache(pubkey) {
|
||||
if (typeof this.getProfileCacheEntry !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const entry = this.getProfileCacheEntry(pubkey);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (entry && typeof entry === "object" && entry.profile) {
|
||||
return entry.profile;
|
||||
}
|
||||
return entry;
|
||||
} catch (error) {
|
||||
if (this.logger?.warn) {
|
||||
this.logger.warn(
|
||||
"[commentThread] Failed to read profile cache entry:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
scheduleProfileHydration() {
|
||||
if (this.profileQueue.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (this.profileHydrationTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.profileHydrationTimer = setTimeout(() => {
|
||||
this.profileHydrationTimer = null;
|
||||
this.flushProfileQueue();
|
||||
}, this.hydrationDelay);
|
||||
}
|
||||
|
||||
async flushProfileQueue() {
|
||||
if (!this.profileQueue.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pubkeys = Array.from(this.profileQueue);
|
||||
this.profileQueue.clear();
|
||||
|
||||
if (typeof this.batchFetchProfiles !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const hydrationPromise = Promise.resolve(
|
||||
this.batchFetchProfiles(pubkeys),
|
||||
)
|
||||
.catch((error) => {
|
||||
if (this.logger?.warn) {
|
||||
this.logger.warn(
|
||||
"[commentThread] Profile hydration failed:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
this.emitError(error);
|
||||
})
|
||||
.then(() => {
|
||||
pubkeys.forEach((pubkey) => {
|
||||
const profile = this.getProfileFromCache(pubkey);
|
||||
if (profile) {
|
||||
this.profileCache.set(pubkey, profile);
|
||||
}
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.profileHydrationPromise === hydrationPromise) {
|
||||
this.profileHydrationPromise = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.profileHydrationPromise = hydrationPromise;
|
||||
await hydrationPromise;
|
||||
}
|
||||
|
||||
emitThreadReady() {
|
||||
const payload = {
|
||||
videoEventId: this.videoEventId,
|
||||
parentCommentId: this.parentCommentId || null,
|
||||
topLevelIds: this.getCommentIdsForParent(null),
|
||||
commentsById: this.cloneCommentsMap(),
|
||||
childrenByParent: this.cloneTreeMap(),
|
||||
profiles: this.getProfilesSnapshot(),
|
||||
};
|
||||
safeCall(this.callbacks.onThreadReady, payload);
|
||||
}
|
||||
|
||||
emitAppend({ parentId, eventId }) {
|
||||
const payload = {
|
||||
videoEventId: this.videoEventId,
|
||||
parentCommentId: parentId || null,
|
||||
commentIds: [eventId],
|
||||
commentsById: this.cloneCommentsMap(),
|
||||
childrenByParent: this.cloneTreeMap(),
|
||||
profiles: this.getProfilesSnapshot(),
|
||||
};
|
||||
safeCall(this.callbacks.onCommentsAppended, payload);
|
||||
}
|
||||
|
||||
emitError(error) {
|
||||
if (this.logger?.warn && error) {
|
||||
this.logger.warn("[commentThread]", error);
|
||||
}
|
||||
safeCall(this.callbacks.onError, error);
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
return {
|
||||
videoEventId: this.videoEventId,
|
||||
parentCommentId: this.parentCommentId || null,
|
||||
commentsById: this.cloneCommentsMap(),
|
||||
childrenByParent: this.cloneTreeMap(),
|
||||
profiles: this.getProfilesSnapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
cloneCommentsMap() {
|
||||
return new Map(this.eventsById);
|
||||
}
|
||||
|
||||
cloneTreeMap() {
|
||||
const tree = new Map();
|
||||
this.childrenByParent.forEach((ids, parentKey) => {
|
||||
tree.set(fromParentKey(parentKey), [...ids]);
|
||||
});
|
||||
return tree;
|
||||
}
|
||||
|
||||
getProfilesSnapshot() {
|
||||
return new Map(this.profileCache);
|
||||
}
|
||||
|
||||
getProfile(pubkey) {
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.profileCache.has(normalized)) {
|
||||
return this.profileCache.get(normalized);
|
||||
}
|
||||
|
||||
const profile = this.getProfileFromCache(normalized);
|
||||
if (profile) {
|
||||
this.profileCache.set(normalized, profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getCommentIdsForParent(parentId) {
|
||||
const key = toParentKey(normalizeString(parentId));
|
||||
const list = this.childrenByParent.get(key);
|
||||
return Array.isArray(list) ? [...list] : [];
|
||||
}
|
||||
|
||||
waitForProfileHydration() {
|
||||
return this.profileHydrationPromise || Promise.resolve();
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if (this.subscriptionCleanup) {
|
||||
try {
|
||||
this.subscriptionCleanup();
|
||||
} catch (error) {
|
||||
if (this.logger?.warn) {
|
||||
this.logger.warn(
|
||||
"[commentThread] Failed during teardown unsubscribe:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.subscriptionCleanup = null;
|
||||
|
||||
if (this.profileHydrationTimer) {
|
||||
clearTimeout(this.profileHydrationTimer);
|
||||
this.profileHydrationTimer = null;
|
||||
}
|
||||
|
||||
this.profileQueue.clear();
|
||||
this.profileHydrationPromise = null;
|
||||
this.resetInternalState();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
import { buildVideoAddressPointer } from "../utils/videoPointer.js";
|
||||
|
||||
const DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS = 24;
|
||||
const COUNT_UNSUPPORTED_TITLE = "Relay does not support NIP-45 COUNT queries.";
|
||||
@@ -132,7 +133,7 @@ export default class DiscussionCountService {
|
||||
filters.push({ kinds: [1], "#e": [eventId] });
|
||||
}
|
||||
|
||||
const address = this.getVideoAddressPointer(video);
|
||||
const address = buildVideoAddressPointer(video);
|
||||
if (address) {
|
||||
filters.push({ kinds: [1], "#a": [address] });
|
||||
}
|
||||
@@ -141,40 +142,7 @@ export default class DiscussionCountService {
|
||||
}
|
||||
|
||||
getVideoAddressPointer(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const tags = Array.isArray(video.tags) ? video.tags : [];
|
||||
const dTag = tags.find(
|
||||
(tag) =>
|
||||
Array.isArray(tag) &&
|
||||
tag.length >= 2 &&
|
||||
tag[0] === "d" &&
|
||||
typeof tag[1] === "string" &&
|
||||
tag[1].trim()
|
||||
);
|
||||
|
||||
if (!dTag) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const pubkey = typeof video.pubkey === "string" ? video.pubkey.trim() : "";
|
||||
if (!pubkey) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const identifier = dTag[1].trim();
|
||||
if (!identifier) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const kind =
|
||||
Number.isFinite(video.kind) && video.kind > 0
|
||||
? Math.floor(video.kind)
|
||||
: 30078;
|
||||
|
||||
return `${kind}:${pubkey}:${identifier}`;
|
||||
return buildVideoAddressPointer(video);
|
||||
}
|
||||
|
||||
setDiscussionCountPending(element) {
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
// js/services/hashtagPreferencesService.js
|
||||
import {
|
||||
nostrClient,
|
||||
requestDefaultExtensionPermissions,
|
||||
} from "../nostrClientFacade.js";
|
||||
import { getActiveSigner } from "../nostr/index.js";
|
||||
import { buildHashtagPreferenceEvent } from "../nostrEventSchemas.js";
|
||||
import {
|
||||
publishEventToRelays,
|
||||
assertAnyRelayAccepted,
|
||||
} from "../nostrPublish.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
|
||||
const LOG_PREFIX = "[HashtagPreferences]";
|
||||
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const DEFAULT_VERSION = 1;
|
||||
|
||||
class TinyEventEmitter {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
if (typeof eventName !== "string" || typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!this.listeners.has(eventName)) {
|
||||
this.listeners.set(eventName, new Set());
|
||||
}
|
||||
|
||||
const handlers = this.listeners.get(eventName);
|
||||
handlers.add(handler);
|
||||
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
const handlers = this.listeners.get(eventName);
|
||||
if (!handlers || !handlers.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const handler of Array.from(handlers)) {
|
||||
try {
|
||||
handler(detail);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} listener for "${eventName}" threw an error`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EVENTS = Object.freeze({
|
||||
CHANGE: "change",
|
||||
});
|
||||
|
||||
function normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeRelayList(candidate) {
|
||||
return Array.isArray(candidate)
|
||||
? candidate
|
||||
.map((url) => (typeof url === "string" ? url.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeTag(input) {
|
||||
if (typeof input !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = input.trim().replace(/^#+/, "");
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function extractEncryptionHints(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tags = Array.isArray(event.tags) ? event.tags : [];
|
||||
const hints = [];
|
||||
|
||||
const pushUnique = (scheme) => {
|
||||
if (!scheme || hints.includes(scheme)) {
|
||||
return;
|
||||
}
|
||||
hints.push(scheme);
|
||||
};
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const label = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
|
||||
if (label !== "encrypted" && label !== "encryption") {
|
||||
continue;
|
||||
}
|
||||
const rawValue = typeof tag[1] === "string" ? tag[1] : "";
|
||||
if (!rawValue) {
|
||||
continue;
|
||||
}
|
||||
const parts = rawValue
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
for (const part of parts) {
|
||||
if (part.startsWith("nip44")) {
|
||||
if (/^nip44(?:[_-]?v2|v2)/.test(part)) {
|
||||
pushUnique("nip44_v2");
|
||||
} else {
|
||||
pushUnique("nip44");
|
||||
}
|
||||
} else if (part === "nip04" || part === "nip-04") {
|
||||
pushUnique("nip04");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
|
||||
function determineDecryptionOrder(event, availableSchemes) {
|
||||
const available = Array.isArray(availableSchemes) ? availableSchemes : [];
|
||||
const availableSet = new Set(available);
|
||||
const prioritized = [];
|
||||
|
||||
const hints = extractEncryptionHints(event);
|
||||
const aliasMap = {
|
||||
nip04: ["nip04"],
|
||||
nip44: ["nip44", "nip44_v2"],
|
||||
nip44_v2: ["nip44_v2", "nip44"],
|
||||
};
|
||||
|
||||
for (const hint of hints) {
|
||||
const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint];
|
||||
for (const candidate of candidates) {
|
||||
if (availableSet.has(candidate) && !prioritized.includes(candidate)) {
|
||||
prioritized.push(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const fallback of ["nip04", "nip44", "nip44_v2"]) {
|
||||
if (availableSet.has(fallback) && !prioritized.includes(fallback)) {
|
||||
prioritized.push(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
return prioritized.length ? prioritized : available;
|
||||
}
|
||||
|
||||
class HashtagPreferencesService {
|
||||
constructor() {
|
||||
this.emitter = new TinyEventEmitter();
|
||||
this.interests = new Set();
|
||||
this.disinterests = new Set();
|
||||
this.activePubkey = null;
|
||||
this.eventId = null;
|
||||
this.eventCreatedAt = null;
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
return this.emitter.on(eventName, handler);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.interests = new Set();
|
||||
this.disinterests = new Set();
|
||||
this.eventId = null;
|
||||
this.eventCreatedAt = null;
|
||||
this.loaded = false;
|
||||
this.emitChange("reset");
|
||||
}
|
||||
|
||||
getInterests() {
|
||||
return Array.from(this.interests).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
getDisinterests() {
|
||||
return Array.from(this.disinterests).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
addInterest(tag) {
|
||||
const normalized = normalizeTag(tag);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hadInterest = this.interests.has(normalized);
|
||||
const removedFromDisinterests = this.disinterests.delete(normalized);
|
||||
this.interests.add(normalized);
|
||||
|
||||
if (!hadInterest || removedFromDisinterests) {
|
||||
this.emitChange("interest-added", { tag: normalized });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
removeInterest(tag) {
|
||||
const normalized = normalizeTag(tag);
|
||||
if (!normalized || !this.interests.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.interests.delete(normalized);
|
||||
this.emitChange("interest-removed", { tag: normalized });
|
||||
return true;
|
||||
}
|
||||
|
||||
addDisinterest(tag) {
|
||||
const normalized = normalizeTag(tag);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hadDisinterest = this.disinterests.has(normalized);
|
||||
const removedFromInterests = this.interests.delete(normalized);
|
||||
this.disinterests.add(normalized);
|
||||
|
||||
if (!hadDisinterest || removedFromInterests) {
|
||||
this.emitChange("disinterest-added", { tag: normalized });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
removeDisinterest(tag) {
|
||||
const normalized = normalizeTag(tag);
|
||||
if (!normalized || !this.disinterests.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.disinterests.delete(normalized);
|
||||
this.emitChange("disinterest-removed", { tag: normalized });
|
||||
return true;
|
||||
}
|
||||
|
||||
emitChange(action, detail = {}) {
|
||||
try {
|
||||
this.emitter.emit(EVENTS.CHANGE, {
|
||||
action,
|
||||
interests: this.getInterests(),
|
||||
disinterests: this.getDisinterests(),
|
||||
eventId: this.eventId,
|
||||
createdAt: this.eventCreatedAt,
|
||||
...detail,
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn(`${LOG_PREFIX} Failed to emit change event`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async load(pubkey) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
this.activePubkey = normalized;
|
||||
|
||||
if (!normalized) {
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!nostrClient ||
|
||||
!nostrClient.pool ||
|
||||
typeof nostrClient.pool.list !== "function"
|
||||
) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} nostrClient.pool.list unavailable; treating preferences as empty.`,
|
||||
);
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const relays = sanitizeRelayList(
|
||||
Array.isArray(nostrClient.relays)
|
||||
? nostrClient.relays
|
||||
: nostrClient.writeRelays,
|
||||
);
|
||||
if (!relays.length) {
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [30005],
|
||||
authors: [normalized],
|
||||
"#d": [HASHTAG_IDENTIFIER],
|
||||
limit: 50,
|
||||
};
|
||||
|
||||
let events = [];
|
||||
try {
|
||||
const result = await nostrClient.pool.list(relays, [filter]);
|
||||
if (Array.isArray(result)) {
|
||||
events = result.filter((event) => event && event.pubkey === normalized);
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Failed to load hashtag preferences from relays`,
|
||||
error,
|
||||
);
|
||||
events = [];
|
||||
}
|
||||
|
||||
if (!events.length) {
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const latest = events.reduce((current, candidate) => {
|
||||
if (!candidate) {
|
||||
return current;
|
||||
}
|
||||
if (!current) {
|
||||
return candidate;
|
||||
}
|
||||
const candidateTs = Number(candidate.created_at) || 0;
|
||||
const currentTs = Number(current.created_at) || 0;
|
||||
if (candidateTs === currentTs) {
|
||||
return candidate.id > current.id ? candidate : current;
|
||||
}
|
||||
return candidateTs > currentTs ? candidate : current;
|
||||
}, null);
|
||||
|
||||
if (!latest) {
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const decryptResult = await this.decryptEvent(latest, normalized);
|
||||
if (!decryptResult.ok) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Failed to decrypt hashtag preferences`,
|
||||
decryptResult.error,
|
||||
);
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decryptResult.plaintext);
|
||||
const rawInterests = Array.isArray(payload?.interests)
|
||||
? payload.interests
|
||||
: [];
|
||||
const rawDisinterests = Array.isArray(payload?.disinterests)
|
||||
? payload.disinterests
|
||||
: [];
|
||||
|
||||
const nextInterests = new Set();
|
||||
for (const tag of rawInterests) {
|
||||
const normalizedTag = normalizeTag(tag);
|
||||
if (normalizedTag) {
|
||||
nextInterests.add(normalizedTag);
|
||||
}
|
||||
}
|
||||
|
||||
const nextDisinterests = new Set();
|
||||
for (const tag of rawDisinterests) {
|
||||
const normalizedTag = normalizeTag(tag);
|
||||
if (!normalizedTag) {
|
||||
continue;
|
||||
}
|
||||
nextDisinterests.add(normalizedTag);
|
||||
nextInterests.delete(normalizedTag);
|
||||
}
|
||||
|
||||
this.interests = nextInterests;
|
||||
this.disinterests = nextDisinterests;
|
||||
this.eventId = latest.id || null;
|
||||
this.eventCreatedAt = Number.isFinite(latest?.created_at)
|
||||
? latest.created_at
|
||||
: null;
|
||||
this.loaded = true;
|
||||
this.emitChange("sync", { scheme: decryptResult.scheme });
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Failed to parse decrypted hashtag preferences`,
|
||||
error,
|
||||
);
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
async decryptEvent(event, userPubkey) {
|
||||
const ciphertext = typeof event?.content === "string" ? event.content : "";
|
||||
if (!ciphertext) {
|
||||
const error = new Error("Preference event missing ciphertext content.");
|
||||
error.code = "hashtag-preferences-empty";
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const signer = getActiveSigner();
|
||||
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
|
||||
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
|
||||
|
||||
const hints = extractEncryptionHints(event);
|
||||
const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2");
|
||||
const requiresNip04 = !hints.length || hints.includes("nip04");
|
||||
|
||||
if (
|
||||
(!signerHasNip44 && requiresNip44) ||
|
||||
(!signerHasNip04 && requiresNip04)
|
||||
) {
|
||||
try {
|
||||
await requestDefaultExtensionPermissions();
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Extension permissions request failed while loading preferences`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const decryptors = new Map();
|
||||
const registerDecryptor = (scheme, handler) => {
|
||||
if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) {
|
||||
return;
|
||||
}
|
||||
decryptors.set(scheme, handler);
|
||||
};
|
||||
|
||||
if (signerHasNip44) {
|
||||
registerDecryptor("nip44", (payload) => signer.nip44Decrypt(userPubkey, payload));
|
||||
registerDecryptor("nip44_v2", (payload) =>
|
||||
signer.nip44Decrypt(userPubkey, payload),
|
||||
);
|
||||
}
|
||||
|
||||
if (signerHasNip04) {
|
||||
registerDecryptor("nip04", (payload) => signer.nip04Decrypt(userPubkey, payload));
|
||||
}
|
||||
|
||||
const nostrApi =
|
||||
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
|
||||
if (nostrApi) {
|
||||
const nip04 =
|
||||
nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function"
|
||||
? nostrApi.nip04
|
||||
: null;
|
||||
if (nip04 && !decryptors.has("nip04")) {
|
||||
registerDecryptor("nip04", (payload) => nip04.decrypt(userPubkey, payload));
|
||||
}
|
||||
|
||||
const nip44 =
|
||||
nostrApi.nip44 && typeof nostrApi.nip44 === "object"
|
||||
? nostrApi.nip44
|
||||
: null;
|
||||
if (nip44) {
|
||||
if (typeof nip44.decrypt === "function" && !decryptors.has("nip44")) {
|
||||
registerDecryptor("nip44", (payload) => nip44.decrypt(userPubkey, payload));
|
||||
}
|
||||
const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
|
||||
if (nip44v2 && typeof nip44v2.decrypt === "function") {
|
||||
registerDecryptor("nip44_v2", (payload) =>
|
||||
nip44v2.decrypt(userPubkey, payload),
|
||||
);
|
||||
if (!decryptors.has("nip44")) {
|
||||
registerDecryptor("nip44", (payload) =>
|
||||
nip44v2.decrypt(userPubkey, payload),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!decryptors.size) {
|
||||
const error = new Error(
|
||||
"No decryptors available for hashtag preferences payload.",
|
||||
);
|
||||
error.code = "hashtag-preferences-no-decryptors";
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const order = determineDecryptionOrder(event, Array.from(decryptors.keys()));
|
||||
const attemptErrors = [];
|
||||
|
||||
for (const scheme of order) {
|
||||
const decryptFn = decryptors.get(scheme);
|
||||
if (!decryptFn) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const plaintext = await decryptFn(ciphertext);
|
||||
if (typeof plaintext !== "string") {
|
||||
attemptErrors.push({
|
||||
scheme,
|
||||
error: new Error("Decryption returned non-string payload."),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
return { ok: true, plaintext, scheme };
|
||||
} catch (error) {
|
||||
attemptErrors.push({ scheme, error });
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Error("Failed to decrypt hashtag preferences.");
|
||||
error.code = "hashtag-preferences-decrypt-failed";
|
||||
if (attemptErrors.length) {
|
||||
error.cause = attemptErrors;
|
||||
}
|
||||
return { ok: false, error, errors: attemptErrors };
|
||||
}
|
||||
|
||||
async publish(options = {}) {
|
||||
const targetPubkey = normalizeHexPubkey(
|
||||
typeof options?.pubkey === "string" ? options.pubkey : this.activePubkey,
|
||||
);
|
||||
if (!targetPubkey) {
|
||||
const error = new Error("Active pubkey is required to publish preferences.");
|
||||
error.code = "hashtag-preferences-missing-pubkey";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const signer = getActiveSigner();
|
||||
if (!signer || typeof signer.signEvent !== "function") {
|
||||
const error = new Error("An active signer is required to publish preferences.");
|
||||
error.code = "hashtag-preferences-missing-signer";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (signer.type === "extension") {
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult?.ok) {
|
||||
const error = new Error(
|
||||
"The active signer must allow encryption and signing before publishing preferences.",
|
||||
);
|
||||
error.code = "hashtag-preferences-extension-denied";
|
||||
error.cause = permissionResult?.error || null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const interests = this.getInterests();
|
||||
const disinterests = this.getDisinterests().filter(
|
||||
(tag) => !interests.includes(tag),
|
||||
);
|
||||
|
||||
const payload = {
|
||||
version: DEFAULT_VERSION,
|
||||
interests,
|
||||
disinterests,
|
||||
};
|
||||
const plaintext = JSON.stringify(payload);
|
||||
|
||||
const encryptors = [];
|
||||
const registerEncryptor = (scheme, handler) => {
|
||||
if (!scheme || typeof handler !== "function") {
|
||||
return;
|
||||
}
|
||||
encryptors.push({ scheme, handler });
|
||||
};
|
||||
|
||||
if (typeof signer.nip44Encrypt === "function") {
|
||||
registerEncryptor("nip44_v2", (value) => signer.nip44Encrypt(targetPubkey, value));
|
||||
registerEncryptor("nip44", (value) => signer.nip44Encrypt(targetPubkey, value));
|
||||
}
|
||||
|
||||
if (typeof signer.nip04Encrypt === "function") {
|
||||
registerEncryptor("nip04", (value) => signer.nip04Encrypt(targetPubkey, value));
|
||||
}
|
||||
|
||||
const nostrApi =
|
||||
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
|
||||
if (nostrApi) {
|
||||
const nip44 =
|
||||
nostrApi.nip44 && typeof nostrApi.nip44 === "object"
|
||||
? nostrApi.nip44
|
||||
: null;
|
||||
if (nip44) {
|
||||
if (typeof nip44.encrypt === "function") {
|
||||
registerEncryptor("nip44", (value) => nip44.encrypt(targetPubkey, value));
|
||||
}
|
||||
const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
|
||||
if (nip44v2 && typeof nip44v2.encrypt === "function") {
|
||||
registerEncryptor("nip44_v2", (value) =>
|
||||
nip44v2.encrypt(targetPubkey, value),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof nostrApi.nip04?.encrypt === "function") {
|
||||
registerEncryptor("nip04", (value) =>
|
||||
nostrApi.nip04.encrypt(targetPubkey, value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!encryptors.length) {
|
||||
const error = new Error("No encryptors available to publish preferences.");
|
||||
error.code = "hashtag-preferences-no-encryptor";
|
||||
throw error;
|
||||
}
|
||||
|
||||
let ciphertext = "";
|
||||
let schemeUsed = "";
|
||||
const encryptionErrors = [];
|
||||
const triedSchemes = new Set();
|
||||
|
||||
for (const candidate of encryptors) {
|
||||
if (triedSchemes.has(candidate.scheme)) {
|
||||
continue;
|
||||
}
|
||||
triedSchemes.add(candidate.scheme);
|
||||
try {
|
||||
const encrypted = await candidate.handler(plaintext);
|
||||
if (typeof encrypted === "string" && encrypted) {
|
||||
ciphertext = encrypted;
|
||||
schemeUsed = candidate.scheme;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
encryptionErrors.push({ scheme: candidate.scheme, error });
|
||||
}
|
||||
}
|
||||
|
||||
if (!ciphertext) {
|
||||
const error = new Error("Failed to encrypt hashtag preferences.");
|
||||
error.code = "hashtag-preferences-encrypt-failed";
|
||||
error.cause = encryptionErrors;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const event = buildHashtagPreferenceEvent({
|
||||
pubkey: targetPubkey,
|
||||
created_at: createdAt,
|
||||
content: ciphertext,
|
||||
});
|
||||
|
||||
if (!Array.isArray(event.tags)) {
|
||||
event.tags = [];
|
||||
}
|
||||
|
||||
let updatedSchemeTag = false;
|
||||
for (const tag of event.tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
if (typeof tag[0] === "string" && tag[0].toLowerCase() === "encrypted") {
|
||||
tag[1] = schemeUsed;
|
||||
updatedSchemeTag = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!updatedSchemeTag) {
|
||||
event.tags.push(["encrypted", schemeUsed]);
|
||||
}
|
||||
|
||||
const signedEvent = await signer.signEvent(event);
|
||||
|
||||
const writeRelays = sanitizeRelayList(nostrClient.writeRelays);
|
||||
const relayFallback = writeRelays.length
|
||||
? writeRelays
|
||||
: sanitizeRelayList(nostrClient.relays);
|
||||
|
||||
if (!relayFallback.length) {
|
||||
const error = new Error("No relays configured for publishing preferences.");
|
||||
error.code = "hashtag-preferences-no-relays";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
relayFallback,
|
||||
signedEvent,
|
||||
);
|
||||
|
||||
const publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "hashtag-preferences",
|
||||
});
|
||||
|
||||
if (publishSummary.failed?.length) {
|
||||
publishSummary.failed.forEach(({ url, error }) => {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Preferences not accepted by ${url}: ${error}`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.eventId = signedEvent.id || null;
|
||||
this.eventCreatedAt = Number.isFinite(signedEvent?.created_at)
|
||||
? signedEvent.created_at
|
||||
: createdAt;
|
||||
this.emitChange("published", { event: signedEvent, scheme: schemeUsed });
|
||||
return signedEvent;
|
||||
}
|
||||
}
|
||||
|
||||
const hashtagPreferences = new HashtagPreferencesService();
|
||||
|
||||
export default hashtagPreferences;
|
||||
export { HashtagPreferencesService, hashtagPreferences, EVENTS as HASHTAG_PREFERENCES_EVENTS };
|
||||
|
||||
@@ -4,8 +4,8 @@ import { accessControl } from "../accessControl.js";
|
||||
import { userBlocks, USER_BLOCK_EVENTS } from "../userBlocks.js";
|
||||
import logger from "../utils/logger.js";
|
||||
|
||||
const AUTOPLAY_TRUST_THRESHOLD = 2;
|
||||
const BLUR_TRUST_THRESHOLD = 3;
|
||||
const AUTOPLAY_TRUST_THRESHOLD = 1;
|
||||
const BLUR_TRUST_THRESHOLD = 1;
|
||||
|
||||
function normalizeUserLogger(candidate) {
|
||||
if (
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
nostrClient,
|
||||
convertEventToVideo,
|
||||
} from "../nostr.js";
|
||||
import { nostrClient } from "../nostr.js";
|
||||
import { convertEventToVideo } from "../nostr/index.js";
|
||||
import { accessControl } from "../accessControl.js";
|
||||
import { ALLOW_NSFW_CONTENT } from "../config.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "../nwcSettings.js";
|
||||
import { MAX_WALLET_DEFAULT_ZAP } from "../config.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import createProfileSettingsStore from "../state/profileSettingsStore.js";
|
||||
|
||||
const NWC_URI_SCHEME = "nostr+walletconnect://";
|
||||
|
||||
@@ -53,7 +54,10 @@ export default class NwcSettingsService {
|
||||
this.createDefaultSettings = isFunction(createDefaultSettings)
|
||||
? createDefaultSettings
|
||||
: () => createDefaultNwcSettings();
|
||||
this.cache = new Map();
|
||||
this.cache = createProfileSettingsStore({
|
||||
clone: (value) => this.cloneSettings(value),
|
||||
logger: this.logger.dev,
|
||||
});
|
||||
}
|
||||
|
||||
cloneSettings(settings) {
|
||||
@@ -101,7 +105,8 @@ export default class NwcSettingsService {
|
||||
? { ...settings }
|
||||
: this.createDefaultNwcSettings();
|
||||
this.cache.set(normalized, record);
|
||||
return this.cloneSettings(record);
|
||||
const cached = this.cache.get(normalized);
|
||||
return cached || this.createDefaultNwcSettings();
|
||||
} catch (error) {
|
||||
if (isFunction(this.logger.user?.warn)) {
|
||||
this.logger.user.warn(
|
||||
@@ -111,7 +116,8 @@ export default class NwcSettingsService {
|
||||
}
|
||||
const fallback = this.createDefaultNwcSettings();
|
||||
this.cache.set(normalized, fallback);
|
||||
return this.cloneSettings(fallback);
|
||||
const cached = this.cache.get(normalized);
|
||||
return cached || this.createDefaultNwcSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +127,7 @@ export default class NwcSettingsService {
|
||||
return this.createDefaultNwcSettings();
|
||||
}
|
||||
const cached = this.cache.get(normalized);
|
||||
return cached ? this.cloneSettings(cached) : this.createDefaultNwcSettings();
|
||||
return cached ? cached : this.createDefaultNwcSettings();
|
||||
}
|
||||
|
||||
createDefaultNwcSettings() {
|
||||
@@ -178,7 +184,8 @@ export default class NwcSettingsService {
|
||||
? { ...updated }
|
||||
: this.createDefaultNwcSettings();
|
||||
this.cache.set(normalized, record);
|
||||
return this.cloneSettings(record);
|
||||
const cached = this.cache.get(normalized);
|
||||
return cached || this.createDefaultNwcSettings();
|
||||
} catch (error) {
|
||||
if (isFunction(this.logger.user?.warn)) {
|
||||
this.logger.user.warn(
|
||||
@@ -294,14 +301,6 @@ export default class NwcSettingsService {
|
||||
const normalizedActive = this.getNormalizedPubkey(
|
||||
pubkey !== undefined ? pubkey : this.getActivePubkey(),
|
||||
);
|
||||
const normalizedPrevious = this.getNormalizedPubkey(previousPubkey);
|
||||
|
||||
if (
|
||||
normalizedPrevious &&
|
||||
(!normalizedActive || normalizedPrevious !== normalizedActive)
|
||||
) {
|
||||
await this.clearStoredNwcSettings(normalizedPrevious, { silent: true });
|
||||
}
|
||||
|
||||
if (normalizedActive) {
|
||||
await this.hydrateNwcSettingsForPubkey(normalizedActive);
|
||||
@@ -315,13 +314,14 @@ export default class NwcSettingsService {
|
||||
previousPubkey !== undefined ? previousPubkey : pubkey,
|
||||
);
|
||||
if (normalizedPrevious) {
|
||||
await this.clearStoredNwcSettings(normalizedPrevious, { silent: false });
|
||||
this.cache.delete(normalizedPrevious);
|
||||
}
|
||||
this.clearCache();
|
||||
return this.createDefaultNwcSettings();
|
||||
}
|
||||
|
||||
onIdentityChanged() {
|
||||
this.clearCache();
|
||||
// Intentionally preserve cached settings so profile switches retain
|
||||
// per-account configuration. Explicit cache clearing remains
|
||||
// available via `clearCache()` when required by callers.
|
||||
}
|
||||
}
|
||||
+264
-82
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
DEFAULT_BLUR_THRESHOLD,
|
||||
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
getTrustedMuteHideThreshold,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
removeUrlHealthFromStorage,
|
||||
writeUrlHealthToStorage,
|
||||
} from "../utils/storage.js";
|
||||
import createProfileSettingsStore from "./profileSettingsStore.js";
|
||||
|
||||
const PROFILE_CACHE_STORAGE_KEY = "bitvid:profileCache:v1";
|
||||
const PROFILE_CACHE_VERSION = 1;
|
||||
@@ -29,54 +32,86 @@ const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1";
|
||||
const MODERATION_OVERRIDE_STORAGE_VERSION = 1;
|
||||
const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1";
|
||||
const MODERATION_SETTINGS_STORAGE_VERSION = 1;
|
||||
const MODERATION_SETTINGS_STORAGE_VERSION = 2;
|
||||
|
||||
function computeDefaultModerationSettings() {
|
||||
let runtimeMuteHide = DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD;
|
||||
let runtimeSpamHide = DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD;
|
||||
const defaultBlur = sanitizeModerationThreshold(
|
||||
DEFAULT_BLUR_THRESHOLD,
|
||||
DEFAULT_BLUR_THRESHOLD,
|
||||
);
|
||||
const defaultAutoplay = sanitizeModerationThreshold(
|
||||
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
);
|
||||
const defaultTrustedMute = sanitizeModerationThreshold(
|
||||
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
);
|
||||
const defaultTrustedSpam = sanitizeModerationThreshold(
|
||||
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
);
|
||||
|
||||
let runtimeMuteHide = defaultTrustedMute;
|
||||
let runtimeSpamHide = defaultTrustedSpam;
|
||||
|
||||
try {
|
||||
if (typeof getTrustedMuteHideThreshold === "function") {
|
||||
runtimeMuteHide = getTrustedMuteHideThreshold();
|
||||
runtimeMuteHide = sanitizeModerationThreshold(
|
||||
getTrustedMuteHideThreshold(),
|
||||
defaultTrustedMute,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[cache.computeDefaultModerationSettings] Failed to read runtime mute hide threshold:",
|
||||
error,
|
||||
);
|
||||
runtimeMuteHide = defaultTrustedMute;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof getTrustedSpamHideThreshold === "function") {
|
||||
runtimeSpamHide = getTrustedSpamHideThreshold();
|
||||
runtimeSpamHide = sanitizeModerationThreshold(
|
||||
getTrustedSpamHideThreshold(),
|
||||
defaultTrustedSpam,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[cache.computeDefaultModerationSettings] Failed to read runtime spam hide threshold:",
|
||||
error,
|
||||
);
|
||||
runtimeSpamHide = defaultTrustedSpam;
|
||||
}
|
||||
|
||||
return {
|
||||
blurThreshold: 3,
|
||||
autoplayBlockThreshold: 2,
|
||||
blurThreshold: defaultBlur,
|
||||
autoplayBlockThreshold: defaultAutoplay,
|
||||
trustedMuteHideThreshold: sanitizeModerationThreshold(
|
||||
runtimeMuteHide,
|
||||
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
defaultTrustedMute,
|
||||
),
|
||||
trustedSpamHideThreshold: sanitizeModerationThreshold(
|
||||
runtimeSpamHide,
|
||||
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
defaultTrustedSpam,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_MODERATION_SETTINGS = Object.freeze(computeDefaultModerationSettings());
|
||||
|
||||
function createDefaultModerationSettings() {
|
||||
return { ...computeDefaultModerationSettings() };
|
||||
}
|
||||
|
||||
const GLOBAL_MODERATION_PROFILE_KEY = "__global__";
|
||||
const moderationSettingsStore = createProfileSettingsStore({
|
||||
clone: (value) => ({ ...value }),
|
||||
});
|
||||
moderationSettingsStore.set(
|
||||
GLOBAL_MODERATION_PROFILE_KEY,
|
||||
createDefaultModerationSettings(),
|
||||
);
|
||||
|
||||
function sanitizeModerationThreshold(value, fallback) {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
@@ -111,7 +146,6 @@ const profileCache = new Map();
|
||||
const urlHealthCache = new Map();
|
||||
const urlHealthInFlight = new Map();
|
||||
const moderationOverrides = new Map();
|
||||
let moderationSettings = createDefaultModerationSettings();
|
||||
|
||||
function hasSavedProfilesChanged(previousProfiles, nextProfiles) {
|
||||
if (previousProfiles === nextProfiles) {
|
||||
@@ -228,6 +262,7 @@ export function setActiveProfilePubkey(pubkey, { persist = true } = {}) {
|
||||
}
|
||||
|
||||
activeProfilePubkey = nextValue;
|
||||
ensureModerationSettingsForKey(getActiveModerationStoreKey());
|
||||
if (persist) {
|
||||
persistSavedProfiles({ persistActive: true });
|
||||
}
|
||||
@@ -360,7 +395,14 @@ export function persistSavedProfiles({ persistActive = true } = {}) {
|
||||
typeof entry.npub === "string" && entry.npub.trim() ? entry.npub.trim() : null,
|
||||
name: typeof entry.name === "string" ? entry.name : "",
|
||||
picture: typeof entry.picture === "string" ? entry.picture : "",
|
||||
authType: entry.authType === "nsec" ? "nsec" : "nip07",
|
||||
authType:
|
||||
typeof entry.authType === "string" && entry.authType.trim()
|
||||
? entry.authType.trim()
|
||||
: null,
|
||||
providerId:
|
||||
typeof entry.providerId === "string" && entry.providerId.trim()
|
||||
? entry.providerId.trim()
|
||||
: null,
|
||||
})),
|
||||
activePubkey: activePubkeyToPersist,
|
||||
};
|
||||
@@ -408,7 +450,43 @@ export function loadSavedProfilesFromStorage() {
|
||||
}
|
||||
|
||||
seenPubkeys.add(normalizedPubkey);
|
||||
const storedAuthType = candidate.authType === "nsec" ? "nsec" : "nip07";
|
||||
const rawAuthType =
|
||||
typeof candidate.authType === "string" ? candidate.authType : null;
|
||||
let storedAuthType = null;
|
||||
if (rawAuthType) {
|
||||
const trimmedAuthType = rawAuthType.trim();
|
||||
if (trimmedAuthType) {
|
||||
storedAuthType = trimmedAuthType;
|
||||
if (trimmedAuthType !== rawAuthType) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!storedAuthType) {
|
||||
storedAuthType = "nip07";
|
||||
if (rawAuthType !== "nip07") {
|
||||
needsRewrite = true;
|
||||
}
|
||||
}
|
||||
const rawProviderId =
|
||||
typeof candidate.providerId === "string" ? candidate.providerId : null;
|
||||
let storedProviderId = null;
|
||||
if (rawProviderId) {
|
||||
const trimmedProviderId = rawProviderId.trim();
|
||||
if (trimmedProviderId) {
|
||||
storedProviderId = trimmedProviderId;
|
||||
if (trimmedProviderId !== rawProviderId) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!storedProviderId && storedAuthType) {
|
||||
storedProviderId = storedAuthType;
|
||||
if (rawProviderId !== storedProviderId) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
}
|
||||
const npub =
|
||||
typeof candidate.npub === "string" && candidate.npub.trim()
|
||||
? candidate.npub.trim()
|
||||
@@ -420,6 +498,7 @@ export function loadSavedProfilesFromStorage() {
|
||||
name: typeof candidate.name === "string" ? candidate.name : "",
|
||||
picture: typeof candidate.picture === "string" ? candidate.picture : "",
|
||||
authType: storedAuthType,
|
||||
providerId: storedProviderId,
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -765,68 +844,152 @@ function haveModerationSettingsChanged(previous, next) {
|
||||
);
|
||||
}
|
||||
|
||||
function getModerationStoreKeyForPubkey(pubkey) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
return normalized || GLOBAL_MODERATION_PROFILE_KEY;
|
||||
}
|
||||
|
||||
function getActiveModerationStoreKey() {
|
||||
return getModerationStoreKeyForPubkey(activeProfilePubkey);
|
||||
}
|
||||
|
||||
function ensureModerationSettingsForKey(key) {
|
||||
const storeKey = key || GLOBAL_MODERATION_PROFILE_KEY;
|
||||
if (!moderationSettingsStore.has(storeKey)) {
|
||||
moderationSettingsStore.set(storeKey, createDefaultModerationSettings());
|
||||
}
|
||||
return moderationSettingsStore.get(storeKey);
|
||||
}
|
||||
|
||||
export function getDefaultModerationSettings() {
|
||||
return createDefaultModerationSettings();
|
||||
}
|
||||
|
||||
export function getModerationSettingsForPubkey(pubkey) {
|
||||
const key = getModerationStoreKeyForPubkey(pubkey);
|
||||
const settings = ensureModerationSettingsForKey(key);
|
||||
return { ...settings };
|
||||
}
|
||||
|
||||
export function getModerationSettings() {
|
||||
return { ...moderationSettings };
|
||||
return getModerationSettingsForPubkey(activeProfilePubkey);
|
||||
}
|
||||
|
||||
export function loadModerationSettingsFromStorage() {
|
||||
moderationSettingsStore.clear();
|
||||
moderationSettingsStore.set(
|
||||
GLOBAL_MODERATION_PROFILE_KEY,
|
||||
createDefaultModerationSettings(),
|
||||
);
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
moderationSettings = createDefaultModerationSettings();
|
||||
return getModerationSettings();
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(MODERATION_SETTINGS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
moderationSettings = createDefaultModerationSettings();
|
||||
return getModerationSettings();
|
||||
}
|
||||
|
||||
let shouldRewrite = false;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(raw);
|
||||
if (!payload || typeof payload !== "object") {
|
||||
moderationSettings = createDefaultModerationSettings();
|
||||
return getModerationSettings();
|
||||
shouldRewrite = true;
|
||||
} else if (payload.version === 1) {
|
||||
const defaults = createDefaultModerationSettings();
|
||||
const overrides =
|
||||
payload.overrides && typeof payload.overrides === "object"
|
||||
? payload.overrides
|
||||
: {};
|
||||
|
||||
const legacySettings = {
|
||||
blurThreshold: sanitizeModerationThreshold(
|
||||
overrides.blurThreshold,
|
||||
defaults.blurThreshold,
|
||||
),
|
||||
autoplayBlockThreshold: sanitizeModerationThreshold(
|
||||
overrides.autoplayBlockThreshold,
|
||||
defaults.autoplayBlockThreshold,
|
||||
),
|
||||
trustedMuteHideThreshold: sanitizeModerationThreshold(
|
||||
overrides.trustedMuteHideThreshold,
|
||||
defaults.trustedMuteHideThreshold,
|
||||
),
|
||||
trustedSpamHideThreshold: sanitizeModerationThreshold(
|
||||
overrides.trustedSpamHideThreshold,
|
||||
defaults.trustedSpamHideThreshold,
|
||||
),
|
||||
};
|
||||
|
||||
moderationSettingsStore.set(
|
||||
GLOBAL_MODERATION_PROFILE_KEY,
|
||||
legacySettings,
|
||||
);
|
||||
shouldRewrite = true;
|
||||
} else if (payload.version === MODERATION_SETTINGS_STORAGE_VERSION) {
|
||||
const defaults = createDefaultModerationSettings();
|
||||
const entries =
|
||||
payload.entries && typeof payload.entries === "object"
|
||||
? payload.entries
|
||||
: {};
|
||||
|
||||
for (const [key, entry] of Object.entries(entries)) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
shouldRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const overrides =
|
||||
entry.overrides && typeof entry.overrides === "object"
|
||||
? entry.overrides
|
||||
: {};
|
||||
|
||||
const storeKey =
|
||||
key === "default"
|
||||
? GLOBAL_MODERATION_PROFILE_KEY
|
||||
: getModerationStoreKeyForPubkey(key);
|
||||
|
||||
if (!storeKey) {
|
||||
shouldRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const settings = {
|
||||
blurThreshold: sanitizeModerationThreshold(
|
||||
overrides.blurThreshold,
|
||||
defaults.blurThreshold,
|
||||
),
|
||||
autoplayBlockThreshold: sanitizeModerationThreshold(
|
||||
overrides.autoplayBlockThreshold,
|
||||
defaults.autoplayBlockThreshold,
|
||||
),
|
||||
trustedMuteHideThreshold: sanitizeModerationThreshold(
|
||||
overrides.trustedMuteHideThreshold,
|
||||
defaults.trustedMuteHideThreshold,
|
||||
),
|
||||
trustedSpamHideThreshold: sanitizeModerationThreshold(
|
||||
overrides.trustedSpamHideThreshold,
|
||||
defaults.trustedSpamHideThreshold,
|
||||
),
|
||||
};
|
||||
|
||||
moderationSettingsStore.set(storeKey, settings);
|
||||
}
|
||||
} else {
|
||||
shouldRewrite = true;
|
||||
}
|
||||
|
||||
if (payload.version !== MODERATION_SETTINGS_STORAGE_VERSION) {
|
||||
moderationSettings = createDefaultModerationSettings();
|
||||
return getModerationSettings();
|
||||
}
|
||||
|
||||
const defaults = createDefaultModerationSettings();
|
||||
const overrides =
|
||||
payload.overrides && typeof payload.overrides === "object"
|
||||
? payload.overrides
|
||||
: {};
|
||||
|
||||
moderationSettings = {
|
||||
blurThreshold: sanitizeModerationThreshold(
|
||||
overrides.blurThreshold,
|
||||
defaults.blurThreshold,
|
||||
),
|
||||
autoplayBlockThreshold: sanitizeModerationThreshold(
|
||||
overrides.autoplayBlockThreshold,
|
||||
defaults.autoplayBlockThreshold,
|
||||
),
|
||||
trustedMuteHideThreshold: sanitizeModerationThreshold(
|
||||
overrides.trustedMuteHideThreshold,
|
||||
defaults.trustedMuteHideThreshold,
|
||||
),
|
||||
trustedSpamHideThreshold: sanitizeModerationThreshold(
|
||||
overrides.trustedSpamHideThreshold,
|
||||
defaults.trustedSpamHideThreshold,
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
moderationSettings = createDefaultModerationSettings();
|
||||
userLogger.warn(
|
||||
"[cache.loadModerationSettingsFromStorage] Failed to parse payload:",
|
||||
error,
|
||||
);
|
||||
shouldRewrite = true;
|
||||
}
|
||||
|
||||
if (shouldRewrite) {
|
||||
persistModerationSettingsToStorage();
|
||||
}
|
||||
|
||||
return getModerationSettings();
|
||||
@@ -838,31 +1001,46 @@ function persistModerationSettingsToStorage() {
|
||||
}
|
||||
|
||||
const defaults = createDefaultModerationSettings();
|
||||
const overrides = {};
|
||||
const entries = {};
|
||||
|
||||
if (moderationSettings.blurThreshold !== defaults.blurThreshold) {
|
||||
overrides.blurThreshold = moderationSettings.blurThreshold;
|
||||
for (const [key, value] of moderationSettingsStore.entries()) {
|
||||
const settings =
|
||||
value && typeof value === "object"
|
||||
? value
|
||||
: createDefaultModerationSettings();
|
||||
|
||||
const overrides = {};
|
||||
|
||||
if (settings.blurThreshold !== defaults.blurThreshold) {
|
||||
overrides.blurThreshold = settings.blurThreshold;
|
||||
}
|
||||
|
||||
if (settings.autoplayBlockThreshold !== defaults.autoplayBlockThreshold) {
|
||||
overrides.autoplayBlockThreshold = settings.autoplayBlockThreshold;
|
||||
}
|
||||
|
||||
if (settings.trustedMuteHideThreshold !== defaults.trustedMuteHideThreshold) {
|
||||
overrides.trustedMuteHideThreshold = settings.trustedMuteHideThreshold;
|
||||
}
|
||||
|
||||
if (settings.trustedSpamHideThreshold !== defaults.trustedSpamHideThreshold) {
|
||||
overrides.trustedSpamHideThreshold = settings.trustedSpamHideThreshold;
|
||||
}
|
||||
|
||||
if (Object.keys(overrides).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const storageKey =
|
||||
key === GLOBAL_MODERATION_PROFILE_KEY ? "default" : key;
|
||||
|
||||
entries[storageKey] = {
|
||||
overrides,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
if (moderationSettings.autoplayBlockThreshold !== defaults.autoplayBlockThreshold) {
|
||||
overrides.autoplayBlockThreshold = moderationSettings.autoplayBlockThreshold;
|
||||
}
|
||||
|
||||
if (
|
||||
moderationSettings.trustedMuteHideThreshold !==
|
||||
defaults.trustedMuteHideThreshold
|
||||
) {
|
||||
overrides.trustedMuteHideThreshold = moderationSettings.trustedMuteHideThreshold;
|
||||
}
|
||||
|
||||
if (
|
||||
moderationSettings.trustedSpamHideThreshold !==
|
||||
defaults.trustedSpamHideThreshold
|
||||
) {
|
||||
overrides.trustedSpamHideThreshold = moderationSettings.trustedSpamHideThreshold;
|
||||
}
|
||||
|
||||
if (Object.keys(overrides).length === 0) {
|
||||
if (Object.keys(entries).length === 0) {
|
||||
try {
|
||||
localStorage.removeItem(MODERATION_SETTINGS_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
@@ -876,7 +1054,7 @@ function persistModerationSettingsToStorage() {
|
||||
|
||||
const payload = {
|
||||
version: MODERATION_SETTINGS_STORAGE_VERSION,
|
||||
overrides,
|
||||
entries,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
|
||||
@@ -894,8 +1072,10 @@ function persistModerationSettingsToStorage() {
|
||||
}
|
||||
|
||||
export function setModerationSettings(partial = {}, { persist = true } = {}) {
|
||||
const key = getActiveModerationStoreKey();
|
||||
const defaults = createDefaultModerationSettings();
|
||||
const next = { ...moderationSettings };
|
||||
const current = ensureModerationSettingsForKey(key);
|
||||
const next = { ...current };
|
||||
let changed = false;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(partial, "blurThreshold")) {
|
||||
@@ -950,33 +1130,35 @@ export function setModerationSettings(partial = {}, { persist = true } = {}) {
|
||||
if (persist) {
|
||||
persistModerationSettingsToStorage();
|
||||
}
|
||||
return { ...moderationSettings };
|
||||
return ensureModerationSettingsForKey(key);
|
||||
}
|
||||
|
||||
moderationSettings = next;
|
||||
moderationSettingsStore.set(key, next);
|
||||
|
||||
if (persist) {
|
||||
persistModerationSettingsToStorage();
|
||||
}
|
||||
|
||||
return { ...moderationSettings };
|
||||
return ensureModerationSettingsForKey(key);
|
||||
}
|
||||
|
||||
export function resetModerationSettings({ persist = true } = {}) {
|
||||
const previous = moderationSettings;
|
||||
moderationSettings = createDefaultModerationSettings();
|
||||
const key = getActiveModerationStoreKey();
|
||||
const previous = ensureModerationSettingsForKey(key);
|
||||
const defaults = createDefaultModerationSettings();
|
||||
moderationSettingsStore.set(key, defaults);
|
||||
|
||||
const changed = haveModerationSettingsChanged(previous, moderationSettings);
|
||||
const changed = haveModerationSettingsChanged(previous, defaults);
|
||||
|
||||
if (persist) {
|
||||
persistModerationSettingsToStorage();
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { ...moderationSettings };
|
||||
return ensureModerationSettingsForKey(key);
|
||||
}
|
||||
|
||||
return { ...moderationSettings };
|
||||
return ensureModerationSettingsForKey(key);
|
||||
}
|
||||
|
||||
export function getModerationOverridesMap() {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
function noopClone(value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createProfileSettingsStore({ clone = noopClone, logger = devLogger } = {}) {
|
||||
const cache = new Map();
|
||||
|
||||
const cloneValue = (value) => {
|
||||
try {
|
||||
return clone(value);
|
||||
} catch (error) {
|
||||
if (logger && typeof logger.warn === "function") {
|
||||
logger.warn("[profileSettingsStore] Failed to clone value", error);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const store = {
|
||||
set(key, value) {
|
||||
if (!key) {
|
||||
return store;
|
||||
}
|
||||
cache.set(key, cloneValue(value));
|
||||
return store;
|
||||
},
|
||||
get(key) {
|
||||
if (!key || !cache.has(key)) {
|
||||
return undefined;
|
||||
}
|
||||
return cloneValue(cache.get(key));
|
||||
},
|
||||
has(key) {
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
return cache.has(key);
|
||||
},
|
||||
delete(key) {
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
return cache.delete(key);
|
||||
},
|
||||
clear() {
|
||||
cache.clear();
|
||||
return store;
|
||||
},
|
||||
keys() {
|
||||
return Array.from(cache.keys());
|
||||
},
|
||||
values() {
|
||||
return Array.from(cache.values()).map((value) => cloneValue(value));
|
||||
},
|
||||
entries() {
|
||||
return Array.from(cache.entries()).map(([key, value]) => [
|
||||
key,
|
||||
cloneValue(value),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(store, "size", {
|
||||
get() {
|
||||
return cache.size;
|
||||
},
|
||||
});
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
export default createProfileSettingsStore;
|
||||
+189
-39
@@ -1,10 +1,17 @@
|
||||
// js/subscriptions.js
|
||||
import {
|
||||
nostrClient,
|
||||
convertEventToVideo as sharedConvertEventToVideo,
|
||||
requestDefaultExtensionPermissions,
|
||||
DEFAULT_RELAY_URLS,
|
||||
} from "./nostr.js";
|
||||
} from "./nostrClientFacade.js";
|
||||
import {
|
||||
getActiveSigner,
|
||||
convertEventToVideo as sharedConvertEventToVideo,
|
||||
} from "./nostr/index.js";
|
||||
import {
|
||||
listVideoViewEvents,
|
||||
subscribeVideoViewEvents,
|
||||
} from "./nostrViewEventsFacade.js";
|
||||
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
|
||||
import {
|
||||
buildSubscriptionListEvent,
|
||||
SUBSCRIPTION_LIST_IDENTIFIER
|
||||
@@ -109,6 +116,11 @@ function determineDecryptionOrder(event, availableSchemes) {
|
||||
|
||||
const getApp = () => getApplication();
|
||||
|
||||
const listVideoViewEventsApi = (pointer, options) =>
|
||||
listVideoViewEvents(nostrClient, pointer, options);
|
||||
const subscribeVideoViewEventsApi = (pointer, options) =>
|
||||
subscribeVideoViewEvents(nostrClient, pointer, options);
|
||||
|
||||
/**
|
||||
* Manages the user's subscription list (kind=30002) *privately*,
|
||||
* using NIP-04 encryption for the content field.
|
||||
@@ -187,9 +199,13 @@ class SubscriptionsManager {
|
||||
}
|
||||
};
|
||||
|
||||
addRelayCandidates(nostrClient.readRelays);
|
||||
addRelayCandidates(nostrClient.relays);
|
||||
addRelayCandidates(DEFAULT_RELAY_URLS);
|
||||
if (!relaySet.size) {
|
||||
addRelayCandidates(nostrClient.readRelays);
|
||||
}
|
||||
if (!relaySet.size) {
|
||||
addRelayCandidates(DEFAULT_RELAY_URLS);
|
||||
}
|
||||
|
||||
const relayUrls = Array.from(relaySet);
|
||||
if (!relayUrls.length) {
|
||||
@@ -225,7 +241,22 @@ class SubscriptionsManager {
|
||||
const newest = events[0];
|
||||
this.subsEventId = newest.id;
|
||||
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
const signer = getActiveSigner();
|
||||
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
|
||||
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
|
||||
const hints = extractEncryptionHints(newest);
|
||||
const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2");
|
||||
const requiresNip04 =
|
||||
!hints.length || hints.includes("nip04") || !requiresNip44;
|
||||
|
||||
let permissionResult = { ok: true };
|
||||
const signerCoversRequiredSchemes =
|
||||
(!requiresNip04 || signerHasNip04) && (!requiresNip44 || signerHasNip44);
|
||||
|
||||
if (!signerCoversRequiredSchemes) {
|
||||
permissionResult = await requestDefaultExtensionPermissions();
|
||||
}
|
||||
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[SubscriptionsManager] Extension permissions denied while loading subscriptions; treating list as empty.",
|
||||
@@ -286,14 +317,6 @@ class SubscriptionsManager {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const nostrApi =
|
||||
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
|
||||
if (!nostrApi) {
|
||||
const error = new Error("Nostr extension is unavailable for decrypting subscriptions.");
|
||||
error.code = "nostr-extension-missing";
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const decryptors = new Map();
|
||||
const registerDecryptor = (scheme, handler) => {
|
||||
if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) {
|
||||
@@ -302,28 +325,51 @@ class SubscriptionsManager {
|
||||
decryptors.set(scheme, handler);
|
||||
};
|
||||
|
||||
if (nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function") {
|
||||
registerDecryptor("nip04", (payload) => nostrApi.nip04.decrypt(userPubkey, payload));
|
||||
const signer = getActiveSigner();
|
||||
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
|
||||
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
|
||||
|
||||
if (signerHasNip04) {
|
||||
registerDecryptor("nip04", (payload) => signer.nip04Decrypt(userPubkey, payload));
|
||||
}
|
||||
|
||||
const nip44 = nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null;
|
||||
if (nip44) {
|
||||
if (typeof nip44.decrypt === "function") {
|
||||
registerDecryptor("nip44", (payload) => nip44.decrypt(userPubkey, payload));
|
||||
if (signerHasNip44) {
|
||||
registerDecryptor("nip44", (payload) => signer.nip44Decrypt(userPubkey, payload));
|
||||
}
|
||||
|
||||
const nostrApi =
|
||||
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
|
||||
if (nostrApi) {
|
||||
if (!signerHasNip04 && nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function") {
|
||||
registerDecryptor("nip04", (payload) =>
|
||||
nostrApi.nip04.decrypt(userPubkey, payload)
|
||||
);
|
||||
}
|
||||
|
||||
const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
|
||||
if (nip44v2 && typeof nip44v2.decrypt === "function") {
|
||||
registerDecryptor("nip44_v2", (payload) => nip44v2.decrypt(userPubkey, payload));
|
||||
if (!decryptors.has("nip44")) {
|
||||
registerDecryptor("nip44", (payload) => nip44v2.decrypt(userPubkey, payload));
|
||||
const nip44 =
|
||||
nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null;
|
||||
if (nip44) {
|
||||
if (!signerHasNip44 && typeof nip44.decrypt === "function") {
|
||||
registerDecryptor("nip44", (payload) => nip44.decrypt(userPubkey, payload));
|
||||
}
|
||||
|
||||
const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
|
||||
if (nip44v2 && typeof nip44v2.decrypt === "function") {
|
||||
registerDecryptor("nip44_v2", (payload) =>
|
||||
nip44v2.decrypt(userPubkey, payload)
|
||||
);
|
||||
if (!decryptors.has("nip44")) {
|
||||
registerDecryptor("nip44", (payload) => nip44v2.decrypt(userPubkey, payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!decryptors.size) {
|
||||
const error = new Error("No compatible decryption helpers are available.");
|
||||
error.code = "subscriptions-no-decryptors";
|
||||
const error = new Error(
|
||||
"No active signer or extension decryptors are available for subscriptions."
|
||||
);
|
||||
error.code = "nostr-extension-missing";
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
@@ -413,17 +459,40 @@ class SubscriptionsManager {
|
||||
throw new Error("No pubkey => cannot publish subscription list.");
|
||||
}
|
||||
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[SubscriptionsManager] Extension permissions denied while updating subscriptions.",
|
||||
permissionResult.error,
|
||||
);
|
||||
const signer = getActiveSigner();
|
||||
if (!signer) {
|
||||
const error = new Error(
|
||||
"The NIP-07 extension must allow encryption and signing before updating subscriptions.",
|
||||
"An active signer is required to update subscriptions."
|
||||
);
|
||||
error.code = "extension-permission-denied";
|
||||
error.cause = permissionResult.error;
|
||||
error.code = "signer-missing";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (signer.type === "extension") {
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[SubscriptionsManager] Signer permissions denied while updating subscriptions.",
|
||||
permissionResult.error,
|
||||
);
|
||||
const error = new Error(
|
||||
"The active signer must allow encryption and signing before updating subscriptions.",
|
||||
);
|
||||
error.code = "extension-permission-denied";
|
||||
error.cause = permissionResult.error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof signer.nip04Encrypt !== "function") {
|
||||
const error = new Error("NIP-04 encryption is required to update subscriptions.");
|
||||
error.code = "nip04-missing";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (typeof signer.signEvent !== "function") {
|
||||
const error = new Error("Active signer missing signEvent support.");
|
||||
error.code = "sign-event-missing";
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -440,7 +509,7 @@ class SubscriptionsManager {
|
||||
*/
|
||||
let cipherText = "";
|
||||
try {
|
||||
cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr);
|
||||
cipherText = await signer.nip04Encrypt(userPubkey, plainStr);
|
||||
} catch (err) {
|
||||
userLogger.error("Encryption failed:", err);
|
||||
throw err;
|
||||
@@ -454,7 +523,7 @@ class SubscriptionsManager {
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
signedEvent = await window.nostr.signEvent(evt);
|
||||
signedEvent = await signer.signEvent(evt);
|
||||
} catch (signErr) {
|
||||
userLogger.error("Failed to sign subscription list:", signErr);
|
||||
throw signErr;
|
||||
@@ -809,12 +878,33 @@ class SubscriptionsManager {
|
||||
? Math.floor(limitCandidate)
|
||||
: null;
|
||||
|
||||
const preferenceSource =
|
||||
typeof app?.getHashtagPreferences === "function"
|
||||
? app.getHashtagPreferences()
|
||||
: {};
|
||||
|
||||
const moderationThresholds =
|
||||
typeof app?.getActiveModerationThresholds === "function"
|
||||
? app.getActiveModerationThresholds()
|
||||
: null;
|
||||
|
||||
return {
|
||||
subscriptionAuthors: normalizedAuthors,
|
||||
authors: normalizedAuthors,
|
||||
blacklistedEventIds: blacklist,
|
||||
isAuthorBlocked,
|
||||
limit: normalizedLimit
|
||||
limit: normalizedLimit,
|
||||
tagPreferences: {
|
||||
interests: Array.isArray(preferenceSource?.interests)
|
||||
? [...preferenceSource.interests]
|
||||
: [],
|
||||
disinterests: Array.isArray(preferenceSource?.disinterests)
|
||||
? [...preferenceSource.disinterests]
|
||||
: [],
|
||||
},
|
||||
moderationThresholds: moderationThresholds
|
||||
? { ...moderationThresholds }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1054,6 +1144,49 @@ class SubscriptionsManager {
|
||||
};
|
||||
|
||||
const listView = new VideoListView(listViewConfig);
|
||||
if (typeof listView.setPopularTagsContainer === "function") {
|
||||
listView.setPopularTagsContainer(null);
|
||||
}
|
||||
|
||||
const buildModerationPayload = (detail = {}) => {
|
||||
const event = detail?.event || null;
|
||||
const trigger =
|
||||
detail?.trigger ||
|
||||
(event && (event.currentTarget || event.target)) ||
|
||||
null;
|
||||
const card = detail?.card || null;
|
||||
const video = detail?.video || null;
|
||||
const datasetContext = (() => {
|
||||
const detailContext =
|
||||
typeof detail?.context === "string" ? detail.context.trim() : "";
|
||||
if (detailContext) {
|
||||
return detailContext;
|
||||
}
|
||||
const datasetSource =
|
||||
(detail?.dataset && typeof detail.dataset === "object"
|
||||
? detail.dataset
|
||||
: null) ||
|
||||
(trigger && trigger.dataset) ||
|
||||
(card && card.root && card.root.dataset) ||
|
||||
null;
|
||||
if (
|
||||
datasetSource &&
|
||||
typeof datasetSource.context === "string" &&
|
||||
datasetSource.context.trim()
|
||||
) {
|
||||
return datasetSource.context.trim();
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
|
||||
return {
|
||||
...detail,
|
||||
video,
|
||||
card,
|
||||
trigger,
|
||||
context: datasetContext || "subscriptions",
|
||||
};
|
||||
};
|
||||
|
||||
listView.setPlaybackHandler((detail) => {
|
||||
if (!detail) {
|
||||
@@ -1122,6 +1255,20 @@ class SubscriptionsManager {
|
||||
app?.handleMoreMenuAction?.("blacklist-author", detail);
|
||||
});
|
||||
|
||||
listView.setModerationOverrideHandler((detail = {}) => {
|
||||
if (typeof app?.handleModerationOverride !== "function") {
|
||||
return false;
|
||||
}
|
||||
return app.handleModerationOverride(buildModerationPayload(detail));
|
||||
});
|
||||
|
||||
listView.setModerationBlockHandler((detail = {}) => {
|
||||
if (typeof app?.handleModerationBlock !== "function") {
|
||||
return false;
|
||||
}
|
||||
return app.handleModerationBlock(buildModerationPayload(detail));
|
||||
});
|
||||
|
||||
listView.addEventListener("video:share", (event) => {
|
||||
const detail = event?.detail || {};
|
||||
const dataset = {
|
||||
@@ -1184,4 +1331,7 @@ class SubscriptionsManager {
|
||||
}
|
||||
}
|
||||
|
||||
SubscriptionsManager.listVideoViewEvents = listVideoViewEventsApi;
|
||||
SubscriptionsManager.subscribeVideoViewEvents = subscribeVideoViewEventsApi;
|
||||
|
||||
export const subscriptions = new SubscriptionsManager();
|
||||
@@ -0,0 +1,281 @@
|
||||
// js/ui/ambientBackground.js
|
||||
|
||||
const DEFAULT_THROTTLE_MS = 66;
|
||||
|
||||
function getGlobalFromCanvas(canvas) {
|
||||
const doc = canvas?.ownerDocument || (typeof document !== "undefined" ? document : null);
|
||||
const win = doc?.defaultView || (typeof window !== "undefined" ? window : null);
|
||||
return { doc, win };
|
||||
}
|
||||
|
||||
function getFallbackFillColor(canvas) {
|
||||
const { doc, win } = getGlobalFromCanvas(canvas);
|
||||
const candidates = ["--color-overlay-strong", "--color-overlay", "--color-surface", "--color-bg"];
|
||||
let styles = null;
|
||||
if (doc && typeof doc.documentElement !== "undefined") {
|
||||
const root = doc.documentElement;
|
||||
if (win && typeof win.getComputedStyle === "function") {
|
||||
styles = win.getComputedStyle(root);
|
||||
}
|
||||
}
|
||||
|
||||
for (const token of candidates) {
|
||||
if (!styles) {
|
||||
break;
|
||||
}
|
||||
const value = styles.getPropertyValue(token);
|
||||
if (value && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return "#000000";
|
||||
}
|
||||
|
||||
function resizeCanvas(canvas, video) {
|
||||
if (!canvas) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
const { doc, win } = getGlobalFromCanvas(canvas);
|
||||
const host = canvas.parentElement || canvas;
|
||||
const rect = host?.getBoundingClientRect?.();
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
||||
return { width: canvas.width || 0, height: canvas.height || 0 };
|
||||
}
|
||||
|
||||
const dpr = (win && win.devicePixelRatio) || 1;
|
||||
const targetWidth = Math.max(1, Math.round(rect.width * dpr));
|
||||
const targetHeight = Math.max(1, Math.round(rect.height * dpr));
|
||||
|
||||
if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
}
|
||||
|
||||
if (video) {
|
||||
const readyState = Number.isFinite(video.readyState) ? video.readyState : 0;
|
||||
if (readyState >= 1 && video.videoWidth && video.videoHeight) {
|
||||
return { width: canvas.width, height: canvas.height };
|
||||
}
|
||||
}
|
||||
|
||||
return { width: canvas.width, height: canvas.height };
|
||||
}
|
||||
|
||||
function drawCover(ctx, video, canvasWidth, canvasHeight) {
|
||||
if (!ctx || !video || !canvasWidth || !canvasHeight) {
|
||||
return;
|
||||
}
|
||||
const videoWidth = video.videoWidth;
|
||||
const videoHeight = video.videoHeight;
|
||||
if (!videoWidth || !videoHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoRatio = videoWidth / videoHeight;
|
||||
const canvasRatio = canvasWidth / canvasHeight;
|
||||
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let sw = videoWidth;
|
||||
let sh = videoHeight;
|
||||
|
||||
if (videoRatio > canvasRatio) {
|
||||
sh = videoHeight;
|
||||
sw = Math.round(videoHeight * canvasRatio);
|
||||
sx = Math.max(0, Math.round((videoWidth - sw) / 2));
|
||||
sy = 0;
|
||||
} else {
|
||||
sw = videoWidth;
|
||||
sh = Math.round(videoWidth / canvasRatio);
|
||||
sx = 0;
|
||||
sy = Math.max(0, Math.round((videoHeight - sh) / 2));
|
||||
}
|
||||
|
||||
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
export function attachAmbientBackground(videoElement, canvasElement, options = {}) {
|
||||
if (!videoElement || !canvasElement) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const ctx = canvasElement.getContext("2d", { alpha: false });
|
||||
if (!ctx) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const { doc, win } = getGlobalFromCanvas(canvasElement);
|
||||
const throttleMs = Number.isFinite(options.throttleMs)
|
||||
? Math.max(0, options.throttleMs)
|
||||
: DEFAULT_THROTTLE_MS;
|
||||
|
||||
const requestFrame = win?.requestAnimationFrame?.bind(win) || ((cb) => setTimeout(() => cb(Date.now()), throttleMs));
|
||||
const cancelFrame = win?.cancelAnimationFrame?.bind(win) || ((id) => clearTimeout(id));
|
||||
|
||||
let rafId = 0;
|
||||
let running = false;
|
||||
let destroyed = false;
|
||||
let lastTime = 0;
|
||||
let tainted = false;
|
||||
let resizeBound = false;
|
||||
|
||||
const clearCanvas = () => {
|
||||
if (!canvasElement) {
|
||||
return;
|
||||
}
|
||||
const fillColor = getFallbackFillColor(canvasElement);
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fillRect(0, 0, canvasElement.width || 0, canvasElement.height || 0);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
running = false;
|
||||
if (rafId) {
|
||||
cancelFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
if (resizeBound && win) {
|
||||
win.removeEventListener("resize", handleResize);
|
||||
resizeBound = false;
|
||||
}
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
if (!running || destroyed) {
|
||||
return;
|
||||
}
|
||||
rafId = requestFrame(step);
|
||||
};
|
||||
|
||||
const step = (timestamp = (win?.performance?.now?.() ?? Date.now())) => {
|
||||
if (!running || destroyed) {
|
||||
return;
|
||||
}
|
||||
if (timestamp - lastTime < throttleMs) {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
lastTime = timestamp;
|
||||
|
||||
try {
|
||||
const { width, height } = resizeCanvas(canvasElement, videoElement);
|
||||
if (!width || !height) {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
drawCover(ctx, videoElement, width, height);
|
||||
} catch (error) {
|
||||
tainted = true;
|
||||
stop();
|
||||
clearCanvas();
|
||||
return;
|
||||
}
|
||||
|
||||
schedule();
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (destroyed || tainted || running) {
|
||||
return;
|
||||
}
|
||||
if (doc?.hidden) {
|
||||
running = false;
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
lastTime = 0;
|
||||
resizeCanvas(canvasElement, videoElement);
|
||||
if (win && !resizeBound) {
|
||||
win.addEventListener("resize", handleResize);
|
||||
resizeBound = true;
|
||||
}
|
||||
step();
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
resizeCanvas(canvasElement, videoElement);
|
||||
};
|
||||
|
||||
const handleVisibility = () => {
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
if (doc.hidden) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
resizeCanvas(canvasElement, videoElement);
|
||||
if (!running && !videoElement.paused && !videoElement.ended) {
|
||||
start();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
if (destroyed || tainted) {
|
||||
return;
|
||||
}
|
||||
start();
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (destroyed || tainted) {
|
||||
return;
|
||||
}
|
||||
resizeCanvas(canvasElement, videoElement);
|
||||
if (!videoElement.paused && !videoElement.ended) {
|
||||
start();
|
||||
}
|
||||
};
|
||||
|
||||
videoElement.addEventListener("play", handlePlay);
|
||||
videoElement.addEventListener("pause", handlePause);
|
||||
videoElement.addEventListener("ended", handleEnded);
|
||||
videoElement.addEventListener("emptied", handlePause);
|
||||
videoElement.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
|
||||
if (doc) {
|
||||
doc.addEventListener("visibilitychange", handleVisibility);
|
||||
}
|
||||
|
||||
resizeCanvas(canvasElement, videoElement);
|
||||
if (!videoElement.paused && !videoElement.ended) {
|
||||
start();
|
||||
}
|
||||
|
||||
return ({ clear = true } = {}) => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
destroyed = true;
|
||||
stop();
|
||||
videoElement.removeEventListener("play", handlePlay);
|
||||
videoElement.removeEventListener("pause", handlePause);
|
||||
videoElement.removeEventListener("ended", handleEnded);
|
||||
videoElement.removeEventListener("emptied", handlePause);
|
||||
videoElement.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
if (doc) {
|
||||
doc.removeEventListener("visibilitychange", handleVisibility);
|
||||
}
|
||||
if (clear) {
|
||||
clearCanvas();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -791,12 +791,16 @@ export class EditModal {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.activeVideo || !this.root) {
|
||||
this.showError("No video selected for editing.");
|
||||
const contextVideo = this.activeVideo || this.pendingSubmitVideo || null;
|
||||
|
||||
if (!contextVideo || !this.root) {
|
||||
if (this.isVisible) {
|
||||
this.showError("No video selected for editing.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.setSubmitState({ pending: true, video: this.activeVideo });
|
||||
this.setSubmitState({ pending: true, video: contextVideo });
|
||||
|
||||
const fieldValue = (key) => {
|
||||
const input = this.fields[key];
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
import { normalizeDesignSystemContext } from "../../designSystem.js";
|
||||
import { formatShortNpub } from "../../utils/formatters.js";
|
||||
|
||||
const DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg";
|
||||
|
||||
export class SimilarContentCard {
|
||||
constructor({
|
||||
document: doc,
|
||||
video,
|
||||
index = 0,
|
||||
shareUrl = "#",
|
||||
pointerInfo = null,
|
||||
timeAgo = "",
|
||||
postedAt = null,
|
||||
identity = null,
|
||||
nsfwContext = null,
|
||||
designSystem = null,
|
||||
thumbnailCache = null,
|
||||
fallbackThumbnailSrc = "",
|
||||
} = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("SimilarContentCard requires a document reference.");
|
||||
}
|
||||
if (!video || typeof video !== "object" || !video.id || !video.title) {
|
||||
throw new Error("SimilarContentCard requires a video with id and title.");
|
||||
}
|
||||
|
||||
this.document = doc;
|
||||
this.window = doc.defaultView || globalThis;
|
||||
this.video = video;
|
||||
this.index = Number.isFinite(index) ? Number(index) : 0;
|
||||
this.shareUrl =
|
||||
typeof shareUrl === "string" && shareUrl.trim() ? shareUrl.trim() : "#";
|
||||
this.pointerInfo = this.normalizePointerInfo(pointerInfo);
|
||||
this.timeAgo = typeof timeAgo === "string" ? timeAgo : "";
|
||||
this.postedAt = this.normalizeTimestamp(postedAt);
|
||||
this.identity = this.normalizeIdentity(identity);
|
||||
this.nsfwContext = {
|
||||
isNsfw: Boolean(nsfwContext?.isNsfw),
|
||||
allowNsfw: nsfwContext?.allowNsfw !== false,
|
||||
viewerIsOwner: nsfwContext?.viewerIsOwner === true,
|
||||
};
|
||||
this.shouldMaskNsfwForOwner =
|
||||
this.nsfwContext.isNsfw &&
|
||||
!this.nsfwContext.allowNsfw &&
|
||||
this.nsfwContext.viewerIsOwner;
|
||||
|
||||
this.designSystem = normalizeDesignSystemContext(designSystem);
|
||||
this.thumbnailCache =
|
||||
thumbnailCache instanceof Map ? thumbnailCache : null;
|
||||
this.fallbackThumbnailSrc =
|
||||
typeof fallbackThumbnailSrc === "string"
|
||||
? fallbackThumbnailSrc.trim()
|
||||
: "";
|
||||
|
||||
this.callbacks = { onPlay: null };
|
||||
|
||||
this.root = null;
|
||||
this.mediaLinkEl = null;
|
||||
this.thumbnailEl = null;
|
||||
this.contentEl = null;
|
||||
this.titleEl = null;
|
||||
this.avatarEl = null;
|
||||
this.authorNameEl = null;
|
||||
this.authorNpubEl = null;
|
||||
this.timeEl = null;
|
||||
this.viewCountEl = null;
|
||||
|
||||
this.build();
|
||||
}
|
||||
|
||||
set onPlay(fn) {
|
||||
this.callbacks.onPlay = typeof fn === "function" ? fn : null;
|
||||
}
|
||||
|
||||
getRoot() {
|
||||
return this.root;
|
||||
}
|
||||
|
||||
getViewCountElement() {
|
||||
return this.viewCountEl;
|
||||
}
|
||||
|
||||
closeMoreMenu() {}
|
||||
|
||||
closeSettingsMenu() {}
|
||||
|
||||
updateIdentity(nextIdentity = {}) {
|
||||
this.identity = this.normalizeIdentity(nextIdentity, this.identity);
|
||||
|
||||
this.applyIdentityToElements();
|
||||
}
|
||||
|
||||
normalizePointerInfo(info) {
|
||||
if (!info || typeof info !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key =
|
||||
typeof info.key === "string" && info.key.trim() ? info.key.trim() : "";
|
||||
const pointer = Array.isArray(info.pointer) ? info.pointer.slice(0, 3) : null;
|
||||
|
||||
if (!key && !pointer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { key, pointer };
|
||||
}
|
||||
|
||||
normalizeTimestamp(candidate) {
|
||||
if (!Number.isFinite(candidate)) {
|
||||
return null;
|
||||
}
|
||||
const value = Number(candidate);
|
||||
const seconds = value > 1e12 ? Math.floor(value / 1000) : Math.floor(value);
|
||||
return seconds;
|
||||
}
|
||||
|
||||
normalizeIdentity(nextIdentity = {}, fallback = null) {
|
||||
const baseline = fallback && typeof fallback === "object" ? fallback : {};
|
||||
|
||||
const candidate =
|
||||
nextIdentity && typeof nextIdentity === "object" ? nextIdentity : {};
|
||||
|
||||
const pubkey = (() => {
|
||||
if (typeof candidate.pubkey === "string" && candidate.pubkey.trim()) {
|
||||
return candidate.pubkey.trim();
|
||||
}
|
||||
if (typeof baseline.pubkey === "string" && baseline.pubkey.trim()) {
|
||||
return baseline.pubkey.trim();
|
||||
}
|
||||
if (typeof this.video?.pubkey === "string" && this.video.pubkey.trim()) {
|
||||
return this.video.pubkey.trim();
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
|
||||
const nameCandidates = [
|
||||
candidate.name,
|
||||
candidate.displayName,
|
||||
candidate.username,
|
||||
baseline.name,
|
||||
baseline.displayName,
|
||||
baseline.username,
|
||||
];
|
||||
let name = "";
|
||||
for (const entry of nameCandidates) {
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (trimmed) {
|
||||
name = trimmed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const npub = (() => {
|
||||
const entries = [candidate.npub, baseline.npub];
|
||||
for (const entry of entries) {
|
||||
if (typeof entry === "string" && entry.trim()) {
|
||||
return entry.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
|
||||
const shortNpub = (() => {
|
||||
const entries = [candidate.shortNpub, baseline.shortNpub];
|
||||
let resolved = "";
|
||||
for (const entry of entries) {
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (trimmed) {
|
||||
resolved = trimmed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (npub) {
|
||||
const formatted = formatShortNpub(npub);
|
||||
if (formatted) {
|
||||
resolved = formatted;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
})();
|
||||
|
||||
if (!name) {
|
||||
name = shortNpub || npub || "";
|
||||
}
|
||||
|
||||
const picture = (() => {
|
||||
const entries = [
|
||||
candidate.picture,
|
||||
candidate.image,
|
||||
candidate.photo,
|
||||
baseline.picture,
|
||||
baseline.image,
|
||||
baseline.photo,
|
||||
this.video?.author?.picture,
|
||||
this.video?.creator?.picture,
|
||||
this.video?.profile?.picture,
|
||||
this.video?.authorPicture,
|
||||
this.video?.creatorPicture,
|
||||
];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
})();
|
||||
|
||||
return { name, npub, shortNpub, pubkey, picture };
|
||||
}
|
||||
|
||||
applyIdentityToElements() {
|
||||
const nameLabel =
|
||||
this.identity.name ||
|
||||
this.identity.shortNpub ||
|
||||
this.identity.npub ||
|
||||
"";
|
||||
const npubLabel = this.identity.shortNpub || this.identity.npub || "";
|
||||
|
||||
if (this.authorNameEl) {
|
||||
this.authorNameEl.textContent = nameLabel;
|
||||
if (this.identity.pubkey) {
|
||||
this.authorNameEl.dataset.pubkey = this.identity.pubkey;
|
||||
} else if (this.authorNameEl.dataset?.pubkey) {
|
||||
delete this.authorNameEl.dataset.pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.authorNpubEl) {
|
||||
this.authorNpubEl.textContent = npubLabel;
|
||||
|
||||
const hasNpub = Boolean(npubLabel);
|
||||
const normalizedName = nameLabel.trim().toLowerCase();
|
||||
const normalizedNpub = npubLabel.trim().toLowerCase();
|
||||
const isDuplicate =
|
||||
hasNpub && normalizedName && normalizedName === normalizedNpub;
|
||||
|
||||
this.authorNpubEl.hidden = !hasNpub || isDuplicate;
|
||||
this.authorNpubEl.setAttribute(
|
||||
"aria-hidden",
|
||||
!hasNpub || isDuplicate ? "true" : "false",
|
||||
);
|
||||
|
||||
if (this.identity.npub && !isDuplicate) {
|
||||
this.authorNpubEl.setAttribute("title", this.identity.npub);
|
||||
} else {
|
||||
this.authorNpubEl.removeAttribute("title");
|
||||
}
|
||||
|
||||
if (this.identity.npub) {
|
||||
this.authorNpubEl.dataset.npub = this.identity.npub;
|
||||
} else if (this.authorNpubEl.dataset?.npub) {
|
||||
delete this.authorNpubEl.dataset.npub;
|
||||
}
|
||||
|
||||
const resolvedPubkey =
|
||||
this.identity.pubkey ||
|
||||
(typeof this.video?.pubkey === "string" ? this.video.pubkey.trim() : "");
|
||||
|
||||
if (resolvedPubkey) {
|
||||
this.authorNpubEl.dataset.pubkey = resolvedPubkey;
|
||||
} else if (this.authorNpubEl.dataset?.pubkey) {
|
||||
delete this.authorNpubEl.dataset.pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.avatarEl) {
|
||||
const picture = this.identity.picture || DEFAULT_PROFILE_AVATAR;
|
||||
if (this.avatarEl.getAttribute("src") !== picture) {
|
||||
this.avatarEl.src = picture;
|
||||
}
|
||||
|
||||
const altLabel =
|
||||
nameLabel || this.identity.shortNpub || this.identity.npub || "Channel";
|
||||
this.avatarEl.alt = `${altLabel}'s avatar`;
|
||||
|
||||
if (this.identity.pubkey) {
|
||||
this.avatarEl.dataset.pubkey = this.identity.pubkey;
|
||||
} else if (this.avatarEl.dataset?.pubkey) {
|
||||
delete this.avatarEl.dataset.pubkey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
const root = this.document.createElement("article");
|
||||
root.classList.add("player-modal__similar-card", "card");
|
||||
root.dataset.component = "similar-content-card";
|
||||
root.dataset.index = String(this.index);
|
||||
if (this.video.id) {
|
||||
root.dataset.videoId = this.video.id;
|
||||
}
|
||||
const dsMode = this.designSystem?.getMode?.();
|
||||
if (dsMode) {
|
||||
root.setAttribute("data-ds", dsMode);
|
||||
}
|
||||
|
||||
this.root = root;
|
||||
this.root.__bitvidSimilarContentCard = this;
|
||||
|
||||
const media = this.buildMediaSection();
|
||||
const content = this.buildContentSection();
|
||||
|
||||
if (media) {
|
||||
root.appendChild(media);
|
||||
}
|
||||
if (content) {
|
||||
root.appendChild(content);
|
||||
}
|
||||
|
||||
this.applyPointerDatasets();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
buildMediaSection() {
|
||||
const anchor = this.document.createElement("a");
|
||||
anchor.classList.add("player-modal__similar-card-media");
|
||||
anchor.href = this.shareUrl;
|
||||
anchor.setAttribute("data-primary-action", "play");
|
||||
|
||||
const thumbnail = this.buildThumbnail();
|
||||
if (thumbnail) {
|
||||
anchor.appendChild(thumbnail);
|
||||
}
|
||||
|
||||
this.mediaLinkEl = anchor;
|
||||
return anchor;
|
||||
}
|
||||
|
||||
buildThumbnail() {
|
||||
const img = this.document.createElement("img");
|
||||
img.decoding = "async";
|
||||
img.loading = "lazy";
|
||||
img.alt = this.video.title || "";
|
||||
img.dataset.videoThumbnail = "true";
|
||||
|
||||
const rawThumbnail =
|
||||
typeof this.video.thumbnail === "string"
|
||||
? this.video.thumbnail.trim()
|
||||
: "";
|
||||
const thumbnailUrl = rawThumbnail;
|
||||
const fallbackSrc = this.fallbackThumbnailSrc;
|
||||
const cachedValue = this.thumbnailCache?.get(this.video.id) || "";
|
||||
const shouldLazyLoad = !!thumbnailUrl && cachedValue !== thumbnailUrl;
|
||||
|
||||
if (shouldLazyLoad) {
|
||||
if (fallbackSrc) {
|
||||
img.src = fallbackSrc;
|
||||
img.dataset.fallbackSrc = fallbackSrc;
|
||||
this.setCardBackdropImage(fallbackSrc);
|
||||
} else {
|
||||
this.setCardBackdropImage("");
|
||||
}
|
||||
img.dataset.lazy = thumbnailUrl;
|
||||
} else {
|
||||
const initialSrc = thumbnailUrl || fallbackSrc;
|
||||
if (initialSrc) {
|
||||
img.src = initialSrc;
|
||||
if (fallbackSrc) {
|
||||
img.dataset.fallbackSrc = fallbackSrc;
|
||||
}
|
||||
this.setCardBackdropImage(initialSrc);
|
||||
} else {
|
||||
this.setCardBackdropImage("");
|
||||
}
|
||||
}
|
||||
|
||||
if (this.shouldMaskNsfwForOwner || this.video?.moderation?.blurThumbnail) {
|
||||
img.dataset.thumbnailState = "blurred";
|
||||
}
|
||||
|
||||
const handleLoad = () => {
|
||||
const currentSrc = img.currentSrc || img.src || "";
|
||||
if (!currentSrc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackAttr =
|
||||
(typeof img.dataset.fallbackSrc === "string"
|
||||
? img.dataset.fallbackSrc.trim()
|
||||
: "") || fallbackSrc || "";
|
||||
const isFallback =
|
||||
!!fallbackAttr &&
|
||||
(currentSrc === fallbackAttr || currentSrc.endsWith(fallbackAttr));
|
||||
|
||||
if (!isFallback) {
|
||||
this.setCardBackdropImage(currentSrc);
|
||||
} else if (fallbackAttr) {
|
||||
this.setCardBackdropImage(fallbackAttr);
|
||||
}
|
||||
|
||||
if (thumbnailUrl && !isFallback && this.thumbnailCache) {
|
||||
this.thumbnailCache.set(this.video.id, thumbnailUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
const fallbackAttr =
|
||||
(typeof img.dataset.fallbackSrc === "string"
|
||||
? img.dataset.fallbackSrc.trim()
|
||||
: "") || fallbackSrc || "";
|
||||
if (fallbackAttr) {
|
||||
this.setCardBackdropImage(fallbackAttr);
|
||||
if (!img.src || img.src === thumbnailUrl) {
|
||||
img.src = fallbackAttr;
|
||||
}
|
||||
} else {
|
||||
this.setCardBackdropImage("");
|
||||
}
|
||||
if (thumbnailUrl && this.thumbnailCache) {
|
||||
const cached = this.thumbnailCache.get(this.video.id);
|
||||
if (cached === thumbnailUrl) {
|
||||
this.thumbnailCache.delete(this.video.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
img.addEventListener("load", handleLoad);
|
||||
img.addEventListener("error", handleError, { once: true });
|
||||
|
||||
if (img.complete) {
|
||||
handleLoad();
|
||||
}
|
||||
|
||||
this.thumbnailEl = img;
|
||||
return img;
|
||||
}
|
||||
|
||||
buildContentSection() {
|
||||
const content = this.document.createElement("div");
|
||||
content.classList.add("player-modal__similar-card-content");
|
||||
content.style.minWidth = "0";
|
||||
|
||||
const titleLink = this.document.createElement("a");
|
||||
titleLink.classList.add("player-modal__similar-card-title");
|
||||
titleLink.href = this.shareUrl;
|
||||
titleLink.textContent = this.video.title || "Untitled";
|
||||
titleLink.title = this.video.title || "Untitled";
|
||||
titleLink.style.minWidth = "0";
|
||||
|
||||
const authorStack = this.buildAuthorStack();
|
||||
const metaRow = this.buildMetaRow();
|
||||
|
||||
content.appendChild(titleLink);
|
||||
if (authorStack) {
|
||||
content.appendChild(authorStack);
|
||||
}
|
||||
if (metaRow) {
|
||||
content.appendChild(metaRow);
|
||||
}
|
||||
|
||||
this.contentEl = content;
|
||||
this.titleEl = titleLink;
|
||||
return content;
|
||||
}
|
||||
|
||||
buildAuthorStack() {
|
||||
const wrapper = this.document.createElement("div");
|
||||
wrapper.classList.add("player-modal__similar-card-author");
|
||||
wrapper.style.minWidth = "0";
|
||||
|
||||
const avatarWrapper = this.document.createElement("span");
|
||||
avatarWrapper.classList.add("player-modal__similar-card-avatar");
|
||||
|
||||
const avatarImg = this.document.createElement("img");
|
||||
avatarImg.classList.add("player-modal__similar-card-avatar-img");
|
||||
avatarImg.decoding = "async";
|
||||
avatarImg.loading = "lazy";
|
||||
avatarImg.alt = "";
|
||||
avatarWrapper.appendChild(avatarImg);
|
||||
|
||||
const textWrapper = this.document.createElement("span");
|
||||
textWrapper.classList.add("player-modal__similar-card-author-meta");
|
||||
textWrapper.style.minWidth = "0";
|
||||
|
||||
const nameEl = this.document.createElement("span");
|
||||
nameEl.classList.add("author-name", "player-modal__similar-card-author-name");
|
||||
textWrapper.appendChild(nameEl);
|
||||
|
||||
const npubEl = this.document.createElement("span");
|
||||
npubEl.classList.add("author-npub", "player-modal__similar-card-author-npub");
|
||||
textWrapper.appendChild(npubEl);
|
||||
|
||||
wrapper.appendChild(avatarWrapper);
|
||||
wrapper.appendChild(textWrapper);
|
||||
|
||||
this.avatarEl = avatarImg;
|
||||
this.authorNameEl = nameEl;
|
||||
this.authorNpubEl = npubEl;
|
||||
|
||||
this.applyIdentityToElements();
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
buildMetaRow() {
|
||||
const row = this.document.createElement("div");
|
||||
row.classList.add("player-modal__similar-card-meta");
|
||||
row.style.minWidth = "0";
|
||||
|
||||
const timeEl = this.document.createElement("time");
|
||||
timeEl.classList.add("player-modal__similar-card-timestamp");
|
||||
if (this.postedAt !== null) {
|
||||
try {
|
||||
const iso = new Date(this.postedAt * 1000).toISOString();
|
||||
timeEl.setAttribute("datetime", iso);
|
||||
timeEl.title = new Date(this.postedAt * 1000).toLocaleString();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
timeEl.textContent = this.timeAgo || "";
|
||||
row.appendChild(timeEl);
|
||||
this.timeEl = timeEl;
|
||||
|
||||
const shouldShowViews = Boolean(this.pointerInfo?.key);
|
||||
if (shouldShowViews) {
|
||||
const separator = this.document.createElement("span");
|
||||
separator.classList.add("player-modal__similar-card-separator");
|
||||
separator.setAttribute("aria-hidden", "true");
|
||||
separator.textContent = "•";
|
||||
row.appendChild(separator);
|
||||
}
|
||||
|
||||
const viewEl = this.document.createElement("span");
|
||||
viewEl.classList.add("player-modal__similar-card-views", "view-count-text");
|
||||
viewEl.dataset.viewCount = "";
|
||||
viewEl.textContent = "– views";
|
||||
if (this.pointerInfo?.key) {
|
||||
viewEl.dataset.viewPointer = this.pointerInfo.key;
|
||||
}
|
||||
row.appendChild(viewEl);
|
||||
this.viewCountEl = viewEl;
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
applyPointerDatasets() {
|
||||
if (!this.root || !this.pointerInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { key, pointer } = this.pointerInfo;
|
||||
if (key) {
|
||||
this.root.dataset.pointerKey = key;
|
||||
}
|
||||
if (Array.isArray(pointer)) {
|
||||
const [type, value, relay] = pointer;
|
||||
if (typeof type === "string" && type) {
|
||||
this.root.dataset.pointerType = type;
|
||||
}
|
||||
if (typeof value === "string" && value) {
|
||||
this.root.dataset.pointerValue = value;
|
||||
}
|
||||
if (typeof relay === "string" && relay) {
|
||||
this.root.dataset.pointerRelay = relay;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
const MouseEventCtor = this.window?.MouseEvent || globalThis.MouseEvent;
|
||||
const handler = (event) => {
|
||||
if (!this.callbacks.onPlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMouseEvent =
|
||||
typeof MouseEventCtor !== "undefined" && event instanceof MouseEventCtor;
|
||||
if (isMouseEvent) {
|
||||
const isPrimaryClick =
|
||||
typeof event.button !== "number" || event.button === 0;
|
||||
const hasModifier =
|
||||
event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
|
||||
if (!isPrimaryClick || hasModifier) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
|
||||
this.callbacks.onPlay({ event, video: this.video, card: this });
|
||||
};
|
||||
|
||||
[this.mediaLinkEl, this.titleEl].forEach((el) => {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.addEventListener("click", handler);
|
||||
});
|
||||
}
|
||||
|
||||
setCardBackdropImage(src) {
|
||||
if (!this.root || !this.root.style) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = this.root.style;
|
||||
|
||||
const normalizeSource = (raw) => {
|
||||
if (typeof raw !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (/^javascript:/i.test(trimmed) || /^vbscript:/i.test(trimmed)) {
|
||||
return "";
|
||||
}
|
||||
if (/^(?:https?:|data:|blob:)/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (
|
||||
trimmed.startsWith("/") ||
|
||||
trimmed.startsWith("./") ||
|
||||
trimmed.startsWith("../") ||
|
||||
trimmed.startsWith("assets/")
|
||||
) {
|
||||
return trimmed;
|
||||
}
|
||||
try {
|
||||
const base =
|
||||
typeof this.document?.baseURI === "string" && this.document.baseURI
|
||||
? this.document.baseURI
|
||||
: this.window?.location?.href || "";
|
||||
if (!base) {
|
||||
return "";
|
||||
}
|
||||
const resolved = new URL(trimmed, base);
|
||||
if (/^(?:https?:|data:|blob:)/i.test(resolved.protocol)) {
|
||||
return resolved.href;
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const sanitized = normalizeSource(src);
|
||||
if (sanitized) {
|
||||
const escaped = sanitized.replace(/(["\\])/g, "\\$1");
|
||||
style.setProperty(
|
||||
"--similar-card-thumb-url",
|
||||
`url("${escaped}")`
|
||||
);
|
||||
} else {
|
||||
style.removeProperty("--similar-card-thumb-url");
|
||||
}
|
||||
}
|
||||
}
|
||||
+686
-503
File diff suppressed because it is too large.
Load diff
+3642
-11
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Tag pill list helpers for rendering and updating the compact tag strip used on video cards.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const { root } = renderTagPillStrip({ tags: ["nostr", "bitvid"], onTagActivate });
|
||||
* container.append(root);
|
||||
* ```
|
||||
*/
|
||||
|
||||
function resolveDocument(doc) {
|
||||
if (doc) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
return typeof globalThis !== "undefined" ? globalThis.document : undefined;
|
||||
}
|
||||
|
||||
function assertDocument(doc) {
|
||||
if (!doc) {
|
||||
throw new Error("A document instance is required to render tag pill lists.");
|
||||
}
|
||||
}
|
||||
|
||||
const TAG_PREFERENCE_STATES = new Set(["interest", "disinterest"]);
|
||||
|
||||
function normalizeTagPreferenceState(state) {
|
||||
if (typeof state !== "string") {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
const trimmed = state.trim().toLowerCase();
|
||||
if (TAG_PREFERENCE_STATES.has(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function applyPreferenceState(button, state) {
|
||||
if (!button || typeof button !== "object" || button.nodeType !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeTagPreferenceState(state);
|
||||
button.dataset.preferenceState = normalized;
|
||||
|
||||
if (normalized === "interest") {
|
||||
button.dataset.variant = "success";
|
||||
} else if (normalized === "disinterest") {
|
||||
button.dataset.variant = "critical";
|
||||
} else {
|
||||
delete button.dataset.variant;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTagPreferenceState(button, state) {
|
||||
applyPreferenceState(button, state);
|
||||
}
|
||||
|
||||
function createTagButton({ doc, tag, onTagActivate, getTagState }) {
|
||||
const button = doc.createElement("button");
|
||||
button.type = "button";
|
||||
button.classList.add("pill", "video-tag-pill", "focus-ring");
|
||||
button.dataset.tag = tag;
|
||||
button.title = `#${tag}`;
|
||||
|
||||
const label = doc.createElement("span");
|
||||
label.className = "video-tag-pill__label";
|
||||
label.textContent = `#${tag}`;
|
||||
|
||||
const icon = doc.createElement("span");
|
||||
icon.className = "video-tag-pill__icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
|
||||
const svgNS = "http://www.w3.org/2000/svg";
|
||||
const svg = doc.createElementNS(svgNS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 16 16");
|
||||
svg.setAttribute("width", "16");
|
||||
svg.setAttribute("height", "16");
|
||||
svg.setAttribute("fill", "currentColor");
|
||||
svg.setAttribute("focusable", "false");
|
||||
|
||||
const path = doc.createElementNS(svgNS, "path");
|
||||
path.setAttribute(
|
||||
"d",
|
||||
"M8.75 3.5a.75.75 0 0 0-1.5 0v3.75H3.5a.75.75 0 1 0 0 1.5h3.75V12.5a.75.75 0 0 0 1.5 0V8.75H12.5a.75.75 0 0 0 0-1.5H8.75z",
|
||||
);
|
||||
|
||||
svg.append(path);
|
||||
icon.append(svg);
|
||||
|
||||
button.append(label, icon);
|
||||
|
||||
if (getTagState) {
|
||||
try {
|
||||
const state = getTagState(tag);
|
||||
applyPreferenceState(button, state);
|
||||
} catch (error) {
|
||||
// Ignore state resolution errors; leave button in neutral state.
|
||||
}
|
||||
} else {
|
||||
applyPreferenceState(button, null);
|
||||
}
|
||||
|
||||
if (onTagActivate) {
|
||||
const handler = (event) => {
|
||||
onTagActivate(tag, { event, button });
|
||||
};
|
||||
button.addEventListener("click", handler);
|
||||
// Store the handler for cleanup when the strip is updated.
|
||||
button.__tagPillClickHandler = handler;
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
function cleanupExistingButtons(root) {
|
||||
for (const button of root.querySelectorAll("button")) {
|
||||
const handler = button.__tagPillClickHandler;
|
||||
if (handler) {
|
||||
button.removeEventListener("click", handler);
|
||||
delete button.__tagPillClickHandler;
|
||||
}
|
||||
}
|
||||
root.textContent = "";
|
||||
}
|
||||
|
||||
export function renderTagPillStrip({
|
||||
document: doc,
|
||||
tags = [],
|
||||
onTagActivate,
|
||||
getTagState,
|
||||
} = {}) {
|
||||
const resolvedDocument = resolveDocument(doc);
|
||||
assertDocument(resolvedDocument);
|
||||
|
||||
const root = resolvedDocument.createElement("div");
|
||||
root.classList.add("video-tag-strip");
|
||||
|
||||
const buttons = tags.map((tag) =>
|
||||
createTagButton({
|
||||
doc: resolvedDocument,
|
||||
tag,
|
||||
onTagActivate,
|
||||
getTagState,
|
||||
}),
|
||||
);
|
||||
root.append(...buttons);
|
||||
|
||||
return { root, buttons };
|
||||
}
|
||||
|
||||
export function updateTagPillStrip({
|
||||
root,
|
||||
tags = [],
|
||||
onTagActivate,
|
||||
getTagState,
|
||||
document: doc,
|
||||
} = {}) {
|
||||
if (!root) {
|
||||
throw new Error("A root element is required to update the tag pill strip.");
|
||||
}
|
||||
|
||||
const resolvedDocument = resolveDocument(doc ?? root?.ownerDocument);
|
||||
assertDocument(resolvedDocument);
|
||||
|
||||
cleanupExistingButtons(root);
|
||||
|
||||
const buttons = tags.map((tag) =>
|
||||
createTagButton({
|
||||
doc: resolvedDocument,
|
||||
tag,
|
||||
onTagActivate,
|
||||
getTagState,
|
||||
}),
|
||||
);
|
||||
root.append(...buttons);
|
||||
|
||||
return { root, buttons };
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { normalizeDesignSystemContext } from "../../designSystem.js";
|
||||
|
||||
export const TAG_PREFERENCE_ACTIONS = {
|
||||
ADD_INTEREST: "add-interest",
|
||||
REMOVE_INTEREST: "remove-interest",
|
||||
ADD_DISINTEREST: "add-disinterest",
|
||||
REMOVE_DISINTEREST: "remove-disinterest",
|
||||
};
|
||||
|
||||
function resolveDocument(documentRef) {
|
||||
if (documentRef && documentRef.nodeType === 9) {
|
||||
return documentRef;
|
||||
}
|
||||
if (typeof document !== "undefined" && document?.nodeType === 9) {
|
||||
return document;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createElement(doc, tagName, { classNames = [], attrs = {}, textContent } = {}) {
|
||||
const el = doc.createElement(tagName);
|
||||
classNames
|
||||
.filter((name) => typeof name === "string" && name.trim())
|
||||
.forEach((name) => el.classList.add(name));
|
||||
Object.entries(attrs).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return;
|
||||
}
|
||||
el.setAttribute(key, value);
|
||||
});
|
||||
if (typeof textContent === "string") {
|
||||
el.textContent = textContent;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function ensureMenuContainer(doc, panelClassNames = []) {
|
||||
const panel = createElement(doc, "div", {
|
||||
classNames: ["popover__panel", ...panelClassNames],
|
||||
attrs: { role: "menu" },
|
||||
});
|
||||
|
||||
const list = createElement(doc, "div", {
|
||||
classNames: ["menu"],
|
||||
attrs: { role: "none" },
|
||||
});
|
||||
|
||||
panel.appendChild(list);
|
||||
return { panel, list };
|
||||
}
|
||||
|
||||
function appendMenuHeading(doc, list, text) {
|
||||
const heading = createElement(doc, "div", {
|
||||
classNames: ["menu__heading"],
|
||||
textContent: text,
|
||||
});
|
||||
list.appendChild(heading);
|
||||
return heading;
|
||||
}
|
||||
|
||||
function appendMenuAction(doc, list, { text, action, dataset = {}, disabled = false }) {
|
||||
const button = createElement(doc, "button", {
|
||||
classNames: ["menu__item", "justify-start"],
|
||||
textContent: text,
|
||||
attrs: { type: "button", role: "menuitem" },
|
||||
});
|
||||
|
||||
button.dataset.action = action;
|
||||
Object.entries(dataset).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
button.dataset[key] = String(value);
|
||||
});
|
||||
|
||||
if (disabled) {
|
||||
button.disabled = true;
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
|
||||
list.appendChild(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
function normalizeTag(tag) {
|
||||
if (typeof tag !== "string") {
|
||||
return { label: "", normalized: "" };
|
||||
}
|
||||
|
||||
const trimmed = tag.trim();
|
||||
if (!trimmed) {
|
||||
return { label: "", normalized: "" };
|
||||
}
|
||||
|
||||
const withoutHash = trimmed.replace(/^#+/, "");
|
||||
const normalized = withoutHash.toLowerCase();
|
||||
return {
|
||||
label: withoutHash,
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMembership(membership = {}) {
|
||||
const state =
|
||||
typeof membership.state === "string" ? membership.state.trim().toLowerCase() : "";
|
||||
const interestFlag = membership.interest === true || state === "interest";
|
||||
const disinterestFlag = membership.disinterest === true || state === "disinterest";
|
||||
|
||||
if (interestFlag && disinterestFlag) {
|
||||
// Favor the explicit state value when both flags are set to avoid conflicting UI states.
|
||||
if (state === "interest") {
|
||||
return { interest: true, disinterest: false };
|
||||
}
|
||||
if (state === "disinterest") {
|
||||
return { interest: false, disinterest: true };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interest: Boolean(interestFlag && !disinterestFlag),
|
||||
disinterest: Boolean(disinterestFlag && !interestFlag),
|
||||
};
|
||||
}
|
||||
|
||||
function setButtonDisabled(button, disabled) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
button.disabled = true;
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
} else {
|
||||
button.disabled = false;
|
||||
button.removeAttribute("aria-disabled");
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTagPreferenceMenuState({
|
||||
buttons = {},
|
||||
membership = {},
|
||||
isLoggedIn = false,
|
||||
}) {
|
||||
const resolvedMembership = resolveMembership(membership);
|
||||
const disableAll = !isLoggedIn;
|
||||
|
||||
setButtonDisabled(buttons.addInterest, disableAll || resolvedMembership.interest);
|
||||
setButtonDisabled(buttons.removeInterest, disableAll || !resolvedMembership.interest);
|
||||
setButtonDisabled(
|
||||
buttons.addDisinterest,
|
||||
disableAll || resolvedMembership.disinterest,
|
||||
);
|
||||
setButtonDisabled(
|
||||
buttons.removeDisinterest,
|
||||
disableAll || !resolvedMembership.disinterest,
|
||||
);
|
||||
}
|
||||
|
||||
export function createTagPreferenceMenu({
|
||||
document: documentRef = null,
|
||||
tag = "",
|
||||
isLoggedIn = false,
|
||||
membership = {},
|
||||
onAction = null,
|
||||
designSystem = null,
|
||||
} = {}) {
|
||||
const doc = resolveDocument(documentRef);
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
normalizeDesignSystemContext(designSystem);
|
||||
|
||||
const { label, normalized } = normalizeTag(tag);
|
||||
const headingLabel = label ? `#${label}` : "Tag preferences";
|
||||
|
||||
const { panel, list } = ensureMenuContainer(doc, ["w-56", "p-0"]);
|
||||
panel.dataset.menu = "tag-preference";
|
||||
if (normalized) {
|
||||
panel.dataset.tag = normalized;
|
||||
}
|
||||
|
||||
appendMenuHeading(doc, list, headingLabel);
|
||||
|
||||
if (!isLoggedIn) {
|
||||
const message = createElement(doc, "p", {
|
||||
classNames: ["px-4", "pb-2", "text-xs", "text-muted"],
|
||||
textContent: "Sign in to personalize your recommendations.",
|
||||
});
|
||||
list.appendChild(message);
|
||||
}
|
||||
|
||||
const buttons = {};
|
||||
const actionDataset = { tag: label || normalized };
|
||||
|
||||
buttons.addInterest = appendMenuAction(doc, list, {
|
||||
text: "Add to interests",
|
||||
action: TAG_PREFERENCE_ACTIONS.ADD_INTEREST,
|
||||
dataset: actionDataset,
|
||||
});
|
||||
|
||||
buttons.removeInterest = appendMenuAction(doc, list, {
|
||||
text: "Remove from interests",
|
||||
action: TAG_PREFERENCE_ACTIONS.REMOVE_INTEREST,
|
||||
dataset: actionDataset,
|
||||
});
|
||||
|
||||
buttons.addDisinterest = appendMenuAction(doc, list, {
|
||||
text: "Add to disinterests",
|
||||
action: TAG_PREFERENCE_ACTIONS.ADD_DISINTEREST,
|
||||
dataset: actionDataset,
|
||||
});
|
||||
|
||||
buttons.removeDisinterest = appendMenuAction(doc, list, {
|
||||
text: "Remove from disinterests",
|
||||
action: TAG_PREFERENCE_ACTIONS.REMOVE_DISINTEREST,
|
||||
dataset: actionDataset,
|
||||
});
|
||||
|
||||
applyTagPreferenceMenuState({ buttons, membership, isLoggedIn });
|
||||
|
||||
const handleAction = (action, event, button) => {
|
||||
if (typeof onAction !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onAction(action, {
|
||||
tag: label || normalized,
|
||||
normalizedTag: normalized,
|
||||
event,
|
||||
button,
|
||||
});
|
||||
} catch (error) {
|
||||
// Surface errors to callers by rethrowing to keep console visibility in dev mode.
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
Object.entries(buttons).forEach(([, button]) => {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const action = button.dataset.action;
|
||||
handleAction(action, event, button);
|
||||
});
|
||||
});
|
||||
|
||||
return { panel, buttons };
|
||||
}
|
||||
|
||||
export default createTagPreferenceMenu;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { DeleteModal } from "./components/DeleteModal.js";
|
||||
|
||||
export function initDeleteModal({
|
||||
app = null,
|
||||
deleteModalOverride = null,
|
||||
container = null,
|
||||
utilities = {},
|
||||
callbacks = {},
|
||||
eventTarget = null,
|
||||
} = {}) {
|
||||
const events = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
|
||||
|
||||
const {
|
||||
removeTrackingScripts: removeTrackingScriptsFn = null,
|
||||
setGlobalModalState: setGlobalModalStateFn = null,
|
||||
truncateMiddle = null,
|
||||
} = utilities;
|
||||
|
||||
const {
|
||||
onConfirm = null,
|
||||
onCancel = null,
|
||||
} = callbacks;
|
||||
|
||||
const modal =
|
||||
(typeof deleteModalOverride === "function"
|
||||
? deleteModalOverride({ app, eventTarget: events })
|
||||
: deleteModalOverride) ||
|
||||
new DeleteModal({
|
||||
removeTrackingScripts: removeTrackingScriptsFn,
|
||||
setGlobalModalState: setGlobalModalStateFn,
|
||||
truncateMiddle,
|
||||
container,
|
||||
eventTarget: events,
|
||||
});
|
||||
|
||||
const boundConfirmHandler =
|
||||
typeof onConfirm === "function" ? (event) => onConfirm(event) : null;
|
||||
const boundCancelHandler = typeof onCancel === "function" ? () => onCancel() : null;
|
||||
|
||||
if (boundConfirmHandler && typeof modal?.addEventListener === "function") {
|
||||
modal.addEventListener("video:delete-confirm", boundConfirmHandler);
|
||||
}
|
||||
|
||||
if (boundCancelHandler && typeof modal?.addEventListener === "function") {
|
||||
modal.addEventListener("video:delete-cancel", boundCancelHandler);
|
||||
}
|
||||
|
||||
return {
|
||||
modal,
|
||||
events,
|
||||
handlers: {
|
||||
confirm: boundConfirmHandler,
|
||||
cancel: boundCancelHandler,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default initDeleteModal;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { EditModal } from "./components/EditModal.js";
|
||||
|
||||
export function initEditModal({
|
||||
app = null,
|
||||
editModalOverride = null,
|
||||
container = null,
|
||||
services = {},
|
||||
utilities = {},
|
||||
callbacks = {},
|
||||
eventTarget = null,
|
||||
} = {}) {
|
||||
const events = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
|
||||
|
||||
const {
|
||||
getMode = null,
|
||||
sanitizers = {},
|
||||
} = services;
|
||||
|
||||
const {
|
||||
removeTrackingScripts: removeTrackingScriptsFn = null,
|
||||
setGlobalModalState: setGlobalModalStateFn = null,
|
||||
escapeHtml = null,
|
||||
} = utilities;
|
||||
|
||||
const {
|
||||
showError = null,
|
||||
onSubmit = null,
|
||||
onCancel = null,
|
||||
} = callbacks;
|
||||
|
||||
const modal =
|
||||
(typeof editModalOverride === "function"
|
||||
? editModalOverride({ app, eventTarget: events })
|
||||
: editModalOverride) ||
|
||||
new EditModal({
|
||||
removeTrackingScripts: removeTrackingScriptsFn,
|
||||
setGlobalModalState: setGlobalModalStateFn,
|
||||
showError,
|
||||
getMode,
|
||||
sanitizers,
|
||||
escapeHtml,
|
||||
eventTarget: events,
|
||||
container,
|
||||
});
|
||||
|
||||
const boundSubmitHandler =
|
||||
typeof onSubmit === "function" ? (event) => onSubmit(event) : null;
|
||||
const boundCancelHandler = typeof onCancel === "function" ? () => onCancel() : null;
|
||||
|
||||
if (boundSubmitHandler && typeof modal?.addEventListener === "function") {
|
||||
modal.addEventListener("video:edit-submit", boundSubmitHandler);
|
||||
}
|
||||
|
||||
if (boundCancelHandler && typeof modal?.addEventListener === "function") {
|
||||
modal.addEventListener("video:edit-cancel", boundCancelHandler);
|
||||
}
|
||||
|
||||
return {
|
||||
modal,
|
||||
events,
|
||||
handlers: {
|
||||
submit: boundSubmitHandler,
|
||||
cancel: boundCancelHandler,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default initEditModal;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { UploadModal } from "./components/UploadModal.js";
|
||||
|
||||
export function initUploadModal({
|
||||
app = null,
|
||||
uploadModalOverride = null,
|
||||
container = null,
|
||||
services = {},
|
||||
utilities = {},
|
||||
callbacks = {},
|
||||
eventTarget = null,
|
||||
} = {}) {
|
||||
const events = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
|
||||
|
||||
const {
|
||||
authService = null,
|
||||
r2Service = null,
|
||||
} = services;
|
||||
|
||||
const {
|
||||
removeTrackingScripts: removeTrackingScriptsFn = null,
|
||||
setGlobalModalState: setGlobalModalStateFn = null,
|
||||
} = utilities;
|
||||
|
||||
const {
|
||||
publishVideoNote = null,
|
||||
showError = null,
|
||||
showSuccess = null,
|
||||
getCurrentPubkey = null,
|
||||
safeEncodeNpub = null,
|
||||
onSubmit = null,
|
||||
} = callbacks;
|
||||
|
||||
const modal =
|
||||
(typeof uploadModalOverride === "function"
|
||||
? uploadModalOverride({ app, eventTarget: events })
|
||||
: uploadModalOverride) ||
|
||||
new UploadModal({
|
||||
authService,
|
||||
r2Service,
|
||||
publishVideoNote,
|
||||
removeTrackingScripts: removeTrackingScriptsFn,
|
||||
setGlobalModalState: setGlobalModalStateFn,
|
||||
showError,
|
||||
showSuccess,
|
||||
getCurrentPubkey,
|
||||
safeEncodeNpub,
|
||||
eventTarget: events,
|
||||
container,
|
||||
});
|
||||
|
||||
const boundSubmitHandler =
|
||||
typeof onSubmit === "function" ? (event) => onSubmit(event) : null;
|
||||
|
||||
if (boundSubmitHandler && typeof modal?.addEventListener === "function") {
|
||||
modal.addEventListener("upload:submit", boundSubmitHandler);
|
||||
}
|
||||
|
||||
return {
|
||||
modal,
|
||||
events,
|
||||
handlers: {
|
||||
submit: boundSubmitHandler,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default initUploadModal;
|
||||
+2121
-339
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,162 @@
|
||||
// js/ui/moderationCopy.js
|
||||
|
||||
export function buildModerationReasonText(context) {
|
||||
if (!context) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const reasons = [];
|
||||
|
||||
if (context.trustedMuted) {
|
||||
const muteCount = Math.max(1, Number(context.trustedMuteCount) || 0);
|
||||
const muteLabel = muteCount === 1 ? "trusted contact" : "trusted contacts";
|
||||
const countLabel = muteCount === 1 ? "a" : muteCount;
|
||||
reasons.push(`muted by ${countLabel} ${muteLabel}`);
|
||||
}
|
||||
|
||||
const typeLabel = context.friendlyType || "this video";
|
||||
const reportCount = Math.max(0, Number(context.trustedCount) || 0);
|
||||
if (reportCount > 0) {
|
||||
const friendLabel = reportCount === 1 ? "friend" : "friends";
|
||||
reasons.push(`${reportCount} ${friendLabel} reported ${typeLabel}`);
|
||||
} else if (!context.trustedMuted) {
|
||||
reasons.push(context.friendlyType ? `reports of ${typeLabel}` : "reports");
|
||||
}
|
||||
|
||||
if (!reasons.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const combined = reasons.join(" · ");
|
||||
return combined.charAt(0).toUpperCase() + combined.slice(1);
|
||||
}
|
||||
|
||||
export function buildHiddenSummaryLabel(context) {
|
||||
if (!context) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const reason = context.effectiveHideReason;
|
||||
if (!reason) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const countsSource =
|
||||
context.effectiveHideCounts || context.originalHideCounts || context.hideCounts || null;
|
||||
|
||||
const getCount = (key, fallback) => {
|
||||
if (countsSource && Number.isFinite(countsSource[key])) {
|
||||
return Math.max(0, Number(countsSource[key]));
|
||||
}
|
||||
if (Number.isFinite(context[key])) {
|
||||
return Math.max(0, Number(context[key]));
|
||||
}
|
||||
return Math.max(0, Number(fallback) || 0);
|
||||
};
|
||||
|
||||
if (reason === "trusted-mute-hide") {
|
||||
const count = getCount("trustedMuteCount", context.trustedMuteCount || 0);
|
||||
if (count > 0) {
|
||||
const label = count === 1 ? "trusted mute" : "trusted mutes";
|
||||
return `${count} ${label}`;
|
||||
}
|
||||
return "trusted mute";
|
||||
}
|
||||
|
||||
if (reason === "trusted-report-hide") {
|
||||
const count = getCount("trustedReportCount", context.trustedCount || 0);
|
||||
const type = typeof context.friendlyType === "string" ? context.friendlyType.trim() : "";
|
||||
const normalizedType = type ? type.toLowerCase() : "";
|
||||
if (count > 0) {
|
||||
const label = count === 1 ? "report" : "reports";
|
||||
if (normalizedType) {
|
||||
return `${count} trusted ${normalizedType} ${label}`;
|
||||
}
|
||||
const base = count === 1 ? "trusted report" : "trusted reports";
|
||||
return `${count} ${base}`;
|
||||
}
|
||||
if (normalizedType) {
|
||||
return `trusted ${normalizedType} reports`;
|
||||
}
|
||||
return "trusted reports";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function buildModerationBadgeText(context, options = {}) {
|
||||
if (!context) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const variant = typeof options.variant === "string" ? options.variant : "card";
|
||||
|
||||
if (variant === "modal") {
|
||||
if (context.originalBlockAutoplay && context.trustedMuted) {
|
||||
return "Content or user blocked by a trusted contact.";
|
||||
}
|
||||
|
||||
const blurSource = (context.originalBlurReason || context.blurReason || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
const suppressBlurLabel =
|
||||
blurSource === "trusted-mute" || blurSource === "trusted-mute-hide";
|
||||
|
||||
const parts = [];
|
||||
if (!suppressBlurLabel) {
|
||||
parts.push("Blurred");
|
||||
}
|
||||
|
||||
const reason = buildModerationReasonText(context);
|
||||
if (reason) {
|
||||
parts.push(reason);
|
||||
}
|
||||
|
||||
if (!parts.length) {
|
||||
return "Blurred due to reports";
|
||||
}
|
||||
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
const hiddenLabel = buildHiddenSummaryLabel(context);
|
||||
const reason = buildModerationReasonText(context);
|
||||
|
||||
if (context.overrideActive) {
|
||||
if (hiddenLabel) {
|
||||
return `Showing despite ${hiddenLabel}`;
|
||||
}
|
||||
if (reason) {
|
||||
return `Showing despite ${reason}`;
|
||||
}
|
||||
return "Showing despite reports";
|
||||
}
|
||||
|
||||
if (context.activeHidden || (context.originalHidden && !context.overrideActive)) {
|
||||
if (hiddenLabel) {
|
||||
return `Hidden · ${hiddenLabel}`;
|
||||
}
|
||||
return "Hidden";
|
||||
}
|
||||
|
||||
if (context.originalBlockAutoplay && context.trustedMuted) {
|
||||
return "Content or user blocked by a trusted contact.";
|
||||
}
|
||||
|
||||
const blurSource = (context.originalBlurReason || context.blurReason || "").toLowerCase();
|
||||
const suppressBlurLabel =
|
||||
blurSource === "trusted-mute" || blurSource === "trusted-mute-hide";
|
||||
|
||||
const parts = [];
|
||||
if (context.originalBlur && !suppressBlurLabel) {
|
||||
parts.push("Blurred");
|
||||
}
|
||||
if (context.originalBlockAutoplay) {
|
||||
parts.push("Autoplay blocked");
|
||||
}
|
||||
if (reason) {
|
||||
parts.push(reason);
|
||||
}
|
||||
|
||||
return parts.join(" · ");
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
const SHOW_ANYWAY_LABEL = "Show anyway";
|
||||
const RESTORE_DEFAULT_MODERATION_LABEL = "Restore default moderation";
|
||||
|
||||
export function getModerationOverrideActionLabels({ overrideActive } = {}) {
|
||||
const active = overrideActive === true;
|
||||
const label = active
|
||||
? RESTORE_DEFAULT_MODERATION_LABEL
|
||||
: SHOW_ANYWAY_LABEL;
|
||||
|
||||
return {
|
||||
text: label,
|
||||
ariaLabel: label,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeVideoModerationContext(moderationInput) {
|
||||
const moderation =
|
||||
moderationInput && typeof moderationInput === "object"
|
||||
? moderationInput
|
||||
: null;
|
||||
|
||||
const summary =
|
||||
moderation?.summary && typeof moderation.summary === "object"
|
||||
? moderation.summary
|
||||
: null;
|
||||
|
||||
let reportType = "";
|
||||
if (typeof moderation?.reportType === "string" && moderation.reportType.trim()) {
|
||||
reportType = moderation.reportType.trim().toLowerCase();
|
||||
}
|
||||
|
||||
if (!reportType && summary && summary.types && typeof summary.types === "object") {
|
||||
for (const [type, stats] of Object.entries(summary.types)) {
|
||||
if (stats && Number.isFinite(stats.trusted) && Math.floor(stats.trusted) > 0) {
|
||||
reportType = String(type).toLowerCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let trustedCount = Number.isFinite(moderation?.trustedCount)
|
||||
? Math.max(0, Math.floor(moderation.trustedCount))
|
||||
: 0;
|
||||
|
||||
if (!trustedCount && summary && summary.types && typeof summary.types === "object") {
|
||||
for (const stats of Object.values(summary.types)) {
|
||||
if (stats && Number.isFinite(stats.trusted)) {
|
||||
trustedCount = Math.max(trustedCount, Math.floor(stats.trusted));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reporterDisplayNames = Array.isArray(moderation?.reporterDisplayNames)
|
||||
? moderation.reporterDisplayNames
|
||||
.map((name) => (typeof name === "string" ? name.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const trustedMuted = moderation?.trustedMuted === true;
|
||||
let trustedMuteCount = Number.isFinite(moderation?.trustedMuteCount)
|
||||
? Math.max(0, Math.floor(moderation.trustedMuteCount))
|
||||
: 0;
|
||||
|
||||
if (!trustedMuteCount && Array.isArray(moderation?.trustedMuters)) {
|
||||
const muters = moderation.trustedMuters
|
||||
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
||||
.filter(Boolean);
|
||||
trustedMuteCount = muters.length;
|
||||
}
|
||||
|
||||
const trustedMuteDisplayNames = Array.isArray(moderation?.trustedMuterDisplayNames)
|
||||
? moderation.trustedMuterDisplayNames
|
||||
.map((name) => (typeof name === "string" ? name.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const original =
|
||||
moderation?.original && typeof moderation.original === "object"
|
||||
? moderation.original
|
||||
: {};
|
||||
|
||||
const activeHidden = moderation?.hidden === true;
|
||||
const originalHidden = original.hidden === true;
|
||||
const hideReasonActive =
|
||||
typeof moderation?.hideReason === "string" ? moderation.hideReason.trim() : "";
|
||||
const hideBypassActive =
|
||||
typeof moderation?.hideBypass === "string" ? moderation.hideBypass.trim() : "";
|
||||
const originalHideReason =
|
||||
typeof original.hideReason === "string" ? original.hideReason.trim() : "";
|
||||
const originalHideBypass =
|
||||
typeof original.hideBypass === "string" ? original.hideBypass.trim() : "";
|
||||
const originalHideTriggered = original.hideTriggered === true;
|
||||
|
||||
const normalizeHideCounts = (input) => {
|
||||
if (!input || typeof input !== "object") {
|
||||
return null;
|
||||
}
|
||||
const normalized = {};
|
||||
let hasValue = false;
|
||||
if (Number.isFinite(input.trustedMuteCount)) {
|
||||
normalized.trustedMuteCount = Math.max(0, Math.floor(input.trustedMuteCount));
|
||||
hasValue = true;
|
||||
}
|
||||
if (Number.isFinite(input.trustedReportCount)) {
|
||||
normalized.trustedReportCount = Math.max(0, Math.floor(input.trustedReportCount));
|
||||
hasValue = true;
|
||||
}
|
||||
return hasValue ? normalized : null;
|
||||
};
|
||||
|
||||
const baseHideCounts = {
|
||||
trustedMuteCount,
|
||||
trustedReportCount: trustedCount,
|
||||
};
|
||||
|
||||
const activeHideCounts =
|
||||
normalizeHideCounts(moderation?.hideCounts) ||
|
||||
(activeHidden || hideReasonActive ? { ...baseHideCounts } : null);
|
||||
const originalHideCounts =
|
||||
normalizeHideCounts(original?.hideCounts) ||
|
||||
(originalHideTriggered ? { ...baseHideCounts } : null);
|
||||
|
||||
const effectiveHideReason = hideReasonActive || originalHideReason;
|
||||
const effectiveHideCounts = activeHideCounts || originalHideCounts;
|
||||
|
||||
const blurReason =
|
||||
typeof moderation?.blurReason === "string" ? moderation.blurReason.trim() : "";
|
||||
const originalBlurReason =
|
||||
typeof original?.blurReason === "string" ? original.blurReason.trim() : "";
|
||||
|
||||
const context = {
|
||||
reportType,
|
||||
friendlyType: reportType ? reportType.replace(/[_-]+/g, " ").trim() : "",
|
||||
trustedCount,
|
||||
reporterDisplayNames,
|
||||
trustedMuted,
|
||||
trustedMuteCount,
|
||||
trustedMuteDisplayNames,
|
||||
blurReason,
|
||||
originalBlurReason,
|
||||
originalBlur: original.blurThumbnail === true,
|
||||
originalBlockAutoplay: original.blockAutoplay === true,
|
||||
activeBlur: moderation?.blurThumbnail === true,
|
||||
activeBlockAutoplay: moderation?.blockAutoplay === true,
|
||||
overrideActive: moderation?.viewerOverride?.showAnyway === true,
|
||||
activeHidden,
|
||||
originalHidden,
|
||||
originalHideTriggered,
|
||||
hideReason: hideReasonActive,
|
||||
hideCounts: activeHideCounts,
|
||||
hideBypass: hideBypassActive,
|
||||
originalHideReason,
|
||||
originalHideCounts,
|
||||
originalHideBypass,
|
||||
effectiveHideReason,
|
||||
effectiveHideCounts,
|
||||
};
|
||||
|
||||
context.shouldShow =
|
||||
context.originalBlur ||
|
||||
context.originalBlockAutoplay ||
|
||||
context.trustedCount > 0 ||
|
||||
context.trustedMuted ||
|
||||
context.overrideActive ||
|
||||
context.originalHidden ||
|
||||
context.activeHidden ||
|
||||
context.originalHideTriggered;
|
||||
|
||||
context.allowOverride =
|
||||
context.originalBlur || context.originalBlockAutoplay || context.originalHidden;
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function collectElements(...inputs) {
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
inputs.forEach((input) => {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const values = Array.isArray(input) ? input : [input];
|
||||
values.forEach((value) => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
if (!("dataset" in value)) {
|
||||
return;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return;
|
||||
}
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function applyModerationContextDatasets(
|
||||
context,
|
||||
{
|
||||
root,
|
||||
roots,
|
||||
thumbnail,
|
||||
thumbnails,
|
||||
avatar,
|
||||
avatars,
|
||||
shouldMaskNsfwForOwner = false,
|
||||
} = {},
|
||||
) {
|
||||
if (!context || typeof context !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootElements = collectElements(root, roots);
|
||||
const thumbnailElements = shouldMaskNsfwForOwner
|
||||
? []
|
||||
: collectElements(thumbnail, thumbnails);
|
||||
const avatarElements = shouldMaskNsfwForOwner ? [] : collectElements(avatar, avatars);
|
||||
|
||||
const shouldBlur = context.activeBlur && !context.overrideActive;
|
||||
|
||||
thumbnailElements.forEach((el) => {
|
||||
if (shouldBlur) {
|
||||
el.dataset.thumbnailState = "blurred";
|
||||
} else if (el.dataset.thumbnailState === "blurred") {
|
||||
delete el.dataset.thumbnailState;
|
||||
}
|
||||
});
|
||||
|
||||
avatarElements.forEach((el) => {
|
||||
if (shouldBlur) {
|
||||
el.dataset.visualState = "blurred";
|
||||
} else if (el.dataset.visualState) {
|
||||
delete el.dataset.visualState;
|
||||
}
|
||||
});
|
||||
|
||||
const applyToRoots = (callback) => {
|
||||
rootElements.forEach((el) => {
|
||||
if (el) {
|
||||
callback(el);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const restoreCopy = getModerationOverrideActionLabels({ overrideActive: true });
|
||||
|
||||
applyToRoots((el) => {
|
||||
if (context.originalBlockAutoplay && !context.overrideActive) {
|
||||
el.dataset.autoplayPolicy = "blocked";
|
||||
} else if (el.dataset.autoplayPolicy) {
|
||||
delete el.dataset.autoplayPolicy;
|
||||
}
|
||||
|
||||
if (context.overrideActive) {
|
||||
el.dataset.moderationOverride = "show-anyway";
|
||||
el.dataset.moderationOverrideActionLabel = restoreCopy.text;
|
||||
} else if (el.dataset.moderationOverride) {
|
||||
delete el.dataset.moderationOverride;
|
||||
if (el.dataset.moderationOverrideActionLabel) {
|
||||
delete el.dataset.moderationOverrideActionLabel;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const reportCount = Math.max(0, Number(context.trustedCount) || 0);
|
||||
applyToRoots((el) => {
|
||||
if (reportCount > 0) {
|
||||
el.dataset.moderationReportCount = String(reportCount);
|
||||
if (context.reportType) {
|
||||
el.dataset.moderationReportType = context.reportType;
|
||||
} else if (el.dataset.moderationReportType) {
|
||||
delete el.dataset.moderationReportType;
|
||||
}
|
||||
} else {
|
||||
if (el.dataset.moderationReportType) {
|
||||
delete el.dataset.moderationReportType;
|
||||
}
|
||||
if (el.dataset.moderationReportCount) {
|
||||
delete el.dataset.moderationReportCount;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
applyToRoots((el) => {
|
||||
if (context.trustedMuted) {
|
||||
el.dataset.moderationTrustedMute = "true";
|
||||
const muteCount = Math.max(0, Number(context.trustedMuteCount) || 0);
|
||||
if (muteCount > 0) {
|
||||
el.dataset.moderationTrustedMuteCount = String(muteCount);
|
||||
} else if (el.dataset.moderationTrustedMuteCount) {
|
||||
delete el.dataset.moderationTrustedMuteCount;
|
||||
}
|
||||
} else {
|
||||
if (el.dataset.moderationTrustedMute) {
|
||||
delete el.dataset.moderationTrustedMute;
|
||||
}
|
||||
if (el.dataset.moderationTrustedMuteCount) {
|
||||
delete el.dataset.moderationTrustedMuteCount;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
applyToRoots((el) => {
|
||||
if (context.activeHidden && !context.overrideActive) {
|
||||
el.dataset.moderationHidden = "true";
|
||||
const reason = context.effectiveHideReason || context.hideReason;
|
||||
if (reason) {
|
||||
el.dataset.moderationHideReason = reason;
|
||||
} else if (el.dataset.moderationHideReason) {
|
||||
delete el.dataset.moderationHideReason;
|
||||
}
|
||||
|
||||
const counts = context.hideCounts || context.effectiveHideCounts || null;
|
||||
const muteCount = counts && Number.isFinite(counts.trustedMuteCount)
|
||||
? Math.max(0, Number(counts.trustedMuteCount))
|
||||
: null;
|
||||
const reportCountHide = counts && Number.isFinite(counts.trustedReportCount)
|
||||
? Math.max(0, Number(counts.trustedReportCount))
|
||||
: null;
|
||||
|
||||
if (muteCount !== null) {
|
||||
el.dataset.moderationHideTrustedMuteCount = String(muteCount);
|
||||
} else if (el.dataset.moderationHideTrustedMuteCount) {
|
||||
delete el.dataset.moderationHideTrustedMuteCount;
|
||||
}
|
||||
|
||||
if (reportCountHide !== null) {
|
||||
el.dataset.moderationHideTrustedReportCount = String(reportCountHide);
|
||||
} else if (el.dataset.moderationHideTrustedReportCount) {
|
||||
delete el.dataset.moderationHideTrustedReportCount;
|
||||
}
|
||||
} else {
|
||||
if (el.dataset.moderationHidden) {
|
||||
delete el.dataset.moderationHidden;
|
||||
}
|
||||
if (el.dataset.moderationHideReason) {
|
||||
delete el.dataset.moderationHideReason;
|
||||
}
|
||||
if (el.dataset.moderationHideTrustedMuteCount) {
|
||||
delete el.dataset.moderationHideTrustedMuteCount;
|
||||
}
|
||||
if (el.dataset.moderationHideTrustedReportCount) {
|
||||
delete el.dataset.moderationHideTrustedReportCount;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -266,7 +266,11 @@ export function createPopover(trigger, render, options = {}) {
|
||||
}
|
||||
|
||||
const restoreScroll = () => {
|
||||
view.scrollTo(scrollX, scrollY);
|
||||
try {
|
||||
view.scrollTo(scrollX, scrollY);
|
||||
} catch (error) {
|
||||
logger.dev?.warn?.("[popover] scroll restoration failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
restoreScroll();
|
||||
|
||||
+1197
-53
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,15 @@
|
||||
import { VideoCard } from "../components/VideoCard.js";
|
||||
import {
|
||||
renderTagPillStrip,
|
||||
applyTagPreferenceState,
|
||||
} from "../components/tagPillList.js";
|
||||
import {
|
||||
subscribeToVideoViewCount,
|
||||
unsubscribeFromVideoViewCount,
|
||||
} from "../../viewCounter.js";
|
||||
import { normalizeDesignSystemContext } from "../../designSystem.js";
|
||||
import { userLogger } from "../../utils/logger.js";
|
||||
import { collectVideoTags } from "../../utils/videoTags.js";
|
||||
|
||||
const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__";
|
||||
|
||||
@@ -174,6 +179,8 @@ export class VideoListView {
|
||||
this.currentVideos = [];
|
||||
this.lastRenderedVideoSignature = null;
|
||||
this._lastRenderedVideoListElement = null;
|
||||
this.popularTagsRoot = null;
|
||||
this._popularTagStrip = null;
|
||||
|
||||
this.handlers = {
|
||||
playback: null,
|
||||
@@ -182,8 +189,13 @@ export class VideoListView {
|
||||
delete: null,
|
||||
blacklist: null,
|
||||
moderationOverride: null,
|
||||
moderationBlock: null,
|
||||
moderationHide: null,
|
||||
tagActivate: null,
|
||||
};
|
||||
|
||||
this.tagPreferenceStateResolver = null;
|
||||
|
||||
this.allowNsfw = allowNsfw !== false;
|
||||
this.designSystem = normalizeDesignSystemContext(designSystem);
|
||||
|
||||
@@ -256,6 +268,12 @@ export class VideoListView {
|
||||
this.currentVideos = [];
|
||||
this.lastRenderedVideoSignature = null;
|
||||
this._lastRenderedVideoListElement = null;
|
||||
if (this.popularTagsRoot) {
|
||||
this.popularTagsRoot.textContent = "";
|
||||
this.popularTagsRoot.hidden = true;
|
||||
}
|
||||
this.popularTagsRoot = null;
|
||||
this._popularTagStrip = null;
|
||||
}
|
||||
|
||||
setPlaybackHandler(handler) {
|
||||
@@ -282,6 +300,116 @@ export class VideoListView {
|
||||
this.handlers.moderationOverride = typeof handler === "function" ? handler : null;
|
||||
}
|
||||
|
||||
setModerationBlockHandler(handler) {
|
||||
this.handlers.moderationBlock =
|
||||
typeof handler === "function" ? handler : null;
|
||||
}
|
||||
|
||||
setModerationHideHandler(handler) {
|
||||
this.handlers.moderationHide =
|
||||
typeof handler === "function" ? handler : null;
|
||||
}
|
||||
|
||||
setTagActivationHandler(handler) {
|
||||
this.handlers.tagActivate = typeof handler === "function" ? handler : null;
|
||||
}
|
||||
|
||||
setTagPreferenceStateResolver(resolver) {
|
||||
this.tagPreferenceStateResolver =
|
||||
typeof resolver === "function" ? resolver : null;
|
||||
}
|
||||
|
||||
setPopularTagsContainer(container) {
|
||||
if (this.popularTagsRoot && this.popularTagsRoot !== container) {
|
||||
this.popularTagsRoot.textContent = "";
|
||||
this.popularTagsRoot.hidden = true;
|
||||
}
|
||||
|
||||
this.popularTagsRoot = container || null;
|
||||
this._popularTagStrip = null;
|
||||
|
||||
if (this.popularTagsRoot) {
|
||||
this.popularTagsRoot.textContent = "";
|
||||
this.popularTagsRoot.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
updatePopularTags(videos) {
|
||||
const root = this.popularTagsRoot;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(videos) || videos.length === 0) {
|
||||
root.textContent = "";
|
||||
root.hidden = true;
|
||||
this._popularTagStrip = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const counts = new Map();
|
||||
const displayNames = new Map();
|
||||
|
||||
videos.forEach((video) => {
|
||||
const tags = collectVideoTags(video);
|
||||
tags.forEach((tag) => {
|
||||
if (typeof tag !== "string" || !tag) {
|
||||
return;
|
||||
}
|
||||
const lower = tag.toLowerCase();
|
||||
counts.set(lower, (counts.get(lower) || 0) + 1);
|
||||
if (!displayNames.has(lower)) {
|
||||
displayNames.set(lower, tag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!counts.size) {
|
||||
root.textContent = "";
|
||||
root.hidden = true;
|
||||
this._popularTagStrip = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const tagEntries = Array.from(counts.entries()).map(([lower, count]) => ({
|
||||
count,
|
||||
tag: displayNames.get(lower) || lower,
|
||||
}));
|
||||
|
||||
tagEntries.sort((a, b) => {
|
||||
if (b.count !== a.count) {
|
||||
return b.count - a.count;
|
||||
}
|
||||
const lowerA = a.tag.toLowerCase();
|
||||
const lowerB = b.tag.toLowerCase();
|
||||
if (lowerA === lowerB) {
|
||||
return a.tag.localeCompare(b.tag);
|
||||
}
|
||||
return lowerA.localeCompare(lowerB);
|
||||
});
|
||||
|
||||
const tags = tagEntries.map((entry) => entry.tag);
|
||||
const doc = this.document || root.ownerDocument || null;
|
||||
if (!doc) {
|
||||
root.textContent = "";
|
||||
root.hidden = true;
|
||||
this._popularTagStrip = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { root: strip } = renderTagPillStrip({
|
||||
document: doc,
|
||||
tags,
|
||||
onTagActivate: (tag, detail = {}) =>
|
||||
this.handlePopularTagActivate(tag, detail),
|
||||
getTagState: (tag) => this.resolveTagPreferenceState(tag),
|
||||
});
|
||||
root.textContent = "";
|
||||
root.appendChild(strip);
|
||||
root.hidden = false;
|
||||
this._popularTagStrip = strip;
|
||||
}
|
||||
|
||||
render(videos, metadata = null) {
|
||||
if (!this.container) {
|
||||
return [];
|
||||
@@ -307,6 +435,8 @@ export class VideoListView {
|
||||
return canEdit || !isPrivate;
|
||||
});
|
||||
|
||||
this.updatePopularTags(displayVideos);
|
||||
|
||||
this.syncViewCountSubscriptions(displayVideos);
|
||||
this.cleanupThumbnailCache(displayVideos);
|
||||
this.cleanupHealthCache(displayVideos);
|
||||
@@ -325,7 +455,10 @@ export class VideoListView {
|
||||
return displayVideos;
|
||||
}
|
||||
|
||||
displayVideos.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
|
||||
displayVideos.sort(
|
||||
(a, b) =>
|
||||
this.getVideoPostedAtForSort(b) - this.getVideoPostedAtForSort(a),
|
||||
);
|
||||
this.currentVideos = displayVideos.slice();
|
||||
|
||||
const signaturePayload = displayVideos.map((video) => ({
|
||||
@@ -333,6 +466,10 @@ export class VideoListView {
|
||||
createdAt: Number.isFinite(video?.created_at)
|
||||
? video.created_at
|
||||
: Number(video?.created_at) || 0,
|
||||
postedAt: (() => {
|
||||
const timestamp = this.getVideoPostedAtForSort(video);
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
})(),
|
||||
deleted: Boolean(video?.deleted),
|
||||
isPrivate: Boolean(video?.isPrivate),
|
||||
isNsfw: Boolean(video?.isNsfw),
|
||||
@@ -400,9 +537,10 @@ export class VideoListView {
|
||||
const fallbackTimestamp =
|
||||
normalizedPostedAt !== null
|
||||
? normalizedPostedAt
|
||||
: Number.isFinite(video?.created_at)
|
||||
? Math.floor(video.created_at)
|
||||
: null;
|
||||
: (() => {
|
||||
const sortTimestamp = this.getVideoPostedAtForSort(video);
|
||||
return Number.isFinite(sortTimestamp) ? sortTimestamp : null;
|
||||
})();
|
||||
const timeAgo =
|
||||
fallbackTimestamp !== null
|
||||
? this.formatters.formatTimeAgo(fallbackTimestamp)
|
||||
@@ -483,6 +621,34 @@ export class VideoListView {
|
||||
});
|
||||
};
|
||||
|
||||
videoCard.onModerationBlock = ({ event: blockEvent }) => {
|
||||
if (!this.handlers.moderationBlock) {
|
||||
return false;
|
||||
}
|
||||
const trigger =
|
||||
blockEvent?.currentTarget || blockEvent?.target || null;
|
||||
return this.handlers.moderationBlock({
|
||||
event: blockEvent,
|
||||
video,
|
||||
card: videoCard,
|
||||
trigger,
|
||||
});
|
||||
};
|
||||
|
||||
videoCard.onModerationHide = ({ event: hideEvent }) => {
|
||||
if (!this.handlers.moderationHide) {
|
||||
return false;
|
||||
}
|
||||
const trigger =
|
||||
hideEvent?.currentTarget || hideEvent?.target || null;
|
||||
return this.handlers.moderationHide({
|
||||
event: hideEvent,
|
||||
video,
|
||||
card: videoCard,
|
||||
trigger,
|
||||
});
|
||||
};
|
||||
|
||||
videoCard.onEdit = ({ event: editEvent, video: editVideo, index: editIndex }) => {
|
||||
if (this.handlers.edit) {
|
||||
const trigger = editEvent?.currentTarget || editEvent?.target || null;
|
||||
@@ -770,6 +936,31 @@ export class VideoListView {
|
||||
return next;
|
||||
}
|
||||
|
||||
getVideoPostedAtForSort(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
const known = this.utils.getKnownVideoPostedAt(video);
|
||||
if (Number.isFinite(known)) {
|
||||
return Math.floor(known);
|
||||
}
|
||||
|
||||
if (Number.isFinite(video?.rootCreatedAt)) {
|
||||
return Math.floor(video.rootCreatedAt);
|
||||
}
|
||||
|
||||
if (Number.isFinite(video?.nip71Source?.created_at)) {
|
||||
return Math.floor(video.nip71Source.created_at);
|
||||
}
|
||||
|
||||
if (Number.isFinite(video?.created_at)) {
|
||||
return Math.floor(video.created_at);
|
||||
}
|
||||
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
computeShareBase() {
|
||||
const explicit = this.utils.getShareUrlBase();
|
||||
if (explicit) {
|
||||
@@ -1126,4 +1317,62 @@ export class VideoListView {
|
||||
const detail = this.extractPlaybackDetail(trigger, null);
|
||||
this.emitSelected(detail);
|
||||
}
|
||||
|
||||
resolveTagPreferenceState(tag) {
|
||||
const resolver = this.tagPreferenceStateResolver;
|
||||
if (typeof resolver !== "function") {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
try {
|
||||
return resolver(tag);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[VideoListView] Failed to resolve tag preference state:",
|
||||
error,
|
||||
);
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
refreshTagPreferenceStates() {
|
||||
if (!this._popularTagStrip) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buttons = this._popularTagStrip.querySelectorAll("button[data-tag]");
|
||||
buttons.forEach((button) => {
|
||||
const tag = button.dataset.tag || "";
|
||||
const state = this.resolveTagPreferenceState(tag);
|
||||
applyTagPreferenceState(button, state);
|
||||
});
|
||||
}
|
||||
|
||||
handlePopularTagActivate(tag, { event = null, button = null } = {}) {
|
||||
const normalizedTag =
|
||||
typeof tag === "string" && tag.trim() ? tag.trim() : String(tag ?? "");
|
||||
if (!normalizedTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = {
|
||||
tag: normalizedTag,
|
||||
event,
|
||||
trigger: button,
|
||||
context: "popular-tags",
|
||||
};
|
||||
|
||||
if (typeof this.handlers.tagActivate === "function") {
|
||||
try {
|
||||
this.handlers.tagActivate(detail);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[VideoListView] Tag activation handler threw an error:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit("tag:activate", detail);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { isDevMode } from "../config.js";
|
||||
import {
|
||||
normalizePointerInput,
|
||||
pointerKey as derivePointerKey,
|
||||
} from "../nostr.js";
|
||||
} from "../nostr/watchHistory.js";
|
||||
import { normalizeDesignSystemContext } from "../designSystem.js";
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
|
||||
+241
-40
@@ -2,7 +2,8 @@
|
||||
import {
|
||||
nostrClient,
|
||||
requestDefaultExtensionPermissions,
|
||||
} from "./nostr.js";
|
||||
} from "./nostrClientFacade.js";
|
||||
import { getActiveSigner } from "./nostr/index.js";
|
||||
import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js";
|
||||
import { userLogger } from "./utils/logger.js";
|
||||
import {
|
||||
@@ -245,6 +246,8 @@ class UserBlockListManager {
|
||||
this.blockEventId = null;
|
||||
this.blockEventCreatedAt = null;
|
||||
this.lastPublishedCreatedAt = null;
|
||||
this.muteEventId = null;
|
||||
this.muteEventCreatedAt = null;
|
||||
this.loaded = false;
|
||||
this.emitter = new TinyEventEmitter();
|
||||
this.seedStateCache = new Map();
|
||||
@@ -255,6 +258,8 @@ class UserBlockListManager {
|
||||
this.blockEventId = null;
|
||||
this.blockEventCreatedAt = null;
|
||||
this.lastPublishedCreatedAt = null;
|
||||
this.muteEventId = null;
|
||||
this.muteEventCreatedAt = null;
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
@@ -338,33 +343,53 @@ class UserBlockListManager {
|
||||
});
|
||||
};
|
||||
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[UserBlockList] Unable to load block list without extension permissions.",
|
||||
permissionResult.error,
|
||||
);
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
const error =
|
||||
permissionResult.error instanceof Error
|
||||
? permissionResult.error
|
||||
: new Error("Extension permissions required to load block list.");
|
||||
emitStatus({ status: "error", error });
|
||||
emitStatus({ status: "settled" });
|
||||
return;
|
||||
const activeSigner = getActiveSigner();
|
||||
const signerDecryptor =
|
||||
activeSigner && typeof activeSigner.nip04Decrypt === "function"
|
||||
? activeSigner.nip04Decrypt
|
||||
: null;
|
||||
|
||||
let extensionDecryptor =
|
||||
typeof window?.nostr?.nip04?.decrypt === "function"
|
||||
? (pubkey, payload) => window.nostr.nip04.decrypt(pubkey, payload)
|
||||
: null;
|
||||
|
||||
if (!signerDecryptor && !extensionDecryptor) {
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[UserBlockList] Unable to load block list via extension decryptor; permissions denied.",
|
||||
permissionResult.error,
|
||||
);
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
const error =
|
||||
permissionResult.error instanceof Error
|
||||
? permissionResult.error
|
||||
: new Error(
|
||||
"Extension permissions are required to use the browser decryptor.",
|
||||
);
|
||||
emitStatus({ status: "error", error, decryptor: "extension" });
|
||||
emitStatus({ status: "settled" });
|
||||
return;
|
||||
}
|
||||
|
||||
extensionDecryptor =
|
||||
typeof window?.nostr?.nip04?.decrypt === "function"
|
||||
? (pubkey, payload) => window.nostr.nip04.decrypt(pubkey, payload)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!window?.nostr?.nip04?.decrypt) {
|
||||
if (!signerDecryptor && !extensionDecryptor) {
|
||||
userLogger.warn(
|
||||
"[UserBlockList] nip04.decrypt is unavailable; treating block list as empty."
|
||||
"[UserBlockList] No nip04 decryptor available; treating block list as empty.",
|
||||
);
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
emitStatus({
|
||||
status: "error",
|
||||
error: new Error("nip04.decrypt is unavailable"),
|
||||
});
|
||||
const error = new Error(
|
||||
"No NIP-04 decryptor is available to load the block list.",
|
||||
);
|
||||
emitStatus({ status: "error", error, decryptor: "unavailable" });
|
||||
emitStatus({ status: "settled" });
|
||||
return;
|
||||
}
|
||||
@@ -549,17 +574,38 @@ class UserBlockListManager {
|
||||
}
|
||||
|
||||
let decrypted = "";
|
||||
const decryptPath = signerDecryptor
|
||||
? "active-signer"
|
||||
: extensionDecryptor
|
||||
? "extension"
|
||||
: "unavailable";
|
||||
try {
|
||||
decrypted = await window.nostr.nip04.decrypt(normalized, newest.content);
|
||||
if (signerDecryptor) {
|
||||
decrypted = await signerDecryptor(normalized, newest.content);
|
||||
} else if (extensionDecryptor) {
|
||||
decrypted = await extensionDecryptor(normalized, newest.content);
|
||||
} else {
|
||||
throw new Error("nip04-unavailable");
|
||||
}
|
||||
} catch (err) {
|
||||
userLogger.error("[UserBlockList] Failed to decrypt block list:", err);
|
||||
userLogger.error(
|
||||
`[UserBlockList] Failed to decrypt block list via ${decryptPath}:`,
|
||||
err,
|
||||
);
|
||||
applyBlockedPubkeys([], {
|
||||
source,
|
||||
reason: "decrypt-error",
|
||||
event: newest,
|
||||
error: err,
|
||||
decryptor: decryptPath,
|
||||
});
|
||||
emitStatus({
|
||||
status: "error",
|
||||
event: newest,
|
||||
error: err,
|
||||
source,
|
||||
decryptor: decryptPath,
|
||||
});
|
||||
emitStatus({ status: "error", event: newest, error: err, source });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -899,27 +945,169 @@ class UserBlockListManager {
|
||||
}
|
||||
}
|
||||
|
||||
async publishMuteListSnapshot({
|
||||
signer,
|
||||
ownerPubkey,
|
||||
blockedPubkeys = [],
|
||||
createdAt,
|
||||
plaintext = "",
|
||||
onStatus,
|
||||
} = {}) {
|
||||
const owner = normalizeHex(ownerPubkey);
|
||||
if (!owner || !signer || typeof signer.signEvent !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tagSet = new Set();
|
||||
const tags = [];
|
||||
|
||||
const iterateBlocked = (input) => {
|
||||
if (Array.isArray(input)) {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof Set) {
|
||||
return Array.from(input);
|
||||
}
|
||||
if (input && typeof input[Symbol.iterator] === "function") {
|
||||
return Array.from(input);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
for (const value of iterateBlocked(blockedPubkeys)) {
|
||||
const normalized = normalizeHex(value);
|
||||
if (!normalized || normalized === owner || tagSet.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
tagSet.add(normalized);
|
||||
tags.push(["p", normalized]);
|
||||
}
|
||||
|
||||
const timestamp = Number.isFinite(createdAt)
|
||||
? Math.max(0, Math.floor(createdAt))
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const event = {
|
||||
kind: 10000,
|
||||
pubkey: owner,
|
||||
created_at: timestamp,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
|
||||
const payloadText = typeof plaintext === "string" ? plaintext : "";
|
||||
let encryptedContent = "";
|
||||
let encryptionTagValue = "";
|
||||
|
||||
if (payloadText) {
|
||||
const encryptionCandidates = [];
|
||||
|
||||
if (typeof signer.nip44Encrypt === "function") {
|
||||
encryptionCandidates.push({ tag: "nip44_v2", encrypt: signer.nip44Encrypt });
|
||||
}
|
||||
|
||||
if (typeof signer.nip04Encrypt === "function") {
|
||||
encryptionCandidates.push({ tag: "nip04", encrypt: signer.nip04Encrypt });
|
||||
}
|
||||
|
||||
for (const candidate of encryptionCandidates) {
|
||||
try {
|
||||
const encrypted = await candidate.encrypt(owner, payloadText);
|
||||
if (typeof encrypted === "string" && encrypted) {
|
||||
encryptedContent = encrypted;
|
||||
encryptionTagValue = candidate.tag;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore encryption failures and fall back to the next candidate.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (encryptedContent) {
|
||||
event.content = encryptedContent;
|
||||
if (encryptionTagValue) {
|
||||
event.tags.push(["encrypted", encryptionTagValue]);
|
||||
}
|
||||
}
|
||||
|
||||
onStatus?.({ status: "mute-publishing" });
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
signedEvent = await signer.signEvent(event);
|
||||
} catch (error) {
|
||||
const wrapped =
|
||||
error instanceof Error ? error : new Error(String(error || "mute-signature-failed"));
|
||||
wrapped.code = wrapped.code || "mute-signature-failed";
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
nostrClient.relays,
|
||||
signedEvent,
|
||||
);
|
||||
|
||||
const summary = assertAnyRelayAccepted(publishResults, { context: "mute list" });
|
||||
|
||||
if (summary.failed.length) {
|
||||
summary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
userLogger.warn(
|
||||
`[UserBlockList] Mute list not accepted by ${url}: ${reason}`,
|
||||
relayError,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.muteEventId = signedEvent.id;
|
||||
this.muteEventCreatedAt = Number.isFinite(signedEvent?.created_at)
|
||||
? signedEvent.created_at
|
||||
: event.created_at;
|
||||
|
||||
onStatus?.({ status: "mute-published", event: signedEvent });
|
||||
|
||||
return signedEvent;
|
||||
}
|
||||
|
||||
async publishBlockList(userPubkey, options = {}) {
|
||||
const onStatus =
|
||||
options && typeof options.onStatus === "function" ? options.onStatus : null;
|
||||
|
||||
onStatus?.({ status: "publishing" });
|
||||
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[UserBlockList] Extension permissions denied while updating the block list.",
|
||||
permissionResult.error,
|
||||
);
|
||||
const signer = getActiveSigner();
|
||||
if (!signer) {
|
||||
const err = new Error(
|
||||
"The NIP-07 extension must allow encryption and signing before updating the block list.",
|
||||
"An active signer is required to update the block list."
|
||||
);
|
||||
err.code = "extension-permission-denied";
|
||||
err.cause = permissionResult.error;
|
||||
err.code = "signer-missing";
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!window?.nostr?.nip04?.encrypt) {
|
||||
if (signer.type === "extension") {
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[UserBlockList] Signer permissions denied while updating the block list.",
|
||||
permissionResult.error,
|
||||
);
|
||||
const err = new Error(
|
||||
"The active signer must allow encryption and signing before updating the block list.",
|
||||
);
|
||||
err.code = "extension-permission-denied";
|
||||
err.cause = permissionResult.error;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof signer.nip04Encrypt !== "function") {
|
||||
const err = new Error(
|
||||
"NIP-04 encryption is required to update the block list."
|
||||
);
|
||||
@@ -927,9 +1115,9 @@ class UserBlockListManager {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (typeof window.nostr.signEvent !== "function") {
|
||||
const err = new Error("Nostr extension missing signEvent support.");
|
||||
err.code = "nip04-missing";
|
||||
if (typeof signer.signEvent !== "function") {
|
||||
const err = new Error("Active signer missing signEvent support.");
|
||||
err.code = "sign-event-missing";
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -947,7 +1135,7 @@ class UserBlockListManager {
|
||||
|
||||
let cipherText = "";
|
||||
try {
|
||||
cipherText = await window.nostr.nip04.encrypt(normalized, plaintext);
|
||||
cipherText = await signer.nip04Encrypt(normalized, plaintext);
|
||||
} catch (error) {
|
||||
const err = new Error("Failed to encrypt block list.");
|
||||
err.code = "nip04-missing";
|
||||
@@ -960,7 +1148,7 @@ class UserBlockListManager {
|
||||
content: cipherText,
|
||||
});
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
const signedEvent = await signer.signEvent(event);
|
||||
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
@@ -1009,6 +1197,19 @@ class UserBlockListManager {
|
||||
this.lastPublishedCreatedAt = this.blockEventCreatedAt;
|
||||
onStatus?.({ status: "published", event: signedEvent });
|
||||
|
||||
try {
|
||||
await this.publishMuteListSnapshot({
|
||||
signer,
|
||||
ownerPubkey: normalized,
|
||||
blockedPubkeys: payload.blockedPubkeys,
|
||||
createdAt: event.created_at,
|
||||
plaintext,
|
||||
onStatus,
|
||||
});
|
||||
} catch (muteError) {
|
||||
userLogger.warn("[UserBlockList] Failed to publish mute list", muteError);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.loadBlocks(normalized, {
|
||||
since: this.lastPublishedCreatedAt ?? undefined,
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
const hasDocument =
|
||||
typeof document !== "undefined" &&
|
||||
typeof document.createElement === "function" &&
|
||||
typeof document.createElementNS === "function";
|
||||
|
||||
let QRCodeExport = null;
|
||||
let createQrCodeExport = null;
|
||||
let renderQrCodeExport = null;
|
||||
let updateQrCodeExport = null;
|
||||
|
||||
if (hasDocument) {
|
||||
/**
|
||||
* @fileoverview
|
||||
* - Using the 'QRCode for Javascript library'
|
||||
* - Fixed dataset of 'QRCode for Javascript library' for support full-spec.
|
||||
* - this library has no dependencies.
|
||||
*
|
||||
* @author davidshimjs
|
||||
* @see <a href="http://www.d-project.com/" target="_blank">http://www.d-project.com/</a>
|
||||
* @see <a href="http://jeromeetienne.github.com/jquery-qrcode/" target="_blank">http://jeromeetienne.github.com/jquery-qrcode/</a>
|
||||
*/
|
||||
var QRCode;
|
||||
|
||||
(function () {
|
||||
//---------------------------------------------------------------------
|
||||
// QRCode for JavaScript
|
||||
//
|
||||
// Copyright (c) 2009 Kazuhiko Arase
|
||||
//
|
||||
// URL: http://www.d-project.com/
|
||||
//
|
||||
// Licensed under the MIT license:
|
||||
// http://www.opensource.org/licenses/mit-license.php
|
||||
//
|
||||
// The word "QR Code" is registered trademark of
|
||||
// DENSO WAVE INCORPORATED
|
||||
// http://www.denso-wave.com/qrcode/faqpatent-e.html
|
||||
//
|
||||
//---------------------------------------------------------------------
|
||||
function QR8bitByte(data) {
|
||||
this.mode = QRMode.MODE_8BIT_BYTE;
|
||||
this.data = data;
|
||||
this.parsedData = [];
|
||||
|
||||
// Added to support UTF-8 Characters
|
||||
for (var i = 0, l = this.data.length; i < l; i++) {
|
||||
var byteArray = [];
|
||||
var code = this.data.charCodeAt(i);
|
||||
|
||||
if (code > 0x10000) {
|
||||
byteArray[0] = 0xF0 | ((code & 0x1C0000) >>> 18);
|
||||
byteArray[1] = 0x80 | ((code & 0x3F000) >>> 12);
|
||||
byteArray[2] = 0x80 | ((code & 0xFC0) >>> 6);
|
||||
byteArray[3] = 0x80 | (code & 0x3F);
|
||||
} else if (code > 0x800) {
|
||||
byteArray[0] = 0xE0 | ((code & 0xF000) >>> 12);
|
||||
byteArray[1] = 0x80 | ((code & 0xFC0) >>> 6);
|
||||
byteArray[2] = 0x80 | (code & 0x3F);
|
||||
} else if (code > 0x80) {
|
||||
byteArray[0] = 0xC0 | ((code & 0x7C0) >>> 6);
|
||||
byteArray[1] = 0x80 | (code & 0x3F);
|
||||
} else {
|
||||
byteArray[0] = code;
|
||||
}
|
||||
|
||||
this.parsedData.push(byteArray);
|
||||
}
|
||||
|
||||
this.parsedData = Array.prototype.concat.apply([], this.parsedData);
|
||||
|
||||
if (this.parsedData.length != this.data.length) {
|
||||
this.parsedData.unshift(191);
|
||||
this.parsedData.unshift(187);
|
||||
this.parsedData.unshift(239);
|
||||
}
|
||||
}
|
||||
|
||||
QR8bitByte.prototype = {
|
||||
getLength: function (buffer) {
|
||||
return this.parsedData.length;
|
||||
},
|
||||
write: function (buffer) {
|
||||
for (var i = 0, l = this.parsedData.length; i < l; i++) {
|
||||
buffer.put(this.parsedData[i], 8);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function QRCodeModel(typeNumber, errorCorrectLevel) {
|
||||
this.typeNumber = typeNumber;
|
||||
this.errorCorrectLevel = errorCorrectLevel;
|
||||
this.modules = null;
|
||||
this.moduleCount = 0;
|
||||
this.dataCache = null;
|
||||
this.dataList = [];
|
||||
}
|
||||
|
||||
QRCodeModel.prototype={addData:function(data){var newData=new QR8bitByte(data);this.dataList.push(newData);this.dataCache=null;},isDark:function(row,col){if(row<0||this.moduleCount<=row||col<0||this.moduleCount<=col){throw new Error(row+","+col);}
|
||||
return this.modules[row][col];},getModuleCount:function(){return this.moduleCount;},make:function(){this.makeImpl(false,this.getBestMaskPattern());},makeImpl:function(test,maskPattern){this.moduleCount=this.typeNumber*4+17;this.modules=new Array(this.moduleCount);for(var row=0;row<this.moduleCount;row++){this.modules[row]=new Array(this.moduleCount);for(var col=0;col<this.moduleCount;col++){this.modules[row][col]=null;}}
|
||||
this.setupPositionProbePattern(0,0);this.setupPositionProbePattern(this.moduleCount-7,0);this.setupPositionProbePattern(0,this.moduleCount-7);this.setupPositionAdjustPattern();this.setupTimingPattern();this.setupTypeInfo(test,maskPattern);if(this.typeNumber>=7){this.setupTypeNumber(test);}
|
||||
if(this.dataCache==null){this.dataCache=QRCodeModel.createData(this.typeNumber,this.errorCorrectLevel,this.dataList);}
|
||||
this.mapData(this.dataCache,maskPattern);},setupPositionProbePattern:function(row,col){for(var r=-1;r<=7;r++){if(row+r<=-1||this.moduleCount<=row+r)continue;for(var c=-1;c<=7;c++){if(col+c<=-1||this.moduleCount<=col+c)continue;if((0<=r&&r<=6&&(c==0||c==6))||(0<=c&&c<=6&&(r==0||r==6))||(2<=r&&r<=4&&2<=c&&c<=4)){this.modules[row+r][col+c]=true;}else{this.modules[row+r][col+c]=false;}}}},getBestMaskPattern:function(){var minLostPoint=0;var pattern=0;for(var i=0;i<8;i++){this.makeImpl(true,i);var lostPoint=QRUtil.getLostPoint(this);if(i==0||minLostPoint>lostPoint){minLostPoint=lostPoint;pattern=i;}}
|
||||
return pattern;},createMovieClip:function(target_mc,instance_name,depth){var qr_mc=target_mc.createEmptyMovieClip(instance_name,depth);var cs=1;this.make();for(var row=0;row<this.modules.length;row++){var y=row*cs;for(var col=0;col<this.modules[row].length;col++){var x=col*cs;var dark=this.modules[row][col];if(dark){qr_mc.beginFill(0,100);qr_mc.moveTo(x,y);qr_mc.lineTo(x+cs,y);qr_mc.lineTo(x+cs,y+cs);qr_mc.lineTo(x,y+cs);qr_mc.endFill();}}}
|
||||
return qr_mc;},setupTimingPattern:function(){for(var r=8;r<this.moduleCount-8;r++){if(this.modules[r][6]!=null){continue;}
|
||||
this.modules[r][6]=(r%2==0);}
|
||||
for(var c=8;c<this.moduleCount-8;c++){if(this.modules[6][c]!=null){continue;}
|
||||
this.modules[6][c]=(c%2==0);}},setupPositionAdjustPattern:function(){var pos=QRUtil.getPatternPosition(this.typeNumber);for(var i=0;i<pos.length;i++){for(var j=0;j<pos.length;j++){var row=pos[i];var col=pos[j];if(this.modules[row][col]!=null){continue;}
|
||||
for(var r=-2;r<=2;r++){for(var c=-2;c<=2;c++){if(r==-2||r==2||c==-2||c==2||(r==0&&c==0)){this.modules[row+r][col+c]=true;}else{this.modules[row+r][col+c]=false;}}}}}},setupTypeNumber:function(test){var bits=QRUtil.getBCHTypeNumber(this.typeNumber);for(var i=0;i<18;i++){var mod=(!test&&((bits>>i)&1)==1);this.modules[Math.floor(i/3)][i%3+this.moduleCount-8-3]=mod;}
|
||||
for(var i=0;i<18;i++){var mod=(!test&&((bits>>i)&1)==1);this.modules[i%3+this.moduleCount-8-3][Math.floor(i/3)]=mod;}},setupTypeInfo:function(test,maskPattern){var data=(this.errorCorrectLevel<<3)|maskPattern;var bits=QRUtil.getBCHTypeInfo(data);for(var i=0;i<15;i++){var mod=(!test&&((bits>>i)&1)==1);if(i<6){this.modules[i][8]=mod;}else if(i<8){this.modules[i+1][8]=mod;}else{this.modules[this.moduleCount-15+i][8]=mod;}}
|
||||
for(var i=0;i<15;i++){var mod=(!test&&((bits>>i)&1)==1);if(i<8){this.modules[8][this.moduleCount-i-1]=mod;}else if(i<9){this.modules[8][15-i-1+1]=mod;}else{this.modules[8][15-i-1]=mod;}}
|
||||
this.modules[this.moduleCount-8][8]=(!test);},mapData:function(data,maskPattern){var inc=-1;var row=this.moduleCount-1;var bitIndex=7;var byteIndex=0;for(var col=this.moduleCount-1;col>0;col-=2){if(col==6)col--;while(true){for(var c=0;c<2;c++){if(this.modules[row][col-c]==null){var dark=false;if(byteIndex<data.length){dark=(((data[byteIndex]>>>bitIndex)&1)==1);}
|
||||
var mask=QRUtil.getMask(maskPattern,row,col-c);if(mask){dark=!dark;}
|
||||
this.modules[row][col-c]=dark;bitIndex--;if(bitIndex==-1){byteIndex++;bitIndex=7;}}}
|
||||
row+=inc;if(row<0||this.moduleCount<=row){row-=inc;inc=-inc;break;}}}}};QRCodeModel.PAD0=0xEC;QRCodeModel.PAD1=0x11;QRCodeModel.createData=function(typeNumber,errorCorrectLevel,dataList){var rsBlocks=QRRSBlock.getRSBlocks(typeNumber,errorCorrectLevel);var buffer=new QRBitBuffer();for(var i=0;i<dataList.length;i++){var data=dataList[i];buffer.put(data.mode,4);buffer.put(data.getLength(),QRUtil.getLengthInBits(data.mode,typeNumber));data.write(buffer);}
|
||||
var totalDataCount=0;for(var i=0;i<rsBlocks.length;i++){totalDataCount+=rsBlocks[i].dataCount;}
|
||||
if(buffer.getLengthInBits()>totalDataCount*8){throw new Error("code length overflow. ("
|
||||
+buffer.getLengthInBits()
|
||||
+">"
|
||||
+totalDataCount*8
|
||||
+")");}
|
||||
if(buffer.getLengthInBits()+4<=totalDataCount*8){buffer.put(0,4);}
|
||||
while(buffer.getLengthInBits()%8!=0){buffer.putBit(false);}
|
||||
while(true){if(buffer.getLengthInBits()>=totalDataCount*8){break;}
|
||||
buffer.put(QRCodeModel.PAD0,8);if(buffer.getLengthInBits()>=totalDataCount*8){break;}
|
||||
buffer.put(QRCodeModel.PAD1,8);}
|
||||
return QRCodeModel.createBytes(buffer,rsBlocks);};QRCodeModel.createBytes=function(buffer,rsBlocks){var offset=0;var maxDcCount=0;var maxEcCount=0;var dcdata=new Array(rsBlocks.length);var ecdata=new Array(rsBlocks.length);for(var r=0;r<rsBlocks.length;r++){var dcCount=rsBlocks[r].dataCount;var ecCount=rsBlocks[r].totalCount-dcCount;maxDcCount=Math.max(maxDcCount,dcCount);maxEcCount=Math.max(maxEcCount,ecCount);dcdata[r]=new Array(dcCount);for(var i=0;i<dcdata[r].length;i++){dcdata[r][i]=0xff&buffer.buffer[i+offset];}
|
||||
offset+=dcCount;var rsPoly=QRUtil.getErrorCorrectPolynomial(ecCount);var rawPoly=new QRPolynomial(dcdata[r],rsPoly.getLength()-1);var modPoly=rawPoly.mod(rsPoly);ecdata[r]=new Array(rsPoly.getLength()-1);for(var i=0;i<ecdata[r].length;i++){var modIndex=i+modPoly.getLength()-ecdata[r].length;ecdata[r][i]=(modIndex>=0)?modPoly.get(modIndex):0;}}
|
||||
var totalCodeCount=0;for(var i=0;i<rsBlocks.length;i++){totalCodeCount+=rsBlocks[i].totalCount;}
|
||||
var data=new Array(totalCodeCount);var index=0;for(var i=0;i<maxDcCount;i++){for(var r=0;r<rsBlocks.length;r++){if(i<dcdata[r].length){data[index++]=dcdata[r][i];}}}
|
||||
for(var i=0;i<maxEcCount;i++){for(var r=0;r<rsBlocks.length;r++){if(i<ecdata[r].length){data[index++]=ecdata[r][i];}}}
|
||||
return data;};var QRMode={MODE_NUMBER:1<<0,MODE_ALPHA_NUM:1<<1,MODE_8BIT_BYTE:1<<2,MODE_KANJI:1<<3};var QRErrorCorrectLevel={L:1,M:0,Q:3,H:2};var QRMaskPattern={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7};var QRUtil={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:(1<<10)|(1<<8)|(1<<5)|(1<<4)|(1<<2)|(1<<1)|(1<<0),G18:(1<<12)|(1<<11)|(1<<10)|(1<<9)|(1<<8)|(1<<5)|(1<<2)|(1<<0),G15_MASK:(1<<14)|(1<<12)|(1<<10)|(1<<4)|(1<<1),getBCHTypeInfo:function(data){var d=data<<10;while(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G15)>=0){d^=(QRUtil.G15<<(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G15)));}
|
||||
return((data<<10)|d)^QRUtil.G15_MASK;},getBCHTypeNumber:function(data){var d=data<<12;while(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)>=0){d^=(QRUtil.G18<<(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)));}
|
||||
return(data<<12)|d;},getBCHDigit:function(data){var digit=0;while(data!=0){digit++;data>>>=1;}
|
||||
return digit;},getPatternPosition:function(typeNumber){return QRUtil.PATTERN_POSITION_TABLE[typeNumber-1];},getMask:function(maskPattern,i,j){switch(maskPattern){case QRMaskPattern.PATTERN000:return(i+j)%2==0;case QRMaskPattern.PATTERN001:return i%2==0;case QRMaskPattern.PATTERN010:return j%3==0;case QRMaskPattern.PATTERN011:return(i+j)%3==0;case QRMaskPattern.PATTERN100:return(Math.floor(i/2)+Math.floor(j/3))%2==0;case QRMaskPattern.PATTERN101:return(i*j)%2+(i*j)%3==0;case QRMaskPattern.PATTERN110:return((i*j)%2+(i*j)%3)%2==0;case QRMaskPattern.PATTERN111:return((i*j)%3+(i+j)%2)%2==0;default:throw new Error("bad maskPattern:"+maskPattern);}},getErrorCorrectPolynomial:function(errorCorrectLength){var a=new QRPolynomial([1],0);for(var i=0;i<errorCorrectLength;i++){a=a.multiply(new QRPolynomial([1,QRMath.gexp(i)],0));}
|
||||
return a;},getLengthInBits:function(mode,type){if(1<=type&&type<10){switch(mode){case QRMode.MODE_NUMBER:return 10;case QRMode.MODE_ALPHA_NUM:return 9;case QRMode.MODE_8BIT_BYTE:return 8;case QRMode.MODE_KANJI:return 8;default:throw new Error("mode:"+mode);}}else if(type<27){switch(mode){case QRMode.MODE_NUMBER:return 12;case QRMode.MODE_ALPHA_NUM:return 11;case QRMode.MODE_8BIT_BYTE:return 16;case QRMode.MODE_KANJI:return 10;default:throw new Error("mode:"+mode);}}else if(type<41){switch(mode){case QRMode.MODE_NUMBER:return 14;case QRMode.MODE_ALPHA_NUM:return 13;case QRMode.MODE_8BIT_BYTE:return 16;case QRMode.MODE_KANJI:return 12;default:throw new Error("mode:"+mode);}}else{throw new Error("type:"+type);}},getLostPoint:function(qrCode){var moduleCount=qrCode.getModuleCount();var lostPoint=0;for(var row=0;row<moduleCount;row++){for(var col=0;col<moduleCount;col++){var sameCount=0;var dark=qrCode.isDark(row,col);for(var r=-1;r<=1;r++){if(row+r<0||moduleCount<=row+r){continue;}
|
||||
for(var c=-1;c<=1;c++){if(col+c<0||moduleCount<=col+c){continue;}
|
||||
if(r==0&&c==0){continue;}
|
||||
if(dark==qrCode.isDark(row+r,col+c)){sameCount++;}}}
|
||||
if(sameCount>5){lostPoint+=(3+sameCount-5);}}}
|
||||
for(var row=0;row<moduleCount-1;row++){for(var col=0;col<moduleCount-1;col++){var count=0;if(qrCode.isDark(row,col))count++;if(qrCode.isDark(row+1,col))count++;if(qrCode.isDark(row,col+1))count++;if(qrCode.isDark(row+1,col+1))count++;if(count==0||count==4){lostPoint+=3;}}}
|
||||
for(var row=0;row<moduleCount;row++){for(var col=0;col<moduleCount-6;col++){if(qrCode.isDark(row,col)&&!qrCode.isDark(row,col+1)&&qrCode.isDark(row,col+2)&&qrCode.isDark(row,col+3)&&qrCode.isDark(row,col+4)&&!qrCode.isDark(row,col+5)&&qrCode.isDark(row,col+6)){lostPoint+=40;}}}
|
||||
for(var col=0;col<moduleCount;col++){for(var row=0;row<moduleCount-6;row++){if(qrCode.isDark(row,col)&&!qrCode.isDark(row+1,col)&&qrCode.isDark(row+2,col)&&qrCode.isDark(row+3,col)&&qrCode.isDark(row+4,col)&&!qrCode.isDark(row+5,col)&&qrCode.isDark(row+6,col)){lostPoint+=40;}}}
|
||||
var darkCount=0;for(var col=0;col<moduleCount;col++){for(var row=0;row<moduleCount;row++){if(qrCode.isDark(row,col)){darkCount++;}}}
|
||||
var ratio=Math.abs(100*darkCount/moduleCount/moduleCount-50)/5;lostPoint+=ratio*10;return lostPoint;}};var QRMath={glog:function(n){if(n<1){throw new Error("glog("+n+")");}
|
||||
return QRMath.LOG_TABLE[n];},gexp:function(n){while(n<0){n+=255;}
|
||||
while(n>=256){n-=255;}
|
||||
return QRMath.EXP_TABLE[n];},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)};for(var i=0;i<8;i++){QRMath.EXP_TABLE[i]=1<<i;}
|
||||
for(var i=8;i<256;i++){QRMath.EXP_TABLE[i]=QRMath.EXP_TABLE[i-4]^QRMath.EXP_TABLE[i-5]^QRMath.EXP_TABLE[i-6]^QRMath.EXP_TABLE[i-8];}
|
||||
for(var i=0;i<255;i++){QRMath.LOG_TABLE[QRMath.EXP_TABLE[i]]=i;}
|
||||
function QRPolynomial(num,shift){if(num.length==undefined){throw new Error(num.length+"/"+shift);}
|
||||
var offset=0;while(offset<num.length&&num[offset]==0){offset++;}
|
||||
this.num=new Array(num.length-offset+shift);for(var i=0;i<num.length-offset;i++){this.num[i]=num[i+offset];}}
|
||||
QRPolynomial.prototype={get:function(index){return this.num[index];},getLength:function(){return this.num.length;},multiply:function(e){var num=new Array(this.getLength()+e.getLength()-1);for(var i=0;i<this.getLength();i++){for(var j=0;j<e.getLength();j++){num[i+j]^=QRMath.gexp(QRMath.glog(this.get(i))+QRMath.glog(e.get(j)));}}
|
||||
return new QRPolynomial(num,0);},mod:function(e){if(this.getLength()-e.getLength()<0){return this;}
|
||||
var ratio=QRMath.glog(this.get(0))-QRMath.glog(e.get(0));var num=new Array(this.getLength());for(var i=0;i<this.getLength();i++){num[i]=this.get(i);}
|
||||
for(var i=0;i<e.getLength();i++){num[i]^=QRMath.gexp(QRMath.glog(e.get(i))+ratio);}
|
||||
return new QRPolynomial(num,0).mod(e);}};function QRRSBlock(totalCount,dataCount){this.totalCount=totalCount;this.dataCount=dataCount;}
|
||||
QRRSBlock.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]];QRRSBlock.getRSBlocks=function(typeNumber,errorCorrectLevel){var rsBlock=QRRSBlock.getRsBlockTable(typeNumber,errorCorrectLevel);if(rsBlock==undefined){throw new Error("bad rs block @ typeNumber:"+typeNumber+"/errorCorrectLevel:"+errorCorrectLevel);}
|
||||
var length=rsBlock.length/3;var list=[];for(var i=0;i<length;i++){var count=rsBlock[i*3+0];var totalCount=rsBlock[i*3+1];var dataCount=rsBlock[i*3+2];for(var j=0;j<count;j++){list.push(new QRRSBlock(totalCount,dataCount));}}
|
||||
return list;};QRRSBlock.getRsBlockTable=function(typeNumber,errorCorrectLevel){switch(errorCorrectLevel){case QRErrorCorrectLevel.L:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+0];case QRErrorCorrectLevel.M:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+1];case QRErrorCorrectLevel.Q:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+2];case QRErrorCorrectLevel.H:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+3];default:return undefined;}};function QRBitBuffer(){this.buffer=[];this.length=0;}
|
||||
QRBitBuffer.prototype={get:function(index){var bufIndex=Math.floor(index/8);return((this.buffer[bufIndex]>>>(7-index%8))&1)==1;},put:function(num,length){for(var i=0;i<length;i++){this.putBit(((num>>>(length-i-1))&1)==1);}},getLengthInBits:function(){return this.length;},putBit:function(bit){var bufIndex=Math.floor(this.length/8);if(this.buffer.length<=bufIndex){this.buffer.push(0);}
|
||||
if(bit){this.buffer[bufIndex]|=(0x80>>>(this.length%8));}
|
||||
this.length++;}};var QRCodeLimitLength=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]];
|
||||
|
||||
function _isSupportCanvas() {
|
||||
return typeof CanvasRenderingContext2D != "undefined";
|
||||
}
|
||||
|
||||
// android 2.x doesn't support Data-URI spec
|
||||
function _getAndroid() {
|
||||
if (typeof navigator === "undefined" || !navigator.userAgent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var android = false;
|
||||
var sAgent = navigator.userAgent;
|
||||
|
||||
if (/android/i.test(sAgent)) { // android
|
||||
android = true;
|
||||
var aMat = sAgent.toString().match(/android ([0-9]\.[0-9])/i);
|
||||
|
||||
if (aMat && aMat[1]) {
|
||||
android = parseFloat(aMat[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return android;
|
||||
}
|
||||
|
||||
var svgDrawer = (function() {
|
||||
|
||||
var Drawing = function (el, htOption) {
|
||||
this._el = el;
|
||||
this._htOption = htOption;
|
||||
};
|
||||
|
||||
Drawing.prototype.draw = function (oQRCode) {
|
||||
var _htOption = this._htOption;
|
||||
var _el = this._el;
|
||||
var nCount = oQRCode.getModuleCount();
|
||||
var nWidth = Math.floor(_htOption.width / nCount);
|
||||
var nHeight = Math.floor(_htOption.height / nCount);
|
||||
|
||||
this.clear();
|
||||
|
||||
function makeSVG(tag, attrs) {
|
||||
var el = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
||||
for (var k in attrs)
|
||||
if (attrs.hasOwnProperty(k)) el.setAttribute(k, attrs[k]);
|
||||
return el;
|
||||
}
|
||||
|
||||
var svg = makeSVG("svg" , {'viewBox': '0 0 ' + String(nCount) + " " + String(nCount), 'width': '100%', 'height': '100%', 'fill': _htOption.colorLight});
|
||||
svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink");
|
||||
_el.appendChild(svg);
|
||||
|
||||
svg.appendChild(makeSVG("rect", {"fill": _htOption.colorLight, "width": "100%", "height": "100%"}));
|
||||
svg.appendChild(makeSVG("rect", {"fill": _htOption.colorDark, "width": "1", "height": "1", "id": "template"}));
|
||||
|
||||
for (var row = 0; row < nCount; row++) {
|
||||
for (var col = 0; col < nCount; col++) {
|
||||
if (oQRCode.isDark(row, col)) {
|
||||
var child = makeSVG("use", {"x": String(col), "y": String(row)});
|
||||
child.setAttributeNS("http://www.w3.org/1999/xlink", "href", "#template")
|
||||
svg.appendChild(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Drawing.prototype.clear = function () {
|
||||
while (this._el.hasChildNodes())
|
||||
this._el.removeChild(this._el.lastChild);
|
||||
};
|
||||
return Drawing;
|
||||
})();
|
||||
|
||||
var useSVG = document.documentElement.tagName.toLowerCase() === "svg";
|
||||
|
||||
// Drawing in DOM by using Table tag
|
||||
var Drawing = useSVG ? svgDrawer : !_isSupportCanvas() ? (function () {
|
||||
var Drawing = function (el, htOption) {
|
||||
this._el = el;
|
||||
this._htOption = htOption;
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw the QRCode
|
||||
*
|
||||
* @param {QRCode} oQRCode
|
||||
*/
|
||||
Drawing.prototype.draw = function (oQRCode) {
|
||||
var _htOption = this._htOption;
|
||||
var _el = this._el;
|
||||
var nCount = oQRCode.getModuleCount();
|
||||
var nWidth = Math.floor(_htOption.width / nCount);
|
||||
var nHeight = Math.floor(_htOption.height / nCount);
|
||||
var aHTML = ['<table style="border:0;border-collapse:collapse;">'];
|
||||
|
||||
for (var row = 0; row < nCount; row++) {
|
||||
aHTML.push('<tr>');
|
||||
|
||||
for (var col = 0; col < nCount; col++) {
|
||||
aHTML.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:' + nWidth + 'px;height:' + nHeight + 'px;background-color:' + (oQRCode.isDark(row, col) ? _htOption.colorDark : _htOption.colorLight) + ';"></td>');
|
||||
}
|
||||
|
||||
aHTML.push('</tr>');
|
||||
}
|
||||
|
||||
aHTML.push('</table>');
|
||||
_el.innerHTML = aHTML.join('');
|
||||
|
||||
// Fix the margin values as real size.
|
||||
var elTable = _el.childNodes[0];
|
||||
var nLeftMarginTable = (_htOption.width - elTable.offsetWidth) / 2;
|
||||
var nTopMarginTable = (_htOption.height - elTable.offsetHeight) / 2;
|
||||
|
||||
if (nLeftMarginTable > 0 && nTopMarginTable > 0) {
|
||||
elTable.style.margin = nTopMarginTable + "px " + nLeftMarginTable + "px";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the QRCode
|
||||
*/
|
||||
Drawing.prototype.clear = function () {
|
||||
this._el.innerHTML = '';
|
||||
};
|
||||
|
||||
return Drawing;
|
||||
})() : (function () { // Drawing in Canvas
|
||||
function _onMakeImage() {
|
||||
this._elImage.src = this._elCanvas.toDataURL("image/png");
|
||||
this._elImage.style.display = "block";
|
||||
this._elCanvas.style.display = "none";
|
||||
}
|
||||
|
||||
// Android 2.1 bug workaround
|
||||
// http://code.google.com/p/android/issues/detail?id=5141
|
||||
var legacyAndroidVersion = (function () {
|
||||
try {
|
||||
return _getAndroid();
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (
|
||||
legacyAndroidVersion &&
|
||||
legacyAndroidVersion <= 2.1 &&
|
||||
typeof CanvasRenderingContext2D !== "undefined"
|
||||
) {
|
||||
var pixelRatio =
|
||||
typeof window !== "undefined" && window.devicePixelRatio
|
||||
? window.devicePixelRatio
|
||||
: 1;
|
||||
var factor = pixelRatio ? 1 / pixelRatio : 1;
|
||||
var drawImage = CanvasRenderingContext2D.prototype.drawImage;
|
||||
CanvasRenderingContext2D.prototype.drawImage = function (image, sx, sy, sw, sh, dx, dy, dw, dh) {
|
||||
if (("nodeName" in image) && /img/i.test(image.nodeName)) {
|
||||
for (var i = arguments.length - 1; i >= 1; i--) {
|
||||
arguments[i] = arguments[i] * factor;
|
||||
}
|
||||
} else if (typeof dw == "undefined") {
|
||||
arguments[1] *= factor;
|
||||
arguments[2] *= factor;
|
||||
arguments[3] *= factor;
|
||||
arguments[4] *= factor;
|
||||
}
|
||||
|
||||
drawImage.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user's browser supports Data URI or not
|
||||
*
|
||||
* @private
|
||||
* @param {Function} fSuccess Occurs if it supports Data URI
|
||||
* @param {Function} fFail Occurs if it doesn't support Data URI
|
||||
*/
|
||||
function _safeSetDataURI(fSuccess, fFail) {
|
||||
var self = this;
|
||||
self._fFail = fFail;
|
||||
self._fSuccess = fSuccess;
|
||||
|
||||
// Check it just once
|
||||
if (self._bSupportDataURI === null) {
|
||||
var el = document.createElement("img");
|
||||
var fOnError = function() {
|
||||
self._bSupportDataURI = false;
|
||||
|
||||
if (self._fFail) {
|
||||
self._fFail.call(self);
|
||||
}
|
||||
};
|
||||
var fOnSuccess = function() {
|
||||
self._bSupportDataURI = true;
|
||||
|
||||
if (self._fSuccess) {
|
||||
self._fSuccess.call(self);
|
||||
}
|
||||
};
|
||||
|
||||
el.onabort = fOnError;
|
||||
el.onerror = fOnError;
|
||||
el.onload = fOnSuccess;
|
||||
el.src = "data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; // the Image contains 1px data.
|
||||
return;
|
||||
} else if (self._bSupportDataURI === true && self._fSuccess) {
|
||||
self._fSuccess.call(self);
|
||||
} else if (self._bSupportDataURI === false && self._fFail) {
|
||||
self._fFail.call(self);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Drawing QRCode by using canvas
|
||||
*
|
||||
* @constructor
|
||||
* @param {HTMLElement} el
|
||||
* @param {Object} htOption QRCode Options
|
||||
*/
|
||||
var Drawing = function (el, htOption) {
|
||||
this._bIsPainted = false;
|
||||
this._android = _getAndroid();
|
||||
|
||||
this._htOption = htOption;
|
||||
this._elCanvas = document.createElement("canvas");
|
||||
this._elCanvas.width = htOption.width;
|
||||
this._elCanvas.height = htOption.height;
|
||||
el.appendChild(this._elCanvas);
|
||||
this._el = el;
|
||||
this._oContext = this._elCanvas.getContext("2d");
|
||||
this._bIsPainted = false;
|
||||
this._elImage = document.createElement("img");
|
||||
this._elImage.alt = "Scan me!";
|
||||
this._elImage.style.display = "none";
|
||||
this._el.appendChild(this._elImage);
|
||||
this._bSupportDataURI = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw the QRCode
|
||||
*
|
||||
* @param {QRCode} oQRCode
|
||||
*/
|
||||
Drawing.prototype.draw = function (oQRCode) {
|
||||
var _elImage = this._elImage;
|
||||
var _oContext = this._oContext;
|
||||
var _htOption = this._htOption;
|
||||
|
||||
var nCount = oQRCode.getModuleCount();
|
||||
var nWidth = _htOption.width / nCount;
|
||||
var nHeight = _htOption.height / nCount;
|
||||
var nRoundedWidth = Math.round(nWidth);
|
||||
var nRoundedHeight = Math.round(nHeight);
|
||||
|
||||
_elImage.style.display = "none";
|
||||
this.clear();
|
||||
|
||||
for (var row = 0; row < nCount; row++) {
|
||||
for (var col = 0; col < nCount; col++) {
|
||||
var bIsDark = oQRCode.isDark(row, col);
|
||||
var nLeft = col * nWidth;
|
||||
var nTop = row * nHeight;
|
||||
_oContext.strokeStyle = bIsDark ? _htOption.colorDark : _htOption.colorLight;
|
||||
_oContext.lineWidth = 1;
|
||||
_oContext.fillStyle = bIsDark ? _htOption.colorDark : _htOption.colorLight;
|
||||
_oContext.fillRect(nLeft, nTop, nWidth, nHeight);
|
||||
|
||||
// 안티 앨리어싱 방지 처리
|
||||
_oContext.strokeRect(
|
||||
Math.floor(nLeft) + 0.5,
|
||||
Math.floor(nTop) + 0.5,
|
||||
nRoundedWidth,
|
||||
nRoundedHeight
|
||||
);
|
||||
|
||||
_oContext.strokeRect(
|
||||
Math.ceil(nLeft) - 0.5,
|
||||
Math.ceil(nTop) - 0.5,
|
||||
nRoundedWidth,
|
||||
nRoundedHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this._bIsPainted = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Make the image from Canvas if the browser supports Data URI.
|
||||
*/
|
||||
Drawing.prototype.makeImage = function () {
|
||||
if (this._bIsPainted) {
|
||||
_safeSetDataURI.call(this, _onMakeImage);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return whether the QRCode is painted or not
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
Drawing.prototype.isPainted = function () {
|
||||
return this._bIsPainted;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the QRCode
|
||||
*/
|
||||
Drawing.prototype.clear = function () {
|
||||
this._oContext.clearRect(0, 0, this._elCanvas.width, this._elCanvas.height);
|
||||
this._bIsPainted = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Number} nNumber
|
||||
*/
|
||||
Drawing.prototype.round = function (nNumber) {
|
||||
if (!nNumber) {
|
||||
return nNumber;
|
||||
}
|
||||
|
||||
return Math.floor(nNumber * 1000) / 1000;
|
||||
};
|
||||
|
||||
return Drawing;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Get the type by string length
|
||||
*
|
||||
* @private
|
||||
* @param {String} sText
|
||||
* @param {Number} nCorrectLevel
|
||||
* @return {Number} type
|
||||
*/
|
||||
function _getTypeNumber(sText, nCorrectLevel) {
|
||||
var nType = 1;
|
||||
var length = _getUTF8Length(sText);
|
||||
|
||||
for (var i = 0, len = QRCodeLimitLength.length; i <= len; i++) {
|
||||
var nLimit = 0;
|
||||
|
||||
switch (nCorrectLevel) {
|
||||
case QRErrorCorrectLevel.L :
|
||||
nLimit = QRCodeLimitLength[i][0];
|
||||
break;
|
||||
case QRErrorCorrectLevel.M :
|
||||
nLimit = QRCodeLimitLength[i][1];
|
||||
break;
|
||||
case QRErrorCorrectLevel.Q :
|
||||
nLimit = QRCodeLimitLength[i][2];
|
||||
break;
|
||||
case QRErrorCorrectLevel.H :
|
||||
nLimit = QRCodeLimitLength[i][3];
|
||||
break;
|
||||
}
|
||||
|
||||
if (length <= nLimit) {
|
||||
break;
|
||||
} else {
|
||||
nType++;
|
||||
}
|
||||
}
|
||||
|
||||
if (nType > QRCodeLimitLength.length) {
|
||||
throw new Error("Too long data");
|
||||
}
|
||||
|
||||
return nType;
|
||||
}
|
||||
|
||||
function _getUTF8Length(sText) {
|
||||
var replacedText = encodeURI(sText).toString().replace(/\%[0-9a-fA-F]{2}/g, 'a');
|
||||
return replacedText.length + (replacedText.length != sText ? 3 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @class QRCode
|
||||
* @constructor
|
||||
* @example
|
||||
* new QRCode(document.getElementById("test"), "http://jindo.dev.naver.com/collie");
|
||||
*
|
||||
* @example
|
||||
* var oQRCode = new QRCode("test", {
|
||||
* text : "http://naver.com",
|
||||
* width : 128,
|
||||
* height : 128
|
||||
* });
|
||||
*
|
||||
* oQRCode.clear(); // Clear the QRCode.
|
||||
* oQRCode.makeCode("http://map.naver.com"); // Re-create the QRCode.
|
||||
*
|
||||
* @param {HTMLElement|String} el target element or 'id' attribute of element.
|
||||
* @param {Object|String} vOption
|
||||
* @param {String} vOption.text QRCode link data
|
||||
* @param {Number} [vOption.width=256]
|
||||
* @param {Number} [vOption.height=256]
|
||||
* @param {String} [vOption.colorDark="#000000"]
|
||||
* @param {String} [vOption.colorLight="#ffffff"]
|
||||
* @param {QRCode.CorrectLevel} [vOption.correctLevel=QRCode.CorrectLevel.H] [L|M|Q|H]
|
||||
*/
|
||||
QRCode = function (el, vOption) {
|
||||
this._htOption = {
|
||||
width : 256,
|
||||
height : 256,
|
||||
typeNumber : 4,
|
||||
colorDark : "#000000",
|
||||
colorLight : "#ffffff",
|
||||
correctLevel : QRErrorCorrectLevel.H
|
||||
};
|
||||
|
||||
if (typeof vOption === 'string') {
|
||||
vOption = {
|
||||
text : vOption
|
||||
};
|
||||
}
|
||||
|
||||
// Overwrites options
|
||||
if (vOption) {
|
||||
for (var i in vOption) {
|
||||
this._htOption[i] = vOption[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof el == "string") {
|
||||
el = document.getElementById(el);
|
||||
}
|
||||
|
||||
if (this._htOption.useSVG) {
|
||||
Drawing = svgDrawer;
|
||||
}
|
||||
|
||||
this._android = _getAndroid();
|
||||
this._el = el;
|
||||
this._oQRCode = null;
|
||||
this._oDrawing = new Drawing(this._el, this._htOption);
|
||||
|
||||
if (this._htOption.text) {
|
||||
this.makeCode(this._htOption.text);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Make the QRCode
|
||||
*
|
||||
* @param {String} sText link data
|
||||
*/
|
||||
QRCode.prototype.makeCode = function (sText) {
|
||||
this._oQRCode = new QRCodeModel(_getTypeNumber(sText, this._htOption.correctLevel), this._htOption.correctLevel);
|
||||
this._oQRCode.addData(sText);
|
||||
this._oQRCode.make();
|
||||
this._el.title = sText;
|
||||
this._oDrawing.draw(this._oQRCode);
|
||||
this.makeImage();
|
||||
};
|
||||
|
||||
/**
|
||||
* Make the Image from Canvas element
|
||||
* - It occurs automatically
|
||||
* - Android below 3 doesn't support Data-URI spec.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
QRCode.prototype.makeImage = function () {
|
||||
if (typeof this._oDrawing.makeImage == "function" && (!this._android || this._android >= 3)) {
|
||||
this._oDrawing.makeImage();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the QRCode
|
||||
*/
|
||||
QRCode.prototype.clear = function () {
|
||||
this._oDrawing.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* @name QRCode.CorrectLevel
|
||||
*/
|
||||
QRCode.CorrectLevel = QRErrorCorrectLevel;
|
||||
})();
|
||||
|
||||
QRCodeExport = QRCode;
|
||||
|
||||
createQrCodeExport = function createQrCode(element, options = {}) {
|
||||
return new QRCode(element, options);
|
||||
};
|
||||
|
||||
renderQrCodeExport = function renderQrCode(element, text, options = {}) {
|
||||
const normalizedOptions = Object.assign({}, options, { text });
|
||||
const instance = new QRCode(element, normalizedOptions);
|
||||
return instance;
|
||||
};
|
||||
|
||||
updateQrCodeExport = function updateQrCode(instance, text) {
|
||||
if (!instance || typeof instance.makeCode !== "function") {
|
||||
throw new Error("Invalid QRCode instance");
|
||||
}
|
||||
instance.makeCode(text);
|
||||
return instance;
|
||||
};
|
||||
} else {
|
||||
class QRCodeStub {
|
||||
constructor() {
|
||||
throw new Error("QR code rendering requires a DOM environment.");
|
||||
}
|
||||
|
||||
makeCode() {
|
||||
throw new Error("QR code rendering requires a DOM environment.");
|
||||
}
|
||||
|
||||
clear() {}
|
||||
}
|
||||
|
||||
QRCodeExport = QRCodeStub;
|
||||
|
||||
createQrCodeExport = function createQrCodeStub() {
|
||||
throw new Error("QR code rendering requires a DOM environment.");
|
||||
};
|
||||
|
||||
renderQrCodeExport = function renderQrCodeStub() {
|
||||
throw new Error("QR code rendering requires a DOM environment.");
|
||||
};
|
||||
|
||||
updateQrCodeExport = function updateQrCodeStub() {
|
||||
throw new Error("QR code rendering requires a DOM environment.");
|
||||
};
|
||||
}
|
||||
|
||||
export { QRCodeExport as QRCode };
|
||||
export {
|
||||
createQrCodeExport as createQrCode,
|
||||
renderQrCodeExport as renderQrCode,
|
||||
updateQrCodeExport as updateQrCode,
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
// js/utils/videoPointer.js
|
||||
|
||||
import { pointerArrayToKey } from "./pointer.js";
|
||||
import { devLogger } from "./logger.js";
|
||||
|
||||
const DEFAULT_VIDEO_KIND = 30078;
|
||||
|
||||
@@ -49,3 +50,60 @@ export function resolveVideoPointer({
|
||||
}
|
||||
|
||||
export default resolveVideoPointer;
|
||||
|
||||
export function buildVideoAddressPointer(
|
||||
video,
|
||||
{ defaultKind = DEFAULT_VIDEO_KIND, logger = devLogger } = {}
|
||||
) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const tags = Array.isArray(video.tags) ? video.tags : [];
|
||||
const dTag = tags.find(
|
||||
(tag) =>
|
||||
Array.isArray(tag) &&
|
||||
tag.length >= 2 &&
|
||||
tag[0] === "d" &&
|
||||
typeof tag[1] === "string" &&
|
||||
tag[1].trim()
|
||||
);
|
||||
|
||||
if (!dTag) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const pubkey = typeof video.pubkey === "string" ? video.pubkey.trim() : "";
|
||||
if (!pubkey) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const identifier = dTag[1].trim();
|
||||
if (!identifier) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let kind = defaultKind;
|
||||
if (Number.isFinite(video.kind) && video.kind > 0) {
|
||||
kind = Math.floor(video.kind);
|
||||
} else if (typeof video.kind === "string" && video.kind.trim()) {
|
||||
const parsed = Number(video.kind);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
kind = Math.floor(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(kind) || kind <= 0) {
|
||||
if (logger?.warn) {
|
||||
logger.warn(
|
||||
"[videoPointer] Invalid kind detected while building video pointer; defaulting to 30078.",
|
||||
{ kind: video.kind }
|
||||
);
|
||||
}
|
||||
kind = DEFAULT_VIDEO_KIND;
|
||||
}
|
||||
|
||||
return `${kind}:${pubkey}:${identifier}`;
|
||||
}
|
||||
|
||||
export { DEFAULT_VIDEO_KIND };
|
||||
@@ -0,0 +1,136 @@
|
||||
// js/utils/videoTags.js
|
||||
|
||||
/**
|
||||
* @module utils/videoTags
|
||||
* @description Helpers for collecting and formatting normalized video tags from
|
||||
* various metadata sources.
|
||||
*/
|
||||
|
||||
const HASH_PREFIX_PATTERN = /^#+/;
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object";
|
||||
}
|
||||
|
||||
function asIterableStrings(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return [value];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeTagValue(rawValue) {
|
||||
if (typeof rawValue !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const withoutHashes = trimmed.replace(HASH_PREFIX_PATTERN, "").trim();
|
||||
|
||||
return withoutHashes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix a tag with `#` for display purposes, ensuring a single leading hash.
|
||||
*
|
||||
* @param {string} tag - The normalized tag to format.
|
||||
* @returns {string} The tag prefixed with `#` when a tag exists, otherwise an
|
||||
* empty string.
|
||||
*/
|
||||
export function formatTagDisplay(tag) {
|
||||
if (typeof tag !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = tag.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `#${trimmed.replace(HASH_PREFIX_PATTERN, "").trim()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect normalized hashtags from a video object, deduping across NIP-71
|
||||
* metadata and traditional Nostr tag arrays. Results are sorted alphabetically
|
||||
* in a case-insensitive manner while preserving the first-seen casing.
|
||||
*
|
||||
* @param {object} video - Video metadata that may contain NIP-71 structured
|
||||
* fields or Nostr tag arrays.
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.includeHashes=false] - When true, prefix `#` on the
|
||||
* returned tag strings.
|
||||
* @returns {string[]} Sorted list of unique tag strings.
|
||||
*/
|
||||
export function collectVideoTags(video, { includeHashes = false } = {}) {
|
||||
if (!isObject(video)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const deduped = new Set();
|
||||
const collected = [];
|
||||
|
||||
const pushTag = (rawValue) => {
|
||||
const normalized = normalizeTagValue(rawValue);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lower = normalized.toLowerCase();
|
||||
if (deduped.has(lower)) {
|
||||
return;
|
||||
}
|
||||
|
||||
deduped.add(lower);
|
||||
collected.push(normalized);
|
||||
};
|
||||
|
||||
if (isObject(video.nip71)) {
|
||||
const { hashtags, t } = video.nip71;
|
||||
for (const candidate of asIterableStrings(hashtags)) {
|
||||
pushTag(candidate);
|
||||
}
|
||||
for (const candidate of asIterableStrings(t)) {
|
||||
pushTag(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(video.tags)) {
|
||||
for (const tagTuple of video.tags) {
|
||||
if (!Array.isArray(tagTuple) || tagTuple.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [marker, value] = tagTuple;
|
||||
if (typeof marker !== "string" || marker.toLowerCase() !== "t") {
|
||||
continue;
|
||||
}
|
||||
|
||||
pushTag(value);
|
||||
}
|
||||
}
|
||||
|
||||
collected.sort((a, b) => {
|
||||
const lowerA = a.toLowerCase();
|
||||
const lowerB = b.toLowerCase();
|
||||
if (lowerA === lowerB) {
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
return lowerA.localeCompare(lowerB);
|
||||
});
|
||||
|
||||
if (!includeHashes) {
|
||||
return collected;
|
||||
}
|
||||
|
||||
return collected.map((tag) => formatTagDisplay(tag));
|
||||
}
|
||||
|
||||
export default collectVideoTags;
|
||||
@@ -1,3 +1,5 @@
|
||||
import { collectVideoTags } from "./videoTags.js";
|
||||
|
||||
export function getVideoRootIdentifier(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return "";
|
||||
@@ -79,6 +81,8 @@ export function syncActiveVideoRootTimestamp({
|
||||
}
|
||||
|
||||
activeVideo.rootCreatedAt = normalized;
|
||||
const modalTags = collectVideoTags(activeVideo);
|
||||
activeVideo.displayTags = modalTags;
|
||||
|
||||
const editedAt = Number.isFinite(activeVideo.lastEditedAt)
|
||||
? Math.floor(activeVideo.lastEditedAt)
|
||||
@@ -95,7 +99,7 @@ export function syncActiveVideoRootTimestamp({
|
||||
postedAt: normalized,
|
||||
editedAt,
|
||||
});
|
||||
videoModal.updateMetadata({ timestamps: payload });
|
||||
videoModal.updateMetadata({ timestamps: payload, tags: modalTags });
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
+19
-9
@@ -17,23 +17,30 @@ import {
|
||||
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
|
||||
} from "./config.js";
|
||||
import { userLogger } from "./utils/logger.js";
|
||||
import { nostrClient } from "./nostrClientFacade.js";
|
||||
import {
|
||||
countVideoViewEvents,
|
||||
listVideoViewEvents,
|
||||
subscribeVideoViewEvents,
|
||||
} from "./nostr.js";
|
||||
countVideoViewEventsWithDefaultClient as countVideoViewEvents,
|
||||
listVideoViewEventsWithDefaultClient as listVideoViewEvents,
|
||||
subscribeVideoViewEventsWithDefaultClient as subscribeVideoViewEvents,
|
||||
} from "./nostrViewEventsFacade.js";
|
||||
|
||||
const VIEW_COUNTER_STORAGE_KEY = "bitvid:view-counter:v1";
|
||||
const STORAGE_DEBOUNCE_MS = 500;
|
||||
const SECONDS_PER_DAY = 86_400;
|
||||
|
||||
const listVideoViewEventsApi = (pointer, options) =>
|
||||
listVideoViewEvents(pointer, options);
|
||||
const subscribeVideoViewEventsApi = (pointer, options) =>
|
||||
subscribeVideoViewEvents(pointer, options);
|
||||
const countVideoViewEventsApi = (pointer, options) =>
|
||||
countVideoViewEvents(pointer, options);
|
||||
|
||||
/** @type {Map<string, ViewCounterState>} */
|
||||
const pointerStates = new Map();
|
||||
/** @type {Map<string, PointerListeners>} */
|
||||
const pointerListeners = new Map();
|
||||
let persistTimer = null;
|
||||
let nextTokenId = 1;
|
||||
let nostrClientRef = null;
|
||||
|
||||
restoreCacheSnapshot();
|
||||
|
||||
@@ -456,11 +463,11 @@ async function hydratePointer(key, listeners) {
|
||||
const options = listeners.options || {};
|
||||
setPointerStatus(key, "hydrating");
|
||||
const sinceSeconds = Math.floor(Date.now() / 1000) - VIEW_COUNT_BACKFILL_MAX_DAYS * SECONDS_PER_DAY;
|
||||
const listPromise = listVideoViewEvents(pointer, {
|
||||
const listPromise = listVideoViewEventsApi(pointer, {
|
||||
since: sinceSeconds,
|
||||
relays: options.relays,
|
||||
});
|
||||
const countPromise = countVideoViewEvents(pointer, {
|
||||
const countPromise = countVideoViewEventsApi(pointer, {
|
||||
relays: options.relays,
|
||||
signal: options.signal,
|
||||
});
|
||||
@@ -535,7 +542,7 @@ function ensureLiveSubscription(key, listeners) {
|
||||
const options = listeners.options || {};
|
||||
try {
|
||||
const since = Math.floor(Date.now() / 1000) - Math.max(1, VIEW_COUNT_DEDUPE_WINDOW_SECONDS);
|
||||
const unsubscribe = subscribeVideoViewEvents(pointer, {
|
||||
const unsubscribe = subscribeVideoViewEventsApi(pointer, {
|
||||
relays: options.relays,
|
||||
since,
|
||||
onEvent: (event) => {
|
||||
@@ -564,7 +571,10 @@ function ensureLiveSubscription(key, listeners) {
|
||||
}
|
||||
|
||||
export function initViewCounter({ nostrClient } = {}) {
|
||||
nostrClientRef = nostrClient || null;
|
||||
if (nostrClient && typeof nostrClient === "object") {
|
||||
// The singleton Nostr client is already registered inside js/nostr.js.
|
||||
// This hook remains for backwards compatibility.
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeToVideoViewCount(pointerInput, handler, options = {}) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getApplication } from "./applicationContext.js";
|
||||
import nostrService from "./services/nostrService.js";
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import { nostrClient } from "./nostrClientFacade.js";
|
||||
import { logWatchHistoryDebug } from "./watchHistoryDebug.js";
|
||||
|
||||
function logInfo(message, details) {
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import {
|
||||
nostrClient,
|
||||
requestDefaultExtensionPermissions,
|
||||
} from "./nostrClientFacade.js";
|
||||
import {
|
||||
normalizePointerInput,
|
||||
pointerKey,
|
||||
normalizeActorKey,
|
||||
requestDefaultExtensionPermissions,
|
||||
} from "./nostr.js";
|
||||
} from "./nostr/watchHistory.js";
|
||||
import {
|
||||
WATCH_HISTORY_CACHE_TTL_MS,
|
||||
WATCH_HISTORY_MAX_ITEMS,
|
||||
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user