Merge pull request #1366 from PR0M3TH3AN/unstable

Unstable
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-18 23:17:09 -05:00
commit 4f6086e067
150 files changed
+13291 -7083

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
{
"name": "bitvid",
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
"postCreateCommand": "npm ci",
"customizations": {
"vscode": {
"extensions": [
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss"
]
}
}
}
+1
View File
@@ -5,3 +5,4 @@ src/webtorrent-docs/
playwright-report/
test-results/
tests/visual/__snapshots__/
artifacts/
+38
View File
@@ -31,6 +31,44 @@ If we later introduce **dual-licensing** options (e.g., commercial licenses for
* Follow existing formatting/lint rules.
* Update docs and user-facing text when behavior changes.
## Agent PR Conventions
Automated agents contributing to this repository should follow these rules:
- **Atomic Commits**: Keep changes small and self-contained.
- **Descriptive Titles**: Use clear, semantic titles (e.g., `fix(ui): resolve upload modal overflow`).
- **Reference Issues**: Link to relevant issues in the PR description.
- **Review AGENTS.md**: Always consult `AGENTS.md` for specific architectural guidelines and constraints before starting work.
## Submitting a Pull Request
1. Fork the repository and create a new branch from `main`.
2. Make your changes, adding tests if applicable.
3. Run `npm run test:unit` and `npm run lint` to ensure quality.
4. Push your branch and open a Pull Request against the `main` branch.
5. Provide a clear description of the problem and solution.
## Development Setup
To set up the project locally:
1. **Install Dependencies**:
```bash
npm ci
```
2. **Run Tests**:
```bash
npm run test:unit
```
3. **Format & Lint**:
```bash
npm run format
npm run lint
```
For a full guide, see the [Local Setup section in README.md](./README.md#local-setup).
## Security
If you find a vulnerability, please email **[[email protected]](mailto:[email protected])**. We’ll coordinate a responsible disclosure window before public release.
+67 -7
View File
@@ -2,8 +2,6 @@
# bitvid - Decentralized Video Sharing
##### IPNS: [k51qzi5uqu5dgwr4oejq9rk41aoe9zcupenby6iqecsk5byc7rx48uecd133a1](https://k51qzi5uqu5dgwr4oejq9rk41aoe9zcupenby6iqecsk5byc7rx48uecd133a1.ipns.dweb.link/)
**bitvid** is a decentralized platform where users can share videos and follow creators with privacy and freedom. Built with a static site architecture, it’s lightweight, efficient, and fully decentralized, making it ideal for hosting or local deployment.
---
@@ -103,7 +101,13 @@ To run **bitvid** locally:
cd bitvid
```
2. Start a local server:
2. Install dependencies:
```bash
npm ci
```
3. Start a local server:
- Using Python:
```bash
python -m http.server 8000
@@ -113,12 +117,42 @@ To run **bitvid** locally:
npx serve
```
3. Open the site in your browser:
4. Open the site in your browser:
```
http://localhost:8000
```
### Common Commands
- **Run unit tests**: `npm run test:unit`
- **Format code**: `npm run format`
- **Lint code**: `npm run lint`
### Send your first video post
Use the event builders in `js/nostrEventSchemas.js` to construct valid video notes.
```javascript
import { buildVideoPostEvent } from "./js/nostrEventSchemas.js";
const event = buildVideoPostEvent({
pubkey: "your_pubkey_hex",
created_at: Math.floor(Date.now() / 1000),
dTagValue: "my-first-video",
content: {
version: 3,
title: "My First Video",
videoRootId: "my-first-video", // Logical ID, often matches d-tag
url: "https://example.com/video.mp4",
description: "This is a test video post sent via the SDK."
// magnet: "magnet:?xt=urn:btih:..." // Optional fallback
}
});
console.log("Event to publish:", event);
```
### CSS build pipeline
Tailwind utilities are generated from `css/tailwind.source.css` and themed via
@@ -212,6 +246,27 @@ The legacy `js/nostr.js` shim has been removed. Update any remaining
`import { ... } from './nostr.js';` calls to use the facades above so upgrades
stay painless.
### Signer Adapters
bitvid exposes a stable signer API through `js/nostrClientFacade.js`, which
wraps the shared client created in `js/nostr/defaultClient.js` and registers
signer adapters in `js/nostr/client.js`. Import the facade whenever you need to
sign events or encrypt payloads so the adapter registry can select the active
signer, fall back to supported capabilities, and surface permission prompts.
Use the facade for the common, stable entry points:
```js
import {
nostrClient,
requestDefaultExtensionPermissions
} from "./nostrClientFacade.js";
```
Custom auth providers should still register their signer object through the
adapter registry in `js/nostr/client.js`, but downstream modules should only
call the facade to keep API surfaces consistent.
### Nostr video fetch limits
`nostrClient.fetchVideos(options)` now accepts an optional `options.limit` to
@@ -315,11 +370,16 @@ placeholder at “—” and development builds log a warning—so mixed deploym
```bash
git checkout -b feature/your-feature-name
```
3. **Make Changes**:
3. **Install Dependencies**:
```bash
npm ci
```
4. **Make Changes**:
- Ensure your code follows best practices and is well-documented.
4. **Test**:
5. **Test**:
- Run unit tests: `npm run test:unit`
- Validate the site functionality locally before submitting.
5. **Submit a Pull Request**:
6. **Submit a Pull Request**:
- Explain your changes and reference any related issues.
### Contribution Guidelines
+38
View File
@@ -0,0 +1,38 @@
# Onboarding Audit Report - 2026-01-18
## Summary
Performed a fresh checkout validation of the `bitvid` repository to audit the developer onboarding experience.
## Execution Log
1. **Dependency Installation**: `npm ci` was used instead of `npm install` (implied by README) to ensure a clean state.
- Result: Success.
2. **Build**: `npm run build:css`.
- Result: Success.
3. **Format**: `npm run format`.
- Result: Success.
4. **Testing**: `npm run test:unit`.
- Result: Timed out in the audit environment (400s limit), but individual tests pass. The test runner executes tests sequentially which can be slow on constrained resources.
## Findings & Fixes
### Documentation Gaps
- **README.md**:
- The "Local Setup" section implied `npm install` but `npm ci` is preferred for consistent CI/CD and fresh setups.
- The "For Contributors" section completely missed the dependency installation step.
- **Fix**: Updated `README.md` to explicitly list `npm ci` in both sections and added a "Test" step with `npm run test:unit`.
- **CONTRIBUTING.md**:
- Lacked technical setup instructions, relying on README.
- **Fix**: Added a "Development Setup" section summarizing `npm ci`, testing, and linting commands, linking back to README.
### Environment Brittleness
- The reliance on system-wide Node.js versions was unidentified.
- **Fix**: Added a `.devcontainer/devcontainer.json` configuration to standardize the development environment using a Docker-based Node.js 20 container.
### Linting Failures
- `npm run lint` fails on a fresh checkout due to pre-existing inline style violations in `js/ui/components/EventDetailsModal.js` and `js/ui/components/SimilarContentCard.js`.
- **Recommendation**: These legacy style violations should be addressed in a separate refactoring PR.
## Recommendations
- Contributors should use the provided Devcontainer or ensure Node.js 20+ is installed.
- `npm ci` should be the standard instruction for new environments.
+49
View File
@@ -0,0 +1,49 @@
# Weekly Agent Report: 2026-01-18
**Date Range:** 2026-01-11 to 2026-01-18
## 1. Weekly Summary
### PRs Opened (Agent)
The following branches were created and work submitted by the AI agent in the past week:
| PR/Branch | Title | Status |
| :--- | :--- | :--- |
| `ai/smoke-tests-20260117-...` | `chore(ai): add smoke test script (agent)` | Merged (`a8647c1`) |
| `ai/load-test-20260117-...` | `Add load testing scripts and report` | Merged (`898f305`) |
### Tests Added & Coverage Improvements
- **Smoke Testing:** Added `scripts/agent/smoke-test.mjs` to verify critical user flows (login, publish, DM encryption) in a headless environment using `JSDOM` and `ws`.
- **Load Testing:** Added `scripts/agent/load-test.mjs` to measure relay performance (latency, throughput) and generate reports.
### Security & Dependency Suggestions
- **Dependency:** `fake-indexeddb` has been added to `devDependencies`.
- *Suggestion:* Ensure this mock is explicitly utilized in unit tests for the new `UnifiedDB` implementation to prevent regression of the storage layer during CI.
### High-Risk Items (Requires Review)
The following items merged in the past week involve significant architectural changes or security protocols and require human attention:
- **`requires-protocol-review`**: **Watch History v2** (Commit `cbd8af4`)
- Enforces `["v", "2"]` tag and mandatory NIP-44 encryption for watch history events.
- *Risk:* Potential backward compatibility issues with legacy v1 history if not properly migrated or handled.
- **`requires-review`**: **UnifiedDB & SyncEngine** (Commit `fa84089`)
- Replaces `localStorage` with `IndexedDB` for robust data persistence.
- *Risk:* Data loss during migration for existing users if the `localStorage` -> `IndexedDB` handover fails.
- **`requires-security-review`**: **NIP-46 Encryption Exposure** (Commit `8c042d7`)
- Exposes `nip04_encrypt`/`decrypt` and `nip44_encrypt`/`decrypt` via RPC for remote signers.
- *Risk:* Unauthorized access to encryption methods if the RPC interface is not strictly scoped or validated.
- **`requires-security-review`**: **Session Actor Isolation** (Commit `b49a294`)
- Restricts ephemeral session actors from publishing public events.
- *Risk:* Ensure legitimate use cases for ephemeral keys (if any) are not unintentionally blocked.
---
## 2. Top 5 Recommended Human Actions
| Priority | Action | Description |
| :--- | :--- | :--- |
| **P0** | **Verify Data Migration** | Manually test the **UnifiedDB** migration path. Load a profile with existing `localStorage` data and confirm it persists to `IndexedDB` without data loss. |
| **P0** | **Security Audit (NIP-46)** | Review `js/nostr/nip46Client.js` changes (Commit `8c042d7`). Ensure encryption RPC calls are secure and only accessible to authorized origins. |
| **P1** | **Protocol Check (Watch History)** | Verify that new watch history events carry the `["v", "2"]` tag and are correctly encrypted. Confirm the client gracefully handles legacy v1 events. |
| **P1** | **Run Smoke Tests** | Execute `node scripts/agent/smoke-test.mjs` against a test relay to verify that core flows (login, publish, DM) remain functional after the storage refactor. |
| **P2** | **Review Load Test Report** | Analyze the report generated by the new load test suite to understand relay performance baselines and identify potential bottlenecks. |
File renamed without changes.
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2c2.5 2.7 4 6.2 4 10s-1.5 7.3-4 10" />
<path d="M12 2c-2.5 2.7-4 6.2-4 10s1.5 7.3 4 10" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

-5
View File
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2">
<path d="M21,7.702L12.5,12.322L12.5,22C14.067,21.135 18.879,18.483 20.477,17.601C20.8,17.424 21,17.082 21,16.71L21,7.702ZM11.5,12.321L3,7.599L3,16.605C3,16.975 3.197,17.313 3.514,17.492C5.104,18.39 9.93,21.115 11.5,22L11.5,12.321ZM3.421,6.692L12,11.455L20.672,6.742C20.672,6.742 14.041,3.004 12.486,2.128C12.335,2.043 12.167,2 12,2C11.832,2 11.665,2.043 11.514,2.128C9.959,3.004 3.421,6.692 3.421,6.692Z" fill="#5C6F8A" fill-rule="nonzero"/>
</svg>

Before

Width:  |  Height:  |  Size: 895 B

+36 -64
View File
@@ -338,8 +338,7 @@
<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.
Scan the QR code with your signer (like Amber or NsecBunker) to login.
</p>
</div>
<div
@@ -347,50 +346,49 @@
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 data-nip46-handshake-panel class="space-y-6">
<div class="flex flex-col items-center gap-6">
<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"
class="flex aspect-square w-full max-w-xs items-center justify-center rounded-2xl border border-border-strong bg-white p-4 shadow-sm"
data-nip46-qr
aria-hidden="true"
>
<div class="w-full text-center text-sm text-text-muted">
Generating connect code…
<div class="w-full text-center text-sm text-text-muted animate-pulse">
Generating 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"
<div class="w-full space-y-4 text-center">
<!-- Hidden input for copy functionality -->
<input
type="text"
class="sr-only"
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>
aria-hidden="true"
>
<button
type="button"
class="btn w-full sm:max-w-xs"
data-nip46-copy-uri
disabled
>
Copy Connect Link
</button>
<details class="text-sm text-text-muted">
<summary class="cursor-pointer hover:text-text hover:underline">Trouble scanning?</summary>
<div class="mt-2 p-2 bg-overlay-panel border border-border rounded-lg break-all text-xs font-mono text-left select-all" data-nip46-debug-uri>
<!-- Raw URI injected here for debugging/manual copy -->
</div>
</details>
</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"
class="hidden rounded-lg border border-info/40 bg-info/10 px-4 py-3 text-sm text-info font-medium text-center"
role="status"
></div>
<div
@@ -403,45 +401,19 @@
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>
<div class="flex justify-between items-center pt-2">
<button
type="button"
class="btn-link text-sm hidden"
data-nip46-reuse
>
Reconnect using the saved session
Reconnect saved
</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>
<!-- Hidden submit to satisfy form semantics if needed, though we trigger via logic -->
<button type="submit" class="hidden" data-nip46-submit></button>
</form>
</template>
</div>
+249 -7
View File
@@ -230,13 +230,22 @@
<span id="profileModalName" class="truncate text-sm font-semibold text-text">Loading...</span>
<span id="profileModalNpub" class="break-all font-mono text-xs text-muted">npub1...</span>
</div>
<a
id="profileChannelLink"
href="#"
class="btn-ghost focus-ring profile-modal__channel-link hidden text-xs"
>
View channel
</a>
<div class="flex flex-wrap items-center gap-2 profile-modal__actions">
<a
id="profileChannelLink"
href="#"
class="btn-ghost focus-ring hidden text-xs"
>
View channel
</a>
<button
id="profileEditBtn"
type="button"
class="btn-ghost focus-ring text-xs"
>
Edit profile
</button>
</div>
</div>
<div
@@ -286,6 +295,33 @@
Choose how many trusted reports or mutes it takes to blur thumbnails, block autoplay previews, or hide videos entirely.
Lower numbers are stricter.
</p>
<div class="rounded-lg border border-border bg-bg-muted p-3 text-xs text-muted">
<p>
Trusted counts include your first-degree follows (F1) plus seeded moderators.
</p>
<dl class="mt-2 grid gap-1">
<div class="flex items-center justify-between gap-3">
<dt>Trusted contacts</dt>
<dd id="profileModerationTrustedContactsCount" class="font-semibold text-text">0</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt>Active trusted mute contributors</dt>
<dd id="profileModerationTrustedMuteCount" class="font-semibold text-text">0</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt>Active trusted report contributors</dt>
<dd id="profileModerationTrustedReportCount" class="font-semibold text-text">0</dd>
</div>
</dl>
<p
id="profileModerationSeedOnlyIndicator"
class="mt-2 flex items-center gap-1 text-xs text-warning-strong"
hidden
>
<span aria-hidden="true">●</span>
<span>Seeded moderators only</span>
</p>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<label
@@ -387,6 +423,185 @@
role="status"
aria-live="polite"
></p>
<div class="space-y-3 border-t border-border/60 pt-4">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-text">Active overrides</h4>
<p class="text-xs text-muted">
Videos you unblocked with “Show anyway” stay listed here until reset.
</p>
</div>
<div
id="profileModerationOverridesEmpty"
class="card border border-dashed border-border/60 p-4 text-center text-xs text-muted"
>
No moderation overrides yet.
</div>
<ul
id="profileModerationOverridesList"
class="grid-stack hidden"
data-orientation="vertical"
></ul>
</div>
</div>
</section>
<section
id="profilePaneEdit"
class="profile-pane hidden space-y-6"
role="tabpanel"
aria-label="Edit profile"
>
<div class="flex items-center gap-2 mb-4">
<button
id="profileEditBackBtn"
type="button"
class="btn-ghost focus-ring p-1 -ml-1 text-muted hover:text-text"
aria-label="Back to account"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</button>
<h3 class="text-lg font-bold text-text">Edit Profile</h3>
</div>
<div class="space-y-5">
<!-- Basic Info -->
<div class="grid gap-4 sm:grid-cols-2">
<label class="block text-sm text-muted-strong" for="editNameInput">
<span class="mb-2 block font-medium">Username</span>
<input
id="editNameInput"
type="text"
class="input"
placeholder="Name"
/>
</label>
<label class="block text-sm text-muted-strong" for="editDisplayNameInput">
<span class="mb-2 block font-medium">Display Name</span>
<input
id="editDisplayNameInput"
type="text"
class="input"
placeholder="Display Name (optional)"
/>
</label>
</div>
<label class="block text-sm text-muted-strong" for="editAboutInput">
<span class="mb-2 block font-medium">About</span>
<textarea
id="editAboutInput"
class="input min-h-[80px]"
placeholder="Tell us about yourself..."
></textarea>
</label>
<label class="block text-sm text-muted-strong" for="editWebsiteInput">
<span class="mb-2 block font-medium">Website</span>
<input
id="editWebsiteInput"
type="url"
class="input"
placeholder="https://example.com"
/>
</label>
<!-- Verifications -->
<div class="grid gap-4 sm:grid-cols-2">
<label class="block text-sm text-muted-strong" for="editNip05Input">
<span class="mb-2 block font-medium">NIP-05 Verification</span>
<input
id="editNip05Input"
type="text"
class="input"
placeholder="[email protected]"
/>
</label>
<label class="block text-sm text-muted-strong" for="editLud16Input">
<span class="mb-2 block font-medium">Lightning Address (LUD16)</span>
<input
id="editLud16Input"
type="text"
class="input"
placeholder="[email protected]"
/>
</label>
</div>
<!-- Images -->
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label class="block text-sm text-muted-strong" for="editPictureInput">
<span class="mb-2 block font-medium">Profile Picture URL</span>
</label>
<div class="flex gap-2">
<input
id="editPictureInput"
type="url"
class="input flex-1"
placeholder="https://..."
/>
<input type="file" id="editPictureFile" hidden accept="image/*" />
<button
id="editPictureUploadBtn"
type="button"
class="btn-ghost focus-ring"
title="Upload profile picture"
>
Upload
</button>
</div>
<div id="editPictureStorageHint" class="text-xs text-muted hidden">
<a href="#" id="editPictureConfigureLink" class="underline hover:text-text">Configure storage</a> to enable uploads.
</div>
</div>
<div class="flex flex-col gap-2">
<label class="block text-sm text-muted-strong" for="editBannerInput">
<span class="mb-2 block font-medium">Banner Image URL</span>
</label>
<div class="flex gap-2">
<input
id="editBannerInput"
type="url"
class="input flex-1"
placeholder="https://..."
/>
<input type="file" id="editBannerFile" hidden accept="image/*" />
<button
id="editBannerUploadBtn"
type="button"
class="btn-ghost focus-ring"
title="Upload banner image"
>
Upload
</button>
</div>
<div id="editBannerStorageHint" class="text-xs text-muted hidden">
<a href="#" id="editBannerConfigureLink" class="underline hover:text-text">Configure storage</a> to enable uploads.
</div>
</div>
</div>
<!-- Actions -->
<div class="flex flex-wrap items-center gap-3 pt-4 border-t border-border/60">
<button
id="editSaveBtn"
type="button"
class="btn focus-ring"
>
Save Profile
</button>
<button
id="editCancelBtn"
type="button"
class="btn-ghost focus-ring"
>
Cancel
</button>
</div>
<p id="editStatusText" class="text-sm text-muted" role="status" aria-live="polite"></p>
</div>
</section>
@@ -852,6 +1067,33 @@
aria-live="polite"
aria-label="Direct message threads"
></ul>
<div class="flex flex-col gap-3 border-t border-border/60 pt-4">
<label class="sr-only" for="profileMessageInput">Message</label>
<textarea
id="profileMessageInput"
class="input min-h-[96px]"
rows="3"
placeholder="Write a message…"
></textarea>
<div class="flex flex-wrap items-center justify-between gap-2">
<p
id="profileMessagesComposerHelper"
class="text-xs text-muted hidden"
role="status"
aria-live="polite"
>
Sign in to send messages.
</p>
<button
id="profileMessageSendBtn"
type="button"
class="btn focus-ring inline-flex items-center gap-2"
data-size="sm"
>
Send
</button>
</div>
</div>
</section>
<section
id="profilePaneHistory"
+26 -21
View File
@@ -40,14 +40,28 @@
</button>
</div>
<nav class="sidebar-nav" aria-label="Primary">
<a href="#view=most-recent-videos" class="sidebar-nav-link" title="Recent">
<a
id="forYouLink"
href="#view=for-you"
class="sidebar-nav-link hidden"
title="For You"
>
<img
src="assets/svg/recent-icon.svg"
src="assets/svg/for-you-feed-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Recent</span>
<span class="sidebar-text">For You</span>
</a>
<a href="#view=most-recent-videos" class="sidebar-nav-link" title="Recently Added">
<img
src="assets/svg/globe-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Recently Added</span>
</a>
<a href="#view=explore" class="sidebar-nav-link" title="Explore">
<img
@@ -58,15 +72,6 @@
/>
<span class="sidebar-text">Explore</span>
</a>
<a href="#view=history" class="sidebar-nav-link" title="History">
<img
src="assets/svg/history-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">History</span>
</a>
<a
id="subscriptionsLink"
href="#view=subscriptions"
@@ -81,6 +86,15 @@
/>
<span class="sidebar-text">Subscriptions</span>
</a>
<a href="#view=history" class="sidebar-nav-link" title="History">
<img
src="assets/svg/history-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">History</span>
</a>
</nav>
</section>
@@ -288,15 +302,6 @@
/>
<span class="sidebar-text">Links</span>
</a>
<a href="#view=ipns" class="sidebar-nav-link sidebar-nav-link--sub" title="IPNS">
<img
src="assets/svg/ipns-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">IPNS</span>
</a>
<a
href="#"
data-dns-link
+99 -74
View File
@@ -115,93 +115,118 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Configure R2 Storage</span>
<span>Storage Configuration</span>
</button>
<!-- Storage Settings Form -->
<!-- Storage Settings Container -->
<div id="section-storage-settings" class="hidden mt-3 space-y-4 bg-surface-alt/50 p-4 rounded-lg border border-border/50">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-text">Cloudflare R2 Credentials</h4>
<div class="flex items-center gap-2">
<span id="storage-lock-status" class="text-xs text-muted">Locked 🔒</span>
<button type="button" id="btn-storage-unlock" class="text-xs text-accent hover:underline hidden">Unlock</button>
<span id="storage-status" class="text-xs text-muted hidden">Stored locally</span>
<!-- Read-Only Summary (for configured storage) -->
<div id="storage-summary-view" class="hidden space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-text">Active Storage</h4>
<div class="flex items-center gap-2">
<span id="storage-lock-status" class="text-xs text-muted">Locked 🔒</span>
<button type="button" id="btn-storage-unlock" class="text-xs text-accent hover:underline hidden">Unlock</button>
<button type="button" id="btn-manage-storage" class="text-xs text-muted hover:text-text hover:underline">Manage Storage</button>
</div>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div>
<span class="block text-muted">Provider</span>
<span id="summary-provider" class="font-medium text-text">--</span>
</div>
<div>
<span class="block text-muted">Bucket</span>
<span id="summary-bucket" class="font-medium text-text">--</span>
</div>
</div>
</div>
<div class="mb-2">
<p class="text-xs text-muted">
Required for direct file uploads.
<a href="#view=upload-content" id="link-r2-help" class="text-accent hover:underline font-medium ml-1">
Read the setup guide.
</a>
Your videos will be uploaded to this bucket.
</p>
</div>
<!-- Target Bucket Display -->
<div class="space-y-1">
<label for="input-r2-bucket-name" class="text-xs font-medium text-text">Target Bucket Name (Required)</label>
<div class="flex gap-2">
<input
id="input-r2-bucket-name"
type="text"
class="input text-xs bg-surface-alt text-muted cursor-text select-all"
readonly
placeholder="Log in to see your bucket name"
/>
<button
type="button"
id="btn-copy-bucket"
class="btn-ghost px-2 text-xs border border-border/30"
aria-label="Copy bucket name"
>
Copy
</button>
<!-- Editable Form (Legacy/Fallback) -->
<div id="storage-form-view" class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-text">Storage Credentials</h4>
<span id="storage-status" class="text-xs text-muted hidden">Stored locally</span>
</div>
<p class="text-4xs text-muted">Create a bucket with this exact name in Cloudflare R2.</p>
</div>
<!-- Step 1: Credentials -->
<div id="step-credentials" class="space-y-3">
<div class="space-y-1">
<label for="input-r2-account" class="text-xs font-medium text-text">Account ID</label>
<input id="input-r2-account" type="text" class="input text-xs" placeholder="xxxxxxxx..." />
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-1">
<label for="input-r2-key" class="text-xs font-medium text-text">Access Key ID</label>
<input id="input-r2-key" type="text" class="input text-xs" placeholder="AKIA..." />
</div>
<div class="space-y-1">
<label for="input-r2-secret" class="text-xs font-medium text-text">Secret Access Key</label>
<input id="input-r2-secret" type="password" class="input text-xs" />
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-next-step" class="btn btn-sm">Next</button>
</div>
</div>
<!-- Step 2: Verification -->
<div id="step-verification" class="hidden space-y-3 pt-2 border-t border-border/30">
<div class="space-y-1">
<label for="input-r2-domain" class="text-xs font-medium text-text">Public Bucket URL</label>
<input id="input-r2-domain" type="text" class="input text-xs" placeholder="https://pub-xxx.r2.dev" />
<p class="text-4xs text-muted leading-tight">
Enable <strong>Public Access</strong> in the Cloudflare dashboard to get your r2.dev URL.
<div class="mb-2">
<p class="text-xs text-muted">
Required for direct file uploads.
<a href="#view=upload-content" id="link-r2-help" class="text-accent hover:underline font-medium ml-1">
Read the setup guide.
</a>
</p>
</div>
<!-- Error Container for Verification Failures -->
<div id="container-verification-error" class="hidden bg-critical/10 border border-critical/30 rounded p-3 text-xs text-critical space-y-2">
<p id="text-verification-error" class="font-medium">Verification Failed</p>
<a id="link-cloudflare-guide" href="#" target="_blank" class="block text-center bg-critical/10 hover:bg-critical/20 py-1.5 rounded font-medium transition-colors">
Open Cloudflare Dashboard &rarr;
</a>
<!-- Target Bucket Display -->
<div class="space-y-1">
<label for="input-r2-bucket-name" class="text-xs font-medium text-text">Target Bucket Name (Required)</label>
<div class="flex gap-2">
<input
id="input-r2-bucket-name"
type="text"
class="input text-xs bg-surface-alt text-muted cursor-text select-all"
readonly
placeholder="Log in to see your bucket name"
/>
<button
type="button"
id="btn-copy-bucket"
class="btn-ghost px-2 text-xs border border-border/30"
aria-label="Copy bucket name"
>
Copy
</button>
</div>
<p class="text-4xs text-muted">Create a bucket with this exact name in your storage provider.</p>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-back-step" class="btn-ghost px-2 text-xs">Back</button>
<button type="button" id="btn-save-settings" class="btn btn-sm">Verify & Save</button>
<!-- Step 1: Credentials -->
<div id="step-credentials" class="space-y-3">
<div class="space-y-1">
<label for="input-r2-account" class="text-xs font-medium text-text">Account ID / Endpoint</label>
<input id="input-r2-account" type="text" class="input text-xs" placeholder="xxxxxxxx... or https://..." />
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-1">
<label for="input-r2-key" class="text-xs font-medium text-text">Access Key ID</label>
<input id="input-r2-key" type="text" class="input text-xs" placeholder="AKIA..." />
</div>
<div class="space-y-1">
<label for="input-r2-secret" class="text-xs font-medium text-text">Secret Access Key</label>
<input id="input-r2-secret" type="password" class="input text-xs" />
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-next-step" class="btn btn-sm">Next</button>
</div>
</div>
<!-- Step 2: Verification -->
<div id="step-verification" class="hidden space-y-3 pt-2 border-t border-border/30">
<div class="space-y-1">
<label for="input-r2-domain" class="text-xs font-medium text-text">Public URL</label>
<input id="input-r2-domain" type="text" class="input text-xs" placeholder="https://pub-xxx.r2.dev" />
<p class="text-4xs text-muted leading-tight">
The public URL where your files will be accessible.
</p>
</div>
<!-- Error Container for Verification Failures -->
<div id="container-verification-error" class="hidden bg-critical/10 border border-critical/30 rounded p-3 text-xs text-critical space-y-2">
<p id="text-verification-error" class="font-medium">Verification Failed</p>
<a id="link-cloudflare-guide" href="#" target="_blank" class="block text-center bg-critical/10 hover:bg-critical/20 py-1.5 rounded font-medium transition-colors">
Check Provider Settings &rarr;
</a>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-back-step" class="btn-ghost px-2 text-xs">Back</button>
<button type="button" id="btn-save-settings" class="btn btn-sm">Verify & Save</button>
</div>
</div>
</div>
</div>
@@ -404,4 +429,4 @@
</form>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@ Ready to jump in? Here's everything you need to know to start watching and shari
## Watching Videos
1. Just visit [bitvid.network](https://bitvid.network) or one of our alternate sites like [bitvid.btc.us](https://bitvid.btc.us) and [bitvid.eth.limo](https://bitvid.eth.limo). We also have other instances via [IPNS](ipns.html) gateways you can try.
1. Just visit [bitvid.network](https://bitvid.network) or one of our alternate sites like [bitvid.btc.us](https://bitvid.btc.us) and [bitvid.eth.limo](https://bitvid.eth.limo).
2. Browse the videos on the homepage
3. Click any video to start watching
That's it! No account needed to watch.
-19
View File
@@ -1,19 +0,0 @@
# IPNS Gateways
Below is a list of available IPNS gateways you can use with the hash `k51qzi5uqu5dgwr4oejq9rk41aoe9zcupenby6iqecsk5byc7rx48uecd133a1`:
1. **[FLK IPFS Gateway](https://k51qzi5uqu5dgwr4oejq9rk41aoe9zcupenby6iqecsk5byc7rx48uecd133a1.ipns.flk-ipfs.xyz/)**
A public gateway that resolves the provided IPNS hash.
2. **[Aragon IPFS Gateway](https://ipfs.eth.aragon.network/ipfs/bafybeih2ebj55ki3wvasj5i3rhwgjn6e72f6vxsrlrjfqvzezot2eoeqz4/)**
A gateway hosted by Aragon for IPFS content resolution.
3. **[Dweb.link Gateway](https://dweb.link/ipns/k51qzi5uqu5dgwr4oejq9rk41aoe9zcupenby6iqecsk5byc7rx48uecd133a1)**
A subdomain resolution gateway provided by Protocol Labs.
4. **[IPFS.io Gateway](https://ipfs.io/ipns/k51qzi5uqu5dgwr4oejq9rk41aoe9zcupenby6iqecsk5byc7rx48uecd133a1)**
A public gateway operated by Protocol Labs.
---
**Note:** The availability and performance of these gateways may vary.
-8
View File
@@ -129,11 +129,3 @@ Beta - [beta.bitvid.network](https://beta.bitvid.network)
Main - [bitvid.network#view=links](https://bitvid.network#view=links)
Beta - [beta.bitvid.network#view=links](https://beta.bitvid.network#view=links)
---
### IPNS
Main - [bitvid.network#view=ipns](https://bitvid.network#view=ipns)
Beta - [beta.bitvid.network#view=ipns](https://beta.bitvid.network#view=ipns)
+8 -2
View File
@@ -1,8 +1,14 @@
: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;--z-modal-peak-root:165;--z-modal-peak-overlay:175;--z-modal-peak-content:185;--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;--breakpoint-lg:64rem;--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-blocLine 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}:root[data-theme-accent=override]{--color-accent:var(--color-accent-override);--color-accent-strong:var(--color-accent-strong-override);--color-accent-pressed:var(--color-accent-pressed-override)}#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:vaLine truncated
/*! tailwindcss v3.4.19 | 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}:root[data-theme-accent=override]{--color-accent:var(--color-accent-override);--color-accent-strong:var(--color-accent-strong-override);--color-accent-pressed:var(--color-accent-pressed-override)}#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:vaLine truncated
--motion-video-card-backdrop-translate
);--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong)}.\!card:focus-within,.\!card:hover{--video-card-lift-translate-y:var(--motion-video-card-hover-translate)!important;--video-card-backdrop-opacity:0.425!important;--video-card-backdrop-translate-y:var(
--motion-video-card-backdrop-translate
)!important;--video-card-backdrop-scale:1.12!important;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong)!important;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.card:focus-within,.card:hover{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card:focus-within,.\!card:hover{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card[data-state=critical],.card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.\!card[data-state=private],.card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card[data-state=disabled],.card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.video-card__title{transition:color var(--motion-duration-base) var(--motion-ease-in-out)}.video-card__title:focus-visible,.video-card__title:hover{color:var(--color-accent)}.form-control{outline:2px solid transparent;outline-offset:2px}.form-control:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.form-control{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--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);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.form-control::-moz-placeholder{color:var(--text-muted)}.form-control::placeholder{color:var(--text-muted)}.form-control:hover{border-color:var(--border-default)}.form-control:focus-visible{border-color:var(--color-accent-strong)}.form-control:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.form-control:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.\!input[data-state=locked],.form-control[data-state=locked],.input[data-state=locked],.select[data-state=locked],.textarea[data-state=locked],select.form-control[data-state=locked],textarea.form-control[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.form-control[data-state=locked]:focus-visible,.input[data-state=locked]:focus-visible,.select[data-state=locked]:focus-visible,.textarea[data-state=locked]:focus-visible,select.form-control[data-state=locked]:focus-visible,textarea.form-control[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--Line 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__primary>.watch-container:last-child,#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);min-width:0;overflow-wrap:break-word;word-break:break-word}@media (min-width:1024px){.video-modal__headline #videoTitle.video-modal__title{flex:1 1 0%;min-width:0;max-width:calc(100% - var(--space-3xl) - var(--space-md))}.video-modal__headline .video-modal__status{flex:0 0 auto;max-width:clamp(var(--space-xl),25%,var(--space-3xl));margin-left:auto}}#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(var(--player-modal-module-scroll-min-height),calc(100vh - var(--player-modal-vertical-padding)*2 - var(--space-2xl)),var(--player-modal-module-scroll-max-height));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)) sLine 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(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))!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{outline:2px solid transparent;outline-offset:2px}.player-modal__similar-card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.player-modal__similar-card{display:block}.player-modal__similar-card{position:relative;displayLine truncated
)}}.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(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))!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{outline:2px solid transparent;outline-offset:2px}.player-modal__similar-card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.player-modal__similar-card{display:block}.player-modal__similar-card{position:relative;displayLine truncated
--motion-video-card-backdrop-translate
);--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.watch-history-card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.watch-history-card[data-state=disabled],.watch-history-card[data-state=private]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.watch-history-card[data-alert=nsfw-owner]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--color-black)}.watch-history-card[data-alert=nsfw-owner]:hover{--tw-border-opacity:0.6;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card img[data-thumbnail-state=blurred],.watch-history-card img[data-visual-state=blurred]{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}.watch-history-card[data-owner-is-viewer=true][data-url-health-state=offline][data-stream-health-state=unhealthy]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.watch-history-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>.watch-history-card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max));flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.watch-history-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>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.watch-history-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)}.watch-history-card[data-motion=enter]{opacity:0;animation:card-motion-enter .22s ease-out forwards}#playerModal.watch-history-card{display:block}.watch-history-card{display:flex;flex-wrap:wrap;align-items:stretch;gap:1.25rem;padding:1rem;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)}@media (prefers-reduced-motion:reduce){.watch-history-card,.watch-history-card[data-motion=enter]{transition-property:none}.watch-history-card[data-motion=enter]{opacity:1;animation:none}}.watch-history-card:hover{--tw-translate-y:-0.125rem;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));--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.watch-history-card{gap:1.5rem;padding:1.25rem}}.watch-history-card--private{border-color:var(--color-info);--tw-border-opacity:0.6}#playerModal.watch-histoLine truncated
+72 -43
View File
@@ -846,13 +846,21 @@
.moderation-badge__icon {
@apply inline-flex h-5 w-5 items-center justify-center rounded-full bg-status-warning-surface text-status-warning-on;
background-color: color-mix(in srgb, var(--status-warning) 18%, transparent);
background-color: color-mix(
in srgb,
var(--status-warning) 18%,
transparent
);
color: var(--status-warning-on);
}
.moderation-badge[data-variant="neutral"] .moderation-badge__icon {
@apply bg-status-neutral-surface text-status-neutral-on;
background-color: color-mix(in srgb, var(--status-neutral) 18%, transparent);
background-color: color-mix(
in srgb,
var(--status-neutral) 18%,
transparent
);
color: var(--status-neutral-on);
}
@@ -861,40 +869,65 @@
}
.moderation-badge__text {
@apply text-xs font-semibold tracking-tight whitespace-nowrap;
@apply whitespace-nowrap text-xs font-semibold tracking-tight;
}
.moderation-badge__action {
@apply inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold normal-case tracking-tight transition duration-150 ease-out bg-status-warning-surface border-status-warning-border text-status-warning-on;
background-color: color-mix(in srgb, var(--status-warning) 16%, transparent);
border-color: color-mix(in srgb, var(--status-warning-border) 80%, transparent);
@apply inline-flex items-center gap-1.5 rounded-full border border-status-warning-border bg-status-warning-surface px-3 py-1 text-xs font-semibold normal-case tracking-tight text-status-warning-on transition duration-150 ease-out;
background-color: color-mix(
in srgb,
var(--status-warning) 16%,
transparent
);
border-color: color-mix(
in srgb,
var(--status-warning-border) 80%,
transparent
);
color: var(--status-warning-on);
}
.moderation-badge[data-variant="warning"] .moderation-badge__action:hover {
background-color: color-mix(in srgb, var(--status-warning) 24%, transparent);
background-color: color-mix(
in srgb,
var(--status-warning) 24%,
transparent
);
}
.moderation-badge[data-variant="neutral"] .moderation-badge__action {
@apply bg-status-neutral-surface border-status-neutral-border text-status-neutral-on;
background-color: color-mix(in srgb, var(--status-neutral) 16%, transparent);
border-color: color-mix(in srgb, var(--status-neutral-border) 80%, transparent);
@apply border-status-neutral-border bg-status-neutral-surface text-status-neutral-on;
background-color: color-mix(
in srgb,
var(--status-neutral) 16%,
transparent
);
border-color: color-mix(
in srgb,
var(--status-neutral-border) 80%,
transparent
);
color: var(--status-neutral-on);
}
.moderation-badge[data-variant="neutral"] .moderation-badge__action:hover {
background-color: color-mix(in srgb, var(--status-neutral) 24%, transparent);
background-color: color-mix(
in srgb,
var(--status-neutral) 24%,
transparent
);
}
.moderation-badge__action:focus-visible {
outline: var(--outline-thick-width)
solid color-mix(in srgb, var(--status-warning) 38%, transparent);
outline: var(--outline-thick-width) solid
color-mix(in srgb, var(--status-warning) 38%, transparent);
outline-offset: var(--space-3xs);
}
.moderation-badge[data-variant="neutral"] .moderation-badge__action:focus-visible {
outline: var(--outline-thick-width)
solid color-mix(in srgb, var(--status-neutral) 38%, transparent);
.moderation-badge[data-variant="neutral"]
.moderation-badge__action:focus-visible {
outline: var(--outline-thick-width) solid
color-mix(in srgb, var(--status-neutral) 38%, transparent);
}
.moderation-badge__action[disabled] {
@@ -1082,7 +1115,9 @@
.card:focus-within {
--video-card-lift-translate-y: var(--motion-video-card-hover-translate);
--video-card-backdrop-opacity: 0.425;
--video-card-backdrop-translate-y: var(--motion-video-card-backdrop-translate);
--video-card-backdrop-translate-y: var(
--motion-video-card-backdrop-translate
);
--video-card-backdrop-scale: 1.12;
--video-card-backdrop-blur: var(--blur-video-card-backdrop-strong);
@apply shadow-lg;
@@ -1493,7 +1528,9 @@
position: relative;
}
#playerModal .video-modal__video[data-visual-state="blurred"] .video-modal__media {
#playerModal
.video-modal__video[data-visual-state="blurred"]
.video-modal__media {
@apply transition duration-base ease-in-out;
filter: blur(var(--blur-thumbnail-blur));
transform: scale(1.05);
@@ -2076,9 +2113,9 @@
width: 100%;
}
#playerModal .player-modal__module-item > .card:not(
.player-modal__similar-card
) {
#playerModal
.player-modal__module-item
> .card:not(.player-modal__similar-card) {
@apply flex w-full flex-row flex-nowrap items-stretch gap-md;
}
@@ -2164,7 +2201,7 @@
}
.player-modal__similar-card-author {
@apply flex items-center min-w-0;
@apply flex min-w-0 items-center;
}
.player-modal__similar-card-author-meta {
@@ -2398,7 +2435,7 @@
}
.video-modal__actions-row {
@apply flex w-full flex-wrap items-start gap-y-3 gap-x-3 sm:gap-x-4;
@apply flex w-full flex-wrap items-start gap-x-3 gap-y-3 sm:gap-x-4;
}
.video-modal__reactions,
@@ -2571,11 +2608,8 @@
margin: 0;
padding: var(--space-md);
border-radius: var(--radius-lg);
border: var(--border-width-hairline) dashed color-mix(
in srgb,
var(--border-default) 40%,
transparent
);
border: var(--border-width-hairline) dashed
color-mix(in srgb, var(--border-default) 40%, transparent);
background-color: var(--surface-alt);
}
@@ -2680,21 +2714,14 @@
.comment-thread__load-more:focus-visible {
border-color: var(--border-default);
color: var(--text-primary);
background-color: color-mix(
in srgb,
var(--surface-alt) 70%,
transparent
);
background-color: color-mix(in srgb, var(--surface-alt) 70%, transparent);
}
.comment-thread__disabled {
@apply text-sm text-muted;
border-radius: var(--radius-lg);
border: var(--border-width-hairline) dashed color-mix(
in srgb,
var(--border-default) 32%,
transparent
);
border: var(--border-width-hairline) dashed
color-mix(in srgb, var(--border-default) 32%, transparent);
background-color: var(--surface-alt);
padding: var(--space-md);
}
@@ -3239,11 +3266,11 @@
}
.watch-history-card__primary {
@apply flex min-w-0 flex-1 flex-wrap items-stretch gap-4 sm:flex-nowrap;
@apply flex min-w-0 flex-1 flex-col items-stretch gap-4 sm:flex-row;
}
.watch-history-card__thumbnail {
@apply block flex-none text-text no-underline sm:w-56;
@apply block w-56 max-w-full flex-none text-text no-underline;
}
.watch-history-card__thumbnailInner {
@@ -3352,7 +3379,9 @@
--text-status-warning-on: var(--color-warning-on);
}
#playerModal .video-modal__moderation-bar .moderation-badge[data-variant="neutral"] {
#playerModal
.video-modal__moderation-bar
.moderation-badge[data-variant="neutral"] {
color: var(--color-text);
}
}
@@ -3716,8 +3745,8 @@
flex-direction: column;
align-items: center;
gap: var(--space-md);
padding: calc(var(--space-xl) + env(safe-area-inset-top))
var(--space-lg) var(--space-lg);
padding: calc(var(--space-xl) + env(safe-area-inset-top)) var(--space-lg)
var(--space-lg);
pointer-events: none;
opacity: 0;
transform: translateY(var(--toast-translate));
+80
View File
@@ -0,0 +1,80 @@
import os
import subprocess
import sys
def find_test_files(start_dir):
test_files = []
for root, dirs, files in os.walk(start_dir):
if 'visual' in dirs:
dirs.remove('visual')
for file in files:
if file.endswith('.test.mjs') or file.endswith('.test.js'):
full_path = os.path.join(root, file)
try:
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
if 'node:test' in content:
test_files.append(full_path)
except Exception as e:
print(f"Error reading {full_path}: {e}")
return sorted(test_files)
def run_test(test_file, setup_file):
# We use node directly. sys.executable might be python, so we need 'node'.
# Assuming 'node' is in PATH.
cmd = ['node', '--import', setup_file, test_file]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False
)
return result.returncode == 0, result.stdout, result.stderr
except Exception as e:
return False, "", str(e)
def main():
root_dir = os.getcwd()
tests_dir = os.path.join(root_dir, 'tests')
setup_file = os.path.join(root_dir, 'tests', 'test-helpers', 'setup-localstorage.mjs')
if not os.path.exists(setup_file):
# The script uses a relative URL in the JS runner, let's try to resolve it relative to CWD
# The JS runner uses: new URL("../tests/test-helpers/setup-localstorage.mjs", import.meta.url)
# If running from root, it is tests/test-helpers/setup-localstorage.mjs
pass
test_files = find_test_files(tests_dir)
print(f"Found {len(test_files)} test files.")
flaky_tests = {}
for test_file in test_files:
print(f"Checking {os.path.relpath(test_file, root_dir)}...")
failures = []
for i in range(10):
success, stdout, stderr = run_test(test_file, f"file://{setup_file}")
if not success:
failures.append((i, stdout, stderr))
print(f" Failed on iteration {i+1}")
# We can stop early if we found it's flaky, or continue to see failure rate?
# Let's stop early to save time for this task, as one failure is enough to be flaky.
break
if failures:
flaky_tests[test_file] = failures
if flaky_tests:
print("\nFound flaky tests:")
for test_file, failures in flaky_tests.items():
print(f"- {os.path.relpath(test_file, root_dir)}")
# Print the first failure output
print(f" Output:\n{failures[0][2]}\n{failures[0][1]}")
else:
print("\nNo flaky tests found.")
if __name__ == "__main__":
main()
+6
View File
@@ -125,6 +125,12 @@ implementation on the `nostrClient`. Remote signers and extensions must support
those encryption methods for DM features to work, while local `nsec` signers can
provide the primitives directly.
Signing/encryption requests always flow through the signer adapter registry in
`js/nostr/client.js` (accessed via `js/nostrClientFacade.js`). The registry
selects the active signer, falls back to the strongest supported capability, and
triggers permission prompts (for example, the NIP-07 extension handshake) before
attempting privileged calls.
## Related docs
- [`docs/nostr-auth.md`](nostr-auth.md)
+9 -5
View File
@@ -15,7 +15,11 @@
<h1 class="text-2xl font-semibold">bitvid UI Kitchen Sink</h1>
<div class="flex items-center gap-sm">
<label class="sr-only" for="themeSwitcher">Theme</label>
<select id="themeSwitcher" class="select h-10" aria-label="Select theme">
<select
id="themeSwitcher"
class="select h-10"
aria-label="Select theme"
>
<option value="default">Default (Dark)</option>
<option value="light">Light</option>
<option value="contrast">High Contrast (Preview)</option>
@@ -343,11 +347,11 @@
</p>
</header>
<input class="input" type="text" placeholder="Magnet URI" />
<div class="flex flex-col gap-3 pt-2 sm:flex-row sm:justify-end">
<div
class="flex flex-col gap-3 pt-2 sm:flex-row sm:justify-end"
>
<button class="btn-ghost w-full sm:w-auto">Cancel</button>
<button class="modal-primary sm:w-auto">
Publish
</button>
<button class="modal-primary sm:w-auto">Publish</button>
</div>
</div>
</div>
+2
View File
@@ -77,6 +77,8 @@ New operators should also note that fresh viewer accounts automatically inherit
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.
The trusted mute hide helper copy clarifies the escalation path: once the trusted mute count hits the threshold, cards are hidden with a “Show anyway” override, while lower signals only blur thumbnails or block autoplay previews.
### 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】
+116 -63
View File
@@ -50,9 +50,9 @@
<main id="moderationFixtureRoot" class="moderation-fixtures__main">
<h1 class="moderation-fixtures__title">Moderation Test Fixtures</h1>
<p>
These scenarios mirror the QA vectors in <code>docs/moderation/testing.md</code> so
automated checks can assert blur thresholds, autoplay blocking, and local
override persistence.
These scenarios mirror the QA vectors in
<code>docs/moderation/testing.md</code> so automated checks can assert
blur thresholds, autoplay blocking, and local override persistence.
</p>
</main>
<script type="module">
@@ -74,94 +74,95 @@
id: "1".repeat(64),
title: "Fixture: Blurred Preview",
pubkey: "a".repeat(64),
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg",
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg"
},
moderation: {
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 3,
reportType: "nudity",
reportType: "nudity"
},
reporters: [
{ name: "Jess F1", pubkey: "b".repeat(64), latest: 1_700_000_010 },
{ name: "Mira F1", pubkey: "c".repeat(64), latest: 1_700_000_020 },
{ name: "Omar F1", pubkey: "d".repeat(64), latest: 1_700_000_030 },
{ name: "Omar F1", pubkey: "d".repeat(64), latest: 1_700_000_030 }
],
blockToggle: {
label: "Simulate viewer blocking Mira F1",
pubkey: "c".repeat(64),
},
pubkey: "c".repeat(64)
}
},
{
key: "autoplay-threshold",
heading: "Autoplay threshold (config default: ≥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: {
id: "2".repeat(64),
title: "Fixture: Autoplay Blocked",
pubkey: "e".repeat(64),
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg",
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg"
},
moderation: {
blockAutoplay: true,
blurThumbnail: false,
trustedCount: 2,
reportType: "nudity",
reportType: "nudity"
},
reporters: [
{ name: "Jess F1", pubkey: "b".repeat(64), latest: 1_700_000_010 },
{ name: "Mira F1", pubkey: "c".repeat(64), latest: 1_700_000_020 },
],
{ name: "Mira F1", pubkey: "c".repeat(64), latest: 1_700_000_020 }
]
},
{
key: "show-anyway",
heading: "\"Show anyway\" override (persists locally)",
heading: '"Show anyway" override (persists locally)',
description:
"Card starts blurred and blocked; activating \"Show anyway\" should persist across reloads.",
'Card starts blurred and blocked; activating "Show anyway" should persist across reloads.',
enableOverride: true,
video: {
id: "3".repeat(64),
title: "Fixture: Override Persistence",
pubkey: "f".repeat(64),
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg",
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg"
},
moderation: {
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 3,
reportType: "nudity",
reportType: "nudity"
},
reporters: [
{ name: "Jess F1", pubkey: "b".repeat(64), latest: 1_700_000_010 },
{ name: "Mira F1", pubkey: "c".repeat(64), latest: 1_700_000_020 },
{ name: "Omar F1", pubkey: "d".repeat(64), latest: 1_700_000_030 },
],
{ name: "Omar F1", pubkey: "d".repeat(64), latest: 1_700_000_030 }
]
},
{
key: "trusted-report-hide",
heading: "Trusted report hide (config default: ≥3 spam reports)",
description:
"Card should render hidden with spam copy until the viewer clicks \"Show anyway\".",
'Card should render hidden with spam copy until the viewer clicks "Show anyway".',
enableOverride: true,
thresholds: { reportHide: 3 },
video: {
id: "4".repeat(64),
title: "Fixture: Trusted Report Hide",
pubkey: "aa".repeat(32),
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg",
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg"
},
moderation: {
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 3,
reportType: "spam",
reportType: "spam"
},
reporters: [
{ name: "Jess F1", pubkey: "b".repeat(64), latest: 1_700_000_010 },
{ name: "Mira F1", pubkey: "c".repeat(64), latest: 1_700_000_020 },
{ name: "Omar F1", pubkey: "d".repeat(64), latest: 1_700_000_030 },
],
{ name: "Omar F1", pubkey: "d".repeat(64), latest: 1_700_000_030 }
]
},
{
key: "trusted-mute-hide",
@@ -174,16 +175,16 @@
id: "5".repeat(64),
title: "Fixture: Trusted Mute Hide",
pubkey: "bb".repeat(32),
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg",
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg"
},
moderation: {
blockAutoplay: false,
blurThumbnail: false,
trustedMuted: true,
trustedMuteCount: 1,
trustedMuteCount: 1
},
muters: [{ name: "Jess F1", pubkey: "b".repeat(64) }],
},
muters: [{ name: "Jess F1", pubkey: "b".repeat(64) }]
}
];
const blockedReportersByVideo = new Map();
@@ -196,7 +197,12 @@
}
function setReporterBlocked(videoId, pubkey, blocked) {
if (typeof videoId !== "string" || !videoId || typeof pubkey !== "string" || !pubkey) {
if (
typeof videoId !== "string" ||
!videoId ||
typeof pubkey !== "string" ||
!pubkey
) {
return;
}
@@ -229,7 +235,9 @@
}
function getActiveReporters(fixture, videoId) {
const reporters = Array.isArray(fixture?.reporters) ? fixture.reporters : [];
const reporters = Array.isArray(fixture?.reporters)
? fixture.reporters
: [];
if (!reporters.length) {
return [];
}
@@ -240,7 +248,8 @@
}
return reporters.filter((reporter) => {
const key = typeof reporter?.pubkey === "string" ? reporter.pubkey : "";
const key =
typeof reporter?.pubkey === "string" ? reporter.pubkey : "";
if (!key) {
return false;
}
@@ -258,11 +267,14 @@
if (!reporter || typeof reporter !== "object") {
return null;
}
const pubkey = typeof reporter.pubkey === "string" ? reporter.pubkey : "";
const pubkey =
typeof reporter.pubkey === "string" ? reporter.pubkey : "";
if (!pubkey) {
return null;
}
const latest = Number.isFinite(reporter.latest) ? Math.floor(reporter.latest) : 0;
const latest = Number.isFinite(reporter.latest)
? Math.floor(reporter.latest)
: 0;
const name = typeof reporter.name === "string" ? reporter.name : "";
return { name, pubkey, latest };
})
@@ -302,7 +314,10 @@
? sanitizedReporters
: normalizeFixtureReporters(getActiveReporters(fixture, videoId));
const latest = reporters.reduce((max, reporter) => Math.max(max, reporter.latest), 0);
const latest = reporters.reduce(
(max, reporter) => Math.max(max, reporter.latest),
0
);
return {
eventId: fixture.video.id,
@@ -311,10 +326,10 @@
[fixture.moderation.reportType]: {
trusted: reporters.length,
total: reporters.length,
latest,
},
latest
}
},
updatedAt: Date.now(),
updatedAt: Date.now()
};
}
@@ -324,7 +339,13 @@
const sanitizedReporters = normalizeFixtureReporters(activeReporters);
const thresholds =
fixture.thresholds && typeof fixture.thresholds === "object"
? { blur: 3, autoplay: 2, reportHide: 3, muteHide: 1, ...fixture.thresholds }
? {
blur: 3,
autoplay: 2,
reportHide: 3,
muteHide: 1,
...fixture.thresholds
}
: { blur: 3, autoplay: 2, reportHide: 3, muteHide: 1 };
const autoplayThreshold = Number.isFinite(thresholds.autoplay)
? Math.max(0, Math.floor(thresholds.autoplay))
@@ -346,16 +367,22 @@
.map((reporter) => reporter.name)
.filter((value) => typeof value === "string" && value);
const reporterPubkeys = sanitizedReporters.map((reporter) => reporter.pubkey);
const reporterPubkeys = sanitizedReporters.map(
(reporter) => reporter.pubkey
);
const trustedReporterEntries = sanitizedReporters.map((reporter) => ({
pubkey: reporter.pubkey,
latest: reporter.latest,
latest: reporter.latest
}));
const muterEntries = Array.isArray(fixture.muters) ? fixture.muters : [];
const muterEntries = Array.isArray(fixture.muters)
? fixture.muters
: [];
const muterPubkeys = muterEntries
.map((muter) => (typeof muter?.pubkey === "string" ? muter.pubkey : ""))
.map((muter) =>
typeof muter?.pubkey === "string" ? muter.pubkey : ""
)
.filter(Boolean);
const muterDisplayNames = muterEntries
.map((muter) => (typeof muter?.name === "string" ? muter.name : ""))
@@ -390,7 +417,7 @@
const hideCounts = hidden
? {
trustedMuteCount: muteCount,
trustedReportCount: trustedCount,
trustedReportCount: trustedCount
}
: null;
@@ -403,7 +430,7 @@
trustedMuted,
trustedMuters: muterPubkeys,
trustedMuteCount: muteCount,
trustedMuterDisplayNames: muterDisplayNames,
trustedMuterDisplayNames: muterDisplayNames
};
const summary = buildSummary(fixture, videoId, sanitizedReporters);
@@ -425,12 +452,18 @@
hideReason: hidden ? hideReason : "",
hideCounts: hideCounts
? {
trustedMuteCount: Math.max(0, Number(hideCounts.trustedMuteCount) || 0),
trustedReportCount: Math.max(0, Number(hideCounts.trustedReportCount) || 0),
trustedMuteCount: Math.max(
0,
Number(hideCounts.trustedMuteCount) || 0
),
trustedReportCount: Math.max(
0,
Number(hideCounts.trustedReportCount) || 0
)
}
: null,
hideBypass,
hideTriggered: hidden,
hideTriggered: hidden
};
base.original = {
@@ -440,7 +473,7 @@
hideReason: originalState.hideReason,
hideCounts: originalState.hideCounts,
hideBypass: originalState.hideBypass,
hideTriggered: originalState.hideTriggered,
hideTriggered: originalState.hideTriggered
};
if (overrideEntry?.showAnyway) {
@@ -448,7 +481,7 @@
showAnyway: true,
updatedAt: Number.isFinite(overrideEntry.updatedAt)
? Math.floor(overrideEntry.updatedAt)
: Date.now(),
: Date.now()
};
base.blockAutoplay = false;
base.blurThumbnail = false;
@@ -501,7 +534,7 @@
...entry,
updatedAt: Number.isFinite(entry?.updatedAt)
? Math.floor(entry.updatedAt)
: Date.now(),
: Date.now()
};
persistOverrides(next);
return next;
@@ -509,7 +542,7 @@
function createCard(fixture, overrides) {
const video = {
...fixture.video,
...fixture.video
};
video.moderation = buildModerationState(fixture, overrides, video.id);
@@ -524,7 +557,7 @@
return value.toLocaleString("en-US");
}
return String(value ?? "");
},
}
},
helpers: {
isMagnetSupported: () => true,
@@ -534,19 +567,23 @@
return value.toLocaleString(locales || "en-US", options);
}
return String(value ?? "");
},
}
},
assets: {
fallbackThumbnailSrc: "/assets/jpg/video-thumbnail-fallback.jpg",
fallbackThumbnailSrc: "/assets/jpg/video-thumbnail-fallback.jpg"
},
designSystem: {
getMode: () => "new",
isNew: () => true,
},
isNew: () => true
}
});
const refreshModeration = () => {
card.video.moderation = buildModerationState(fixture, overrides, video.id);
card.video.moderation = buildModerationState(
fixture,
overrides,
video.id
);
if (typeof card.refreshModerationUi === "function") {
card.refreshModerationUi();
}
@@ -585,11 +622,16 @@
if (fixture.description) {
const description = document.createElement("p");
description.textContent = fixture.description;
description.classList.add("moderation-fixtures__section-description");
description.classList.add(
"moderation-fixtures__section-description"
);
section.appendChild(description);
}
const { root: cardRoot, refreshModeration } = createCard(fixture, overrides);
const { root: cardRoot, refreshModeration } = createCard(
fixture,
overrides
);
section.appendChild(cardRoot);
if (fixture.blockToggle && fixture.blockToggle.pubkey) {
@@ -601,14 +643,25 @@
const input = document.createElement("input");
input.type = "checkbox";
input.checked = isReporterBlocked(fixture.video.id, fixture.blockToggle.pubkey);
input.checked = isReporterBlocked(
fixture.video.id,
fixture.blockToggle.pubkey
);
input.addEventListener("change", () => {
const checked = input.checked;
setReporterBlocked(fixture.video.id, fixture.blockToggle.pubkey, checked);
setReporterBlocked(
fixture.video.id,
fixture.blockToggle.pubkey,
checked
);
const action = checked ? "block" : "unblock";
try {
const detail = { action, targetPubkey: fixture.blockToggle.pubkey };
const maybePromise = moderationService?.queueUserBlockRefresh?.(detail);
const detail = {
action,
targetPubkey: fixture.blockToggle.pubkey
};
const maybePromise =
moderationService?.queueUserBlockRefresh?.(detail);
if (maybePromise && typeof maybePromise.then === "function") {
maybePromise.catch(() => {});
}
+11 -4
View File
@@ -17,10 +17,15 @@
- `blurThumbnail = trustedReportCount(event,'nudity') >= DEFAULT_BLUR_THRESHOLD`
- `hideAutoplay = trustedReportCount(event,'nudity') >= DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`
- `downrankIfMutedByF1 = true`
- `hideIfTrustedMuteCount(author) >= DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`
- `hideIfTrustedMuteCount(author, category) >= trustedMuteHideThresholds[category] ?? DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`
- `hideIfTrustedSpamReports(event) >= DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`
Threshold constants are exported from [`config/instance-config.js`](../../config/instance-config.js) so operators can change the defaults without touching moderation code. Inspect the `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` exports to set your policy. The upstream repo includes example values (blur at 1, autoplay block at 1, trusted mute hide at 20, trusted spam hide at 1), but treat those as guidance rather than hard-coded requirements.
Threshold constants are exported from [`config/instance-config.js`](../../config/instance-config.js) so operators can change the defaults without touching moderation code. Inspect the `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` exports to set your policy. The upstream repo includes example values (blur at 1, autoplay block at 1, trusted mute hide at 20, trusted spam hide at 1), but treat those as guidance rather than hard-coded requirements. Per-category trusted mute hide thresholds now live in moderation settings as `trustedMuteHideThresholds` (e.g., `{ spam: 5, nudity: 10 }`), and any category not present falls back to the default trusted mute hide threshold. When evaluating trusted mutes we use the content's report type if available, otherwise we fall back to any category metadata attached to trusted mute list entries.
### Trusted mute decay window
- Trusted mute counts are **time-bounded**: only mute lists updated within the rolling window (currently 60 days) contribute to `trustedMuteCount`.
- If an F1 account has not refreshed its mute list within the window, its mutes are treated as expired until a newer list is ingested.
- This window acts as a rate-limit/decay mechanism so stale mutes do not accumulate indefinitely.
### Why these numbers?
- F1-only reports resist Sybil attacks.
@@ -62,8 +67,10 @@ function shouldHideAutoplay(eventId: string, ctx: Ctx): boolean {
return trustedReportCount(eventId, 'nudity', ctx.viewerFollows, ctx.reports) >= DEFAULT_AUTOPLAY_BLOCK_THRESHOLD;
}
function shouldHideForTrustedMute(authorHex: string, ctx: Ctx): boolean {
return ctx.trustedMuteCount(authorHex) >= DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD;
function shouldHideForTrustedMute(authorHex: string, ctx: Ctx, category: Report['type']): boolean {
const thresholds = ctx.trustedMuteHideThresholds || {};
const threshold = thresholds[category] ?? DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD;
return ctx.trustedMuteCount(authorHex, category) >= threshold;
}
function shouldHideForTrustedSpam(eventId: string, ctx: Ctx): boolean {
+52
View File
@@ -0,0 +1,52 @@
# NIP-46 Roadmap & Features
This document outlines the current state of our NIP-46 (Nostr Connect) implementation and tracks future enhancements. The goal is to provide a seamless, secure, and user-friendly remote signing experience.
## Current State
As of the latest refactor, the NIP-46 login flow has been simplified to prioritize ease of use for the majority of users:
* **QR-First UX**: The login modal immediately presents a QR code for easy pairing with mobile signers (e.g., Amber, NsecBunker).
* **One-Click Copy**: A prominent "Copy Connect Link" button allows users to quickly grab the `nostrconnect://` URI for manual pasting if needed.
* **Hidden Complexity**: Advanced options like manual bunker URL entry and relay configuration are hidden by default to reduce cognitive load.
* **Manual Fallback**: A "Trouble scanning?" toggle reveals the raw URI for debugging or manual copying in edge cases.
* **Automatic Persistence**: Sessions are automatically remembered to streamline future logins.
## Planned Features & Improvements
The following features are candidates for future implementation:
### 1. Manual Bunker Connection
* **Description**: Allow users to manually paste a `bunker://` URL provided by their signer service.
* **Use Case**: Users who set up a remote signer on a server or desktop application that provides a static connection string.
* **Implementation**: Restore the manual input toggle in a "Advanced" or "Manual" tab within the modal.
### 2. Advanced Relay Configuration
* **Description**: Allow users to customize the relays used for the NIP-46 handshake and subsequent communication.
* **Use Case**: Users with strict privacy requirements or those using private relays for their signing devices.
* **Implementation**: Add a "Relay Settings" accordion or step in the connection flow.
### 3. NIP-05 Discovery
* **Description**: Allow users to enter a NIP-05 address (e.g., `[email protected]`) to discover their signer's relay and public key automatically.
* **Use Case**: Simplifies the login process to just an email-like identifier, similar to regular web logins.
* **Implementation**: Query the `.well-known/nostr.json` of the domain to resolve the NIP-46 config.
### 4. Two-Way Pairing / Auth
* **Description**: Support flows where the client (this app) scans a QR code generated by the signer, rather than the signer scanning the client.
* **Use Case**: Desktop users pairing with a mobile device where the desktop has a webcam.
### 5. Session Management UI
* **Description**: A dedicated settings panel to view connected signers, revoke permissions, or forget saved sessions.
* **Use Case**: Users managing multiple signing devices or rotating keys.
### 6. Multi-Account Support via NIP-46
* **Description**: Better support for signers that manage multiple keys (personas) behind a single connection.
* **Use Case**: Users who want to switch between "Personal", "Work", and "Anon" profiles without reconnecting.
## Known Issues
* **QR Code Density**: Very long URIs (with many relays or long secrets) can create dense QR codes that are hard to scan on low-resolution screens.
* **Browser Compatibility**: The "Copy" button relies on the Clipboard API, which requires a secure context (HTTPS) and active focus.
---
*Last Updated: ${new Date().toISOString().split('T')[0]}*
+91
View File
@@ -0,0 +1,91 @@
# NostrClient Overview
`js/nostr/client.js` exports the `NostrClient` class, which serves as the central controller for all Nostr network interactions and state management within the application. It acts as a facade over `nostr-tools`, managing connections, subscriptions, caching, and signing.
## Key Responsibilities
1. **Connection Management**: Maintains a pool of connections to multiple relays (`SimplePool`).
2. **State Management**: Tracks video events, deduplicates them, and manages deletions via tombstones.
3. **Caching**: Implements a "stale-while-revalidate" strategy using IndexedDB (with localStorage fallback) to restore UI state immediately upon load.
4. **Event Publishing**: Orchestrates the signing and broadcasting of complex event chains (Video Note -> NIP-94 Mirror -> NIP-71 Metadata).
5. **Signer Abstraction**: Unifies NIP-07 (Extension), NIP-46 (Remote/Bunker), and Local (nsec) signing under a single interface.
6. **Video History**: Reconstructs the edit history of videos using `videoRootId` and `d` tags.
## Architecture & State
The client maintains several internal maps to manage state:
* `allEvents`: A raw map of all fetched video events (key: event ID).
* `activeMap`: A derived map containing only the *latest* version of each video, keyed by a stable identifier (`videoRootId` or addressable pointer).
* `rawEvents`: A cache of the exact raw JSON events from relays, used for signature verification and re-publishing.
* `tombstones`: Tracks timestamps of deletion events to prevent "zombie" events (deleted events reappearing from slow relays) from resurfacing.
### Caching Strategy (`EventsCacheStore`)
To ensure instant load times, the client persists its state to IndexedDB (`bitvid-events-cache`).
* **Snapshot**: On a debounced timer (or idle callback), the current state (`allEvents`, `tombstones`) is serialized.
* **Fingerprinting**: Events are fingerprinted to avoid writing unchanged data to the DB.
* **Restore**: On `init()`, the client loads the latest snapshot into memory, allowing the UI to render before any network requests complete.
## Key Flows
### 1. Initialization (`init`)
```mermaid
graph TD
A[init()] --> B[restoreLocalData]
B --> C{IndexedDB Available?}
C -- Yes --> D[Load Snapshot]
C -- No --> E[Load localStorage]
D & E --> F[Render UI with Cached Data]
F --> G[ensurePool]
G --> H[connectToRelays]
H --> I[Ready for Subscriptions]
```
### 2. Video Subscription (`subscribeVideos`)
To prevent UI thrashing during high-volume event ingress (e.g., initial flood from relays), the client uses a **buffering and debouncing** strategy.
1. **Buffer**: Incoming events are pushed to `eventBuffer` instead of being processed immediately.
2. **Debounce**: A flush timer is scheduled (75ms).
3. **Flush**: When the timer fires, the entire buffer is processed:
* Events are validated and converted.
* `activeMap` is updated (latest-wins logic).
* `onVideo` callback is fired once with the batch updates.
* Cache persistence is scheduled.
### 3. Publishing a Video (`publishVideo`)
Publishing is a multi-step process ensuring compatibility and discoverability.
1. **Video Note (Kind 30078)**: The core content (magnet, infohash, metadata) is signed and published. A unique `videoRootId` is generated for new videos.
2. **NIP-94 Mirror (Kind 1063)**: If a direct HTTP URL is provided, a file header event is published to point to the hosted file.
3. **NIP-71 Metadata (Kind 22/21)**: Addressable metadata tags are published to support categorization/grouping without modifying the immutable video note.
### 4. Remote Signer Handshake (`connectRemoteSigner`)
For NIP-46 (Nostr Connect), the client implements a full handshake flow:
1. **Ephemeral Key**: Generates a local key pair for the session.
2. **Handshake**: Publishes a Kind 24133 (`NIP46_RPC_KIND`) event to the relay.
3. **Wait**: Listens for an acknowledgment or `auth_url` challenge from the signer.
4. **Session**: Once connected, stores the session metadata to allow auto-reconnection.
## Why it works this way
* **Buffering**: essential for performance. React rendering cycles are expensive; updating state for every single event in a 5000-event relay dump would freeze the browser.
* **Tombstones**: essential for eventual consistency. Relays are distributed and can be out of sync. If a user deletes a video, we must remember that deletion timestamp so a slow relay doesn't "undelete" it by serving the old event later.
* **Dual-Layer Cache**: IndexedDB is asynchronous and large-capacity; localStorage is synchronous but small. We use IndexedDB for the bulk data but keep a fallback for environments where IDB might be flaky or slow to initialize.
## When to change
* **Refactor `EventsCacheStore`**: If moving to a more robust state management library (like RxDB or dedicated worker), this logic should be extracted.
* **New Event Kinds**: If the video data model changes (e.g., V4 schema), `convertEventToVideo` and `publishVideo` will need updates.
* **Signer upgrades**: As NIP-46 evolves (e.g., Bunker standards), the handshake and connection logic in `nip46Client.js` (and its orchestration here) will need adjustment.
## Related Files
* `js/nostrEventSchemas.js`: Defines the structure of the events being built/parsed.
* `js/nostr/nip46Client.js`: Handles the low-level RPC for remote signers.
* `js/state/profileCache.js`: Handles user profile metadata (Kind 0), which is separate from video data.
+10 -4
View File
@@ -67,10 +67,16 @@ 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.
`nostrClientFacade.js`) routes signing and encryption through the adapter
registry in `js/nostr/client.js`, prefers the registered signer, and falls back
to the strongest available capability (or session actors for telemetry) when a
method is unavailable. Permission prompts are surfaced via
`nostrClient.ensureExtensionPermissions()` or the
`requestDefaultExtensionPermissions()` helper from
`js/nostr/defaultClient.js`, so features can ask for access before issuing
sign/encrypt calls. 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
+35 -16
View File
@@ -11,9 +11,9 @@
<header class="space-y-sm">
<h1 class="text-2xl font-semibold">Popover positioning scenarios</h1>
<p class="max-w-3xl text-sm text-muted">
Interactive harness that exercises the popover engine near the edges of the
viewport, inside modals, and within scroll containers. Playwright scenarios
rely on the data attributes surfaced here.
Interactive harness that exercises the popover engine near the edges
of the viewport, inside modals, and within scroll containers.
Playwright scenarios rely on the data attributes surfaced here.
</p>
</header>
@@ -21,13 +21,16 @@
<div class="space-y-xs">
<h2 class="text-xl font-semibold">Grid edge cases</h2>
<p class="text-sm text-muted">
The rightmost card hugs the viewport edge to verify collision handling.
The rightmost card hugs the viewport edge to verify collision
handling.
</p>
</div>
<div class="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
<article class="card space-y-sm p-md">
<h3 class="text-lg font-semibold">Top left</h3>
<p class="text-sm text-muted">Baseline placement without collisions.</p>
<p class="text-sm text-muted">
Baseline placement without collisions.
</p>
<button
class="btn-ghost"
type="button"
@@ -37,7 +40,10 @@
Menu
</button>
</article>
<article class="card space-y-sm p-md" data-test-card="grid-bottom-right">
<article
class="card space-y-sm p-md"
data-test-card="grid-bottom-right"
>
<h3 class="text-lg font-semibold">Bottom right</h3>
<p class="text-sm text-muted">
Forces the panel to flip when it would overflow the viewport.
@@ -55,7 +61,9 @@
</article>
<article class="card space-y-sm p-md">
<h3 class="text-lg font-semibold">Center</h3>
<p class="text-sm text-muted">Acts as a control for viewport padding.</p>
<p class="text-sm text-muted">
Acts as a control for viewport padding.
</p>
<div class="flex justify-center">
<button
class="btn-ghost"
@@ -74,23 +82,28 @@
<div class="space-y-xs">
<h2 class="text-xl font-semibold">Video modal integration</h2>
<p class="text-sm text-muted">
Mimics the production video modal so we can verify popovers respect the overlay root.
Mimics the production video modal so we can verify popovers respect
the overlay root.
</p>
</div>
<button class="btn w-40" type="button" data-test-open-modal>
Open modal
</button>
<div
class="fixed inset-0 z-overlay-modal hidden items-center justify-center bg-overlay/60 backdrop-blur"
class="z-overlay-modal bg-overlay/60 fixed inset-0 hidden items-center justify-center backdrop-blur"
role="dialog"
aria-modal="true"
aria-labelledby="testModalTitle"
data-test-modal
>
<div class="bv-modal__panel flex max-h-modal-shell flex-col gap-md p-lg">
<div
class="bv-modal__panel flex max-h-modal-shell flex-col gap-md p-lg"
>
<header class="flex items-center justify-between gap-sm">
<div>
<p class="text-xs uppercase tracking-wide text-muted">Now playing</p>
<p class="text-xs uppercase tracking-wide text-muted">
Now playing
</p>
<h3 id="testModalTitle" class="text-lg font-semibold">
Edge-of-viewport sample
</h3>
@@ -101,7 +114,8 @@
</header>
<div class="space-y-md">
<p class="text-sm text-muted">
Interact with the more menu to verify popovers inside scroll-locked contexts.
Interact with the more menu to verify popovers inside
scroll-locked contexts.
</p>
<div class="flex justify-end">
<button
@@ -122,14 +136,15 @@
<div class="space-y-xs">
<h2 class="text-xl font-semibold">Scroll container</h2>
<p class="text-sm text-muted">
Ensures auto-update keeps popovers aligned when the trigger lives in overflow regions.
Ensures auto-update keeps popovers aligned when the trigger lives in
overflow regions.
</p>
</div>
<div
class="h-64 overflow-auto rounded-xl border border-border bg-surface-alt p-md"
data-test-scroll-region
>
<div class="space-y-lg docs-popover-scenarios__scroll-spacer">
<div class="docs-popover-scenarios__scroll-spacer space-y-lg">
<p class="text-sm text-muted">
Scroll to the bottom to find the trigger used in automated tests.
</p>
@@ -155,7 +170,8 @@
function buildMenu({ container, trigger, id }) {
const panel = container.ownerDocument.createElement("div");
panel.className = "popover__panel card w-64 max-w-popover-safe space-y-sm";
panel.className =
"popover__panel card w-64 max-w-popover-safe space-y-sm";
panel.dataset.testPanel = id;
const heading = container.ownerDocument.createElement("h4");
@@ -198,7 +214,10 @@
const triggers = [
{ selector: "[data-test-trigger=grid-top-left]", id: "grid-top-left" },
{ selector: "[data-test-trigger=grid-bottom-right]", id: "grid-bottom-right" },
{
selector: "[data-test-trigger=grid-bottom-right]",
id: "grid-bottom-right"
},
{ selector: "[data-test-trigger=grid-center]", id: "grid-center" },
{ selector: "[data-test-trigger=modal-menu]", id: "modal-menu" },
{ selector: "[data-test-trigger=scroll-bottom]", id: "scroll-bottom" }
@@ -0,0 +1,23 @@
# SimilarContentCard Thumbnail Blur Bug Reproducer
This reproducer demonstrates a bug where `SimilarContentCard` clears the backdrop image (CSS variable `--similar-card-thumb-url`) when the thumbnail is blurred, causing a regression in `SimilarContentCard.test.mjs`.
## Issue
The `SimilarContentCard` component explicitly clears the backdrop image when `shouldBlur` is true to prevent sharp background showing through the blurred image. However, the unit tests expect the backdrop to be set to the fallback image even when blurred.
## How to run
From the repository root:
```bash
node --import ./tests/test-helpers/setup-localstorage.mjs examples/reproducers/1-similar-content-card-thumbnail/reproduce_issue.js
```
## Expected Output
The script should fail with an assertion error indicating that the CSS variable is empty instead of the expected URL.
```
CSS variable should be set to fallback src. Expected url("https://cdn.example.com/fallback.jpg"), got ''
```
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { SimilarContentCard } from "../../../js/ui/components/SimilarContentCard.js";
import { createUiDom } from "../../../tests/ui/helpers/jsdom-test-helpers.mjs";
console.log("Reproducing issue: Uncached thumbnails should use fallback and retain blur state");
const { document, window, cleanup } = createUiDom();
try {
const fallbackSrc = "https://cdn.example.com/fallback.jpg";
const remoteThumb = "https://cdn.example.com/remote.jpg";
const cache = new Map();
const card = new SimilarContentCard({
document,
video: {
id: "video-456",
title: "Test Video",
thumbnail: remoteThumb,
moderation: { blurThumbnail: true },
},
thumbnailCache: cache,
fallbackThumbnailSrc: fallbackSrc,
});
const root = card.getRoot();
const img = root.querySelector("img[data-video-thumbnail]");
assert(img, "Thumbnail image should exist");
const cssVar = window.getComputedStyle(root).getPropertyValue("--similar-card-thumb-url").trim();
console.log(`CSS Variable: '${cssVar}'`);
assert.equal(
cssVar,
`url("${fallbackSrc}")`,
`CSS variable should be set to fallback src. Expected url("${fallbackSrc}"), got '${cssVar}'`
);
console.log("Test PASSED (Unexpectedly?)");
} catch (error) {
console.error("Test FAILED (As expected for a reproducer):");
console.error(error.message);
process.exit(1);
} finally {
cleanup();
}
+23
View File
@@ -0,0 +1,23 @@
# Fuzz Testing Reproducers
This directory contains reproducers for crashes found during fuzz testing.
These files contain the input that caused a crash and the error message.
## How to run
The reproducers are JSON files. To re-run a specific case, you can use the fuzz harness and load the JSON input, or manually import the function and call it with the input.
Example structure:
```json
{
"target": "functionName",
"input": { ... },
"error": { ... }
}
```
## Known Issues
- `buildVideoPostEvent` crashes on circular objects if not handled (Fixed).
- `normalizeAndAugmentMagnet` crashes on non-iterable `extraTrackers` (Fixed).
- `build...` functions crash on `null`/`undefined` input (Fixed).
+61
View File
@@ -353,6 +353,67 @@
seed. zap. subscribe.
</h2>
</div>
<!-- Mobile Search FAB -->
<button
id="mobileSearchFab"
class="focus-ring fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-accent-strong shadow-lg transition-transform duration-200 hover:scale-105 active:scale-95 md:hidden"
aria-label="Open search"
>
<svg
class="h-6 w-6 text-white"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<!-- Mobile Search Container -->
<div
id="mobileSearchContainer"
class="fixed bottom-6 right-6 z-50 hidden w-[calc(100%-3rem)] max-w-sm origin-bottom-right transition-all duration-200 md:hidden"
>
<form
id="mobileSearchForm"
role="search"
class="flex items-center gap-3 rounded-full border border-border bg-overlay-panel bg-opacity-95 p-2 shadow-xl backdrop-blur-md"
>
<button
type="button"
class="header-search__filter shrink-0"
aria-label="Open search filters"
>
<span
aria-hidden="true"
class="header-search__icon header-search__icon--filter"
></span>
</button>
<label for="mobileSearchInput" class="sr-only">Search videos</label>
<input
id="mobileSearchInput"
class="header-search__input min-w-0 flex-1 bg-transparent px-2 text-base text-text outline-none placeholder:text-muted"
type="search"
name="query"
placeholder="Search videos"
autocomplete="off"
/>
<button type="submit" class="header-search__submit shrink-0">
<span class="sr-only">Submit search</span>
<span
aria-hidden="true"
class="header-search__icon header-search__icon--submit"
></span>
</button>
</form>
</div>
</div>
<!-- Additional Scripts -->
+48 -39
View File
@@ -17,7 +17,9 @@ import { devLogger, userLogger } from "./utils/logger.js";
import {
buildAdminListEvent,
ADMIN_LIST_IDENTIFIERS,
NOTE_TYPES,
} from "./nostrEventSchemas.js";
import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js";
import { publishEventToRelay } from "./nostrPublish.js";
const ADMIN_STATE_CACHE_VERSION = 1;
@@ -231,6 +233,11 @@ function hasAnyEntries(state = {}) {
}
export const readCachedAdminState = () => {
const policy = CACHE_POLICIES[NOTE_TYPES.ADMIN_MODERATION_LIST];
if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) {
return null;
}
if (typeof localStorage === "undefined") {
return null;
}
@@ -263,6 +270,11 @@ export const readCachedAdminState = () => {
};
export const writeCachedAdminState = (state) => {
const policy = CACHE_POLICIES[NOTE_TYPES.ADMIN_MODERATION_LIST];
if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) {
return;
}
if (typeof localStorage === "undefined") {
return;
}
@@ -512,59 +524,56 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") {
const nostrClient = requireNostrClient();
const relays = ensureNostrReady();
const normalizedFilter = {};
if (Array.isArray(filter?.kinds) && filter.kinds.length) {
normalizedFilter.kinds = [...filter.kinds];
} else {
normalizedFilter.kinds = [30000];
}
const kind = (Array.isArray(filter?.kinds) && filter.kinds.length)
? filter.kinds[0]
: 30000;
if (typeof filter?.limit === "number") {
normalizedFilter.limit = filter.limit;
} else {
normalizedFilter.limit = 50;
}
const pubkey = (Array.isArray(filter?.authors) && filter.authors.length)
? filter.authors[0]
: "";
if (Array.isArray(filter?.["#d"]) && filter["#d"].length) {
normalizedFilter["#d"] = [...filter["#d"]];
}
const dTag = (Array.isArray(filter?.["#d"]) && filter["#d"].length)
? filter["#d"][0]
: "";
if (Array.isArray(filter?.authors) && filter.authors.length) {
normalizedFilter.authors = [...filter.authors];
// Note: fetchListIncrementally expects single kind, pubkey, dTag.
// The existing fetchLatestListEvent was a bit generic but admin lists are specific.
// If we can't extract specific identifiers, fall back to pool list (no incremental).
if (!pubkey) {
devLogger.warn(`[adminListStore] Cannot use incremental fetch for ${contextLabel} without author.`);
// Fallback to old behavior if pubkey missing (e.g. search scenario? unlikely for admin lists)
// Actually admin lists always have specific d-tag and author (editor list, whitelist, blacklist, community lists)
// So we should be fine.
}
let events = [];
try {
const combined = await nostrClient.pool.list(relays, [normalizedFilter]);
if (Array.isArray(combined)) {
events = combined;
if (pubkey) {
events = await nostrClient.fetchListIncrementally({
kind,
pubkey,
dTag,
relayUrls: relays
});
} else {
// Fallback for non-specific queries
const normalizedFilter = {
kinds: [kind],
limit: 50
};
if (dTag) normalizedFilter["#d"] = [dTag];
if (filter.authors) normalizedFilter.authors = filter.authors;
events = await nostrClient.pool.list(relays, [normalizedFilter]);
}
} catch (error) {
devLogger.warn(
`[adminListStore] Combined relay fetch failed for ${contextLabel}:`,
`[adminListStore] Fetch failed for ${contextLabel}:`,
error,
);
}
if (!events.length) {
const perRelay = await Promise.all(
relays.map(async (url) => {
try {
const result = await nostrClient.pool.list([url], [normalizedFilter]);
return Array.isArray(result) ? result : [];
} catch (error) {
devLogger.warn(
`[adminListStore] Relay fetch failed for ${contextLabel} on ${url}:`,
error,
);
return [];
}
})
);
events = perRelay.flat();
}
if (!events.length) {
if (!events || !events.length) {
return null;
}
+663 -394
View File
File diff suppressed because it is too large. Load diff
+135 -5
View File
@@ -155,6 +155,49 @@ function normalizeHex(value) {
return /^[0-9a-f]{64}$/.test(trimmed) ? trimmed : "";
}
function decodeNpubToHex(npub) {
if (typeof npub !== "string" || !npub.trim()) {
return "";
}
try {
const decoded = window?.NostrTools?.nip19?.decode?.(npub);
if (decoded?.type !== "npub" || !decoded.data) {
return "";
}
if (typeof decoded.data === "string") {
return normalizeHex(decoded.data);
}
let bufferSource = null;
if (decoded.data instanceof Uint8Array) {
bufferSource = decoded.data;
} else if (Array.isArray(decoded.data)) {
bufferSource = Uint8Array.from(decoded.data);
} else if (
decoded.data?.type === "Buffer" &&
Array.isArray(decoded.data?.data)
) {
bufferSource = Uint8Array.from(decoded.data.data);
}
if (!bufferSource) {
return "";
}
if (typeof window?.NostrTools?.utils?.bytesToHex === "function") {
return normalizeHex(window.NostrTools.utils.bytesToHex(bufferSource));
}
return normalizeHex(
Array.from(bufferSource)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
);
} catch (error) {
devLogger.warn("[ChannelProfile] Failed to decode npub to hex", error);
return "";
}
}
function decorateChannelVideo(video, app = getApp()) {
if (!video || typeof video !== "object") {
return null;
@@ -2405,6 +2448,28 @@ function resetZapRetryState() {
}
}
function setZapCompleted(completed) {
const sendButton = getZapSendButton();
if (!sendButton) {
return;
}
if (completed) {
delete sendButton.dataset.retryPending;
sendButton.dataset.completed = "true";
sendButton.textContent = "Done";
sendButton.setAttribute("aria-label", "Close zap dialog");
sendButton.title = "Close zap dialog";
} else {
delete sendButton.dataset.completed;
if (!sendButton.dataset.retryPending) {
sendButton.textContent = "Send";
sendButton.setAttribute("aria-label", "Send a zap");
sendButton.removeAttribute("title");
}
}
}
function markZapRetryPending(shares) {
const validShares = Array.isArray(shares)
? shares.filter((share) => share && share.amount > 0)
@@ -2786,6 +2851,7 @@ async function runZapAttempt({ amount, overrideFee = null, walletSettings }) {
function handleZapAmountChange() {
resetZapRetryState();
setZapCompleted(false);
updateZapSplitSummary();
}
@@ -2935,6 +3001,11 @@ async function handleZapSend(event) {
return;
}
if (sendButton.dataset.completed === "true") {
closeZapControls({ focusButton: true });
return;
}
if (!zapPopover || zapPopoverTrigger !== zapButton) {
setupZapButton({ force: true });
}
@@ -3096,6 +3167,7 @@ async function handleZapSend(event) {
setZapStatus(summary, "success");
app?.showSuccess?.("Zap sent successfully!");
resetZapRetryState();
setZapCompleted(true);
} catch (error) {
const tracker = Array.isArray(error?.__zapShareTracker)
? error.__zapShareTracker
@@ -3253,6 +3325,67 @@ function setupChannelShareButton() {
shareBtn.dataset.initialized = "true";
}
function resolveChannelHexForControls() {
const normalized = normalizeHex(currentChannelHex);
if (normalized) {
return normalized;
}
return decodeNpubToHex(currentChannelNpub);
}
function setupChannelMessageControls() {
const doc = typeof document !== "undefined" ? document : null;
const messageBtn = doc?.getElementById("channelMessageBtn") || null;
if (!messageBtn) {
return;
}
const channelHex = resolveChannelHexForControls();
if (!channelHex) {
devLogger.warn("[ChannelProfile] Unable to resolve channel hex for controls.");
}
if (messageBtn) {
if (messageBtn.dataset.initialized !== "true") {
messageBtn.addEventListener("click", () => {
const app = getApp();
const resolvedHex = resolveChannelHexForControls();
if (!resolvedHex) {
app?.showError?.("Unable to resolve channel messages.");
userLogger.warn("Channel message clicked without a valid channel hex.");
return;
}
const controller = app?.profileController;
if (!controller) {
app?.showError?.("Messages are unavailable right now.");
userLogger.warn(
"Profile controller unavailable for channel messages."
);
return;
}
if (typeof controller.handleActiveDmIdentityChanged === "function") {
controller.handleActiveDmIdentityChanged(resolvedHex);
} else {
userLogger.warn(
"Profile controller missing handleActiveDmIdentityChanged."
);
}
if (typeof controller.show === "function") {
controller.show("messages");
} else {
app?.showError?.("Messages are unavailable right now.");
userLogger.warn("Profile controller missing show() for messages.");
}
});
messageBtn.dataset.initialized = "true";
}
}
}
function setupChannelMoreMenu() {
const doc = typeof document !== "undefined" ? document : null;
const moreBtn = doc?.getElementById("channelMoreBtn") || null;
@@ -3627,6 +3760,7 @@ export async function initChannelProfileView() {
updateChannelModerationVisuals({ app, pubkey: currentChannelHex });
setupChannelShareButton();
setupChannelMessageControls();
setupChannelMoreMenu();
const initialMenuRefresh = updateChannelMenuState().catch((error) => {
@@ -4013,11 +4147,7 @@ function renderSubscribeButton(channelHex) {
return;
}
try {
if (alreadySubscribed) {
await subscriptions.removeChannel(channelHex, currentApp.pubkey);
} else {
await subscriptions.addChannel(channelHex, currentApp.pubkey);
}
await subscriptions.toggleChannel(channelHex, currentApp.pubkey);
// Re-render the button so it toggles state
renderSubscribeButton(channelHex);
} catch (err) {
+2
View File
@@ -41,6 +41,8 @@ import {
ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS,
} from "../config/instance-config.js";
export const WATCH_HISTORY_VERSION_TAG_VALUE = "2";
export const isDevMode = Boolean(IS_DEV_MODE);
export { IS_DEV_MODE };
+1
View File
@@ -8,6 +8,7 @@ export {
export {
createDedupeByRootStage,
createBlacklistFilterStage,
createTagPreferenceFilterStage,
createWatchHistorySuppressionStage,
createModerationStage,
createResolvePostedAtStage,
+226 -23
View File
@@ -10,10 +10,25 @@ import nostrService from "../services/nostrService.js";
import moderationService from "../services/moderationService.js";
import logger from "../utils/logger.js";
import { dedupeToNewestByRoot } from "../utils/videoDeduper.js";
import { isPlainObject, toSet, hasDisinterestedTag } from "./utils.js";
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
import { isPlainObject, toSet } from "./utils.js";
const FEED_HIDE_BYPASS_NAMES = new Set(["home", "recent"]);
function normalizeTagSet(values) {
const normalized = new Set();
const source = toSet(values);
for (const value of source) {
const tag = normalizeHashtag(value);
if (tag) {
normalized.add(tag);
}
}
return normalized;
}
function resolveDedupeFunction(customDedupe) {
if (typeof customDedupe === "function") {
return customDedupe;
@@ -258,6 +273,107 @@ export function createResolvePostedAtStage({
};
}
export function createTagPreferenceFilterStage({
stageName = "tag-preference-filter",
} = {}) {
return async function tagPreferenceFilterStage(items = [], context = {}) {
const tagPreferences = context?.runtime?.tagPreferences;
const interests = normalizeTagSet(tagPreferences?.interests);
const disinterests = normalizeTagSet(tagPreferences?.disinterests);
if (!interests.size && !disinterests.size) {
return items;
}
const results = [];
for (const item of items) {
const video = item?.video;
if (!video || typeof video !== "object") {
results.push(item);
continue;
}
const videoTags = new Set();
if (Array.isArray(video.tags)) {
for (const tag of video.tags) {
if (Array.isArray(tag) && tag[0] === "t" && typeof tag[1] === "string") {
const normalized = normalizeHashtag(tag[1]);
if (normalized) {
videoTags.add(normalized);
}
}
}
}
if (Array.isArray(video.nip71?.hashtags)) {
for (const tag of video.nip71.hashtags) {
if (typeof tag === "string") {
const normalized = normalizeHashtag(tag);
if (normalized) {
videoTags.add(normalized);
}
}
}
}
let disinterested = false;
if (disinterests.size) {
for (const tag of videoTags) {
if (disinterests.has(tag)) {
disinterested = true;
break;
}
}
}
if (disinterested) {
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "disinterested-tag",
videoId: typeof video.id === "string" ? video.id : null,
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
});
continue;
}
let matchedInterests = [];
if (interests.size) {
matchedInterests = [...videoTags].filter((tag) => interests.has(tag));
if (matchedInterests.length === 0) {
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "no-interest-match",
videoId: typeof video.id === "string" ? video.id : null,
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
});
continue;
}
}
if (matchedInterests.length > 0) {
if (!isPlainObject(item.metadata)) {
item.metadata = {};
}
item.metadata.matchedInterests = matchedInterests;
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "matched-interests",
videoId: typeof video.id === "string" ? video.id : null,
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
tags: matchedInterests,
});
}
results.push(item);
}
return results;
};
}
export function createBlacklistFilterStage({
stageName = "blacklist-filter",
shouldIncludeVideo,
@@ -276,10 +392,6 @@ export function createBlacklistFilterStage({
const options = { blacklistedEventIds: blacklist, isAuthorBlocked };
const tagPreferences = context?.runtime?.tagPreferences;
const disinterests = toSet(tagPreferences?.disinterests);
const hasTagPreferences = disinterests.size > 0;
const results = [];
for (const item of items) {
@@ -289,17 +401,6 @@ export function createBlacklistFilterStage({
continue;
}
if (hasTagPreferences && hasDisinterestedTag(video, disinterests)) {
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "disinterested-tag",
videoId: typeof video.id === "string" ? video.id : null,
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
});
continue;
}
let include = false;
try {
include = includeFn(video, options) !== false;
@@ -483,7 +584,7 @@ export function createModerationStage({
defaultValue: DEFAULT_BLUR_THRESHOLD,
fallbackValue: DEFAULT_BLUR_THRESHOLD,
});
const resolveMuteHideThreshold = createThresholdResolver(
const resolveMuteHideThresholdBase = createThresholdResolver(
trustedMuteHideThreshold,
{
runtimeKey: "trustedMuteHideThreshold",
@@ -492,6 +593,35 @@ export function createModerationStage({
allowInfinity: true,
},
);
const resolveMuteHideThreshold = (context, category) => {
const base = resolveMuteHideThresholdBase(context);
const normalizedCategory =
typeof category === "string" ? category.trim().toLowerCase() : "";
if (!normalizedCategory) {
return base;
}
const runtimeThresholds =
context?.runtime && typeof context.runtime === "object"
? context.runtime.moderationThresholds
: null;
const thresholdMap =
runtimeThresholds && typeof runtimeThresholds === "object"
? runtimeThresholds.trustedMuteHideThresholds
: null;
if (!thresholdMap || typeof thresholdMap !== "object") {
return base;
}
const override = thresholdMap[normalizedCategory];
if (Number.isFinite(override)) {
return Math.max(0, Math.floor(override));
}
return base;
};
const resolveReportHideThreshold = createThresholdResolver(
trustedReportHideThreshold,
{
@@ -529,7 +659,6 @@ export function createModerationStage({
const normalizedAutoplayThreshold = resolveAutoplayThreshold(context);
const normalizedBlurThreshold = resolveBlurThreshold(context);
const normalizedMuteHideThreshold = resolveMuteHideThreshold(context);
const normalizedReportHideThreshold = resolveReportHideThreshold(context);
const activeIds = new Set();
@@ -779,6 +908,9 @@ export function createModerationStage({
let trustedMuted = false;
let trustedMuters = [];
let trustedMuteCount = 0;
let trustedMuteCountTotal = 0;
let trustedMuteCategory = "";
let trustedMuteCountsByCategory = null;
let viewerMuted = false;
if (videoId) {
try {
@@ -839,6 +971,21 @@ export function createModerationStage({
});
}
}
if (
trustedMuted &&
typeof resolvedService.getTrustedMuteCountsForAuthor === "function"
) {
const summary = resolvedService.getTrustedMuteCountsForAuthor(authorHex);
if (summary && typeof summary === "object") {
trustedMuteCountTotal = Number.isFinite(summary.total)
? Math.max(0, Math.floor(summary.total))
: 0;
trustedMuteCountsByCategory =
summary.categories && typeof summary.categories === "object"
? { ...summary.categories }
: null;
}
}
if (typeof resolvedService.isAuthorMutedByViewer === "function") {
viewerMuted = resolvedService.isAuthorMutedByViewer(authorHex) === true;
}
@@ -851,20 +998,76 @@ export function createModerationStage({
}
if (trustedMuted) {
trustedMuteCount = Number.isFinite(video?.moderation?.trustedMuteCount)
trustedMuteCountTotal = Number.isFinite(video?.moderation?.trustedMuteCount)
? Math.max(0, Math.floor(video.moderation.trustedMuteCount))
: trustedMuters.length;
if (!trustedMuteCount) {
trustedMuteCount = trustedMuters.length;
: trustedMuteCountTotal || trustedMuters.length;
if (!trustedMuteCountTotal) {
trustedMuteCountTotal = trustedMuters.length;
}
if (trustedMuteCountTotal <= 0) {
trustedMuteCountTotal = Math.max(1, trustedMuters.length || 1);
}
trustedMuteCategory = normalizedReportType;
if (
trustedMuteCountsByCategory &&
typeof trustedMuteCountsByCategory === "object"
) {
const categoryCount =
trustedMuteCategory &&
Number.isFinite(trustedMuteCountsByCategory[trustedMuteCategory])
? Math.max(
0,
Math.floor(trustedMuteCountsByCategory[trustedMuteCategory]),
)
: null;
if (categoryCount !== null && categoryCount !== undefined) {
trustedMuteCount = categoryCount;
} else if (!trustedMuteCategory) {
let bestCategory = "";
let bestCount = 0;
for (const [category, count] of Object.entries(
trustedMuteCountsByCategory,
)) {
if (!Number.isFinite(count)) {
continue;
}
const normalizedCategory = category.trim().toLowerCase();
const normalizedCount = Math.max(0, Math.floor(count));
if (!normalizedCategory) {
continue;
}
if (normalizedCount > bestCount) {
bestCount = normalizedCount;
bestCategory = normalizedCategory;
}
}
if (bestCategory) {
trustedMuteCategory = bestCategory;
trustedMuteCount = bestCount;
} else {
trustedMuteCount = trustedMuteCountTotal;
}
} else {
trustedMuteCount = trustedMuteCountTotal;
}
} else {
trustedMuteCount = trustedMuteCountTotal;
}
if (trustedMuteCount <= 0) {
trustedMuteCount = Math.max(1, trustedMuters.length || 1);
trustedMuteCount = trustedMuteCountTotal || Math.max(1, trustedMuters.length || 1);
}
} else {
trustedMuteCount = 0;
trustedMuteCountTotal = 0;
trustedMuteCategory = "";
trustedMuters = [];
}
const normalizedMuteHideThreshold = resolveMuteHideThreshold(
context,
trustedMuteCategory,
);
const blockAutoplay =
trustedCount >= normalizedAutoplayThreshold || trustedMuted || viewerMuted;
const blurFromReports = trustedCount >= normalizedBlurThreshold;
+9 -2
View File
@@ -1,5 +1,7 @@
// js/feedEngine/utils.js
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
export function isPlainObject(value) {
if (value === null || typeof value !== "object") {
return false;
@@ -33,11 +35,16 @@ export function hasDisinterestedTag(video, disinterestsSet) {
return false;
}
const matchesDisinterest = (tag) => {
const normalized = normalizeHashtag(tag);
return Boolean(normalized && disinterestsSet.has(normalized));
};
// Check raw tags
if (Array.isArray(video.tags)) {
for (const tag of video.tags) {
if (Array.isArray(tag) && tag[0] === "t" && typeof tag[1] === "string") {
if (disinterestsSet.has(tag[1].toLowerCase())) {
if (matchesDisinterest(tag[1])) {
return true;
}
}
@@ -47,7 +54,7 @@ export function hasDisinterestedTag(video, disinterestsSet) {
// Check NIP-71 metadata hashtags if available
if (video.nip71 && Array.isArray(video.nip71.hashtags)) {
for (const tag of video.nip71.hashtags) {
if (typeof tag === "string" && disinterestsSet.has(tag.toLowerCase())) {
if (typeof tag === "string" && matchesDisinterest(tag)) {
return true;
}
}
+143
View File
@@ -0,0 +1,143 @@
import { getApplication } from "./applicationContext.js";
import { devLogger } from "./utils/logger.js";
const VIDEO_CARD_SELECTOR = "[data-component=\"video-card\"]";
const EMPTY_STATE_SELECTOR = "[data-for-you-empty-state]";
const LOADING_SELECTOR = ".sidebar-loading-wrapper";
class ForYouView {
constructor({ document: doc = document } = {}) {
this.document = doc;
this.container = null;
this.observer = null;
this.handleContainerClick = this.handleContainerClick.bind(this);
this.handleMutation = this.handleMutation.bind(this);
}
init() {
this.container = this.document?.getElementById("videoList") || null;
if (!this.container) {
return;
}
this.container.addEventListener("click", this.handleContainerClick);
this.observeContainer();
this.updateEmptyState();
}
observeContainer() {
if (!this.container || typeof MutationObserver === "undefined") {
return;
}
if (this.observer) {
this.observer.disconnect();
}
this.observer = new MutationObserver(this.handleMutation);
this.observer.observe(this.container, {
childList: true,
subtree: true,
});
}
handleMutation() {
this.updateEmptyState();
}
handleContainerClick(event) {
const target = event?.target instanceof HTMLElement ? event.target : null;
const actionButton = target?.closest?.("[data-action=\"open-hashtag-preferences\"]");
if (!(actionButton instanceof HTMLElement)) {
return;
}
event.preventDefault();
this.openHashtagPreferences();
}
openHashtagPreferences() {
const app = getApplication();
if (app?.profileController && typeof app.profileController.show === "function") {
app.profileController.show("hashtags");
return;
}
devLogger.warn("[ForYouView] Profile modal controller unavailable.");
}
updateEmptyState() {
if (!this.container) {
return;
}
const hasVideos = Boolean(this.container.querySelector(VIDEO_CARD_SELECTOR));
const isLoading = Boolean(this.container.querySelector(LOADING_SELECTOR));
const existingEmptyState = this.container.querySelector(EMPTY_STATE_SELECTOR);
if (isLoading) {
if (existingEmptyState) {
existingEmptyState.remove();
}
return;
}
if (hasVideos) {
if (existingEmptyState) {
existingEmptyState.remove();
}
return;
}
if (existingEmptyState) {
return;
}
const emptyState = this.buildEmptyState();
if (emptyState) {
this.container.innerHTML = "";
this.container.appendChild(emptyState);
}
}
buildEmptyState() {
if (!this.document) {
return null;
}
const wrapper = this.document.createElement("div");
wrapper.className = "col-span-full";
wrapper.dataset.forYouEmptyState = "true";
const card = this.document.createElement("div");
card.className = "card p-lg text-center";
const title = this.document.createElement("h3");
title.className = "text-lg font-semibold text-text";
title.textContent = "Your For You feed is empty";
const copy = this.document.createElement("p");
copy.className = "mt-2 text-sm text-muted";
copy.textContent =
"Add some interests to personalize your recommendations and start discovering videos.";
const button = this.document.createElement("button");
button.type = "button";
button.className = "btn focus-ring mt-4";
button.dataset.action = "open-hashtag-preferences";
button.textContent = "Add interests";
card.appendChild(title);
card.appendChild(copy);
card.appendChild(button);
wrapper.appendChild(card);
return wrapper;
}
}
export function initForYouView() {
const view = new ForYouView();
view.init();
return view;
}
+24 -2
View File
@@ -3,6 +3,11 @@ import { infoHashFromMagnet } from "./magnets.js";
import { updateVideoCardSourceVisibility } from "./utils/cardSourceVisibility.js";
import { TorrentClient, torrentClient } from "./webtorrent.js";
import { userLogger } from "./utils/logger.js";
import {
readTorrentProbeFromStorage,
removeTorrentProbeFromStorage,
writeTorrentProbeToStorage,
} from "./utils/storage.js";
const badgeUpdateListeners = new Set();
@@ -33,14 +38,29 @@ function getCacheEntry(key) {
if (!key) {
return null;
}
const entry = probeCache.get(key);
let entry = probeCache.get(key);
if (!entry) {
// Try storage if memory miss
entry = readTorrentProbeFromStorage(key);
}
if (!entry) {
return null;
}
// Verify freshness
if (now() - entry.ts > PROBE_CACHE_TTL_MS) {
probeCache.delete(key);
removeTorrentProbeFromStorage(key);
return null;
}
// Populate memory if it came from storage
if (!probeCache.has(key)) {
probeCache.set(key, entry);
}
return entry.value;
}
@@ -48,7 +68,9 @@ function setCacheEntry(key, value) {
if (!key) {
return;
}
probeCache.set(key, { ts: now(), value });
const entry = { ts: now(), value };
probeCache.set(key, entry);
writeTorrentProbeToStorage(key, entry);
}
function formatTime(ts) {
+103
View File
@@ -0,0 +1,103 @@
import { devLogger as defaultDevLogger, userLogger as defaultUserLogger } from "./utils/logger.js";
const createLoggerFacade = (logger, fallback) => {
if (logger && typeof logger === "object") {
return {
log: typeof logger.log === "function" ? logger.log.bind(logger) : fallback.log,
warn:
typeof logger.warn === "function" ? logger.warn.bind(logger) : fallback.warn,
error:
typeof logger.error === "function"
? logger.error.bind(logger)
: fallback.error,
};
}
return fallback;
};
const noopLogger = {
log() {},
warn() {},
error() {},
};
export function createHashChangeHandler({
getApplication,
getApplicationReady,
loadView,
viewInitRegistry,
devLogger = defaultDevLogger,
userLogger = defaultUserLogger,
} = {}) {
const devLog = createLoggerFacade(devLogger, noopLogger);
const userLog = createLoggerFacade(userLogger, noopLogger);
const getReadyPromise = () => {
if (typeof getApplicationReady === "function") {
return getApplicationReady();
}
return Promise.resolve();
};
const resolveIsLoggedIn = () => {
if (typeof getApplication !== "function") {
return false;
}
const app = getApplication();
if (!app || typeof app.isUserLoggedIn !== "function") {
return false;
}
return Boolean(app.isUserLoggedIn());
};
return async function handleHashChange() {
const currentHash =
typeof window !== "undefined" && window.location
? window.location.hash
: undefined;
devLog.log("handleHashChange called, current hash =", currentHash);
try {
await getReadyPromise();
} catch (error) {
userLog.warn(
"Proceeding with hash handling despite application initialization failure:",
error,
);
}
const hash =
typeof window !== "undefined" && window.location && window.location.hash
? window.location.hash
: "";
const match = hash.match(/^#view=([^&]+)/);
try {
if (!match || !match[1]) {
const defaultViewName = resolveIsLoggedIn()
? "for-you"
: "most-recent-videos";
await loadView(`views/${defaultViewName}.html`);
const initFn = viewInitRegistry?.[defaultViewName];
if (typeof initFn === "function") {
await initFn();
}
return;
}
const viewName = match[1];
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
}
const viewUrl = `views/${viewName}.html`;
await loadView(viewUrl);
const initFn = viewInitRegistry?.[viewName];
if (typeof initFn === "function") {
await initFn();
}
} catch (error) {
userLog.error("Failed to handle hash change:", error);
}
};
}
+33
View File
@@ -0,0 +1,33 @@
// js/hashView.js
const HASH_VIEW_REGEX = /^#view=([^&]+)/;
export function getHashViewName() {
const hash = typeof window !== "undefined" ? window.location.hash || "" : "";
const match = hash.match(HASH_VIEW_REGEX);
const viewName = match?.[1];
if (typeof viewName !== "string") {
return "";
}
return viewName.trim();
}
export function setHashView(viewName) {
if (typeof window === "undefined") {
return;
}
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("v");
const newUrl = url.pathname + url.search + `#view=${viewName}`;
window.history.replaceState({}, "", newUrl);
if (typeof HashChangeEvent === "function") {
window.dispatchEvent(new HashChangeEvent("hashchange"));
} else {
window.dispatchEvent(new Event("hashchange"));
}
}
-99
View File
@@ -1,99 +0,0 @@
import PQueue from "https://esm.sh/[email protected]";
import { trackerPing } from "./trackerPing.js";
import { infoHashFromMagnet } from "./magnets.js";
import { HEALTH_TTL_MS, CONCURRENCY } from "./trackerConfig.js";
import { userLogger } from "./utils/logger.js";
const queue = new PQueue({ concurrency: CONCURRENCY });
const cache = new Map();
const inflight = new Map();
export function getDefaultHealth() {
return {
ok: false,
seeders: 0,
leechers: 0,
responded: false,
from: [],
};
}
export function getHealthCached(infoHash) {
if (!infoHash) {
return null;
}
const entry = cache.get(infoHash);
if (!entry) {
return null;
}
if (Date.now() - entry.ts > HEALTH_TTL_MS) {
cache.delete(infoHash);
return null;
}
return entry.value;
}
export function setHealthCache(infoHash, value) {
if (!infoHash) {
return;
}
cache.set(infoHash, { ts: Date.now(), value });
}
export function queueHealthCheck(magnet, onResult) {
const infoHash = infoHashFromMagnet(magnet);
if (!infoHash) {
const fallback = getDefaultHealth();
if (typeof onResult === "function") {
onResult(fallback);
}
return Promise.resolve(fallback);
}
const cached = getHealthCached(infoHash);
if (cached) {
if (typeof onResult === "function") {
onResult(cached);
}
return Promise.resolve(cached);
}
if (inflight.has(infoHash)) {
const pending = inflight.get(infoHash);
if (typeof onResult === "function") {
pending.then(onResult).catch(() => {});
}
return pending;
}
const jobPromise = queue
.add(async () => {
try {
const health = await trackerPing(magnet);
setHealthCache(infoHash, health);
return health;
} catch (err) {
userLogger.warn("trackerPing failed", err);
return getDefaultHealth();
}
})
.finally(() => {
inflight.delete(infoHash);
});
inflight.set(infoHash, jobPromise);
if (typeof onResult === "function") {
jobPromise.then(onResult).catch(() => {});
}
return jobPromise;
}
export function purgeHealthCache() {
const now = Date.now();
Array.from(cache.keys()).forEach((infoHash) => {
const entry = cache.get(infoHash);
if (!entry || now - entry.ts > HEALTH_TTL_MS) {
cache.delete(infoHash);
}
});
}
-131
View File
@@ -26,8 +26,6 @@ export const WATCH_HISTORY_EMPTY_COPY =
export const WATCH_HISTORY_DISABLED_COPY =
"Watch history sync is unavailable. Connect a NIP-07 extension or log in to enable syncing.";
const WATCH_HISTORY_PRIVACY_DISMISSED_KEY =
"bitvid:watch-history:privacy-banner-dismissed";
const DEFAULT_WATCH_HISTORY_BATCH_SIZE = 12;
const WATCH_HISTORY_BATCH_SIZE = (() => {
const raw = Number(WATCH_HISTORY_BATCH_PAGE_SIZE);
@@ -1406,13 +1404,7 @@ export function createWatchHistoryRenderer(config = {}) {
sentinelSelector = "#watchHistorySentinel",
loadMoreSelector = "#watchHistoryLoadMore",
clearButtonSelector = '[data-history-action="clear-cache"]',
republishButtonSelector = '[data-history-action="republish"]',
refreshButtonSelector = '[data-history-action="refresh"]',
privacyBannerSelector = "#watchHistoryPrivacyBanner",
privacyMessageSelector = "#watchHistoryPrivacyMessage",
privacyToggleSelector = "#watchHistoryPrivacyToggle",
privacyDismissSelector = "#watchHistoryPrivacyDismiss",
infoSelector = "#watchHistoryInfo",
errorBannerSelector = "#watchHistoryError",
scrollContainerSelector = null,
featureBannerSelector = "#profileHistoryFeatureBanner",
@@ -1455,7 +1447,6 @@ export function createWatchHistoryRenderer(config = {}) {
lastError: null,
observer: null,
observerAttached: false,
privacyDismissed: false,
feedMetadata: null,
sessionFallbackActive: false,
syncEnabled,
@@ -1479,13 +1470,7 @@ export function createWatchHistoryRenderer(config = {}) {
sentinel: null,
loadMore: null,
clearButton: null,
republishButton: null,
refreshButton: null,
privacyBanner: null,
privacyMessage: null,
privacyToggle: null,
privacyDismiss: null,
info: null,
errorBanner: null,
scrollContainer: null,
featureBanner: null,
@@ -1495,9 +1480,7 @@ export function createWatchHistoryRenderer(config = {}) {
let boundGridClickHandler = null;
let boundLoadMoreHandler = null;
let boundClearHandler = null;
let boundRepublishHandler = null;
let boundRefreshHandler = null;
let boundPrivacyDismissHandler = null;
const subscriptions = new Set();
@@ -1512,13 +1495,7 @@ export function createWatchHistoryRenderer(config = {}) {
sentinel: resolveElement(sentinelSelector, scope),
loadMore: resolveElement(loadMoreSelector, scope),
clearButton: resolveElement(clearButtonSelector, scope),
republishButton: resolveElement(republishButtonSelector, scope),
refreshButton: resolveElement(refreshButtonSelector, scope),
privacyBanner: resolveElement(privacyBannerSelector, scope),
privacyMessage: resolveElement(privacyMessageSelector, scope),
privacyToggle: resolveElement(privacyToggleSelector, scope),
privacyDismiss: resolveElement(privacyDismissSelector, scope),
info: resolveElement(infoSelector, scope),
errorBanner: resolveElement(errorBannerSelector, scope),
scrollContainer: resolveElement(scrollContainerSelector, scope),
featureBanner: resolveElement(featureBannerSelector, scope),
@@ -1858,34 +1835,6 @@ export function createWatchHistoryRenderer(config = {}) {
}
}
function updatePrivacyBanner() {
if (!(elements.privacyBanner instanceof HTMLElement)) {
return;
}
const dismissed = state.privacyDismissed;
if (dismissed) {
setHidden(elements.privacyBanner, true);
return;
}
if (elements.privacyMessage) {
elements.privacyMessage.textContent =
"Watch history only stores the event IDs of videos you've played. Titles and thumbnails reload from relays when available.";
}
if (elements.privacyToggle) {
setHidden(elements.privacyToggle, true);
}
setHidden(elements.privacyBanner, false);
}
function updateInfoCallout() {
if (!(elements.info instanceof HTMLElement)) {
return;
}
const message =
"Watch history keeps a lean list of event IDs. Details load on demand from relays when you open this page. Nothing is published from this view.";
elements.info.textContent = message;
}
async function resolveActorKey() {
if (typeof getActor === "function") {
try {
@@ -2171,42 +2120,6 @@ export function createWatchHistoryRenderer(config = {}) {
elements.clearButton.addEventListener("click", boundClearHandler);
}
if (elements.republishButton) {
if (boundRepublishHandler) {
elements.republishButton.removeEventListener(
"click",
boundRepublishHandler
);
}
boundRepublishHandler = async (event) => {
event.preventDefault();
userLogger.info("[historyView] 'Republish now' clicked.");
const actor = await resolveActorKey();
try {
const payload = state.items.map((entry) => ({
...entry.pointer,
watchedAt: entry.watchedAt,
}));
await snapshot(payload, { actor, reason: "manual-republish" });
const app = getAppInstance();
if (typeof app?.showSuccess === "function") {
app.showSuccess("Watch history snapshot queued for publish.");
} else {
console.log("Watch history snapshot queued for publish.");
}
} catch (error) {
const app = getAppInstance();
if (typeof app?.showError === "function") {
app.showError("Failed to publish watch history. Try again later.");
} else {
console.error("Failed to publish watch history.");
}
userLogger.warn("[historyView] Republish failed:", error);
}
};
elements.republishButton.addEventListener("click", boundRepublishHandler);
}
if (elements.refreshButton) {
if (boundRefreshHandler) {
elements.refreshButton.removeEventListener("click", boundRefreshHandler);
@@ -2224,31 +2137,6 @@ export function createWatchHistoryRenderer(config = {}) {
}
}
function bindPrivacyControls() {
if (elements.privacyToggle) {
setHidden(elements.privacyToggle, true);
}
if (elements.privacyDismiss) {
if (boundPrivacyDismissHandler) {
elements.privacyDismiss.removeEventListener(
"click",
boundPrivacyDismissHandler
);
}
boundPrivacyDismissHandler = (event) => {
event.preventDefault();
state.privacyDismissed = true;
writePreference(WATCH_HISTORY_PRIVACY_DISMISSED_KEY, "true");
updatePrivacyBanner();
};
elements.privacyDismiss.addEventListener(
"click",
boundPrivacyDismissHandler
);
}
}
async function loadHistory({ force = false, actorOverride } = {}) {
if (state.isLoading) {
return false;
@@ -2386,13 +2274,8 @@ export function createWatchHistoryRenderer(config = {}) {
bindGridEvents();
bindLoadMore();
bindActions();
bindPrivacyControls();
subscribeToFingerprintUpdates();
subscribeToModerationEvents();
updateInfoCallout();
state.privacyDismissed =
readPreference(WATCH_HISTORY_PRIVACY_DISMISSED_KEY, "false") === "true";
updatePrivacyBanner();
state.initialized = true;
if (!state.featureEnabled) {
state.items = [];
@@ -2484,24 +2367,10 @@ export function createWatchHistoryRenderer(config = {}) {
elements.clearButton.removeEventListener("click", boundClearHandler);
boundClearHandler = null;
}
if (elements.republishButton && boundRepublishHandler) {
elements.republishButton.removeEventListener(
"click",
boundRepublishHandler
);
boundRepublishHandler = null;
}
if (elements.refreshButton && boundRefreshHandler) {
elements.refreshButton.removeEventListener("click", boundRefreshHandler);
boundRefreshHandler = null;
}
if (elements.privacyDismiss && boundPrivacyDismissHandler) {
elements.privacyDismiss.removeEventListener(
"click",
boundPrivacyDismissHandler
);
boundPrivacyDismissHandler = null;
}
clearSubscriptions();
if (elements.grid) {
elements.grid.innerHTML = "";
+137 -72
View File
@@ -3,7 +3,11 @@
import { validateInstanceConfig } from "../config/validate-config.js";
import { ASSET_VERSION } from "../config/asset-version.js";
import "./bufferPolyfill.js";
import { setApplication, setApplicationReady } from "./applicationContext.js";
import {
getApplication,
setApplication,
setApplicationReady,
} from "./applicationContext.js";
import nostrService from "./services/nostrService.js";
import r2Service from "./services/r2Service.js";
import { loadView, viewInitRegistry } from "./viewManager.js";
@@ -12,6 +16,7 @@ import {
initThemeController,
refreshThemeControls,
} from "./themeController.js";
import { setHashView } from "./hashView.js";
import { devLogger, userLogger } from "./utils/logger.js";
import {
prepareStaticModal,
@@ -28,6 +33,7 @@ import {
TIP_JAR_URL,
isLockdownMode,
} from "./config.js";
import { createHashChangeHandler } from "./hashChangeHandler.js";
import AuthService from "./services/authService.js";
import getAuthProvider, {
providers as authProviders,
@@ -685,23 +691,134 @@ async function bootstrapInterface() {
try {
await applicationReadyPromise;
} catch (error) {
userLogger.info("Search function is coming soon.");
// Continue even if app fails full initialization, if possible.
}
const input = document.getElementById("headerSearchInput");
const query = input && input.value ? input.value.trim() : "";
if (!query) {
return;
}
if (application && typeof application.showStatus === "function") {
try {
application.showStatus("Search function is coming soon.", {
autoHideMs: 3500,
showSpinner: false,
});
return;
} catch (error) {
// fall through to logger fallback
// Check if it's an npub
try {
if (
window.NostrTools &&
window.NostrTools.nip19 &&
query.startsWith("npub1")
) {
const decoded = window.NostrTools.nip19.decode(query);
if (decoded && decoded.type === "npub") {
setHashView(`channel-profile&npub=${encodeURIComponent(query)}`);
return;
}
}
} catch (error) {
// Not a valid npub, proceed to search
}
userLogger.info("Search function is coming soon.");
setHashView(`search&q=${encodeURIComponent(query)}`);
});
}
// Mobile Search Logic
const mobileSearchFab = document.getElementById("mobileSearchFab");
const mobileSearchContainer = document.getElementById(
"mobileSearchContainer",
);
const mobileSearchForm = document.getElementById("mobileSearchForm");
const mobileSearchInput = document.getElementById("mobileSearchInput");
if (mobileSearchFab && mobileSearchContainer) {
const openMobileSearch = () => {
mobileSearchFab.classList.add("hidden");
mobileSearchContainer.classList.remove("hidden");
if (mobileSearchInput) {
mobileSearchInput.focus();
}
};
const closeMobileSearch = () => {
mobileSearchContainer.classList.add("hidden");
mobileSearchFab.classList.remove("hidden");
};
mobileSearchFab.addEventListener("click", (e) => {
e.stopPropagation(); // Prevent document click from immediately closing it
openMobileSearch();
});
// Close when clicking outside
document.addEventListener("click", (event) => {
// If mobile search is not visible, do nothing
if (mobileSearchContainer.classList.contains("hidden")) {
return;
}
// If click is on the FAB, do nothing (handled by FAB listener)
if (mobileSearchFab.contains(event.target)) {
return;
}
// If click is inside the search container, do nothing
if (mobileSearchContainer.contains(event.target)) {
return;
}
// Otherwise, close it
closeMobileSearch();
});
// Prevent clicks inside the container from closing it (redundant with above check but safe)
mobileSearchContainer.addEventListener("click", (e) => {
e.stopPropagation();
});
}
if (mobileSearchForm) {
mobileSearchForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await applicationReadyPromise;
} catch (error) {
// Continue even if app fails full initialization, if possible.
}
const query =
mobileSearchInput && mobileSearchInput.value
? mobileSearchInput.value.trim()
: "";
if (!query) {
return;
}
// Check if it's an npub (Copy logic from headerSearchForm)
try {
if (
window.NostrTools &&
window.NostrTools.nip19 &&
query.startsWith("npub1")
) {
const decoded = window.NostrTools.nip19.decode(query);
if (decoded && decoded.type === "npub") {
setHashView(`channel-profile&npub=${encodeURIComponent(query)}`);
// Close search on submit
mobileSearchContainer.classList.add("hidden");
mobileSearchFab.classList.remove("hidden");
return;
}
}
} catch (error) {
// Not a valid npub, proceed to search
}
setHashView(`search&q=${encodeURIComponent(query)}`);
// Close search on submit
mobileSearchContainer.classList.add("hidden");
mobileSearchFab.classList.remove("hidden");
});
}
@@ -1145,23 +1262,6 @@ if (document.readyState === "loading") {
HELPER FUNCTIONS FOR QUERY AND HASH
-------------------------------------------- */
/**
* Sets the location.hash to "#view=<viewName>",
* removing any ?modal=... or ?v=... from the query string.
*/
export function setHashView(viewName) {
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("v");
const newUrl = url.pathname + url.search + `#view=${viewName}`;
window.history.replaceState({}, "", newUrl);
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
}
handleHashChange();
}
/**
* Sets a query param (e.g. ?modal=xxx or ?v=yyy),
* removing any "#view=..." from the hash to avoid collisions.
@@ -1262,46 +1362,11 @@ function handleQueryParams() {
}
}
async function handleHashChange() {
devLogger.log("handleHashChange called, current hash =", window.location.hash);
try {
await applicationReadyPromise;
} catch (error) {
userLogger.warn(
"Proceeding with hash handling despite application initialization failure:",
error
);
}
const hash = window.location.hash || "";
// Use a regex that captures up to the first ampersand or end of string.
// E.g. "#view=channel-profile&npub=..." => viewName = "channel-profile"
const match = hash.match(/^#view=([^&]+)/);
try {
if (!match || !match[1]) {
// No valid "#view=..." => default to "most-recent-videos"
await loadView("views/most-recent-videos.html");
const initFn = viewInitRegistry["most-recent-videos"];
if (typeof initFn === "function") {
await initFn();
}
return;
}
const viewName = match[1]; // only the chunk before any '&'
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
}
const viewUrl = `views/${viewName}.html`;
// Now dynamically load that partial, then call its init function
await loadView(viewUrl);
const initFn = viewInitRegistry[viewName];
if (typeof initFn === "function") {
await initFn();
}
} catch (error) {
userLogger.error("Failed to handle hash change:", error);
}
}
const handleHashChange = createHashChangeHandler({
getApplication,
getApplicationReady: () => applicationReadyPromise,
loadView,
viewInitRegistry,
devLogger,
userLogger,
});
+3 -1
View File
@@ -25,6 +25,8 @@ export function normalizeAndAugmentMagnet(
appProtocol,
} = {}
) {
const safeExtraTrackers = Array.isArray(extraTrackers) ? extraTrackers : [];
const {
initial,
canonicalValue,
@@ -49,7 +51,7 @@ export function normalizeAndAugmentMagnet(
let didChange = didMutate;
if (ensureTrackers(params, [...WSS_TRACKERS, ...extraTrackers])) {
if (ensureTrackers(params, [...WSS_TRACKERS, ...safeExtraTrackers])) {
didChange = true;
}
+124
View File
@@ -0,0 +1,124 @@
// js/nostr/adapters/nip07Adapter.js
import { normalizeActorKey } from "../watchHistory.js";
function resolveNip44Module(extension) {
const nip44 = extension?.nip44 && typeof extension.nip44 === "object" ? extension.nip44 : null;
if (!nip44) {
return null;
}
const v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
if (v2?.encrypt || v2?.decrypt) {
return v2;
}
return nip44;
}
export async function createNip07Adapter(extension) {
const resolvedExtension = extension || (typeof window !== "undefined" ? window?.nostr : null);
if (!resolvedExtension) {
return {
type: "nip07",
pubkey: "",
metadata: async () => null,
relays: async () => null,
signEvent: async () => {
throw new Error("NIP-07 extension unavailable.");
},
requestPermissions: async () => ({
ok: false,
error: new Error("extension-unavailable"),
}),
destroy: async () => {},
canSign: () => false,
capabilities: {
sign: false,
nip44: false,
nip04: false,
},
};
}
const getPublicKey =
typeof resolvedExtension.getPublicKey === "function"
? resolvedExtension.getPublicKey.bind(resolvedExtension)
: null;
const pubkey = getPublicKey ? normalizeActorKey(await getPublicKey()) : "";
const nip04 =
resolvedExtension.nip04 && typeof resolvedExtension.nip04 === "object"
? resolvedExtension.nip04
: null;
const nip44 = resolveNip44Module(resolvedExtension);
const nip04Encrypt =
nip04 && typeof nip04.encrypt === "function" ? nip04.encrypt.bind(nip04) : null;
const nip04Decrypt =
nip04 && typeof nip04.decrypt === "function" ? nip04.decrypt.bind(nip04) : null;
const nip44Encrypt =
nip44 && typeof nip44.encrypt === "function" ? nip44.encrypt.bind(nip44) : null;
const nip44Decrypt =
nip44 && typeof nip44.decrypt === "function" ? nip44.decrypt.bind(nip44) : null;
const requestPermissions = async (methods = []) => {
if (typeof resolvedExtension.requestPermissions === "function") {
return resolvedExtension.requestPermissions({
permissions: Array.isArray(methods) ? methods : [],
});
}
if (typeof resolvedExtension.enable === "function") {
if (Array.isArray(methods) && methods.length) {
return resolvedExtension.enable({ permissions: methods.map((method) => ({ method })) });
}
return resolvedExtension.enable();
}
return { ok: false, error: new Error("permission-request-unavailable") };
};
const signer = {
type: "nip07",
pubkey,
metadata: async () =>
typeof resolvedExtension.getMetadata === "function"
? resolvedExtension.getMetadata()
: null,
relays: async () =>
typeof resolvedExtension.getRelays === "function"
? resolvedExtension.getRelays()
: null,
signEvent: async (event) => {
if (typeof resolvedExtension.signEvent !== "function") {
throw new Error("NIP-07 extension missing signEvent.");
}
return resolvedExtension.signEvent(event);
},
requestPermissions,
destroy: async () => {},
canSign: () => typeof resolvedExtension.signEvent === "function",
capabilities: {
sign: typeof resolvedExtension.signEvent === "function",
nip44: Boolean(nip44Encrypt || nip44Decrypt),
nip04: Boolean(nip04Encrypt || nip04Decrypt),
},
};
if (nip04Encrypt) {
signer.nip04Encrypt = nip04Encrypt;
}
if (nip04Decrypt) {
signer.nip04Decrypt = nip04Decrypt;
}
if (nip44Encrypt) {
signer.nip44Encrypt = nip44Encrypt;
}
if (nip44Decrypt) {
signer.nip44Decrypt = nip44Decrypt;
}
return signer;
}
+88
View File
@@ -0,0 +1,88 @@
// js/nostr/adapters/nip46Adapter.js
import { Nip46RpcClient } from "../nip46Client.js";
import { normalizeActorKey } from "../watchHistory.js";
function resolveClient(client) {
if (client instanceof Nip46RpcClient) {
return client;
}
return client?.nip46Client instanceof Nip46RpcClient ? client.nip46Client : null;
}
export async function createNip46Adapter(client) {
const resolvedClient = resolveClient(client);
if (!resolvedClient) {
return {
type: "nip46",
pubkey: "",
metadata: async () => null,
relays: async () => null,
signEvent: async () => {
throw new Error("NIP-46 client unavailable.");
},
requestPermissions: async () => ({
ok: false,
error: new Error("client-unavailable"),
}),
destroy: async () => {},
canSign: () => false,
capabilities: {
sign: false,
nip44: false,
nip04: false,
},
};
}
let pubkey = normalizeActorKey(resolvedClient.userPubkey);
if (!pubkey) {
pubkey = normalizeActorKey(await resolvedClient.getUserPubkey());
}
const signer = {
type: "nip46",
pubkey,
metadata: async () =>
resolvedClient.metadata && typeof resolvedClient.metadata === "object"
? { ...resolvedClient.metadata }
: null,
relays: async () => (Array.isArray(resolvedClient.relays) ? [...resolvedClient.relays] : null),
signEvent: async (event) => resolvedClient.signEvent(event),
requestPermissions: async () => ({ ok: true }),
destroy: async () => {
resolvedClient.destroy();
},
canSign: () =>
!resolvedClient.destroyed && typeof resolvedClient.signEvent === "function",
capabilities: {
sign:
!resolvedClient.destroyed &&
typeof resolvedClient.signEvent === "function",
nip44: false,
nip04: false,
},
};
if (typeof resolvedClient.nip04Encrypt === "function") {
signer.nip04Encrypt = resolvedClient.nip04Encrypt.bind(resolvedClient);
}
if (typeof resolvedClient.nip04Decrypt === "function") {
signer.nip04Decrypt = resolvedClient.nip04Decrypt.bind(resolvedClient);
}
if (typeof resolvedClient.nip44Encrypt === "function") {
signer.nip44Encrypt = resolvedClient.nip44Encrypt.bind(resolvedClient);
}
if (typeof resolvedClient.nip44Decrypt === "function") {
signer.nip44Decrypt = resolvedClient.nip44Decrypt.bind(resolvedClient);
}
signer.capabilities.nip04 = Boolean(
signer.nip04Encrypt || signer.nip04Decrypt
);
signer.capabilities.nip44 = Boolean(
signer.nip44Encrypt || signer.nip44Decrypt
);
return signer;
}
+90
View File
@@ -0,0 +1,90 @@
// js/nostr/adapters/nsecAdapter.js
import { signEventWithPrivateKey } from "../publishHelpers.js";
import { createPrivateKeyCipherClosures } from "../signerHelpers.js";
import { ensureNostrTools, getCachedNostrTools } from "../toolkit.js";
import { normalizeActorKey } from "../watchHistory.js";
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
function normalizePrivateKey(privateKey) {
if (typeof privateKey !== "string") {
return "";
}
const trimmed = privateKey.trim();
if (!trimmed || !HEX64_REGEX.test(trimmed)) {
return "";
}
return trimmed.toLowerCase();
}
async function resolvePublicKey(privateKey, pubkey) {
const normalizedPubkey = normalizeActorKey(pubkey);
if (normalizedPubkey && HEX64_REGEX.test(normalizedPubkey)) {
return normalizedPubkey;
}
if (!privateKey) {
return "";
}
const tools = (await ensureNostrTools()) || getCachedNostrTools();
if (tools?.getPublicKey && typeof tools.getPublicKey === "function") {
const derived = tools.getPublicKey(privateKey);
const normalized = normalizeActorKey(derived);
if (normalized && HEX64_REGEX.test(normalized)) {
return normalized;
}
}
return "";
}
export async function createNsecAdapter({ privateKey, pubkey } = {}) {
const normalizedPrivateKey = normalizePrivateKey(privateKey);
const resolvedPubkey = await resolvePublicKey(normalizedPrivateKey, pubkey);
const cipherClosures = await createPrivateKeyCipherClosures(normalizedPrivateKey);
const signer = {
type: "nsec",
pubkey: resolvedPubkey,
metadata: async () => null,
relays: async () => null,
signEvent: async (event) => {
if (!normalizedPrivateKey) {
throw new Error("Missing private key for signing.");
}
return signEventWithPrivateKey(event, normalizedPrivateKey);
},
requestPermissions: async () => ({ ok: true }),
destroy: async () => {},
canSign: () => Boolean(normalizedPrivateKey),
capabilities: {
sign: Boolean(normalizedPrivateKey),
nip44: false,
nip04: false,
},
};
if (typeof cipherClosures.nip04Encrypt === "function") {
signer.nip04Encrypt = cipherClosures.nip04Encrypt;
}
if (typeof cipherClosures.nip04Decrypt === "function") {
signer.nip04Decrypt = cipherClosures.nip04Decrypt;
}
if (typeof cipherClosures.nip44Encrypt === "function") {
signer.nip44Encrypt = cipherClosures.nip44Encrypt;
}
if (typeof cipherClosures.nip44Decrypt === "function") {
signer.nip44Decrypt = cipherClosures.nip44Decrypt;
}
signer.capabilities.nip04 = Boolean(
signer.nip04Encrypt || signer.nip04Decrypt
);
signer.capabilities.nip44 = Boolean(
signer.nip44Encrypt || signer.nip44Decrypt
);
return signer;
}
+96
View File
@@ -0,0 +1,96 @@
import { NOTE_TYPES } from "../nostrEventSchemas.js";
import { WATCH_HISTORY_LIST_IDENTIFIER } from "../config.js";
export const STORAGE_TIERS = Object.freeze({
MEMORY: "memory",
INDEXED_DB: "indexedDB",
LOCAL_STORAGE: "localStorage",
});
export const MERGE_STRATEGIES = Object.freeze({
REPLACEABLE: "replaceable",
APPEND_ONLY: "append-only",
});
export const CACHE_POLICIES = Object.freeze({
[NOTE_TYPES.VIDEO_POST]: {
storage: STORAGE_TIERS.INDEXED_DB,
ttl: 10 * 60 * 1000, // 10 minutes
addressing: "kind:pubkey:d",
merge: MERGE_STRATEGIES.REPLACEABLE,
},
[NOTE_TYPES.WATCH_HISTORY]: {
storage: STORAGE_TIERS.LOCAL_STORAGE,
ttl: 24 * 60 * 60 * 1000, // 24 hours
addressing: "kind:pubkey:d",
defaultDTag: WATCH_HISTORY_LIST_IDENTIFIER,
merge: MERGE_STRATEGIES.APPEND_ONLY, // Bucketed append
},
[NOTE_TYPES.SUBSCRIPTION_LIST]: {
storage: STORAGE_TIERS.LOCAL_STORAGE,
ttl: Infinity, // Manual management
addressing: "kind:pubkey:d",
merge: MERGE_STRATEGIES.REPLACEABLE,
},
[NOTE_TYPES.USER_BLOCK_LIST]: {
storage: STORAGE_TIERS.LOCAL_STORAGE,
ttl: Infinity, // Manual management
addressing: "kind:pubkey:d",
merge: MERGE_STRATEGIES.REPLACEABLE,
},
[NOTE_TYPES.RELAY_LIST]: {
storage: STORAGE_TIERS.LOCAL_STORAGE,
ttl: Infinity, // Manual management
addressing: "kind:pubkey",
merge: MERGE_STRATEGIES.REPLACEABLE,
},
[NOTE_TYPES.ADMIN_MODERATION_LIST]: {
storage: STORAGE_TIERS.LOCAL_STORAGE,
ttl: Infinity,
addressing: "kind:pubkey:d",
merge: MERGE_STRATEGIES.REPLACEABLE,
},
[NOTE_TYPES.ADMIN_BLACKLIST]: {
storage: STORAGE_TIERS.LOCAL_STORAGE,
ttl: Infinity,
addressing: "kind:pubkey:d",
merge: MERGE_STRATEGIES.REPLACEABLE,
},
[NOTE_TYPES.ADMIN_WHITELIST]: {
storage: STORAGE_TIERS.LOCAL_STORAGE,
ttl: Infinity,
addressing: "kind:pubkey:d",
merge: MERGE_STRATEGIES.REPLACEABLE,
},
[NOTE_TYPES.VIDEO_COMMENT]: {
storage: STORAGE_TIERS.MEMORY,
ttl: 5 * 60 * 1000, // 5 minutes
addressing: "id",
merge: MERGE_STRATEGIES.APPEND_ONLY,
},
[NOTE_TYPES.VIDEO_REACTION]: {
storage: STORAGE_TIERS.MEMORY,
ttl: 5 * 60 * 1000, // 5 minutes
addressing: "id",
merge: MERGE_STRATEGIES.APPEND_ONLY,
},
[NOTE_TYPES.VIEW_EVENT]: {
storage: STORAGE_TIERS.MEMORY,
ttl: 0,
addressing: "id",
merge: MERGE_STRATEGIES.APPEND_ONLY,
},
[NOTE_TYPES.HASHTAG_PREFERENCES]: {
storage: STORAGE_TIERS.LOCAL_STORAGE,
ttl: Infinity,
addressing: "kind:pubkey:d",
defaultDTag: "bitvid:tag-preferences",
merge: MERGE_STRATEGIES.REPLACEABLE,
},
[NOTE_TYPES.PROFILE_METADATA]: {
storage: STORAGE_TIERS.LOCAL_STORAGE,
ttl: Infinity,
addressing: "kind:pubkey",
merge: MERGE_STRATEGIES.REPLACEABLE,
},
});
+449 -268
View File
File diff suppressed because it is too large. Load diff
+109 -17
View File
@@ -8,7 +8,16 @@ import { publishEventToRelay } from "../nostrPublish.js";
import { RELAY_URLS } from "./toolkit.js";
import { normalizePointerInput } from "./watchHistory.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { LRUCache } from "../utils/lruCache.js";
import { CACHE_POLICIES } from "./cachePolicies.js";
import { isSessionActor } from "./sessionActor.js";
import { queueSignEvent } from "./signRequestQueue.js";
import { getActiveSigner } from "../nostrClientRegistry.js";
const COMMENT_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_COMMENT];
const commentCache = new LRUCache({ maxSize: 100 });
export const COMMENT_EVENT_KIND = Number.isFinite(COMMENT_EVENT_SCHEMA?.kind)
? COMMENT_EVENT_SCHEMA.kind
: 1111;
@@ -1099,7 +1108,6 @@ export async function publishComment(
targetInput,
options = {},
{
resolveActiveSigner,
shouldRequestExtensionPermissions,
DEFAULT_NIP07_PERMISSION_METHODS,
} = {},
@@ -1108,6 +1116,14 @@ export async function publishComment(
return { ok: false, error: "nostr-uninitialized" };
}
if (isSessionActor(client)) {
const error = new Error(
"Publishing comments is not allowed for session actors."
);
error.code = "session-actor-publish-blocked";
return { ok: false, error };
}
const descriptor = normalizeCommentTarget(targetInput, options);
if (!descriptor) {
return { ok: false, error: "invalid-target" };
@@ -1173,12 +1189,19 @@ export async function publishComment(
let signedEvent = null;
const resolveSignerFn =
typeof resolveActiveSigner === "function" ? resolveActiveSigner : null;
const signer = resolveSignerFn ? resolveSignerFn(actorPubkey) : null;
const signer = getActiveSigner();
if (!signer || typeof signer.signEvent !== "function") {
return { ok: false, error: "auth-required" };
const error = new Error(
"Login required: an active signer is needed to publish comments."
);
error.code = "auth-required";
return {
ok: false,
error: "auth-required",
message: error.message,
details: error,
};
}
let permissionResult = { ok: true };
@@ -1206,7 +1229,9 @@ export async function publishComment(
}
try {
signedEvent = await signer.signEvent(event);
signedEvent = await queueSignEvent(signer, event, {
timeoutMs: options?.timeoutMs,
});
} catch (error) {
userLogger.warn(
"[nostr] Failed to sign comment event with active signer:",
@@ -1272,25 +1297,81 @@ export async function listVideoComments(client, targetInput, options = {}) {
}
let descriptor;
let filters;
let filterTemplate;
try {
({ descriptor, filters } = createVideoCommentFilters(targetInput, options));
// createVideoCommentFilters returns { descriptor, filters: [...] }
const result = createVideoCommentFilters(targetInput, options);
descriptor = result.descriptor;
// We assume the first filter is the primary one we want to augment with 'since'
// or use in per-relay logic. For now, we use the full array.
filterTemplate = result.filters;
} catch (error) {
devLogger.warn("[nostr] Failed to build comment filters:", error);
return [];
}
const relayList = sanitizeRelayList(options.relays, client.relays);
const cacheKey = descriptor.videoEventId;
const cached = commentCache.get(cacheKey);
const ttl = CACHE_POLICY.ttl;
const now = Date.now();
const forceRefresh = options?.forceRefresh === true;
let rawResults;
try {
rawResults = await pool.list(relayList, filters);
} catch (error) {
devLogger.warn("[nostr] Failed to list video comments:", error);
return [];
if (cached && !forceRefresh && (now - cached.fetchedAt < ttl)) {
devLogger.debug(`[nostr] Comments cache hit for ${cacheKey}`);
return cached.items.filter((event) =>
isVideoCommentEvent(event, descriptor),
);
}
const flattened = flattenListResults(rawResults);
if (cached) {
devLogger.debug(`[nostr] Comments cache stale for ${cacheKey}, refreshing...`);
} else {
devLogger.debug(`[nostr] Comments cache miss for ${cacheKey}`);
}
const relayList = sanitizeRelayList(options.relays, client.relays);
const lastSeens = cached?.lastSeenPerRelay || {};
const mergedLastSeens = { ...lastSeens };
const rawResults = [];
// Parallel fetch from relays with incremental logic
await Promise.all(
relayList.map(async (url) => {
if (!url) return;
const lastSeen = lastSeens[url] || 0;
// Deep clone filters to apply 'since' safely per relay
const relayFilters = filterTemplate.map(f => {
const copy = { ...f };
if (lastSeen > 0) {
copy.since = lastSeen + 1;
}
return copy;
});
try {
const events = await pool.list([url], relayFilters);
let maxCreated = lastSeen;
if (Array.isArray(events)) {
for (const ev of events) {
rawResults.push(ev);
if (ev.created_at > maxCreated) {
maxCreated = ev.created_at;
}
}
}
if (maxCreated > lastSeen) {
mergedLastSeens[url] = maxCreated;
}
} catch (err) {
devLogger.warn(`[nostr] Failed to fetch comments from ${url}:`, err);
}
})
);
// If we have cached items, add them to the raw list for deduplication/merging
const combinedRaw = cached ? [...cached.items, ...rawResults] : rawResults;
const flattened = flattenListResults(combinedRaw);
const dedupe = new Map();
const order = [];
@@ -1323,7 +1404,7 @@ export async function listVideoComments(client, targetInput, options = {}) {
}
}
return order
const finalItems = order
.map((entry) => {
if (!entry) {
return null;
@@ -1337,6 +1418,17 @@ export async function listVideoComments(client, targetInput, options = {}) {
return null;
})
.filter(Boolean);
// Update cache
if (cacheKey) {
commentCache.set(cacheKey, {
items: finalItems,
lastSeenPerRelay: mergedLastSeens,
fetchedAt: Date.now()
});
}
return finalItems;
}
export function subscribeVideoComments(client, targetInput, options = {}) {
+279 -159
View File
@@ -12,6 +12,7 @@ import {
assertAnyRelayAccepted as defaultAssertAnyRelayAccepted,
} from "../nostrPublish.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { Nip46RequestQueue, NIP46_PRIORITY } from "./nip46Queue.js";
export const HEX64_REGEX = /^[0-9a-f]{64}$/i;
@@ -95,10 +96,6 @@ export function sanitizeStoredNip46Session(candidate) {
return null;
}
const clientPrivateKey =
typeof candidate.clientPrivateKey === "string" && HEX64_REGEX.test(candidate.clientPrivateKey)
? candidate.clientPrivateKey.toLowerCase()
: "";
const clientPublicKey =
typeof candidate.clientPublicKey === "string" && candidate.clientPublicKey.trim()
? candidate.clientPublicKey.trim().toLowerCase()
@@ -108,7 +105,7 @@ export function sanitizeStoredNip46Session(candidate) {
? candidate.remotePubkey.trim().toLowerCase()
: "";
if (!clientPrivateKey || !remotePubkey) {
if (!remotePubkey) {
return null;
}
@@ -138,7 +135,6 @@ export function sanitizeStoredNip46Session(candidate) {
return {
version: 1,
clientPrivateKey,
clientPublicKey,
remotePubkey,
relays,
@@ -149,10 +145,6 @@ export function sanitizeStoredNip46Session(candidate) {
? candidate.algorithm
: "",
),
secret:
typeof candidate.secret === "string" && candidate.secret.trim()
? candidate.secret.trim()
: "",
permissions:
typeof candidate.permissions === "string" && candidate.permissions.trim()
? candidate.permissions.trim()
@@ -187,7 +179,15 @@ export function readStoredNip46Session() {
try {
const parsed = JSON.parse(raw);
return sanitizeStoredNip46Session(parsed);
const sanitized = sanitizeStoredNip46Session(parsed);
if (
sanitized &&
(typeof parsed?.clientPrivateKey === "string" ||
typeof parsed?.secret === "string")
) {
writeStoredNip46Session(sanitized);
}
return sanitized;
} catch (error) {
try {
storage.removeItem(NIP46_SESSION_STORAGE_KEY);
@@ -207,7 +207,21 @@ export function writeStoredNip46Session(payload) {
return;
}
const normalized = sanitizeStoredNip46Session(payload);
const sanitizedInput =
payload && typeof payload === "object"
? {
version: payload.version,
clientPublicKey: payload.clientPublicKey,
remotePubkey: payload.remotePubkey,
relays: payload.relays,
encryption: payload.encryption,
permissions: payload.permissions,
metadata: payload.metadata,
userPubkey: payload.userPubkey,
lastConnectedAt: payload.lastConnectedAt,
}
: payload;
const normalized = sanitizeStoredNip46Session(sanitizedInput);
if (!normalized) {
try {
storage.removeItem(NIP46_SESSION_STORAGE_KEY);
@@ -1387,6 +1401,7 @@ export class Nip46RpcClient {
this.lastSeen = 0;
this.userPubkey = "";
this.activeSignerCache = null;
this.requestQueue = new Nip46RequestQueue();
}
get pool() {
@@ -1620,6 +1635,7 @@ export class Nip46RpcClient {
error: error?.message || String(error),
});
}
this.requestQueue.clear(error);
for (const [id, pending] of this.pendingRequests.entries()) {
clearTimeout(pending.timeoutId);
try {
@@ -1636,173 +1652,207 @@ export class Nip46RpcClient {
throw new Error("Remote signer session has been disposed.");
}
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
? options.timeoutMs
: NIP46_RESPONSE_TIMEOUT_MS;
const retries = Number.isFinite(options.retries) && options.retries >= 0
? options.retries
: NIP46_MAX_RETRIES;
const priority = Number.isFinite(options.priority)
? options.priority
: NIP46_PRIORITY.NORMAL;
let lastError = null;
return this.requestQueue.enqueue(async () => {
const timeoutMs =
Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
? options.timeoutMs
: NIP46_RESPONSE_TIMEOUT_MS;
const retries =
Number.isFinite(options.retries) && options.retries >= 0
? options.retries
: NIP46_MAX_RETRIES;
devLogger.debug("[nostr] Remote signer RPC start", {
method,
remotePubkey: summarizeHexForLog(this.remotePubkey),
timeoutMs,
retries,
params: summarizeRpcParamsForLog(method, params),
});
let lastError = null;
for (let attempt = 0; attempt <= retries; attempt += 1) {
await this.ensureSubscription();
const requestId = createNip46RequestId();
const message = {
id: requestId,
devLogger.debug("[nostr] Remote signer RPC start", {
method,
params: Array.isArray(params) ? params : [],
};
devLogger.debug("[nostr] Remote signer RPC attempt prepared", {
method,
attempt: attempt + 1,
requestId,
remotePubkey: summarizeHexForLog(this.remotePubkey),
timeoutMs,
retries,
params: summarizeRpcParamsForLog(method, params),
priority,
});
let event;
try {
const content = await this.encryptPayload(message, {
method,
requestId,
});
if (!this.signEventWithKey) {
throw new Error("Remote signer signing helper is unavailable.");
}
event = this.signEventWithKey(
{
kind: NIP46_RPC_KIND,
pubkey: this.clientPublicKey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", this.remotePubkey]],
content,
},
this.clientPrivateKey,
);
devLogger.debug("[nostr] Remote signer RPC event signed", {
method,
attempt: attempt + 1,
requestId,
relayCount: this.relays.length,
contentLength: typeof event.content === "string" ? event.content.length : 0,
});
} catch (error) {
lastError = error;
devLogger.warn("[nostr] Remote signer RPC encryption failed", {
method,
attempt: attempt + 1,
requestId,
error: error?.message || String(error),
});
break;
}
for (let attempt = 0; attempt <= retries; attempt += 1) {
await this.ensureSubscription();
const responsePromise = new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingRequests.delete(requestId);
const timeoutError = new Error(
`Timed out waiting for remote signer response to ${method}.`,
const requestId = createNip46RequestId();
const message = {
id: requestId,
method,
params: Array.isArray(params) ? params : [],
};
devLogger.debug("[nostr] Remote signer RPC attempt prepared", {
method,
attempt: attempt + 1,
requestId,
remotePubkey: summarizeHexForLog(this.remotePubkey),
});
let event;
try {
const content = await this.encryptPayload(message, {
method,
requestId,
});
if (!this.signEventWithKey) {
throw new Error("Remote signer signing helper is unavailable.");
}
event = this.signEventWithKey(
{
kind: NIP46_RPC_KIND,
pubkey: this.clientPublicKey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", this.remotePubkey]],
content,
},
this.clientPrivateKey,
);
timeoutError.code = "nip46-timeout";
devLogger.warn("[nostr] Remote signer RPC timed out", {
devLogger.debug("[nostr] Remote signer RPC event signed", {
method,
attempt: attempt + 1,
requestId,
relayCount: this.relays.length,
contentLength:
typeof event.content === "string" ? event.content.length : 0,
});
} catch (error) {
lastError = error;
devLogger.warn("[nostr] Remote signer RPC encryption failed", {
method,
attempt: attempt + 1,
requestId,
error: error?.message || String(error),
});
break;
}
const responsePromise = new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingRequests.delete(requestId);
const timeoutError = new Error(
`Timed out waiting for remote signer response to ${method}.`,
);
timeoutError.code = "nip46-timeout";
devLogger.warn("[nostr] Remote signer RPC timed out", {
method,
requestId,
attempt: attempt + 1,
timeoutMs,
});
reject(timeoutError);
}, timeoutMs);
this.pendingRequests.set(requestId, {
resolve,
reject,
timeoutId,
method,
});
devLogger.debug("[nostr] Remote signer RPC awaiting response", {
method,
requestId,
attempt: attempt + 1,
timeoutMs,
});
reject(timeoutError);
}, timeoutMs);
});
this.pendingRequests.set(requestId, {
resolve,
reject,
timeoutId,
method,
});
devLogger.debug("[nostr] Remote signer RPC awaiting response", {
method,
requestId,
attempt: attempt + 1,
});
});
try {
const publishResults = await this.publishEventToRelays(
await this.ensurePool(),
this.relays,
event,
{ timeoutMs: NIP46_PUBLISH_TIMEOUT_MS },
);
this.assertAnyRelayAccepted(publishResults, { context: method });
devLogger.debug("[nostr] Remote signer RPC published", {
method,
requestId,
attempt: attempt + 1,
publishResults: summarizeRelayPublishResultsForLog(publishResults),
});
} catch (error) {
const pending = this.pendingRequests.get(requestId);
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingRequests.delete(requestId);
}
lastError = error;
try {
const publishResults = await this.publishEventToRelays(
await this.ensurePool(),
this.relays,
event,
{ timeoutMs: NIP46_PUBLISH_TIMEOUT_MS },
);
this.assertAnyRelayAccepted(publishResults, { context: method });
devLogger.debug("[nostr] Remote signer RPC published", {
method,
requestId,
attempt: attempt + 1,
publishResults: summarizeRelayPublishResultsForLog(publishResults),
});
} catch (error) {
const pending = this.pendingRequests.get(requestId);
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingRequests.delete(requestId);
const isRateLimited =
error &&
(error.message?.includes("rate-limited") ||
error.message?.includes("noting too much"));
if (isRateLimited) {
devLogger.warn(
"[nostr] Remote signer RPC rate limited, backing off",
{
method,
requestId,
attempt: attempt + 1,
error: error?.message || String(error),
},
);
// Exponential backoff: 2s, 4s, 8s...
const backoffMs = 2000 * Math.pow(2, attempt);
await new Promise((r) => setTimeout(r, backoffMs));
} else {
devLogger.warn("[nostr] Remote signer RPC publish failed", {
method,
requestId,
attempt: attempt + 1,
error: error?.message || String(error),
});
}
continue;
}
lastError = error;
devLogger.warn("[nostr] Remote signer RPC publish failed", {
method,
requestId,
attempt: attempt + 1,
error: error?.message || String(error),
});
continue;
}
try {
const result = await responsePromise;
devLogger.debug("[nostr] Remote signer RPC response received", {
method,
requestId,
attempt: attempt + 1,
resultSummary: summarizeRpcResultForLog(method, result),
});
return result;
} catch (error) {
lastError = error;
devLogger.warn("[nostr] Remote signer RPC attempt rejected", {
method,
requestId,
attempt: attempt + 1,
error: error?.message || String(error),
});
if (error?.code === "auth-challenge") {
throw error;
try {
const result = await responsePromise;
devLogger.debug("[nostr] Remote signer RPC response received", {
method,
requestId,
attempt: attempt + 1,
resultSummary: summarizeRpcResultForLog(method, result),
});
return result;
} catch (error) {
lastError = error;
devLogger.warn("[nostr] Remote signer RPC attempt rejected", {
method,
requestId,
attempt: attempt + 1,
error: error?.message || String(error),
});
if (error?.code === "auth-challenge") {
throw error;
}
}
}
}
if (lastError) {
devLogger.warn("[nostr] Remote signer RPC exhausted", {
method,
remotePubkey: summarizeHexForLog(this.remotePubkey),
error: lastError?.message || String(lastError),
});
throw lastError;
}
if (lastError) {
devLogger.warn("[nostr] Remote signer RPC exhausted", {
method,
remotePubkey: summarizeHexForLog(this.remotePubkey),
error: lastError?.message || String(lastError),
});
throw lastError;
}
devLogger.warn("[nostr] Remote signer RPC failed without explicit error", {
method,
remotePubkey: summarizeHexForLog(this.remotePubkey),
});
throw new Error(`Remote signer request for ${method} failed.`);
devLogger.warn(
"[nostr] Remote signer RPC failed without explicit error",
{
method,
remotePubkey: summarizeHexForLog(this.remotePubkey),
},
);
throw new Error(`Remote signer request for ${method} failed.`);
}, priority);
}
async connect({ permissions } = {}) {
@@ -1819,11 +1869,15 @@ export class Nip46RpcClient {
const result = await this.sendRpc("connect", params, {
timeoutMs: Math.max(NIP46_RESPONSE_TIMEOUT_MS, 12_000),
retries: 0,
priority: NIP46_PRIORITY.HIGH,
});
if (this.secret) {
const normalizedResult = typeof result === "string" ? result.trim() : "";
if (!normalizedResult || normalizedResult !== this.secret) {
if (
!normalizedResult ||
(normalizedResult !== this.secret && normalizedResult.toLowerCase() !== "ack")
) {
const error = new Error("Remote signer secret mismatch. Rejecting connection.");
error.code = "nip46-secret-mismatch";
throw error;
@@ -1842,6 +1896,7 @@ export class Nip46RpcClient {
const result = await this.sendRpc("get_public_key", [], {
timeoutMs: NIP46_RESPONSE_TIMEOUT_MS,
retries: 0,
priority: NIP46_PRIORITY.HIGH,
});
const pubkey = typeof result === "string" ? result.trim() : "";
if (!pubkey) {
@@ -1862,6 +1917,7 @@ export class Nip46RpcClient {
const result = await this.sendRpc("ping", [], {
timeoutMs: 5000,
retries: 0,
priority: NIP46_PRIORITY.HIGH,
});
const ok = typeof result === "string" && result.trim().toLowerCase() === "pong";
devLogger.debug("[nostr] Remote signer ping result", {
@@ -1905,6 +1961,7 @@ export class Nip46RpcClient {
? options.timeoutMs
: NIP46_SIGN_EVENT_TIMEOUT_MS,
retries: Number.isFinite(options.retries) ? options.retries : NIP46_MAX_RETRIES,
priority: NIP46_PRIORITY.HIGH,
},
);
@@ -1941,6 +1998,58 @@ export class Nip46RpcClient {
}
}
async nip04Encrypt(pubkey, plaintext) {
if (!pubkey || !plaintext) {
throw new Error("Pubkey and plaintext are required for NIP-04 encryption.");
}
const result = await this.sendRpc("nip04_encrypt", [pubkey, plaintext], {
priority: NIP46_PRIORITY.LOW,
});
if (typeof result !== "string") {
throw new Error("Remote signer returned invalid NIP-04 ciphertext.");
}
return result;
}
async nip04Decrypt(pubkey, ciphertext) {
if (!pubkey || !ciphertext) {
throw new Error("Pubkey and ciphertext are required for NIP-04 decryption.");
}
const result = await this.sendRpc("nip04_decrypt", [pubkey, ciphertext], {
priority: NIP46_PRIORITY.LOW,
});
if (typeof result !== "string") {
throw new Error("Remote signer returned invalid NIP-04 plaintext.");
}
return result;
}
async nip44Encrypt(pubkey, plaintext) {
if (!pubkey || !plaintext) {
throw new Error("Pubkey and plaintext are required for NIP-44 encryption.");
}
const result = await this.sendRpc("nip44_encrypt", [pubkey, plaintext], {
priority: NIP46_PRIORITY.LOW,
});
if (typeof result !== "string") {
throw new Error("Remote signer returned invalid NIP-44 ciphertext.");
}
return result;
}
async nip44Decrypt(pubkey, ciphertext) {
if (!pubkey || !ciphertext) {
throw new Error("Pubkey and ciphertext are required for NIP-44 decryption.");
}
const result = await this.sendRpc("nip44_decrypt", [pubkey, ciphertext], {
priority: NIP46_PRIORITY.LOW,
});
if (typeof result !== "string") {
throw new Error("Remote signer returned invalid NIP-44 plaintext.");
}
return result;
}
getActiveSigner() {
if (!this.userPubkey) {
return null;
@@ -1951,6 +2060,14 @@ export class Nip46RpcClient {
type: "nip46",
pubkey: this.userPubkey,
signEvent: (event) => this.signEvent(event),
nip04: {
encrypt: (pubkey, plaintext) => this.nip04Encrypt(pubkey, plaintext),
decrypt: (pubkey, ciphertext) => this.nip04Decrypt(pubkey, ciphertext),
},
nip44: {
encrypt: (pubkey, plaintext) => this.nip44Encrypt(pubkey, plaintext),
decrypt: (pubkey, ciphertext) => this.nip44Decrypt(pubkey, ciphertext),
},
};
}
@@ -1978,6 +2095,9 @@ export class Nip46RpcClient {
}
this.subscription = null;
if (this.requestQueue) {
this.requestQueue.clear();
}
this.rejectAllPending(new Error("Remote signer session closed."));
}
}
+88
View File
@@ -0,0 +1,88 @@
// js/nostr/nip46Queue.js
import { devLogger } from "../utils/logger.js";
export const NIP46_PRIORITY = Object.freeze({
HIGH: 10,
NORMAL: 5,
LOW: 1,
});
export class Nip46RequestQueue {
constructor({ minDelayMs = 250 } = {}) {
this.minDelayMs = minDelayMs;
this.queue = [];
this.running = false;
this.lastRunTime = 0;
}
enqueue(task, priority = NIP46_PRIORITY.NORMAL) {
return new Promise((resolve, reject) => {
this.queue.push({
task,
priority: Number.isFinite(priority) ? priority : NIP46_PRIORITY.NORMAL,
resolve,
reject,
addedAt: Date.now(),
});
// Sort by priority (descending), then by insertion time (ascending) for fairness
this.queue.sort((a, b) => {
if (b.priority !== a.priority) {
return b.priority - a.priority;
}
return a.addedAt - b.addedAt;
});
this.process();
});
}
async process() {
if (this.running) return;
this.running = true;
while (this.queue.length > 0) {
const now = Date.now();
const timeSinceLast = now - this.lastRunTime;
if (timeSinceLast < this.minDelayMs) {
const waitMs = this.minDelayMs - timeSinceLast;
await new Promise((r) => setTimeout(r, waitMs));
}
const item = this.queue.shift();
if (!item) break; // Should not happen given while condition
const { task, resolve, reject } = item;
try {
const result = await task();
this.lastRunTime = Date.now();
resolve(result);
} catch (error) {
this.lastRunTime = Date.now();
reject(error);
}
}
this.running = false;
}
/**
* Clears the queue, rejecting all pending tasks.
* Useful when disconnecting or destroying the client.
*/
clear(error) {
const pending = this.queue;
this.queue = [];
const rejection = error || new Error("Queue cleared");
for (const item of pending) {
try {
item.reject(rejection);
} catch (e) {
devLogger.warn("[nostr] Failed to reject cleared queue item:", e);
}
}
}
}
+4 -1
View File
@@ -19,6 +19,7 @@ import {
import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nip07Permissions.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { logRebroadcastCountFailure } from "./countDiagnostics.js";
import { queueSignEvent } from "./signRequestQueue.js";
const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1";
const rebroadcastAttemptMemory = new Map();
@@ -359,7 +360,9 @@ export async function signAndPublishEvent({
}
if (permissionResult.ok) {
try {
signedEvent = await signer.signEvent(event);
signedEvent = await queueSignEvent(signer, event, {
timeoutMs: options.timeoutMs,
});
} catch (error) {
userLogger.warn(
"[nostr] Failed to sign event with active signer:",
+147 -1
View File
@@ -3,6 +3,13 @@ import { publishEventToRelay } from "../nostrPublish.js";
import { RELAY_URLS } from "./toolkit.js";
import { normalizePointerInput } from "./watchHistory.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { LRUCache } from "../utils/lruCache.js";
import { CACHE_POLICIES } from "./cachePolicies.js";
import { NOTE_TYPES } from "../nostrEventSchemas.js";
import { queueSignEvent } from "./signRequestQueue.js";
const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_REACTION];
const reactionCache = new LRUCache({ maxSize: 100 });
function sanitizeRelayList(primary, fallback) {
if (Array.isArray(primary) && primary.length) {
@@ -127,6 +134,143 @@ function extractEventRelayFromPointerInput(pointerInput) {
return "";
}
export async function listVideoReactions(client, targetInput, options = {}) {
let pool = client?.pool;
const ensurePool =
typeof client?.ensurePool === "function"
? client.ensurePool.bind(client)
: null;
if (!pool || typeof pool.list !== "function") {
if (ensurePool) {
try {
pool = await ensurePool();
} catch (error) {
devLogger.warn(
"[nostr] Unable to list video reactions: pool init failed.",
error,
);
return [];
}
}
}
if (!pool || typeof pool.list !== "function") {
devLogger.warn("[nostr] Unable to list video reactions: pool missing.");
return [];
}
// Normalize target to get video event ID
let videoEventId = "";
if (typeof targetInput === "string") {
videoEventId = targetInput.trim();
} else if (targetInput && typeof targetInput === "object") {
videoEventId =
normalizeString(targetInput.id) ||
normalizeString(targetInput.eventId) ||
normalizeString(targetInput.videoEventId) ||
extractEventIdFromPointerInput(targetInput);
}
if (!videoEventId) {
devLogger.warn("[nostr] Invalid target for listVideoReactions (missing ID).");
return [];
}
const cacheKey = videoEventId;
const cached = reactionCache.get(cacheKey);
const ttl = CACHE_POLICY.ttl;
const now = Date.now();
const forceRefresh = options?.forceRefresh === true;
if (cached && !forceRefresh && (now - cached.fetchedAt < ttl)) {
devLogger.debug(`[nostr] Reactions cache hit for ${cacheKey}`);
return cached.items;
}
if (cached) {
devLogger.debug(`[nostr] Reactions cache stale for ${cacheKey}, refreshing...`);
} else {
devLogger.debug(`[nostr] Reactions cache miss for ${cacheKey}`);
}
const relayList = sanitizeRelayList(options.relays, client.relays);
const lastSeens = cached?.lastSeenPerRelay || {};
const mergedLastSeens = { ...lastSeens };
const rawResults = [];
const filters = [{ kinds: [7], "#e": [videoEventId] }];
// Parallel fetch from relays with incremental logic
await Promise.all(
relayList.map(async (url) => {
if (!url) return;
const lastSeen = lastSeens[url] || 0;
// Deep clone filters to apply 'since' safely per relay
const relayFilters = filters.map(f => {
const copy = { ...f };
if (lastSeen > 0) {
copy.since = lastSeen + 1;
}
return copy;
});
try {
const events = await pool.list([url], relayFilters);
let maxCreated = lastSeen;
if (Array.isArray(events)) {
for (const ev of events) {
rawResults.push(ev);
if (ev.created_at > maxCreated) {
maxCreated = ev.created_at;
}
}
}
if (maxCreated > lastSeen) {
mergedLastSeens[url] = maxCreated;
}
} catch (err) {
devLogger.warn(`[nostr] Failed to fetch reactions from ${url}:`, err);
}
})
);
// Merge with cached items
const combinedRaw = cached ? [...cached.items, ...rawResults] : rawResults;
// Deduplicate by ID and sort by created_at (descending)
const dedupe = new Map();
for (const event of combinedRaw) {
if (!event || typeof event.id !== "string") continue;
// Basic validation: must be kind 7 and tag the video
if (event.kind !== 7) continue;
// We could verify the 'e' tag matches videoEventId, but the filter usually handles it.
// Being defensive:
const tags = Array.isArray(event.tags) ? event.tags : [];
const hasETag = tags.some(t => t[0] === 'e' && t[1] === videoEventId);
// Note: some clients might use 'a' tag for parameterized events, but we filtered by #e.
if (!hasETag) continue;
const existing = dedupe.get(event.id);
if (!existing || event.created_at > existing.created_at) {
dedupe.set(event.id, event);
}
}
const finalItems = Array.from(dedupe.values()).sort((a, b) => b.created_at - a.created_at);
// Update cache
if (cacheKey) {
reactionCache.set(cacheKey, {
items: finalItems,
lastSeenPerRelay: mergedLastSeens,
fetchedAt: Date.now()
});
}
return finalItems;
}
export async function publishVideoReaction(
client,
pointerInput,
@@ -298,7 +442,9 @@ export async function publishVideoReaction(
}
if (permissionResult.ok) {
try {
signedEvent = await signer.signEvent(event);
signedEvent = await queueSignEvent(signer, event, {
timeoutMs: options?.timeoutMs,
});
} catch (error) {
userLogger.warn(
"[nostr] Failed to sign reaction event with active signer:",
+145 -38
View File
@@ -7,6 +7,10 @@ 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;
const SESSION_ACTOR_DB_NAME = "bitvidSessionActor";
const SESSION_ACTOR_DB_VERSION = 1;
const SESSION_ACTOR_STORE = "sessionActor";
let sessionActorDbPromise = null;
function arrayBufferToBase64(buffer) {
if (!buffer) {
@@ -62,6 +66,91 @@ function base64ToUint8Array(base64) {
return bytes;
}
function isIndexedDbAvailable() {
try {
return typeof indexedDB !== "undefined";
} catch (error) {
return false;
}
}
function openSessionActorDb() {
if (!isIndexedDbAvailable()) {
return Promise.resolve(null);
}
if (sessionActorDbPromise) {
return sessionActorDbPromise;
}
sessionActorDbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(SESSION_ACTOR_DB_NAME, SESSION_ACTOR_DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(SESSION_ACTOR_STORE)) {
db.createObjectStore(SESSION_ACTOR_STORE);
}
};
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error || new Error("Failed to open session actor DB"));
};
});
return sessionActorDbPromise;
}
function persistSessionActorToIndexedDb(payload) {
return openSessionActorDb()
.then((db) => {
if (!db) {
return false;
}
return new Promise((resolve, reject) => {
const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite");
const store = tx.objectStore(SESSION_ACTOR_STORE);
store.put(payload, SESSION_ACTOR_STORAGE_KEY);
tx.oncomplete = () => resolve(true);
tx.onerror = () =>
reject(tx.error || new Error("Session actor IndexedDB write failed"));
tx.onabort = () =>
reject(tx.error || new Error("Session actor IndexedDB write aborted"));
});
})
.catch((error) => {
devLogger.warn("[nostr] Failed to persist session actor to IndexedDB:", error);
return false;
});
}
function clearStoredSessionActorIndexedDb() {
return openSessionActorDb()
.then((db) => {
if (!db) {
return false;
}
return new Promise((resolve, reject) => {
const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite");
const store = tx.objectStore(SESSION_ACTOR_STORE);
store.delete(SESSION_ACTOR_STORAGE_KEY);
tx.oncomplete = () => resolve(true);
tx.onerror = () =>
reject(tx.error || new Error("Session actor IndexedDB delete failed"));
tx.onabort = () =>
reject(tx.error || new Error("Session actor IndexedDB delete aborted"));
});
})
.catch((error) => {
devLogger.warn("[nostr] Failed to clear session actor IndexedDB entry:", error);
return false;
});
}
function generateRandomBytes(length) {
const size = Number.isFinite(length) ? Math.max(0, Math.floor(length)) : 0;
if (size <= 0) {
@@ -283,8 +372,6 @@ export function readStoredSessionActorEntry() {
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()
@@ -294,9 +381,33 @@ export function readStoredSessionActorEntry() {
? parsed.createdAt
: Date.now();
if (parsed?.privateKey) {
if (privateKeyEncrypted && encryption) {
persistSessionActor({
pubkey,
privateKeyEncrypted,
encryption,
createdAt,
});
} else {
try {
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear legacy session actor entry:",
cleanupError,
);
}
}
}
if (!privateKeyEncrypted || !encryption) {
return null;
}
return {
pubkey,
privateKey,
privateKey: "",
privateKeyEncrypted,
encryption,
createdAt,
@@ -317,16 +428,14 @@ export function readStoredSessionActorEntry() {
}
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))
typeof actor.privateKeyEncrypted !== "string" ||
!actor.privateKeyEncrypted ||
!actor.encryption ||
typeof actor.encryption !== "object"
) {
return;
}
@@ -340,46 +449,44 @@ export function persistSessionActor(actor) {
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 {
const normalizedEncryption = normalizeStoredEncryptionMetadata(actor.encryption);
if (!normalizedEncryption) {
return;
}
payload.privateKeyEncrypted = actor.privateKeyEncrypted;
payload.encryption = normalizedEncryption;
try {
localStorage.setItem(
SESSION_ACTOR_STORAGE_KEY,
JSON.stringify(payload),
);
} catch (error) {
devLogger.warn("[nostr] Failed to persist session actor:", error);
persistSessionActorToIndexedDb(payload);
if (typeof localStorage !== "undefined") {
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;
}
clearStoredSessionActorIndexedDb();
try {
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to clear stored session actor:", error);
if (typeof localStorage !== "undefined") {
try {
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to clear stored session actor:", error);
}
}
}
export function isSessionActor(nostrClient) {
const sa = nostrClient?.sessionActor;
return !!sa && sa.source !== "nsec";
}
export const __testExports = {
arrayBufferToBase64,
base64ToUint8Array,
+130
View File
@@ -0,0 +1,130 @@
import { devLogger } from "../utils/logger.js";
const DEFAULT_SIGN_TIMEOUT_MS = 20_000;
const signQueues = new WeakMap();
const SIGN_ERROR_CODES = new Set([
"permission-denied",
"timeout",
"not-capable",
"signer-disconnected",
]);
function resolveTimeoutMs(timeoutMs) {
const candidate = Number(timeoutMs);
if (Number.isFinite(candidate) && candidate > 0) {
return Math.floor(candidate);
}
return DEFAULT_SIGN_TIMEOUT_MS;
}
function toError(value, message) {
if (value instanceof Error) {
return value;
}
const error = new Error(message);
error.details = value;
return error;
}
function normalizeSignError(error) {
const normalized = toError(error, "signEvent failed");
const existingCode = typeof normalized.code === "string" ? normalized.code : "";
if (SIGN_ERROR_CODES.has(existingCode)) {
return normalized;
}
const message = typeof normalized.message === "string"
? normalized.message.toLowerCase()
: "";
if (
existingCode === "extension-permission-denied" ||
message.includes("permission") ||
message.includes("denied") ||
message.includes("rejected")
) {
normalized.code = "permission-denied";
return normalized;
}
if (
existingCode === "nostr-extension-missing" ||
message.includes("disconnected") ||
message.includes("not connected") ||
message.includes("connection closed") ||
message.includes("extension missing")
) {
normalized.code = "signer-disconnected";
return normalized;
}
if (existingCode === "sign-event-unavailable") {
normalized.code = "not-capable";
return normalized;
}
return normalized;
}
function createSignError(code, originalError) {
const error = toError(
originalError,
originalError?.message || `signEvent failed with ${code}`,
);
error.code = code;
if (originalError && originalError !== error) {
error.details = originalError;
}
return error;
}
function runWithTimeout(operation, timeoutMs) {
const resolvedTimeout = resolveTimeoutMs(timeoutMs);
let timeoutId;
let timedOut = false;
const signPromise = Promise.resolve().then(operation);
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
timedOut = true;
reject(createSignError("timeout", new Error(
`signEvent timed out after ${resolvedTimeout}ms`,
)));
}, resolvedTimeout);
});
return Promise.race([signPromise, timeoutPromise]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (timedOut) {
void signPromise.catch((error) => {
devLogger.warn("[nostr] signEvent resolved after timeout:", error);
});
}
});
}
export async function queueSignEvent(signer, event, options = {}) {
if (!signer || typeof signer !== "object") {
throw createSignError("not-capable", new Error("signer-unavailable"));
}
if (typeof signer.signEvent !== "function") {
throw createSignError("not-capable", new Error("sign-event-unavailable"));
}
const queue = signQueues.get(signer) || Promise.resolve();
const runSign = async () => runWithTimeout(
() => signer.signEvent(event),
options.timeoutMs,
);
const queued = queue.then(runSign, runSign);
signQueues.set(signer, queued.catch(() => {}));
try {
return await queued;
} catch (error) {
throw normalizeSignError(error);
}
}
+165
View File
@@ -0,0 +1,165 @@
// js/nostr/signerHelpers.js
import { devLogger } from "../utils/logger.js";
import { HEX64_REGEX } from "./nip46Client.js";
import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js";
import { normalizeActorKey } from "./watchHistory.js";
let loggedMissingCipherToolkit = false;
let loggedMissingNip04Cipher = false;
let loggedMissingNip44Cipher = false;
export async function createPrivateKeyCipherClosures(privateKey) {
const normalizedPrivateKey =
typeof privateKey === "string" && HEX64_REGEX.test(privateKey)
? privateKey.toLowerCase()
: "";
if (!normalizedPrivateKey) {
return {};
}
const tools = (await ensureNostrTools()) || getCachedNostrTools();
if (!tools) {
if (!loggedMissingCipherToolkit) {
loggedMissingCipherToolkit = true;
devLogger.warn(
"[nostr] nostr-tools bundle missing for private key cipher helpers.",
);
}
return {};
}
const closures = {};
const normalizeTargetPubkey = (candidate) => {
const normalized = normalizeActorKey(candidate);
if (!normalized || !HEX64_REGEX.test(normalized)) {
throw new Error("A hex-encoded pubkey is required for encryption.");
}
return normalized;
};
const resolveHexToBytes = () => {
if (typeof tools?.utils?.hexToBytes === "function") {
return (value) => tools.utils.hexToBytes(value);
}
return (value) => {
if (typeof value !== "string") {
throw new Error("Invalid hex input.");
}
const trimmed = value.trim();
if (!trimmed || trimmed.length % 2 !== 0) {
throw new Error("Invalid hex input.");
}
const bytes = new Uint8Array(trimmed.length / 2);
for (let index = 0; index < trimmed.length; index += 2) {
const byte = Number.parseInt(trimmed.slice(index, index + 2), 16);
if (Number.isNaN(byte)) {
throw new Error("Invalid hex input.");
}
bytes[index / 2] = byte;
}
return bytes;
};
};
if (
tools?.nip04?.encrypt &&
typeof tools.nip04.encrypt === "function" &&
tools?.nip04?.decrypt &&
typeof tools.nip04.decrypt === "function"
) {
closures.nip04Encrypt = async (targetPubkey, plaintext) =>
tools.nip04.encrypt(
normalizedPrivateKey,
normalizeTargetPubkey(targetPubkey),
plaintext,
);
closures.nip04Decrypt = async (targetPubkey, ciphertext) =>
tools.nip04.decrypt(
normalizedPrivateKey,
normalizeTargetPubkey(targetPubkey),
ciphertext,
);
} else if (!loggedMissingNip04Cipher) {
loggedMissingNip04Cipher = true;
devLogger.warn(
"[nostr] nip04 helpers unavailable in nostr-tools bundle.",
);
}
const nip44 = tools?.nip44 || null;
let nip44Encrypt = null;
let nip44Decrypt = null;
let nip44GetConversationKey = null;
if (nip44?.v2 && typeof nip44.v2 === "object") {
if (typeof nip44.v2.encrypt === "function") {
nip44Encrypt = nip44.v2.encrypt;
}
if (typeof nip44.v2.decrypt === "function") {
nip44Decrypt = nip44.v2.decrypt;
}
if (typeof nip44.v2?.utils?.getConversationKey === "function") {
nip44GetConversationKey = nip44.v2.utils.getConversationKey;
}
}
if ((!nip44Encrypt || !nip44Decrypt) && nip44 && typeof nip44 === "object") {
if (typeof nip44.encrypt === "function") {
nip44Encrypt = nip44.encrypt;
}
if (typeof nip44.decrypt === "function") {
nip44Decrypt = nip44.decrypt;
}
if (!nip44GetConversationKey) {
if (typeof nip44.getConversationKey === "function") {
nip44GetConversationKey = nip44.getConversationKey;
} else if (typeof nip44.utils?.getConversationKey === "function") {
nip44GetConversationKey = nip44.utils.getConversationKey;
}
}
}
if (nip44Encrypt && nip44Decrypt && nip44GetConversationKey) {
const hexToBytes = resolveHexToBytes();
let cachedPrivateKeyBytes = null;
const getPrivateKeyBytes = () => {
if (!cachedPrivateKeyBytes) {
cachedPrivateKeyBytes = hexToBytes(normalizedPrivateKey);
}
return cachedPrivateKeyBytes;
};
const conversationKeyCache = new Map();
const ensureConversationKey = (targetPubkey) => {
const normalizedTarget = normalizeTargetPubkey(targetPubkey);
const cached = conversationKeyCache.get(normalizedTarget);
if (cached) {
return cached;
}
const privateKeyBytes = getPrivateKeyBytes();
const derived = nip44GetConversationKey(privateKeyBytes, normalizedTarget);
conversationKeyCache.set(normalizedTarget, derived);
return derived;
};
closures.nip44Encrypt = async (targetPubkey, plaintext) =>
nip44Encrypt(plaintext, ensureConversationKey(targetPubkey));
closures.nip44Decrypt = async (targetPubkey, ciphertext) =>
nip44Decrypt(ciphertext, ensureConversationKey(targetPubkey));
} else if (!loggedMissingNip44Cipher) {
loggedMissingNip44Cipher = true;
devLogger.warn(
"[nostr] nip44 helpers unavailable in nostr-tools bundle.",
);
}
return closures;
}
+100
View File
@@ -0,0 +1,100 @@
// js/nostr/syncMetadataStore.js
import { userLogger } from "../utils/logger.js";
const SYNC_METADATA_STORAGE_KEY = "bitvid:nostrSyncMetadata:v1";
/**
* SyncMetadataStore
*
* Persists last-seen timestamps for incremental list fetching.
* Key format: `${kind}:${pubkey}:${dTag}:${relayUrl}`
*
* Note: dTag can be empty string for non-parameterized replaceable events.
*/
export class SyncMetadataStore {
constructor() {
this.metadata = new Map();
this.load();
}
computeKey(kind, pubkey, dTag, relayUrl) {
const k = kind || 0;
const p = pubkey ? pubkey.trim().toLowerCase() : "";
const d = dTag ? dTag.trim() : "";
const r = relayUrl ? relayUrl.trim() : "";
return `${k}:${p}:${d}:${r}`;
}
load() {
if (typeof localStorage === "undefined") {
return;
}
try {
const raw = localStorage.getItem(SYNC_METADATA_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
this.metadata = new Map(parsed);
} else if (typeof parsed === "object") {
this.metadata = new Map(Object.entries(parsed));
}
}
} catch (error) {
userLogger.warn("[SyncMetadataStore] Failed to load metadata:", error);
}
}
save() {
if (typeof localStorage === "undefined") {
return;
}
try {
// Serialize Map to array of entries for JSON
const entries = Array.from(this.metadata.entries());
localStorage.setItem(SYNC_METADATA_STORAGE_KEY, JSON.stringify(entries));
} catch (error) {
userLogger.warn("[SyncMetadataStore] Failed to save metadata:", error);
}
}
getLastSeen(kind, pubkey, dTag, relayUrl) {
const key = this.computeKey(kind, pubkey, dTag, relayUrl);
const val = this.metadata.get(key);
return Number.isFinite(val) ? val : 0;
}
updateLastSeen(kind, pubkey, dTag, relayUrl, createdAt) {
const timestamp = Number(createdAt);
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return;
}
const key = this.computeKey(kind, pubkey, dTag, relayUrl);
const current = this.metadata.get(key);
// Only update if newer
if (!Number.isFinite(current) || timestamp > current) {
this.metadata.set(key, timestamp);
this.save();
}
}
getPerRelayLastSeen(kind, pubkey, dTag) {
const prefix = `${kind}:${pubkey ? pubkey.trim().toLowerCase() : ""}:${dTag ? dTag.trim() : ""}:`;
const result = {};
for (const [key, val] of this.metadata.entries()) {
if (key.startsWith(prefix)) {
const relayUrl = key.slice(prefix.length);
if (relayUrl) {
result[relayUrl] = val;
}
}
}
return result;
}
}
export const __testExports = {
SYNC_METADATA_STORAGE_KEY
};
+40 -14
View File
@@ -13,6 +13,7 @@ import { RELAY_URLS } from "./toolkit.js";
import { normalizePointerInput } from "./watchHistory.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { logViewCountFailure } from "./countDiagnostics.js";
import { queueSignEvent } from "./signRequestQueue.js";
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
@@ -150,19 +151,30 @@ function generateViewEventDedupeTag(actorPubkey, pointer, createdAtSeconds) {
return `${scope}:${normalizedActor}:${timestamp}:${entropy}`;
}
export function hasRecentViewPublish(scope, bucketIndex) {
export function hasRecentViewPublish(scope, bucketIndex, actorPubkey) {
if (!scope || !Number.isFinite(bucketIndex)) {
return false;
}
const windowMs = getViewEventGuardWindowMs();
const now = Date.now();
const entry = viewEventPublishMemory.get(scope);
const safeActor =
typeof actorPubkey === "string" && actorPubkey.trim()
? actorPubkey.trim().toLowerCase()
: "anon";
let actorMemory = viewEventPublishMemory.get(safeActor);
if (!actorMemory) {
actorMemory = new Map();
viewEventPublishMemory.set(safeActor, actorMemory);
}
const entry = actorMemory.get(scope);
if (entry) {
const age = now - Number(entry.seenAt);
if (!Number.isFinite(entry.seenAt) || age >= windowMs) {
viewEventPublishMemory.delete(scope);
actorMemory.delete(scope);
} else if (Number(entry.bucket) === bucketIndex) {
return true;
}
@@ -172,7 +184,7 @@ export function hasRecentViewPublish(scope, bucketIndex) {
return false;
}
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`;
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${safeActor}:${scope}`;
let rawValue = null;
try {
rawValue = localStorage.getItem(storageKey);
@@ -207,7 +219,7 @@ export function hasRecentViewPublish(scope, bucketIndex) {
return false;
}
viewEventPublishMemory.set(scope, {
actorMemory.set(scope, {
bucket: storedBucket,
seenAt: storedSeenAt,
});
@@ -215,19 +227,30 @@ export function hasRecentViewPublish(scope, bucketIndex) {
return storedBucket === bucketIndex;
}
export function rememberViewPublish(scope, bucketIndex) {
export function rememberViewPublish(scope, bucketIndex, actorPubkey) {
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);
const safeActor =
typeof actorPubkey === "string" && actorPubkey.trim()
? actorPubkey.trim().toLowerCase()
: "anon";
let actorMemory = viewEventPublishMemory.get(safeActor);
if (!actorMemory) {
actorMemory = new Map();
viewEventPublishMemory.set(safeActor, actorMemory);
}
viewEventPublishMemory.set(scope, {
const entry = actorMemory.get(scope);
if (entry && Number.isFinite(entry.seenAt) && now - entry.seenAt >= windowMs) {
actorMemory.delete(scope);
}
actorMemory.set(scope, {
bucket: bucketIndex,
seenAt: now,
});
@@ -236,7 +259,7 @@ export function rememberViewPublish(scope, bucketIndex) {
return;
}
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`;
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${safeActor}:${scope}`;
try {
localStorage.setItem(storageKey, `${bucketIndex}:${now}`);
} catch (error) {
@@ -666,6 +689,7 @@ export async function publishViewEvent(
}
const actorPubkey = await client.ensureSessionActor();
// actorPubkey derived via client.ensureSessionActor()
if (!actorPubkey) {
return { ok: false, error: "missing-actor" };
}
@@ -677,7 +701,7 @@ export async function publishViewEvent(
const guardScope = deriveViewEventPointerScope(pointer);
const guardBucket = deriveViewEventBucketIndex(createdAt);
if (guardScope && hasRecentViewPublish(guardScope, guardBucket)) {
if (guardScope && hasRecentViewPublish(guardScope, guardBucket, actorPubkey)) {
devLogger.info("[nostr] Skipping duplicate view publish for scope", guardScope);
return {
ok: true,
@@ -770,7 +794,9 @@ export async function publishViewEvent(
}
if (permissionResult.ok) {
try {
signedEvent = await signer.signEvent(event);
signedEvent = await queueSignEvent(signer, event, {
timeoutMs: options?.timeoutMs,
});
} catch (error) {
userLogger.warn(
"[nostr] Failed to sign view event with active signer:",
@@ -815,7 +841,7 @@ export async function publishViewEvent(
const success = acceptedRelays.length > 0;
if (success) {
if (guardScope) {
rememberViewPublish(guardScope, guardBucket);
rememberViewPublish(guardScope, guardBucket, actorPubkey);
}
devLogger.info(
`[nostr] View event accepted by ${acceptedRelays.length} relay(s):`,
+74 -152
View File
@@ -7,10 +7,13 @@ import {
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
WATCH_HISTORY_FETCH_EVENT_LIMIT,
WATCH_HISTORY_CACHE_TTL_MS,
WATCH_HISTORY_VERSION_TAG_VALUE,
} from "../config.js";
import {
buildWatchHistoryEvent,
NOTE_TYPES,
} from "../nostrEventSchemas.js";
import { CACHE_POLICIES } from "./cachePolicies.js";
import { publishEventToRelays } from "../nostrPublish.js";
import {
RELAY_URLS,
@@ -19,6 +22,8 @@ import {
} from "./toolkit.js";
import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nip07Permissions.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { profileCache } from "../state/profileCache.js";
import { queueSignEvent } from "./signRequestQueue.js";
/**
* Domain utilities for watch-history interactions. This module owns pointer
@@ -26,8 +31,6 @@ import { devLogger, userLogger } from "../utils/logger.js";
* relay publishing, fetch/decrypt flows, and exposes a manager factory that is
* dependency-injected with the nostr client hooks it needs.
*/
const WATCH_HISTORY_STORAGE_KEY = "bitvid:watch-history:v4";
const WATCH_HISTORY_STORAGE_VERSION = 4;
const WATCH_HISTORY_REPUBLISH_BASE_DELAY_MS = 2000;
const WATCH_HISTORY_REPUBLISH_MAX_DELAY_MS = 5 * 60 * 1000;
const WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS = 8;
@@ -563,7 +566,7 @@ async function computeWatchHistoryFingerprintForItems(itemsOrBuckets) {
// Check if buckets
let flatItems = [];
if (Array.isArray(itemsOrBuckets)) {
flatItems = itemsOrBuckets;
flatItems = [...itemsOrBuckets];
} else if (itemsOrBuckets && typeof itemsOrBuckets === "object") {
flatItems = Object.keys(itemsOrBuckets)
.sort()
@@ -953,163 +956,86 @@ class WatchHistoryManager {
this.cacheTtlMs = 0;
this.fingerprints = new Map();
this.lastCreatedAt = 0;
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
this.clear();
} else if (event === "runtimeCleared") {
const activePubkey = typeof this.deps.getActivePubkey === "function"
? this.deps.getActivePubkey()
: "";
const normalized = normalizeActorKey(detail.pubkey);
const active = normalizeActorKey(activePubkey);
if (normalized && active && normalized === active) {
this.cache.delete(normalized);
}
}
});
}
getCacheTtlMs() {
if (Number.isFinite(this.cacheTtlMs) && this.cacheTtlMs > 0) {
return this.cacheTtlMs;
}
const policyTtl = CACHE_POLICIES[NOTE_TYPES.WATCH_HISTORY]?.ttl;
const configured = Number(WATCH_HISTORY_CACHE_TTL_MS);
const resolved =
// Prefer config if set (for backward compat or overrides), otherwise policy
let resolved =
Number.isFinite(configured) && configured > 0
? Math.floor(configured)
: 24 * 60 * 60 * 1000;
: Number.isFinite(policyTtl) && policyTtl > 0
? policyTtl
: 24 * 60 * 60 * 1000;
this.cacheTtlMs = resolved;
return resolved;
}
getStorage() {
if (this.storage && this.storage.version === WATCH_HISTORY_STORAGE_VERSION) {
return this.storage;
getStorage(actorInput = null) {
const actorKey = normalizeActorKey(actorInput) || normalizeActorKey(this.deps.getActivePubkey?.());
if (!actorKey) {
return { actors: {} }; // Return empty structure if no actor resolved
}
const emptyStorage = { version: WATCH_HISTORY_STORAGE_VERSION, actors: {} };
if (typeof localStorage === "undefined") {
this.storage = emptyStorage;
return this.storage;
}
let raw = null;
try {
raw = localStorage.getItem(WATCH_HISTORY_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to read watch history storage:", error);
this.storage = emptyStorage;
return this.storage;
}
if (!raw || typeof raw !== "string") {
this.storage = emptyStorage;
return this.storage;
}
let parsed = null;
try {
parsed = JSON.parse(raw);
} catch (error) {
devLogger.warn("[nostr] Failed to parse watch history storage:", error);
this.storage = emptyStorage;
return this.storage;
}
const now = Date.now();
const ttl = this.getCacheTtlMs();
const actors =
parsed && typeof parsed === "object" && parsed.actors && typeof parsed.actors === "object"
? parsed.actors
: {};
const sanitizedActors = {};
let mutated = false;
for (const [actorKeyRaw, entry] of Object.entries(actors)) {
const actorKey = normalizeActorKey(actorKeyRaw);
if (!actorKey) {
mutated = true;
continue;
}
const savedAt = Number(entry?.savedAt);
if (!Number.isFinite(savedAt) || savedAt <= 0 || now - savedAt > ttl) {
mutated = true;
continue;
}
// Migration from v2 (flat items) to v3 (bucketed records)
let records = {};
let items = [];
if (parsed.version < 3 && Array.isArray(entry.items)) {
records = canonicalizeWatchHistoryItems(entry.items, WATCH_HISTORY_MAX_ITEMS);
items = Object.values(records).flat();
mutated = true;
} else if (entry.records && typeof entry.records === "object") {
// Re-canonicalize to ensure structure
const flatItems = Object.values(entry.records).flat();
records = canonicalizeWatchHistoryItems(flatItems, WATCH_HISTORY_MAX_ITEMS);
items = Object.values(records).flat();
} else if (Array.isArray(entry.items)) {
// Fallback if records missing but items present in v3 (shouldn't happen if persisted correctly but safe to handle)
records = canonicalizeWatchHistoryItems(entry.items, WATCH_HISTORY_MAX_ITEMS);
items = Object.values(records).flat();
}
const snapshotId = typeof entry?.snapshotId === "string" ? entry.snapshotId : "";
const fingerprint = typeof entry?.fingerprint === "string" ? entry.fingerprint : "";
const metadata =
entry?.metadata && typeof entry.metadata === "object"
? { ...entry.metadata }
: {};
sanitizedActors[actorKey] = {
actor:
typeof entry?.actor === "string" && entry.actor.trim()
? entry.actor.trim()
: actorKey,
snapshotId,
fingerprint,
savedAt,
records,
items,
metadata,
};
const cached = profileCache.get(NOTE_TYPES.WATCH_HISTORY);
if (!cached) {
return { actors: {} };
}
const storage = {
version: WATCH_HISTORY_STORAGE_VERSION,
actors: sanitizedActors,
// Wrap single entry in actors map for compatibility
return {
actors: {
[actorKey]: cached
}
};
if (mutated || parsed?.version !== WATCH_HISTORY_STORAGE_VERSION) {
try {
localStorage.setItem(WATCH_HISTORY_STORAGE_KEY, JSON.stringify(storage));
} catch (error) {
devLogger.warn("[nostr] Failed to rewrite watch history storage:", error);
}
}
this.storage = storage;
return this.storage;
}
persistEntry(actorInput, entry) {
const actorKey = normalizeActorKey(actorInput);
if (!actorKey) {
if (!actorKey || !entry) {
return;
}
const storage = this.getStorage();
const actors = { ...storage.actors };
const now = Date.now();
const ttl = this.getCacheTtlMs();
let mutated = false;
for (const [key, value] of Object.entries(actors)) {
const savedAt = Number(value?.savedAt);
if (!Number.isFinite(savedAt) || savedAt <= 0 || now - savedAt > ttl) {
delete actors[key];
mutated = true;
}
}
if (!entry) {
if (actors[actorKey]) {
delete actors[actorKey];
mutated = true;
}
} else {
// Entry can have `items` (flat) or `records` (buckets). We standardize to records.
let records = {};
if (entry.records && typeof entry.records === "object") {
const flat = Object.values(entry.records).flat();
records = canonicalizeWatchHistoryItems(flat, WATCH_HISTORY_MAX_ITEMS);
} else if (Array.isArray(entry.items)) {
records = canonicalizeWatchHistoryItems(entry.items, WATCH_HISTORY_MAX_ITEMS);
}
const items = Object.values(records).flat();
const snapshotId = typeof entry.snapshotId === "string" ? entry.snapshotId : "";
const fingerprint = typeof entry.fingerprint === "string" ? entry.fingerprint : "";
const savedAt = Number.isFinite(entry.savedAt) && entry.savedAt > 0 ? entry.savedAt : now;
const actorValue =
// Entry can have `items` (flat) or `records` (buckets). We standardize to records.
let records = {};
if (entry.records && typeof entry.records === "object") {
const flat = Object.values(entry.records).flat();
records = canonicalizeWatchHistoryItems(flat, WATCH_HISTORY_MAX_ITEMS);
} else if (Array.isArray(entry.items)) {
records = canonicalizeWatchHistoryItems(entry.items, WATCH_HISTORY_MAX_ITEMS);
}
const items = Object.values(records).flat();
const snapshotId = typeof entry.snapshotId === "string" ? entry.snapshotId : "";
const fingerprint = typeof entry.fingerprint === "string" ? entry.fingerprint : "";
const savedAt = Number.isFinite(entry.savedAt) && entry.savedAt > 0 ? entry.savedAt : Date.now();
const actorValue =
typeof entry.actor === "string" && entry.actor.trim()
? entry.actor.trim()
: actorInput || actorKey;
actors[actorKey] = {
? entry.actor.trim()
: actorInput || actorKey;
const data = {
actor: actorValue,
snapshotId,
fingerprint,
@@ -1117,22 +1043,10 @@ class WatchHistoryManager {
records,
items,
metadata: {},
};
mutated = true;
}
const payload = {
version: WATCH_HISTORY_STORAGE_VERSION,
actors,
};
this.storage = payload;
if (!mutated || typeof localStorage === "undefined") {
return;
}
try {
localStorage.setItem(WATCH_HISTORY_STORAGE_KEY, JSON.stringify(payload));
} catch (error) {
devLogger.warn("[nostr] Failed to persist watch history entry:", error);
}
// profileCache keys by active profile, so we just set the data for "watchHistory"
profileCache.set(NOTE_TYPES.WATCH_HISTORY, data);
}
cancelRepublish(taskId = null) {
@@ -1539,7 +1453,9 @@ class WatchHistoryManager {
const signEvent = async (event) => {
if (activeSigner) {
return activeSigner.signEvent(event);
return queueSignEvent(activeSigner, event, {
timeoutMs: options.timeoutMs,
});
}
return this.deps.signEventWithPrivateKey(event, privateKey);
};
@@ -1653,6 +1569,11 @@ class WatchHistoryManager {
}
}
if (!encryptionTags.length) {
devLogger.warn("[nostr] Aborting watch history publish: mandatory encryption failed.");
return { ok: false, error: "encryption-failed", retryable: true };
}
const event = buildWatchHistoryEvent({
pubkey: actorPubkey,
created_at: createdAtCursor,
@@ -2045,7 +1966,8 @@ class WatchHistoryManager {
{
kinds,
authors: [actorKey],
// Fetch all parameterized replaceables of this kind to get history across all months
// Filter by the new version tag to ignore older history lists
"#v": [WATCH_HISTORY_VERSION_TAG_VALUE],
limit,
},
];
+10
View File
@@ -9,3 +9,13 @@ export {
nostrClient,
requestDefaultExtensionPermissions,
} from "./nostr/defaultClient.js";
export {
registerSigner,
setActiveSigner,
getActiveSigner,
resolveActiveSigner,
listRegisteredSigners,
onActiveSignerChanged,
offActiveSignerChanged,
} from "./nostrClientRegistry.js";
+218
View File
@@ -2,6 +2,77 @@
let registeredNostrClient = null;
let requestPermissionsDelegate = null;
const registeredSigners = new Map();
let activeSigner = null;
let activeSignerPubkey = "";
const activeSignerListeners = new Set();
const globalScope =
typeof window !== "undefined"
? window
: typeof globalThis !== "undefined"
? globalThis
: null;
function normalizePubkey(pubkey) {
return typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
}
function emitSignerEvent(type, detail) {
if (!globalScope || typeof globalScope.dispatchEvent !== "function") {
return;
}
let event = null;
if (typeof globalScope.CustomEvent === "function") {
event = new globalScope.CustomEvent(type, { detail });
} else if (typeof Event === "function") {
event = new Event(type);
event.detail = detail;
}
if (event) {
globalScope.dispatchEvent(event);
}
}
function hasPermissionChanges(previousMeta, nextMeta) {
const previous = previousMeta?.permissions ?? null;
const next = nextMeta?.permissions ?? null;
try {
return JSON.stringify(previous) !== JSON.stringify(next);
} catch (error) {
return previous !== next;
}
}
function updateActiveSigner(nextSigner, nextPubkey) {
const normalizedPubkey = normalizePubkey(nextPubkey);
const previousSigner = activeSigner;
const previousPubkey = activeSignerPubkey;
activeSigner = nextSigner || null;
activeSignerPubkey = normalizedPubkey;
if (
previousSigner !== activeSigner ||
previousPubkey !== activeSignerPubkey
) {
const payload = {
previousSigner,
previousPubkey,
signer: activeSigner,
pubkey: activeSignerPubkey,
};
activeSignerListeners.forEach((listener) => {
try {
listener(payload);
} catch (error) {
// Intentionally swallow listener errors to avoid breaking signer updates.
}
});
}
}
export function registerNostrClient(client, options = {}) {
registeredNostrClient = client || null;
@@ -45,6 +116,153 @@ export function requestDefaultExtensionPermissions(...args) {
return Promise.resolve({ ok: false, error });
}
export function registerSigner(pubkey, signer, meta = {}) {
const normalized = normalizePubkey(pubkey);
if (!normalized) {
return null;
}
const previous = registeredSigners.get(normalized) || null;
if (!signer || typeof signer !== "object") {
if (previous) {
registeredSigners.delete(normalized);
emitSignerEvent("signer:disconnected", {
pubkey: normalized,
signer: previous.signer,
meta: previous.meta,
});
if (activeSignerPubkey === normalized) {
updateActiveSigner(null, "");
}
}
return null;
}
const sanitizedMeta =
meta && typeof meta === "object" ? { ...meta } : {};
registeredSigners.set(normalized, { signer, meta: sanitizedMeta });
if (!previous) {
emitSignerEvent("signer:connected", {
pubkey: normalized,
signer,
meta: sanitizedMeta,
});
} else if (hasPermissionChanges(previous.meta, sanitizedMeta)) {
emitSignerEvent("signer:permissions:changed", {
pubkey: normalized,
signer,
previousMeta: previous.meta,
meta: sanitizedMeta,
});
}
if (activeSignerPubkey === normalized) {
updateActiveSigner(signer, normalized);
}
return signer;
}
export function setActiveSigner(signerOrPubkey) {
if (!signerOrPubkey) {
updateActiveSigner(null, "");
return null;
}
if (typeof signerOrPubkey === "string") {
const normalized = normalizePubkey(signerOrPubkey);
const entry = registeredSigners.get(normalized);
updateActiveSigner(entry?.signer || null, normalized);
return entry?.signer || null;
}
if (typeof signerOrPubkey === "object") {
const pubkey = normalizePubkey(signerOrPubkey.pubkey);
if (pubkey) {
registerSigner(pubkey, signerOrPubkey);
}
updateActiveSigner(signerOrPubkey, pubkey);
return signerOrPubkey;
}
return null;
}
export function clearActiveSigner() {
updateActiveSigner(null, "");
}
export function getActiveSigner() {
return activeSigner;
}
export function logoutSigner(pubkey) {
const normalized = normalizePubkey(pubkey);
if (normalized) {
const entry = registeredSigners.get(normalized);
if (entry?.signer && typeof entry.signer.destroy === "function") {
try {
entry.signer.destroy();
} catch (error) {
// Ignore signer teardown failures.
}
}
registerSigner(normalized, null);
return;
}
if (activeSigner) {
const activePubkey = normalizePubkey(activeSigner.pubkey || activeSignerPubkey);
if (activePubkey) {
logoutSigner(activePubkey);
return;
}
}
updateActiveSigner(null, "");
}
export function resolveActiveSigner(pubkey) {
const normalized = normalizePubkey(pubkey);
if (normalized) {
const entry = registeredSigners.get(normalized);
if (entry?.signer) {
return entry.signer;
}
if (
activeSigner &&
typeof activeSigner.pubkey === "string" &&
normalizePubkey(activeSigner.pubkey) === normalized
) {
return activeSigner;
}
}
return activeSigner;
}
export function listRegisteredSigners() {
return Array.from(registeredSigners.entries()).map(([pubkey, entry]) => ({
pubkey,
signer: entry.signer,
meta: entry.meta,
}));
}
export function onActiveSignerChanged(callback) {
if (typeof callback === "function") {
activeSignerListeners.add(callback);
}
}
export function offActiveSignerChanged(callback) {
activeSignerListeners.delete(callback);
}
export const __nostrClientRegistryTestHooks = {
clearNostrClientRegistration,
clearActiveSigner,
};
+142 -139
View File
@@ -7,6 +7,7 @@ import {
isDevMode,
WATCH_HISTORY_KIND,
WATCH_HISTORY_LIST_IDENTIFIER,
WATCH_HISTORY_VERSION_TAG_VALUE,
} from "./config.js";
export const NOTE_TYPES = Object.freeze({
@@ -26,7 +27,7 @@ export const NOTE_TYPES = Object.freeze({
ADMIN_MODERATION_LIST: "adminModerationList",
ADMIN_BLACKLIST: "adminBlacklist",
ADMIN_WHITELIST: "adminWhitelist",
STORAGE_CHALLENGE: "storageChallenge",
PROFILE_METADATA: "profileMetadata",
});
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
@@ -359,7 +360,7 @@ const BASE_SCHEMAS = {
name: "d",
},
monthTagName: "month",
appendTags: DEFAULT_APPEND_TAGS,
appendTags: [["v", WATCH_HISTORY_VERSION_TAG_VALUE]],
content: {
format: "json",
description:
@@ -447,15 +448,14 @@ const BASE_SCHEMAS = {
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
[NOTE_TYPES.STORAGE_CHALLENGE]: {
type: NOTE_TYPES.STORAGE_CHALLENGE,
label: "Storage challenge",
kind: 22242,
appendTags: [["purpose", "bitvid-storage-key"]],
[NOTE_TYPES.PROFILE_METADATA]: {
type: NOTE_TYPES.PROFILE_METADATA,
label: "Profile metadata",
kind: 0,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "empty",
description:
"Ephemeral challenge event used for key derivation; never published.",
format: "json",
description: "Standard NIP-01 profile metadata (name, about, picture, etc.).",
},
},
};
@@ -675,13 +675,14 @@ function mergePointerTags(pointerTags = []) {
return merged;
}
export function buildVideoPostEvent({
pubkey,
created_at,
dTagValue,
content,
additionalTags = [],
}) {
export function buildVideoPostEvent(params) {
const {
pubkey,
created_at,
dTagValue,
content,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST);
const tags = [];
if (schema?.topicTag?.name && schema?.topicTag?.value) {
@@ -696,21 +697,33 @@ export function buildVideoPostEvent({
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
let serializedContent = "";
if (typeof content === "string") {
serializedContent = content;
} else {
try {
serializedContent = JSON.stringify(content ?? {});
} catch (error) {
serializedContent = "{}";
}
}
return {
kind: schema?.kind ?? 30078,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : JSON.stringify(content ?? {}),
content: serializedContent,
};
}
export function buildVideoMirrorEvent({
pubkey,
created_at,
tags = [],
content = "",
}) {
export function buildVideoMirrorEvent(params) {
const {
pubkey,
created_at,
tags = [],
content = "",
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_MIRROR);
const combinedTags = [];
appendSchemaTags(combinedTags, schema);
@@ -730,20 +743,21 @@ export function buildVideoMirrorEvent({
};
}
export function buildRepostEvent({
pubkey,
created_at,
eventId = "",
eventRelay = "",
address = "",
addressRelay = "",
authorPubkey = "",
additionalTags = [],
repostKind,
targetKind,
targetEvent = null,
serializedEvent = "",
}) {
export function buildRepostEvent(params) {
const {
pubkey,
created_at,
eventId = "",
eventRelay = "",
address = "",
addressRelay = "",
authorPubkey = "",
additionalTags = [],
repostKind,
targetKind,
targetEvent = null,
serializedEvent = "",
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.REPOST);
const tags = [];
@@ -844,12 +858,13 @@ export function buildRepostEvent({
};
}
export function buildRelayListEvent({
pubkey,
created_at,
relays = [],
additionalTags = [],
}) {
export function buildRelayListEvent(params) {
const {
pubkey,
created_at,
relays = [],
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.RELAY_LIST);
const tags = [];
const relayTagName = schema?.relayTagName || "r";
@@ -914,17 +929,18 @@ export function buildRelayListEvent({
};
}
export function buildViewEvent({
pubkey,
created_at,
pointerValue,
pointerTag,
pointerTags = [],
dedupeTag,
includeSessionTag = false,
additionalTags = [],
content = "",
}) {
export function buildViewEvent(params) {
const {
pubkey,
created_at,
pointerValue,
pointerTag,
pointerTags = [],
dedupeTag,
includeSessionTag = false,
additionalTags = [],
content = "",
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
const tags = [];
if (schema?.topicTag?.name && schema?.topicTag?.value) {
@@ -974,17 +990,18 @@ export function buildViewEvent({
};
}
export function buildReactionEvent({
pubkey,
created_at,
pointerValue,
pointerTag,
pointerTags = [],
targetPointer = null,
targetAuthorPubkey = "",
additionalTags = [],
content = "",
}) {
export function buildReactionEvent(params) {
const {
pubkey,
created_at,
pointerValue,
pointerTag,
pointerTags = [],
targetPointer = null,
targetAuthorPubkey = "",
additionalTags = [],
content = "",
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_REACTION);
const tags = [];
@@ -1105,30 +1122,31 @@ export function buildReactionEvent({
};
}
export function buildCommentEvent({
pubkey,
created_at,
videoEventId = "",
videoEventRelay = "",
videoDefinitionAddress = "",
videoDefinitionRelay = "",
rootIdentifier = "",
rootIdentifierRelay = "",
parentCommentId = "",
parentCommentRelay = "",
threadParticipantPubkey = "",
threadParticipantRelay = "",
rootKind,
rootAuthorPubkey = "",
rootAuthorRelay = "",
parentKind,
parentAuthorPubkey = "",
parentAuthorRelay = "",
parentIdentifier = "",
parentIdentifierRelay = "",
additionalTags = [],
content = "",
}) {
export function buildCommentEvent(params) {
const {
pubkey,
created_at,
videoEventId = "",
videoEventRelay = "",
videoDefinitionAddress = "",
videoDefinitionRelay = "",
rootIdentifier = "",
rootIdentifierRelay = "",
parentCommentId = "",
parentCommentRelay = "",
threadParticipantPubkey = "",
threadParticipantRelay = "",
rootKind,
rootAuthorPubkey = "",
rootAuthorRelay = "",
parentKind,
parentAuthorPubkey = "",
parentAuthorRelay = "",
parentIdentifier = "",
parentIdentifierRelay = "",
additionalTags = [],
content = "",
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
const tags = [];
@@ -1349,13 +1367,14 @@ export function buildCommentEvent({
};
}
export function buildWatchHistoryEvent({
pubkey,
created_at,
monthIdentifier,
pointerTags = [],
content,
}) {
export function buildWatchHistoryEvent(params) {
const {
pubkey,
created_at,
monthIdentifier,
pointerTags = [],
content,
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
@@ -1388,12 +1407,13 @@ export function buildWatchHistoryEvent({
};
}
export function buildSubscriptionListEvent({
pubkey,
created_at,
content,
encryption,
}) {
export function buildSubscriptionListEvent(params) {
const {
pubkey,
created_at,
content,
encryption,
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
@@ -1416,12 +1436,13 @@ export function buildSubscriptionListEvent({
};
}
export function buildBlockListEvent({
pubkey,
created_at,
content,
encryption,
}) {
export function buildBlockListEvent(params) {
const {
pubkey,
created_at,
content,
encryption,
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.USER_BLOCK_LIST);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
@@ -1444,11 +1465,12 @@ export function buildBlockListEvent({
};
}
export function buildHashtagPreferenceEvent({
pubkey,
created_at,
content,
}) {
export function buildHashtagPreferenceEvent(params) {
const {
pubkey,
created_at,
content,
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
@@ -1480,7 +1502,12 @@ function resolveAdminNoteType(listKey) {
}
}
export function buildAdminListEvent(listKey, { pubkey, created_at, hexPubkeys = [] }) {
export function buildAdminListEvent(listKey, params) {
const {
pubkey,
created_at,
hexPubkeys = []
} = params || {};
const schema = getNostrEventSchema(resolveAdminNoteType(listKey));
if (!schema) {
devLogger.warn(
@@ -1515,30 +1542,6 @@ export function buildAdminListEvent(listKey, { pubkey, created_at, hexPubkeys =
};
}
export function buildStorageChallengeEvent({
pubkey,
created_at,
challenge,
}) {
const schema = getNostrEventSchema(NOTE_TYPES.STORAGE_CHALLENGE);
const tags = [];
const normalizedChallenge = typeof challenge === "string" ? challenge.trim() : "";
if (normalizedChallenge) {
tags.push(["challenge", normalizedChallenge]);
}
appendSchemaTags(tags, schema);
return {
kind: schema?.kind ?? 22242,
pubkey,
created_at,
tags,
content: "",
};
}
if (typeof window !== "undefined") {
window.bitvidNostrEvents = {
NOTE_TYPES,
+35 -2
View File
@@ -5,12 +5,14 @@ import {
requestDefaultExtensionPermissions,
} from "./nostrClientFacade.js";
import { getActiveSigner } from "./nostr/index.js";
import { buildRelayListEvent } from "./nostrEventSchemas.js";
import { buildRelayListEvent, NOTE_TYPES } from "./nostrEventSchemas.js";
import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js";
import { devLogger, userLogger } from "./utils/logger.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
} from "./nostrPublish.js";
import { profileCache } from "./state/profileCache.js";
const MODE_SEQUENCE = ["both", "read", "write"];
@@ -191,7 +193,37 @@ class RelayPreferencesManager {
const normalized = normalizeRelayUrl(url) || url;
return createEntry(normalized, "both");
});
this.setEntries(this.defaultEntries, { allowEmpty: false, updateClient: true });
// Attempt to load from storage if policy allows
const loaded = this.loadFromStorage();
if (loaded && loaded.length) {
this.setEntries(loaded, { allowEmpty: false, updateClient: true });
} else {
this.setEntries(this.defaultEntries, { allowEmpty: false, updateClient: true });
}
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
const fresh = this.loadFromStorage();
if (fresh && fresh.length) {
this.setEntries(fresh, { allowEmpty: false, updateClient: true });
} else {
this.setEntries(this.defaultEntries, { allowEmpty: false, updateClient: true });
}
}
});
}
loadFromStorage() {
const cached = profileCache.get("relays");
if (Array.isArray(cached)) {
return cached;
}
return null;
}
saveToStorage() {
profileCache.set("relays", this.entries.map((entry) => ({ url: entry.url, mode: entry.mode })));
}
snapshot() {
@@ -279,6 +311,7 @@ class RelayPreferencesManager {
this.syncClient();
}
this.saveToStorage();
return this.getEntries();
}
+414
View File
@@ -0,0 +1,414 @@
// js/searchView.js
import { nostrClient } from "./nostrClientFacade.js";
import { getApplication } from "./applicationContext.js";
import { renderChannelVideosFromList } from "./channelProfile.js";
import { escapeHTML } from "./utils/domUtils.js";
import { formatShortNpub } from "./utils/formatters.js";
import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js";
import { devLogger } from "./utils/logger.js";
function getApp() {
return getApplication();
}
const FALLBACK_AVATAR = "assets/svg/default-profile.svg";
function renderProfileCards(profiles, container) {
if (!container) return;
container.innerHTML = "";
if (!profiles || profiles.length === 0) {
container.innerHTML = `<p class="text-sm text-muted col-span-full">No matching channels found.</p>`;
return;
}
const fragment = document.createDocumentFragment();
for (const profile of profiles) {
const pubkey = profile.pubkey;
const metadata = profile.metadata || {};
const name = metadata.name || metadata.display_name || "Unknown User";
const picture = sanitizeProfileMediaUrl(metadata.picture) || FALLBACK_AVATAR;
const about = metadata.about || "";
let npub = "";
try {
if (window.NostrTools && window.NostrTools.nip19) {
npub = window.NostrTools.nip19.npubEncode(pubkey);
}
} catch (e) {
// ignore
}
const shortNpub = formatShortNpub(npub) || "";
const card = document.createElement("a");
card.href = `#view=channel-profile&npub=${npub || pubkey}`;
card.className = "flex items-center gap-3 p-3 rounded-lg bg-surface hover:bg-surface-elevated transition-colors border border-border group";
card.innerHTML = `
<div class="relative w-12 h-12 flex-shrink-0 rounded-full overflow-hidden bg-background">
<img
src="${escapeHTML(picture)}"
alt="${escapeHTML(name)}"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='${FALLBACK_AVATAR}'"
/>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-text-strong truncate group-hover:text-primary transition-colors">
${escapeHTML(name)}
</h3>
<p class="text-xs text-muted truncate">
${escapeHTML(shortNpub)}
</p>
</div>
`;
fragment.appendChild(card);
}
container.appendChild(fragment);
}
let currentSearchToken = 0;
export async function initSearchView() {
const hashParams = new URLSearchParams(window.location.hash.split("?")[1] || window.location.hash.slice(1));
// The hash format is #view=search&q=...
// URLSearchParams handles 'view=search&q=...' correctly if we pass the string after #
const query = hashParams.get("q") || "";
const titleEl = document.getElementById("searchTitle");
if (titleEl) {
titleEl.textContent = query ? `Search Results for "${query}"` : "Search Results";
}
const channelList = document.getElementById("searchChannelList");
const videoList = document.getElementById("searchVideoList");
if (channelList) {
channelList.innerHTML = `<p class="text-sm text-muted animate-pulse col-span-full">Searching channels...</p>`;
}
if (videoList) {
videoList.innerHTML = `<p class="text-sm text-muted animate-pulse col-span-full">Searching videos...</p>`;
}
if (!query) {
if (channelList) channelList.innerHTML = `<p class="text-sm text-muted col-span-full">Please enter a search term.</p>`;
if (videoList) videoList.innerHTML = `<p class="text-sm text-muted col-span-full">Please enter a search term.</p>`;
return;
}
const searchToken = ++currentSearchToken;
// 1. Search Profiles
performProfileSearch(query, searchToken).then(profiles => {
if (searchToken !== currentSearchToken) return;
renderProfileCards(profiles, channelList);
}).catch(err => {
devLogger.warn("[Search] Profile search failed", err);
if (channelList && searchToken === currentSearchToken) {
channelList.innerHTML = `<p class="text-sm text-critical col-span-full">Failed to load channels.</p>`;
}
});
// 2. Search Videos
performVideoSearch(query, searchToken).then(videos => {
if (searchToken !== currentSearchToken) return;
const app = getApp();
renderSearchVideos(videos, videoList, app);
}).catch(err => {
devLogger.warn("[Search] Video search failed", err);
if (videoList && searchToken === currentSearchToken) {
videoList.innerHTML = `<p class="text-sm text-critical col-span-full">Failed to load videos.</p>`;
}
});
}
async function renderSearchVideos(videos, container, app) {
if (!container) return;
container.innerHTML = "";
if (!videos || videos.length === 0) {
container.innerHTML = `<p class="text-sm text-muted col-span-full">No matching videos found.</p>`;
return;
}
// Dynamic import VideoCard to avoid circular dependencies if any
const { VideoCard } = await import("./ui/components/VideoCard.js");
const { escapeHTML } = await import("./utils/domUtils.js"); // Ensure availability
const fragment = document.createDocumentFragment();
// We need some helpers from app or utils
const loadedThumbnails = app?.loadedThumbnails instanceof Map ? app.loadedThumbnails : new Map();
let index = 0;
for (const video of videos) {
if (!video) continue;
// Basic decoration if needed (e.g. resolve author info if not present)
// For search results, we might not have full author profiles loaded.
// We will do best effort.
const shareUrl = app?.buildShareUrlFromEventId ? app.buildShareUrlFromEventId(video.id) : "#";
const timeAgo = new Date(video.created_at * 1000).toLocaleString();
// Construct identity object
const identity = {
name: video.authorName || "",
picture: video.authorPicture || "",
pubkey: video.pubkey,
npub: video.authorNpub || "",
shortNpub: video.shortNpub || ""
};
// If we have access to the profile cache, we could enrich this.
// For now, let's rely on what's in the video object or defaults.
const videoCard = new VideoCard({
document,
video,
index: index++,
shareUrl,
timeAgo,
cardState: "", // default
identity,
capabilities: {
canEdit: false,
canDelete: false
},
nsfwContext: {
isNsfw: video.isNsfw,
allowNsfw: true, // or import config
viewerIsOwner: false
},
helpers: {
escapeHtml: escapeHTML,
isMagnetSupported: (magnet) => magnet && magnet.startsWith("magnet:"),
toLocaleString: (val) => val.toLocaleString()
},
assets: {
fallbackThumbnailSrc: "assets/jpg/video-thumbnail-fallback.jpg",
unsupportedBtihMessage: "Unsupported magnet link"
},
state: { loadedThumbnails },
// Callbacks can be wired if we want full interactivity (play, etc)
ensureGlobalMoreMenuHandlers: () => {},
onRequestCloseAllMenus: () => {}
});
// Wire up Play
videoCard.onPlay = ({ event: domEvent }) => {
// We can reuse the global playback handler if available
if (app?.playVideoByEventId) {
app.playVideoByEventId(video.id, {
url: video.url,
magnet: video.magnet,
title: video.title
});
} else if (app?.playVideoWithFallback) {
app.playVideoWithFallback({
url: video.url,
magnet: video.magnet
});
}
};
videoCard.onAuthorNavigate = ({ pubkey }) => {
if (app?.goToProfile) {
app.goToProfile(pubkey || video.pubkey);
}
};
const el = videoCard.getRoot();
if (el) fragment.appendChild(el);
}
container.appendChild(fragment);
}
async function performProfileSearch(query, token) {
if (!query) return [];
const normalizedQuery = query.toLowerCase();
const uniqueProfiles = new Map();
// 1. Local Search (Scan known authors in cache)
if (nostrClient?.activeMap instanceof Map) {
const app = getApp();
if (app && typeof app.getProfileCacheEntry === 'function') {
const seenPubkeys = new Set();
for (const video of nostrClient.activeMap.values()) {
if (!video || !video.pubkey || seenPubkeys.has(video.pubkey)) continue;
seenPubkeys.add(video.pubkey);
const entry = app.getProfileCacheEntry(video.pubkey);
if (entry?.profile) {
const name = entry.profile.name || entry.profile.display_name || "";
const about = entry.profile.about || "";
const nip05 = entry.profile.nip05 || "";
const lightning = entry.profile.lightningAddress || "";
if (name.toLowerCase().includes(normalizedQuery) ||
about.toLowerCase().includes(normalizedQuery) ||
nip05.toLowerCase().includes(normalizedQuery) ||
lightning.toLowerCase().includes(normalizedQuery)) {
uniqueProfiles.set(video.pubkey, {
pubkey: video.pubkey,
metadata: entry.profile,
created_at: entry.event?.created_at || 0
});
}
} else if (video.authorName && video.authorName.toLowerCase().includes(normalizedQuery)) {
// Fallback to video metadata if profile cache missing
uniqueProfiles.set(video.pubkey, {
pubkey: video.pubkey,
metadata: {
name: video.authorName,
picture: video.authorPicture,
nip05: video.authorNip05
},
created_at: 0
});
}
}
}
}
if (currentSearchToken !== token) return [];
// 2. Relay Search (NIP-50)
if (nostrClient && nostrClient.pool) {
const relays = nostrClient.relays || [];
if (relays.length > 0) {
const filter = {
kinds: [0],
search: query,
limit: 20
};
try {
const events = await nostrClient.pool.list(relays, [filter]);
if (currentSearchToken === token) {
for (const event of events) {
try {
const metadata = JSON.parse(event.content);
// Merge/Overwrite with newer
const existing = uniqueProfiles.get(event.pubkey);
if (!existing || existing.created_at < event.created_at) {
uniqueProfiles.set(event.pubkey, {
pubkey: event.pubkey,
metadata,
created_at: event.created_at
});
}
} catch (e) {
// ignore invalid content
}
}
}
} catch (e) {
devLogger.warn("Profile relay search error", e);
}
}
}
if (currentSearchToken !== token) return [];
// Convert map to array and sort by something relevant (maybe creation time or exact match?)
return Array.from(uniqueProfiles.values());
}
async function performVideoSearch(query, token) {
if (!nostrClient) return [];
const isHashtag = query.startsWith("#");
const term = isHashtag ? query.slice(1) : query;
const lowerTerm = term.toLowerCase();
// 1. Local Search (Cache)
const localMatches = [];
if (nostrClient.activeMap instanceof Map) {
for (const video of nostrClient.activeMap.values()) {
if (!video || video.deleted) continue;
let matches = false;
if (isHashtag) {
// Check tags for 't'
if (Array.isArray(video.tags)) {
matches = video.tags.some(t => Array.isArray(t) && t[0] === "t" && t[1].toLowerCase() === lowerTerm);
}
} else {
// Check title/description
const title = video.title ? video.title.toLowerCase() : "";
const desc = video.description ? video.description.toLowerCase() : "";
if (title.includes(lowerTerm) || desc.includes(lowerTerm)) {
matches = true;
}
}
if (matches) {
localMatches.push(video);
}
}
}
if (currentSearchToken !== token) return [];
// 2. Relay Search
let relayVideos = [];
const relays = nostrClient.relays || [];
if (relays.length > 0 && nostrClient.pool) {
const filter = {
kinds: [30078],
limit: 50
};
if (isHashtag) {
filter["#t"] = [term];
} else {
filter.search = term;
}
try {
const events = await nostrClient.pool.list(relays, [filter]);
if (currentSearchToken === token) {
const { convertEventToVideo } = await import("./nostr/index.js");
relayVideos = events.map(evt => {
const vid = convertEventToVideo(evt);
if (vid.invalid) return null;
return vid;
}).filter(Boolean);
}
} catch (e) {
devLogger.warn("Video search error", e);
// Continue with local matches if relay search fails
}
}
if (currentSearchToken !== token) return [];
// Deduplicate by ID
const unique = new Map();
// Add local matches first
for (const v of localMatches) {
if (!unique.has(v.id)) unique.set(v.id, v);
}
// Add relay matches (overwriting local if needed, or ignoring duplicates)
for (const v of relayVideos) {
if (!unique.has(v.id)) unique.set(v.id, v);
}
// Sort by creation date (newest first)
return Array.from(unique.values()).sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
}
+29 -30
View File
@@ -22,6 +22,7 @@ import {
setProfileCacheEntry as setCachedProfileEntry,
resetModerationSettings,
} from "../state/cache.js";
import { profileCache } from "../state/profileCache.js";
import getDefaultAuthProvider, {
providers as defaultAuthProviders,
} from "./authProviders/index.js";
@@ -534,11 +535,13 @@ export default class AuthService {
if (this.nostrClient && typeof this.nostrClient === "object") {
this.nostrClient.pubkey = normalized;
}
profileCache.setActiveProfile(normalized);
} else {
setPubkey(nextPubkey);
if (this.nostrClient && typeof this.nostrClient === "object") {
this.nostrClient.pubkey = trimmed ? trimmed.toLowerCase() : "";
}
profileCache.setActiveProfile(trimmed ? trimmed.toLowerCase() : null);
}
const npub = candidateNpub;
@@ -1057,15 +1060,9 @@ export default class AuthService {
});
const newest = selectNewestEvent(aggregated);
if (!newest) {
return;
if (newest && this.nostrClient && typeof this.nostrClient.handleEvent === "function") {
this.nostrClient.handleEvent(newest);
}
const profile = buildProfileFromEvent(newest);
this.setProfileCacheEntry(normalized, profile, {
persist: true,
reason: "load-own-profile:background",
});
})
.catch((error) => {
this.log("[AuthService] Background profile refresh failed", error);
@@ -1093,11 +1090,12 @@ export default class AuthService {
if (fastResult?.events?.length) {
const newest = selectNewestEvent(fastResult.events);
if (newest) {
if (this.nostrClient && typeof this.nostrClient.handleEvent === "function") {
this.nostrClient.handleEvent(newest);
}
const profile = buildProfileFromEvent(newest);
this.setProfileCacheEntry(normalized, profile, {
persist: true,
reason: "load-own-profile:fast",
});
// setProfileCacheEntry now delegates to profileCache.setProfile, but handleEvent also does it.
// Doing it here returns the profile object which might be needed immediately.
background.catch(() => {});
return profile;
}
@@ -1156,24 +1154,25 @@ export default class AuthService {
}
}
if (newest?.content) {
const data = JSON.parse(newest.content);
const profile = {
name: data.display_name || data.name || FALLBACK_PROFILE.name,
picture: data.picture || FALLBACK_PROFILE.picture,
about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
website:
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website,
banner:
typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner,
lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
};
this.setProfileCacheEntry(normalized, profile, {
persist: true,
reason: forceRefresh ? "force-refresh" : "fetch-profile",
});
return profile;
if (newest) {
if (this.nostrClient && typeof this.nostrClient.handleEvent === "function") {
this.nostrClient.handleEvent(newest);
}
if (newest.content) {
const data = JSON.parse(newest.content);
const profile = {
name: data.display_name || data.name || FALLBACK_PROFILE.name,
picture: data.picture || FALLBACK_PROFILE.picture,
about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
website:
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website,
banner:
typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner,
lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
};
return profile;
}
}
} catch (error) {
this.log("[AuthService] Failed to fetch profile", error);
+131 -96
View File
@@ -2,8 +2,9 @@
import {
nostrClient,
requestDefaultExtensionPermissions,
getActiveSigner,
} from "../nostrClientFacade.js";
import { getActiveSigner } from "../nostr/index.js";
import { isSessionActor } from "../nostr/sessionActor.js";
import {
buildHashtagPreferenceEvent,
getNostrEventSchema,
@@ -13,7 +14,9 @@ import {
publishEventToRelays,
assertAnyRelayAccepted,
} from "../nostrPublish.js";
import { userLogger } from "../utils/logger.js";
import { userLogger, devLogger } from "../utils/logger.js";
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
import { profileCache } from "../state/profileCache.js";
const LOG_PREFIX = "[HashtagPreferences]";
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
@@ -93,19 +96,6 @@ function sanitizeRelayList(candidate) {
: [];
}
function normalizeTag(input) {
if (typeof input !== "string") {
return "";
}
const trimmed = input.trim().replace(/^#+/, "");
if (!trimmed) {
return "";
}
return trimmed.toLowerCase();
}
function normalizeEncryptionToken(value) {
if (typeof value !== "string") {
return "";
@@ -131,7 +121,7 @@ function normalizePreferencesPayload(payload) {
const interests = new Set();
for (const tag of rawInterests) {
const normalizedTag = normalizeTag(tag);
const normalizedTag = normalizeHashtag(tag);
if (normalizedTag) {
interests.add(normalizedTag);
}
@@ -139,7 +129,7 @@ function normalizePreferencesPayload(payload) {
const disinterests = new Set();
for (const tag of rawDisinterests) {
const normalizedTag = normalizeTag(tag);
const normalizedTag = normalizeHashtag(tag);
if (!normalizedTag) {
continue;
}
@@ -244,6 +234,20 @@ class HashtagPreferencesService {
this.eventCreatedAt = null;
this.loaded = false;
this.preferencesVersion = DEFAULT_VERSION;
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
this.reset();
if (detail.pubkey) {
this.load(detail.pubkey);
}
} else if (event === "runtimeCleared" && detail.pubkey === this.activePubkey) {
this.reset();
if (this.activePubkey) {
this.load(this.activePubkey);
}
}
});
}
on(eventName, handler) {
@@ -260,6 +264,63 @@ class HashtagPreferencesService {
this.emitChange("reset");
}
saveToCache() {
const interests = this.getInterests();
const disinterests = this.getDisinterests();
const payload = {
interests,
disinterests,
version: this.preferencesVersion,
eventId: this.eventId,
createdAt: this.eventCreatedAt,
};
// Use the note type key directly, though "interests" maps to HASHTAG_PREFERENCES
// in profileCache. Using NOTE_TYPES.HASHTAG_PREFERENCES explicitly is safer if we import it,
// but relying on the mapped key "interests" is also consistent with current usage.
// However, the fix requires us to ensure we use the unified path.
// We'll stick to "interests" which maps to NOTE_TYPES.HASHTAG_PREFERENCES in profileCache.js
profileCache.set("interests", payload);
}
loadFromCache(pubkey) {
// profileCache internally uses activePubkey, but we should verify if it matches
try {
let cached = profileCache.get("interests");
// Legacy fallback migration
if (!cached && typeof localStorage !== "undefined") {
try {
const legacy = localStorage.getItem(HASHTAG_IDENTIFIER);
if (legacy) {
const parsed = JSON.parse(legacy);
// Migrate to unified profile cache
profileCache.set("interests", parsed);
localStorage.removeItem(HASHTAG_IDENTIFIER);
cached = parsed;
devLogger.log(`${LOG_PREFIX} Migrated legacy preferences to profileCache.`);
}
} catch (err) {
devLogger.warn(`${LOG_PREFIX} Legacy migration failed:`, err);
}
}
if (cached && typeof cached === "object") {
this.activePubkey = normalizeHexPubkey(pubkey);
this.interests = new Set(Array.isArray(cached.interests) ? cached.interests : []);
this.disinterests = new Set(Array.isArray(cached.disinterests) ? cached.disinterests : []);
this.eventId = cached.eventId || null;
this.eventCreatedAt = cached.createdAt || null;
this.preferencesVersion = cached.version || DEFAULT_VERSION;
this.loaded = true;
this.emitChange("cache-load");
return true;
}
} catch (err) {
devLogger.warn(`${LOG_PREFIX} loadFromCache failed:`, err);
}
return false;
}
getInterests() {
return Array.from(this.interests).sort((a, b) => a.localeCompare(b));
}
@@ -269,7 +330,7 @@ class HashtagPreferencesService {
}
addInterest(tag) {
const normalized = normalizeTag(tag);
const normalized = normalizeHashtag(tag);
if (!normalized) {
return false;
}
@@ -287,7 +348,7 @@ class HashtagPreferencesService {
}
removeInterest(tag) {
const normalized = normalizeTag(tag);
const normalized = normalizeHashtag(tag);
if (!normalized || !this.interests.has(normalized)) {
return false;
}
@@ -298,7 +359,7 @@ class HashtagPreferencesService {
}
addDisinterest(tag) {
const normalized = normalizeTag(tag);
const normalized = normalizeHashtag(tag);
if (!normalized) {
return false;
}
@@ -316,7 +377,7 @@ class HashtagPreferencesService {
}
removeDisinterest(tag) {
const normalized = normalizeTag(tag);
const normalized = normalizeHashtag(tag);
if (!normalized || !this.disinterests.has(normalized)) {
return false;
}
@@ -344,7 +405,7 @@ class HashtagPreferencesService {
async load(pubkey) {
const normalized = normalizeHexPubkey(pubkey);
const wasLoadedForUser =
let wasLoadedForUser =
this.activePubkey &&
this.activePubkey === normalized &&
(this.interests.size > 0 || this.disinterests.size > 0 || this.loaded);
@@ -357,6 +418,11 @@ class HashtagPreferencesService {
return;
}
// Try cache first
if (this.loadFromCache(normalized)) {
wasLoadedForUser = true;
}
if (
!nostrClient ||
!nostrClient.pool ||
@@ -403,21 +469,24 @@ class HashtagPreferencesService {
kinds.push(legacyKind);
}
const filter = {
kinds,
authors: [normalized],
"#d": [HASHTAG_IDENTIFIER],
limit: 50,
};
let events = [];
let fetchError = null;
try {
const result = await nostrClient.pool.list(relays, [filter]);
if (Array.isArray(result)) {
events = result.filter((event) => event && event.pubkey === normalized);
}
// Use incremental fetching for both kinds concurrently (if multiple kinds)
// fetchListIncrementally takes a single kind.
// kinds is array of [canonicalKind, legacyKind] if different.
const promises = kinds.map(kind => nostrClient.fetchListIncrementally({
kind,
pubkey: normalized,
dTag: HASHTAG_IDENTIFIER,
relayUrls: relays
}));
const results = await Promise.all(promises);
events = results.flat();
} catch (error) {
fetchError = error;
userLogger.warn(
@@ -430,6 +499,7 @@ class HashtagPreferencesService {
if (!events.length) {
// If we failed to fetch (network error) but already have data for this user,
// preserve the existing state instead of wiping it.
// If fetchListIncrementally returns empty, it means no new updates or full fetch yielded nothing.
if (wasLoadedForUser) {
userLogger.warn(
`${LOG_PREFIX} Keeping existing preferences despite empty relay response.`,
@@ -494,9 +564,16 @@ class HashtagPreferencesService {
`${LOG_PREFIX} Failed to decrypt hashtag preferences`,
decryptResult.error,
);
// If decryption fails on a reload, we might want to keep old data?
// But typically decryption failure means key mismatch or bad payload.
// Resetting is safer here to avoid stuck state, unless we want to be very conservative.
// If we already have loaded preferences (e.g. from cache), preserve them
// rather than wiping everything just because the remote update couldn't be decrypted.
if (wasLoadedForUser) {
userLogger.warn(
`${LOG_PREFIX} Preserving cached preferences despite decryption failure.`,
);
return;
}
this.reset();
this.loaded = true;
return;
@@ -516,6 +593,7 @@ class HashtagPreferencesService {
? latest.created_at
: null;
this.loaded = true;
this.saveToCache();
this.emitChange("sync", { scheme: decryptResult.scheme });
} catch (error) {
userLogger.warn(
@@ -535,7 +613,10 @@ class HashtagPreferencesService {
return { ok: false, error };
}
const signer = getActiveSigner();
let signer = getActiveSigner();
if (!signer && typeof nostrClient?.ensureActiveSignerForPubkey === "function") {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
@@ -576,39 +657,6 @@ class HashtagPreferencesService {
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.",
@@ -649,6 +697,15 @@ class HashtagPreferencesService {
}
async publish(options = {}) {
// nostrClient imported from nostrClientFacade
if (isSessionActor(nostrClient)) {
const error = new Error(
"Publishing preferences is not allowed for session actors."
);
error.code = "session-actor-publish-blocked";
throw error;
}
const targetPubkey = normalizeHexPubkey(
typeof options?.pubkey === "string" ? options.pubkey : this.activePubkey,
);
@@ -663,7 +720,10 @@ class HashtagPreferencesService {
signer = await nostrClient.ensureActiveSignerForPubkey(targetPubkey);
}
if (!signer || typeof signer.signEvent !== "function") {
const canSign = typeof signer?.canSign === "function"
? signer.canSign()
: typeof signer?.signEvent === "function";
if (!canSign || typeof signer?.signEvent !== "function") {
const error = new Error("An active signer is required to publish preferences.");
error.code = "hashtag-preferences-missing-signer";
throw error;
@@ -710,31 +770,6 @@ class HashtagPreferencesService {
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";
+46 -9
View File
@@ -97,7 +97,7 @@ export default class ModerationActionController {
return false;
}
this.persistModerationOverride(video.id);
this.persistModerationOverride(video);
const target = this.resolveTargetVideo(video);
this.clearHideState(target);
@@ -199,7 +199,7 @@ export default class ModerationActionController {
this.showStatus(statusMessage, { showSpinner: false });
}
this.clearModerationOverride(video.id);
this.clearModerationOverride(video);
const target = this.resolveTargetVideo(video);
this.clearViewerOverride(target);
@@ -233,7 +233,7 @@ export default class ModerationActionController {
return false;
}
this.clearModerationOverride(video.id);
this.clearModerationOverride(video);
const target = this.resolveTargetVideo(video);
this.clearViewerOverride(target);
@@ -427,13 +427,45 @@ export default class ModerationActionController {
}
}
persistModerationOverride(eventId) {
if (!eventId || !this.services.setModerationOverride) {
getModerationOverrideDescriptor(video) {
if (!video || typeof video !== "object") {
return null;
}
const eventId = video.id;
if (!eventId) {
return null;
}
const rawAuthor =
typeof video.pubkey === "string"
? video.pubkey
: typeof video.author?.pubkey === "string"
? video.author.pubkey
: "";
const normalizedAuthor =
rawAuthor && this.auth.normalizePubkey
? this.auth.normalizePubkey(rawAuthor)
: rawAuthor;
return {
eventId,
authorPubkey: normalizedAuthor || "",
};
}
persistModerationOverride(video) {
if (!this.services.setModerationOverride) {
return;
}
const descriptor = this.getModerationOverrideDescriptor(video);
if (!descriptor) {
return;
}
try {
this.services.setModerationOverride(eventId, {
this.services.setModerationOverride(descriptor, {
showAnyway: true,
updatedAt: Date.now(),
});
@@ -445,13 +477,18 @@ export default class ModerationActionController {
}
}
clearModerationOverride(eventId) {
if (!eventId || !this.services.clearModerationOverride) {
clearModerationOverride(video) {
if (!this.services.clearModerationOverride) {
return;
}
const descriptor = this.getModerationOverrideDescriptor(video);
if (!descriptor) {
return;
}
try {
this.services.clearModerationOverride(eventId);
this.services.clearModerationOverride(descriptor);
} catch (error) {
devLogger.warn(
"[ModerationActionController] Failed to clear moderation override:",
+257 -46
View File
@@ -1,4 +1,8 @@
import { nostrClient } from "../nostrClientFacade.js";
import {
getActiveSigner,
nostrClient,
requestDefaultExtensionPermissions,
} from "../nostrClientFacade.js";
import { publishEventToRelays, assertAnyRelayAccepted } from "../nostrPublish.js";
import { accessControl } from "../accessControl.js";
import { userBlocks, USER_BLOCK_EVENTS } from "../userBlocks.js";
@@ -6,6 +10,8 @@ import logger from "../utils/logger.js";
const AUTOPLAY_TRUST_THRESHOLD = 1;
const BLUR_TRUST_THRESHOLD = 1;
const TRUSTED_MUTE_WINDOW_DAYS = 60;
const TRUSTED_MUTE_WINDOW_SECONDS = TRUSTED_MUTE_WINDOW_DAYS * 24 * 60 * 60;
function normalizeUserLogger(candidate) {
if (
@@ -107,6 +113,39 @@ function normalizeReportType(value) {
return trimmed ? trimmed : "";
}
function isRelayHint(value) {
if (typeof value !== "string") {
return false;
}
const trimmed = value.trim().toLowerCase();
return (
trimmed.startsWith("wss://") ||
trimmed.startsWith("ws://") ||
trimmed.startsWith("https://") ||
trimmed.startsWith("http://")
);
}
function normalizeMuteCategory(value) {
return normalizeReportType(value);
}
function extractMuteCategoryFromTag(tag) {
if (!Array.isArray(tag)) {
return "";
}
const direct = normalizeMuteCategory(tag[3]);
if (direct) {
return direct;
}
const fallback = typeof tag[2] === "string" && !isRelayHint(tag[2])
? normalizeMuteCategory(tag[2])
: "";
return fallback;
}
function cloneSummary(summary) {
if (!summary || typeof summary !== "object") {
return { eventId: "", totalTrusted: 0, types: {}, updatedAt: 0 };
@@ -466,10 +505,12 @@ export class ModerationService {
userBlocks: userBlockManager = null,
accessControl: accessControlService = null,
userLogger: userChannel = null,
requestExtensionPermissions: permissionRequester = null,
} = {}) {
this.nostrClient = client;
this.log = normalizeLogger(log);
this.userLogger = normalizeUserLogger(userChannel);
this.requestExtensionPermissions = permissionRequester || requestDefaultExtensionPermissions;
this.viewerPubkey = "";
this.viewerIsSessionActor = false;
@@ -495,6 +536,7 @@ export class ModerationService {
this.trustedMuteLists = new Map();
this.trustedMutedAuthors = new Map();
this.trustedMuteSubscriptions = new Map();
this.trustedSeedOnly = false;
this.emitter = new SimpleEventEmitter((message, error) => {
try {
@@ -547,6 +589,35 @@ export class ModerationService {
return merged;
}
computeTrustedSeedOnly() {
const trustedContacts =
this.trustedContacts instanceof Set ? this.trustedContacts : new Set();
const trustedSeedContacts =
this.trustedSeedContacts instanceof Set ? this.trustedSeedContacts : new Set();
if (!trustedSeedContacts.size || trustedContacts.size !== trustedSeedContacts.size) {
return false;
}
for (const value of trustedContacts) {
if (!trustedSeedContacts.has(value)) {
return false;
}
}
return true;
}
updateTrustedSeedOnlyStatus() {
const nextValue = this.computeTrustedSeedOnly();
if (nextValue === this.trustedSeedOnly) {
return;
}
this.trustedSeedOnly = nextValue;
this.emit("trusted-seed-only", { seedOnly: nextValue });
}
rebuildTrustedContacts(nextContacts = new Set(), { previous = null } = {}) {
const sanitized = new Set();
@@ -578,6 +649,7 @@ export class ModerationService {
const seededNext = this.mergeSeedsIntoSet(sanitized);
this.trustedContacts = seededNext;
this.updateTrustedSeedOnlyStatus();
this.emit("contacts", { size: seededNext.size });
this.recomputeAllSummaries();
this.reconcileTrustedMuteSubscriptions(previousContacts, seededNext);
@@ -679,6 +751,10 @@ export class ModerationService {
this.rebuildTrustedContacts(this.viewerContacts, { previous: previousContacts });
}
isTrustedSeedOnly() {
return this.trustedSeedOnly === true;
}
logThresholdTransitions({
eventId = "",
reportType = "",
@@ -735,6 +811,105 @@ export class ModerationService {
}
}
getTrustedMuteWindowCutoff(nowSeconds = Date.now() / 1000) {
const normalizedNow = ensureNumber(nowSeconds) || Date.now() / 1000;
return normalizedNow - TRUSTED_MUTE_WINDOW_SECONDS;
}
pruneTrustedMuteAggregates(targetAuthors = null, { nowSeconds = Date.now() / 1000 } = {}) {
const cutoff = this.getTrustedMuteWindowCutoff(nowSeconds);
const targets =
targetAuthors instanceof Set || Array.isArray(targetAuthors)
? Array.from(targetAuthors)
: targetAuthors && typeof targetAuthors[Symbol.iterator] === "function"
? Array.from(targetAuthors)
: null;
const entries = targets
? targets.map((author) => [author, this.trustedMutedAuthors.get(author)])
: Array.from(this.trustedMutedAuthors.entries());
for (const [author, aggregate] of entries) {
if (!aggregate || !(aggregate.muters instanceof Map)) {
continue;
}
const expiredOwners = new Set();
for (const [owner, updatedAt] of aggregate.muters.entries()) {
if (ensureNumber(updatedAt) < cutoff) {
aggregate.muters.delete(owner);
expiredOwners.add(owner);
}
}
if (aggregate.categories instanceof Map && expiredOwners.size) {
for (const [category, muters] of aggregate.categories.entries()) {
for (const owner of expiredOwners) {
muters.delete(owner);
}
if (!muters.size) {
aggregate.categories.delete(category);
}
}
}
aggregate.count = aggregate.muters.size;
if (!aggregate.muters.size) {
this.trustedMutedAuthors.delete(author);
}
}
}
getActiveTrustedMutersForAuthor(author) {
const normalized = normalizeHex(author);
if (!normalized) {
return [];
}
const entry = this.trustedMutedAuthors.get(normalized);
if (!entry || !(entry.muters instanceof Map)) {
return [];
}
this.pruneTrustedMuteAggregates([normalized]);
const refreshed = this.trustedMutedAuthors.get(normalized);
if (!refreshed || !(refreshed.muters instanceof Map)) {
return [];
}
return Array.from(refreshed.muters.keys());
}
getTrustedMuteCountsForAuthor(author) {
const normalized = normalizeHex(author);
if (!normalized) {
return { total: 0, categories: {} };
}
const entry = this.trustedMutedAuthors.get(normalized);
if (!entry || !(entry.muters instanceof Map)) {
return { total: 0, categories: {} };
}
this.pruneTrustedMuteAggregates([normalized]);
const refreshed = this.trustedMutedAuthors.get(normalized);
if (!refreshed || !(refreshed.muters instanceof Map)) {
return { total: 0, categories: {} };
}
const categories = {};
if (refreshed.categories instanceof Map) {
for (const [category, muters] of refreshed.categories.entries()) {
if (typeof category !== "string" || !category.trim()) {
continue;
}
categories[category] = muters instanceof Map ? muters.size : 0;
}
}
return {
total: refreshed.muters.size,
categories,
};
}
setLogger(newLogger) {
this.log = normalizeLogger(newLogger);
}
@@ -847,7 +1022,7 @@ export class ModerationService {
this.trustedMuteSubscriptions.clear();
for (const [author, aggregate] of this.trustedMutedAuthors.entries()) {
if (aggregate && aggregate.muters instanceof Set) {
if (aggregate && aggregate.muters instanceof Map) {
aggregate.muters.clear();
}
}
@@ -1052,38 +1227,72 @@ export class ModerationService {
return;
}
const sanitizedAuthors = new Set();
if (mutedAuthors instanceof Set || Array.isArray(mutedAuthors)) {
const sanitizedAuthors = new Map();
const addAuthor = (candidate, category = "") => {
const normalized = normalizeToHex(candidate);
if (!normalized) {
return;
}
const normalizedCategory = normalizeMuteCategory(category);
sanitizedAuthors.set(normalized, normalizedCategory);
};
if (mutedAuthors instanceof Map) {
for (const [candidate, category] of mutedAuthors.entries()) {
addAuthor(candidate, category);
}
} else if (mutedAuthors instanceof Set || Array.isArray(mutedAuthors)) {
for (const candidate of mutedAuthors) {
const normalized = normalizeToHex(candidate);
if (!normalized) {
if (!candidate) {
continue;
}
sanitizedAuthors.add(normalized);
if (typeof candidate === "object") {
addAuthor(candidate.pubkey || candidate.author, candidate.category || candidate.reason);
continue;
}
addAuthor(candidate);
}
} else if (mutedAuthors && typeof mutedAuthors[Symbol.iterator] === "function") {
for (const candidate of mutedAuthors) {
const normalized = normalizeToHex(candidate);
if (!normalized) {
if (!candidate) {
continue;
}
sanitizedAuthors.add(normalized);
if (typeof candidate === "object") {
addAuthor(candidate.pubkey || candidate.author, candidate.category || candidate.reason);
continue;
}
addAuthor(candidate);
}
}
const touchedAuthors = new Set();
const previous = this.trustedMuteLists.get(owner);
if (previous && previous.authors instanceof Set) {
for (const author of previous.authors) {
if (previous && previous.authors instanceof Map) {
for (const [author, category] of previous.authors.entries()) {
const aggregate = this.trustedMutedAuthors.get(author);
if (!aggregate || !(aggregate.muters instanceof Set)) {
if (!aggregate || !(aggregate.muters instanceof Map)) {
continue;
}
aggregate.muters.delete(owner);
if (!aggregate.muters.size) {
this.trustedMutedAuthors.delete(author);
} else {
aggregate.count = aggregate.muters.size;
if (aggregate.categories instanceof Map) {
if (category) {
const categoryMuters = aggregate.categories.get(category);
if (categoryMuters instanceof Map) {
categoryMuters.delete(owner);
if (!categoryMuters.size) {
aggregate.categories.delete(category);
}
}
} else {
for (const [existingCategory, muters] of aggregate.categories.entries()) {
muters.delete(owner);
if (!muters.size) {
aggregate.categories.delete(existingCategory);
}
}
}
}
touchedAuthors.add(author);
}
}
@@ -1105,17 +1314,26 @@ export class ModerationService {
eventId: normalizedEventId,
});
for (const author of sanitizedAuthors) {
for (const [author, category] of sanitizedAuthors.entries()) {
let aggregate = this.trustedMutedAuthors.get(author);
if (!aggregate) {
aggregate = { muters: new Set(), count: 0 };
aggregate = { muters: new Map(), categories: new Map(), count: 0 };
this.trustedMutedAuthors.set(author, aggregate);
}
aggregate.muters.add(owner);
aggregate.count = aggregate.muters.size;
aggregate.muters.set(owner, normalizedCreatedAt);
if (category) {
let categoryMuters = aggregate.categories.get(category);
if (!categoryMuters) {
categoryMuters = new Map();
aggregate.categories.set(category, categoryMuters);
}
categoryMuters.set(owner, normalizedCreatedAt);
}
touchedAuthors.add(author);
}
}
this.pruneTrustedMuteAggregates(touchedAuthors);
this.emit("trusted-mutes", { total: this.trustedMutedAuthors.size, owner });
}
@@ -1144,7 +1362,7 @@ export class ModerationService {
}
}
const mutedAuthors = new Set();
const mutedAuthors = new Map();
if (Array.isArray(event.tags)) {
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag.length < 2) {
@@ -1155,7 +1373,8 @@ export class ModerationService {
}
const normalized = normalizeToHex(tag[1]);
if (normalized) {
mutedAuthors.add(normalized);
const category = extractMuteCategoryFromTag(tag);
mutedAuthors.set(normalized, category);
}
}
}
@@ -1173,7 +1392,6 @@ export class ModerationService {
return;
}
this.log(`[moderationService] ingesting trusted mute event from ${owner} (${event.id})`);
this.applyTrustedMuteEvent(owner, event);
}
@@ -1330,15 +1548,22 @@ export class ModerationService {
await this.ensurePool();
const extension = typeof window !== "undefined" ? window.nostr : null;
if (!extension || typeof extension.signEvent !== "function") {
let signer = getActiveSigner();
if (!signer && typeof this.nostrClient?.ensureActiveSignerForPubkey === "function") {
signer = await this.nostrClient.ensureActiveSignerForPubkey(viewer);
}
const canSign = typeof signer?.canSign === "function"
? signer.canSign()
: typeof signer?.signEvent === "function";
if (!canSign || typeof signer?.signEvent !== "function") {
const error = new Error("nostr-extension-missing");
error.code = "nostr-extension-missing";
throw error;
}
if (typeof this.nostrClient?.ensureExtensionPermissions === "function") {
const permissionResult = await this.nostrClient.ensureExtensionPermissions([
if (signer?.type === "extension") {
const permissionResult = await this.requestExtensionPermissions([
"sign_event",
"get_public_key",
]);
@@ -1371,7 +1596,7 @@ export class ModerationService {
let signedEvent;
try {
signedEvent = await extension.signEvent(event);
signedEvent = await signer.signEvent(event);
} catch (error) {
const wrapped = new Error("signature-failed");
wrapped.code = "signature-failed";
@@ -1460,26 +1685,12 @@ export class ModerationService {
}
isAuthorMutedByTrusted(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return false;
}
const entry = this.trustedMutedAuthors.get(normalized);
return Boolean(entry && entry.muters instanceof Set && entry.muters.size > 0);
const muters = this.getActiveTrustedMutersForAuthor(pubkey);
return muters.length > 0;
}
getTrustedMutersForAuthor(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return [];
}
const entry = this.trustedMutedAuthors.get(normalized);
if (!entry || !(entry.muters instanceof Set)) {
return [];
}
return Array.from(entry.muters);
return this.getActiveTrustedMutersForAuthor(pubkey);
}
isPubkeyBlockedByViewer(pubkey) {
+105 -6
View File
@@ -17,6 +17,52 @@ import {
} from "../state/appState.js";
const VIDEO_KIND = 30078;
const STORAGE_KEY_FILTERED_VIDEOS = "bitvid:filtered-videos:v1";
const MAX_PERSISTED_VIDEOS = 100;
function loadPersistedFilteredVideos() {
if (
typeof window === "undefined" ||
!window.localStorage ||
typeof window.localStorage.getItem !== "function"
) {
return [];
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY_FILTERED_VIDEOS);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
userLogger.warn("[nostrService] Failed to load persisted filtered videos", error);
return [];
}
}
function persistFilteredVideos(videos) {
if (
typeof window === "undefined" ||
!window.localStorage ||
typeof window.localStorage.setItem !== "function"
) {
return;
}
if (!Array.isArray(videos)) {
return;
}
try {
const sliced = videos.slice(0, MAX_PERSISTED_VIDEOS);
const serialized = JSON.stringify(sliced);
window.localStorage.setItem(STORAGE_KEY_FILTERED_VIDEOS, serialized);
} catch (error) {
userLogger.warn("[nostrService] Failed to persist filtered videos", error);
}
}
class SimpleEventEmitter {
constructor(logger = null) {
@@ -1104,12 +1150,16 @@ export class NostrService {
this.ensureInitialLoadDeferred();
try {
this.ensureAccessControlReady().catch((error) => {
// Ensure access control is ready before we attempt the optimistic re-filter
// so checks like canAccess() are accurate.
try {
await this.ensureAccessControlReady();
} catch (error) {
userLogger.warn(
"[nostrService] Background access control ready check failed:",
"[nostrService] Access control ready check failed (continuing):",
error
);
});
}
if (forceFetch) {
this.clearVideoSubscription();
@@ -1129,13 +1179,62 @@ export class NostrService {
}
}
this.emit("videos:updated", { videos: filtered, reason });
// Persist the fresh filtered result for next time
if (filtered.length > 0) {
persistFilteredVideos(filtered);
}
return filtered;
};
const cached = this.nostrClient.getActiveVideos();
const initial = applyAndNotify(cached, "cache");
// Optimistic Step: Load from persistent cache
let hasOptimisticData = false;
let optimisticResult = [];
try {
const persisted = loadPersistedFilteredVideos();
if (persisted && persisted.length > 0) {
// Re-filter synchronously using strict checks (same as normal flow)
const optimisticFiltered = this.filterVideos(persisted, {
blacklistedEventIds,
isAuthorBlocked,
});
this.resolveInitialLoad({ videos: initial, reason: "cache" });
if (optimisticFiltered.length > 0) {
this.cacheVideos(optimisticFiltered);
hasOptimisticData = true;
optimisticResult = optimisticFiltered;
if (typeof onVideos === "function") {
try {
onVideos(optimisticFiltered, { reason: "persistent-cache" });
} catch (error) {
userLogger.warn("[nostrService] onVideos handler threw (optimistic)", error);
}
}
this.emit("videos:updated", { videos: optimisticFiltered, reason: "persistent-cache" });
this.resolveInitialLoad({ videos: optimisticFiltered, reason: "persistent-cache" });
}
}
} catch (optimisticError) {
userLogger.warn("[nostrService] Optimistic load failed", optimisticError);
}
const cached = this.nostrClient.getActiveVideos();
// If we loaded optimistically and the in-memory cache is empty,
// skip emitting the empty list to prevent flashing empty content.
// Otherwise, proceed as normal.
const shouldSkipEmptyCache = hasOptimisticData && (!cached || cached.length === 0);
let initial = [];
if (!shouldSkipEmptyCache) {
initial = applyAndNotify(cached, "cache");
this.resolveInitialLoad({ videos: initial, reason: "cache" });
} else {
// If we skipped, return the optimistic data so the caller receives the currently visible videos
initial = optimisticResult;
}
if (!getStoredVideoSubscription()) {
const subscriptionOptions = {
-139
View File
@@ -1,139 +0,0 @@
import { devLogger } from "../utils/logger.js";
import { buildStorageChallengeEvent } from "../nostrEventSchemas.js";
/**
* Generates a random 32-byte hex challenge string.
* @returns {string} The challenge string.
*/
function generateRandomChallenge() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Requests a signature for a storage key derivation challenge.
*
* @param {object} params
* @param {object} params.signer - The active signer (must implement signEvent).
* @param {string} params.pubkey - The user's public key (hex).
* @returns {Promise<string>} The signature (hex string).
*/
export async function requestStorageSignature({ signer, pubkey }) {
if (!signer || typeof signer.signEvent !== "function") {
throw new Error("Invalid signer provided");
}
if (!pubkey) {
throw new Error("Missing pubkey");
}
const challenge = generateRandomChallenge();
const event = buildStorageChallengeEvent({
pubkey,
created_at: Math.floor(Date.now() / 1000),
challenge,
});
devLogger.info("[SignerKeyManager] Requesting signature for storage challenge");
const signedEvent = await signer.signEvent(event);
if (!signedEvent.sig) {
throw new Error("Signer returned event without signature");
}
// Basic validation that the signer actually signed *our* event
// (In a real scenario, we might also verify the signature against the pubkey,
// but here we trust the signer returned what we asked for if the IDs match).
if (signedEvent.content !== event.content || signedEvent.kind !== event.kind) {
devLogger.warn("[SignerKeyManager] Signer returned modified event structure");
}
// Mask the signature in logs if we were to log it (we won't).
devLogger.info("[SignerKeyManager] Received signature");
return signedEvent.sig;
}
/**
* Derives an AES-GCM CryptoKey from a signature.
*
* @param {string} signature - The hex signature string.
* @returns {Promise<CryptoKey>} The derived symmetric key.
*/
export async function deriveKeyFromSignature(signature) {
if (!signature || typeof signature !== "string") {
throw new Error("Invalid signature");
}
// 1. Convert hex signature to Uint8Array
const signatureBytes = new Uint8Array(
signature.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))
);
// 2. Hash the signature with SHA-256 to get uniform key material
const hashBuffer = await crypto.subtle.digest("SHA-256", signatureBytes);
// 3. Import as AES-GCM key
const key = await crypto.subtle.importKey(
"raw",
hashBuffer,
{ name: "AES-GCM" },
false, // not extractable
["encrypt", "decrypt"]
);
return key;
}
/**
* Generates a random 16-byte salt for PBKDF2.
* @returns {Uint8Array} The salt.
*/
export function generatePassphraseSalt() {
const salt = new Uint8Array(16);
crypto.getRandomValues(salt);
return salt;
}
/**
* Derives an AES-GCM CryptoKey from a passphrase using PBKDF2.
*
* @param {string} passphrase - The user's passphrase.
* @param {Uint8Array} salt - The salt.
* @returns {Promise<CryptoKey>} The derived symmetric key.
*/
export async function deriveKeyFromPassphrase(passphrase, salt) {
if (!passphrase) {
throw new Error("Passphrase required");
}
if (!salt || !(salt instanceof Uint8Array) || salt.length !== 16) {
throw new Error("Invalid salt (must be 16 bytes)");
}
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
enc.encode(passphrase),
{ name: "PBKDF2" },
false,
["deriveKey"]
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
return key;
}
+3
View File
@@ -117,6 +117,9 @@ export async function bootstrapTrustedSeeds() {
});
devLogger.info(`[trustBootstrap] Applying ${seeds.size} trusted seeds`, Array.from(seeds));
moderationService.setTrustedSeeds(seeds);
if (typeof moderationService.updateTrustedSeedOnlyStatus === "function") {
moderationService.updateTrustedSeedOnlyStatus();
}
if (
moderationService &&
typeof moderationService.recomputeAllSummaries === "function"
+18
View File
@@ -52,6 +52,7 @@ export default class WatchHistoryTelemetry {
normalizeHexPubkey,
getActiveUserPubkey,
ingestLocalViewEvent = defaultIngestLocalViewEvent,
onViewLogged,
viewThresholdSeconds = DEFAULT_VIEW_THRESHOLD_SECONDS,
} = {}) {
this.watchHistoryService = watchHistoryService;
@@ -68,6 +69,7 @@ export default class WatchHistoryTelemetry {
typeof ingestLocalViewEvent === "function"
? ingestLocalViewEvent
: defaultIngestLocalViewEvent;
this.onViewLogged = typeof onViewLogged === "function" ? onViewLogged : null;
this.viewThresholdSeconds = Number.isFinite(viewThresholdSeconds)
? Math.max(0, viewThresholdSeconds)
: DEFAULT_VIEW_THRESHOLD_SECONDS;
@@ -271,6 +273,22 @@ export default class WatchHistoryTelemetry {
);
}
}
if (this.onViewLogged) {
try {
this.onViewLogged({
pointer: thresholdPointer,
pointerKey: thresholdPointerKey,
video: state.videoMetadata,
event: viewResult?.event || null,
result: viewResult || null,
});
} catch (error) {
devLogger.warn(
"[watchHistoryTelemetry] onViewLogged handler failed:",
error,
);
}
}
} else if (isDevMode && viewResult) {
userLogger.warn(
"[watchHistoryTelemetry] View event rejected by relays:",
+2 -2
View File
@@ -1,5 +1,5 @@
// sidebar.js
import { setHashView } from "./index.js";
import { setHashView } from "./hashView.js";
export function setupSidebarNavigation() {
const sidebarLinks = document.querySelectorAll('#sidebar a[href^="#view="]');
@@ -12,7 +12,7 @@ export function setupSidebarNavigation() {
const match = href.match(/^#view=(.+)/);
if (!match) return;
const viewName = match[1]; // "about", "ipns", etc.
const viewName = match[1]; // "about", etc.
setHashView(viewName); // This changes the hash and loads the view.
});
});
+1 -1
View File
@@ -131,7 +131,7 @@ export function setVideosMap(value) {
if (value instanceof Map) {
return updateKey("videosMap", value);
}
return updateKey("videosMap", value ?? null);
return updateKey("videosMap", null);
}
export function getVideoSubscription() {
+270 -113
View File
@@ -29,10 +29,11 @@ const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1";
const MODERATION_OVERRIDE_STORAGE_VERSION = 1;
const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v2";
const LEGACY_MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1";
const MODERATION_OVERRIDE_STORAGE_VERSION = 2;
const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1";
const MODERATION_SETTINGS_STORAGE_VERSION = 2;
const MODERATION_SETTINGS_STORAGE_VERSION = 3;
function computeDefaultModerationSettings() {
const defaultBlur = sanitizeModerationThreshold(
@@ -92,6 +93,7 @@ function computeDefaultModerationSettings() {
runtimeMuteHide,
defaultTrustedMute,
),
trustedMuteHideThresholds: {},
trustedSpamHideThreshold: sanitizeModerationThreshold(
runtimeSpamHide,
defaultTrustedSpam,
@@ -130,6 +132,37 @@ function sanitizeModerationThreshold(value, fallback) {
return clamped;
}
function sanitizeModerationThresholdMap(
value,
fallback = {},
{ mergeFallback = false } = {},
) {
const fallbackMap = fallback && typeof fallback === "object" ? fallback : {};
if (!value || typeof value !== "object") {
return mergeFallback ? { ...fallbackMap } : {};
}
const sanitized = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof key !== "string") {
continue;
}
const normalizedKey = key.trim().toLowerCase();
if (!normalizedKey) {
continue;
}
const sanitizedValue = sanitizeModerationThreshold(
entry,
fallbackMap[normalizedKey],
);
if (Number.isFinite(sanitizedValue)) {
sanitized[normalizedKey] = sanitizedValue;
}
}
return mergeFallback ? { ...fallbackMap, ...sanitized } : sanitized;
}
function sanitizeProfileString(value) {
if (typeof value !== "string") {
return "";
@@ -238,6 +271,50 @@ function normalizeEventId(value) {
return HEX64_REGEX.test(trimmed) ? trimmed : null;
}
function buildModerationOverrideKey(authorPubkey, eventId) {
const normalizedAuthor =
typeof authorPubkey === "string" ? authorPubkey.trim().toLowerCase() : "";
return `${normalizedAuthor}:${eventId}`;
}
function normalizeModerationOverrideDescriptor(eventIdOrDescriptor, authorPubkey) {
let eventId = null;
let author = null;
if (eventIdOrDescriptor && typeof eventIdOrDescriptor === "object") {
eventId =
eventIdOrDescriptor.eventId ||
eventIdOrDescriptor.id ||
eventIdOrDescriptor.eventID ||
null;
author =
eventIdOrDescriptor.authorPubkey ||
eventIdOrDescriptor.pubkey ||
eventIdOrDescriptor.author ||
null;
} else if (typeof eventIdOrDescriptor === "string") {
eventId = eventIdOrDescriptor;
}
if (!author && typeof authorPubkey === "string") {
author = authorPubkey;
}
const normalizedEventId = normalizeEventId(eventId);
if (!normalizedEventId) {
return null;
}
const normalizedAuthor = normalizeHexPubkey(author);
const authorKey = normalizedAuthor || "";
return {
eventId: normalizedEventId,
authorPubkey: normalizedAuthor || "",
key: buildModerationOverrideKey(authorKey, normalizedEventId),
};
}
export function getSavedProfiles() {
return savedProfiles;
}
@@ -743,91 +820,22 @@ export function persistProfileCacheToStorage() {
}
}
import { profileCache as unifiedProfileCache } from "./profileCache.js";
export function getProfileCacheEntry(pubkey) {
const normalized = normalizeHexPubkey(pubkey);
if (!normalized) {
return null;
const profile = unifiedProfileCache.getProfile(pubkey);
// Maintain backward compatibility with { profile, timestamp } return shape
// although consumers mostly check .profile
if (profile) {
// We can fetch the raw data to get the timestamp if needed, but for now
// returning a synthetic entry matches most usage patterns.
return { profile, timestamp: Date.now() };
}
const entry = profileCache.get(normalized);
if (!entry) {
return null;
}
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
if (!timestamp || Date.now() - timestamp > PROFILE_CACHE_TTL_MS) {
profileCache.delete(normalized);
persistProfileCacheToStorage();
return null;
}
return entry;
return null;
}
export function setProfileCacheEntry(pubkey, profile, { persist = true } = {}) {
const normalizedPubkey = normalizeHexPubkey(pubkey);
if (!normalizedPubkey || !profile) {
return null;
}
const normalized = {
name: profile.name || profile.display_name || "Unknown",
picture:
sanitizeProfileMediaUrl(profile.picture || profile.image) ||
"assets/svg/default-profile.svg",
};
const about = sanitizeProfileString(profile.about || profile.aboutMe);
if (about) {
normalized.about = about;
}
const website = sanitizeProfileString(profile.website || profile.url);
if (website) {
normalized.website = website;
}
const banner = sanitizeProfileMediaUrl(
profile.banner ||
profile.header ||
profile.background ||
profile.cover ||
profile.cover_image ||
profile.coverImage
);
if (banner) {
normalized.banner = banner;
}
const lud16 = sanitizeProfileString(profile.lud16);
if (lud16) {
normalized.lud16 = lud16;
}
const lud06 = sanitizeProfileString(profile.lud06);
if (lud06) {
normalized.lud06 = lud06;
}
const lightningCandidates = [
sanitizeProfileString(profile.lightningAddress),
lud16,
lud06,
].filter(Boolean);
if (lightningCandidates.length) {
normalized.lightningAddress = lightningCandidates[0];
}
const entry = {
profile: normalized,
timestamp: Date.now(),
};
profileCache.set(normalizedPubkey, entry);
if (persist) {
persistProfileCacheToStorage();
}
const entry = unifiedProfileCache.setProfile(pubkey, profile, { persist });
return entry;
}
@@ -836,6 +844,27 @@ function haveModerationSettingsChanged(previous, next) {
return true;
}
const prevMuteThresholds =
previous.trustedMuteHideThresholds &&
typeof previous.trustedMuteHideThresholds === "object"
? previous.trustedMuteHideThresholds
: {};
const nextMuteThresholds =
next.trustedMuteHideThresholds &&
typeof next.trustedMuteHideThresholds === "object"
? next.trustedMuteHideThresholds
: {};
const allMuteKeys = new Set([
...Object.keys(prevMuteThresholds),
...Object.keys(nextMuteThresholds),
]);
for (const key of allMuteKeys) {
if (prevMuteThresholds[key] !== nextMuteThresholds[key]) {
return true;
}
}
return (
previous.blurThreshold !== next.blurThreshold ||
previous.autoplayBlockThreshold !== next.autoplayBlockThreshold ||
@@ -917,6 +946,11 @@ export function loadModerationSettingsFromStorage() {
overrides.trustedMuteHideThreshold,
defaults.trustedMuteHideThreshold,
),
trustedMuteHideThresholds: sanitizeModerationThresholdMap(
overrides.trustedMuteHideThresholds,
defaults.trustedMuteHideThresholds,
{ mergeFallback: true },
),
trustedSpamHideThreshold: sanitizeModerationThreshold(
overrides.trustedSpamHideThreshold,
defaults.trustedSpamHideThreshold,
@@ -928,7 +962,7 @@ export function loadModerationSettingsFromStorage() {
legacySettings,
);
shouldRewrite = true;
} else if (payload.version === MODERATION_SETTINGS_STORAGE_VERSION) {
} else if (payload.version === 2 || payload.version === MODERATION_SETTINGS_STORAGE_VERSION) {
const defaults = createDefaultModerationSettings();
const entries =
payload.entries && typeof payload.entries === "object"
@@ -969,6 +1003,11 @@ export function loadModerationSettingsFromStorage() {
overrides.trustedMuteHideThreshold,
defaults.trustedMuteHideThreshold,
),
trustedMuteHideThresholds: sanitizeModerationThresholdMap(
overrides.trustedMuteHideThresholds,
defaults.trustedMuteHideThresholds,
{ mergeFallback: true },
),
trustedSpamHideThreshold: sanitizeModerationThreshold(
overrides.trustedSpamHideThreshold,
defaults.trustedSpamHideThreshold,
@@ -1023,6 +1062,16 @@ function persistModerationSettingsToStorage() {
overrides.trustedMuteHideThreshold = settings.trustedMuteHideThreshold;
}
const trustedMuteHideThresholds = sanitizeModerationThresholdMap(
settings.trustedMuteHideThresholds,
defaults.trustedMuteHideThresholds,
{ mergeFallback: false },
);
if (Object.keys(trustedMuteHideThresholds).length > 0) {
overrides.trustedMuteHideThresholds = trustedMuteHideThresholds;
}
if (settings.trustedSpamHideThreshold !== defaults.trustedSpamHideThreshold) {
overrides.trustedSpamHideThreshold = settings.trustedSpamHideThreshold;
}
@@ -1114,6 +1163,38 @@ export function setModerationSettings(partial = {}, { persist = true } = {}) {
}
}
if (
Object.prototype.hasOwnProperty.call(partial, "trustedMuteHideThresholds")
) {
const value = partial.trustedMuteHideThresholds;
const sanitized =
value === null
? defaults.trustedMuteHideThresholds
: sanitizeModerationThresholdMap(value, defaults.trustedMuteHideThresholds, {
mergeFallback: true,
});
const current =
next.trustedMuteHideThresholds &&
typeof next.trustedMuteHideThresholds === "object"
? next.trustedMuteHideThresholds
: {};
const nextKeys = new Set([
...Object.keys(current),
...Object.keys(sanitized),
]);
let mapChanged = false;
for (const key of nextKeys) {
if (current[key] !== sanitized[key]) {
mapChanged = true;
break;
}
}
if (mapChanged) {
next.trustedMuteHideThresholds = { ...sanitized };
changed = true;
}
}
if (Object.prototype.hasOwnProperty.call(partial, "trustedSpamHideThreshold")) {
const value = partial.trustedSpamHideThreshold;
const sanitized =
@@ -1165,13 +1246,31 @@ export function getModerationOverridesMap() {
return moderationOverrides;
}
export function getModerationOverride(eventId) {
const normalized = normalizeEventId(eventId);
export function getModerationOverridesList() {
return Array.from(moderationOverrides.values()).map((entry) => ({ ...entry }));
}
function findModerationOverrideByEventId(eventId) {
for (const entry of moderationOverrides.values()) {
if (entry?.eventId === eventId) {
return entry;
}
}
return null;
}
export function getModerationOverride(eventIdOrDescriptor, authorPubkey) {
const normalized = normalizeModerationOverrideDescriptor(
eventIdOrDescriptor,
authorPubkey,
);
if (!normalized) {
return null;
}
const entry = moderationOverrides.get(normalized);
const entry =
moderationOverrides.get(normalized.key) ||
findModerationOverrideByEventId(normalized.eventId);
if (!entry) {
return null;
}
@@ -1184,39 +1283,63 @@ export function loadModerationOverridesFromStorage() {
return;
}
const raw = localStorage.getItem(MODERATION_OVERRIDE_STORAGE_KEY);
const raw =
localStorage.getItem(MODERATION_OVERRIDE_STORAGE_KEY) ||
localStorage.getItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY);
if (!raw) {
return;
}
let shouldRewrite = false;
try {
const payload = JSON.parse(raw);
if (!payload || typeof payload !== "object") {
return;
}
if (payload.version !== MODERATION_OVERRIDE_STORAGE_VERSION) {
const entries = [];
if (payload.version === MODERATION_OVERRIDE_STORAGE_VERSION) {
if (Array.isArray(payload.entries)) {
entries.push(...payload.entries);
} else {
shouldRewrite = true;
}
} else if (payload.version === 1) {
const legacyEntries =
payload.entries && typeof payload.entries === "object"
? payload.entries
: {};
Object.entries(legacyEntries).forEach(([eventId, entry]) => {
entries.push({
eventId,
authorPubkey: "",
showAnyway: entry?.showAnyway === true,
updatedAt: entry?.updatedAt,
});
});
shouldRewrite = true;
} else {
return;
}
const entries = payload.entries && typeof payload.entries === "object"
? payload.entries
: {};
moderationOverrides.clear();
for (const [eventId, entry] of Object.entries(entries)) {
const normalized = normalizeEventId(eventId);
if (!normalized) {
for (const entry of entries) {
if (!entry || entry.showAnyway !== true) {
continue;
}
if (!entry || entry.showAnyway !== true) {
const normalized = normalizeModerationOverrideDescriptor(entry);
if (!normalized) {
continue;
}
const updatedAt = Number.isFinite(entry.updatedAt)
? Math.floor(entry.updatedAt)
: Date.now();
moderationOverrides.set(normalized, {
moderationOverrides.set(normalized.key, {
eventId: normalized.eventId,
authorPubkey: normalized.authorPubkey,
showAnyway: true,
updatedAt,
});
@@ -1227,6 +1350,11 @@ export function loadModerationOverridesFromStorage() {
"[cache.loadModerationOverridesFromStorage] Failed to parse payload:",
error,
);
return;
}
if (shouldRewrite) {
persistModerationOverridesToStorage();
}
}
@@ -1235,23 +1363,30 @@ export function persistModerationOverridesToStorage() {
return;
}
const entries = {};
for (const [eventId, entry] of moderationOverrides.entries()) {
const entries = [];
for (const entry of moderationOverrides.values()) {
if (!entry || entry.showAnyway !== true) {
continue;
}
const normalized = normalizeModerationOverrideDescriptor(entry);
if (!normalized) {
continue;
}
const updatedAt = Number.isFinite(entry.updatedAt)
? Math.floor(entry.updatedAt)
: Date.now();
entries[eventId] = {
entries.push({
eventId: normalized.eventId,
authorPubkey: normalized.authorPubkey,
showAnyway: true,
updatedAt,
};
});
}
if (Object.keys(entries).length === 0) {
if (entries.length === 0) {
try {
localStorage.removeItem(MODERATION_OVERRIDE_STORAGE_KEY);
localStorage.removeItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY);
} catch (error) {
userLogger.warn(
"[cache.persistModerationOverridesToStorage] Failed to clear overrides:",
@@ -1272,6 +1407,7 @@ export function persistModerationOverridesToStorage() {
MODERATION_OVERRIDE_STORAGE_KEY,
JSON.stringify(payload),
);
localStorage.removeItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY);
} catch (error) {
userLogger.warn(
"[cache.persistModerationOverridesToStorage] Failed to persist overrides:",
@@ -1281,18 +1417,21 @@ export function persistModerationOverridesToStorage() {
}
export function setModerationOverride(
eventId,
eventIdOrDescriptor,
override = {},
{ persist = true } = {},
) {
const normalized = normalizeEventId(eventId);
const normalized = normalizeModerationOverrideDescriptor(
eventIdOrDescriptor,
override?.authorPubkey,
);
if (!normalized) {
return null;
}
const showAnyway = override?.showAnyway === true;
if (!showAnyway) {
const removed = moderationOverrides.delete(normalized);
const removed = moderationOverrides.delete(normalized.key);
if (removed && persist) {
persistModerationOverridesToStorage();
}
@@ -1303,14 +1442,21 @@ export function setModerationOverride(
? Math.floor(override.updatedAt)
: Date.now();
const existing = moderationOverrides.get(normalized);
const nextEntry = { showAnyway: true, updatedAt };
const existing = moderationOverrides.get(normalized.key);
const nextEntry = {
eventId: normalized.eventId,
authorPubkey: normalized.authorPubkey,
showAnyway: true,
updatedAt,
};
const changed =
!existing ||
existing.showAnyway !== nextEntry.showAnyway ||
existing.updatedAt !== nextEntry.updatedAt;
existing.updatedAt !== nextEntry.updatedAt ||
existing.eventId !== nextEntry.eventId ||
existing.authorPubkey !== nextEntry.authorPubkey;
moderationOverrides.set(normalized, nextEntry);
moderationOverrides.set(normalized.key, nextEntry);
if (persist && changed) {
persistModerationOverridesToStorage();
@@ -1319,13 +1465,24 @@ export function setModerationOverride(
return { ...nextEntry };
}
export function clearModerationOverride(eventId, { persist = true } = {}) {
const normalized = normalizeEventId(eventId);
export function clearModerationOverride(
eventIdOrDescriptor,
{ persist = true } = {},
) {
const normalized = normalizeModerationOverrideDescriptor(eventIdOrDescriptor);
if (!normalized) {
return false;
}
const removed = moderationOverrides.delete(normalized);
let removed = moderationOverrides.delete(normalized.key);
for (const [key, entry] of moderationOverrides.entries()) {
if (entry?.eventId === normalized.eventId) {
moderationOverrides.delete(key);
removed = true;
}
}
if (removed && persist) {
persistModerationOverridesToStorage();
}
+365
View File
@@ -0,0 +1,365 @@
import { CACHE_POLICIES, STORAGE_TIERS } from "../nostr/cachePolicies.js";
import { NOTE_TYPES } from "../nostrEventSchemas.js";
import { userLogger } from "../utils/logger.js";
import { sanitizeProfileMediaUrl } from "../utils/profileMedia.js";
const PROFILE_CACHE_VERSION = 1;
const STORAGE_KEY_PREFIX = "bitvid:profile:";
// Maps legacy/service section names to NOTE_TYPES for policy lookup
const SECTION_TO_NOTE_TYPE = {
"watchHistory": NOTE_TYPES.WATCH_HISTORY,
"subscriptions": NOTE_TYPES.SUBSCRIPTION_LIST,
"blocks": NOTE_TYPES.USER_BLOCK_LIST,
"interests": NOTE_TYPES.HASHTAG_PREFERENCES,
"relays": NOTE_TYPES.RELAY_LIST,
"profile": NOTE_TYPES.PROFILE_METADATA,
};
function sanitizeProfileString(value) {
if (typeof value !== "string") {
return "";
}
return value.trim();
}
class ProfileCache {
constructor() {
this.activePubkey = null;
this.memoryCache = new Map(); // For runtime decrypted data: pubkey:section -> data
this.listeners = new Set();
}
normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
// Simple hex check, relying on callers to provide valid hex mostly,
// but robust enough to handle basic whitespace.
// For full validation we'd need nostr-tools or similar regex.
// Assuming standard 64-char hex for now.
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
return null;
}
/**
* Resolves the cache policy for a given section/noteType.
* @param {string} section - The section identifier or noteType.
* @returns {object|null} The policy object or null.
*/
getPolicy(section) {
if (!section) return null;
// Check if section is a known alias
const noteType = SECTION_TO_NOTE_TYPE[section] || section;
return CACHE_POLICIES[noteType] || null;
}
/**
* Resolves the canonical storage key for a section based on policy addressing.
* @param {string} section - The section identifier or noteType.
* @param {object} params - Parameters for key resolution { pubkey, dTag, kind }.
* @returns {string} The canonical storage key.
*/
resolveAddressKey(section, { pubkey, dTag } = {}) {
const policy = this.getPolicy(section);
const resolvedPubkey = this.normalizeHexPubkey(pubkey || this.activePubkey);
if (!resolvedPubkey) {
// Fallback or error? For now, we return a key with "unknown" or empty if no pubkey available
// but typically we should have one.
return "";
}
if (policy && policy.addressing === "kind:pubkey:d") {
const resolvedDTag = dTag || policy.defaultDTag;
const noteType = SECTION_TO_NOTE_TYPE[section] || section;
// We can use the noteType string as part of the key if we don't have the numeric kind easily accessible
// or we can hardcode the mapping if we really want kind numbers.
// However, the policy key in CACHE_POLICIES is the note type string (e.g. "watchHistory").
// To satisfy "kind:pubkey:d", we should ideally use the kind number.
// But `CACHE_POLICIES` is keyed by string type.
// Let's use the noteType string in the key to be unique and consistent with CACHE_POLICIES keys.
// Format: bitvid:profile:{pubkey}:{noteType}:{dTag}:v{VERSION}
// If dTag is missing for a replaceable list, it's an issue, but we'll use "default" or empty.
const dSegment = resolvedDTag || "default";
// Note: We use the noteType string (e.g. "watchHistory") instead of numeric kind
// because we don't import the numeric mappings here directly beyond what's in schemas,
// and noteType is unique enough.
return `${STORAGE_KEY_PREFIX}${resolvedPubkey}:${noteType}:${dSegment}:v${PROFILE_CACHE_VERSION}`;
} else if (policy && policy.addressing === "kind:pubkey") {
const noteType = SECTION_TO_NOTE_TYPE[section] || section;
return `${STORAGE_KEY_PREFIX}${resolvedPubkey}:${noteType}:v${PROFILE_CACHE_VERSION}`;
}
// Fallback legacy key format
return `${STORAGE_KEY_PREFIX}${resolvedPubkey}:${section}:v${PROFILE_CACHE_VERSION}`;
}
getStorageKey(pubkey, section) {
// Forward to resolveAddressKey which handles the logic,
// but getStorageKey signature is (pubkey, section).
// We assume default dTag if applicable.
return this.resolveAddressKey(section, { pubkey });
}
getStorageTier(section) {
const policy = this.getPolicy(section);
if (policy) {
return policy.storage;
}
// Default to LOCAL_STORAGE for unknown sections to match legacy behavior
return STORAGE_TIERS.LOCAL_STORAGE;
}
setActiveProfile(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
if (this.activePubkey === normalized) {
return;
}
// Clear memory cache for the old profile to remove sensitive decrypted data
if (this.activePubkey) {
this.clearMemoryCache(this.activePubkey);
}
this.activePubkey = normalized;
// Emit change event
this.emit("profileChanged", { pubkey: this.activePubkey });
}
getActiveProfile() {
return this.activePubkey;
}
get(section) {
if (!this.activePubkey) {
return null;
}
return this.getProfileData(this.activePubkey, section);
}
set(section, data) {
if (!this.activePubkey) {
return;
}
this.setProfileData(this.activePubkey, section, data);
}
getProfile(pubkey) {
const data = this.getProfileData(pubkey, "profile");
return data && data.profile ? data.profile : null;
}
setProfile(pubkey, profile, { persist = true } = {}) {
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
if (!normalizedPubkey || !profile) {
return null;
}
const normalized = {
name:
sanitizeProfileString(profile.name || profile.display_name) ||
"Unknown",
picture:
sanitizeProfileMediaUrl(profile.picture || profile.image) ||
"assets/svg/default-profile.svg",
};
const about = sanitizeProfileString(profile.about || profile.aboutMe);
if (about) {
normalized.about = about;
}
const website = sanitizeProfileString(profile.website || profile.url);
if (website) {
normalized.website = website;
}
const banner = sanitizeProfileMediaUrl(
profile.banner ||
profile.header ||
profile.background ||
profile.cover ||
profile.cover_image ||
profile.coverImage
);
if (banner) {
normalized.banner = banner;
}
const lud16 = sanitizeProfileString(profile.lud16);
if (lud16) {
normalized.lud16 = lud16;
}
const lud06 = sanitizeProfileString(profile.lud06);
if (lud06) {
normalized.lud06 = lud06;
}
const lightningCandidates = [
sanitizeProfileString(profile.lightningAddress),
lud16,
lud06,
].filter(Boolean);
if (lightningCandidates.length) {
normalized.lightningAddress = lightningCandidates[0];
}
const entry = {
profile: normalized,
timestamp: Date.now(),
};
// We can update memory directly if it's the active pubkey or we track everyone?
// ProfileCache logic for setProfileData updates memory cache if using setMemoryData,
// but typically getProfileData checks persistence if memory is empty.
// However, for profiles, we probably want persistence.
// The instructions say: "normalizes, stores, sets timestamp and persists".
// If persist is false, we might only update memory.
if (persist) {
this.setProfileData(normalizedPubkey, "profile", entry);
} else {
// Just memory
this.setMemoryDataForPubkey(normalizedPubkey, "profile", entry);
}
return entry;
}
getProfileData(pubkey, section) {
// 1. Check memory cache (runtime data)
const memKey = `${pubkey}:${section}`;
if (this.memoryCache.has(memKey)) {
return this.memoryCache.get(memKey);
}
// 2. Load from persistence
const storageKey = this.getStorageKey(pubkey, section);
const tier = this.getStorageTier(section);
if (tier === STORAGE_TIERS.LOCAL_STORAGE && typeof localStorage !== "undefined") {
try {
const raw = localStorage.getItem(storageKey);
if (raw) {
const parsed = JSON.parse(raw);
// TTL Check
const policy = this.getPolicy(section);
if (policy && Number.isFinite(policy.ttl) && policy.ttl > 0) {
// We check if data has savedAt or similar timestamp.
// If parsed is an object and has savedAt
const savedAt = parsed?.savedAt || parsed?.timestamp;
if (Number.isFinite(savedAt)) {
const now = Date.now();
// Check if savedAt is seconds or ms. Usually ms in JS, but watchHistory might use one or other.
// In watchHistory.js: savedAt = Date.now(). So it's ms.
if (now - savedAt > policy.ttl) {
userLogger.info(`[ProfileCache] Expired ${section} for ${pubkey}`);
return null;
}
}
}
// Populate memory cache to avoid repeat parsing
this.memoryCache.set(memKey, parsed);
return parsed;
}
} catch (error) {
userLogger.warn(`[ProfileCache] Failed to load ${section} for ${pubkey}`, error);
}
}
return null;
}
setProfileData(pubkey, section, data) {
const storageKey = this.getStorageKey(pubkey, section);
const tier = this.getStorageTier(section);
// Save to storage
if (tier === STORAGE_TIERS.LOCAL_STORAGE && typeof localStorage !== "undefined") {
try {
if (data === null || data === undefined) {
localStorage.removeItem(storageKey);
} else {
localStorage.setItem(storageKey, JSON.stringify(data));
}
} catch (error) {
userLogger.warn(`[ProfileCache] Failed to save ${section} for ${pubkey}`, error);
}
}
// Update memory cache
const memKey = `${pubkey}:${section}`;
this.memoryCache.set(memKey, data);
this.emit("update", { pubkey, section, data });
this.emit("partition-updated", { pubkey, key: section });
}
setMemoryDataForPubkey(pubkey, section, data) {
const memKey = `${pubkey}:${section}`;
this.memoryCache.set(memKey, data);
this.emit("update", { pubkey, section, data });
}
setMemoryData(section, data) {
if (!this.activePubkey) return;
const key = `${this.activePubkey}:${section}`;
this.memoryCache.set(key, data);
}
getMemoryData(section) {
if (!this.activePubkey) return null;
const key = `${this.activePubkey}:${section}`;
return this.memoryCache.get(key);
}
clearMemoryCache(pubkey) {
// Clear all entries starting with pubkey:
const prefix = `${pubkey}:`;
for (const key of this.memoryCache.keys()) {
if (key.startsWith(prefix)) {
this.memoryCache.delete(key);
}
}
}
clearSignerRuntime(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
if (!normalized) {
return;
}
this.clearMemoryCache(normalized);
this.emit("runtimeCleared", { pubkey: normalized });
}
subscribe(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
emit(event, detail) {
for (const listener of this.listeners) {
try {
listener(event, detail);
} catch (error) {
console.error("[ProfileCache] Listener error", error);
}
}
}
}
export const profileCache = new ProfileCache();
+166 -82
View File
@@ -1,12 +1,11 @@
// js/subscriptions.js
import {
getActiveSigner,
nostrClient,
requestDefaultExtensionPermissions,
} from "./nostrClientFacade.js";
import {
getActiveSigner,
convertEventToVideo as sharedConvertEventToVideo,
} from "./nostr/index.js";
import { convertEventToVideo as sharedConvertEventToVideo } from "./nostr/index.js";
import { normalizeNostrPubkey } from "./nostr/nip46Client.js";
import {
listVideoViewEvents,
subscribeVideoViewEvents,
@@ -18,6 +17,7 @@ import {
getNostrEventSchema,
NOTE_TYPES,
} from "./nostrEventSchemas.js";
import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js";
import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
import {
publishEventToRelays,
@@ -29,6 +29,7 @@ import { ALLOW_NSFW_CONTENT } from "./config.js";
import { devLogger, userLogger } from "./utils/logger.js";
import moderationService from "./services/moderationService.js";
import nostrService from "./services/nostrService.js";
import { profileCache } from "./state/profileCache.js";
const SUBSCRIPTION_SET_KIND =
getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000;
@@ -237,6 +238,19 @@ class SubscriptionsManager {
this.isRunningFeed = false;
this.hasRenderedOnce = false;
this.ensureNostrServiceListener();
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
this.reset();
// Optionally reload immediately if we have a pubkey?
// Usually UI triggers reload via showSubscriptionVideos
} else if (event === "runtimeCleared" && detail.pubkey === this.currentUserPubkey) {
this.reset();
if (this.currentUserPubkey) {
this.loadSubscriptions(this.currentUserPubkey);
}
}
});
}
/**
@@ -247,31 +261,38 @@ class SubscriptionsManager {
userLogger.warn("[SubscriptionsManager] No pubkey => cannot load subs.");
return;
}
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
// 1. Attempt to load from cache first
const cached = profileCache.get("subscriptions");
if (Array.isArray(cached)) {
devLogger.log("[SubscriptionsManager] Loaded subscriptions from cache.");
this.subscribedPubkeys = new Set(cached);
this.currentUserPubkey = normalizedUserPubkey;
this.loaded = true;
// Trigger background update
this.updateFromRelays(userPubkey).catch((err) => {
devLogger.warn("[SubscriptionsManager] Background update failed:", err);
});
return;
}
// 2. If no cache, must wait for relays
await this.updateFromRelays(userPubkey);
}
saveToCache(userPubkey) {
// We assume userPubkey matches active profile, enforced by profileCache
profileCache.set("subscriptions", Array.from(this.subscribedPubkeys));
}
async updateFromRelays(userPubkey) {
if (!userPubkey) return;
try {
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
const sessionActorPubkey = normalizeHexPubkey(
nostrClient?.sessionActor?.pubkey,
);
const authorSet = new Set();
if (normalizedUserPubkey) {
authorSet.add(normalizedUserPubkey);
}
if (sessionActorPubkey) {
authorSet.add(sessionActorPubkey);
}
const authors = Array.from(authorSet);
if (!authors.length && userPubkey) {
authors.push(userPubkey);
}
const filter = {
kinds: [SUBSCRIPTION_SET_KIND],
authors,
"#d": [SUBSCRIPTION_LIST_IDENTIFIER],
limit: 1
};
const relaySet = new Set();
const addRelayCandidates = (candidates) => {
@@ -307,33 +328,64 @@ class SubscriptionsManager {
devLogger.warn(
"[SubscriptionsManager] No relay URLs available while loading subscriptions.",
);
return;
}
const relayPromises = relayUrls.map((url) =>
nostrClient.pool.list([url], [filter]).catch((err) => {
userLogger.error(`[SubscriptionsManager] Relay error at ${url}`, err);
return [];
}),
);
// Use incremental fetch helper
let events = await nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedUserPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls
});
const settledResults = await Promise.allSettled(relayPromises);
const events = [];
for (const outcome of settledResults) {
if (outcome.status === "fulfilled" && Array.isArray(outcome.value) && outcome.value.length) {
events.push(...outcome.value);
}
}
// Also check session actor if different?
// The original code checked both userPubkey and sessionActor.pubkey.
// fetchListIncrementally takes a single pubkey.
// If we want to check session actor too, we need another call.
// However, usually the subscription list belongs to the logged in user (userPubkey).
// The session actor logic in original code:
// const sessionActorPubkey = normalizeHexPubkey(nostrClient?.sessionActor?.pubkey);
// ... authors: [normalizedUserPubkey, sessionActorPubkey]
// Since fetchListIncrementally filters by author=pubkey, we only query for normalizedUserPubkey here.
// If session actor support is critical for some delegation case, it needs a separate fetch.
// Assuming userPubkey is the primary target for subscriptions.
if (!events.length) {
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.loaded = true;
if (!this.loaded) {
// If we have nothing loaded and found nothing, maybe user has no list.
// But we shouldn't wipe blindly if incremental fetch just returned no *new* events.
// Wait, fetchListIncrementally returns *events found*.
// If it performed incremental fetch and found nothing, it returns [].
// If it performed full fetch and found nothing, it returns [].
// We need to differentiate "no updates" vs "empty list".
// If we have cached data, we keep it.
// If we have NO cached data (this.loaded = false), and we get [], we assume empty list.
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.loaded = true;
} else {
// We have data loaded, and relays returned nothing.
// This means no updates. We keep what we have.
devLogger.log("[SubscriptionsManager] No updates from relays.");
}
return;
}
// Sort by created_at desc, pick newest
events.sort((a, b) => b.created_at - a.created_at);
const newest = events[0];
// Check if newest is actually newer than what we have
// If we loaded from cache, we might not have the event object, just the pubkeys.
// But we don't store the event timestamp in cache currently?
// profileCache only stores the array.
// Wait, we don't persist the event ID or created_at in profileCache for subscriptions.
// So we can't strictly compare timestamps against cache.
// However, fetchListIncrementally already handles the "newer than last sync" logic per relay.
// So any event returned here is effectively "new information" (or re-fetched full state).
this.subsEventId = newest.id;
const signer = getActiveSigner();
@@ -357,9 +409,12 @@ class SubscriptionsManager {
"[SubscriptionsManager] Extension permissions denied while loading subscriptions; treating list as empty.",
permissionResult.error,
);
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.loaded = true;
// Permission denied is definitive enough to stop trying to use this event
if (!this.loaded) {
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.loaded = true;
}
return;
}
@@ -369,21 +424,51 @@ class SubscriptionsManager {
"[SubscriptionsManager] Failed to decrypt subscription list:",
decryptResult.error,
);
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.loaded = true;
if (!this.loaded) {
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.loaded = true;
}
return;
}
const decryptedStr = decryptResult.plaintext;
const normalized = parseSubscriptionPlaintext(decryptedStr);
this.subscribedPubkeys = new Set(normalized);
const newSet = new Set(normalized);
const previousSet = this.subscribedPubkeys;
// Update state
this.subscribedPubkeys = newSet;
this.currentUserPubkey = normalizedUserPubkey;
this.loaded = true;
// Update persistent cache
this.saveToCache(normalizedUserPubkey);
// Check if we need to refresh the feed
let changed = newSet.size !== previousSet.size;
if (!changed) {
for (const key of newSet) {
if (!previousSet.has(key)) {
changed = true;
break;
}
}
}
if (changed) {
devLogger.log("[SubscriptionsManager] Subscription list updated from relays, refreshing feed.");
this.refreshActiveFeed({ reason: "subscription-update-background" }).catch((error) => {
userLogger.warn(
"[SubscriptionsManager] Failed to refresh after background subscription update:",
error
);
});
}
} catch (err) {
userLogger.error("[SubscriptionsManager] Failed to load subs:", err);
userLogger.error("[SubscriptionsManager] Failed to update subs from relays:", err);
}
}
@@ -450,6 +535,25 @@ class SubscriptionsManager {
return this.subscribedPubkeys.has(normalized);
}
async toggleChannel(channelHex, userPubkey) {
if (!userPubkey) {
throw new Error("No user pubkey => cannot toggleChannel.");
}
await this.ensureLoaded(userPubkey);
const isSubscribed = this.isSubscribed(channelHex) === true;
try {
if (isSubscribed) {
await this.removeChannel(channelHex, userPubkey);
} else {
await this.addChannel(channelHex, userPubkey);
}
return { ok: true, subscribed: !isSubscribed };
} catch (error) {
userLogger.error("[SubscriptionsManager] Failed to toggle subscription:", error);
throw error;
}
}
getSubscribedAuthors() {
return Array.from(this.subscribedPubkeys);
}
@@ -470,7 +574,10 @@ class SubscriptionsManager {
decryptors.set(scheme, handler);
};
const signer = getActiveSigner();
let signer = getActiveSigner();
if (!signer && typeof nostrClient?.ensureActiveSignerForPubkey === "function") {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
@@ -482,34 +589,6 @@ class SubscriptionsManager {
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 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 active signer or extension decryptors are available for subscriptions."
@@ -564,6 +643,7 @@ class SubscriptionsManager {
return;
}
this.subscribedPubkeys.add(normalizedChannel);
this.saveToCache(userPubkey);
await this.publishSubscriptionList(userPubkey);
this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => {
userLogger.warn(
@@ -588,6 +668,7 @@ class SubscriptionsManager {
return;
}
this.subscribedPubkeys.delete(normalizedChannel);
this.saveToCache(userPubkey);
await this.publishSubscriptionList(userPubkey);
this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => {
userLogger.warn(
@@ -611,7 +692,10 @@ class SubscriptionsManager {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
if (!signer) {
const canSign = typeof signer?.canSign === "function"
? signer.canSign()
: typeof signer?.signEvent === "function";
if (!canSign) {
const error = new Error(
"An active signer is required to update subscriptions."
);
+19 -2
View File
@@ -37,9 +37,11 @@ import { isWatchHistoryDebugEnabled } from "../watchHistoryDebug.js";
import { splitAndZap as splitAndZapDefault } from "../payments/zapSplit.js";
import {
getDefaultModerationSettings,
getModerationOverridesList,
getModerationSettings,
setModerationSettings,
resetModerationSettings,
clearModerationOverride,
persistSavedProfiles,
getSavedProfiles,
getActiveProfilePubkey,
@@ -135,6 +137,13 @@ export default class ApplicationBootstrap {
isNew: () => true,
};
app.videoModalReadyPromise = null;
app.feedTelemetryState = {
activeFeed: "",
matchedTagsById: new Map(),
matchReasonsById: new Map(),
lastImpressionSignature: "",
activePlayback: null,
};
app.pendingModalZapOpen = false;
app.videoListViewPlaybackHandler = null;
@@ -251,6 +260,7 @@ export default class ApplicationBootstrap {
app.feedEngine.run = (...args) => app.feedEngine.runFeed(...args);
}
app.registerRecentFeed();
app.registerForYouFeed();
app.registerSubscriptionsFeed();
app.registerWatchHistoryFeed();
@@ -274,6 +284,7 @@ export default class ApplicationBootstrap {
normalizeHexPubkey: (value) => app.normalizeHexPubkey(value),
getActiveUserPubkey: () => app.pubkey,
ingestLocalViewEvent,
onViewLogged: (detail) => app.handleFeedViewTelemetry(detail),
});
const playbackDependencies = {
@@ -393,8 +404,6 @@ export default class ApplicationBootstrap {
app.loginModalController = null;
app.currentUserNpub = null;
app.initializeLoginModalController({ logIfMissing: true });
app.profileButton = doc?.getElementById("profileButton") || null;
app.profileAvatar = doc?.getElementById("profileAvatar") || null;
@@ -431,6 +440,7 @@ export default class ApplicationBootstrap {
nostrClient,
nostrService: app.nostrService,
storageService,
r2Service: app.r2Service,
subscriptions,
accessControl,
moderation: moderationService,
@@ -443,6 +453,9 @@ export default class ApplicationBootstrap {
setModerationSettings(partial),
resetModerationSettings: () => resetModerationSettings(),
},
getModerationOverrides: () => getModerationOverridesList(),
clearModerationOverride: (descriptor) =>
clearModerationOverride(descriptor),
loadVideos: (forceFetch, context) =>
app.loadVideos(forceFetch, context),
onVideosShouldRefresh: (context) =>
@@ -615,6 +628,7 @@ export default class ApplicationBootstrap {
app.tagPreferencePopovers = new Map();
app.subscriptionsLink = null;
app.forYouLink = null;
app.notificationPortal = doc?.getElementById("notificationPortal") || null;
app.errorContainer = doc?.getElementById("errorContainer") || null;
@@ -808,6 +822,9 @@ export default class ApplicationBootstrap {
magnet,
trigger,
}) => {
if (videoId) {
app.recordForYouClick(videoId);
}
if (videoId) {
Promise.resolve(
app.playVideoByEventId(videoId, { url, magnet, trigger }),
+85 -8
View File
@@ -102,7 +102,7 @@ export class EventDetailsModal {
</button>
</div>
<div class="relative flex-1 min-h-[200px] bg-black/80 rounded-lg border border-border overflow-hidden group">
<pre class="absolute inset-0 p-4 overflow-auto text-xs font-mono text-gray-300 scrollbar-thin"><code data-json-content></code></pre>
<pre class="absolute inset-0 p-4 overflow-auto text-xs font-mono text-white/80 scrollbar-thin"><code data-json-content></code></pre>
</div>
</section>
</div>
@@ -135,8 +135,7 @@ export class EventDetailsModal {
const container = this.document.getElementById("modalContainer") || this.document.body;
const modal = this.document.createElement("div");
modal.id = "eventDetailsModal";
modal.style.zIndex = "140";
modal.className = "bv-modal hidden items-center justify-center p-4";
modal.className = "bv-modal z-[140] hidden items-center justify-center p-4";
modal.innerHTML = this.buildMarkup();
this.root = modal;
@@ -305,21 +304,99 @@ export class EventDetailsModal {
}
if (jsonEl) {
const rawEvent = this.getRawEvent(video);
jsonEl.textContent = JSON.stringify(rawEvent, null, 2);
jsonEl.textContent = "Loading raw event...";
this.currentRawEvent = null;
if (this.app?.nostrService?.nostrClient?.getEventById) {
this.app.nostrService.nostrClient
.getEventById(video.id, { includeRaw: true })
.then((result) => {
if (this.currentVideo && this.currentVideo.id === video.id) {
// Ensure we actually have a raw event (with signature)
// If getEventById returns a wrapper { video, rawEvent: null }, we must handle null.
const raw =
result && typeof result.rawEvent === "object"
? result.rawEvent
: null;
if (raw) {
this.currentRawEvent = raw;
jsonEl.textContent = JSON.stringify(raw, null, 2);
} else {
// Fallback if fetch returned nothing usable
// Do not set currentRawEvent so copy-json uses the reconstruction logic
jsonEl.textContent = JSON.stringify(
this.getRawEvent(video),
null,
2,
);
}
}
})
.catch((error) => {
console.error(
"[EventDetailsModal] Failed to fetch raw event:",
error,
);
if (this.currentVideo && this.currentVideo.id === video.id) {
jsonEl.textContent = `// Failed to load raw event.\n// Falling back to known data:\n${JSON.stringify(
this.getRawEvent(video),
null,
2,
)}`;
}
});
} else {
// Fallback if client or method missing
const rawEvent = this.getRawEvent(video);
jsonEl.textContent = JSON.stringify(rawEvent, null, 2);
}
}
}
getRawEvent(video) {
if (!video) return {};
let content = video.content;
// If raw content is missing (e.g. video loaded from cache/parsed), reconstruct it
// so the user can inspect the metadata fields (thumbnail, title, etc.)
if (typeof content !== "string") {
const payload = {
videoRootId: video.videoRootId,
version: video.version,
deleted: video.deleted || false,
isPrivate: video.isPrivate,
isNsfw: video.isNsfw,
isForKids: video.isForKids,
title: video.title,
url: video.url,
magnet: video.magnet,
thumbnail: video.thumbnail,
description: video.description,
mode: video.mode,
enableComments: video.enableComments ?? true,
ws: video.ws,
xs: video.xs,
};
// Clean up undefined/empty fields to keep it tidy
Object.keys(payload).forEach((key) => {
if (payload[key] === undefined || payload[key] === null) {
delete payload[key];
}
});
content = JSON.stringify(payload);
}
return {
id: video.id,
pubkey: video.pubkey,
created_at: video.created_at,
kind: video.kind,
kind: video.kind || 30078,
tags: video.tags,
content: video.content,
sig: video.sig,
content: content,
sig: video.sig || "unavailable (cached)",
};
}
+7 -10
View File
@@ -482,7 +482,7 @@ export class SimilarContentCard {
button.textContent = "Show";
button.addEventListener("click", this.boundShowAnywayHandler);
// Explicit pointer events since container is none
button.style.pointerEvents = "auto";
button.classList.add("pointer-events-auto");
return button;
}
@@ -503,7 +503,7 @@ export class SimilarContentCard {
button.setAttribute("aria-label", ariaLabel);
button.textContent = "Hide";
button.addEventListener("click", this.boundModerationHideHandler);
button.style.pointerEvents = "auto";
button.classList.add("pointer-events-auto");
return button;
}
@@ -520,7 +520,7 @@ export class SimilarContentCard {
button.dataset.moderationAction = "block";
button.textContent = "Block";
button.addEventListener("click", this.boundModerationBlockHandler);
button.style.pointerEvents = "auto";
button.classList.add("pointer-events-auto");
return button;
}
@@ -669,7 +669,7 @@ export class SimilarContentCard {
: "trusted-mute";
// Allow interaction with children
badge.style.pointerEvents = "auto";
badge.classList.add("pointer-events-auto");
const label = this.document.createElement("span");
label.className = "moderation-badge__label inline-flex items-center gap-xs";
@@ -763,14 +763,12 @@ export class SimilarContentCard {
// Force blur style in case class utility is missing or overridden,
// and clear the backdrop so the sharp background doesn't show through.
// Scale up to hide feathered edges of the blur.
this.thumbnailEl.style.filter = "blur(24px)";
this.thumbnailEl.style.transform = "scale(1.2)";
this.thumbnailEl.classList.add("scale-[1.2]");
this.setCardBackdropImage("");
} else {
delete this.thumbnailEl.dataset.thumbnailState;
this.thumbnailEl.classList.remove("blur-xl");
this.thumbnailEl.style.filter = "";
this.thumbnailEl.style.transform = "";
this.thumbnailEl.classList.remove("scale-[1.2]");
// Restore backdrop if src is available
if (this.thumbnailEl.src) {
this.setCardBackdropImage(this.thumbnailEl.src);
@@ -845,8 +843,7 @@ export class SimilarContentCard {
img.dataset.thumbnailState = "blurred";
img.classList.add("blur-xl");
// Pre-apply style to avoid flash and crop edges
img.style.filter = "blur(24px)";
img.style.transform = "scale(1.2)";
img.classList.add("scale-[1.2]");
}
const handleLoad = () => {
+143 -60
View File
@@ -11,6 +11,10 @@ import { createTorrentMetadata } from "../../utils/torrentHash.js";
import { sanitizeBucketName } from "../../storage/r2-mgmt.js";
import { buildR2Key, buildPublicUrl } from "../../r2.js";
import { PROVIDERS } from "../../services/storageService.js";
import {
getActiveSigner,
requestDefaultExtensionPermissions,
} from "../../nostrClientFacade.js";
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
@@ -36,6 +40,7 @@ export class UploadModal {
safeEncodeNpub,
eventTarget,
container,
onRequestStorageSettings,
} = {}) {
this.authService = authService || null;
this.r2Service = r2Service || null;
@@ -60,6 +65,7 @@ export class UploadModal {
this.eventTarget =
eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
this.container = container || null;
this.onRequestStorageSettings = typeof onRequestStorageSettings === "function" ? onRequestStorageSettings : null;
this.root = null;
this.isVisible = false;
@@ -67,6 +73,7 @@ export class UploadModal {
this.cloudflareSettings = this.r2Service?.getSettings?.() || null;
this.isStorageUnlocked = false;
this.activeConnectionId = null;
this.storageConfigured = false;
// UI References
this.form = null;
@@ -76,6 +83,7 @@ export class UploadModal {
this.toggles = {};
this.submitButton = null;
this.submitStatus = null;
this.summaryView = {};
// Logic/State
this.nip71FormManager = new Nip71FormManager();
@@ -125,14 +133,8 @@ export class UploadModal {
if (this.storageService) {
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
if (this.isStorageUnlocked) {
await this.loadFromStorage();
} else {
// Attempt legacy load if not unlocked (or maybe just show locked state)
// For now we keep legacy behavior as fallback if storageService isn't used
// But user requested provider agnostic.
// We will update UI state in setSourceMode or similar
}
// Even if locked, we want to know if there's *potentially* a connection saved to show correct UI state (locked vs empty)
await this.loadFromStorage();
} else {
await this.loadR2Settings();
}
@@ -172,6 +174,11 @@ export class UploadModal {
progress: $("#upload-progress-container"),
};
this.storageViews = {
summary: $("#storage-summary-view"),
form: $("#storage-form-view"),
};
// Inputs (Common)
this.inputs = {
title: $("#input-title"),
@@ -225,6 +232,7 @@ export class UploadModal {
r2HelpLink: $("#link-r2-help"),
copyBucket: $("#btn-copy-bucket"),
storageUnlock: $("#btn-storage-unlock"),
manageStorage: $("#btn-manage-storage"),
};
// Status text
@@ -233,6 +241,8 @@ export class UploadModal {
storageLock: $("#storage-lock-status"),
uploadMain: $("#upload-status-text"),
uploadPercent: $("#upload-percent-text"),
summaryProvider: $("#summary-provider"),
summaryBucket: $("#summary-bucket"),
};
this.nip71FormManager.registerSection("main", this.form);
@@ -294,6 +304,16 @@ export class UploadModal {
this.toggles.storageUnlock.addEventListener("click", () => this.handleUnlock());
}
// Manage Storage
if (this.toggles.manageStorage) {
this.toggles.manageStorage.addEventListener("click", () => {
if (this.onRequestStorageSettings) {
this.close();
this.onRequestStorageSettings();
}
});
}
// Automation
this.setupDescriptionMirror();
this.setupMutuallyExclusiveCheckboxes(this.toggles.nsfw, this.toggles.kids);
@@ -333,9 +353,11 @@ export class UploadModal {
this.sourceSections.upload.classList.remove("hidden");
this.sourceSections.external.classList.add("hidden");
// Auto-show settings if missing
if (!this.hasValidR2Settings() && this.sourceSections.settings.classList.contains("hidden")) {
// Auto-show settings if missing or not configured
if (!this.storageConfigured && !this.hasValidR2Settings() && this.sourceSections.settings.classList.contains("hidden")) {
this.toggles.storageSettings.click(); // Expand
} else if (this.storageConfigured) {
// Keep collapsed if configured, per requirements
}
if (this.toggles.browseThumbnail) {
@@ -461,15 +483,7 @@ export class UploadModal {
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
this.updateLockUi();
if (this.isStorageUnlocked) {
await this.loadFromStorage();
} else {
// Locked: Clear sensitive data from UI and memory
this.cloudflareSettings = null;
this.fillSettingsForm({});
this.updateStorageStatus(false);
this.activeConnectionId = null;
}
await this.loadFromStorage();
} else if (this.r2Service && typeof this.r2Service.loadSettings === "function") {
await this.loadR2Settings();
}
@@ -483,13 +497,24 @@ export class UploadModal {
this.cloudflareSettings = settings || {};
this.fillSettingsForm(this.cloudflareSettings);
this.updateStorageStatus(this.hasValidR2Settings());
// Legacy mode implies no storage service or not used
this.toggleStorageView("form");
return settings;
}
async loadFromStorage() {
if (!this.storageService) return;
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
if (!pubkey) return;
this.cloudflareSettings = null;
this.activeConnectionId = null;
this.storageConfigured = false;
if (!pubkey) {
this.toggleStorageView("form");
return;
}
try {
const connections = await this.storageService.listConnections(pubkey);
@@ -498,32 +523,56 @@ export class UploadModal {
const targetConn = defaultConn || connections.find(c => c.provider === PROVIDERS.R2 || c.provider === "cloudflare_r2");
if (targetConn) {
const details = await this.storageService.getConnection(pubkey, targetConn.id);
if (details) {
// Map generic S3 or R2 details to settings
// R2 settings struct: accountId, accessKeyId, secretAccessKey, baseDomain
this.cloudflareSettings = {
accountId: details.accountId, // Might be undefined for generic S3
accessKeyId: details.accessKeyId,
secretAccessKey: details.secretAccessKey,
baseDomain: details.meta?.baseDomain || "",
// Add generic endpoint for internal use if needed
endpoint: details.endpoint,
region: details.region,
bucket: details.bucket
};
this.fillSettingsForm(this.cloudflareSettings);
this.storageConfigured = true;
this.toggleStorageView("summary");
const providerLabel = targetConn.provider === PROVIDERS.R2 || targetConn.provider === "cloudflare_r2"
? "R2"
: (targetConn.meta?.label || "S3");
this.updateStorageStatus(true, providerLabel);
// Populate Summary
const providerName = targetConn.meta?.provider === "cloudflare_r2" ? "Cloudflare R2" : (targetConn.meta?.label || "S3 Storage");
const bucketName = targetConn.meta?.bucket || "Unknown Bucket";
this.activeConnectionId = targetConn.id;
if (this.statusText.summaryProvider) this.statusText.summaryProvider.textContent = providerName;
if (this.statusText.summaryBucket) this.statusText.summaryBucket.textContent = bucketName;
if (this.isStorageUnlocked) {
const details = await this.storageService.getConnection(pubkey, targetConn.id);
if (details) {
// Map generic S3 or R2 details to settings
// R2 settings struct: accountId, accessKeyId, secretAccessKey, baseDomain
this.cloudflareSettings = {
accountId: details.accountId, // Might be undefined for generic S3
accessKeyId: details.accessKeyId,
secretAccessKey: details.secretAccessKey,
baseDomain: details.meta?.baseDomain || "",
// Add generic endpoint for internal use if needed
endpoint: details.endpoint,
region: details.region,
bucket: details.bucket
};
this.fillSettingsForm(this.cloudflareSettings);
this.updateStorageStatus(true, providerName);
this.activeConnectionId = targetConn.id;
}
} else {
// Locked state, can't get details yet
this.updateStorageStatus(false);
}
} else {
// No connections configured
this.toggleStorageView("form");
}
} catch (err) {
console.error("Failed to load connection", err);
userLogger.error("Failed to load connection", err);
this.toggleStorageView("form");
}
}
toggleStorageView(viewName) {
if (viewName === "summary") {
this.storageViews.summary.classList.remove("hidden");
this.storageViews.form.classList.add("hidden");
} else {
this.storageViews.summary.classList.add("hidden");
this.storageViews.form.classList.remove("hidden");
}
}
@@ -532,30 +581,44 @@ export class UploadModal {
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
if (!pubkey) return;
// We need a signer. Try window.nostr or authService
let signer = window.nostr;
// We need a signer. Try active signer or authService
let signer = getActiveSigner();
if (!signer && this.authService?.signer) {
signer = this.authService.signer;
}
if (!signer) {
const canSign = typeof signer?.canSign === "function"
? signer.canSign()
: typeof signer?.signEvent === "function";
if (!canSign) {
alert("No signer available to unlock storage.");
return;
}
try {
this.toggles.storageUnlock.textContent = "Unlocking...";
this.toggles.storageUnlock.disabled = true;
if (signer?.type === "extension") {
const permissionResult = await requestDefaultExtensionPermissions();
if (!permissionResult?.ok) {
alert("Extension permissions are required to unlock storage.");
return;
}
}
if (this.toggles.storageUnlock) {
this.toggles.storageUnlock.textContent = "Unlocking...";
this.toggles.storageUnlock.disabled = true;
}
await this.storageService.unlock(pubkey, { signer });
this.isStorageUnlocked = true;
this.updateLockUi();
await this.loadFromStorage();
} catch (err) {
console.error("Unlock failed", err);
userLogger.error("Unlock failed", err);
alert("Failed to unlock storage: " + err.message);
} finally {
this.toggles.storageUnlock.textContent = "Unlock";
this.toggles.storageUnlock.disabled = false;
if (this.toggles.storageUnlock) {
this.toggles.storageUnlock.textContent = "Unlock";
this.toggles.storageUnlock.disabled = false;
}
}
}
@@ -566,14 +629,17 @@ export class UploadModal {
this.statusText.storageLock.className = locked ? "text-xs text-critical" : "text-xs text-success";
}
if (this.toggles.storageUnlock) {
if (locked) {
// Show unlock button only if locked AND we have a configuration to unlock
if (locked && this.storageConfigured) {
this.toggles.storageUnlock.classList.remove("hidden");
} else {
this.toggles.storageUnlock.classList.add("hidden");
}
}
if (this.statusText.storage) {
if (locked) {
if (locked && this.storageConfigured) {
this.statusText.storage.classList.add("hidden"); // Summary view has its own status area
} else if (locked) {
this.statusText.storage.classList.add("hidden");
this.statusText.storage.textContent = "";
} else {
@@ -610,7 +676,7 @@ export class UploadModal {
const secret = this.inputs.r2Secret?.value?.trim();
if (!accountId || !keyId || !secret) {
alert("Please fill in Account ID, Access Key ID, and Secret Access Key.");
alert("Please fill in Account ID/Endpoint, Access Key ID, and Secret Access Key.");
return;
}
@@ -642,7 +708,7 @@ export class UploadModal {
const npub = pubkey ? this.safeEncodeNpub(pubkey) : null;
if (!settings.baseDomain) {
this.wizard.errorText.textContent = "Public Bucket URL is required.";
this.wizard.errorText.textContent = "Public URL is required.";
this.wizard.errorContainer.classList.remove("hidden");
return;
}
@@ -663,7 +729,7 @@ export class UploadModal {
// Dynamic Cloudflare Link
// https://dash.cloudflare.com/?to=/:account/r2/default/buckets/:bucket/settings
if (settings.accountId && npub) {
if (settings.accountId && npub && !settings.accountId.includes("://")) {
const bucketName = this.inputs.r2BucketName?.value || "";
const cfLink = `https://dash.cloudflare.com/?to=/${settings.accountId}/r2/default/buckets/${bucketName}/settings`;
this.wizard.guideLink.href = cfLink;
@@ -681,16 +747,31 @@ export class UploadModal {
if (pubkey) {
// Save to secure storage
const connectionId = this.activeConnectionId || `r2-${Date.now()}`;
await this.storageService.saveConnection(pubkey, connectionId, {
provider: PROVIDERS.R2,
accountId: settings.accountId,
// Heuristic for provider type
let provider = PROVIDERS.GENERIC;
let payload = {
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
};
// If account ID doesn't look like a URL, assume R2/S3-account-based
if (settings.accountId && !settings.accountId.includes("://")) {
provider = PROVIDERS.R2;
payload.accountId = settings.accountId;
} else {
payload.endpoint = settings.accountId;
}
await this.storageService.saveConnection(pubkey, connectionId, {
provider,
...payload,
}, {
provider: PROVIDERS.R2,
label: "Default R2",
provider,
label: provider === PROVIDERS.R2 ? "Default R2" : "Default S3",
baseDomain: settings.baseDomain,
defaultForUploads: true
defaultForUploads: true,
bucket: this.inputs.r2BucketName?.value || sanitizeBucketName(npub)
});
this.activeConnectionId = connectionId;
}
@@ -712,6 +793,8 @@ export class UploadModal {
this.toggles.storageSettings.setAttribute("aria-expanded", "false");
// Reset wizard to start for next edit
this.goToCredentialsStep();
// Reload state to potentially switch to summary view
this.loadFromStorage();
}, 1500);
} catch (err) {
userLogger.error("Verification crashed:", err);
+2 -1
View File
@@ -35,6 +35,7 @@ import {
formatShortNpub as defaultFormatShortNpub,
} from "../../utils/formatters.js";
import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js";
import { normalizeHashtag } from "../../utils/hashtagNormalization.js";
import { getBreakpointLg } from "../../designSystem/metrics.js";
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
@@ -2917,7 +2918,7 @@ export class VideoModal {
}
const raw = typeof entry === "string" ? entry : String(entry ?? "");
const trimmed = raw.trim().replace(/^#+/, "");
const trimmed = normalizeHashtag(raw);
if (!trimmed) {
continue;
+6 -14
View File
@@ -1,4 +1,8 @@
import { userLogger } from "../../utils/logger.js";
import {
normalizeHashtag,
formatHashtag,
} from "../../utils/hashtagNormalization.js";
// NOTE: Keep the Upload, Edit, and Revert modals in lockstep when updating NIP-71 form features.
const REPEATER_KEYS = ["imeta", "text-track", "segment", "t", "p", "r"];
@@ -468,22 +472,11 @@ export class Nip71FormManager {
value = `${value ?? ""}`;
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const withoutHash = trimmed.replace(/^#+/, "").trim();
if (!withoutHash) {
return "";
}
return withoutHash.toLowerCase();
return normalizeHashtag(value);
}
renderHashtagValue(value) {
const sanitized = this.sanitizeHashtagValue(value);
return sanitized ? `#${sanitized}` : "";
return formatHashtag(value);
}
collectNestedValues(entry, nestedKey) {
@@ -804,4 +797,3 @@ export class Nip71FormManager {
return this.sections.get(key) || null;
}
}
+9 -15
View File
@@ -1,4 +1,8 @@
import { normalizeDesignSystemContext } from "../../designSystem.js";
import {
normalizeHashtag,
formatHashtag,
} from "../../utils/hashtagNormalization.js";
export const TAG_PREFERENCE_ACTIONS = {
ADD_INTEREST: "add-interest",
@@ -83,19 +87,9 @@ function appendMenuAction(doc, list, { text, action, dataset = {}, disabled = fa
}
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();
const normalized = normalizeHashtag(tag);
return {
label: withoutHash,
label: normalized,
normalized,
};
}
@@ -172,7 +166,7 @@ export function createTagPreferenceMenu({
normalizeDesignSystemContext(designSystem);
const { label, normalized } = normalizeTag(tag);
const headingLabel = label ? `#${label}` : "Tag preferences";
const headingLabel = label ? formatHashtag(label) : "Tag preferences";
const { panel, list } = ensureMenuContainer(doc, ["w-56", "p-0"]);
panel.dataset.menu = "tag-preference";
@@ -191,7 +185,7 @@ export function createTagPreferenceMenu({
}
const buttons = {};
const actionDataset = { tag: label || normalized };
const actionDataset = { tag: normalized };
buttons.addInterest = appendMenuAction(doc, list, {
text: "Add to interests",
@@ -225,7 +219,7 @@ export function createTagPreferenceMenu({
}
try {
onAction(action, {
tag: label || normalized,
tag: normalized,
normalizedTag: normalized,
event,
button,
+5
View File
@@ -48,6 +48,11 @@ export function initUploadModal({
safeEncodeNpub,
eventTarget: events,
container,
onRequestStorageSettings: () => {
if (app && app.profileController && typeof app.profileController.show === "function") {
app.profileController.show("storage");
}
},
});
const boundSubmitHandler =
+33 -122
View File
@@ -1140,19 +1140,14 @@ export default class LoginModalController {
const handshakePanel = form.querySelector("[data-nip46-handshake-panel]");
const qrContainer = form.querySelector("[data-nip46-qr]");
const handshakeInput = form.querySelector("[data-nip46-handshake-uri]");
const debugUriContainer = form.querySelector("[data-nip46-debug-uri]");
const copyButton = form.querySelector("[data-nip46-copy-uri]");
const secretNode = form.querySelector("[data-nip46-secret]");
const statusNode = form.querySelector("[data-nip46-status]");
const authContainer = form.querySelector("[data-nip46-auth]");
const authMessageNode = form.querySelector("[data-nip46-auth-message]");
const authOpenButton = form.querySelector("[data-nip46-auth-open]");
const manualToggle = form.querySelector("[data-nip46-toggle-manual]");
const manualFields = form.querySelector("[data-nip46-manual-fields]");
const manualInput = form.querySelector("[data-nip46-legacy-uri]");
const rememberCheckbox = form.querySelector("[data-nip46-remember]");
const reuseButton = form.querySelector("[data-nip46-reuse]");
const cancelButton = form.querySelector("[data-nip46-cancel]");
const submitButton = form.querySelector("[data-nip46-submit]");
const errorNode = form.querySelector("[data-nip46-error]");
const storedMetadata = this.getStoredNip46Metadata();
@@ -1178,10 +1173,12 @@ export default class LoginModalController {
this.modalBody.appendChild(form);
this.activeNip46Form = form;
if (handshakeInput instanceof HTMLTextAreaElement) {
if (handshakeInput instanceof HTMLInputElement) {
handshakeInput.readOnly = true;
handshakeInput.value = "";
handshakeInput.placeholder = "Waiting for connect link…";
}
if (debugUriContainer instanceof HTMLElement) {
debugUriContainer.textContent = "Waiting for connect link...";
}
if (copyButton instanceof HTMLButtonElement) {
copyButton.disabled = true;
@@ -1236,7 +1233,6 @@ export default class LoginModalController {
const resetStatus = () => setStatus("", "info");
let qrInstance = null;
let manualMode = false;
let pendingAuthUrl = "";
const setAuthChallenge = (url) => {
@@ -1264,18 +1260,6 @@ export default class LoginModalController {
const setPendingState = (pending) => {
const disabled = pending === true;
if (submitButton instanceof HTMLButtonElement) {
submitButton.disabled = disabled;
}
if (rememberCheckbox instanceof HTMLInputElement) {
rememberCheckbox.disabled = disabled;
}
if (manualToggle instanceof HTMLButtonElement) {
manualToggle.disabled = disabled;
}
if (manualInput instanceof HTMLTextAreaElement) {
manualInput.disabled = disabled;
}
if (reuseButton instanceof HTMLButtonElement) {
reuseButton.disabled = disabled;
}
@@ -1284,7 +1268,7 @@ export default class LoginModalController {
}
if (copyButton instanceof HTMLButtonElement) {
const hasUri =
handshakeInput instanceof HTMLTextAreaElement &&
handshakeInput instanceof HTMLInputElement &&
!!handshakeInput.value;
copyButton.disabled = disabled || !hasUri;
}
@@ -1295,23 +1279,21 @@ export default class LoginModalController {
detail && typeof detail === "object"
? detail.uri || detail.connectionString || ""
: "";
if (handshakeInput instanceof HTMLTextAreaElement) {
if (!uri) {
setError("Failed to generate connection link. Please try again.");
return;
}
if (handshakeInput instanceof HTMLInputElement) {
handshakeInput.value = uri;
}
if (debugUriContainer instanceof HTMLElement) {
debugUriContainer.textContent = uri;
}
if (copyButton instanceof HTMLButtonElement) {
copyButton.disabled = !uri;
}
if (secretNode instanceof HTMLElement) {
const secretValue =
detail && typeof detail.secret === "string" ? detail.secret.trim() : "";
if (secretValue) {
secretNode.textContent = `Secret: ${secretValue}`;
secretNode.classList.remove("hidden");
} else {
secretNode.textContent = "";
secretNode.classList.add("hidden");
}
}
if (qrContainer instanceof HTMLElement) {
qrContainer.innerHTML = "";
const resolveTokenColor = (tokenName, fallback) => {
@@ -1354,7 +1336,7 @@ export default class LoginModalController {
if (fallback) {
fallback.className = "text-center text-xs text-text-muted";
fallback.textContent =
"Unable to render QR code. Use the connect link below.";
"Unable to render QR code. Use the copy button below.";
qrContainer.appendChild(fallback);
}
}
@@ -1363,9 +1345,6 @@ export default class LoginModalController {
}
}
setPendingState(false);
if (submitButton instanceof HTMLButtonElement) {
submitButton.disabled = true;
}
resetStatus();
clearAuthChallenge();
setError("");
@@ -1411,41 +1390,12 @@ export default class LoginModalController {
setAuthChallenge(normalized);
};
const applyManualMode = (enabled) => {
manualMode = enabled === true;
if (manualFields instanceof HTMLElement) {
manualFields.classList.toggle("hidden", !manualMode);
}
if (handshakePanel instanceof HTMLElement) {
handshakePanel.classList.toggle("hidden", manualMode);
}
if (manualToggle instanceof HTMLButtonElement) {
manualToggle.textContent = manualMode
? "Back to QR pairing"
: "Paste a signer-provided bunker link instead";
}
if (manualMode && manualInput instanceof HTMLTextAreaElement) {
manualInput.focus();
}
if (!manualMode && manualInput instanceof HTMLTextAreaElement) {
manualInput.value = "";
}
resetStatus();
clearAuthChallenge();
setError("");
};
let submitHandler = null;
let cancelHandler = null;
let reuseHandler = null;
let copyHandler = null;
let manualToggleHandler = null;
let authOpenHandler = null;
const cleanup = () => {
if (submitHandler) {
form.removeEventListener("submit", submitHandler);
}
if (cancelHandler && cancelButton instanceof HTMLButtonElement) {
cancelButton.removeEventListener("click", cancelHandler);
}
@@ -1455,9 +1405,6 @@ export default class LoginModalController {
if (copyHandler && copyButton instanceof HTMLButtonElement) {
copyButton.removeEventListener("click", copyHandler);
}
if (manualToggleHandler && manualToggle instanceof HTMLButtonElement) {
manualToggle.removeEventListener("click", manualToggleHandler);
}
if (authOpenHandler && authOpenButton instanceof HTMLButtonElement) {
authOpenButton.removeEventListener("click", authOpenHandler);
}
@@ -1483,7 +1430,6 @@ export default class LoginModalController {
resetStatus();
clearAuthChallenge();
applyManualMode(false);
setError("");
return new Promise((resolve) => {
@@ -1506,42 +1452,12 @@ export default class LoginModalController {
resolve(detail);
};
submitHandler = (event) => {
event.preventDefault();
// Auto-submit immediately to generate the QR code
const autoStart = () => {
setError("");
resetStatus();
clearAuthChallenge();
const remember =
rememberCheckbox instanceof HTMLInputElement
? rememberCheckbox.checked
: true;
if (manualMode) {
if (!(manualInput instanceof HTMLTextAreaElement)) {
setError("Manual input is unavailable.");
return;
}
const raw = manualInput.value.trim();
if (!raw) {
setError("Paste the bunker:// link from your signer.");
manualInput.focus();
return;
}
setPendingState(true);
finish(
{
mode: "manual",
connectionString: raw,
remember: remember !== false,
onStatus: handleStatus,
onAuthUrl: handleAuthUrl,
},
{ keepMounted: false },
);
return;
}
const metadata = {};
if (this.document && typeof this.document.title === "string") {
const title = this.document.title.trim();
@@ -1557,7 +1473,7 @@ export default class LoginModalController {
finish(
{
mode: "handshake",
remember: remember !== false,
remember: true, // Always remember for now in simplified flow
metadata,
onHandshakePrepared: updateHandshakeDisplay,
onStatus: handleStatus,
@@ -1567,8 +1483,6 @@ export default class LoginModalController {
);
};
form.addEventListener("submit", submitHandler);
if (cancelButton instanceof HTMLButtonElement) {
cancelHandler = (event) => {
event.preventDefault();
@@ -1588,7 +1502,7 @@ export default class LoginModalController {
if (copyButton instanceof HTMLButtonElement) {
copyHandler = async (event) => {
event.preventDefault();
if (!(handshakeInput instanceof HTMLTextAreaElement)) {
if (!(handshakeInput instanceof HTMLInputElement)) {
return;
}
const value = handshakeInput.value.trim();
@@ -1615,18 +1529,20 @@ export default class LoginModalController {
}
} catch (error) {
copied = false;
} finally {
handshakeInput.setSelectionRange(
handshakeInput.value.length,
handshakeInput.value.length,
);
}
}
if (copied) {
setStatus("Connect URI copied to clipboard.", "success");
// Temporary button feedback
const originalText = copyButton.textContent;
copyButton.textContent = "Copied!";
copyButton.classList.add("btn-success"); // Assuming this utility exists or similar
setTimeout(() => {
copyButton.textContent = originalText;
copyButton.classList.remove("btn-success");
}, 2000);
} else {
setStatus(
"Copy failed. Copy the link manually if needed.",
"Copy failed. Use the manual fallback below.",
"warning",
);
}
@@ -1634,14 +1550,6 @@ export default class LoginModalController {
copyButton.addEventListener("click", copyHandler);
}
if (manualToggle instanceof HTMLButtonElement) {
manualToggleHandler = (event) => {
event.preventDefault();
applyManualMode(!manualMode);
};
manualToggle.addEventListener("click", manualToggleHandler);
}
if (authOpenButton instanceof HTMLButtonElement) {
authOpenHandler = (event) => {
event.preventDefault();
@@ -1656,6 +1564,9 @@ export default class LoginModalController {
};
authOpenButton.addEventListener("click", authOpenHandler);
}
// Kick off the handshake generation
setTimeout(autoStart, 0);
});
}
+824 -15
View File
@@ -16,7 +16,12 @@ import {
import { getBreakpointLg } from "../designSystem/metrics.js";
import { getProviderMetadata } from "../services/authProviders/index.js";
import { devLogger, userLogger } from "../utils/logger.js";
import {
normalizeHashtag,
formatHashtag,
} from "../utils/hashtagNormalization.js";
import { getActiveSigner } from "../nostr/client.js";
import { buildPublicUrl, buildR2Key } from "../r2.js";
const noop = () => {};
@@ -28,6 +33,8 @@ const NWC_URI_SCHEME = "nostr+walletconnect://";
const SECRET_PLACEHOLDER = "*****";
const DEFAULT_MAX_WALLET_DEFAULT_ZAP = 100000000;
const DEFAULT_SAVED_PROFILE_LABEL = "Saved profile";
const TRUSTED_MUTE_HIDE_HELPER_TEXT =
"Reaching this count hides cards (with “Show anyway”); lower signals only blur thumbnails or block autoplay.";
const ADD_PROFILE_CANCELLATION_CODES = new Set([
"login-cancelled",
@@ -183,6 +190,12 @@ const SERVICE_CONTRACT = [
? fallbackMessage.trim()
: "Failed to login. Please try again.",
},
{
key: "r2Service",
type: "object",
description: "Service for handling file uploads (R2/S3).",
optional: true,
},
{
key: "nostrService",
type: "object",
@@ -501,6 +514,20 @@ const SERVICE_CONTRACT = [
return service;
},
},
{
key: "getModerationOverrides",
type: "function",
description:
"Returns a list of stored moderation overrides for the active viewer.",
fallback: () => () => [],
},
{
key: "clearModerationOverride",
type: "function",
description:
"Clears a specific moderation override when the viewer resets a decision.",
fallback: () => () => false,
},
{
key: "loadVideos",
type: "function",
@@ -903,17 +930,28 @@ export class ProfileModalController {
? moderationServiceCandidate
: null;
this.unsubscribeModerationContacts = null;
this.unsubscribeModerationStats = [];
if (
this.moderationService &&
typeof this.moderationService.on === "function"
) {
try {
const updateTrustStats = () => {
this.updateModerationTrustStats();
};
this.unsubscribeModerationContacts = this.moderationService.on(
"contacts",
() => {
void this.populateFriendsList();
updateTrustStats();
},
);
this.unsubscribeModerationStats = [
this.moderationService.on("trusted-mutes", updateTrustStats),
this.moderationService.on("summary", updateTrustStats),
].filter((unsubscribe) => typeof unsubscribe === "function");
} catch (error) {
devLogger.warn(
"[profileModal] Failed to subscribe to moderation contacts updates:",
@@ -1062,6 +1100,9 @@ export class ProfileModalController {
this.profileMessagesStatus = null;
this.profileMessagesReloadButton = null;
this.profileMessagesPane = null;
this.profileMessageInput = null;
this.profileMessageSendButton = null;
this.profileMessagesComposerHelper = null;
this.walletUriInput = null;
this.walletDefaultZapInput = null;
this.walletSaveButton = null;
@@ -1086,8 +1127,11 @@ export class ProfileModalController {
this.moderationSaveButton = null;
this.moderationResetButton = null;
this.moderationStatusText = null;
this.moderationOverridesList = null;
this.moderationOverridesEmpty = null;
this.moderationHideControlsGroup = null;
this.moderationHideControlElements = [];
this.boundModerationOverridesUpdate = null;
this.moderatorSection = null;
this.moderatorEmpty = null;
this.adminModeratorList = null;
@@ -1263,7 +1307,11 @@ export class ProfileModalController {
document.getElementById("profileNavHistory") || null;
this.navButtons.admin = document.getElementById("profileNavAdmin") || null;
this.profileEditBtn = document.getElementById("profileEditBtn") || null;
this.profileEditBackBtn = document.getElementById("profileEditBackBtn") || null;
this.panes.account = document.getElementById("profilePaneAccount") || null;
this.panes.edit = document.getElementById("profilePaneEdit") || null;
this.panes.relays = document.getElementById("profilePaneRelays") || null;
this.panes.wallet = document.getElementById("profilePaneWallet") || null;
this.panes.storage = document.getElementById("profilePaneStorage") || null;
@@ -1310,6 +1358,12 @@ export class ProfileModalController {
document.getElementById("profileMessagesStatus") || null;
this.profileMessagesReloadButton =
document.getElementById("profileMessagesReload") || null;
this.profileMessageInput =
document.getElementById("profileMessageInput") || null;
this.profileMessageSendButton =
document.getElementById("profileMessageSendBtn") || null;
this.profileMessagesComposerHelper =
document.getElementById("profileMessagesComposerHelper") || null;
if (this.pendingMessagesRender) {
const { messages, actorPubkey } = this.pendingMessagesRender;
@@ -1402,6 +1456,18 @@ export class ProfileModalController {
document.getElementById("profileModerationReset") || null;
this.moderationStatusText =
document.getElementById("profileModerationStatus") || null;
this.moderationOverridesList =
document.getElementById("profileModerationOverridesList") || null;
this.moderationOverridesEmpty =
document.getElementById("profileModerationOverridesEmpty") || null;
this.moderationTrustedContactsCount =
document.getElementById("profileModerationTrustedContactsCount") || null;
this.moderationTrustedMuteCount =
document.getElementById("profileModerationTrustedMuteCount") || null;
this.moderationTrustedReportCount =
document.getElementById("profileModerationTrustedReportCount") || null;
this.moderationSeedOnlyIndicator =
document.getElementById("profileModerationSeedOnlyIndicator") || null;
this.moderationHideControlsGroup =
this.moderationSettingsCard?.querySelector(
"[data-role=\"trusted-hide-controls\"]",
@@ -1411,6 +1477,7 @@ export class ProfileModalController {
"[data-role=\"trusted-hide-control\"]",
) || [],
);
this.updateTrustedMuteHideHelperCopy();
this.moderatorSection =
document.getElementById("adminModeratorsSection") || null;
@@ -1481,6 +1548,29 @@ export class ProfileModalController {
this.adminAddBlacklistButton = this.addBlacklistButton;
this.adminBlacklistInput = this.blacklistInput;
this.editNameInput = document.getElementById("editNameInput") || null;
this.editDisplayNameInput = document.getElementById("editDisplayNameInput") || null;
this.editAboutInput = document.getElementById("editAboutInput") || null;
this.editWebsiteInput = document.getElementById("editWebsiteInput") || null;
this.editNip05Input = document.getElementById("editNip05Input") || null;
this.editLud16Input = document.getElementById("editLud16Input") || null;
this.editPictureInput = document.getElementById("editPictureInput") || null;
this.editBannerInput = document.getElementById("editBannerInput") || null;
this.editPictureFile = document.getElementById("editPictureFile") || null;
this.editPictureUploadBtn = document.getElementById("editPictureUploadBtn") || null;
this.editPictureStorageHint = document.getElementById("editPictureStorageHint") || null;
this.editPictureConfigureLink = document.getElementById("editPictureConfigureLink") || null;
this.editBannerFile = document.getElementById("editBannerFile") || null;
this.editBannerUploadBtn = document.getElementById("editBannerUploadBtn") || null;
this.editBannerStorageHint = document.getElementById("editBannerStorageHint") || null;
this.editBannerConfigureLink = document.getElementById("editBannerConfigureLink") || null;
this.editSaveBtn = document.getElementById("editSaveBtn") || null;
this.editCancelBtn = document.getElementById("editCancelBtn") || null;
this.editStatusText = document.getElementById("editStatusText") || null;
if (this.createWatchHistoryRenderer) {
this.ensureProfileHistoryRenderer();
}
@@ -1864,6 +1954,7 @@ export class ProfileModalController {
}
this.updateMessagesReloadState();
this.updateMessageComposerState();
}
updateMessagesReloadState() {
@@ -1889,6 +1980,40 @@ export class ProfileModalController {
}
}
updateMessageComposerState() {
const input = this.profileMessageInput;
const button = this.profileMessageSendButton;
const helper = this.profileMessagesComposerHelper;
const shouldDisable = this.messagesLoadingState === "unauthenticated";
const applyDisabledState = (element) => {
if (!(element instanceof HTMLElement) || !("disabled" in element)) {
return;
}
element.disabled = shouldDisable;
if (shouldDisable) {
element.setAttribute("aria-disabled", "true");
} else {
element.removeAttribute("aria-disabled");
}
};
applyDisabledState(input);
applyDisabledState(button);
if (helper instanceof HTMLElement) {
if (shouldDisable) {
helper.textContent = "Sign in to send messages.";
helper.classList.remove("hidden");
helper.removeAttribute("hidden");
} else {
helper.classList.add("hidden");
helper.setAttribute("hidden", "");
}
}
}
setMessagesAnnouncement(message) {
if (!(this.profileMessagesStatus instanceof HTMLElement)) {
return;
@@ -1919,6 +2044,93 @@ export class ProfileModalController {
}
}
describeDirectMessageSendError(code) {
switch (code) {
case "sign-event-unavailable":
return "Connect a Nostr signer to send messages.";
case "encryption-unsupported":
return "Your signer does not support NIP-44 or NIP-04 encryption.";
case "extension-permission-denied":
return "Please grant your Nostr extension permission to send messages.";
case "missing-actor-pubkey":
return "We couldn’t determine your public key to send this message.";
case "nostr-uninitialized":
return "Direct messages are still connecting to relays. Please try again.";
case "signature-failed":
return "We couldn’t sign the message. Please reconnect your signer and try again.";
case "encryption-failed":
return "We couldn’t encrypt the message. Please try again.";
case "publish-failed":
return "Failed to deliver this message to any relay. Please try again.";
case "invalid-target":
return "Select a valid recipient before sending.";
case "empty-message":
return "Please enter a message.";
default:
return "Unable to send message. Please try again.";
}
}
async handleSendProfileMessage() {
const input = this.profileMessageInput;
if (!(input instanceof HTMLTextAreaElement)) {
return;
}
const message = typeof input.value === "string" ? input.value.trim() : "";
if (!message) {
this.showError("Please enter a message.");
return;
}
const targetCandidate =
this.resolveActiveDmActor() || this.directMessagesLastActor;
const target =
typeof targetCandidate === "string" ? targetCandidate.trim() : "";
if (!target) {
this.showError("Please select a message recipient.");
return;
}
if (
!this.services.nostrClient ||
typeof this.services.nostrClient.sendDirectMessage !== "function"
) {
this.showError("Direct message service unavailable.");
return;
}
const sendButton = this.profileMessageSendButton;
if (sendButton instanceof HTMLElement && "disabled" in sendButton) {
sendButton.disabled = true;
sendButton.setAttribute("aria-disabled", "true");
}
try {
const result = await this.services.nostrClient.sendDirectMessage(
target,
message,
);
if (result?.ok) {
input.value = "";
this.showSuccess("Message sent.");
void this.populateProfileMessages({ force: true, reason: "send-message" });
return;
}
const errorCode =
typeof result?.error === "string" ? result.error : "unknown";
userLogger.warn("[profileModal] Failed to send direct message:", errorCode);
this.showError(this.describeDirectMessageSendError(errorCode));
} catch (error) {
userLogger.error("[profileModal] Unexpected DM send failure:", error);
this.showError("Unable to send message. Please try again.");
} finally {
this.updateMessageComposerState();
}
}
clearProfileMessages({ message } = {}) {
this.directMessagesCache = [];
this.directMessagesLastActor = this.resolveActiveDmActor();
@@ -2592,6 +2804,21 @@ export class ProfileModalController {
});
}
if (this.profileMessageSendButton instanceof HTMLElement) {
this.profileMessageSendButton.addEventListener("click", () => {
void this.handleSendProfileMessage();
});
}
if (this.profileMessageInput instanceof HTMLElement) {
this.profileMessageInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void this.handleSendProfileMessage();
}
});
}
if (this.walletUriInput instanceof HTMLElement) {
this.walletUriInput.addEventListener("focus", () => {
this.revealSecretInputValue(this.walletUriInput);
@@ -2666,6 +2893,24 @@ export class ProfileModalController {
});
}
if (!this.boundModerationOverridesUpdate && typeof document !== "undefined") {
this.boundModerationOverridesUpdate = () => {
this.refreshModerationOverridesUi();
};
document.addEventListener(
"video:moderation-override",
this.boundModerationOverridesUpdate,
);
document.addEventListener(
"video:moderation-hide",
this.boundModerationOverridesUpdate,
);
document.addEventListener(
"video:moderation-block",
this.boundModerationOverridesUpdate,
);
}
if (this.addModeratorButton instanceof HTMLElement) {
this.addModeratorButton.addEventListener("click", () => {
void this.handleAddModerator();
@@ -2741,6 +2986,68 @@ export class ProfileModalController {
});
}
if (this.profileEditBtn instanceof HTMLElement) {
this.profileEditBtn.addEventListener("click", () => {
this.handleEditProfile();
});
}
if (this.profileEditBackBtn instanceof HTMLElement) {
this.profileEditBackBtn.addEventListener("click", () => {
this.selectPane("account");
});
}
if (this.editCancelBtn instanceof HTMLElement) {
this.editCancelBtn.addEventListener("click", () => {
this.selectPane("account");
});
}
if (this.editSaveBtn instanceof HTMLElement) {
this.editSaveBtn.addEventListener("click", () => {
void this.handleSaveProfile();
});
}
if (this.editPictureUploadBtn instanceof HTMLElement) {
this.editPictureUploadBtn.addEventListener("click", () => {
if (this.editPictureFile) this.editPictureFile.click();
});
}
if (this.editPictureFile instanceof HTMLElement) {
this.editPictureFile.addEventListener("change", () => {
void this.handleUpload("picture");
});
}
if (this.editBannerUploadBtn instanceof HTMLElement) {
this.editBannerUploadBtn.addEventListener("click", () => {
if (this.editBannerFile) this.editBannerFile.click();
});
}
if (this.editBannerFile instanceof HTMLElement) {
this.editBannerFile.addEventListener("change", () => {
void this.handleUpload("banner");
});
}
if (this.editPictureConfigureLink instanceof HTMLElement) {
this.editPictureConfigureLink.addEventListener("click", (e) => {
e.preventDefault();
this.selectPane("storage");
});
}
if (this.editBannerConfigureLink instanceof HTMLElement) {
this.editBannerConfigureLink.addEventListener("click", (e) => {
e.preventDefault();
this.selectPane("storage");
});
}
this.updateMessagesReloadState();
}
@@ -4404,24 +4711,11 @@ export class ProfileModalController {
}
normalizeHashtagTag(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
return trimmed.replace(/^#+/, "").toLowerCase();
return normalizeHashtag(value);
}
formatHashtagTag(value) {
const normalized = this.normalizeHashtagTag(value);
if (!normalized) {
return "";
}
return `#${normalized}`;
return formatHashtag(value);
}
sanitizeHashtagList(list) {
@@ -5891,6 +6185,171 @@ export class ProfileModalController {
}
}
handleEditProfile() {
this.selectPane("edit");
void this.populateEditPane();
}
async populateEditPane() {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (!pubkey) {
return;
}
const cacheEntry = this.services.getProfileCacheEntry(pubkey);
const profile = cacheEntry?.profile || {};
if (this.editNameInput) this.editNameInput.value = profile.name || "";
if (this.editDisplayNameInput)
this.editDisplayNameInput.value = profile.display_name || "";
if (this.editAboutInput) this.editAboutInput.value = profile.about || "";
if (this.editWebsiteInput)
this.editWebsiteInput.value = profile.website || "";
if (this.editNip05Input) this.editNip05Input.value = profile.nip05 || "";
if (this.editLud16Input) this.editLud16Input.value = profile.lud16 || "";
if (this.editPictureInput)
this.editPictureInput.value = profile.picture || "";
if (this.editBannerInput) this.editBannerInput.value = profile.banner || "";
void this.checkStorageForUploads(pubkey);
}
async checkStorageForUploads(pubkey) {
const r2Service = this.services.r2Service;
if (!r2Service) return;
let hasStorage = false;
try {
const credentials = await r2Service.resolveConnection(pubkey);
hasStorage = !!credentials;
} catch (e) {
hasStorage = false;
}
const updateUI = (uploadBtn, hint, has) => {
if (uploadBtn) {
uploadBtn.disabled = !has;
if (!has) uploadBtn.setAttribute("aria-disabled", "true");
else uploadBtn.removeAttribute("aria-disabled");
}
if (hint) {
if (has) hint.classList.add("hidden");
else hint.classList.remove("hidden");
}
};
updateUI(
this.editPictureUploadBtn,
this.editPictureStorageHint,
hasStorage,
);
updateUI(this.editBannerUploadBtn, this.editBannerStorageHint, hasStorage);
}
async handleUpload(type) {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (!pubkey) return;
const r2Service = this.services.r2Service;
if (!r2Service) return;
const fileInput =
type === "picture" ? this.editPictureFile : this.editBannerFile;
const urlInput =
type === "picture" ? this.editPictureInput : this.editBannerInput;
const uploadBtn =
type === "picture" ? this.editPictureUploadBtn : this.editBannerUploadBtn;
if (!fileInput || !fileInput.files.length) return;
const file = fileInput.files[0];
try {
if (uploadBtn) {
uploadBtn.disabled = true;
uploadBtn.textContent = "Uploading...";
}
const credentials = await r2Service.resolveConnection(pubkey);
if (!credentials) {
this.showError("Storage configuration missing.");
return;
}
const key = buildR2Key(pubkey, file);
await r2Service.uploadFile({
file,
...credentials,
bucket: credentials.bucket,
key,
});
const url = buildPublicUrl(credentials.baseDomain, key);
if (urlInput) urlInput.value = url;
fileInput.value = "";
} catch (error) {
this.showError("Upload failed: " + (error.message || "Unknown error"));
devLogger.error("Upload error:", error);
} finally {
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.textContent = "Upload";
}
}
}
async handleSaveProfile() {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (!pubkey) return;
const profile = {
name: this.editNameInput?.value?.trim() || "",
display_name: this.editDisplayNameInput?.value?.trim() || "",
about: this.editAboutInput?.value?.trim() || "",
website: this.editWebsiteInput?.value?.trim() || "",
nip05: this.editNip05Input?.value?.trim() || "",
lud16: this.editLud16Input?.value?.trim() || "",
picture: this.editPictureInput?.value?.trim() || "",
banner: this.editBannerInput?.value?.trim() || "",
};
if (this.editSaveBtn) {
this.editSaveBtn.disabled = true;
this.editSaveBtn.textContent = "Saving...";
}
try {
const content = JSON.stringify(profile);
const event = {
kind: 0,
created_at: Math.floor(Date.now() / 1000),
pubkey,
tags: [],
content,
};
const result =
await this.services.nostrClient.signAndPublishEvent(event);
if (result && result.signedEvent) {
if (this.services.nostrClient.handleEvent) {
this.services.nostrClient.handleEvent(result.signedEvent);
}
}
this.showSuccess("Profile updated!");
this.selectPane("account");
this.renderSavedProfiles();
} catch (error) {
this.showError("Failed to save profile: " + error.message);
} finally {
if (this.editSaveBtn) {
this.editSaveBtn.disabled = false;
this.editSaveBtn.textContent = "Save Profile";
}
}
}
async populateStoragePane() {
const storageService = this.services.storageService;
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
@@ -7044,12 +7503,232 @@ export class ProfileModalController {
}
}
updateTrustedMuteHideHelperCopy() {
if (!(this.moderationMuteHideInput instanceof HTMLInputElement)) {
return;
}
const label = this.moderationMuteHideInput.closest("label");
if (!(label instanceof HTMLElement)) {
return;
}
const helper = label.querySelector("span.text-xs");
if (!(helper instanceof HTMLElement)) {
return;
}
helper.textContent = TRUSTED_MUTE_HIDE_HELPER_TEXT;
}
getModerationOverrideEntries() {
if (typeof this.services.getModerationOverrides !== "function") {
return [];
}
try {
const entries = this.services.getModerationOverrides();
return Array.isArray(entries) ? entries : [];
} catch (error) {
devLogger.info(
"[profileModal] moderation overrides fallback used",
error,
);
return [];
}
}
normalizeModerationOverrideEntries(entries = []) {
const normalized = [];
const seen = new Set();
entries.forEach((entry) => {
if (!entry || typeof entry !== "object") {
return;
}
const eventId =
typeof entry.eventId === "string"
? entry.eventId.trim().toLowerCase()
: "";
if (!eventId) {
return;
}
const author =
typeof entry.authorPubkey === "string"
? entry.authorPubkey.trim()
: "";
const normalizedAuthor = author ? this.normalizeHexPubkey(author) || author : "";
const key = `${normalizedAuthor || ""}:${eventId}`;
if (seen.has(key)) {
return;
}
seen.add(key);
normalized.push({
eventId,
authorPubkey: normalizedAuthor || "",
updatedAt: Number.isFinite(entry.updatedAt)
? Math.floor(entry.updatedAt)
: 0,
});
});
normalized.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
return normalized;
}
formatModerationOverrideTimestamp(updatedAt) {
const numeric = Number(updatedAt);
if (!Number.isFinite(numeric) || numeric <= 0) {
return { display: "", iso: "" };
}
try {
const date = new Date(numeric);
return {
display: date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}),
iso: date.toISOString(),
};
} catch (error) {
return { display: "", iso: "" };
}
}
async handleModerationOverrideReset(entry) {
if (!entry || typeof entry !== "object") {
return false;
}
if (typeof this.services.clearModerationOverride !== "function") {
return false;
}
try {
await this.services.clearModerationOverride({
eventId: entry.eventId,
authorPubkey: entry.authorPubkey,
});
this.refreshModerationOverridesUi();
this.showSuccess("Moderation override reset.");
return true;
} catch (error) {
this.showError("Unable to reset this moderation override.");
return false;
}
}
refreshModerationOverridesUi() {
if (
!(this.moderationOverridesList instanceof HTMLElement) ||
!(this.moderationOverridesEmpty instanceof HTMLElement)
) {
return;
}
const entries = this.normalizeModerationOverrideEntries(
this.getModerationOverrideEntries(),
);
this.moderationOverridesList.innerHTML = "";
if (!entries.length) {
this.moderationOverridesEmpty.classList.remove("hidden");
this.moderationOverridesList.classList.add("hidden");
return;
}
this.moderationOverridesEmpty.classList.add("hidden");
this.moderationOverridesList.classList.remove("hidden");
const entriesNeedingFetch = new Set();
entries.forEach((entry) => {
const item = document.createElement("li");
item.className = "card space-y-2 p-4";
const row = document.createElement("div");
row.className = "flex items-center justify-between gap-4";
const authorKey = entry.authorPubkey;
let profileSummary = null;
if (authorKey) {
const cacheEntry = this.services.getProfileCacheEntry(authorKey);
if (!cacheEntry) {
entriesNeedingFetch.add(authorKey);
}
}
const summaryData = this.resolveProfileSummaryForPubkey(authorKey);
profileSummary = this.createCompactProfileSummary(summaryData);
const actions = document.createElement("div");
actions.className = "flex flex-wrap items-center justify-end gap-2";
const resetButton = this.createRemoveButton({
label: "Reset",
onRemove: () => this.handleModerationOverrideReset(entry),
});
if (resetButton) {
actions.appendChild(resetButton);
}
if (profileSummary) {
row.appendChild(profileSummary);
}
if (actions.childElementCount > 0) {
row.appendChild(actions);
}
const meta = document.createElement("div");
meta.className = "flex flex-wrap items-center gap-3 text-2xs text-muted";
const contentId = document.createElement("span");
contentId.className = "font-mono text-2xs text-muted";
const shortId =
typeof this.truncateMiddle === "function"
? this.truncateMiddle(entry.eventId, 16)
: entry.eventId;
contentId.textContent = `Content ${shortId}`;
contentId.title = entry.eventId;
meta.appendChild(contentId);
const timestamp = this.formatModerationOverrideTimestamp(entry.updatedAt);
if (timestamp.display) {
const time = document.createElement("time");
time.className = "text-2xs text-muted";
time.dateTime = timestamp.iso;
time.textContent = `Updated ${timestamp.display}`;
meta.appendChild(time);
}
item.appendChild(row);
item.appendChild(meta);
this.moderationOverridesList.appendChild(item);
});
if (
entriesNeedingFetch.size &&
typeof this.services.batchFetchProfiles === "function"
) {
this.services.batchFetchProfiles(entriesNeedingFetch);
}
}
refreshModerationSettingsUi() {
const service = this.getModerationSettingsService();
if (!service) {
this.moderationSettingsDefaults = createInternalDefaultModerationSettings();
this.currentModerationSettings = createInternalDefaultModerationSettings();
this.updateTrustedHideControlsVisibility();
this.updateModerationTrustStats();
this.refreshModerationOverridesUi();
this.applyModerationSettingsControlState({ resetStatus: true });
return;
}
@@ -7091,10 +7770,140 @@ export class ProfileModalController {
}
this.updateTrustedHideControlsVisibility();
this.updateModerationTrustStats();
this.refreshModerationOverridesUi();
this.applyModerationSettingsControlState({ resetStatus: true });
}
getModerationTrustStats() {
const summary = {
trustedContactsCount: 0,
trustedMuteContributors: 0,
trustedReportContributors: 0,
trustedSeedOnly: false,
};
const service = this.moderationService;
if (!service) {
return summary;
}
if (typeof service.isTrustedSeedOnly === "function") {
summary.trustedSeedOnly = service.isTrustedSeedOnly();
} else if (typeof service.trustedSeedOnly === "boolean") {
summary.trustedSeedOnly = service.trustedSeedOnly;
}
const trustedContacts =
service.trustedContacts instanceof Set
? service.trustedContacts
: Array.isArray(service.trustedContacts)
? new Set(service.trustedContacts)
: new Set();
summary.trustedContactsCount = trustedContacts.size;
const adminSnapshot =
typeof service.getAdminListSnapshot === "function"
? service.getAdminListSnapshot()
: null;
const resolveStatus = (candidate) => {
if (typeof service.getAccessControlStatus === "function") {
return service.getAccessControlStatus(candidate, adminSnapshot);
}
return {
hex: this.normalizeHexPubkey(candidate),
whitelisted: false,
blacklisted: false,
};
};
const isBlocked = (pubkey) =>
typeof service.isPubkeyBlockedByViewer === "function"
? service.isPubkeyBlockedByViewer(pubkey)
: false;
const isTrustedCandidate = (status) => {
if (!status || !status.hex) {
return false;
}
if (status.blacklisted) {
return false;
}
if (isBlocked(status.hex)) {
return false;
}
return Boolean(status.whitelisted || trustedContacts.has(status.hex));
};
if (service.trustedMuteLists instanceof Map) {
const trustedMuteOwners = new Set();
for (const owner of service.trustedMuteLists.keys()) {
const status = resolveStatus(owner);
if (isTrustedCandidate(status)) {
trustedMuteOwners.add(status.hex);
}
}
summary.trustedMuteContributors = trustedMuteOwners.size;
}
if (service.reportEvents instanceof Map) {
const trustedReporters = new Set();
for (const eventReports of service.reportEvents.values()) {
if (!(eventReports instanceof Map)) {
continue;
}
for (const reporter of eventReports.keys()) {
const status = resolveStatus(reporter);
if (!isTrustedCandidate(status)) {
continue;
}
trustedReporters.add(status.hex);
}
}
summary.trustedReportContributors = trustedReporters.size;
}
return summary;
}
updateModerationTrustStats() {
if (
!(this.moderationTrustedContactsCount instanceof HTMLElement) &&
!(this.moderationTrustedMuteCount instanceof HTMLElement) &&
!(this.moderationTrustedReportCount instanceof HTMLElement) &&
!(this.moderationSeedOnlyIndicator instanceof HTMLElement)
) {
return;
}
const summary = this.getModerationTrustStats();
if (this.moderationTrustedContactsCount instanceof HTMLElement) {
this.moderationTrustedContactsCount.textContent = String(
summary.trustedContactsCount,
);
}
if (this.moderationTrustedMuteCount instanceof HTMLElement) {
this.moderationTrustedMuteCount.textContent = String(
summary.trustedMuteContributors,
);
}
if (this.moderationTrustedReportCount instanceof HTMLElement) {
this.moderationTrustedReportCount.textContent = String(
summary.trustedReportContributors,
);
}
if (this.moderationSeedOnlyIndicator instanceof HTMLElement) {
this.moderationSeedOnlyIndicator.hidden = !summary.trustedSeedOnly;
}
}
async handleModerationSettingsSave() {
const service = this.getModerationSettingsService();
const context = {
+357
View File
@@ -0,0 +1,357 @@
import { TAG_PREFERENCE_ACTIONS, applyTagPreferenceMenuState, createTagPreferenceMenu } from "./components/tagPreferenceMenu.js";
import { devLogger, userLogger } from "../utils/logger.js";
export default class TagPreferenceMenuController {
constructor({ services = {}, callbacks = {}, helpers = {} } = {}) {
this.services = services;
this.callbacks = callbacks;
this.helpers = helpers;
this.popovers = new Map();
this.publishInFlight = false;
this.publishPromise = null;
}
ensurePopover(detail = {}) {
const triggerCandidate = detail?.trigger || null;
const trigger =
triggerCandidate && triggerCandidate.nodeType === 1 ? triggerCandidate : null;
const rawTag = typeof detail?.tag === "string" ? detail.tag : "";
const tag = rawTag.trim();
if (!trigger || !tag) {
return null;
}
let entry = this.popovers.get(trigger);
const render = ({ document: documentRef, close }) => {
const menu = createTagPreferenceMenu({
document: documentRef,
tag: entry.tag,
isLoggedIn: this.callbacks.isLoggedIn(),
membership: this.callbacks.getMembership(entry.tag),
designSystem: this.helpers.getDesignSystem
? this.helpers.getDesignSystem()
: null,
onAction: (action, actionDetail = {}) => {
void this.handleMenuAction(action, {
tag: entry.tag,
trigger,
video: entry.video || null,
closePopover: close,
actionDetail,
});
},
});
if (!menu?.panel) {
return null;
}
entry.panel = menu.panel;
entry.buttons = menu.buttons;
return menu.panel;
};
if (!entry) {
entry = {
trigger,
tag,
context: detail.context || "",
video: detail.video || null,
panel: null,
buttons: null,
popover: null,
};
const ownerDocument =
trigger.ownerDocument || (typeof document !== "undefined" ? document : null);
const popover = this.helpers.createPopover(trigger, render, {
document: ownerDocument,
placement: "bottom-start",
restoreFocusOnClose: true,
});
if (!popover) {
return null;
}
const originalDestroy = popover.destroy?.bind(popover);
if (typeof originalDestroy === "function") {
popover.destroy = (...args) => {
originalDestroy(...args);
if (this.popovers.get(trigger) === entry) {
this.popovers.delete(trigger);
}
};
}
entry.popover = popover;
this.popovers.set(trigger, entry);
} else {
entry.tag = tag;
entry.context = detail.context || entry.context || "";
entry.video = detail.video || entry.video || null;
}
return entry;
}
requestMenu(detail = {}) {
const entry = this.ensurePopover(detail);
if (!entry?.popover) {
return;
}
const popover = entry.popover;
const restoreFocus = detail.restoreFocus !== false;
if (typeof popover.isOpen === "function" && popover.isOpen()) {
popover.close({ restoreFocus });
return;
}
this.closeMenus({
restoreFocus: false,
skipTrigger: entry.trigger,
});
// Notify app to close other menus
if (this.callbacks.onMenuOpen) {
this.callbacks.onMenuOpen(entry.trigger);
}
popover
.open()
.then(() => {
this.refreshActiveMenus();
})
.catch((error) =>
userLogger.error("[TagPreferenceMenu] Failed to open popover:", error),
);
}
closeMenus(detail = {}) {
const triggerCandidate = detail?.trigger || null;
const trigger =
triggerCandidate && triggerCandidate.nodeType === 1 ? triggerCandidate : null;
const restoreFocus = detail?.restoreFocus !== false;
const skipTrigger = detail?.skipTrigger || null;
if (trigger) {
const entry = this.popovers.get(trigger);
if (entry?.popover && typeof entry.popover.close === "function") {
return entry.popover.close({ restoreFocus });
}
return false;
}
let closed = false;
this.popovers.forEach((entry, key) => {
if (!entry?.popover || typeof entry.popover.close !== "function") {
return;
}
if (skipTrigger && key === skipTrigger) {
return;
}
const result = entry.popover.close({ restoreFocus });
closed = closed || result;
});
return closed;
}
persistPreferencesFromMenu() {
const service = this.services.hashtagPreferences;
const publish =
service && typeof service.publish === "function" ? service.publish : null;
const describe = this.callbacks.describeError;
if (!publish) {
const message =
typeof describe === "function"
? describe(null, {
fallbackMessage: "Hashtag preferences are unavailable right now.",
})
: "Hashtag preferences are unavailable right now.";
if (this.callbacks.showError) {
this.callbacks.showError(message);
}
const error = new Error(message);
error.code = "service-unavailable";
return Promise.reject(error);
}
if (this.publishInFlight) {
return this.publishPromise;
}
const pubkey = this.callbacks.getPubkey ? this.callbacks.getPubkey() : null;
const payload = pubkey ? { pubkey } : {};
this.publishInFlight = true;
const publishPromise = (async () => {
try {
return await publish.call(service, payload);
} catch (error) {
const failure =
error instanceof Error ? error : new Error(String(error || ""));
if (!failure.code) {
failure.code = "hashtag-preferences-publish-failed";
}
if (this.callbacks.showError) {
const message =
typeof describe === "function"
? describe(failure, { operation: "update" })
: failure.message || "Failed to update preferences.";
this.callbacks.showError(message);
}
throw failure;
} finally {
this.publishInFlight = false;
this.publishPromise = null;
}
})();
this.publishPromise = publishPromise;
return publishPromise;
}
async handleMenuAction(action, detail = {}) {
const tag = typeof detail?.tag === "string" ? detail.tag : "";
if (!tag) {
return;
}
const service = this.services.hashtagPreferences;
if (!service) {
return;
}
let result = false;
try {
switch (action) {
case TAG_PREFERENCE_ACTIONS.ADD_INTEREST:
result = service.addInterest(tag);
break;
case TAG_PREFERENCE_ACTIONS.REMOVE_INTEREST:
result = service.removeInterest(tag);
break;
case TAG_PREFERENCE_ACTIONS.ADD_DISINTEREST:
result = service.addDisinterest(tag);
break;
case TAG_PREFERENCE_ACTIONS.REMOVE_DISINTEREST:
result = service.removeDisinterest(tag);
break;
default:
userLogger.warn(`[TagPreferenceMenu] Unhandled action: ${action}`);
return;
}
} catch (error) {
devLogger.error(
"[TagPreferenceMenuController] Failed to mutate hashtag preference via menu:",
error,
);
if (this.callbacks.showError) {
const describe = this.callbacks.describeError;
const message =
typeof describe === "function"
? describe(error, { operation: "update" })
: "Failed to update hashtag preferences.";
this.callbacks.showError(message);
}
return;
}
if (!result) {
return;
}
if (this.callbacks.onPreferenceUpdate) {
this.callbacks.onPreferenceUpdate();
}
try {
await this.persistPreferencesFromMenu();
} catch (error) {
return;
}
if (this.callbacks.onPreferenceUpdate) {
this.callbacks.onPreferenceUpdate();
}
if (typeof detail?.closePopover === "function") {
detail.closePopover({ restoreFocus: false });
}
}
handleActivation(detail = {}) {
const tag = typeof detail?.tag === "string" ? detail.tag : "";
if (!tag) {
return;
}
const triggerCandidate = detail?.trigger || null;
const trigger =
triggerCandidate && triggerCandidate.nodeType === 1 ? triggerCandidate : null;
if (!trigger) {
return;
}
if (detail?.event) {
detail.event.preventDefault?.();
detail.event.stopPropagation?.();
}
this.requestMenu({
trigger,
tag,
context: detail?.context || "",
video: detail?.video || null,
});
}
refreshActiveMenus() {
if (!this.popovers) {
return;
}
const isLoggedIn = this.callbacks.isLoggedIn();
this.popovers.forEach((entry) => {
if (!entry) {
return;
}
const buttons = entry.buttons || {};
if (!buttons || Object.keys(buttons).length === 0) {
return;
}
try {
applyTagPreferenceMenuState({
buttons,
membership: this.callbacks.getMembership(entry.tag),
isLoggedIn,
});
} catch (error) {
devLogger.warn(
"[TagPreferenceMenuController] Failed to refresh tag preference menu state:",
error,
);
}
});
}
clear() {
this.closeMenus({ restoreFocus: false });
if (this.popovers) {
this.popovers.clear();
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import { devLogger } from "../utils/logger.js";
export default class TorrentStatusController {
constructor({ getVideoModal, onRemovePoster }) {
if (typeof getVideoModal !== "function") {
throw new Error("TorrentStatusController requires a getVideoModal accessor.");
}
this.getVideoModal = getVideoModal;
this.onRemovePoster = typeof onRemovePoster === "function" ? onRemovePoster : () => {};
}
update(torrent) {
devLogger.log("[DEBUG] TorrentStatusController.update called with torrent:", torrent);
if (!torrent) {
devLogger.log("[DEBUG] torrent is null/undefined!");
return;
}
if (torrent.ready || (typeof torrent.progress === "number" && torrent.progress > 0)) {
// Belt-and-suspenders: if WebTorrent reports progress but the DOM events
// failed to fire we still rip off the loading GIF. This regression has
// bitten us in past releases, so the extra clear is intentional.
this.onRemovePoster(
torrent.ready ? "torrent-ready-flag" : "torrent-progress"
);
}
// Log only fields that actually exist on the torrent:
devLogger.log("[DEBUG] torrent.progress =", torrent.progress);
devLogger.log("[DEBUG] torrent.numPeers =", torrent.numPeers);
devLogger.log("[DEBUG] torrent.downloadSpeed =", torrent.downloadSpeed);
devLogger.log("[DEBUG] torrent.downloaded =", torrent.downloaded);
devLogger.log("[DEBUG] torrent.length =", torrent.length);
devLogger.log("[DEBUG] torrent.ready =", torrent.ready);
const videoModal = this.getVideoModal();
// Use "Complete" vs. "Downloading" as the textual status.
if (videoModal) {
const fullyDownloaded = torrent.progress >= 1;
if (typeof videoModal.updateStatus === "function") {
videoModal.updateStatus(fullyDownloaded ? "Complete" : "Downloading");
}
if (typeof videoModal.updateProgress === "function") {
const percent = (torrent.progress * 100).toFixed(2);
videoModal.updateProgress(`${percent}%`);
}
if (typeof videoModal.updatePeers === "function") {
videoModal.updatePeers(`Peers: ${torrent.numPeers}`);
}
if (typeof videoModal.updateSpeed === "function") {
const kb = (torrent.downloadSpeed / 1024).toFixed(2);
videoModal.updateSpeed(`${kb} KB/s`);
}
if (typeof videoModal.updateDownloaded === "function") {
const downloadedMb = (torrent.downloaded / (1024 * 1024)).toFixed(2);
const lengthMb = (torrent.length / (1024 * 1024)).toFixed(2);
videoModal.updateDownloaded(
`${downloadedMb} MB / ${lengthMb} MB`
);
}
if (torrent.ready && typeof videoModal.updateStatus === "function") {
videoModal.updateStatus("Ready to play");
}
}
}
}
+5 -2
View File
@@ -17,13 +17,16 @@ export default class VideoListViewController {
);
}
mount({ container = null, view, currentVideoList = null } = {}) {
mount({ container = null, view, currentVideoList = null, includeTags = true } = {}) {
if (!view) {
return { videoList: currentVideoList || null, popularTags: null };
}
const target = container || this.getElementById("videoList");
const popularTags = this.getElementById("recentVideoTags");
let popularTags = null;
if (includeTags) {
popularTags = this.getElementById("recentVideoTags");
}
if (typeof view.setPopularTagsContainer === "function") {
view.setPopularTagsContainer(popularTags || null);
+1 -1
View File
@@ -398,7 +398,7 @@ export default class ZapController {
this.resetRetryState();
this.modalZapCommentValue = "";
this.videoModal?.resetZapForm({ amount: "", comment: "" });
this.videoModal?.setZapComment("");
this.videoModal?.setZapCompleted(true);
this.applyDefaultAmount();
} catch (error) {
+105 -418
View File
@@ -4,12 +4,16 @@ import {
requestDefaultExtensionPermissions,
} from "./nostrClientFacade.js";
import { getActiveSigner } from "./nostr/index.js";
import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js";
import { isSessionActor } from "./nostr/sessionActor.js";
import { normalizeNostrPubkey } from "./nostr/nip46Client.js";
import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER, NOTE_TYPES } from "./nostrEventSchemas.js";
import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js";
import { devLogger, userLogger } from "./utils/logger.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
} from "./nostrPublish.js";
import { profileCache } from "./state/profileCache.js";
class TinyEventEmitter {
constructor() {
@@ -64,24 +68,6 @@ const FAST_BLOCKLIST_RELAY_LIMIT = 3;
const FAST_BLOCKLIST_TIMEOUT_MS = 2500;
const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000;
const BLOCKLIST_STORAGE_PREFIX = "bitvid:user-blocks";
const BLOCKLIST_SEEDED_KEY_PREFIX = `${BLOCKLIST_STORAGE_PREFIX}:seeded:v1`;
const BLOCKLIST_REMOVALS_KEY_PREFIX = `${BLOCKLIST_STORAGE_PREFIX}:removals:v1`;
const BLOCKLIST_LOCAL_KEY_PREFIX = `${BLOCKLIST_STORAGE_PREFIX}:local:v1`;
function resolveStorage() {
if (typeof localStorage !== "undefined") {
return localStorage;
}
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage;
}
if (typeof globalThis !== "undefined" && globalThis.localStorage) {
return globalThis.localStorage;
}
return null;
}
function normalizeEncryptionToken(value) {
if (typeof value !== "string") {
return "";
@@ -611,119 +597,6 @@ function decodeNpubToHex(npub) {
return normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed));
}
function readSeededFlag(actorHex) {
if (typeof actorHex !== "string" || !actorHex) {
return false;
}
if (typeof localStorage === "undefined") {
return false;
}
const key = `${BLOCKLIST_SEEDED_KEY_PREFIX}:${actorHex}`;
try {
const value = localStorage.getItem(key);
return value === "1";
} catch (error) {
userLogger.warn(
`[UserBlockList] Failed to read seeded baseline state for ${actorHex}:`,
error,
);
return false;
}
}
function writeSeededFlag(actorHex, seeded) {
if (typeof actorHex !== "string" || !actorHex) {
return;
}
const storage = resolveStorage();
if (!storage) {
return;
}
const key = `${BLOCKLIST_SEEDED_KEY_PREFIX}:${actorHex}`;
try {
if (seeded) {
localStorage.setItem(key, "1");
} else {
localStorage.removeItem(key);
}
} catch (error) {
userLogger.warn(
`[UserBlockList] Failed to persist seeded baseline state for ${actorHex}:`,
error,
);
}
}
function readRemovalSet(actorHex) {
const empty = new Set();
if (typeof actorHex !== "string" || !actorHex) {
return empty;
}
if (typeof localStorage === "undefined") {
return empty;
}
const key = `${BLOCKLIST_REMOVALS_KEY_PREFIX}:${actorHex}`;
try {
const raw = localStorage.getItem(key);
if (raw === null) {
return empty;
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return empty;
}
const normalized = parsed
.map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : ""))
.filter((entry) => /^[0-9a-f]{64}$/.test(entry));
return new Set(normalized);
} catch (error) {
userLogger.warn(
`[UserBlockList] Failed to read seed removal state for ${actorHex}:`,
error,
);
return empty;
}
}
function writeRemovalSet(actorHex, removals) {
if (typeof actorHex !== "string" || !actorHex) {
return;
}
const storage = resolveStorage();
if (!storage) {
return;
}
const key = `${BLOCKLIST_REMOVALS_KEY_PREFIX}:${actorHex}`;
try {
if (!removals || !removals.size) {
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, JSON.stringify(Array.from(removals)));
} catch (error) {
userLogger.warn(
`[UserBlockList] Failed to persist seed removal state for ${actorHex}:`,
error,
);
}
}
function normalizeHex(pubkey) {
if (typeof pubkey !== "string") {
return null;
@@ -746,73 +619,6 @@ function normalizeHex(pubkey) {
return null;
}
function readLocalBlocks(actorHex) {
if (typeof actorHex !== "string" || !actorHex) {
return null;
}
const storage = resolveStorage();
if (!storage) {
return null;
}
const key = `${BLOCKLIST_LOCAL_KEY_PREFIX}:${actorHex}`;
try {
const raw = storage.getItem(key);
if (raw === null) {
return null;
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return new Set();
}
const normalized = parsed
.map((entry) =>
typeof entry === "string" ? entry.trim().toLowerCase() : "",
)
.filter((entry) => /^[0-9a-f]{64}$/.test(entry));
return new Set(normalized);
} catch (error) {
userLogger.warn(
`[UserBlockList] Failed to read local blocks for ${actorHex}:`,
error,
);
return null;
}
}
function writeLocalBlocks(actorHex, blocks) {
if (typeof actorHex !== "string" || !actorHex) {
return;
}
const storage = resolveStorage();
if (!storage) {
return;
}
const key = `${BLOCKLIST_LOCAL_KEY_PREFIX}:${actorHex}`;
try {
if (!blocks || !blocks.size) {
// We persist empty sets as [] to distinguish from "not found" (null)
storage.setItem(key, "[]");
return;
}
storage.setItem(key, JSON.stringify(Array.from(blocks)));
} catch (error) {
userLogger.warn(
`[UserBlockList] Failed to persist local blocks for ${actorHex}:`,
error,
);
}
}
class UserBlockListManager {
constructor() {
this.blockedPubkeys = new Set();
@@ -824,6 +630,17 @@ class UserBlockListManager {
this.loaded = false;
this.emitter = new TinyEventEmitter();
this.seedStateCache = new Map();
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
this.reset();
} else if (event === "runtimeCleared" && detail.pubkey === this.activePubkey) {
this.reset();
if (this.activePubkey) {
this.loadBlocks(this.activePubkey);
}
}
});
}
reset() {
@@ -867,6 +684,8 @@ class UserBlockListManager {
async loadBlocks(userPubkey, options = {}) {
const normalized = normalizeHex(userPubkey);
this.activePubkey = normalized;
if (!normalized) {
this.reset();
this.loaded = true;
@@ -1034,105 +853,23 @@ class UserBlockListManager {
};
try {
const filter = {
kinds: [10000, 30002],
authors: [normalized],
limit: 2,
};
if (since !== null) {
filter.since = since;
}
const relays = Array.isArray(nostrClient.relays)
? nostrClient.relays.filter((relay) => typeof relay === "string" && relay)
: [];
if (!relays.length) {
this.blockedPubkeys.clear();
this.blockEventId = null;
this.blockEventCreatedAt = null;
this.loaded = true;
// Only clear if truly no relays, though loadBlocks with no relays is weird.
if (!this.loaded) {
this.blockedPubkeys.clear();
this.blockEventId = null;
this.blockEventCreatedAt = null;
this.loaded = true;
}
emitStatus({ status: "applied-empty" });
emitStatus({ status: "settled" });
return;
}
const fastRelays = relays.slice(0, FAST_BLOCKLIST_RELAY_LIMIT);
const backgroundRelays = relays.slice(fastRelays.length);
const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) =>
new Promise((resolve, reject) => {
let settled = false;
const pool = nostrClient?.pool;
if (!pool || typeof pool.list !== "function") {
const poolError = new Error(
"nostrClient.pool.list is unavailable; cannot query block list.",
);
poolError.code = "pool-unavailable";
poolError.relay = relayUrl;
reject(poolError);
return;
}
const timer = setTimeout(() => {
if (settled) {
return;
}
settled = true;
const timeoutError = new Error(
`Timed out fetching block list from ${relayUrl} after ${timeoutMs}ms`
);
timeoutError.code = "timeout";
timeoutError.relay = relayUrl;
timeoutError.timeoutMs = timeoutMs;
reject(timeoutError);
}, timeoutMs);
Promise.resolve()
.then(() => pool.list([relayUrl], [filter]))
.then((result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
const events = Array.isArray(result)
? result.filter(
(event) =>
event && event.pubkey === normalized && isUserBlockListEvent(event),
)
: [];
if (requireEvent && !events.length) {
const emptyError = new Error(
`No block list events returned from ${relayUrl}`
);
emptyError.code = "empty";
emptyError.relay = relayUrl;
reject(emptyError);
return;
}
resolve({ relayUrl, events });
})
.catch((error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
const wrapped =
error instanceof Error ? error : new Error(String(error));
wrapped.relay = relayUrl;
reject(wrapped);
});
});
const fastPromises = fastRelays.map((relayUrl) =>
fetchFromRelay(relayUrl, FAST_BLOCKLIST_TIMEOUT_MS, true)
);
const backgroundPromises = backgroundRelays.map((relayUrl) =>
fetchFromRelay(relayUrl, BACKGROUND_BLOCKLIST_TIMEOUT_MS, false)
);
const applyEvents = async (
events,
{ skipIfEmpty = false, source = "fast" } = {},
@@ -1148,9 +885,11 @@ class UserBlockListManager {
emitStatus({ status: "stale", reason: "empty-result", source });
return;
}
this.blockEventId = null;
this.blockEventCreatedAt = null;
applyBlockedPubkeys([], { source, reason: "empty-result" });
if (!this.loaded) {
this.blockEventId = null;
this.blockEventCreatedAt = null;
applyBlockedPubkeys([], { source, reason: "empty-result" });
}
emitStatus({ status: "applied-empty", source });
return;
}
@@ -1171,9 +910,11 @@ class UserBlockListManager {
emitStatus({ status: "stale", reason: "empty-result", source });
return;
}
this.blockEventId = null;
this.blockEventCreatedAt = null;
applyBlockedPubkeys([], { source, reason: "empty-result" });
if (!this.loaded) {
this.blockEventId = null;
this.blockEventCreatedAt = null;
applyBlockedPubkeys([], { source, reason: "empty-result" });
}
emitStatus({ status: "applied-empty", source });
return;
}
@@ -1211,46 +952,6 @@ class UserBlockListManager {
newestMuteTime >= newestBlockTime ? newestMute : newestBlock;
const newestCreatedAt = Math.max(newestMuteTime, newestBlockTime);
const guardCreatedAt = Math.max(
this.blockEventCreatedAt ?? 0,
this.lastPublishedCreatedAt ?? 0,
);
// If both are stale, skip
if (newestCreatedAt < guardCreatedAt) {
emitStatus({
status: "stale",
event: newestOverall,
guardCreatedAt,
source,
});
return;
}
if (
newestCreatedAt === guardCreatedAt &&
this.blockEventId &&
newestOverall?.id &&
newestOverall.id !== this.blockEventId
) {
// If equal time but different ID, we might be seeing a race or propagation delay.
// But since we are merging, we should proceed unless it's strictly older context.
// For safety against flapping, we treat strict equality with different ID as stale
// if we already have a confirmed ID.
emitStatus({
status: "stale",
event: newestOverall,
guardCreatedAt,
source,
});
return;
}
if (newestOverall?.id && newestOverall.id === this.blockEventId) {
// Already applied this event. Idempotency check.
return;
}
// We accept this state.
this.blockEventId = newestOverall?.id || null;
this.blockEventCreatedAt = newestCreatedAt;
@@ -1364,76 +1065,33 @@ class UserBlockListManager {
}
};
// Sequential promise handling to avoid race conditions but preserve background updates.
// 1. Try fast path.
let fastResult = null;
if (fastPromises.length) {
try {
fastResult = await Promise.any(fastPromises);
} catch (error) {
if (error instanceof AggregateError) {
error.errors?.forEach((err) => {
if (err?.code === "timeout") {
userLogger.warn(
`[UserBlockList] Relay ${err.relay} timed out while loading block list (${err.timeoutMs}ms)`
);
}
});
} else {
userLogger.error("[UserBlockList] Fast block list fetch failed:", error);
}
}
// Concurrent incremental fetch for both kinds
const [muteEvents, blockEvents] = await Promise.all([
nostrClient.fetchListIncrementally({
kind: 10000,
pubkey: normalized,
relayUrls: relays,
}),
nostrClient.fetchListIncrementally({
kind: 30002,
pubkey: normalized,
dTag: BLOCK_LIST_IDENTIFIER,
relayUrls: relays,
}),
]);
const combined = [...muteEvents, ...blockEvents];
// If combined is empty, check if we have loaded state
if (!combined.length) {
if (!this.loaded) {
// Assuming empty if we have no prior state
await applyEvents([], { source: "incremental" });
}
} else {
await applyEvents(combined, { source: "incremental" });
}
if (fastResult?.events?.length) {
await applyEvents(fastResult.events, { source: "fast" });
// NOTE: We do NOT return here. We allow background relays to potentially provide newer data.
}
// 2. Wait for background (and failed fast relays).
if (backgroundRelays.length || (!fastResult?.events?.length && fastPromises.length)) {
emitStatus({
status: "awaiting-background",
relays: backgroundRelays.length ? backgroundRelays : fastRelays,
});
}
const allPromises = [...fastPromises, ...backgroundPromises];
const outcomes = await Promise.allSettled(allPromises);
const aggregated = [];
for (const outcome of outcomes) {
if (outcome.status === "fulfilled") {
const events = Array.isArray(outcome.value?.events)
? outcome.value.events
: [];
if (events.length) {
aggregated.push(...events);
}
} else {
const reason = outcome.reason;
if (reason?.code === "timeout") {
userLogger.warn(
`[UserBlockList] Relay ${reason.relay} timed out while loading block list (${reason.timeoutMs}ms)`
);
} else {
const relay = reason?.relay || reason?.relayUrl;
userLogger.error(
`[UserBlockList] Relay error at ${relay}:`,
reason?.error ?? reason
);
}
}
}
if (!aggregated.length) {
// Only apply empty if fast path also found nothing (already handled by logic inside applyEvents,
// but explicit call ensures we settle state if everything failed).
await applyEvents([], { source: "background" });
return;
}
await applyEvents(aggregated, { skipIfEmpty: true, source: "background" });
} catch (error) {
userLogger.error("[UserBlockList] loadBlocks failed:", error);
@@ -1448,11 +1106,15 @@ class UserBlockListManager {
}
_loadLocal(actorHex) {
return readLocalBlocks(actorHex);
const cached = profileCache.get("blocks");
if (Array.isArray(cached)) {
return new Set(cached);
}
return null;
}
_saveLocal(actorHex) {
writeLocalBlocks(actorHex, this.blockedPubkeys);
profileCache.set("blocks", Array.from(this.blockedPubkeys));
}
_getSeedState(actorHex) {
@@ -1465,48 +1127,56 @@ class UserBlockListManager {
return this.seedStateCache.get(normalized);
}
const seeded = readSeededFlag(normalized);
const removals = readRemovalSet(normalized);
const stored = profileCache.get("blocks_meta") || {};
const seeded = !!stored.seeded;
const removals = new Set(Array.isArray(stored.removals) ? stored.removals : []);
const state = { seeded, removals };
this.seedStateCache.set(normalized, state);
return state;
}
_persistSeedState(actorHex) {
const normalized = normalizeHex(actorHex);
if (!normalized) return;
const state = this.seedStateCache.get(normalized);
if (!state) return;
profileCache.set("blocks_meta", {
seeded: state.seeded,
removals: Array.from(state.removals),
});
}
_setSeeded(actorHex, seeded) {
const normalized = normalizeHex(actorHex);
if (!normalized) {
return;
}
if (!normalized) return;
const state = this._getSeedState(normalized);
state.seeded = Boolean(seeded);
writeSeededFlag(normalized, state.seeded);
this._persistSeedState(normalized);
}
_addSeedRemoval(actorHex, targetHex) {
const normalizedActor = normalizeHex(actorHex);
const normalizedTarget = normalizeHex(targetHex);
if (!normalizedActor || !normalizedTarget) {
return;
}
if (!normalizedActor || !normalizedTarget) return;
const state = this._getSeedState(normalizedActor);
if (!state.removals.has(normalizedTarget)) {
state.removals.add(normalizedTarget);
writeRemovalSet(normalizedActor, state.removals);
this._persistSeedState(normalizedActor);
}
}
_clearSeedRemoval(actorHex, targetHex) {
const normalizedActor = normalizeHex(actorHex);
const normalizedTarget = normalizeHex(targetHex);
if (!normalizedActor || !normalizedTarget) {
return;
}
if (!normalizedActor || !normalizedTarget) return;
const state = this._getSeedState(normalizedActor);
if (state.removals.delete(normalizedTarget)) {
writeRemovalSet(normalizedActor, state.removals);
this._persistSeedState(normalizedActor);
}
}
@@ -1852,6 +1522,15 @@ class UserBlockListManager {
plaintext = "",
onStatus,
} = {}) {
// nostrClient imported from nostrClientFacade
if (isSessionActor(nostrClient)) {
const error = new Error(
"Publishing mute lists is not allowed for session actors."
);
error.code = "session-actor-publish-blocked";
throw error;
}
const owner = normalizeHex(ownerPubkey);
if (!owner || !signer || typeof signer.signEvent !== "function") {
return null;
@@ -1953,6 +1632,14 @@ class UserBlockListManager {
}
async publishBlockList(userPubkey, options = {}) {
if (isSessionActor(nostrClient)) {
const error = new Error(
"Publishing block lists is not allowed for session actors."
);
error.code = "session-actor-publish-blocked";
throw error;
}
const onStatus =
options && typeof options.onStatus === "function" ? options.onStatus : null;
+50
View File
@@ -0,0 +1,50 @@
// js/utils/hashtagNormalization.js
const HASH_PREFIX_PATTERN = /^#+/;
/**
* Normalize hashtags consistently across storage, matching, and UI validation.
*
* Normalization rules:
* 1) Trim surrounding whitespace.
* 2) Strip leading "#" characters.
* 3) Lowercase the remaining value.
*
* Note: We do not strip punctuation today. Only add punctuation stripping if
* product explicitly decides to change the rules.
*
* @param {string} value
* @returns {string} normalized hashtag or empty string when invalid.
*/
export function normalizeHashtag(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const withoutHashes = trimmed.replace(HASH_PREFIX_PATTERN, "").trim();
if (!withoutHashes) {
return "";
}
return withoutHashes.toLowerCase();
}
/**
* Format a hashtag for display, ensuring a single leading hash.
*
* @param {string} value
* @returns {string}
*/
export function formatHashtag(value) {
const normalized = normalizeHashtag(value);
if (!normalized) {
return "";
}
return `#${normalized}`;
}
+109
View File
@@ -0,0 +1,109 @@
/**
* A generic Least Recently Used (LRU) cache implementation.
* It supports a maximum size limit. When the limit is reached,
* the least recently accessed item is evicted.
*
* Note: This cache does not automatically evict items based on TTL.
* It is up to the consumer to check timestamps if expiration is needed.
*/
export class LRUCache {
/**
* @param {object} options
* @param {number} [options.maxSize=100] - Maximum number of entries.
*/
constructor({ maxSize = 100 } = {}) {
this.maxSize = maxSize > 0 ? maxSize : 100;
this.cache = new Map();
this.hits = 0;
this.misses = 0;
}
/**
* Retrieves an item from the cache.
* If found, the item is marked as most recently used and hit count increments.
* If not found, miss count increments.
*
* @param {string} key
* @returns {*} The cached value, or undefined if not found.
*/
get(key) {
const value = this.cache.get(key);
if (value !== undefined) {
// Refresh recency: delete and re-set
this.cache.delete(key);
this.cache.set(key, value);
this.hits++;
} else {
this.misses++;
}
return value;
}
/**
* Adds or updates an item in the cache.
* The item is marked as most recently used.
* If the cache exceeds maxSize, the least recently used item is evicted.
*
* @param {string} key
* @param {*} value
*/
set(key, value) {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxSize) {
// The first key in a Map is the oldest (insertion order)
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, value);
}
/**
* Checks if a key exists in the cache without updating its recency or metrics.
*
* @param {string} key
* @returns {boolean}
*/
has(key) {
return this.cache.has(key);
}
/**
* Returns the number of items in the cache.
*
* @returns {number}
*/
size() {
return this.cache.size;
}
/**
* Clears all items from the cache and resets counters.
*/
clear() {
this.cache.clear();
this.hits = 0;
this.misses = 0;
}
/**
* Returns current statistics for the cache.
* @returns {{ hits: number, misses: number, size: number, maxSize: number }}
*/
getStats() {
return {
hits: this.hits,
misses: this.misses,
size: this.cache.size,
maxSize: this.maxSize,
};
}
/**
* Returns an iterator of [key, value] pairs, from oldest to newest.
*/
entries() {
return this.cache.entries();
}
}
+114
View File
@@ -2,11 +2,21 @@ import { userLogger } from "./logger.js";
// js/utils/storage.js
const URL_HEALTH_STORAGE_PREFIX = "bitvid:urlHealth:";
const TORRENT_PROBE_STORAGE_PREFIX = "bitvid:torrentProbe:";
const TRACKER_HEALTH_STORAGE_PREFIX = "bitvid:trackerHealth:";
export function getUrlHealthStorageKey(eventId) {
return `${URL_HEALTH_STORAGE_PREFIX}${eventId}`;
}
export function getTorrentProbeStorageKey(infoHash) {
return `${TORRENT_PROBE_STORAGE_PREFIX}${infoHash}`;
}
export function getTrackerHealthStorageKey(infoHash) {
return `${TRACKER_HEALTH_STORAGE_PREFIX}${infoHash}`;
}
export function readUrlHealthFromStorage(eventId) {
if (!eventId || typeof localStorage === "undefined") {
return null;
@@ -55,3 +65,107 @@ export function removeUrlHealthFromStorage(eventId) {
userLogger.warn(`Failed to remove URL health for ${eventId}:`, err);
}
}
export function readTorrentProbeFromStorage(infoHash) {
if (!infoHash || typeof localStorage === "undefined") {
return null;
}
try {
const raw = localStorage.getItem(getTorrentProbeStorageKey(infoHash));
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
return parsed;
}
} catch (err) {
userLogger.warn(
`Failed to parse stored torrent probe for ${infoHash}:`,
err
);
}
removeTorrentProbeFromStorage(infoHash);
return null;
}
export function writeTorrentProbeToStorage(infoHash, entry) {
if (!infoHash || typeof localStorage === "undefined") {
return;
}
try {
localStorage.setItem(
getTorrentProbeStorageKey(infoHash),
JSON.stringify(entry)
);
} catch (err) {
userLogger.warn(`Failed to persist torrent probe for ${infoHash}:`, err);
}
}
export function removeTorrentProbeFromStorage(infoHash) {
if (!infoHash || typeof localStorage === "undefined") {
return;
}
try {
localStorage.removeItem(getTorrentProbeStorageKey(infoHash));
} catch (err) {
userLogger.warn(`Failed to remove torrent probe for ${infoHash}:`, err);
}
}
export function readTrackerHealthFromStorage(infoHash) {
if (!infoHash || typeof localStorage === "undefined") {
return null;
}
try {
const raw = localStorage.getItem(getTrackerHealthStorageKey(infoHash));
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
return parsed;
}
} catch (err) {
userLogger.warn(
`Failed to parse stored tracker health for ${infoHash}:`,
err
);
}
removeTrackerHealthFromStorage(infoHash);
return null;
}
export function writeTrackerHealthToStorage(infoHash, entry) {
if (!infoHash || typeof localStorage === "undefined") {
return;
}
try {
localStorage.setItem(
getTrackerHealthStorageKey(infoHash),
JSON.stringify(entry)
);
} catch (err) {
userLogger.warn(`Failed to persist tracker health for ${infoHash}:`, err);
}
}
export function removeTrackerHealthFromStorage(infoHash) {
if (!infoHash || typeof localStorage === "undefined") {
return;
}
try {
localStorage.removeItem(getTrackerHealthStorageKey(infoHash));
} catch (err) {
userLogger.warn(`Failed to remove tracker health for ${infoHash}:`, err);
}
}
+26
View File
@@ -1,5 +1,6 @@
// js/viewManager.js
import { initChannelProfileView } from "./channelProfile.js";
import { initForYouView } from "./forYouView.js";
import { subscriptions } from "./subscriptions.js";
import { getApplication } from "./applicationContext.js";
import { ASSET_VERSION } from "../config/asset-version.js";
@@ -92,6 +93,21 @@ export const viewInitRegistry = {
refreshApp.forceRefreshAllProfiles();
}
},
"for-you": () => {
initForYouView();
const app = getApplication();
if (app && typeof app.loadForYouVideos === "function") {
if (typeof app.mountVideoListView === "function") {
app.mountVideoListView();
}
app.loadForYouVideos();
}
// Force profile updates after the new view is in place.
const refreshApp = getApplication();
if (refreshApp && typeof refreshApp.forceRefreshAllProfiles === "function") {
refreshApp.forceRefreshAllProfiles();
}
},
explore: () => {
devLogger.log("Explore view loaded.");
},
@@ -135,4 +151,14 @@ export const viewInitRegistry = {
// Call the initialization function from channelProfile.js
initChannelProfileView();
},
search: async () => {
try {
const module = await import("./searchView.js");
if (typeof module.initSearchView === "function") {
await module.initSearchView();
}
} catch (error) {
userLogger.error("Failed to initialize search view:", error);
}
},
};
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more