mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge branch 'unstable' into refactor/nostr-client-managers-15317011398012543442
This commit is contained in:
54 files changed
+8279
-1206
No files matched your search
+20
-2
@@ -1,14 +1,32 @@
|
||||
# Known Issues
|
||||
|
||||
## Environment
|
||||
|
||||
### Missing Dependencies
|
||||
|
||||
The current development environment is missing critical `devDependencies` (`jsdom`, `playwright`, `fake-indexeddb`), preventing many test suites from running.
|
||||
|
||||
- **Affected:** `tests/app/channel-profile-moderation.test.mjs`, `test:dm:unit`, `test:dm:integration`, `test:e2e`, `test:visual`.
|
||||
- **Reference:** `issues/test-env-deps.md`
|
||||
- **Last checked:** 2026-02-08
|
||||
|
||||
## Tests
|
||||
|
||||
### Unit Tests
|
||||
- `npm run test:unit` may time out in resource-constrained environments (like CI) when running the full suite serially. CI workflows use sharding (`test:unit:shardX`) to mitigate this. Targeted testing (`test:dm:unit`, `test:dm:integration`) is recommended for local development focus. (Last checked: 2025-02-23)
|
||||
|
||||
- `npm run test:unit` generally passes, but `tests/app/channel-profile-moderation.test.mjs` fails due to missing `jsdom`.
|
||||
- Timeouts may occur in resource-constrained environments (like CI) when running the full suite serially. CI workflows use sharding (`test:unit:shardX`) to mitigate this. Targeted testing (`test:dm:unit`, `test:dm:integration`) is recommended for local development focus but currently fails due to missing `fake-indexeddb`. (Last checked: 2026-02-08)
|
||||
|
||||
### Skipped Tests
|
||||
|
||||
The following tests are skipped due to flakiness or environmental issues and should be investigated:
|
||||
|
||||
- `tests/e2e/popover.spec.ts`: `keeps the bottom-right grid menu inside the viewport` (Visual regression layout issue: Floating UI `flip` middleware fails to respect viewport boundary in constrained height scenarios. Verified 2025-02-23, fix attempt failed.)
|
||||
- `tests/e2e/popover.spec.ts`: `keeps the bottom-right grid menu inside the viewport`
|
||||
- **Issue:** Visual regression layout issue: Floating UI `flip` middleware fails to respect viewport boundary in constrained height scenarios.
|
||||
- **Reference:** `issues/popover-flip-bug.md`
|
||||
- **Status:** Verification attempted 2026-02-08 but failed due to missing `@playwright/test`. (Verified 2025-02-23, fix attempt failed.)
|
||||
|
||||
### Visual Regression Tests (`test:visual`)
|
||||
|
||||
- **Artifact Retention**: Visual tests are configured to retain screenshots, traces, and videos on failure to assist with debugging. These artifacts can be found in `artifacts/test-results/`.
|
||||
- **Note:** `test:visual` currently fails due to missing `@playwright/test`.
|
||||
@@ -0,0 +1,55 @@
|
||||
|
||||
import { performance } from "perf_hooks";
|
||||
|
||||
// Mock IndexedDB if running in Node
|
||||
if (!globalThis.indexedDB) {
|
||||
const { indexedDB, IDBKeyRange } = await import("fake-indexeddb");
|
||||
globalThis.indexedDB = indexedDB;
|
||||
globalThis.IDBKeyRange = IDBKeyRange;
|
||||
}
|
||||
|
||||
// Mock console to suppress logs
|
||||
const originalConsoleWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
|
||||
// Import after mocking console
|
||||
const { writeMessages, clearDmDb } = await import("../js/storage/dmDb.js");
|
||||
|
||||
async function runBenchmark() {
|
||||
await clearDmDb();
|
||||
|
||||
const iterations = 1000;
|
||||
const messages = [];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
messages.push({
|
||||
id: `msg-${i}`,
|
||||
conversation_id: "conv-1",
|
||||
sender_pubkey: "sender",
|
||||
receiver_pubkey: "receiver",
|
||||
created_at: Date.now(),
|
||||
kind: 4,
|
||||
content: `Message ${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Starting benchmark with ${iterations} writes...`);
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
// Simulate N+1 writes
|
||||
for (const msg of messages) {
|
||||
await writeMessages(msg);
|
||||
}
|
||||
|
||||
const end = performance.now();
|
||||
const duration = end - start;
|
||||
|
||||
console.log(`Total time: ${duration.toFixed(2)}ms`);
|
||||
console.log(`Average time per write: ${(duration / iterations).toFixed(2)}ms`);
|
||||
|
||||
// Restore console
|
||||
console.warn = originalConsoleWarn;
|
||||
}
|
||||
|
||||
runBenchmark().catch(console.error);
|
||||
@@ -0,0 +1,63 @@
|
||||
// benchmarks/kids_scoring_bench.js
|
||||
import { createKidsScorerStage } from "../js/feedEngine/kidsScoring.js";
|
||||
import { performance } from "perf_hooks";
|
||||
|
||||
async function runBenchmark() {
|
||||
const itemCount = 200000;
|
||||
const items = [];
|
||||
|
||||
// Create mock items with diverse data to exercise different paths
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
items.push({
|
||||
video: {
|
||||
id: `v${i}`,
|
||||
pubkey: `pubkey${i % 100}`,
|
||||
duration: 300 + (i % 600), // 5 to 15 minutes
|
||||
tags: [
|
||||
["t", "kids"],
|
||||
["t", "learning"],
|
||||
["t", i % 2 === 0 ? "preschool" : "science"],
|
||||
],
|
||||
viewCount: i * 10,
|
||||
created_at: Date.now() / 1000 - (i % 10000),
|
||||
moderation: { trustedCount: i % 5 },
|
||||
},
|
||||
metadata: {},
|
||||
});
|
||||
}
|
||||
|
||||
const context = {
|
||||
runtime: {
|
||||
ageGroup: "preschool",
|
||||
trustedAuthors: ["pubkey1", "pubkey2"],
|
||||
},
|
||||
addWhy: () => {}, // Mock addWhy
|
||||
};
|
||||
|
||||
const scorer = createKidsScorerStage();
|
||||
|
||||
console.log(`Starting kids scoring benchmark with ${itemCount} items...`);
|
||||
|
||||
// Warmup
|
||||
await scorer([...items.slice(0, 1000)], context);
|
||||
|
||||
const iterations = 100;
|
||||
let totalTime = 0;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
// Reset metadata for each run to be cleaner
|
||||
items.forEach(item => item.metadata = {});
|
||||
|
||||
const start = performance.now();
|
||||
await scorer(items, context);
|
||||
const end = performance.now();
|
||||
totalTime += (end - start);
|
||||
|
||||
if (i % 5 === 0) process.stdout.write(".");
|
||||
}
|
||||
|
||||
const avgTime = totalTime / iterations;
|
||||
console.log(`\nAverage time per run: ${avgTime.toFixed(4)}ms`);
|
||||
}
|
||||
|
||||
runBenchmark().catch(console.error);
|
||||
@@ -0,0 +1,58 @@
|
||||
|
||||
import { createChronologicalSorter } from "../js/feedEngine/sorters.js";
|
||||
import { performance } from "perf_hooks";
|
||||
|
||||
const NUM_ITEMS = 100000;
|
||||
|
||||
function buildItem(i) {
|
||||
return {
|
||||
video: {
|
||||
id: `vid-${i}`,
|
||||
rootCreatedAt: 1000 + i,
|
||||
},
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function runBenchmark() {
|
||||
const items = [];
|
||||
for (let i = 0; i < NUM_ITEMS; i++) {
|
||||
items.push(buildItem(i));
|
||||
}
|
||||
|
||||
// Mock context with hooks to trigger the candidate lookup loop
|
||||
const context = {
|
||||
hooks: {
|
||||
timestamps: {
|
||||
getKnownVideoPostedAt: () => null,
|
||||
getKnownPostedAt: () => null,
|
||||
getVideoPostedAt: () => null,
|
||||
resolveVideoPostedAt: () => null,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sorter = createChronologicalSorter({ direction: "desc" });
|
||||
|
||||
console.log(`Sorting ${NUM_ITEMS} items with hooks...`);
|
||||
|
||||
const iterations = 20; // Increased iterations for stability
|
||||
let totalTime = 0;
|
||||
|
||||
// Warmup
|
||||
for (let i = 0; i < 5; i++) sorter(items, context);
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const start = performance.now();
|
||||
sorter(items, context);
|
||||
const end = performance.now();
|
||||
totalTime += (end - start);
|
||||
if (i % 5 === 0) process.stdout.write(".");
|
||||
}
|
||||
|
||||
const avgTime = totalTime / iterations;
|
||||
console.log("\n");
|
||||
console.log(`Average time per sort: ${avgTime.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
runBenchmark();
|
||||
@@ -33,6 +33,8 @@ export const IS_DEV_MODE = false;
|
||||
* a quieter dev mode without disabling other development helpers entirely.
|
||||
* Use the runtime override documented in `js/config.js` if you need to toggle
|
||||
* verbosity without editing this file.
|
||||
*
|
||||
* Security: This must be false in production.
|
||||
*/
|
||||
export const IS_VERBOSE_DEV_MODE = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# ModerationService Overview
|
||||
|
||||
The `ModerationService` is the central authority for Trust & Safety within the application. It implements a "Web of Trust" model to filter content, aggregating reports (NIP-56) and mute lists (NIP-51) to determine the visibility and safety level of content.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Web of Trust
|
||||
Instead of a centralized moderation authority, the service builds a local "trust graph" based on the user's social connections:
|
||||
- **Viewer**: The currently logged-in user.
|
||||
- **Trusted Contacts**: Users followed by the Viewer (Kind 3 contact list).
|
||||
- **Trusted Seeds**: An optional set of admin-defined pubkeys that provide a baseline of trust (e.g., project maintainers).
|
||||
- **Admin Whitelist/Blacklist**: Hardcoded lists in `accessControl` that override dynamic trust.
|
||||
|
||||
### 2. Trust Aggregation
|
||||
When a piece of content (Event) is reported (Kind 1984), the service aggregates these reports but *only counts reports from trusted users*.
|
||||
- **Untrusted Reports**: Ignored by default.
|
||||
- **Trusted Reports**: Count towards the "trust score" for a specific category (e.g., "nudity", "spam").
|
||||
|
||||
### 3. Thresholds
|
||||
The service defines thresholds for automatic actions based on the trusted report count:
|
||||
- **`AUTOPLAY_TRUST_THRESHOLD` (1)**: If ≥1 trusted user reports content as NSFW/Spam, autoplay is disabled.
|
||||
- **`BLUR_TRUST_THRESHOLD` (1)**: If ≥1 trusted user reports content, it is blurred by default.
|
||||
- **`TRUSTED_MUTE_WINDOW_DAYS` (60)**: Mute list entries are considered valid for 60 days to prevent stale mutes from persisting forever.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User[User / Viewer] -->|Follows| Contacts[Trusted Contacts]
|
||||
Admins[Admin / Seed List] -->|Augments| Contacts
|
||||
|
||||
Reports[Report Events (Kind 1984)] -->|Ingested| Service[Moderation Service]
|
||||
Contacts -->|Filters| Service
|
||||
|
||||
Service -->|Aggregates| Summary[Trusted Report Summary]
|
||||
Summary -->|Checks Thresholds| UI[UI Components]
|
||||
|
||||
UI -->|Blur / Hide| Content
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **Initialization**:
|
||||
- The service initializes with empty sets.
|
||||
- When `setViewerPubkey(pubkey)` is called, it fetches the user's Kind 3 Contact List.
|
||||
- It merges these contacts with any configured `trustedSeedContacts`.
|
||||
|
||||
2. **Report Ingestion**:
|
||||
- The service subscribes to Kind 1984 events referencing active content.
|
||||
- When a report arrives, `ingestReportEvent` checks if the reporter is in the `trustedContacts` set.
|
||||
- If trusted, the report is indexed by `targetEventId` -> `reportType`.
|
||||
|
||||
3. **Summary Recomputation**:
|
||||
- `recomputeSummaryForEvent` calculates the total number of *trusted* reports per category.
|
||||
- It emits a `summary` event, which UI components listen to (e.g., `VideoCard`).
|
||||
- It logs "threshold transitions" (e.g., "autoplay-block-enabled") to the user logger.
|
||||
|
||||
4. **Muting**:
|
||||
- **Viewer Mutes**: The user's own Kind 10000 mute list. Directly hides content.
|
||||
- **Trusted Mutes**: Aggregates mute lists from trusted contacts. If enough trusted users mute an author, that author may be flagged (feature partially implemented via `trustedMutedAuthors`).
|
||||
|
||||
## Key Methods
|
||||
|
||||
- `setViewerPubkey(pubkey)`: Switches the active user context, reloading trust lists.
|
||||
- `submitReport({ eventId, type, ... })`: Publishes a NIP-56 report to relays.
|
||||
- `getTrustedReportSummary(eventId)`: Returns the current trust status for an event.
|
||||
- `getAccessControlStatus(pubkey)`: Checks against the hardcoded Admin Whitelist/Blacklist.
|
||||
|
||||
## Internal Utilities
|
||||
|
||||
The file includes internal implementations of `bech32` encoding/decoding. This is done to:
|
||||
1. Reduce external dependencies for critical path logic.
|
||||
2. Ensure consistent handling of `npub` / `hex` conversions without relying on the global `NostrTools` object potentially being unavailable or different versions.
|
||||
@@ -167,6 +167,9 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
|
||||
| DM attachment (`NOTE_TYPES.DM_ATTACHMENT`) | `15` | `['p', <recipient pubkey>]`, `['x', <sha256 of uploaded bytes>]`, `['url', <download url>]`, optional `['name', <filename>]`, `['type', <mime>]`, `['size', <bytes>]`, optional `['k', <base64 key>]`; inherits schema append tags | Empty content; attachment metadata is represented as tags |
|
||||
| DM read receipt (`NOTE_TYPES.DM_READ_RECEIPT`) | `20001` | `['p', <recipient pubkey>]`, `['e', <message event id>]`, optional `['k', <message kind>]`; inherits schema append tags | Empty content; ephemeral receipt for message activity |
|
||||
| DM typing indicator (`NOTE_TYPES.DM_TYPING`) | `20002` | `['p', <recipient pubkey>]`, optional `['e', <conversation event id>]`, `['t','typing']`, `['expiration', <unix seconds>]`; inherits schema append tags | Empty content; ephemeral typing indicator that expires quickly |
|
||||
| Gift Wrap (`NOTE_TYPES.GIFT_WRAP`) | `1059` | `['p', <recipient pubkey>]` | NIP-44 encrypted seal |
|
||||
| Seal (`NOTE_TYPES.SEAL`) | `13` | Empty tags usually | NIP-44 encrypted rumor |
|
||||
| Chat Message (`NOTE_TYPES.CHAT_MESSAGE`) | `14` | `['p', <recipient pubkey>]` | Plain text chat message |
|
||||
|
||||
> **Publishing note:** Session actors only emit passive telemetry (for example, view counters) and must **not** sign video comments. Require a logged-in Nostr signer for comment publishing via [`commentEvents`](../js/nostr/commentEvents.js).
|
||||
|
||||
@@ -181,6 +184,7 @@ Video posts should treat `videoRootId` as the stable series identifier that rema
|
||||
| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', <eventId>]` or `['a', <address>]`), and a stable dedupe tag `['d', <view identifier>]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message |
|
||||
| Watch history month (`NOTE_TYPES.WATCH_HISTORY`) | `WATCH_HISTORY_KIND` (default `30079`) | Replaceable list tag `['d', `${WATCH_HISTORY_LIST_IDENTIFIER}:<YYYY-MM>`]` with optional `['month', <YYYY-MM>]` marker plus schema append tags; no chunk pointers required. | JSON payload `{ version, month: 'YYYY-MM', items: [{ id: <eventId\|address>, watched_at?: <unix seconds> }] }` |
|
||||
| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30000` | `['d', 'subscriptions']`, `['encrypted', 'nip44_v2']` (updated to the negotiated scheme at publish time) | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', <hex>], …]`) |
|
||||
| Subscription list backup (`NOTE_TYPES.SUBSCRIPTION_BACKUP`) | `30000` | `['d', <identifier>]`, `['encrypted', 'nip44_v2']` | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', <hex>], …]`) |
|
||||
| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `10000` | `['d', 'user-blocks']` | **LEGACY**. NIP-04/NIP-44 encrypted JSON array of [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) tag tuples. Migrated to standard Mute List (kind `10000`) which uses public tags for mutes and encrypted content for private blocks. |
|
||||
| Hashtag preferences (`NOTE_TYPES.HASHTAG_PREFERENCES`) | `30015` | `['d', 'bitvid:tag-preferences']` plus schema-appended `['encrypted','nip44_v2']` | NIP-44 encrypted JSON `{ version, interests: string[], disinterests: string[] }` |
|
||||
| Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Popover Flip Bug in Constrained Viewports
|
||||
|
||||
## Problem
|
||||
|
||||
The popover (using Floating UI `flip` middleware) fails to respect viewport boundaries in constrained height scenarios, causing it to overlap or be cut off instead of flipping to the top.
|
||||
|
||||
## Affected Components
|
||||
|
||||
- `js/ui/overlay/popoverEngine.js`
|
||||
- Popovers triggered from `data-test-trigger="grid-bottom-right"` (e.g., in `tests/e2e/popover.spec.ts`).
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
1. Open a popover near the bottom of a constrained viewport.
|
||||
2. Ensure there is enough space above the popover to flip.
|
||||
3. Observe that the popover stays at the bottom and is clipped.
|
||||
|
||||
## Test Case
|
||||
|
||||
- `tests/e2e/popover.spec.ts`: "keeps the bottom-right grid menu inside the viewport" (skipped).
|
||||
|
||||
## History
|
||||
|
||||
- **2025-02-23**: Verification failed. Fix attempt failed.
|
||||
- **2026-02-08**: Verification attempted but failed due to missing environment dependencies (`@playwright/test`).
|
||||
|
||||
## Possible Causes
|
||||
|
||||
- Misconfiguration of `flip` middleware (padding, boundary).
|
||||
- Conflict with `shift` middleware.
|
||||
- Viewport detection issues in constrained test environment.
|
||||
|
||||
## Tags
|
||||
|
||||
- ui
|
||||
- bug
|
||||
- floating-ui
|
||||
@@ -0,0 +1,30 @@
|
||||
# Test Environment Missing Dependencies
|
||||
|
||||
## Problem
|
||||
|
||||
The current development environment is missing several `devDependencies` that are listed in `package.json`. This prevents running certain test suites.
|
||||
|
||||
## Affected Dependencies
|
||||
|
||||
- `jsdom`: Required for `tests/app/channel-profile-moderation.test.mjs`.
|
||||
- `fake-indexeddb`: Required for `test:dm:unit` and `test:dm:integration`.
|
||||
- `@playwright/test` (and `playwright`): Required for E2E tests (`test:e2e`, `tests/e2e/popover.spec.ts`, etc.) and visual regression tests (`test:visual`).
|
||||
|
||||
## Impact
|
||||
|
||||
- `npm run test:unit` fails partially (`tests/app/channel-profile-moderation.test.mjs` fails with `ERR_MODULE_NOT_FOUND`).
|
||||
- `npm run test:dm:unit` fails completely.
|
||||
- `npm run test:dm:integration` fails completely.
|
||||
- `npm run test:e2e` fails completely.
|
||||
- `npm run test:visual` fails completely.
|
||||
|
||||
## Notes
|
||||
|
||||
- `npm install` is restricted in this environment, preventing easy re-installation.
|
||||
- This issue was verified on 2026-02-08.
|
||||
|
||||
## Tags
|
||||
|
||||
- environment
|
||||
- testing
|
||||
- dependencies
|
||||
@@ -177,7 +177,14 @@ import { createPlaybackCoordinator } from "./app/playbackCoordinator.js";
|
||||
import { createAuthSessionCoordinator } from "./app/authSessionCoordinator.js";
|
||||
import { createModalCoordinator } from "./app/modalCoordinator.js";
|
||||
import { createModerationCoordinator } from "./app/moderationCoordinator.js";
|
||||
import { createRouterCoordinator } from "./app/routerCoordinator.js";
|
||||
import { createUiCoordinator } from "./app/uiCoordinator.js";
|
||||
import { HEX64_REGEX } from "./utils/hex.js";
|
||||
import {
|
||||
safeEncodeNpub,
|
||||
safeDecodeNpub,
|
||||
normalizeHexPubkey,
|
||||
} from "./utils/nostrHelpers.js";
|
||||
|
||||
const recordVideoViewApi = (...args) => recordVideoView(nostrClient, ...args);
|
||||
|
||||
@@ -241,6 +248,15 @@ class Application {
|
||||
|
||||
constructor({ services = {}, ui = {}, helpers = {}, loadView: viewLoader } = {}) {
|
||||
this.loadView = typeof viewLoader === "function" ? viewLoader : null;
|
||||
this._setupOptions = { services, ui, helpers };
|
||||
this._isSetup = false;
|
||||
}
|
||||
|
||||
setup() {
|
||||
if (this._isSetup) return;
|
||||
this._isSetup = true;
|
||||
|
||||
const { services = {}, ui = {}, helpers = {} } = this._setupOptions || {};
|
||||
|
||||
const bootstrapServices = {
|
||||
...services,
|
||||
@@ -502,6 +518,8 @@ class Application {
|
||||
};
|
||||
onActiveSignerChanged(this.handleShareNostrSignerChange);
|
||||
this.updateShareNostrAuthState({ reason: "init" });
|
||||
|
||||
delete this._setupOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -640,6 +658,22 @@ class Application {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(this, "_router", {
|
||||
value: bindCoordinator(this, createRouterCoordinator({
|
||||
devLogger,
|
||||
})),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(this, "_ui", {
|
||||
value: bindCoordinator(this, createUiCoordinator({
|
||||
devLogger,
|
||||
})),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
get modalVideo() {
|
||||
@@ -1208,92 +1242,72 @@ class Application {
|
||||
await watchHistoryInitPromise;
|
||||
}
|
||||
|
||||
async initializeServices() {
|
||||
await this._initViewManager();
|
||||
this._warmupNostrExtension();
|
||||
this._initServiceWorker();
|
||||
this._initAuthAndModeration();
|
||||
}
|
||||
|
||||
initializeUIResources() {
|
||||
return this._initModals();
|
||||
}
|
||||
|
||||
async initializeNetwork() {
|
||||
await this._initNostr();
|
||||
}
|
||||
|
||||
async initializeDataAndSession() {
|
||||
await this._initTrustedSeeds();
|
||||
this._initViewCounter();
|
||||
this._initAccessControl();
|
||||
|
||||
await this._initSessionActor();
|
||||
this._initAccessControlListeners();
|
||||
}
|
||||
|
||||
initializeUserInterface() {
|
||||
this._initUI();
|
||||
}
|
||||
|
||||
async performAutoLogin() {
|
||||
await this._initAutoLogin();
|
||||
}
|
||||
|
||||
async finalizeInitialization() {
|
||||
this.setupEventListeners();
|
||||
await this._initWatchHistory();
|
||||
this.checkUrlParams();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
await this._initViewManager();
|
||||
this._warmupNostrExtension();
|
||||
this._initServiceWorker();
|
||||
this._initAuthAndModeration();
|
||||
await this.initializeServices();
|
||||
|
||||
const modalBootstrapPromise = this._initModals();
|
||||
const modalPromise = this.initializeUIResources();
|
||||
await this.initializeNetwork();
|
||||
await modalPromise;
|
||||
|
||||
await this._initNostr();
|
||||
await this.initializeDataAndSession();
|
||||
|
||||
await modalBootstrapPromise;
|
||||
|
||||
await this._initTrustedSeeds();
|
||||
this._initViewCounter();
|
||||
this._initAccessControl();
|
||||
|
||||
await this._initSessionActor();
|
||||
this._initAccessControlListeners();
|
||||
this._initUI();
|
||||
await this._initAutoLogin();
|
||||
|
||||
// 5. Setup general event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
await this._initWatchHistory();
|
||||
|
||||
// 9. Check URL ?v= param
|
||||
this.checkUrlParams();
|
||||
this.initializeUserInterface();
|
||||
await this.performAutoLogin();
|
||||
|
||||
await this.finalizeInitialization();
|
||||
} catch (error) {
|
||||
devLogger.error("Init failed:", error);
|
||||
this.showError("Failed to connect to Nostr relay");
|
||||
}
|
||||
}
|
||||
|
||||
goToProfile(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
this.showError("No creator info available.");
|
||||
return;
|
||||
}
|
||||
|
||||
let candidate = pubkey.trim();
|
||||
if (!candidate) {
|
||||
this.showError("No creator info available.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (candidate.startsWith("nostr:")) {
|
||||
candidate = candidate.slice("nostr:".length);
|
||||
}
|
||||
|
||||
const normalizedHex = this.normalizeHexPubkey(candidate);
|
||||
const npub = normalizedHex ? this.safeEncodeNpub(normalizedHex) : null;
|
||||
|
||||
if (!npub) {
|
||||
devLogger.warn(
|
||||
"[Application] Invalid pubkey for profile navigation:",
|
||||
candidate,
|
||||
);
|
||||
this.showError("Invalid creator profile.");
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.hash = `#view=channel-profile&npub=${npub}`;
|
||||
goToProfile(...args) {
|
||||
this._initCoordinators();
|
||||
return this._router.goToProfile(...args);
|
||||
}
|
||||
|
||||
openCreatorChannel() {
|
||||
if (!this.currentVideo || !this.currentVideo.pubkey) {
|
||||
this.showError("No creator info available.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Encode the hex pubkey to npub
|
||||
const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey);
|
||||
|
||||
// Close the video modal
|
||||
this.hideModal();
|
||||
|
||||
// Switch to channel profile view
|
||||
window.location.hash = `#view=channel-profile&npub=${npub}`;
|
||||
} catch (err) {
|
||||
devLogger.error("Failed to open creator channel:", err);
|
||||
this.showError("Could not open channel.");
|
||||
}
|
||||
openCreatorChannel(...args) {
|
||||
this._initCoordinators();
|
||||
return this._router.openCreatorChannel(...args);
|
||||
}
|
||||
|
||||
openWalletPane() {
|
||||
@@ -1337,41 +1351,14 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
dispatchAuthLoadingState(detail = {}) {
|
||||
if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = { ...(typeof detail === "object" && detail ? detail : {}) };
|
||||
window.dispatchEvent(new CustomEvent("bitvid:auth-loading-state", { detail: payload }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
devLogger.warn("[Application] Failed to dispatch auth loading state:", error);
|
||||
return false;
|
||||
}
|
||||
dispatchAuthLoadingState(...args) {
|
||||
this._initCoordinators();
|
||||
return this._ui.dispatchAuthLoadingState(...args);
|
||||
}
|
||||
|
||||
updateAuthLoadingState(partial = {}) {
|
||||
const baseState =
|
||||
this.authLoadingState && typeof this.authLoadingState === "object"
|
||||
? this.authLoadingState
|
||||
: { profile: "idle", lists: "idle", dms: "idle" };
|
||||
const nextState = {
|
||||
...baseState,
|
||||
...(partial && typeof partial === "object" ? partial : {}),
|
||||
};
|
||||
this.authLoadingState = nextState;
|
||||
|
||||
const root = typeof document !== "undefined" ? document.documentElement : null;
|
||||
if (root) {
|
||||
root.dataset.authProfileLoading = nextState.profile || "idle";
|
||||
root.dataset.authListsLoading = nextState.lists || "idle";
|
||||
root.dataset.authDmsLoading = nextState.dms || "idle";
|
||||
}
|
||||
|
||||
this.dispatchAuthLoadingState(nextState);
|
||||
return nextState;
|
||||
updateAuthLoadingState(...args) {
|
||||
this._initCoordinators();
|
||||
return this._ui.updateAuthLoadingState(...args);
|
||||
}
|
||||
|
||||
normalizeModalTrigger(...args) {
|
||||
@@ -1663,23 +1650,9 @@ class Application {
|
||||
this.closeAllMoreMenus();
|
||||
}
|
||||
|
||||
handleProfileChannelLink(element) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetNpub =
|
||||
typeof element.dataset.targetNpub === "string"
|
||||
? element.dataset.targetNpub
|
||||
: "";
|
||||
if (this.profileController) {
|
||||
this.profileController.hide();
|
||||
}
|
||||
if (targetNpub) {
|
||||
window.location.hash = `#view=channel-profile&npub=${encodeURIComponent(
|
||||
targetNpub,
|
||||
)}`;
|
||||
}
|
||||
handleProfileChannelLink(...args) {
|
||||
this._initCoordinators();
|
||||
return this._router.handleProfileChannelLink(...args);
|
||||
}
|
||||
|
||||
async handleLoginModalSuccess(payload = {}) {
|
||||
@@ -2250,6 +2223,22 @@ class Application {
|
||||
(detail?.loaded === false
|
||||
? false
|
||||
: Boolean(service && service.loaded));
|
||||
const uiReady =
|
||||
detail?.uiReady === true ||
|
||||
(detail?.uiReady === false
|
||||
? false
|
||||
: Boolean(service && service.uiReady));
|
||||
const dataReady =
|
||||
detail?.dataReady === true ||
|
||||
(detail?.dataReady === false
|
||||
? false
|
||||
: Boolean(service && service.dataReady));
|
||||
const loadedFromCache =
|
||||
detail?.loadedFromCache === true ||
|
||||
(detail?.loadedFromCache === false
|
||||
? false
|
||||
: Boolean(service && service.loadedFromCache));
|
||||
const lastLoadError = detail?.lastLoadError || service?.lastLoadError || null;
|
||||
|
||||
return {
|
||||
interests: this.normalizeHashtagPreferenceList(sourceInterests),
|
||||
@@ -2257,6 +2246,10 @@ class Application {
|
||||
eventId: rawEventId || null,
|
||||
createdAt,
|
||||
loaded,
|
||||
uiReady,
|
||||
dataReady,
|
||||
loadedFromCache,
|
||||
lastLoadError,
|
||||
action,
|
||||
};
|
||||
}
|
||||
@@ -2276,8 +2269,22 @@ class Application {
|
||||
? Number(snapshot.createdAt)
|
||||
: "";
|
||||
const loaded = snapshot.loaded === true ? "1" : "0";
|
||||
const uiReady = snapshot.uiReady === true ? "1" : "0";
|
||||
const dataReady = snapshot.dataReady === true ? "1" : "0";
|
||||
const loadedFromCache = snapshot.loadedFromCache === true ? "1" : "0";
|
||||
const lastLoadError = snapshot?.lastLoadError?.code || "";
|
||||
|
||||
return [interests, disinterests, eventId, createdAt, loaded].join("|");
|
||||
return [
|
||||
interests,
|
||||
disinterests,
|
||||
eventId,
|
||||
createdAt,
|
||||
loaded,
|
||||
uiReady,
|
||||
dataReady,
|
||||
loadedFromCache,
|
||||
lastLoadError,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
updateCachedHashtagPreferences(detail = {}) {
|
||||
@@ -2291,6 +2298,10 @@ class Application {
|
||||
eventId: snapshot.eventId,
|
||||
createdAt: snapshot.createdAt,
|
||||
loaded: snapshot.loaded,
|
||||
uiReady: snapshot.uiReady,
|
||||
dataReady: snapshot.dataReady,
|
||||
loadedFromCache: snapshot.loadedFromCache,
|
||||
lastLoadError: snapshot.lastLoadError,
|
||||
action: snapshot.action,
|
||||
};
|
||||
this.hashtagPreferencesSnapshotSignature = signature;
|
||||
@@ -2361,6 +2372,10 @@ class Application {
|
||||
? Number(snapshot.createdAt)
|
||||
: null,
|
||||
loaded: snapshot.loaded === true,
|
||||
uiReady: snapshot.uiReady === true,
|
||||
dataReady: snapshot.dataReady === true,
|
||||
loadedFromCache: snapshot.loadedFromCache === true,
|
||||
lastLoadError: snapshot.lastLoadError || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3260,36 +3275,9 @@ class Application {
|
||||
return this._moderation.refreshVisibleModerationUi(...args);
|
||||
}
|
||||
|
||||
updateActiveProfileUI(pubkey, profile = {}) {
|
||||
if (this.profileController) {
|
||||
this.profileController.handleProfileUpdated({
|
||||
pubkey,
|
||||
profile,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const picture = profile.picture || "assets/svg/default-profile.svg";
|
||||
|
||||
if (this.profileAvatar) {
|
||||
this.profileAvatar.src = picture;
|
||||
}
|
||||
|
||||
const channelLink = document.getElementById("profileChannelLink");
|
||||
if (channelLink instanceof HTMLElement) {
|
||||
const targetNpub = this.safeEncodeNpub(pubkey);
|
||||
if (targetNpub) {
|
||||
channelLink.href = `#view=channel-profile&npub=${targetNpub}`;
|
||||
channelLink.dataset.targetNpub = targetNpub;
|
||||
channelLink.classList.remove("hidden");
|
||||
} else {
|
||||
channelLink.removeAttribute("href");
|
||||
if (channelLink.dataset) {
|
||||
delete channelLink.dataset.targetNpub;
|
||||
}
|
||||
channelLink.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
updateActiveProfileUI(...args) {
|
||||
this._initCoordinators();
|
||||
return this._ui.updateActiveProfileUI(...args);
|
||||
}
|
||||
|
||||
async hydrateNwcSettingsForPubkey(pubkey) {
|
||||
@@ -3370,226 +3358,49 @@ class Application {
|
||||
return this.nwcSettingsService.clearStoredNwcSettings(pubkey, options);
|
||||
}
|
||||
|
||||
applyAuthenticatedUiState() {
|
||||
if (this.loginButton) {
|
||||
this.loginButton.classList.add("hidden");
|
||||
this.loginButton.setAttribute("hidden", "");
|
||||
}
|
||||
|
||||
if (this.logoutButton) {
|
||||
this.logoutButton.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (this.userStatus) {
|
||||
this.userStatus.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.uploadButton) {
|
||||
this.uploadButton.classList.remove("hidden");
|
||||
this.uploadButton.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
if (this.profileButton) {
|
||||
this.profileButton.classList.remove("hidden");
|
||||
this.profileButton.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
const subscriptionsLink = this.resolveSubscriptionsLink();
|
||||
if (subscriptionsLink) {
|
||||
subscriptionsLink.classList.remove("hidden");
|
||||
}
|
||||
|
||||
const forYouLink = this.resolveForYouLink();
|
||||
if (forYouLink) {
|
||||
forYouLink.classList.remove("hidden");
|
||||
}
|
||||
|
||||
const exploreLink = this.resolveExploreLink();
|
||||
if (exploreLink) {
|
||||
exploreLink.classList.remove("hidden");
|
||||
}
|
||||
applyAuthenticatedUiState(...args) {
|
||||
this._initCoordinators();
|
||||
return this._ui.applyAuthenticatedUiState(...args);
|
||||
}
|
||||
|
||||
applyLoggedOutUiState() {
|
||||
if (this.loginButton) {
|
||||
this.loginButton.classList.remove("hidden");
|
||||
this.loginButton.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
if (this.logoutButton) {
|
||||
this.logoutButton.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.userStatus) {
|
||||
this.userStatus.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.userPubKey) {
|
||||
this.userPubKey.textContent = "";
|
||||
}
|
||||
|
||||
if (this.uploadButton) {
|
||||
this.uploadButton.classList.add("hidden");
|
||||
this.uploadButton.setAttribute("hidden", "");
|
||||
}
|
||||
|
||||
if (this.profileButton) {
|
||||
this.profileButton.classList.add("hidden");
|
||||
this.profileButton.setAttribute("hidden", "");
|
||||
}
|
||||
|
||||
const subscriptionsLink = this.resolveSubscriptionsLink();
|
||||
if (subscriptionsLink) {
|
||||
subscriptionsLink.classList.add("hidden");
|
||||
}
|
||||
|
||||
const forYouLink = this.resolveForYouLink();
|
||||
if (forYouLink) {
|
||||
forYouLink.classList.add("hidden");
|
||||
}
|
||||
|
||||
const exploreLink = this.resolveExploreLink();
|
||||
if (exploreLink) {
|
||||
exploreLink.classList.add("hidden");
|
||||
}
|
||||
applyLoggedOutUiState(...args) {
|
||||
this._initCoordinators();
|
||||
return this._ui.applyLoggedOutUiState(...args);
|
||||
}
|
||||
|
||||
syncAuthUiState() {
|
||||
if (this.isUserLoggedIn()) {
|
||||
this.applyAuthenticatedUiState();
|
||||
} else {
|
||||
this.applyLoggedOutUiState();
|
||||
}
|
||||
syncAuthUiState(...args) {
|
||||
this._initCoordinators();
|
||||
return this._ui.syncAuthUiState(...args);
|
||||
}
|
||||
|
||||
refreshChromeElements() {
|
||||
const doc = typeof document !== "undefined" ? document : null;
|
||||
const findElement = (id) => {
|
||||
if (!doc || typeof doc.getElementById !== "function") {
|
||||
return null;
|
||||
}
|
||||
const element = doc.getElementById(id);
|
||||
if (!element || typeof element.addEventListener !== "function") {
|
||||
return null;
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
this.loginButton = findElement("loginButton");
|
||||
this.logoutButton = findElement("logoutButton");
|
||||
this.uploadButton = findElement("uploadButton");
|
||||
this.profileButton = findElement("profileButton");
|
||||
this.closeLoginModalBtn = findElement("closeLoginModal");
|
||||
|
||||
return {
|
||||
loginButton: this.loginButton,
|
||||
logoutButton: this.logoutButton,
|
||||
uploadButton: this.uploadButton,
|
||||
profileButton: this.profileButton,
|
||||
closeLoginModalButton: this.closeLoginModalBtn,
|
||||
};
|
||||
refreshChromeElements(...args) {
|
||||
this._initCoordinators();
|
||||
return this._ui.refreshChromeElements(...args);
|
||||
}
|
||||
|
||||
resolveSubscriptionsLink() {
|
||||
if (
|
||||
this.subscriptionsLink instanceof HTMLElement &&
|
||||
this.subscriptionsLink.isConnected
|
||||
) {
|
||||
return this.subscriptionsLink;
|
||||
}
|
||||
|
||||
const linkCandidate = document.getElementById("subscriptionsLink");
|
||||
if (linkCandidate instanceof HTMLElement) {
|
||||
this.subscriptionsLink = linkCandidate;
|
||||
return this.subscriptionsLink;
|
||||
}
|
||||
|
||||
this.subscriptionsLink = null;
|
||||
return null;
|
||||
resolveSubscriptionsLink(...args) {
|
||||
this._initCoordinators();
|
||||
return this._ui.resolveSubscriptionsLink(...args);
|
||||
}
|
||||
|
||||
resolveForYouLink() {
|
||||
if (this.forYouLink instanceof HTMLElement && this.forYouLink.isConnected) {
|
||||
return this.forYouLink;
|
||||
}
|
||||
|
||||
const linkCandidate = document.getElementById("forYouLink");
|
||||
if (linkCandidate instanceof HTMLElement) {
|
||||
this.forYouLink = linkCandidate;
|
||||
return this.forYouLink;
|
||||
}
|
||||
|
||||
this.forYouLink = null;
|
||||
return null;
|
||||
resolveForYouLink(...args) {
|
||||
this._initCoordinators();
|
||||
return this._ui.resolveForYouLink(...args);
|
||||
}
|
||||
|
||||
resolveExploreLink() {
|
||||
if (
|
||||
this.exploreLink instanceof HTMLElement &&
|
||||
this.exploreLink.isConnected
|
||||
) {
|
||||
return this.exploreLink;
|
||||
}
|
||||
|
||||
const linkCandidate = document.getElementById("exploreLink");
|
||||
if (linkCandidate instanceof HTMLElement) {
|
||||
this.exploreLink = linkCandidate;
|
||||
return this.exploreLink;
|
||||
}
|
||||
|
||||
this.exploreLink = null;
|
||||
return null;
|
||||
resolveExploreLink(...args) {
|
||||
this._initCoordinators();
|
||||
return this._ui.resolveExploreLink(...args);
|
||||
}
|
||||
|
||||
hydrateSidebarNavigation() {
|
||||
const chromeElements = this.refreshChromeElements();
|
||||
this.resolveSubscriptionsLink();
|
||||
this.resolveForYouLink();
|
||||
this.resolveExploreLink();
|
||||
|
||||
if (this.appChromeController) {
|
||||
if (typeof this.appChromeController.setElements === "function") {
|
||||
this.appChromeController.setElements(chromeElements);
|
||||
} else {
|
||||
if (this.appChromeController.elements) {
|
||||
Object.assign(this.appChromeController.elements, chromeElements);
|
||||
}
|
||||
this.appChromeController.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
this.syncAuthUiState();
|
||||
hydrateSidebarNavigation(...args) {
|
||||
this._initCoordinators();
|
||||
return this._ui.hydrateSidebarNavigation(...args);
|
||||
}
|
||||
|
||||
maybeShowExperimentalLoginWarning(provider) {
|
||||
const normalizedProvider =
|
||||
typeof provider === "string" ? provider.trim().toLowerCase() : "";
|
||||
|
||||
if (normalizedProvider !== "nsec" && normalizedProvider !== "nip46") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (
|
||||
this.lastExperimentalWarningKey === normalizedProvider &&
|
||||
typeof this.lastExperimentalWarningAt === "number" &&
|
||||
now - this.lastExperimentalWarningAt < 2000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastExperimentalWarningKey = normalizedProvider;
|
||||
this.lastExperimentalWarningAt = now;
|
||||
|
||||
const providerLabel =
|
||||
normalizedProvider === "nsec"
|
||||
? "Direct nsec or seed"
|
||||
: "NIP-46 remote signer";
|
||||
|
||||
this.showStatus(
|
||||
`${providerLabel} logins are still in development and may not work well yet. We recommend using a NIP-07 browser extension for the most reliable experience.`,
|
||||
{ showSpinner: false, autoHideMs: 5000 },
|
||||
);
|
||||
maybeShowExperimentalLoginWarning(...args) {
|
||||
this._initCoordinators();
|
||||
return this._ui.maybeShowExperimentalLoginWarning(...args);
|
||||
}
|
||||
|
||||
async handlePermissionPromptRequest() {
|
||||
@@ -3711,6 +3522,95 @@ class Application {
|
||||
this.updatePermissionPromptCta();
|
||||
}
|
||||
|
||||
async handleAuthSyncRetryRequest() {
|
||||
const activePubkey = this.normalizeHexPubkey(this.pubkey);
|
||||
if (!activePubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateAuthLoadingState({ lists: "loading" });
|
||||
|
||||
const taskResults = await Promise.allSettled([
|
||||
Promise.resolve()
|
||||
.then(() => this.authService?.loadBlocksForPubkey?.(activePubkey, {
|
||||
allowPermissionPrompt: true,
|
||||
}))
|
||||
.then((loaded) => ({ name: "blocks", ok: loaded !== false }))
|
||||
.catch((error) => ({ name: "blocks", ok: false, error })),
|
||||
Promise.resolve()
|
||||
.then(() => subscriptions?.ensureLoaded?.(activePubkey, {
|
||||
allowPermissionPrompt: true,
|
||||
}))
|
||||
.then(() => ({
|
||||
name: "subscriptions",
|
||||
ok: !subscriptions?.lastLoadError,
|
||||
error: subscriptions?.lastLoadError || null,
|
||||
}))
|
||||
.catch((error) => ({ name: "subscriptions", ok: false, error })),
|
||||
Promise.resolve()
|
||||
.then(() => this.hashtagPreferences?.load?.(activePubkey, {
|
||||
allowPermissionPrompt: true,
|
||||
}))
|
||||
.then(() => {
|
||||
const error = this.hashtagPreferences?.lastLoadError || null;
|
||||
if (!error) {
|
||||
this.updateCachedHashtagPreferences();
|
||||
}
|
||||
return {
|
||||
name: "hashtags",
|
||||
ok: !error,
|
||||
error,
|
||||
};
|
||||
})
|
||||
.catch((error) => ({ name: "hashtags", ok: false, error })),
|
||||
]);
|
||||
|
||||
const tasks = taskResults.map((result) =>
|
||||
result.status === "fulfilled"
|
||||
? result.value
|
||||
: {
|
||||
name: "unknown",
|
||||
ok: false,
|
||||
error: result.reason || null,
|
||||
},
|
||||
);
|
||||
const hasFailure = tasks.some((task) => !task.ok);
|
||||
|
||||
this.updateAuthLoadingState({
|
||||
lists: hasFailure ? "error" : "ready",
|
||||
listsDetail: {
|
||||
ready: !hasFailure,
|
||||
degraded: hasFailure,
|
||||
error: hasFailure,
|
||||
retryScheduled: false,
|
||||
retryCompleted: true,
|
||||
tasks: tasks.map((task) => ({
|
||||
name: task.name,
|
||||
ok: task.ok,
|
||||
fromCache: false,
|
||||
error: task.error || null,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
this.dispatchAuthChange({
|
||||
status: "auth-sync-retry",
|
||||
loggedIn: true,
|
||||
pubkey: activePubkey,
|
||||
authLoadingState: this.authLoadingState,
|
||||
});
|
||||
|
||||
if (this.profileController) {
|
||||
this.profileController.populateBlockedList();
|
||||
void this.profileController.populateSubscriptionsList();
|
||||
this.profileController.populateHashtagPreferences();
|
||||
}
|
||||
|
||||
if (!hasFailure) {
|
||||
await this.onVideosShouldRefresh({ reason: "auth-sync-retry-success" });
|
||||
}
|
||||
}
|
||||
|
||||
async handleAuthLogin(...args) {
|
||||
this._initCoordinators();
|
||||
return this._auth.handleAuthLogin(...args);
|
||||
@@ -4886,46 +4786,11 @@ class Application {
|
||||
* Simple helper to safely encode an npub.
|
||||
*/
|
||||
safeEncodeNpub(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.NostrTools.nip19.npubEncode(trimmed);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
return safeEncodeNpub(pubkey);
|
||||
}
|
||||
|
||||
safeDecodeNpub(npub) {
|
||||
if (typeof npub !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = npub.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = window.NostrTools.nip19.decode(trimmed);
|
||||
if (decoded.type === "npub" && typeof decoded.data === "string") {
|
||||
return decoded.data;
|
||||
}
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
return safeDecodeNpub(npub);
|
||||
}
|
||||
|
||||
selectPreferredCreatorName(candidates = []) {
|
||||
@@ -5357,27 +5222,7 @@ class Application {
|
||||
}
|
||||
|
||||
normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
const decoded = this.safeDecodeNpub(trimmed);
|
||||
if (decoded && HEX64_REGEX.test(decoded)) {
|
||||
return decoded.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return normalizeHexPubkey(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,8 +42,97 @@ export function createAuthSessionCoordinator(deps) {
|
||||
getActiveProfilePubkey,
|
||||
} = deps;
|
||||
|
||||
const now =
|
||||
typeof performance !== "undefined" && typeof performance.now === "function"
|
||||
? () => performance.now()
|
||||
: () => Date.now();
|
||||
|
||||
const SIGNER_GATE_STATUS = Object.freeze({
|
||||
READY: "signer-ready",
|
||||
EXTENSION_UNAVAILABLE: "extension-unavailable",
|
||||
PERMISSION_DENIED: "permission-denied",
|
||||
});
|
||||
|
||||
const classifySignerGateError = (error) => {
|
||||
const code =
|
||||
typeof error?.code === "string" && error.code.trim()
|
||||
? error.code.trim().toLowerCase()
|
||||
: "";
|
||||
const message =
|
||||
typeof error?.message === "string" && error.message.trim()
|
||||
? error.message.trim().toLowerCase()
|
||||
: "";
|
||||
|
||||
if (
|
||||
code.includes("permission") ||
|
||||
message.includes("permission") ||
|
||||
message.includes("denied")
|
||||
) {
|
||||
return SIGNER_GATE_STATUS.PERMISSION_DENIED;
|
||||
}
|
||||
|
||||
return SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE;
|
||||
};
|
||||
|
||||
const evaluateSignerReadinessGate = async (pubkey) => {
|
||||
if (!pubkey) {
|
||||
return {
|
||||
ready: false,
|
||||
status: SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const signer = await nostrClient.ensureActiveSignerForPubkey(pubkey);
|
||||
if (!signer) {
|
||||
return {
|
||||
ready: false,
|
||||
status: SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
signer?.type === "extension" ||
|
||||
signer?.type === "nip07"
|
||||
) {
|
||||
const permissionResult = await nostrClient.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
if (!permissionResult?.ok) {
|
||||
const error =
|
||||
permissionResult?.error instanceof Error
|
||||
? permissionResult.error
|
||||
: new Error("permission-denied");
|
||||
return {
|
||||
ready: false,
|
||||
status: classifySignerGateError(error),
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
status: SIGNER_GATE_STATUS.READY,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ready: false,
|
||||
status: classifySignerGateError(error),
|
||||
error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
async handleAuthLogin(detail = {}) {
|
||||
const authLoginStart = now();
|
||||
userLogger.info("[auth-login-start]", {
|
||||
pubkey: detail?.pubkey || this.pubkey || null,
|
||||
});
|
||||
const postLoginPromise =
|
||||
detail && typeof detail.postLoginPromise?.then === "function"
|
||||
? detail.postLoginPromise
|
||||
@@ -193,25 +282,71 @@ export function createAuthSessionCoordinator(deps) {
|
||||
return null;
|
||||
});
|
||||
|
||||
// PRE-AUTH: Request full permissions once so subsequent services don't
|
||||
// spam individual prompts. This runs immediately (not gated behind
|
||||
// relaysReadyPromise) so the user can approve while relays connect.
|
||||
const permissionPromise = activePubkey
|
||||
// Shared signer readiness gate used by both auto-login and manual NIP-07
|
||||
// login sync orchestration before decrypt-dependent list fetches begin.
|
||||
const signerReadinessPromise = activePubkey
|
||||
? Promise.resolve()
|
||||
.then(async () => {
|
||||
const signer = await nostrClient.ensureActiveSignerForPubkey(
|
||||
activePubkey,
|
||||
const signerGateStart = now();
|
||||
let outcome = await evaluateSignerReadinessGate(activePubkey);
|
||||
if (!outcome.ready) {
|
||||
outcome = {
|
||||
...outcome,
|
||||
recoveryAttempted: true,
|
||||
initialStatus: outcome.status,
|
||||
};
|
||||
const recoveryOutcome = await evaluateSignerReadinessGate(
|
||||
activePubkey,
|
||||
);
|
||||
outcome = {
|
||||
...recoveryOutcome,
|
||||
recoveryAttempted: true,
|
||||
initialStatus: outcome.initialStatus,
|
||||
};
|
||||
}
|
||||
|
||||
const durationMs = Math.max(
|
||||
0,
|
||||
Math.round(now() - signerGateStart),
|
||||
);
|
||||
if (signer?.type === "extension" || signer?.type === "nip07") {
|
||||
await nostrClient.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
const finalOutcome = {
|
||||
...outcome,
|
||||
recoveryAttempted: Boolean(outcome.recoveryAttempted),
|
||||
durationMs,
|
||||
};
|
||||
userLogger.info("[signer-ready]", {
|
||||
ready: finalOutcome.ready,
|
||||
status: finalOutcome.status,
|
||||
recoveryAttempted: finalOutcome.recoveryAttempted,
|
||||
durationMs: finalOutcome.durationMs,
|
||||
});
|
||||
|
||||
if (!finalOutcome.ready && finalOutcome.error) {
|
||||
devLogger.warn(
|
||||
"[Application] Signer readiness gate not ready after bounded recovery:",
|
||||
finalOutcome.error,
|
||||
);
|
||||
}
|
||||
|
||||
return finalOutcome;
|
||||
})
|
||||
.catch((error) => {
|
||||
devLogger.warn("[Application] Pre-authorization failed:", error);
|
||||
devLogger.warn("[Application] Signer readiness gate failed:", error);
|
||||
return {
|
||||
ready: false,
|
||||
status: classifySignerGateError(error),
|
||||
error,
|
||||
recoveryAttempted: true,
|
||||
durationMs: 0,
|
||||
};
|
||||
})
|
||||
: Promise.resolve();
|
||||
: Promise.resolve({
|
||||
ready: false,
|
||||
status: SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE,
|
||||
error: null,
|
||||
recoveryAttempted: false,
|
||||
durationMs: 0,
|
||||
});
|
||||
|
||||
// Start list loading as soon as relays are ready — lists don't depend on
|
||||
// profile data, only on relay URLs. This runs in parallel with profile
|
||||
@@ -231,7 +366,66 @@ export function createAuthSessionCoordinator(deps) {
|
||||
// signer is guaranteed to be available when decryption starts. This does
|
||||
// NOT slow down fresh logins because the pre-grant resolves immediately
|
||||
// when permissions were already granted during loginWithExtension().
|
||||
const listStatePromise = Promise.all([relaysReadyPromise, permissionPromise]).then(async () => {
|
||||
//
|
||||
// If pre-auth fails (e.g. extension was slow to inject), allow list
|
||||
// services to request permissions themselves so they don't stall waiting
|
||||
// for a retry timer.
|
||||
const listStatePromise = Promise.all([
|
||||
relaysReadyPromise,
|
||||
signerReadinessPromise,
|
||||
]).then(async ([, signerGateOutcome]) => {
|
||||
const listSyncStart = now();
|
||||
const allowPermissionPrompt = !signerGateOutcome?.ready;
|
||||
userLogger.info("[lists-sync-start]", {
|
||||
pubkey: activePubkey || null,
|
||||
allowPermissionPrompt,
|
||||
signerStatus: signerGateOutcome?.status || null,
|
||||
});
|
||||
const hasValidBlocksCache = () => {
|
||||
if (!activePubkey || !userBlocks) {
|
||||
return false;
|
||||
}
|
||||
const activeCachePubkey = this.normalizeHexPubkey(userBlocks.activePubkey);
|
||||
return userBlocks.loaded === true && activeCachePubkey === activePubkey;
|
||||
};
|
||||
const hasValidSubscriptionsCache = () => {
|
||||
if (!activePubkey || !subscriptions) {
|
||||
return false;
|
||||
}
|
||||
const activeCachePubkey = this.normalizeHexPubkey(
|
||||
subscriptions.currentUserPubkey,
|
||||
);
|
||||
return subscriptions.loaded === true && activeCachePubkey === activePubkey;
|
||||
};
|
||||
const hasValidHashtagCache = () => {
|
||||
if (!activePubkey || !this.hashtagPreferences) {
|
||||
return false;
|
||||
}
|
||||
const activeCachePubkey = this.normalizeHexPubkey(
|
||||
this.hashtagPreferences.activePubkey,
|
||||
);
|
||||
return this.hashtagPreferences.loaded === true && activeCachePubkey === activePubkey;
|
||||
};
|
||||
|
||||
const runListTask = async (name, task, { hasValidCache }) => {
|
||||
try {
|
||||
const result = await task();
|
||||
return {
|
||||
name,
|
||||
ok: Boolean(result?.ok),
|
||||
error: result?.error || null,
|
||||
hasCache: hasValidCache(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name,
|
||||
ok: false,
|
||||
error,
|
||||
hasCache: hasValidCache(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const parallelListTasks = [];
|
||||
|
||||
if (
|
||||
@@ -239,16 +433,23 @@ export function createAuthSessionCoordinator(deps) {
|
||||
typeof this.authService.loadBlocksForPubkey === "function"
|
||||
) {
|
||||
parallelListTasks.push(
|
||||
this.authService
|
||||
.loadBlocksForPubkey(activePubkey, {
|
||||
allowPermissionPrompt: false,
|
||||
})
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[Application] Failed to load blocks during login:",
|
||||
error,
|
||||
);
|
||||
}),
|
||||
runListTask(
|
||||
"blocks",
|
||||
async () => {
|
||||
const loaded = await this.authService.loadBlocksForPubkey(activePubkey, {
|
||||
allowPermissionPrompt,
|
||||
signerReadinessGate: signerGateOutcome,
|
||||
});
|
||||
const ok = loaded !== false;
|
||||
if (!ok) {
|
||||
const error = new Error("Block list sync returned a failed status.");
|
||||
error.code = "blocks-sync-failed";
|
||||
return { ok: false, error };
|
||||
}
|
||||
return { ok: true, error: null };
|
||||
},
|
||||
{ hasValidCache: hasValidBlocksCache },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -258,20 +459,19 @@ export function createAuthSessionCoordinator(deps) {
|
||||
typeof subscriptions.ensureLoaded === "function"
|
||||
) {
|
||||
parallelListTasks.push(
|
||||
subscriptions
|
||||
.ensureLoaded(activePubkey, { allowPermissionPrompt: false })
|
||||
.then(() => {
|
||||
this.capturePermissionPromptFromError(
|
||||
subscriptions?.lastLoadError,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[Application] Failed to load subscriptions during login:",
|
||||
error,
|
||||
);
|
||||
runListTask(
|
||||
"subscriptions",
|
||||
async () => {
|
||||
await subscriptions.ensureLoaded(activePubkey, {
|
||||
allowPermissionPrompt,
|
||||
signerReadinessGate: signerGateOutcome,
|
||||
});
|
||||
const error = subscriptions?.lastLoadError || null;
|
||||
this.capturePermissionPromptFromError(error);
|
||||
}),
|
||||
return { ok: !error, error };
|
||||
},
|
||||
{ hasValidCache: hasValidSubscriptionsCache },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,28 +481,141 @@ export function createAuthSessionCoordinator(deps) {
|
||||
typeof this.hashtagPreferences.load === "function"
|
||||
) {
|
||||
parallelListTasks.push(
|
||||
this.hashtagPreferences
|
||||
.load(activePubkey, { allowPermissionPrompt: false })
|
||||
.then(() => {
|
||||
this.capturePermissionPromptFromError(
|
||||
this.hashtagPreferences?.lastLoadError,
|
||||
);
|
||||
this.updateCachedHashtagPreferences();
|
||||
})
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[Application] Failed to load hashtag preferences during login:",
|
||||
error,
|
||||
);
|
||||
runListTask(
|
||||
"hashtags",
|
||||
async () => {
|
||||
await this.hashtagPreferences.load(activePubkey, {
|
||||
allowPermissionPrompt,
|
||||
signerReadinessGate: signerGateOutcome,
|
||||
});
|
||||
const error = this.hashtagPreferences?.lastLoadError || null;
|
||||
this.capturePermissionPromptFromError(error);
|
||||
}),
|
||||
this.updateCachedHashtagPreferences();
|
||||
return { ok: !error, error };
|
||||
},
|
||||
{ hasValidCache: hasValidHashtagCache },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.allSettled(parallelListTasks);
|
||||
const taskOutcomes = await Promise.all(parallelListTasks);
|
||||
const requiredTaskOutcomes = taskOutcomes.filter((outcome) =>
|
||||
["blocks", "subscriptions", "hashtags"].includes(outcome.name),
|
||||
);
|
||||
const failedRequiredOutcomes = requiredTaskOutcomes.filter(
|
||||
(outcome) => !outcome.ok,
|
||||
);
|
||||
const ready = requiredTaskOutcomes.every(
|
||||
(outcome) => outcome.ok || outcome.hasCache,
|
||||
);
|
||||
const degraded = failedRequiredOutcomes.length > 0;
|
||||
const fatal = failedRequiredOutcomes.some((outcome) => !outcome.hasCache);
|
||||
|
||||
this.updateAuthLoadingState({ lists: "ready" });
|
||||
return true;
|
||||
const listSyncDetail = {
|
||||
ready,
|
||||
degraded,
|
||||
error: fatal,
|
||||
retryScheduled: false,
|
||||
tasks: requiredTaskOutcomes.map((outcome) => ({
|
||||
name: outcome.name,
|
||||
ok: outcome.ok,
|
||||
fromCache: outcome.hasCache && !outcome.ok,
|
||||
error: outcome.error || null,
|
||||
})),
|
||||
};
|
||||
|
||||
if (degraded && activePubkey) {
|
||||
listSyncDetail.retryScheduled = true;
|
||||
Promise.resolve()
|
||||
.then(() =>
|
||||
Promise.all([
|
||||
this.authService?.loadBlocksForPubkey?.(activePubkey, {
|
||||
allowPermissionPrompt: true,
|
||||
signerReadinessGate: signerGateOutcome,
|
||||
}),
|
||||
subscriptions?.ensureLoaded?.(activePubkey, {
|
||||
allowPermissionPrompt: true,
|
||||
signerReadinessGate: signerGateOutcome,
|
||||
}),
|
||||
this.hashtagPreferences?.load?.(activePubkey, {
|
||||
allowPermissionPrompt: true,
|
||||
signerReadinessGate: signerGateOutcome,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.then(() => {
|
||||
this.updateCachedHashtagPreferences();
|
||||
const retryTasks = [
|
||||
{
|
||||
name: "blocks",
|
||||
ok: hasValidBlocksCache(),
|
||||
fromCache: false,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
name: "subscriptions",
|
||||
ok:
|
||||
hasValidSubscriptionsCache() &&
|
||||
!subscriptions?.lastLoadError,
|
||||
fromCache: false,
|
||||
error: subscriptions?.lastLoadError || null,
|
||||
},
|
||||
{
|
||||
name: "hashtags",
|
||||
ok:
|
||||
hasValidHashtagCache() &&
|
||||
!this.hashtagPreferences?.lastLoadError,
|
||||
fromCache: false,
|
||||
error: this.hashtagPreferences?.lastLoadError || null,
|
||||
},
|
||||
];
|
||||
const retryReady = retryTasks.every((task) => task.ok);
|
||||
const retryDegraded = retryTasks.some((task) => !task.ok);
|
||||
this.updateAuthLoadingState({
|
||||
lists: retryReady ? "ready" : "degraded",
|
||||
listsDetail: {
|
||||
ready: retryReady,
|
||||
degraded: retryDegraded,
|
||||
error: retryDegraded,
|
||||
retryScheduled: false,
|
||||
retryCompleted: true,
|
||||
tasks: retryTasks,
|
||||
},
|
||||
});
|
||||
this.dispatchAuthChange({
|
||||
status: "login-lists-sync",
|
||||
loggedIn: true,
|
||||
pubkey: activePubkey,
|
||||
authLoadingState: this.authLoadingState,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[Application] Consolidated list sync retry failed after login:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const listsState = ready ? (degraded ? "degraded" : "ready") : "error";
|
||||
userLogger.info("[lists-sync-complete]", {
|
||||
pubkey: activePubkey || null,
|
||||
ready,
|
||||
degraded,
|
||||
fatal,
|
||||
durationMs: Math.max(0, Math.round(now() - listSyncStart)),
|
||||
});
|
||||
this.updateAuthLoadingState({
|
||||
lists: listsState,
|
||||
listsDetail: listSyncDetail,
|
||||
});
|
||||
this.dispatchAuthChange({
|
||||
status: "login-lists-sync",
|
||||
loggedIn: true,
|
||||
pubkey: activePubkey,
|
||||
authLoadingState: this.authLoadingState,
|
||||
});
|
||||
return listSyncDetail;
|
||||
});
|
||||
|
||||
// DMs can wait for profile since they need encryption context.
|
||||
@@ -436,6 +749,11 @@ export function createAuthSessionCoordinator(deps) {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
userLogger.info("[auth-login-complete]", {
|
||||
pubkey: activePubkey || null,
|
||||
durationMs: Math.max(0, Math.round(now() - authLoginStart)),
|
||||
});
|
||||
},
|
||||
|
||||
handleBlocksLoaded(detail = {}) {
|
||||
@@ -516,10 +834,6 @@ export function createAuthSessionCoordinator(deps) {
|
||||
const detail = await this.authService.logout();
|
||||
|
||||
if (detail && typeof detail === "object") {
|
||||
if (detail.__handled === true) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
try {
|
||||
detail.__handled = true;
|
||||
} catch (error) {
|
||||
@@ -532,6 +846,22 @@ export function createAuthSessionCoordinator(deps) {
|
||||
},
|
||||
|
||||
async handleAuthLogout(detail = {}) {
|
||||
// Deduplicate concurrent calls — the auth:logout event listener may
|
||||
// fire-and-forget this method while requestLogout() also awaits it.
|
||||
// Return the in-flight promise so both callers resolve together.
|
||||
if (this._pendingLogoutPromise) {
|
||||
return this._pendingLogoutPromise;
|
||||
}
|
||||
|
||||
this._pendingLogoutPromise = this._executeAuthLogout(detail);
|
||||
try {
|
||||
return await this._pendingLogoutPromise;
|
||||
} finally {
|
||||
this._pendingLogoutPromise = null;
|
||||
}
|
||||
},
|
||||
|
||||
async _executeAuthLogout(detail = {}) {
|
||||
if (detail && typeof detail === "object") {
|
||||
try {
|
||||
detail.__handled = true;
|
||||
|
||||
@@ -523,6 +523,7 @@ export function createFeedCoordinator(deps) {
|
||||
? this.getHashtagPreferences()
|
||||
: {};
|
||||
const { interests = [], disinterests = [] } = preferenceSource || {};
|
||||
const preferencesAvailable = preferenceSource?.dataReady === true;
|
||||
const moderationThresholds = this.getActiveModerationThresholds();
|
||||
|
||||
const watchedKeys = new Set();
|
||||
@@ -544,6 +545,7 @@ export function createFeedCoordinator(deps) {
|
||||
tagPreferences: {
|
||||
interests: Array.isArray(interests) ? [...interests] : [],
|
||||
disinterests: Array.isArray(disinterests) ? [...disinterests] : [],
|
||||
available: preferencesAvailable,
|
||||
},
|
||||
moderationThresholds: moderationThresholds
|
||||
? { ...moderationThresholds }
|
||||
@@ -606,6 +608,7 @@ export function createFeedCoordinator(deps) {
|
||||
? this.getHashtagPreferences()
|
||||
: {};
|
||||
const { interests = [], disinterests = [] } = preferenceSource || {};
|
||||
const preferencesAvailable = preferenceSource?.dataReady === true;
|
||||
const moderationThresholds = this.getActiveModerationThresholds();
|
||||
|
||||
const watchHistorySource =
|
||||
@@ -636,6 +639,7 @@ export function createFeedCoordinator(deps) {
|
||||
tagPreferences: {
|
||||
interests: Array.isArray(interests) ? [...interests] : [],
|
||||
disinterests: Array.isArray(disinterests) ? [...disinterests] : [],
|
||||
available: preferencesAvailable,
|
||||
},
|
||||
watchHistoryTagCounts,
|
||||
exploreTagIdf,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// js/app/routerCoordinator.js
|
||||
|
||||
/**
|
||||
* Routing logic for the application.
|
||||
*
|
||||
* All module-level dependencies are injected from the Application
|
||||
* composition root rather than imported at module scope.
|
||||
*
|
||||
* Methods use `this` which is bound to the Application instance.
|
||||
*/
|
||||
export function createRouterCoordinator(deps) {
|
||||
const {
|
||||
devLogger,
|
||||
} = deps;
|
||||
|
||||
return {
|
||||
goToProfile(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
this.showError("No creator info available.");
|
||||
return;
|
||||
}
|
||||
|
||||
let candidate = pubkey.trim();
|
||||
if (!candidate) {
|
||||
this.showError("No creator info available.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (candidate.startsWith("nostr:")) {
|
||||
candidate = candidate.slice("nostr:".length);
|
||||
}
|
||||
|
||||
const normalizedHex = this.normalizeHexPubkey(candidate);
|
||||
const npub = normalizedHex ? this.safeEncodeNpub(normalizedHex) : null;
|
||||
|
||||
if (!npub) {
|
||||
devLogger.warn(
|
||||
"[Application] Invalid pubkey for profile navigation:",
|
||||
candidate,
|
||||
);
|
||||
this.showError("Invalid creator profile.");
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.hash = `#view=channel-profile&npub=${npub}`;
|
||||
},
|
||||
|
||||
openCreatorChannel() {
|
||||
if (!this.currentVideo || !this.currentVideo.pubkey) {
|
||||
this.showError("No creator info available.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Encode the hex pubkey to npub
|
||||
const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey);
|
||||
|
||||
// Close the video modal
|
||||
this.hideModal();
|
||||
|
||||
// Switch to channel profile view
|
||||
window.location.hash = `#view=channel-profile&npub=${npub}`;
|
||||
} catch (err) {
|
||||
devLogger.error("Failed to open creator channel:", err);
|
||||
this.showError("Could not open channel.");
|
||||
}
|
||||
},
|
||||
|
||||
handleProfileChannelLink(element) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetNpub =
|
||||
typeof element.dataset.targetNpub === "string"
|
||||
? element.dataset.targetNpub
|
||||
: "";
|
||||
if (this.profileController) {
|
||||
this.profileController.hide();
|
||||
}
|
||||
if (targetNpub) {
|
||||
window.location.hash = `#view=channel-profile&npub=${encodeURIComponent(
|
||||
targetNpub,
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// js/app/uiCoordinator.js
|
||||
|
||||
/**
|
||||
* UI State and Chrome management.
|
||||
*
|
||||
* All module-level dependencies are injected from the Application
|
||||
* composition root rather than imported at module scope.
|
||||
*
|
||||
* Methods use `this` which is bound to the Application instance.
|
||||
*/
|
||||
export function createUiCoordinator(deps) {
|
||||
const {
|
||||
devLogger,
|
||||
} = deps;
|
||||
|
||||
return {
|
||||
dispatchAuthLoadingState(detail = {}) {
|
||||
if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = { ...(typeof detail === "object" && detail ? detail : {}) };
|
||||
window.dispatchEvent(new CustomEvent("bitvid:auth-loading-state", { detail: payload }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
devLogger.warn("[Application] Failed to dispatch auth loading state:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
updateAuthLoadingState(partial = {}) {
|
||||
const baseState =
|
||||
this.authLoadingState && typeof this.authLoadingState === "object"
|
||||
? this.authLoadingState
|
||||
: { profile: "idle", lists: "idle", dms: "idle" };
|
||||
const nextState = {
|
||||
...baseState,
|
||||
...(partial && typeof partial === "object" ? partial : {}),
|
||||
};
|
||||
this.authLoadingState = nextState;
|
||||
|
||||
const root = typeof document !== "undefined" ? document.documentElement : null;
|
||||
if (root) {
|
||||
root.dataset.authProfileLoading = nextState.profile || "idle";
|
||||
root.dataset.authListsLoading = nextState.lists || "idle";
|
||||
root.dataset.authDmsLoading = nextState.dms || "idle";
|
||||
}
|
||||
|
||||
this.dispatchAuthLoadingState(nextState);
|
||||
return nextState;
|
||||
},
|
||||
|
||||
updateActiveProfileUI(pubkey, profile = {}) {
|
||||
if (this.profileController) {
|
||||
this.profileController.handleProfileUpdated({
|
||||
pubkey,
|
||||
profile,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const picture = profile.picture || "assets/svg/default-profile.svg";
|
||||
|
||||
if (this.profileAvatar) {
|
||||
this.profileAvatar.src = picture;
|
||||
}
|
||||
|
||||
const channelLink = document.getElementById("profileChannelLink");
|
||||
if (channelLink instanceof HTMLElement) {
|
||||
const targetNpub = this.safeEncodeNpub(pubkey);
|
||||
if (targetNpub) {
|
||||
channelLink.href = `#view=channel-profile&npub=${targetNpub}`;
|
||||
channelLink.dataset.targetNpub = targetNpub;
|
||||
channelLink.classList.remove("hidden");
|
||||
} else {
|
||||
channelLink.removeAttribute("href");
|
||||
if (channelLink.dataset) {
|
||||
delete channelLink.dataset.targetNpub;
|
||||
}
|
||||
channelLink.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
applyAuthenticatedUiState() {
|
||||
if (this.loginButton) {
|
||||
this.loginButton.classList.add("hidden");
|
||||
this.loginButton.setAttribute("hidden", "");
|
||||
}
|
||||
|
||||
if (this.logoutButton) {
|
||||
this.logoutButton.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (this.userStatus) {
|
||||
this.userStatus.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.uploadButton) {
|
||||
this.uploadButton.classList.remove("hidden");
|
||||
this.uploadButton.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
if (this.profileButton) {
|
||||
this.profileButton.classList.remove("hidden");
|
||||
this.profileButton.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
const subscriptionsLink = this.resolveSubscriptionsLink();
|
||||
if (subscriptionsLink) {
|
||||
subscriptionsLink.classList.remove("hidden");
|
||||
}
|
||||
|
||||
const forYouLink = this.resolveForYouLink();
|
||||
if (forYouLink) {
|
||||
forYouLink.classList.remove("hidden");
|
||||
}
|
||||
|
||||
const exploreLink = this.resolveExploreLink();
|
||||
if (exploreLink) {
|
||||
exploreLink.classList.remove("hidden");
|
||||
}
|
||||
},
|
||||
|
||||
applyLoggedOutUiState() {
|
||||
if (this.loginButton) {
|
||||
this.loginButton.classList.remove("hidden");
|
||||
this.loginButton.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
if (this.logoutButton) {
|
||||
this.logoutButton.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.userStatus) {
|
||||
this.userStatus.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.userPubKey) {
|
||||
this.userPubKey.textContent = "";
|
||||
}
|
||||
|
||||
if (this.uploadButton) {
|
||||
this.uploadButton.classList.add("hidden");
|
||||
this.uploadButton.setAttribute("hidden", "");
|
||||
}
|
||||
|
||||
if (this.profileButton) {
|
||||
this.profileButton.classList.add("hidden");
|
||||
this.profileButton.setAttribute("hidden", "");
|
||||
}
|
||||
|
||||
const subscriptionsLink = this.resolveSubscriptionsLink();
|
||||
if (subscriptionsLink) {
|
||||
subscriptionsLink.classList.add("hidden");
|
||||
}
|
||||
|
||||
const forYouLink = this.resolveForYouLink();
|
||||
if (forYouLink) {
|
||||
forYouLink.classList.add("hidden");
|
||||
}
|
||||
|
||||
const exploreLink = this.resolveExploreLink();
|
||||
if (exploreLink) {
|
||||
exploreLink.classList.add("hidden");
|
||||
}
|
||||
},
|
||||
|
||||
syncAuthUiState() {
|
||||
if (this.isUserLoggedIn()) {
|
||||
this.applyAuthenticatedUiState();
|
||||
} else {
|
||||
this.applyLoggedOutUiState();
|
||||
}
|
||||
},
|
||||
|
||||
refreshChromeElements() {
|
||||
const doc = typeof document !== "undefined" ? document : null;
|
||||
const findElement = (id) => {
|
||||
if (!doc || typeof doc.getElementById !== "function") {
|
||||
return null;
|
||||
}
|
||||
const element = doc.getElementById(id);
|
||||
if (!element || typeof element.addEventListener !== "function") {
|
||||
return null;
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
this.loginButton = findElement("loginButton");
|
||||
this.logoutButton = findElement("logoutButton");
|
||||
this.uploadButton = findElement("uploadButton");
|
||||
this.profileButton = findElement("profileButton");
|
||||
this.closeLoginModalBtn = findElement("closeLoginModal");
|
||||
|
||||
return {
|
||||
loginButton: this.loginButton,
|
||||
logoutButton: this.logoutButton,
|
||||
uploadButton: this.uploadButton,
|
||||
profileButton: this.profileButton,
|
||||
closeLoginModalButton: this.closeLoginModalBtn,
|
||||
};
|
||||
},
|
||||
|
||||
resolveSubscriptionsLink() {
|
||||
if (
|
||||
this.subscriptionsLink instanceof HTMLElement &&
|
||||
this.subscriptionsLink.isConnected
|
||||
) {
|
||||
return this.subscriptionsLink;
|
||||
}
|
||||
|
||||
const linkCandidate = document.getElementById("subscriptionsLink");
|
||||
if (linkCandidate instanceof HTMLElement) {
|
||||
this.subscriptionsLink = linkCandidate;
|
||||
return this.subscriptionsLink;
|
||||
}
|
||||
|
||||
this.subscriptionsLink = null;
|
||||
return null;
|
||||
},
|
||||
|
||||
resolveForYouLink() {
|
||||
if (this.forYouLink instanceof HTMLElement && this.forYouLink.isConnected) {
|
||||
return this.forYouLink;
|
||||
}
|
||||
|
||||
const linkCandidate = document.getElementById("forYouLink");
|
||||
if (linkCandidate instanceof HTMLElement) {
|
||||
this.forYouLink = linkCandidate;
|
||||
return this.forYouLink;
|
||||
}
|
||||
|
||||
this.forYouLink = null;
|
||||
return null;
|
||||
},
|
||||
|
||||
resolveExploreLink() {
|
||||
if (
|
||||
this.exploreLink instanceof HTMLElement &&
|
||||
this.exploreLink.isConnected
|
||||
) {
|
||||
return this.exploreLink;
|
||||
}
|
||||
|
||||
const linkCandidate = document.getElementById("exploreLink");
|
||||
if (linkCandidate instanceof HTMLElement) {
|
||||
this.exploreLink = linkCandidate;
|
||||
return this.exploreLink;
|
||||
}
|
||||
|
||||
this.exploreLink = null;
|
||||
return null;
|
||||
},
|
||||
|
||||
hydrateSidebarNavigation() {
|
||||
const chromeElements = this.refreshChromeElements();
|
||||
this.resolveSubscriptionsLink();
|
||||
this.resolveForYouLink();
|
||||
this.resolveExploreLink();
|
||||
|
||||
if (this.appChromeController) {
|
||||
if (typeof this.appChromeController.setElements === "function") {
|
||||
this.appChromeController.setElements(chromeElements);
|
||||
} else {
|
||||
if (this.appChromeController.elements) {
|
||||
Object.assign(this.appChromeController.elements, chromeElements);
|
||||
}
|
||||
this.appChromeController.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
this.syncAuthUiState();
|
||||
},
|
||||
|
||||
maybeShowExperimentalLoginWarning(provider) {
|
||||
const normalizedProvider =
|
||||
typeof provider === "string" ? provider.trim().toLowerCase() : "";
|
||||
|
||||
if (normalizedProvider !== "nsec" && normalizedProvider !== "nip46") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (
|
||||
this.lastExperimentalWarningKey === normalizedProvider &&
|
||||
typeof this.lastExperimentalWarningAt === "number" &&
|
||||
now - this.lastExperimentalWarningAt < 2000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastExperimentalWarningKey = normalizedProvider;
|
||||
this.lastExperimentalWarningAt = now;
|
||||
|
||||
const providerLabel =
|
||||
normalizedProvider === "nsec"
|
||||
? "Direct nsec or seed"
|
||||
: "NIP-46 remote signer";
|
||||
|
||||
this.showStatus(
|
||||
`${providerLabel} logins are still in development and may not work well yet. We recommend using a NIP-07 browser extension for the most reliable experience.`,
|
||||
{ showSpinner: false, autoHideMs: 5000 },
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
Vendored
+3
-1
@@ -30,10 +30,12 @@ function mergeServices(overrides = {}) {
|
||||
}
|
||||
|
||||
export async function createApplication({ services, loadView: loadViewOverride } = {}) {
|
||||
return new Application({
|
||||
const app = new Application({
|
||||
services: mergeServices(services),
|
||||
loadView: typeof loadViewOverride === "function" ? loadViewOverride : loadView,
|
||||
});
|
||||
app.setup();
|
||||
return app;
|
||||
}
|
||||
|
||||
export default createApplication;
|
||||
@@ -102,6 +102,8 @@ app.showSuccess = (message) => {
|
||||
setStatus(message, "success");
|
||||
};
|
||||
|
||||
app.setup();
|
||||
|
||||
const resolvePlaybackPreference = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
|
||||
@@ -391,27 +391,32 @@ export function createKidsScorerStage({
|
||||
const videoId = typeof video.id === "string" ? video.id : null;
|
||||
const pubkey = typeof video.pubkey === "string" ? video.pubkey : null;
|
||||
|
||||
const positiveComponents = [
|
||||
{ key: "age-appropriateness", value: ageAppropriateness },
|
||||
{ key: "educational-boost", value: educationalBoost },
|
||||
{ key: "author-trust", value: authorTrust },
|
||||
{ key: "popularity", value: popularityWithinKids },
|
||||
{ key: "freshness", value: freshness },
|
||||
];
|
||||
let maxKey = "age-appropriateness";
|
||||
let maxValue = ageAppropriateness;
|
||||
|
||||
let dominantPositive = positiveComponents[0];
|
||||
for (const component of positiveComponents) {
|
||||
if (component.value > dominantPositive.value) {
|
||||
dominantPositive = component;
|
||||
}
|
||||
if (educationalBoost > maxValue) {
|
||||
maxKey = "educational-boost";
|
||||
maxValue = educationalBoost;
|
||||
}
|
||||
if (authorTrust > maxValue) {
|
||||
maxKey = "author-trust";
|
||||
maxValue = authorTrust;
|
||||
}
|
||||
if (popularityWithinKids > maxValue) {
|
||||
maxKey = "popularity";
|
||||
maxValue = popularityWithinKids;
|
||||
}
|
||||
if (freshness > maxValue) {
|
||||
maxKey = "freshness";
|
||||
maxValue = freshness;
|
||||
}
|
||||
|
||||
if (dominantPositive.value > 0) {
|
||||
if (maxValue > 0) {
|
||||
context?.addWhy?.({
|
||||
stage: stageName,
|
||||
type: "score",
|
||||
reason: dominantPositive.key,
|
||||
value: dominantPositive.value,
|
||||
reason: maxKey,
|
||||
value: maxValue,
|
||||
score: kidsScore,
|
||||
videoId,
|
||||
pubkey,
|
||||
|
||||
+37
-25
@@ -14,6 +14,14 @@ export function createChronologicalSorter({
|
||||
}
|
||||
|
||||
const copy = [...items];
|
||||
|
||||
// Pre-resolve hooks to avoid allocation and property access in the hot loop
|
||||
const hook = context?.hooks?.timestamps;
|
||||
const h1 = typeof hook?.getKnownVideoPostedAt === "function" ? hook.getKnownVideoPostedAt : null;
|
||||
const h2 = typeof hook?.getKnownPostedAt === "function" ? hook.getKnownPostedAt : null;
|
||||
const h3 = typeof hook?.getVideoPostedAt === "function" ? hook.getVideoPostedAt : null;
|
||||
const h4 = typeof hook?.resolveVideoPostedAt === "function" ? hook.resolveVideoPostedAt : null;
|
||||
|
||||
const getTimestamp = (entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
@@ -36,33 +44,37 @@ export function createChronologicalSorter({
|
||||
}
|
||||
}
|
||||
|
||||
const hook = context?.hooks?.timestamps;
|
||||
if (!hook || typeof hook !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
hook.getKnownVideoPostedAt,
|
||||
hook.getKnownPostedAt,
|
||||
hook.getVideoPostedAt,
|
||||
hook.resolveVideoPostedAt,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "function") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unrolled check for pre-resolved hooks
|
||||
if (h1) {
|
||||
try {
|
||||
const value = candidate(video, { entry, context });
|
||||
if (Number.isFinite(value)) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
const value = h1(video, { entry, context });
|
||||
if (Number.isFinite(value)) return Math.floor(value);
|
||||
} catch (error) {
|
||||
context?.log?.(
|
||||
"[chronological-sorter] timestamps hook threw",
|
||||
error,
|
||||
);
|
||||
context?.log?.("[chronological-sorter] timestamps hook threw", error);
|
||||
}
|
||||
}
|
||||
if (h2) {
|
||||
try {
|
||||
const value = h2(video, { entry, context });
|
||||
if (Number.isFinite(value)) return Math.floor(value);
|
||||
} catch (error) {
|
||||
context?.log?.("[chronological-sorter] timestamps hook threw", error);
|
||||
}
|
||||
}
|
||||
if (h3) {
|
||||
try {
|
||||
const value = h3(video, { entry, context });
|
||||
if (Number.isFinite(value)) return Math.floor(value);
|
||||
} catch (error) {
|
||||
context?.log?.("[chronological-sorter] timestamps hook threw", error);
|
||||
}
|
||||
}
|
||||
if (h4) {
|
||||
try {
|
||||
const value = h4(video, { entry, context });
|
||||
if (Number.isFinite(value)) return Math.floor(value);
|
||||
} catch (error) {
|
||||
context?.log?.("[chronological-sorter] timestamps hook threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -341,6 +341,14 @@ function filterByTagPreferences({
|
||||
enforceInterests,
|
||||
}) {
|
||||
const tagPreferences = context?.runtime?.tagPreferences;
|
||||
if (tagPreferences?.available === false) {
|
||||
context?.addWhy?.({
|
||||
stage: stageName,
|
||||
type: "status",
|
||||
reason: "preferences-unavailable",
|
||||
});
|
||||
return items;
|
||||
}
|
||||
const interests = normalizeTagSet(tagPreferences?.interests);
|
||||
const disinterests = normalizeTagSet(tagPreferences?.disinterests);
|
||||
if (!interests.size && !disinterests.size) {
|
||||
|
||||
+32
-16
@@ -11,7 +11,7 @@ import {
|
||||
WATCH_HISTORY_BATCH_PAGE_SIZE
|
||||
} from "./config.js";
|
||||
import { getApplication } from "./applicationContext.js";
|
||||
import { userLogger } from "./utils/logger.js";
|
||||
import { devLogger, userLogger } from "./utils/logger.js";
|
||||
import {
|
||||
normalizeVideoModerationContext,
|
||||
applyModerationContextDatasets,
|
||||
@@ -1391,6 +1391,7 @@ export function buildHistoryCard({ item, video, profile, variant }) {
|
||||
}
|
||||
|
||||
export function createWatchHistoryRenderer(config = {}) {
|
||||
let hasLoggedFeedRegistrationFallback = false;
|
||||
const {
|
||||
container = typeof document !== "undefined" ? document : null,
|
||||
fetchHistory = async (
|
||||
@@ -1400,25 +1401,40 @@ export function createWatchHistoryRenderer(config = {}) {
|
||||
const app = getAppInstance();
|
||||
const engine = app?.feedEngine;
|
||||
if (engine && typeof engine.run === "function") {
|
||||
let hasFeedDefinition =
|
||||
typeof engine.getFeedDefinition === "function"
|
||||
? Boolean(engine.getFeedDefinition("watch-history"))
|
||||
: true;
|
||||
// Ensure the watch-history feed is registered. It may not be if the
|
||||
// feed engine was ready but registration was silently skipped during
|
||||
// bootstrap (e.g. timing edge cases).
|
||||
if (
|
||||
typeof engine.getFeedDefinition === "function" &&
|
||||
!engine.getFeedDefinition("watch-history") &&
|
||||
typeof app.registerWatchHistoryFeed === "function"
|
||||
) {
|
||||
app.registerWatchHistoryFeed();
|
||||
if (!hasFeedDefinition && typeof app.registerWatchHistoryFeed === "function") {
|
||||
try {
|
||||
app.registerWatchHistoryFeed();
|
||||
} catch (registerError) {
|
||||
// Fall through to service fallback if registration fails.
|
||||
}
|
||||
hasFeedDefinition =
|
||||
typeof engine.getFeedDefinition === "function"
|
||||
? Boolean(engine.getFeedDefinition("watch-history"))
|
||||
: hasFeedDefinition;
|
||||
}
|
||||
try {
|
||||
const runtime = buildWatchHistoryFeedRuntime({
|
||||
actor: actorInput,
|
||||
cursor,
|
||||
forceRefresh,
|
||||
});
|
||||
return engine.run("watch-history", { runtime });
|
||||
} catch (feedError) {
|
||||
// Fall through to service fallback if feed is still not registered
|
||||
if (hasFeedDefinition) {
|
||||
try {
|
||||
const runtime = buildWatchHistoryFeedRuntime({
|
||||
actor: actorInput,
|
||||
cursor,
|
||||
forceRefresh,
|
||||
});
|
||||
return await engine.run("watch-history", { runtime });
|
||||
} catch (feedError) {
|
||||
// Fall through to service fallback if feed is still not registered
|
||||
}
|
||||
} else if (!hasLoggedFeedRegistrationFallback) {
|
||||
hasLoggedFeedRegistrationFallback = true;
|
||||
devLogger.info(
|
||||
"[historyView] watch-history feed unavailable after registration attempt; using service fallback.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const items = await watchHistoryService.loadLatest(actorInput, {
|
||||
|
||||
+52
-442
@@ -40,6 +40,7 @@ import {
|
||||
deriveStoragePointerFromUrl,
|
||||
getStoragePointerFromTags,
|
||||
normalizeStoragePointer,
|
||||
resolveStoragePointerValue,
|
||||
} from "../utils/storagePointer.js";
|
||||
import {
|
||||
createWatchHistoryManager,
|
||||
@@ -67,6 +68,9 @@ import {
|
||||
buildDmAttachmentEvent,
|
||||
buildDeletionEvent,
|
||||
buildLegacyDirectMessageEvent,
|
||||
buildGiftWrapEvent,
|
||||
buildSealEvent,
|
||||
buildChatMessageEvent,
|
||||
getNostrEventSchema,
|
||||
NOTE_TYPES,
|
||||
} from "../nostrEventSchemas.js";
|
||||
@@ -108,6 +112,7 @@ import {
|
||||
repostEvent as repostEventHelper,
|
||||
mirrorVideoEvent as mirrorVideoEventHelper,
|
||||
rebroadcastEvent as rebroadcastEventHelper,
|
||||
buildRevertVideoPayload,
|
||||
summarizePublishResults,
|
||||
} from "./publishHelpers.js";
|
||||
import {
|
||||
@@ -130,6 +135,7 @@ import {
|
||||
summarizeDmEventForLog,
|
||||
} from "./dmDecryptDiagnostics.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import { withRequestTimeout } from "../utils/asyncUtils.js";
|
||||
import { LRUCache } from "../utils/lruCache.js";
|
||||
import { updateConversationFromMessage, writeMessages } from "../storage/dmDb.js";
|
||||
import {
|
||||
@@ -298,55 +304,6 @@ export const __testExports = {
|
||||
attemptDecryptNip46HandshakePayload,
|
||||
};
|
||||
|
||||
function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") {
|
||||
const resolvedTimeout = Number(timeoutMs);
|
||||
const effectiveTimeout =
|
||||
Number.isFinite(resolvedTimeout) && resolvedTimeout > 0
|
||||
? Math.floor(resolvedTimeout)
|
||||
: 4000;
|
||||
|
||||
let timeoutId = null;
|
||||
let settled = false;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (typeof onTimeout === "function") {
|
||||
try {
|
||||
onTimeout();
|
||||
} catch (cleanupError) {
|
||||
logCountTimeoutCleanupFailure(cleanupError);
|
||||
}
|
||||
}
|
||||
reject(new Error(message));
|
||||
}, effectiveTimeout);
|
||||
|
||||
Promise.resolve(promise)
|
||||
.then((value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function extractVideoPublishPayload(rawPayload) {
|
||||
let videoData = rawPayload;
|
||||
@@ -394,40 +351,6 @@ function normalizeHexHash(candidate) {
|
||||
return HEX64_REGEX.test(trimmed) ? trimmed : "";
|
||||
}
|
||||
|
||||
function resolveStoragePointerValue({
|
||||
storagePointer,
|
||||
url,
|
||||
infoHash,
|
||||
fallbackId,
|
||||
provider,
|
||||
} = {}) {
|
||||
const normalized = normalizeStoragePointer(storagePointer);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const derivedFromUrl = deriveStoragePointerFromUrl(url, provider || "url");
|
||||
if (derivedFromUrl) {
|
||||
return derivedFromUrl;
|
||||
}
|
||||
|
||||
if (typeof infoHash === "string" && infoHash.trim()) {
|
||||
return buildStoragePointerValue({
|
||||
provider: "btih",
|
||||
prefix: infoHash.trim().toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof fallbackId === "string" && fallbackId.trim()) {
|
||||
return buildStoragePointerValue({
|
||||
provider: "nostr",
|
||||
prefix: fallbackId.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
const BlobConstructor = typeof Blob !== "undefined" ? Blob : null;
|
||||
let sharedTextEncoder = null;
|
||||
|
||||
@@ -1029,225 +952,15 @@ export class NostrClient {
|
||||
since,
|
||||
timeoutMs = 10000,
|
||||
} = {}) {
|
||||
// Explanation:
|
||||
// Optimizes network usage by only fetching "new" events from each relay.
|
||||
// It uses `SyncMetadataStore` to track the `created_at` of the last seen event per relay.
|
||||
// - If we have a `lastSeen` timestamp, we request `since: lastSeen + 1`.
|
||||
// - If that returns nothing (or fails), we might fall back to a full fetch depending on context.
|
||||
// - Updates the `lastSeen` timestamp upon success.
|
||||
if (!kind || !pubkey) {
|
||||
throw new Error("fetchListIncrementally requires kind and pubkey");
|
||||
}
|
||||
|
||||
const normalizedPubkey = normalizeNostrPubkey(pubkey);
|
||||
if (!normalizedPubkey) {
|
||||
throw new Error("Invalid pubkey for fetchListIncrementally");
|
||||
}
|
||||
|
||||
const relaysToUse = Array.isArray(relayUrls) && relayUrls.length
|
||||
? relayUrls
|
||||
: this.relays;
|
||||
|
||||
const healthyCandidates = this.getHealthyRelays(relaysToUse);
|
||||
let relayCandidates = healthyCandidates;
|
||||
if (!healthyCandidates.length && relaysToUse.length) {
|
||||
const sanitizedFallback = sanitizeRelayList(relaysToUse);
|
||||
const defaultFallback = sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
|
||||
relayCandidates = sanitizedFallback.length
|
||||
? sanitizedFallback
|
||||
: defaultFallback.length
|
||||
? defaultFallback
|
||||
: sanitizeRelayList(Array.from(RELAY_URLS));
|
||||
devLogger.warn(
|
||||
"[fetchListIncrementally] Healthy relays exhausted; using fallback relay list for one-off fetch.",
|
||||
{
|
||||
requested: relaysToUse,
|
||||
fallback: relayCandidates,
|
||||
},
|
||||
);
|
||||
}
|
||||
const readPreferences = new Set(Array.isArray(this.readRelays) ? this.readRelays : []);
|
||||
|
||||
// Sort relays: prefer user's read relays first
|
||||
const sortedRelays = relayCandidates.sort((a, b) => {
|
||||
const aPreferred = readPreferences.has(a);
|
||||
const bPreferred = readPreferences.has(b);
|
||||
if (aPreferred && !bPreferred) return -1;
|
||||
if (!aPreferred && bPreferred) return 1;
|
||||
return 0;
|
||||
return this.relayBatchFetcher.fetchListIncrementally({
|
||||
kind,
|
||||
pubkey,
|
||||
dTag,
|
||||
relayUrls,
|
||||
fetchFn,
|
||||
since,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
// Cap the number of relays to avoid excessive requests
|
||||
const cappedRelays = sortedRelays.slice(0, 8);
|
||||
const normalizedRelays = sanitizeRelayList(cappedRelays);
|
||||
|
||||
if (isDevMode) {
|
||||
devLogger.log("[fetchListIncrementally] Selected relays:", {
|
||||
count: normalizedRelays.length,
|
||||
relays: normalizedRelays,
|
||||
});
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const concurrencyLimit = 8;
|
||||
|
||||
// We'll use the pool's list method if fetchFn isn't provided,
|
||||
// but we need to call it per-relay.
|
||||
const pool = await this.ensurePool();
|
||||
const actualFetchFn = typeof fetchFn === "function"
|
||||
? fetchFn
|
||||
: (r, f, timeout) => pool.list([r], [f], { timeout });
|
||||
|
||||
const chunks = [];
|
||||
for (let i = 0; i < normalizedRelays.length; i += concurrencyLimit) {
|
||||
chunks.push(normalizedRelays.slice(i, i + concurrencyLimit));
|
||||
}
|
||||
|
||||
let anySuccess = false;
|
||||
const failures = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const promises = chunk.map(async (relayUrl) => {
|
||||
let lastSeen = this.getSyncLastSeen(
|
||||
kind,
|
||||
normalizedPubkey,
|
||||
dTag,
|
||||
relayUrl,
|
||||
);
|
||||
|
||||
// If an explicit 'since' override is provided, use it instead of storage.
|
||||
// This allows services to anchor fetching to their own known state.
|
||||
if (since !== undefined) {
|
||||
const overrideSince = Number(since);
|
||||
lastSeen =
|
||||
Number.isFinite(overrideSince) && overrideSince >= 0
|
||||
? Math.floor(overrideSince)
|
||||
: 0;
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [kind],
|
||||
authors: [normalizedPubkey],
|
||||
};
|
||||
|
||||
if (dTag) {
|
||||
filter["#d"] = [dTag];
|
||||
}
|
||||
|
||||
// Strategy:
|
||||
// If we have a lastSeen, ask for since = lastSeen + 1.
|
||||
// If that fails or returns nothing, we don't necessarily fall back
|
||||
// unless there's an error.
|
||||
// Wait, requirements say:
|
||||
// "If metadata is missing for a relay → do a full fetch"
|
||||
// "If a relay returns an error ... fall back to full fetch"
|
||||
|
||||
let doFullFetch = true;
|
||||
if (lastSeen > 0) {
|
||||
filter.since = lastSeen + 1;
|
||||
doFullFetch = false;
|
||||
}
|
||||
|
||||
// Allow callers to override list fetch timeouts without changing non-list query behavior.
|
||||
const listTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 10000;
|
||||
|
||||
try {
|
||||
// Wrap fetch with a timeout to prevent hanging on slow relays
|
||||
let events = await withRequestTimeout(
|
||||
actualFetchFn(relayUrl, filter, listTimeoutMs),
|
||||
listTimeoutMs,
|
||||
null,
|
||||
`Fetch from ${relayUrl} timed out`
|
||||
);
|
||||
|
||||
// If incremental returned empty, we assume no updates (success).
|
||||
// But if we did a full fetch (no since), empty means empty.
|
||||
|
||||
if (!events || !Array.isArray(events)) {
|
||||
events = [];
|
||||
}
|
||||
|
||||
// On success, update lastSeen with max created_at
|
||||
let maxCreated = 0;
|
||||
for (const ev of events) {
|
||||
if (ev.created_at > maxCreated) {
|
||||
maxCreated = ev.created_at;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxCreated > 0) {
|
||||
// Only update if we found something newer or if we did a full fetch
|
||||
// Actually, if we did incremental and found new stuff, maxCreated > lastSeen.
|
||||
// If we did full fetch, maxCreated is the latest.
|
||||
this.updateSyncLastSeen(kind, normalizedPubkey, dTag, relayUrl, maxCreated);
|
||||
}
|
||||
|
||||
return { events, ok: true };
|
||||
} catch (error) {
|
||||
// Fallback to full fetch if incremental failed or if relay error
|
||||
if (!doFullFetch) {
|
||||
try {
|
||||
delete filter.since;
|
||||
const events = await withRequestTimeout(
|
||||
actualFetchFn(relayUrl, filter, listTimeoutMs),
|
||||
listTimeoutMs,
|
||||
null,
|
||||
`Full fetch fallback from ${relayUrl} timed out`
|
||||
);
|
||||
// Update lastSeen on success of fallback
|
||||
let maxCreated = 0;
|
||||
if (Array.isArray(events)) {
|
||||
for (const ev of events) {
|
||||
if (ev.created_at > maxCreated) {
|
||||
maxCreated = ev.created_at;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxCreated > 0) {
|
||||
this.updateSyncLastSeen(kind, normalizedPubkey, dTag, relayUrl, maxCreated);
|
||||
}
|
||||
return { events: events || [], ok: true };
|
||||
} catch (fallbackError) {
|
||||
devLogger.warn(`[fetchListIncrementally] Full fetch fallback failed for ${relayUrl}`, fallbackError);
|
||||
return { error: fallbackError, ok: false };
|
||||
}
|
||||
} else {
|
||||
devLogger.warn(`[fetchListIncrementally] Fetch failed for ${relayUrl}`, error);
|
||||
this.markRelayUnreachable(relayUrl);
|
||||
return { error, ok: false };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const chunkResults = await Promise.all(promises);
|
||||
for (const res of chunkResults) {
|
||||
if (res.ok) {
|
||||
anySuccess = true;
|
||||
if (Array.isArray(res.events)) {
|
||||
results.push(...res.events);
|
||||
}
|
||||
} else {
|
||||
failures.push(res.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!anySuccess && normalizedRelays.length > 0) {
|
||||
const error = new Error("All relays failed to fetch list.");
|
||||
error.code = "fetch-failed";
|
||||
error.failures = failures;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Deduplicate by ID
|
||||
const uniqueEvents = new Map();
|
||||
for (const ev of results) {
|
||||
if (ev && ev.id) {
|
||||
uniqueEvents.set(ev.id, ev);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniqueEvents.values());
|
||||
}
|
||||
|
||||
applyRelayPreferences(preferences = {}) {
|
||||
@@ -2321,13 +2034,12 @@ export class NostrClient {
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (trimmedMessage) {
|
||||
rumorEvents.push({
|
||||
kind: 14,
|
||||
created_at: createdAt,
|
||||
tags: [["p", targetHex]],
|
||||
content: trimmedMessage,
|
||||
rumorEvents.push(buildChatMessageEvent({
|
||||
pubkey: actorHex,
|
||||
});
|
||||
created_at: createdAt,
|
||||
recipientPubkey: targetHex,
|
||||
content: trimmedMessage,
|
||||
}));
|
||||
}
|
||||
|
||||
if (hasAttachments) {
|
||||
@@ -2366,16 +2078,14 @@ export class NostrClient {
|
||||
relayHint,
|
||||
rumorEvent,
|
||||
) => {
|
||||
const sealPayload = {
|
||||
kind: 13,
|
||||
const sealPayload = buildSealEvent({
|
||||
pubkey: actorHex,
|
||||
created_at: randomPastTimestamp(),
|
||||
tags: [],
|
||||
content: await signer.nip44Encrypt(
|
||||
ciphertext: await signer.nip44Encrypt(
|
||||
recipientPubkey,
|
||||
JSON.stringify(rumorEvent),
|
||||
),
|
||||
pubkey: actorHex,
|
||||
};
|
||||
});
|
||||
|
||||
const signedSeal = await signingAdapter.signEvent(sealPayload);
|
||||
if (!signedSeal || typeof signedSeal.id !== "string") {
|
||||
@@ -2398,17 +2108,14 @@ export class NostrClient {
|
||||
recipientPubkey,
|
||||
JSON.stringify(signedSeal),
|
||||
);
|
||||
const wrapTags = relayHint
|
||||
? [["p", recipientPubkey, relayHint]]
|
||||
: [["p", recipientPubkey]];
|
||||
|
||||
const wrapEvent = {
|
||||
kind: 1059,
|
||||
created_at: randomPastTimestamp(),
|
||||
tags: wrapTags,
|
||||
content: wrapCiphertext,
|
||||
const wrapEvent = buildGiftWrapEvent({
|
||||
pubkey: wrapperKeys.pubkey,
|
||||
};
|
||||
created_at: randomPastTimestamp(),
|
||||
recipientPubkey,
|
||||
relayHint,
|
||||
ciphertext: wrapCiphertext,
|
||||
});
|
||||
|
||||
return {
|
||||
wrap: signEventWithPrivateKey(wrapEvent, wrapperKeys.privateKey),
|
||||
@@ -3573,52 +3280,12 @@ export class NostrClient {
|
||||
const stableDTag =
|
||||
existingD || baseEvent?.id || originalEvent?.id || null;
|
||||
|
||||
let oldContent = {};
|
||||
try {
|
||||
oldContent = JSON.parse(baseEvent.content || "{}");
|
||||
} catch (err) {
|
||||
devLogger.warn("[nostr] Failed to parse baseEvent.content while reverting:", err);
|
||||
oldContent = {};
|
||||
}
|
||||
const oldVersion = oldContent.version ?? 1;
|
||||
|
||||
const finalRootId =
|
||||
oldContent.videoRootId ||
|
||||
(existingD
|
||||
? `LEGACY:${baseEvent.pubkey}:${existingD}`
|
||||
: stableDTag || baseEvent?.id || originalEvent?.id || "");
|
||||
|
||||
const oldIsNsfw = oldContent.isNsfw === true;
|
||||
const oldIsForKids = oldContent.isForKids === true && !oldIsNsfw;
|
||||
|
||||
const contentObject = {
|
||||
videoRootId: finalRootId,
|
||||
version: oldVersion,
|
||||
deleted: true,
|
||||
isPrivate: oldContent.isPrivate ?? false,
|
||||
isNsfw: oldIsNsfw,
|
||||
isForKids: oldIsForKids,
|
||||
title: oldContent.title || "",
|
||||
url: "",
|
||||
magnet: "",
|
||||
thumbnail: "",
|
||||
description: "This version was reverted by the creator.",
|
||||
mode: oldContent.mode || "live",
|
||||
};
|
||||
|
||||
const storagePointer = resolveStoragePointerValue({
|
||||
storagePointer: getStoragePointerFromTags(baseEvent.tags),
|
||||
url: oldContent.url,
|
||||
infoHash: oldContent.infoHash,
|
||||
fallbackId: finalRootId,
|
||||
});
|
||||
const additionalTags = storagePointer ? [["s", storagePointer]] : [];
|
||||
const event = buildVideoPostEvent({
|
||||
const event = buildRevertVideoPayload({
|
||||
baseEvent,
|
||||
originalEventId: originalEvent?.id,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
dTagValue: stableDTag,
|
||||
content: contentObject,
|
||||
additionalTags,
|
||||
existingD,
|
||||
stableDTag,
|
||||
});
|
||||
|
||||
await this.ensureActiveSignerForPubkey(pubkey);
|
||||
@@ -4412,85 +4079,28 @@ export class NostrClient {
|
||||
* @deprecated Use `subscribeVideos` for the main feed to support buffering and streaming.
|
||||
*/
|
||||
async fetchVideos(options = {}) {
|
||||
const requestedLimit = Number(options?.limit);
|
||||
const resolvedLimit = this.clampVideoRequestLimit(requestedLimit, DEFAULT_VIDEO_REQUEST_LIMIT);
|
||||
|
||||
const filter = {
|
||||
kinds: [30078],
|
||||
"#t": ["video"],
|
||||
limit: resolvedLimit,
|
||||
since: 0,
|
||||
};
|
||||
|
||||
const localAll = new Map();
|
||||
// NEW: track invalid
|
||||
const invalidNotes = [];
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
this.getHealthyRelays(this.relays).map(async (url) => {
|
||||
const events = await this.pool.list([url], [filter]);
|
||||
for (const evt of events) {
|
||||
if (evt && evt.id) {
|
||||
this.rawEvents.set(evt.id, evt);
|
||||
}
|
||||
const vid = convertEventToVideo(evt);
|
||||
if (vid.invalid) {
|
||||
// Accumulate if invalid
|
||||
invalidNotes.push({ id: vid.id, reason: vid.reason });
|
||||
} else {
|
||||
// Only add if good
|
||||
this.applyRootCreatedAt(vid);
|
||||
const activeKey = getActiveKey(vid);
|
||||
if (vid.deleted) {
|
||||
this.recordTombstone(activeKey, vid.created_at);
|
||||
} else {
|
||||
this.applyTombstoneGuard(vid);
|
||||
}
|
||||
localAll.set(evt.id, vid);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Merge into allEvents
|
||||
for (const [id, vid] of localAll.entries()) {
|
||||
this.allEvents.set(id, vid);
|
||||
this.dirtyEventIds.add(id);
|
||||
this.applyRootCreatedAt(vid);
|
||||
return new Promise((resolve) => {
|
||||
// Default to since: 0 to force full fetch if not specified
|
||||
const effectiveOptions = { ...options };
|
||||
if (effectiveOptions.since === undefined) {
|
||||
effectiveOptions.since = 0;
|
||||
}
|
||||
|
||||
// Rebuild activeMap
|
||||
this.activeMap.clear();
|
||||
for (const [id, video] of this.allEvents.entries()) {
|
||||
if (video.deleted) continue;
|
||||
const activeKey = getActiveKey(video);
|
||||
const existing = this.activeMap.get(activeKey);
|
||||
const sub = this.subscribeVideos(() => {}, effectiveOptions);
|
||||
|
||||
if (!existing || video.created_at > existing.created_at) {
|
||||
this.activeMap.set(activeKey, video);
|
||||
this.applyRootCreatedAt(video);
|
||||
sub.on("eose", async () => {
|
||||
sub.unsub();
|
||||
try {
|
||||
const activeVideos = this.getActiveVideos();
|
||||
await this.populateNip71MetadataForVideos(activeVideos);
|
||||
activeVideos.forEach((video) => this.applyRootCreatedAt(video));
|
||||
resolve(activeVideos);
|
||||
} catch (err) {
|
||||
userLogger.error("fetchVideos error:", err);
|
||||
resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIONAL: Log invalid stats
|
||||
if (invalidNotes.length > 0 && isDevMode) {
|
||||
userLogger.warn(
|
||||
`Skipped ${invalidNotes.length} invalid video notes:\n`,
|
||||
invalidNotes.map((n) => `${n.id.slice(0, 8)}.. => ${n.reason}`)
|
||||
);
|
||||
}
|
||||
|
||||
const activeVideos = Array.from(this.activeMap.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
await this.populateNip71MetadataForVideos(activeVideos);
|
||||
activeVideos.forEach((video) => this.applyRootCreatedAt(video));
|
||||
return activeVideos;
|
||||
} catch (err) {
|
||||
userLogger.error("fetchVideos error:", err);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,10 +5,15 @@ import {
|
||||
summarizePublishResults,
|
||||
} from "../nostrPublish.js";
|
||||
import {
|
||||
buildVideoPostEvent,
|
||||
buildRepostEvent,
|
||||
buildVideoMirrorEvent,
|
||||
sanitizeAdditionalTags,
|
||||
} from "../nostrEventSchemas.js";
|
||||
import {
|
||||
getStoragePointerFromTags,
|
||||
resolveStoragePointerValue,
|
||||
} from "../utils/storagePointer.js";
|
||||
import { normalizePointerInput } from "./watchHistory.js";
|
||||
import { sanitizeRelayList } from "./nip46Client.js";
|
||||
import {
|
||||
@@ -1332,4 +1337,64 @@ export async function rebroadcastEvent({ client, eventId, options = {} }) {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRevertVideoPayload({
|
||||
baseEvent,
|
||||
originalEventId,
|
||||
pubkey,
|
||||
existingD,
|
||||
stableDTag,
|
||||
}) {
|
||||
let oldContent = {};
|
||||
try {
|
||||
oldContent = JSON.parse(baseEvent.content || "{}");
|
||||
} catch (err) {
|
||||
devLogger.warn(
|
||||
"[nostr] Failed to parse baseEvent.content while reverting:",
|
||||
err,
|
||||
);
|
||||
oldContent = {};
|
||||
}
|
||||
const oldVersion = oldContent.version ?? 1;
|
||||
|
||||
const finalRootId =
|
||||
oldContent.videoRootId ||
|
||||
(existingD
|
||||
? `LEGACY:${baseEvent.pubkey}:${existingD}`
|
||||
: stableDTag || baseEvent.id || originalEventId || "");
|
||||
|
||||
const oldIsNsfw = oldContent.isNsfw === true;
|
||||
const oldIsForKids = oldContent.isForKids === true && !oldIsNsfw;
|
||||
|
||||
const contentObject = {
|
||||
videoRootId: finalRootId,
|
||||
version: oldVersion,
|
||||
deleted: true,
|
||||
isPrivate: oldContent.isPrivate ?? false,
|
||||
isNsfw: oldIsNsfw,
|
||||
isForKids: oldIsForKids,
|
||||
title: oldContent.title || "",
|
||||
url: "",
|
||||
magnet: "",
|
||||
thumbnail: "",
|
||||
description: "This version was reverted by the creator.",
|
||||
mode: oldContent.mode || "live",
|
||||
};
|
||||
|
||||
const storagePointer = resolveStoragePointerValue({
|
||||
storagePointer: getStoragePointerFromTags(baseEvent.tags),
|
||||
url: oldContent.url,
|
||||
infoHash: oldContent.infoHash,
|
||||
fallbackId: finalRootId,
|
||||
});
|
||||
const additionalTags = storagePointer ? [["s", storagePointer]] : [];
|
||||
|
||||
return buildVideoPostEvent({
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
dTagValue: stableDTag,
|
||||
content: contentObject,
|
||||
additionalTags,
|
||||
});
|
||||
}
|
||||
|
||||
export { summarizePublishResults };
|
||||
@@ -0,0 +1,235 @@
|
||||
import { isDevMode } from "../config.js";
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
import { withRequestTimeout } from "../utils/asyncUtils.js";
|
||||
import { normalizeNostrPubkey, sanitizeRelayList } from "./nip46Client.js";
|
||||
import { DEFAULT_RELAY_URLS, RELAY_URLS } from "./toolkit.js";
|
||||
|
||||
export class RelayBatchFetcher {
|
||||
constructor(client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async fetchListIncrementally({
|
||||
kind,
|
||||
pubkey,
|
||||
dTag,
|
||||
relayUrls,
|
||||
fetchFn,
|
||||
since,
|
||||
timeoutMs = 10000,
|
||||
} = {}) {
|
||||
if (!kind || !pubkey) {
|
||||
throw new Error("fetchListIncrementally requires kind and pubkey");
|
||||
}
|
||||
|
||||
const normalizedPubkey = normalizeNostrPubkey(pubkey);
|
||||
if (!normalizedPubkey) {
|
||||
throw new Error("Invalid pubkey for fetchListIncrementally");
|
||||
}
|
||||
|
||||
const relaysToUse = Array.isArray(relayUrls) && relayUrls.length
|
||||
? relayUrls
|
||||
: this.client.relays;
|
||||
|
||||
const healthyCandidates = this.client.getHealthyRelays(relaysToUse);
|
||||
let relayCandidates = healthyCandidates;
|
||||
if (!healthyCandidates.length && relaysToUse.length) {
|
||||
const sanitizedFallback = sanitizeRelayList(relaysToUse);
|
||||
const defaultFallback = sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
|
||||
relayCandidates = sanitizedFallback.length
|
||||
? sanitizedFallback
|
||||
: defaultFallback.length
|
||||
? defaultFallback
|
||||
: sanitizeRelayList(Array.from(RELAY_URLS));
|
||||
devLogger.warn(
|
||||
"[fetchListIncrementally] Healthy relays exhausted; using fallback relay list for one-off fetch.",
|
||||
{
|
||||
requested: relaysToUse,
|
||||
fallback: relayCandidates,
|
||||
},
|
||||
);
|
||||
}
|
||||
const readPreferences = new Set(Array.isArray(this.client.readRelays) ? this.client.readRelays : []);
|
||||
|
||||
// Sort relays: prefer user's read relays first
|
||||
const sortedRelays = relayCandidates.sort((a, b) => {
|
||||
const aPreferred = readPreferences.has(a);
|
||||
const bPreferred = readPreferences.has(b);
|
||||
if (aPreferred && !bPreferred) return -1;
|
||||
if (!aPreferred && bPreferred) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Cap the number of relays to avoid excessive requests
|
||||
const cappedRelays = sortedRelays.slice(0, 8);
|
||||
const normalizedRelays = sanitizeRelayList(cappedRelays);
|
||||
|
||||
if (isDevMode) {
|
||||
devLogger.log("[fetchListIncrementally] Selected relays:", {
|
||||
count: normalizedRelays.length,
|
||||
relays: normalizedRelays,
|
||||
});
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const concurrencyLimit = 8;
|
||||
|
||||
// We'll use the pool's list method if fetchFn isn't provided,
|
||||
// but we need to call it per-relay.
|
||||
const pool = await this.client.ensurePool();
|
||||
const actualFetchFn = typeof fetchFn === "function"
|
||||
? fetchFn
|
||||
: (r, f, timeout) => pool.list([r], [f], { timeout });
|
||||
|
||||
const chunks = [];
|
||||
for (let i = 0; i < normalizedRelays.length; i += concurrencyLimit) {
|
||||
chunks.push(normalizedRelays.slice(i, i + concurrencyLimit));
|
||||
}
|
||||
|
||||
let anySuccess = false;
|
||||
const failures = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const promises = chunk.map(async (relayUrl) => {
|
||||
let lastSeen = this.client.getSyncLastSeen(
|
||||
kind,
|
||||
normalizedPubkey,
|
||||
dTag,
|
||||
relayUrl,
|
||||
);
|
||||
|
||||
// If an explicit 'since' override is provided, use it instead of storage.
|
||||
// This allows services to anchor fetching to their own known state.
|
||||
if (since !== undefined) {
|
||||
const overrideSince = Number(since);
|
||||
lastSeen =
|
||||
Number.isFinite(overrideSince) && overrideSince >= 0
|
||||
? Math.floor(overrideSince)
|
||||
: 0;
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [kind],
|
||||
authors: [normalizedPubkey],
|
||||
};
|
||||
|
||||
if (dTag) {
|
||||
filter["#d"] = [dTag];
|
||||
}
|
||||
|
||||
// Strategy:
|
||||
// If we have a lastSeen, ask for since = lastSeen + 1.
|
||||
// If that fails or returns nothing, we don't necessarily fall back
|
||||
// unless there's an error.
|
||||
// Wait, requirements say:
|
||||
// "If metadata is missing for a relay → do a full fetch"
|
||||
// "If a relay returns an error ... fall back to full fetch"
|
||||
|
||||
let doFullFetch = true;
|
||||
if (lastSeen > 0) {
|
||||
filter.since = lastSeen + 1;
|
||||
doFullFetch = false;
|
||||
}
|
||||
|
||||
// Allow callers to override list fetch timeouts without changing non-list query behavior.
|
||||
const listTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 10000;
|
||||
|
||||
try {
|
||||
// Wrap fetch with a timeout to prevent hanging on slow relays
|
||||
let events = await withRequestTimeout(
|
||||
actualFetchFn(relayUrl, filter, listTimeoutMs),
|
||||
listTimeoutMs,
|
||||
null,
|
||||
`Fetch from ${relayUrl} timed out`
|
||||
);
|
||||
|
||||
// If incremental returned empty, we assume no updates (success).
|
||||
// But if we did a full fetch (no since), empty means empty.
|
||||
|
||||
if (!events || !Array.isArray(events)) {
|
||||
events = [];
|
||||
}
|
||||
|
||||
// On success, update lastSeen with max created_at
|
||||
let maxCreated = 0;
|
||||
for (const ev of events) {
|
||||
if (ev.created_at > maxCreated) {
|
||||
maxCreated = ev.created_at;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxCreated > 0) {
|
||||
// Only update if we found something newer or if we did a full fetch
|
||||
// Actually, if we did incremental and found new stuff, maxCreated > lastSeen.
|
||||
// If we did full fetch, maxCreated is the latest.
|
||||
this.client.updateSyncLastSeen(kind, normalizedPubkey, dTag, relayUrl, maxCreated);
|
||||
}
|
||||
|
||||
return { events, ok: true };
|
||||
} catch (error) {
|
||||
// Fallback to full fetch if incremental failed or if relay error
|
||||
if (!doFullFetch) {
|
||||
try {
|
||||
delete filter.since;
|
||||
const events = await withRequestTimeout(
|
||||
actualFetchFn(relayUrl, filter, listTimeoutMs),
|
||||
listTimeoutMs,
|
||||
null,
|
||||
`Full fetch fallback from ${relayUrl} timed out`
|
||||
);
|
||||
// Update lastSeen on success of fallback
|
||||
let maxCreated = 0;
|
||||
if (Array.isArray(events)) {
|
||||
for (const ev of events) {
|
||||
if (ev.created_at > maxCreated) {
|
||||
maxCreated = ev.created_at;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxCreated > 0) {
|
||||
this.client.updateSyncLastSeen(kind, normalizedPubkey, dTag, relayUrl, maxCreated);
|
||||
}
|
||||
return { events: events || [], ok: true };
|
||||
} catch (fallbackError) {
|
||||
devLogger.warn(`[fetchListIncrementally] Full fetch fallback failed for ${relayUrl}`, fallbackError);
|
||||
return { error: fallbackError, ok: false };
|
||||
}
|
||||
} else {
|
||||
devLogger.warn(`[fetchListIncrementally] Fetch failed for ${relayUrl}`, error);
|
||||
this.client.markRelayUnreachable(relayUrl);
|
||||
return { error, ok: false };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const chunkResults = await Promise.all(promises);
|
||||
for (const res of chunkResults) {
|
||||
if (res.ok) {
|
||||
anySuccess = true;
|
||||
if (Array.isArray(res.events)) {
|
||||
results.push(...res.events);
|
||||
}
|
||||
} else {
|
||||
failures.push(res.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!anySuccess && normalizedRelays.length > 0) {
|
||||
const error = new Error("All relays failed to fetch list.");
|
||||
error.code = "fetch-failed";
|
||||
error.failures = failures;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Deduplicate by ID
|
||||
const uniqueEvents = new Map();
|
||||
for (const ev of results) {
|
||||
if (ev && ev.id) {
|
||||
uniqueEvents.set(ev.id, ev);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniqueEvents.values());
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ export const NOTE_TYPES = Object.freeze({
|
||||
LEGACY_DM: "legacyDm",
|
||||
HTTP_AUTH: "httpAuth",
|
||||
REPORT: "report",
|
||||
GIFT_WRAP: "giftWrap",
|
||||
SEAL: "seal",
|
||||
CHAT_MESSAGE: "chatMessage",
|
||||
});
|
||||
|
||||
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
|
||||
@@ -665,6 +668,38 @@ const BASE_SCHEMAS = {
|
||||
description: "Report reason.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.GIFT_WRAP]: {
|
||||
type: NOTE_TYPES.GIFT_WRAP,
|
||||
label: "Gift Wrap",
|
||||
kind: 1059,
|
||||
recipientTagName: "p",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description: "NIP-44 encrypted seal.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.SEAL]: {
|
||||
type: NOTE_TYPES.SEAL,
|
||||
label: "Seal",
|
||||
kind: 13,
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description: "NIP-44 encrypted rumor.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.CHAT_MESSAGE]: {
|
||||
type: NOTE_TYPES.CHAT_MESSAGE,
|
||||
label: "Chat Message (Rumor)",
|
||||
kind: 14,
|
||||
participantTagName: "p",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description: "Plain text chat message.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let schemaOverrides = {};
|
||||
@@ -1097,6 +1132,116 @@ export function buildReportEvent(params) {
|
||||
return event;
|
||||
}
|
||||
|
||||
export function buildGiftWrapEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey,
|
||||
ciphertext = "",
|
||||
relayHint,
|
||||
additionalTags = [],
|
||||
} = params || {};
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.GIFT_WRAP);
|
||||
const tags = [];
|
||||
|
||||
const recipient = normalizePointerIdentifier(recipientPubkey);
|
||||
if (recipient) {
|
||||
const pTag = [schema?.recipientTagName || "p", recipient];
|
||||
if (typeof relayHint === "string" && relayHint.trim()) {
|
||||
pTag.push(relayHint.trim());
|
||||
}
|
||||
tags.push(pTag);
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||||
if (sanitizedAdditionalTags.length) {
|
||||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: schema?.kind ?? 1059,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof ciphertext === "string" ? ciphertext : "",
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
validateEventAgainstSchema(NOTE_TYPES.GIFT_WRAP, event);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
export function buildSealEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
ciphertext = "",
|
||||
additionalTags = [],
|
||||
} = params || {};
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.SEAL);
|
||||
const tags = [];
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||||
if (sanitizedAdditionalTags.length) {
|
||||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: schema?.kind ?? 13,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof ciphertext === "string" ? ciphertext : "",
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
validateEventAgainstSchema(NOTE_TYPES.SEAL, event);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
export function buildChatMessageEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey,
|
||||
content = "",
|
||||
additionalTags = [],
|
||||
} = params || {};
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.CHAT_MESSAGE);
|
||||
const tags = [];
|
||||
|
||||
const participant = normalizePointerIdentifier(recipientPubkey);
|
||||
if (participant) {
|
||||
tags.push([schema?.participantTagName || "p", participant]);
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||||
if (sanitizedAdditionalTags.length) {
|
||||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: schema?.kind ?? 14,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof content === "string" ? content : "",
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
validateEventAgainstSchema(NOTE_TYPES.CHAT_MESSAGE, event);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
export function buildVideoMirrorEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
|
||||
+90
-119
@@ -29,6 +29,11 @@ import getDefaultAuthProvider, {
|
||||
} from "./authProviders/index.js";
|
||||
import { fetchProfileMetadata } from "./profileMetadataService.js";
|
||||
import { HEX64_REGEX } from "../utils/hex.js";
|
||||
import {
|
||||
safeEncodeNpub,
|
||||
safeDecodeNpub,
|
||||
normalizeHexPubkey,
|
||||
} from "../utils/nostrHelpers.js";
|
||||
|
||||
class SimpleEventEmitter {
|
||||
constructor(logger = null) {
|
||||
@@ -267,70 +272,15 @@ export default class AuthService {
|
||||
}
|
||||
|
||||
safeEncodeNpub(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
return window?.NostrTools?.nip19?.npubEncode(trimmed) || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
return safeEncodeNpub(pubkey);
|
||||
}
|
||||
|
||||
safeDecodeNpub(npub) {
|
||||
if (typeof npub !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = npub.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
|
||||
if (decoded?.type === "npub" && typeof decoded.data === "string") {
|
||||
return decoded.data;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
return safeDecodeNpub(npub);
|
||||
}
|
||||
|
||||
normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
const decoded = this.safeDecodeNpub(trimmed);
|
||||
if (decoded && HEX64_REGEX.test(decoded)) {
|
||||
return decoded.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return normalizeHexPubkey(pubkey);
|
||||
}
|
||||
|
||||
async requestLogin(rawOptions = {}) {
|
||||
@@ -1030,8 +980,63 @@ export default class AuthService {
|
||||
return { switched: true, detail };
|
||||
}
|
||||
|
||||
async fetchProfileFromRelay(
|
||||
relayUrl,
|
||||
filter,
|
||||
normalizedPubkey,
|
||||
timeoutMs,
|
||||
requireEvent,
|
||||
) {
|
||||
const fetchPromise = this.nostrClient.pool.list([relayUrl], filter);
|
||||
let timeoutId;
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
const timeoutError = new Error(
|
||||
`Timed out fetching profile from ${relayUrl} after ${timeoutMs}ms`,
|
||||
);
|
||||
timeoutError.code = "timeout";
|
||||
timeoutError.relay = relayUrl;
|
||||
timeoutError.timeoutMs = timeoutMs;
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await Promise.race([fetchPromise, timeoutPromise]);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const events = Array.isArray(result)
|
||||
? result.filter((event) => event && event.pubkey === normalizedPubkey)
|
||||
: [];
|
||||
|
||||
if (requireEvent && !events.length) {
|
||||
const emptyError = new Error(
|
||||
`No profile events returned from ${relayUrl}`,
|
||||
);
|
||||
emptyError.code = "empty";
|
||||
emptyError.relay = relayUrl;
|
||||
throw emptyError;
|
||||
}
|
||||
|
||||
return { relayUrl, events };
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
// Suppress unhandled rejection from fetchPromise if it's still running
|
||||
fetchPromise.catch(() => {});
|
||||
|
||||
if (error.relay) {
|
||||
throw error;
|
||||
}
|
||||
const wrapped = error instanceof Error ? error : new Error(String(error));
|
||||
wrapped.relay = relayUrl;
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
async loadOwnProfile(pubkey) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey) ||
|
||||
const normalized =
|
||||
this.normalizeHexPubkey(pubkey) ||
|
||||
(typeof pubkey === "string" ? pubkey.trim() : "");
|
||||
if (!normalized) {
|
||||
return FALLBACK_PROFILE;
|
||||
@@ -1074,13 +1079,20 @@ export default class AuthService {
|
||||
return {
|
||||
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,
|
||||
about:
|
||||
typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
|
||||
website:
|
||||
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.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,
|
||||
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,
|
||||
};
|
||||
} catch (error) {
|
||||
this.log("[AuthService] Failed to parse profile metadata", error);
|
||||
@@ -1088,67 +1100,26 @@ export default class AuthService {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
const timeoutError = new Error(
|
||||
`Timed out fetching profile from ${relayUrl} after ${timeoutMs}ms`
|
||||
);
|
||||
timeoutError.code = "timeout";
|
||||
timeoutError.relay = relayUrl;
|
||||
timeoutError.timeoutMs = timeoutMs;
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => this.nostrClient.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)
|
||||
: [];
|
||||
if (requireEvent && !events.length) {
|
||||
const emptyError = new Error(
|
||||
`No profile 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 fastRelays = relays.slice(0, FAST_PROFILE_RELAY_LIMIT);
|
||||
const backgroundRelays = relays.slice(fastRelays.length);
|
||||
|
||||
const fastPromises = fastRelays.map((relayUrl) =>
|
||||
fetchFromRelay(relayUrl, FAST_PROFILE_TIMEOUT_MS, true)
|
||||
this.fetchProfileFromRelay(
|
||||
relayUrl,
|
||||
filter,
|
||||
normalized,
|
||||
FAST_PROFILE_TIMEOUT_MS,
|
||||
true,
|
||||
),
|
||||
);
|
||||
const backgroundPromises = backgroundRelays.map((relayUrl) =>
|
||||
fetchFromRelay(relayUrl, BACKGROUND_PROFILE_TIMEOUT_MS, false)
|
||||
this.fetchProfileFromRelay(
|
||||
relayUrl,
|
||||
filter,
|
||||
normalized,
|
||||
BACKGROUND_PROFILE_TIMEOUT_MS,
|
||||
false,
|
||||
),
|
||||
);
|
||||
|
||||
const background = Promise.allSettled([
|
||||
|
||||
@@ -249,7 +249,12 @@ class HashtagPreferencesService {
|
||||
this.activePubkey = null;
|
||||
this.eventId = null;
|
||||
this.eventCreatedAt = null;
|
||||
this.uiReady = false;
|
||||
this.dataReady = false;
|
||||
this.loaded = false;
|
||||
this.loadedFromCache = false;
|
||||
this.lastSuccessfulSyncAt = null;
|
||||
this.lastAttemptAt = null;
|
||||
this.backgroundLoading = false;
|
||||
this.preferencesVersion = DEFAULT_VERSION;
|
||||
this.decryptRetryTimeoutId = null;
|
||||
@@ -283,7 +288,12 @@ class HashtagPreferencesService {
|
||||
this.disinterests = new Set();
|
||||
this.eventId = null;
|
||||
this.eventCreatedAt = null;
|
||||
this.uiReady = false;
|
||||
this.dataReady = false;
|
||||
this.loaded = false;
|
||||
this.loadedFromCache = false;
|
||||
this.lastSuccessfulSyncAt = null;
|
||||
this.lastAttemptAt = null;
|
||||
this.backgroundLoading = false;
|
||||
this.preferencesVersion = DEFAULT_VERSION;
|
||||
this.lastLoadError = null;
|
||||
@@ -390,7 +400,10 @@ class HashtagPreferencesService {
|
||||
this.eventId = cached.eventId || null;
|
||||
this.eventCreatedAt = cached.createdAt || null;
|
||||
this.preferencesVersion = cached.version || DEFAULT_VERSION;
|
||||
this.uiReady = true;
|
||||
this.dataReady = true;
|
||||
this.loaded = true;
|
||||
this.loadedFromCache = true;
|
||||
this.emitChange("cache-load");
|
||||
return true;
|
||||
}
|
||||
@@ -548,7 +561,12 @@ class HashtagPreferencesService {
|
||||
this.loadingPubkey = normalized;
|
||||
|
||||
try {
|
||||
return await loadPromise;
|
||||
await loadPromise;
|
||||
return {
|
||||
ok: this.dataReady === true,
|
||||
error: this.lastLoadError || null,
|
||||
state: this.getLoadState(),
|
||||
};
|
||||
} finally {
|
||||
if (this.loadPromise === loadPromise) {
|
||||
this.loadPromise = null;
|
||||
@@ -560,6 +578,15 @@ class HashtagPreferencesService {
|
||||
async loadInternal(pubkey, options = {}) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
|
||||
const signerReadinessGate =
|
||||
options?.signerReadinessGate && typeof options.signerReadinessGate === "object"
|
||||
? options.signerReadinessGate
|
||||
: null;
|
||||
const shouldSuppressPermissionEscalation =
|
||||
signerReadinessGate &&
|
||||
(signerReadinessGate.status === "permission-denied" ||
|
||||
signerReadinessGate.status === "extension-unavailable");
|
||||
this.lastAttemptAt = Date.now();
|
||||
this.lastLoadError = null;
|
||||
let wasLoadedForUser =
|
||||
this.activePubkey &&
|
||||
@@ -573,6 +600,8 @@ class HashtagPreferencesService {
|
||||
|
||||
if (!normalized) {
|
||||
this.reset();
|
||||
this.uiReady = true;
|
||||
this.dataReady = true;
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
@@ -606,8 +635,7 @@ class HashtagPreferencesService {
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
this.uiReady = true;
|
||||
if (wasBackgroundLoading) {
|
||||
this.backgroundLoading = false;
|
||||
this.emitChange("background-loaded", { background: false });
|
||||
@@ -628,8 +656,7 @@ class HashtagPreferencesService {
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
this.uiReady = true;
|
||||
if (wasBackgroundLoading) {
|
||||
this.backgroundLoading = false;
|
||||
this.emitChange("background-loaded", { background: false });
|
||||
@@ -720,8 +747,16 @@ class HashtagPreferencesService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reset();
|
||||
this.interests = new Set();
|
||||
this.disinterests = new Set();
|
||||
this.eventId = null;
|
||||
this.eventCreatedAt = null;
|
||||
this.preferencesVersion = DEFAULT_VERSION;
|
||||
this.uiReady = true;
|
||||
this.dataReady = true;
|
||||
this.loaded = true;
|
||||
this.loadedFromCache = false;
|
||||
this.lastSuccessfulSyncAt = Date.now();
|
||||
if (wasBackgroundLoading) {
|
||||
this.backgroundLoading = false;
|
||||
this.emitChange("background-loaded", { background: false });
|
||||
@@ -764,8 +799,16 @@ class HashtagPreferencesService {
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.reset();
|
||||
this.interests = new Set();
|
||||
this.disinterests = new Set();
|
||||
this.eventId = null;
|
||||
this.eventCreatedAt = null;
|
||||
this.preferencesVersion = DEFAULT_VERSION;
|
||||
this.uiReady = true;
|
||||
this.dataReady = true;
|
||||
this.loaded = true;
|
||||
this.loadedFromCache = false;
|
||||
this.lastSuccessfulSyncAt = Date.now();
|
||||
if (wasBackgroundLoading) {
|
||||
this.backgroundLoading = false;
|
||||
this.emitChange("background-loaded", { background: false });
|
||||
@@ -793,6 +836,7 @@ class HashtagPreferencesService {
|
||||
try {
|
||||
const decryptPromise = this.decryptEvent(latest, normalized, {
|
||||
allowPermissionPrompt,
|
||||
signerReadinessGate,
|
||||
});
|
||||
let timeoutId;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
@@ -815,17 +859,16 @@ class HashtagPreferencesService {
|
||||
|
||||
if (!decryptResult.ok) {
|
||||
this.lastLoadError = decryptResult.error || null;
|
||||
this.uiReady = true;
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Failed to decrypt hashtag preferences`,
|
||||
decryptResult.error,
|
||||
);
|
||||
|
||||
if (decryptResult.error?.code === "hashtag-preferences-decrypt-timeout") {
|
||||
if (!wasLoadedForUser && !this.loaded) {
|
||||
this.loaded = true;
|
||||
}
|
||||
this.scheduleDecryptRetry(normalized, decryptResult.error, {
|
||||
allowPermissionPrompt,
|
||||
signerReadinessGate,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -833,11 +876,20 @@ class HashtagPreferencesService {
|
||||
!allowPermissionPrompt &&
|
||||
decryptResult.error?.code === "hashtag-preferences-permission-required"
|
||||
) {
|
||||
if (!wasLoadedForUser && !this.loaded) {
|
||||
this.loaded = true;
|
||||
if (shouldSuppressPermissionEscalation) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Skipping permission escalation due to shared signer gate outcome.`,
|
||||
signerReadinessGate,
|
||||
);
|
||||
if (wasBackgroundLoading) {
|
||||
this.backgroundLoading = false;
|
||||
this.emitChange("background-loaded", { background: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.scheduleDecryptRetry(normalized, decryptResult.error, {
|
||||
allowPermissionPrompt: true,
|
||||
signerReadinessGate,
|
||||
});
|
||||
if (wasBackgroundLoading) {
|
||||
this.backgroundLoading = false;
|
||||
@@ -859,8 +911,7 @@ class HashtagPreferencesService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
this.uiReady = true;
|
||||
if (wasBackgroundLoading) {
|
||||
this.backgroundLoading = false;
|
||||
this.emitChange("background-loaded", { background: false });
|
||||
@@ -881,7 +932,11 @@ class HashtagPreferencesService {
|
||||
this.eventCreatedAt = Number.isFinite(latest?.created_at)
|
||||
? latest.created_at
|
||||
: null;
|
||||
this.uiReady = true;
|
||||
this.dataReady = true;
|
||||
this.loaded = true;
|
||||
this.loadedFromCache = false;
|
||||
this.lastSuccessfulSyncAt = Date.now();
|
||||
if (this.backgroundLoading) {
|
||||
this.backgroundLoading = false;
|
||||
}
|
||||
@@ -892,11 +947,25 @@ class HashtagPreferencesService {
|
||||
`${LOG_PREFIX} Failed to parse decrypted hashtag preferences`,
|
||||
error,
|
||||
);
|
||||
this.reset();
|
||||
this.loaded = true;
|
||||
this.uiReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
getLoadState() {
|
||||
return {
|
||||
uiReady: this.uiReady === true,
|
||||
dataReady: this.dataReady === true,
|
||||
lastLoadError: this.lastLoadError || null,
|
||||
loadedFromCache: this.loadedFromCache === true,
|
||||
lastSuccessfulSyncAt: Number.isFinite(this.lastSuccessfulSyncAt)
|
||||
? Number(this.lastSuccessfulSyncAt)
|
||||
: null,
|
||||
lastAttemptAt: Number.isFinite(this.lastAttemptAt)
|
||||
? Number(this.lastAttemptAt)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async decryptEvent(event, userPubkey, options = {}) {
|
||||
const ciphertext = typeof event?.content === "string" ? event.content : "";
|
||||
if (!ciphertext) {
|
||||
@@ -906,6 +975,14 @@ class HashtagPreferencesService {
|
||||
}
|
||||
|
||||
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
|
||||
const signerReadinessGate =
|
||||
options?.signerReadinessGate && typeof options.signerReadinessGate === "object"
|
||||
? options.signerReadinessGate
|
||||
: null;
|
||||
const suppressPermissionPrompt =
|
||||
signerReadinessGate &&
|
||||
(signerReadinessGate.status === "permission-denied" ||
|
||||
signerReadinessGate.status === "extension-unavailable");
|
||||
let signer = getActiveSigner();
|
||||
if (
|
||||
!signer &&
|
||||
@@ -932,6 +1009,13 @@ class HashtagPreferencesService {
|
||||
error.code = "hashtag-preferences-permission-required";
|
||||
return { ok: false, error };
|
||||
}
|
||||
if (suppressPermissionPrompt) {
|
||||
const error = new Error(
|
||||
"Shared signer gate indicates extension permissions are unavailable.",
|
||||
);
|
||||
error.code = "hashtag-preferences-permission-required";
|
||||
return { ok: false, error };
|
||||
}
|
||||
try {
|
||||
const permissionResult = await requestDefaultExtensionPermissions(
|
||||
DEFAULT_NIP07_ENCRYPTION_METHODS,
|
||||
|
||||
@@ -9,8 +9,22 @@ import { userBlocks, USER_BLOCK_EVENTS } from "../userBlocks.js";
|
||||
import { buildReportEvent } from "../nostrEventSchemas.js";
|
||||
import logger from "../utils/logger.js";
|
||||
|
||||
/**
|
||||
* Trust threshold for disabling autoplay on reported videos.
|
||||
* If >= 1 trusted user reports content, autoplay is disabled.
|
||||
*/
|
||||
const AUTOPLAY_TRUST_THRESHOLD = 1;
|
||||
|
||||
/**
|
||||
* Trust threshold for blurring content.
|
||||
* If >= 1 trusted user reports content, it is blurred.
|
||||
*/
|
||||
const BLUR_TRUST_THRESHOLD = 1;
|
||||
|
||||
/**
|
||||
* Time window for considering trusted mute list entries valid.
|
||||
* Mutes older than 60 days are ignored to prevent stale lists from affecting scores.
|
||||
*/
|
||||
const TRUSTED_MUTE_WINDOW_DAYS = 60;
|
||||
const TRUSTED_MUTE_WINDOW_SECONDS = TRUSTED_MUTE_WINDOW_DAYS * 24 * 60 * 60;
|
||||
|
||||
@@ -198,6 +212,12 @@ function bytesToHex(bytes) {
|
||||
return hex;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* Internal implementation of bech32 encoding/decoding.
|
||||
* This ensures the service can normalize `npub` inputs even if the global
|
||||
* `NostrTools` object is unavailable or version-mismatched.
|
||||
*/
|
||||
const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||
const BECH32_CHARSET_MAP = (() => {
|
||||
const map = new Map();
|
||||
@@ -207,6 +227,7 @@ const BECH32_CHARSET_MAP = (() => {
|
||||
return map;
|
||||
})();
|
||||
|
||||
/** @internal */
|
||||
function bech32Polymod(values) {
|
||||
let chk = 1;
|
||||
const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
||||
@@ -499,7 +520,30 @@ function resolveStorage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ModerationService
|
||||
*
|
||||
* Central authority for Trust & Safety.
|
||||
* Implements a "Web of Trust" model where content visibility and safety actions (blur, hide)
|
||||
* are determined by aggregating reports (NIP-56) and mutes (NIP-51) from the user's
|
||||
* trusted social graph (follows).
|
||||
*
|
||||
* Architecture:
|
||||
* - **Viewer Context**: Tracks the currently logged-in user and their Kind 3 Contact List.
|
||||
* - **Trust Graph**: Combines contacts + admin seeds + whitelists.
|
||||
* - **Report Aggregation**: Counts reports only from trusted users.
|
||||
* - **Thresholds**: Triggers actions (blur, block autoplay) when trusted report counts exceed limits.
|
||||
*/
|
||||
export class ModerationService {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} [options.nostrClient] - The Nostr client instance (dependency injection).
|
||||
* @param {object} [options.logger] - System logger.
|
||||
* @param {object} [options.userBlocks] - User block manager instance.
|
||||
* @param {object} [options.accessControl] - Admin whitelist/blacklist controller.
|
||||
* @param {object} [options.userLogger] - User-facing logger for notifications.
|
||||
* @param {function} [options.requestExtensionPermissions] - Helper to request NIP-07 permissions.
|
||||
*/
|
||||
constructor({
|
||||
nostrClient: client = null,
|
||||
logger: log = null,
|
||||
@@ -1700,6 +1744,14 @@ export class ModerationService {
|
||||
return this.setViewerPubkey(clientPubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the active "Viewer" context.
|
||||
* This reloads the trusted contact list (Kind 3) for the new user,
|
||||
* effectively rebuilding the "Web of Trust" graph.
|
||||
*
|
||||
* @param {string} pubkey - The public key of the new viewer.
|
||||
* @returns {Promise<string>} The normalized pubkey of the new viewer.
|
||||
*/
|
||||
async setViewerPubkey(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (normalized === this.viewerPubkey && this.contactListPromise) {
|
||||
@@ -2019,6 +2071,15 @@ export class ModerationService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingests a NIP-56 Report event (Kind 1984).
|
||||
*
|
||||
* The report is indexed by `targetEventId` -> `reporter` -> `reportType`.
|
||||
* Note: This method only stores the report. Trust filtering happens during
|
||||
* `recomputeSummaryForEvent`.
|
||||
*
|
||||
* @param {object} event - The Kind 1984 event.
|
||||
*/
|
||||
ingestReportEvent(event) {
|
||||
if (!event || event.kind !== 1984) {
|
||||
return;
|
||||
@@ -2071,6 +2132,17 @@ export class ModerationService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all reports for a specific event and calculates the "Trust Score".
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Iterate over all reports for the event.
|
||||
* 2. Check if the reporter is in the `trustedContacts` set (or Admin Whitelist).
|
||||
* 3. If trusted, increment the count for that report type (e.g., "nudity").
|
||||
* 4. Update the summary state and emit change events if thresholds are crossed.
|
||||
*
|
||||
* @param {string} eventId - The ID of the event to recompute.
|
||||
*/
|
||||
recomputeSummaryForEvent(eventId) {
|
||||
const normalized = normalizeEventId(eventId);
|
||||
if (!normalized) {
|
||||
@@ -2296,6 +2368,17 @@ export class ModerationService {
|
||||
return Number.isFinite(entry.trusted) ? entry.trusted : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a NIP-56 Report (Kind 1984).
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} params.eventId - The ID of the content being reported.
|
||||
* @param {string} params.type - The category (e.g., "nudity", "spam").
|
||||
* @param {string} params.targetPubkey - The pubkey of the reported content's author.
|
||||
* @param {string} [params.relayHint] - Optional relay URL hint.
|
||||
* @param {string} [params.content] - Optional text description of the report.
|
||||
* @returns {Promise<{ok: boolean, event: object, results: object[]}>}
|
||||
*/
|
||||
async submitReport({
|
||||
eventId,
|
||||
type,
|
||||
|
||||
@@ -153,6 +153,15 @@ function hexToBytes(hex) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function createStorageUnlockError(code, message, cause) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
if (cause) {
|
||||
error.cause = cause;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to manage encrypted connection storage using IndexedDB.
|
||||
* Connections are encrypted with a random Master Key (AES-GCM),
|
||||
@@ -336,6 +345,57 @@ export class StorageService {
|
||||
);
|
||||
}
|
||||
|
||||
_normalizeUnlockDecryptError(error) {
|
||||
if (error?.code === "storage-unlock-permission-denied") {
|
||||
return error;
|
||||
}
|
||||
if (error?.code === "storage-unlock-no-decryptor") {
|
||||
return error;
|
||||
}
|
||||
if (error?.code === "storage-unlock-decrypt-failed") {
|
||||
return error;
|
||||
}
|
||||
|
||||
const rawCode =
|
||||
typeof error?.code === "string" ? error.code.trim().toLowerCase() : "";
|
||||
const rawMessage =
|
||||
typeof error?.message === "string" ? error.message.toLowerCase() : "";
|
||||
|
||||
if (
|
||||
rawCode === "extension-permission-denied" ||
|
||||
rawCode === "extension-encryption-permission-denied" ||
|
||||
rawCode.includes("permission-denied") ||
|
||||
rawMessage.includes("permission denied") ||
|
||||
rawMessage.includes("user rejected")
|
||||
) {
|
||||
return createStorageUnlockError(
|
||||
"storage-unlock-permission-denied",
|
||||
"Signer permission denied while decrypting storage credentials.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
rawCode === "nip04-missing" ||
|
||||
rawCode === "nip44-missing" ||
|
||||
rawCode.includes("no-decrypt") ||
|
||||
rawMessage.includes("does not support") ||
|
||||
rawMessage.includes("failed to decrypt master key")
|
||||
) {
|
||||
return createStorageUnlockError(
|
||||
"storage-unlock-no-decryptor",
|
||||
"Signer does not expose a compatible decrypt method (NIP-04/NIP-44).",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return createStorageUnlockError(
|
||||
"storage-unlock-decrypt-failed",
|
||||
"Unable to decrypt the encrypted storage key.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a connection payload (secrets) using the session's Master Key.
|
||||
* Uses AES-GCM with a random 12-byte IV.
|
||||
@@ -404,7 +464,11 @@ export class StorageService {
|
||||
|
||||
if (account && account.encryptedMasterKey) {
|
||||
// Decrypt existing key
|
||||
masterKey = await this._decryptMasterKey(account.encryptedMasterKey, signer, pubkey);
|
||||
try {
|
||||
masterKey = await this._decryptMasterKey(account.encryptedMasterKey, signer, pubkey);
|
||||
} catch (error) {
|
||||
throw this._normalizeUnlockDecryptError(error);
|
||||
}
|
||||
} else {
|
||||
// Create new key
|
||||
masterKey = await this._generateMasterKey();
|
||||
|
||||
+51
-11
@@ -84,6 +84,9 @@ const MIGRATIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
let dbInstance = null;
|
||||
let dbPromise = null;
|
||||
|
||||
function isIndexedDbAvailable() {
|
||||
try {
|
||||
return typeof indexedDB !== "undefined";
|
||||
@@ -101,7 +104,15 @@ function applyMigrations(db, oldVersion, newVersion, transaction) {
|
||||
}
|
||||
|
||||
function openDmDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (dbInstance) {
|
||||
return Promise.resolve(dbInstance);
|
||||
}
|
||||
|
||||
if (dbPromise) {
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
if (!isIndexedDbAvailable()) {
|
||||
resolve(null);
|
||||
return;
|
||||
@@ -118,10 +129,39 @@ function openDmDb() {
|
||||
applyMigrations(db, oldVersion, newVersion, upgradeTransaction);
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () =>
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
dbInstance = db;
|
||||
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
dbInstance = null;
|
||||
dbPromise = null;
|
||||
};
|
||||
|
||||
db.onclose = () => {
|
||||
dbInstance = null;
|
||||
dbPromise = null;
|
||||
};
|
||||
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
dbPromise = null;
|
||||
reject(request.error || new Error("Failed to open DM database."));
|
||||
};
|
||||
});
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export async function closeDmDb() {
|
||||
if (dbInstance) {
|
||||
dbInstance.close();
|
||||
dbInstance = null;
|
||||
}
|
||||
dbPromise = null;
|
||||
}
|
||||
|
||||
function sanitizeTimestamp(value) {
|
||||
@@ -264,7 +304,7 @@ export async function writeConversationMetadata(conversation) {
|
||||
const tx = db.transaction(STORES.CONVERSATIONS, "readwrite");
|
||||
tx.objectStore(STORES.CONVERSATIONS).put(normalized);
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
// db.close(); // Keep connection open
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to write conversation metadata", error);
|
||||
@@ -296,7 +336,7 @@ export async function writeMessages(messages) {
|
||||
}
|
||||
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
// db.close(); // Keep connection open
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to persist messages", error);
|
||||
@@ -319,7 +359,7 @@ export async function getConversation(conversationId) {
|
||||
const store = tx.objectStore(STORES.CONVERSATIONS);
|
||||
const result = await runRequest(store.get(conversationId.trim()));
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
// db.close(); // Keep connection open
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to read conversation metadata", error);
|
||||
@@ -369,7 +409,7 @@ export async function listMessagesByConversation(conversationId, options = {}) {
|
||||
userLogger.warn("[dmDb] Failed to list messages", request.error);
|
||||
resolve(results);
|
||||
};
|
||||
}).finally(() => db.close());
|
||||
}); // removed finally db.close()
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to list messages", error);
|
||||
return [];
|
||||
@@ -429,7 +469,7 @@ export async function updateConversationFromMessage(message, options = {}) {
|
||||
|
||||
store.put(next);
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
// db.close(); // Keep connection open
|
||||
return next;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to update conversation metadata", error);
|
||||
@@ -478,7 +518,7 @@ export async function updateConversationOpenState(
|
||||
|
||||
store.put(next);
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
// db.close(); // Keep connection open
|
||||
return next;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to update conversation open state", error);
|
||||
@@ -512,7 +552,7 @@ export async function listConversations() {
|
||||
userLogger.warn("[dmDb] Failed to list conversations", request.error);
|
||||
resolve(results);
|
||||
};
|
||||
}).finally(() => db.close());
|
||||
}); // removed finally db.close()
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to list conversations", error);
|
||||
return [];
|
||||
@@ -542,7 +582,7 @@ export async function clearDmDb() {
|
||||
}
|
||||
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
// db.close(); // Keep connection open
|
||||
return true;
|
||||
} catch (error) {
|
||||
devLogger.warn("[dmDb] Failed to clear DM database", error);
|
||||
|
||||
+99
-28
@@ -131,23 +131,16 @@ function determineDecryptionOrder(event, availableSchemes, cachedScheme = null)
|
||||
prioritized.push(cachedScheme);
|
||||
}
|
||||
|
||||
const hasNip44 =
|
||||
availableSet.has("nip44_v2") || availableSet.has("nip44");
|
||||
const allowNip04 = !hasNip44 && availableSet.has("nip04");
|
||||
|
||||
const hints = extractEncryptionHints(event);
|
||||
const aliasMap = {
|
||||
nip04: ["nip04"],
|
||||
nip44: ["nip44_v2", "nip44"],
|
||||
nip44: ["nip44", "nip44_v2"],
|
||||
nip44_v2: ["nip44_v2", "nip44"],
|
||||
};
|
||||
|
||||
for (const hint of hints) {
|
||||
const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate === "nip04" && !allowNip04) {
|
||||
continue;
|
||||
}
|
||||
if (availableSet.has(candidate) && !prioritized.includes(candidate)) {
|
||||
prioritized.push(candidate);
|
||||
break;
|
||||
@@ -155,11 +148,7 @@ function determineDecryptionOrder(event, availableSchemes, cachedScheme = null)
|
||||
}
|
||||
}
|
||||
|
||||
const fallbacks = ["nip44_v2", "nip44"];
|
||||
if (allowNip04) {
|
||||
fallbacks.push("nip04");
|
||||
}
|
||||
for (const fallback of fallbacks) {
|
||||
for (const fallback of ["nip44_v2", "nip44", "nip04"]) {
|
||||
if (availableSet.has(fallback) && !prioritized.includes(fallback)) {
|
||||
prioritized.push(fallback);
|
||||
}
|
||||
@@ -360,7 +349,12 @@ class SubscriptionsManager {
|
||||
this.subsEventId = null;
|
||||
this.subsEventCreatedAt = null;
|
||||
this.currentUserPubkey = null;
|
||||
this.uiReady = false;
|
||||
this.dataReady = false;
|
||||
this.loaded = false;
|
||||
this.loadedFromCache = false;
|
||||
this.lastSuccessfulSyncAt = null;
|
||||
this.lastAttemptAt = null;
|
||||
this.loadingPromise = null;
|
||||
this.backgroundLoading = false;
|
||||
this.subscriptionListView = null;
|
||||
@@ -405,6 +399,7 @@ class SubscriptionsManager {
|
||||
|
||||
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
|
||||
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
|
||||
this.lastAttemptAt = Date.now();
|
||||
|
||||
// 1. Attempt to load from cache first
|
||||
const cached = profileCache.getProfileData(normalizedUserPubkey, "subscriptions");
|
||||
@@ -415,7 +410,10 @@ class SubscriptionsManager {
|
||||
this.subsEventId = cachedSnapshot.eventId;
|
||||
this.subsEventCreatedAt = cachedSnapshot.createdAt;
|
||||
this.currentUserPubkey = normalizedUserPubkey;
|
||||
this.uiReady = true;
|
||||
this.dataReady = true;
|
||||
this.loaded = true;
|
||||
this.loadedFromCache = true;
|
||||
|
||||
// Trigger background update
|
||||
if (!allowPermissionPrompt && !this.backgroundLoading) {
|
||||
@@ -479,7 +477,16 @@ class SubscriptionsManager {
|
||||
if (!userPubkey) return;
|
||||
|
||||
try {
|
||||
this.lastAttemptAt = Date.now();
|
||||
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
|
||||
const signerReadinessGate =
|
||||
options?.signerReadinessGate && typeof options.signerReadinessGate === "object"
|
||||
? options.signerReadinessGate
|
||||
: null;
|
||||
const shouldSuppressPermissionEscalation =
|
||||
signerReadinessGate &&
|
||||
(signerReadinessGate.status === "permission-denied" ||
|
||||
signerReadinessGate.status === "extension-unavailable");
|
||||
const wasBackgroundLoading = this.backgroundLoading;
|
||||
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
|
||||
const wasLoadedForUser =
|
||||
@@ -492,6 +499,7 @@ class SubscriptionsManager {
|
||||
devLogger.warn(
|
||||
"[SubscriptionsManager] No relay URLs available while loading subscriptions.",
|
||||
);
|
||||
this.uiReady = true;
|
||||
if (wasBackgroundLoading) {
|
||||
this.backgroundLoading = false;
|
||||
this.emitter.emit("change", {
|
||||
@@ -594,11 +602,20 @@ class SubscriptionsManager {
|
||||
this.subscribedPubkeys.clear();
|
||||
this.subsEventId = null;
|
||||
this.subsEventCreatedAt = null;
|
||||
this.uiReady = true;
|
||||
this.dataReady = true;
|
||||
this.loaded = true;
|
||||
this.loadedFromCache = false;
|
||||
this.lastSuccessfulSyncAt = Date.now();
|
||||
} 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.");
|
||||
this.uiReady = true;
|
||||
this.dataReady = true;
|
||||
this.loaded = true;
|
||||
this.loadedFromCache = false;
|
||||
this.lastSuccessfulSyncAt = Date.now();
|
||||
}
|
||||
if (wasBackgroundLoading) {
|
||||
this.backgroundLoading = false;
|
||||
@@ -627,6 +644,7 @@ class SubscriptionsManager {
|
||||
try {
|
||||
const decryptPromise = this.decryptSubscriptionEvent(newest, userPubkey, {
|
||||
allowPermissionPrompt,
|
||||
signerReadinessGate,
|
||||
});
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
@@ -647,12 +665,11 @@ class SubscriptionsManager {
|
||||
|
||||
if (!decryptResult.ok) {
|
||||
this.lastLoadError = decryptResult.error || null;
|
||||
this.uiReady = true;
|
||||
if (decryptResult.error?.code === "subscriptions-decrypt-timeout") {
|
||||
if (!this.loaded && !cachedSnapshot.hasSnapshot) {
|
||||
this.loaded = true;
|
||||
}
|
||||
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
|
||||
allowPermissionPrompt,
|
||||
signerReadinessGate,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -660,13 +677,25 @@ class SubscriptionsManager {
|
||||
!allowPermissionPrompt &&
|
||||
decryptResult.error?.code === "subscriptions-permission-required"
|
||||
) {
|
||||
if (!this.loaded && !cachedSnapshot.hasSnapshot) {
|
||||
this.loaded = true;
|
||||
if (shouldSuppressPermissionEscalation) {
|
||||
userLogger.warn(
|
||||
"[SubscriptionsManager] Skipping permission escalation due to shared signer gate outcome.",
|
||||
signerReadinessGate,
|
||||
);
|
||||
if (wasBackgroundLoading) {
|
||||
this.backgroundLoading = false;
|
||||
this.emitter.emit("change", {
|
||||
action: "background-loaded",
|
||||
subscribedPubkeys: Array.from(this.subscribedPubkeys),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Retry with permission prompt enabled so the signer / window.nostr
|
||||
// path is available once the extension has finished initializing.
|
||||
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
|
||||
allowPermissionPrompt: true,
|
||||
signerReadinessGate,
|
||||
});
|
||||
if (wasBackgroundLoading) {
|
||||
this.backgroundLoading = false;
|
||||
@@ -694,6 +723,7 @@ class SubscriptionsManager {
|
||||
);
|
||||
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
|
||||
allowPermissionPrompt,
|
||||
signerReadinessGate,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -701,10 +731,12 @@ class SubscriptionsManager {
|
||||
this.subscribedPubkeys.clear();
|
||||
this.subsEventId = null;
|
||||
this.subsEventCreatedAt = null;
|
||||
this.dataReady = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
|
||||
allowPermissionPrompt,
|
||||
signerReadinessGate,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -718,7 +750,11 @@ class SubscriptionsManager {
|
||||
// Update state
|
||||
this.subscribedPubkeys = newSet;
|
||||
this.currentUserPubkey = normalizedUserPubkey;
|
||||
this.uiReady = true;
|
||||
this.dataReady = true;
|
||||
this.loaded = true;
|
||||
this.loadedFromCache = false;
|
||||
this.lastSuccessfulSyncAt = Date.now();
|
||||
if (wasBackgroundLoading) {
|
||||
this.backgroundLoading = false;
|
||||
}
|
||||
@@ -758,6 +794,7 @@ class SubscriptionsManager {
|
||||
|
||||
} catch (err) {
|
||||
this.lastLoadError = err || null;
|
||||
this.uiReady = true;
|
||||
userLogger.error("[SubscriptionsManager] Failed to update subs from relays:", err);
|
||||
}
|
||||
}
|
||||
@@ -768,7 +805,12 @@ class SubscriptionsManager {
|
||||
this.subsEventId = null;
|
||||
this.subsEventCreatedAt = null;
|
||||
this.currentUserPubkey = null;
|
||||
this.uiReady = false;
|
||||
this.dataReady = false;
|
||||
this.loaded = false;
|
||||
this.loadedFromCache = false;
|
||||
this.lastSuccessfulSyncAt = null;
|
||||
this.lastAttemptAt = null;
|
||||
this.backgroundLoading = false;
|
||||
this.lastRunOptions = null;
|
||||
this.lastResult = null;
|
||||
@@ -834,12 +876,12 @@ class SubscriptionsManager {
|
||||
devLogger.log("[SubscriptionsManager] ensureLoaded start", actorHex);
|
||||
const normalizedActor = normalizeHexPubkey(actorHex) || actorHex;
|
||||
if (!normalizedActor) {
|
||||
return;
|
||||
return { ok: false, error: new Error("Missing actor pubkey."), state: this.getLoadState() };
|
||||
}
|
||||
|
||||
if (this.loaded && this.currentUserPubkey === normalizedActor) {
|
||||
if (this.dataReady && this.currentUserPubkey === normalizedActor) {
|
||||
devLogger.log("[SubscriptionsManager] ensureLoaded already loaded");
|
||||
return;
|
||||
return { ok: true, error: null, state: this.getLoadState() };
|
||||
}
|
||||
|
||||
if (this.loadingPromise) {
|
||||
@@ -847,9 +889,9 @@ class SubscriptionsManager {
|
||||
devLogger.log("[SubscriptionsManager] ensureLoaded awaiting existing promise");
|
||||
await this.loadingPromise;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
return { ok: false, error, state: this.getLoadState() };
|
||||
}
|
||||
return;
|
||||
return { ok: this.dataReady === true, error: this.lastLoadError || null, state: this.getLoadState() };
|
||||
}
|
||||
|
||||
const loader = this.loadSubscriptions(normalizedActor, options);
|
||||
@@ -880,16 +922,30 @@ class SubscriptionsManager {
|
||||
),
|
||||
]);
|
||||
devLogger.log("[SubscriptionsManager] ensureLoaded success");
|
||||
return { ok: this.dataReady === true, error: this.lastLoadError || null, state: this.getLoadState() };
|
||||
} catch (error) {
|
||||
userLogger.warn("[SubscriptionsManager] ensureLoaded timed out or failed:", error);
|
||||
// Mark as loaded so the UI can proceed. The background loader will
|
||||
// continue and emit a "change" event if it eventually succeeds.
|
||||
if (!this.loaded) {
|
||||
this.loaded = true;
|
||||
}
|
||||
this.lastLoadError = this.lastLoadError || error;
|
||||
this.uiReady = true;
|
||||
return { ok: false, error: this.lastLoadError, state: this.getLoadState() };
|
||||
}
|
||||
}
|
||||
|
||||
getLoadState() {
|
||||
return {
|
||||
uiReady: this.uiReady === true,
|
||||
dataReady: this.dataReady === true,
|
||||
lastLoadError: this.lastLoadError || null,
|
||||
loadedFromCache: this.loadedFromCache === true,
|
||||
lastSuccessfulSyncAt: Number.isFinite(this.lastSuccessfulSyncAt)
|
||||
? Number(this.lastSuccessfulSyncAt)
|
||||
: null,
|
||||
lastAttemptAt: Number.isFinite(this.lastAttemptAt)
|
||||
? Number(this.lastAttemptAt)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
isSubscribed(channelHex) {
|
||||
const normalized = normalizeHexPubkey(channelHex);
|
||||
if (!normalized) {
|
||||
@@ -930,6 +986,14 @@ class SubscriptionsManager {
|
||||
}
|
||||
|
||||
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
|
||||
const signerReadinessGate =
|
||||
options?.signerReadinessGate && typeof options.signerReadinessGate === "object"
|
||||
? options.signerReadinessGate
|
||||
: null;
|
||||
const suppressPermissionPrompt =
|
||||
signerReadinessGate &&
|
||||
(signerReadinessGate.status === "permission-denied" ||
|
||||
signerReadinessGate.status === "extension-unavailable");
|
||||
const hints = extractEncryptionHints(event);
|
||||
const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2");
|
||||
const requiresNip04 = !hints.length || hints.includes("nip04") || !requiresNip44;
|
||||
@@ -979,6 +1043,13 @@ class SubscriptionsManager {
|
||||
error.code = "subscriptions-permission-required";
|
||||
return { ok: false, error };
|
||||
}
|
||||
if (suppressPermissionPrompt) {
|
||||
const error = new Error(
|
||||
"Shared signer gate indicates extension permissions are unavailable.",
|
||||
);
|
||||
error.code = "subscriptions-permission-required";
|
||||
return { ok: false, error };
|
||||
}
|
||||
try {
|
||||
const permissionResult = await requestDefaultExtensionPermissions(
|
||||
DEFAULT_NIP07_ENCRYPTION_METHODS,
|
||||
|
||||
@@ -605,6 +605,7 @@ export default class ApplicationBootstrap {
|
||||
onPublishDmRelayPreferences: (payload) =>
|
||||
app.handleProfilePublishDmRelayPreferences(payload),
|
||||
onRequestPermissionPrompt: () => app.handlePermissionPromptRequest(),
|
||||
onRetryAuthSync: () => app.handleAuthSyncRetryRequest(),
|
||||
};
|
||||
|
||||
app.profileController = new ProfileModalController({
|
||||
|
||||
+283
-12
@@ -23,6 +23,7 @@ import {
|
||||
} from "../utils/hashtagNormalization.js";
|
||||
import { formatTimeAgo } from "../utils/formatters.js";
|
||||
import { getActiveSigner } from "../nostr/client.js";
|
||||
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../nostr/nip07Permissions.js";
|
||||
import { sanitizeRelayList } from "../nostr/nip46Client.js";
|
||||
import { buildPublicUrl, buildR2Key } from "../r2.js";
|
||||
import { buildProfileMetadataEvent } from "../nostrEventSchemas.js";
|
||||
@@ -1199,6 +1200,7 @@ export class ProfileModalController {
|
||||
onOpenRelays: callbacks.onOpenRelays || noop,
|
||||
onPublishDmRelayPreferences: callbacks.onPublishDmRelayPreferences || noop,
|
||||
onRequestPermissionPrompt: callbacks.onRequestPermissionPrompt || noop,
|
||||
onRetryAuthSync: callbacks.onRetryAuthSync || noop,
|
||||
};
|
||||
|
||||
this.profileModal = null;
|
||||
@@ -1337,7 +1339,9 @@ export class ProfileModalController {
|
||||
message: "",
|
||||
buttonLabel: "Enable permissions",
|
||||
busy: false,
|
||||
action: "permission",
|
||||
};
|
||||
this.authLoadingStateListener = null;
|
||||
this.profileSubscriptionsRefreshBtn = null;
|
||||
this.profileFriendsRefreshBtn = null;
|
||||
this.profileBlockedRefreshBtn = null;
|
||||
@@ -1501,6 +1505,15 @@ export class ProfileModalController {
|
||||
this.setupLayoutBreakpointObserver();
|
||||
this.applyModalStackingOverrides();
|
||||
this.registerEventListeners();
|
||||
if (!this.authLoadingStateListener && typeof window !== "undefined") {
|
||||
this.authLoadingStateListener = (event) => {
|
||||
this.handleAuthLoadingStateChange(event?.detail || {});
|
||||
};
|
||||
window.addEventListener(
|
||||
"bitvid:auth-loading-state",
|
||||
this.authLoadingStateListener,
|
||||
);
|
||||
}
|
||||
this.populateHashtagPreferences();
|
||||
this.refreshModerationSettingsUi();
|
||||
const preserveMenu = this.isMobileLayoutActive();
|
||||
@@ -5445,6 +5458,10 @@ export class ProfileModalController {
|
||||
|
||||
if (this.permissionPromptCtaButton instanceof HTMLElement) {
|
||||
this.permissionPromptCtaButton.addEventListener("click", () => {
|
||||
if (this.permissionPromptCtaButton?.dataset?.action === "retry-auth-sync") {
|
||||
this.callbacks.onRetryAuthSync({ controller: this });
|
||||
return;
|
||||
}
|
||||
this.callbacks.onRequestPermissionPrompt({ controller: this });
|
||||
});
|
||||
}
|
||||
@@ -8050,6 +8067,8 @@ export class ProfileModalController {
|
||||
}
|
||||
|
||||
const service = this.hashtagPreferencesService || {};
|
||||
const loadState =
|
||||
typeof service.getLoadState === "function" ? service.getLoadState() : null;
|
||||
|
||||
const interestsSource = Array.isArray(snapshot?.interests)
|
||||
? snapshot.interests
|
||||
@@ -8065,6 +8084,17 @@ export class ProfileModalController {
|
||||
return {
|
||||
interests: this.sanitizeHashtagList(interestsSource),
|
||||
disinterests: this.sanitizeHashtagList(disinterestsSource),
|
||||
uiReady:
|
||||
loadState?.uiReady === true ||
|
||||
service.uiReady === true ||
|
||||
(!loadState && service.uiReady !== false),
|
||||
dataReady:
|
||||
loadState?.dataReady === true ||
|
||||
service.dataReady === true ||
|
||||
(!loadState && service.dataReady !== false),
|
||||
lastLoadError: loadState?.lastLoadError || service.lastLoadError || null,
|
||||
loadedFromCache:
|
||||
loadState?.loadedFromCache === true || service.loadedFromCache === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8093,6 +8123,7 @@ export class ProfileModalController {
|
||||
message,
|
||||
buttonLabel,
|
||||
busy,
|
||||
action,
|
||||
} = this.permissionPromptCtaState || {};
|
||||
const normalizedMessage =
|
||||
typeof message === "string" && message.trim()
|
||||
@@ -8117,6 +8148,74 @@ export class ProfileModalController {
|
||||
"aria-busy",
|
||||
busy ? "true" : "false",
|
||||
);
|
||||
this.permissionPromptCtaButton.dataset.action =
|
||||
action === "retry-auth-sync" ? "retry-auth-sync" : "permission";
|
||||
}
|
||||
}
|
||||
|
||||
handleAuthLoadingStateChange(detail = {}) {
|
||||
const listsState =
|
||||
typeof detail?.lists === "string" ? detail.lists.trim().toLowerCase() : "";
|
||||
const listsDetail = detail?.listsDetail && typeof detail.listsDetail === "object"
|
||||
? detail.listsDetail
|
||||
: null;
|
||||
if (!listsDetail || !listsState || listsState === "loading" || listsState === "idle") {
|
||||
return;
|
||||
}
|
||||
|
||||
const failedTasks = Array.isArray(listsDetail.tasks)
|
||||
? listsDetail.tasks.filter((task) => task && task.ok === false)
|
||||
: [];
|
||||
const loadedFromCacheTasks = failedTasks.filter((task) => task.fromCache === true);
|
||||
const hardFailTasks = failedTasks.filter((task) => task.fromCache !== true);
|
||||
|
||||
if (loadedFromCacheTasks.length > 0 && hardFailTasks.length === 0) {
|
||||
this.setSubscriptionsStatus(
|
||||
"Subscriptions loaded from cache; sync is retrying in background.",
|
||||
"warning",
|
||||
);
|
||||
this.setHashtagStatus(
|
||||
"Hashtag preferences loaded from cache; sync is retrying in background.",
|
||||
"warning",
|
||||
);
|
||||
this.setBlockListLoadingState("error", {
|
||||
message: "Blocked creators loaded from cache; syncing latest in background.",
|
||||
});
|
||||
} else if (hardFailTasks.length > 0) {
|
||||
this.setSubscriptionsStatus(
|
||||
"Some profile lists failed to sync. Use Retry sync.",
|
||||
"warning",
|
||||
);
|
||||
this.setHashtagStatus(
|
||||
"Some profile lists failed to sync. Use Retry sync.",
|
||||
"warning",
|
||||
);
|
||||
this.setBlockListLoadingState("error", {
|
||||
message: "Blocked creators failed to sync. Retry to fetch latest lists.",
|
||||
});
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
this.setPermissionPromptCtaState({
|
||||
visible: true,
|
||||
message:
|
||||
loadedFromCacheTasks.length > 0
|
||||
? "Some lists are currently from cache. Retry now to sync with relays."
|
||||
: "Failed to sync required profile lists. Retry now.",
|
||||
buttonLabel: "Retry list sync",
|
||||
action: "retry-auth-sync",
|
||||
busy: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (listsState === "ready") {
|
||||
this.setPermissionPromptCtaState({
|
||||
visible: false,
|
||||
message: "",
|
||||
action: "permission",
|
||||
busy: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8258,11 +8357,37 @@ export class ProfileModalController {
|
||||
populateHashtagPreferences(preferences = null) {
|
||||
const snapshot = this.getResolvedHashtagPreferences(preferences);
|
||||
|
||||
if (!snapshot.uiReady) {
|
||||
this.setHashtagStatus("Loading hashtag preferences…", "info");
|
||||
this.renderHashtagList("interest", []);
|
||||
this.renderHashtagList("disinterest", []);
|
||||
this.refreshHashtagBackgroundStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!snapshot.dataReady) {
|
||||
const message = snapshot.lastLoadError
|
||||
? "Couldn’t sync hashtag preferences. Retry to load your lists."
|
||||
: "Hashtag preferences are unavailable right now. Retry to sync.";
|
||||
this.setHashtagStatus(message, "warning");
|
||||
this.renderHashtagList("interest", []);
|
||||
this.renderHashtagList("disinterest", []);
|
||||
this.refreshHashtagBackgroundStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderHashtagList("interest", snapshot.interests);
|
||||
this.renderHashtagList("disinterest", snapshot.disinterests);
|
||||
|
||||
if (!snapshot.interests.length && !snapshot.disinterests.length) {
|
||||
this.setHashtagStatus("", "muted");
|
||||
if (snapshot.loadedFromCache) {
|
||||
this.setHashtagStatus(
|
||||
"No hashtag preferences yet (showing cached state).",
|
||||
"info",
|
||||
);
|
||||
} else {
|
||||
this.setHashtagStatus("", "muted");
|
||||
}
|
||||
}
|
||||
this.refreshHashtagBackgroundStatus();
|
||||
}
|
||||
@@ -8826,6 +8951,31 @@ export class ProfileModalController {
|
||||
}
|
||||
|
||||
try {
|
||||
const loadState =
|
||||
typeof service.getLoadState === "function" ? service.getLoadState() : {};
|
||||
const uiReady = loadState?.uiReady === true || service.uiReady === true;
|
||||
const dataReady = loadState?.dataReady === true || service.dataReady === true;
|
||||
const lastLoadError = loadState?.lastLoadError || service.lastLoadError || null;
|
||||
|
||||
if (!uiReady) {
|
||||
this.clearSubscriptionsList("Loading subscriptions…");
|
||||
this.setSubscriptionsStatus("Loading subscriptions…", "info");
|
||||
this.refreshSubscriptionsBackgroundStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dataReady) {
|
||||
this.clearSubscriptionsList("Subscriptions unavailable. Retry to sync your list.");
|
||||
this.setSubscriptionsStatus(
|
||||
lastLoadError
|
||||
? "Couldn’t sync subscriptions. Retry to load your list."
|
||||
: "Subscriptions unavailable right now. Retry to sync your list.",
|
||||
"warning",
|
||||
);
|
||||
this.refreshSubscriptionsBackgroundStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
let sourceEntries = [];
|
||||
|
||||
if (Array.isArray(subscriptions) && subscriptions.length) {
|
||||
@@ -8918,6 +9068,7 @@ export class ProfileModalController {
|
||||
this.subscriptionList.innerHTML = "";
|
||||
|
||||
if (!deduped.length) {
|
||||
this.setSubscriptionsStatus("No subscriptions yet.", "muted");
|
||||
this.subscriptionListEmpty.classList.remove("hidden");
|
||||
this.subscriptionList.classList.add("hidden");
|
||||
this.refreshSubscriptionsBackgroundStatus();
|
||||
@@ -9023,13 +9174,17 @@ export class ProfileModalController {
|
||||
}
|
||||
}
|
||||
|
||||
clearSubscriptionsList() {
|
||||
clearSubscriptionsList(emptyMessage = "No subscriptions yet.") {
|
||||
if (this.subscriptionList instanceof HTMLElement) {
|
||||
this.subscriptionList.innerHTML = "";
|
||||
this.subscriptionList.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.subscriptionListEmpty instanceof HTMLElement) {
|
||||
this.subscriptionListEmpty.textContent =
|
||||
typeof emptyMessage === "string" && emptyMessage.trim()
|
||||
? emptyMessage.trim()
|
||||
: "No subscriptions yet.";
|
||||
this.subscriptionListEmpty.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
@@ -9830,8 +9985,16 @@ export class ProfileModalController {
|
||||
const isUnlocked = storageService && storageService.masterKeys.has(pubkey);
|
||||
|
||||
if (this.storageStatusText) {
|
||||
this.storageStatusText.textContent = isUnlocked ? "Unlocked" : "Locked";
|
||||
this.storageStatusText.className = isUnlocked ? "text-xs text-status-success" : "text-xs text-status-warning";
|
||||
if (isUnlocked) {
|
||||
this.storageStatusText.textContent = "Unlocked";
|
||||
this.storageStatusText.className = "text-xs text-status-success";
|
||||
} else if (this.storageUnlockFailure?.message) {
|
||||
this.storageStatusText.textContent = `Locked (${this.storageUnlockFailure.message})`;
|
||||
this.storageStatusText.className = "text-xs text-status-danger";
|
||||
} else {
|
||||
this.storageStatusText.textContent = "Locked";
|
||||
this.storageStatusText.className = "text-xs text-status-warning";
|
||||
}
|
||||
}
|
||||
|
||||
if (isUnlocked) {
|
||||
@@ -9952,12 +10115,59 @@ export class ProfileModalController {
|
||||
}
|
||||
}
|
||||
|
||||
getStorageUnlockFailureMessage(error) {
|
||||
const code = typeof error?.code === "string" ? error.code : "";
|
||||
|
||||
switch (code) {
|
||||
case "storage-unlock-permission-denied":
|
||||
return "Storage unlock requires extension encryption permission. Approve the prompt, then retry unlock.";
|
||||
case "storage-unlock-no-decryptor":
|
||||
return "Your signer cannot decrypt storage keys. Use a signer with NIP-44 or NIP-04 decrypt support.";
|
||||
case "storage-unlock-decrypt-failed":
|
||||
return "Unable to decrypt your saved storage key. Retry unlock and confirm the active account matches.";
|
||||
default:
|
||||
return typeof error?.message === "string" && error.message.trim()
|
||||
? error.message
|
||||
: "Failed to unlock storage. Ensure your signer supports NIP-04/44.";
|
||||
}
|
||||
}
|
||||
|
||||
setStorageUnlockFailureState(error) {
|
||||
const code = typeof error?.code === "string" ? error.code : "storage-unlock-decrypt-failed";
|
||||
const message = this.getStorageUnlockFailureMessage(error);
|
||||
|
||||
this.storageUnlockFailure = { code, message };
|
||||
|
||||
if (this.storageStatusText) {
|
||||
this.storageStatusText.textContent = `Locked (${message})`;
|
||||
this.storageStatusText.className = "text-xs text-status-danger";
|
||||
}
|
||||
|
||||
this.setStorageFormStatus(message, "error");
|
||||
}
|
||||
|
||||
clearStorageUnlockFailureState() {
|
||||
this.storageUnlockFailure = null;
|
||||
}
|
||||
|
||||
async requestStorageUnlockPermissions() {
|
||||
const client = this.services?.nostrClient;
|
||||
if (!client || typeof client.ensureExtensionPermissions !== "function") {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return client.ensureExtensionPermissions(DEFAULT_NIP07_ENCRYPTION_METHODS, {
|
||||
context: "storage-unlock",
|
||||
statusMessage:
|
||||
"Approve the extension prompt to allow storage encryption/decryption.",
|
||||
showSpinner: true,
|
||||
});
|
||||
}
|
||||
|
||||
async handleUnlockStorage() {
|
||||
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
|
||||
if (!pubkey) return;
|
||||
|
||||
// We need the active signer. Try the global first, then resolve via the
|
||||
// nostr client which can detect/restore the NIP-07 extension signer.
|
||||
let signer = getActiveSigner();
|
||||
if (
|
||||
!signer &&
|
||||
@@ -9998,6 +10208,55 @@ export class ProfileModalController {
|
||||
return;
|
||||
}
|
||||
|
||||
const signerType = typeof signer?.type === "string" ? signer.type.trim().toLowerCase() : "";
|
||||
const isExtensionSigner = signerType === "extension" || signerType === "nip07";
|
||||
|
||||
const shouldForcePermissionRetry =
|
||||
this.storageUnlockBtn?.dataset?.retryAction === "request-permissions";
|
||||
|
||||
if (shouldForcePermissionRetry) {
|
||||
const extensionPermissionCache = this.services?.nostrClient?.extensionPermissionCache;
|
||||
if (extensionPermissionCache instanceof Set) {
|
||||
for (const method of DEFAULT_NIP07_ENCRYPTION_METHODS) {
|
||||
extensionPermissionCache.delete(method);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isExtensionSigner) {
|
||||
const permissionResult = await this.requestStorageUnlockPermissions();
|
||||
if (!permissionResult?.ok) {
|
||||
const permissionError = permissionResult?.error || new Error("Extension permissions denied.");
|
||||
permissionError.code = "storage-unlock-permission-denied";
|
||||
this.setStorageUnlockFailureState(permissionError);
|
||||
this.showError(this.getStorageUnlockFailureMessage(permissionError));
|
||||
if (this.storageUnlockBtn) {
|
||||
this.storageUnlockBtn.dataset.retryAction = "request-permissions";
|
||||
this.storageUnlockBtn.textContent = "Retry Permissions + Unlock";
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const caps = signer?.capabilities && typeof signer.capabilities === "object"
|
||||
? signer.capabilities
|
||||
: null;
|
||||
const hasNip44Decrypt =
|
||||
caps?.nip44 !== false && typeof signer?.nip44Decrypt === "function";
|
||||
const hasNip04Decrypt =
|
||||
caps?.nip04 !== false &&
|
||||
(typeof signer?.nip04Decrypt === "function" || typeof signer?.decrypt === "function");
|
||||
|
||||
if (!hasNip44Decrypt && !hasNip04Decrypt) {
|
||||
const missingDecryptError = new Error(
|
||||
"This signer cannot decrypt storage keys (NIP-44/NIP-04 missing).",
|
||||
);
|
||||
missingDecryptError.code = "storage-unlock-no-decryptor";
|
||||
this.setStorageUnlockFailureState(missingDecryptError);
|
||||
this.showError(this.getStorageUnlockFailureMessage(missingDecryptError));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.storageUnlockBtn) {
|
||||
this.storageUnlockBtn.disabled = true;
|
||||
this.storageUnlockBtn.textContent = "Unlocking...";
|
||||
@@ -10005,23 +10264,32 @@ export class ProfileModalController {
|
||||
|
||||
try {
|
||||
await storageService.unlock(pubkey, { signer });
|
||||
this.clearStorageUnlockFailureState();
|
||||
this.showSuccess("Storage unlocked.");
|
||||
this.populateStoragePane();
|
||||
} catch (error) {
|
||||
devLogger.error("Failed to unlock storage:", error);
|
||||
const message =
|
||||
error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Failed to unlock storage. Ensure your signer supports NIP-04/44.";
|
||||
this.showError(message);
|
||||
this.setStorageUnlockFailureState(error);
|
||||
this.showError(this.getStorageUnlockFailureMessage(error));
|
||||
if (this.storageUnlockBtn) {
|
||||
this.storageUnlockBtn.dataset.retryAction = "request-permissions";
|
||||
}
|
||||
} finally {
|
||||
if (this.storageUnlockBtn) {
|
||||
this.storageUnlockBtn.disabled = false;
|
||||
this.storageUnlockBtn.textContent = "Unlock Storage";
|
||||
const shouldShowRetry = this.storageUnlockFailure?.code === "storage-unlock-permission-denied";
|
||||
if (shouldShowRetry) {
|
||||
this.storageUnlockBtn.textContent = "Retry Permissions + Unlock";
|
||||
this.storageUnlockBtn.dataset.retryAction = "request-permissions";
|
||||
} else {
|
||||
this.storageUnlockBtn.textContent = "Unlock Storage";
|
||||
delete this.storageUnlockBtn.dataset.retryAction;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async handleSaveStorage() {
|
||||
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
|
||||
if (!pubkey) return;
|
||||
@@ -13564,6 +13832,7 @@ export class ProfileModalController {
|
||||
this.populateProfileRelays();
|
||||
this.refreshWalletPaneState();
|
||||
this.populateHashtagPreferences();
|
||||
void this.populateStoragePane();
|
||||
this.handleActiveDmIdentityChanged(activePubkey);
|
||||
void this.refreshDmRelayPreferences({ force: true });
|
||||
|
||||
@@ -13577,6 +13846,7 @@ export class ProfileModalController {
|
||||
this.populateProfileRelays();
|
||||
this.refreshWalletPaneState();
|
||||
this.populateHashtagPreferences();
|
||||
void this.populateStoragePane();
|
||||
void this.refreshDmRelayPreferences({ force: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -13642,6 +13912,7 @@ export class ProfileModalController {
|
||||
this.populateProfileRelays();
|
||||
this.refreshWalletPaneState();
|
||||
this.populateHashtagPreferences();
|
||||
void this.populateStoragePane();
|
||||
this.clearHashtagInputs();
|
||||
this.setHashtagStatus("", "muted");
|
||||
this.handleActiveDmIdentityChanged(null);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { devLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Maps over an iterable with a concurrency limit.
|
||||
*
|
||||
@@ -51,3 +53,53 @@ export async function pMap(iterable, mapper, { concurrency = Infinity } = {}) {
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
export function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") {
|
||||
const resolvedTimeout = Number(timeoutMs);
|
||||
const effectiveTimeout =
|
||||
Number.isFinite(resolvedTimeout) && resolvedTimeout > 0
|
||||
? Math.floor(resolvedTimeout)
|
||||
: 4000;
|
||||
|
||||
let timeoutId = null;
|
||||
let settled = false;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (typeof onTimeout === "function") {
|
||||
try {
|
||||
onTimeout();
|
||||
} catch (cleanupError) {
|
||||
devLogger.warn("[asyncUtils] Timeout cleanup failed:", cleanupError);
|
||||
}
|
||||
}
|
||||
reject(new Error(message));
|
||||
}, effectiveTimeout);
|
||||
|
||||
Promise.resolve(promise)
|
||||
.then((value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { HEX64_REGEX } from "./hex.js";
|
||||
|
||||
/**
|
||||
* Safely encode a hex pubkey to npub.
|
||||
*
|
||||
* @param {string} pubkey - The hex public key.
|
||||
* @returns {string|null} The npub string or null if encoding failed.
|
||||
*/
|
||||
export function safeEncodeNpub(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.NostrTools &&
|
||||
window.NostrTools.nip19 &&
|
||||
typeof window.NostrTools.nip19.npubEncode === "function"
|
||||
) {
|
||||
return window.NostrTools.nip19.npubEncode(trimmed);
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely decode an npub to hex pubkey.
|
||||
*
|
||||
* @param {string} npub - The npub string.
|
||||
* @returns {string|null} The hex public key or null if decoding failed.
|
||||
*/
|
||||
export function safeDecodeNpub(npub) {
|
||||
if (typeof npub !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = npub.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.NostrTools &&
|
||||
window.NostrTools.nip19 &&
|
||||
typeof window.NostrTools.nip19.decode === "function"
|
||||
) {
|
||||
const decoded = window.NostrTools.nip19.decode(trimmed);
|
||||
if (decoded.type === "npub" && typeof decoded.data === "string") {
|
||||
return decoded.data;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a public key to hex format, handling npub inputs.
|
||||
*
|
||||
* @param {string} pubkey - The public key (hex or npub).
|
||||
* @returns {string|null} The normalized hex public key or null.
|
||||
*/
|
||||
export function normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
const decoded = safeDecodeNpub(trimmed);
|
||||
if (decoded && HEX64_REGEX.test(decoded)) {
|
||||
return decoded.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function sanitizeProfileMediaUrl(value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
trimmed = trimmed.replace(/^['"]+|['"]+$/g, "");
|
||||
trimmed = trimmed.replace(/^['"]+|['"]+$/g, "").trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function sanitizeProfileMediaUrl(value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (/^ipfs:\/\//i.test(trimmed)) {
|
||||
if (/^ipfs(\/|:\/\/)/i.test(trimmed)) {
|
||||
return normalizeIpfsGatewayUrl(trimmed);
|
||||
}
|
||||
|
||||
|
||||
@@ -121,3 +121,37 @@ export function resolveInfoJsonUrl({ storagePointer, url } = {}) {
|
||||
|
||||
return normalizeInfoJsonBase(prefix);
|
||||
}
|
||||
|
||||
export function resolveStoragePointerValue({
|
||||
storagePointer,
|
||||
url,
|
||||
infoHash,
|
||||
fallbackId,
|
||||
provider,
|
||||
} = {}) {
|
||||
const normalized = normalizeStoragePointer(storagePointer);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const derivedFromUrl = deriveStoragePointerFromUrl(url, provider || "url");
|
||||
if (derivedFromUrl) {
|
||||
return derivedFromUrl;
|
||||
}
|
||||
|
||||
if (typeof infoHash === "string" && infoHash.trim()) {
|
||||
return buildStoragePointerValue({
|
||||
provider: "btih",
|
||||
prefix: infoHash.trim().toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof fallbackId === "string" && fallbackId.trim()) {
|
||||
return buildStoragePointerValue({
|
||||
provider: "nostr",
|
||||
prefix: fallbackId.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
Generated
+3357
File diff suppressed because it is too large.
Load diff
@@ -346,20 +346,36 @@ function generateReport() {
|
||||
max: Math.max(...stats.signingTimes, 0)
|
||||
},
|
||||
resources: stats.resourceUsage,
|
||||
topBottlenecks: [],
|
||||
topHotFunctions: [],
|
||||
errors: stats.errors,
|
||||
proposedRemediation: []
|
||||
};
|
||||
|
||||
// Analysis
|
||||
if (stats.errors > 0) {
|
||||
report.topBottlenecks.push("High error rate detected");
|
||||
// Analysis & Heuristics for "Hot Functions"
|
||||
// Since we don't have a sampling profiler attached, we infer hotspots from timing data.
|
||||
|
||||
// Always report signing time as it's the primary CPU consumer
|
||||
report.topHotFunctions.push({
|
||||
function: "finalizeEvent (client signing)",
|
||||
avgTime: `${avgSignTime.toFixed(2)}ms`,
|
||||
impact: avgSignTime > 10 ? "High CPU usage" : "Moderate CPU usage"
|
||||
});
|
||||
|
||||
if (avgSignTime > 10) {
|
||||
report.proposedRemediation.push("Use optimized crypto library (e.g. secp256k1-wasm) or offload signing to a worker/signer.");
|
||||
}
|
||||
|
||||
if (p99 > 1000) {
|
||||
report.topBottlenecks.push("High p99 latency (>1s)");
|
||||
report.topHotFunctions.push({
|
||||
function: "Relay Processing / Network RTT",
|
||||
avgTime: `${p99}ms (p99)`,
|
||||
impact: "High latency for end users"
|
||||
});
|
||||
report.proposedRemediation.push("Investigate relay event processing loop or network bandwidth.");
|
||||
}
|
||||
if (avgSignTime > 10) { // arbitrary threshold for JS signing
|
||||
report.topBottlenecks.push(`Slow client-side signing (avg ${avgSignTime.toFixed(2)}ms)`);
|
||||
report.proposedRemediation.push("Use optimized crypto library or offload signing.");
|
||||
|
||||
if (stats.errors > 0) {
|
||||
report.proposedRemediation.push("Investigate error logs for connection drops or timeouts.");
|
||||
}
|
||||
|
||||
// Output
|
||||
|
||||
@@ -65,7 +65,12 @@ import {
|
||||
buildSubscriptionListEvent,
|
||||
buildBlockListEvent,
|
||||
buildHashtagPreferenceEvent,
|
||||
buildAdminListEvent
|
||||
buildAdminListEvent,
|
||||
buildGiftWrapEvent,
|
||||
buildSealEvent,
|
||||
buildChatMessageEvent,
|
||||
getNostrEventSchema,
|
||||
sanitizeAdditionalTags
|
||||
} from '../../js/nostrEventSchemas.js';
|
||||
|
||||
import { buildNip71VideoEvent } from '../../js/nostr/nip71.js';
|
||||
@@ -73,7 +78,7 @@ import { buildNip71VideoEvent } from '../../js/nostr/nip71.js';
|
||||
const TEST_PUBKEY = '0000000000000000000000000000000000000000000000000000000000000001';
|
||||
const TEST_TIMESTAMP = 1672531200; // 2023-01-01
|
||||
|
||||
function runTest(name, builderFn, input, expectedType) {
|
||||
function runTest(name, builderFn, input, expectedType, additionalCheck) {
|
||||
console.log(`\nTesting: ${name}`);
|
||||
try {
|
||||
const event = builderFn(input);
|
||||
@@ -84,6 +89,14 @@ function runTest(name, builderFn, input, expectedType) {
|
||||
|
||||
const { valid, errors } = validateEventStructure(expectedType, event);
|
||||
if (valid) {
|
||||
if (additionalCheck) {
|
||||
try {
|
||||
additionalCheck(event);
|
||||
} catch (checkError) {
|
||||
console.error(`❌ Additional check failed: ${checkError.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
console.log(`✅ Valid ${expectedType} (Kind: ${event.kind})`);
|
||||
return true;
|
||||
} else {
|
||||
@@ -101,47 +114,70 @@ async function main() {
|
||||
console.log('Starting Event Schema Validation...');
|
||||
let failures = 0;
|
||||
|
||||
// 1. Video Post
|
||||
const videoInput = {
|
||||
// --- Real-world usage patterns ---
|
||||
|
||||
// 1. Video Post (mimicking js/nostr/client.js publishVideo)
|
||||
// Testing auto-generation of 's' tag from URL
|
||||
const videoInputReal = {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
dTagValue: 'test-video-id',
|
||||
dTagValue: 'test-video-id-123',
|
||||
content: {
|
||||
version: 3,
|
||||
title: 'Test Video',
|
||||
videoRootId: 'test-video-id',
|
||||
title: 'Real World Video',
|
||||
videoRootId: 'test-video-id-123',
|
||||
url: 'https://example.com/video.mp4',
|
||||
magnet: 'magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678',
|
||||
thumbnail: 'https://example.com/thumb.jpg',
|
||||
description: 'A test video',
|
||||
mode: 'live',
|
||||
deleted: false,
|
||||
isPrivate: false,
|
||||
isNsfw: false,
|
||||
isForKids: true,
|
||||
isForKids: false,
|
||||
enableComments: true
|
||||
}
|
||||
},
|
||||
// No explicit 's' tag passed, should be generated
|
||||
additionalTags: [['t', 'art']]
|
||||
};
|
||||
if (!runTest('buildVideoPostEvent', buildVideoPostEvent, videoInput, NOTE_TYPES.VIDEO_POST)) failures++;
|
||||
|
||||
// 2. Relay List
|
||||
const relayListInput = {
|
||||
if (!runTest('buildVideoPostEvent (Real Usage - Auto Storage Pointer)', buildVideoPostEvent, videoInputReal, NOTE_TYPES.VIDEO_POST, (event) => {
|
||||
const sTag = event.tags.find(t => t[0] === 's');
|
||||
if (!sTag) throw new Error("Missing 's' tag (Storage Pointer) which should have been auto-generated from URL");
|
||||
if (!sTag[1].startsWith('url:')) throw new Error(`'s' tag should start with 'url:', got ${sTag[1]}`);
|
||||
})) failures++;
|
||||
|
||||
// 2. Relay List (mimicking js/relayManager.js)
|
||||
const relayListInputReal = {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
relays: [
|
||||
{ url: 'wss://relay.example.com', mode: 'read' },
|
||||
{ url: 'wss://relay.other.com', mode: 'write' },
|
||||
'wss://relay.both.com'
|
||||
{ url: 'wss://relay.damus.io', mode: 'read' },
|
||||
{ url: 'wss://nos.lol', mode: 'write' },
|
||||
{ url: 'wss://relay.primal.net', mode: 'both' }
|
||||
]
|
||||
};
|
||||
if (!runTest('buildRelayListEvent', buildRelayListEvent, relayListInput, NOTE_TYPES.RELAY_LIST)) failures++;
|
||||
if (!runTest('buildRelayListEvent (Real Usage)', buildRelayListEvent, relayListInputReal, NOTE_TYPES.RELAY_LIST, (event) => {
|
||||
const rTags = event.tags.filter(t => t[0] === 'r');
|
||||
if (rTags.length !== 3) throw new Error(`Expected 3 'r' tags, got ${rTags.length}`);
|
||||
const damus = rTags.find(t => t[1] === 'wss://relay.damus.io');
|
||||
if (!damus || damus[2] !== 'read') throw new Error("Damus relay missing or incorrect marker");
|
||||
})) failures++;
|
||||
|
||||
// 3. Hashtag Preferences
|
||||
const hashtagInput = {
|
||||
// 3. Hashtag Preferences (mimicking js/services/hashtagPreferencesService.js)
|
||||
// Content is encrypted string (ciphertext)
|
||||
const hashtagInputReal = {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
content: JSON.stringify({ interests: ['art', 'code'], disinterests: ['spam'] })
|
||||
content: "nip44:ciphertext-blob-simulated",
|
||||
// Service adds encrypted tag
|
||||
additionalTags: [['encrypted', 'nip44']]
|
||||
};
|
||||
if (!runTest('buildHashtagPreferenceEvent', buildHashtagPreferenceEvent, hashtagInput, NOTE_TYPES.HASHTAG_PREFERENCES)) failures++;
|
||||
if (!runTest('buildHashtagPreferenceEvent (Real Usage - Encrypted)', buildHashtagPreferenceEvent, hashtagInputReal, NOTE_TYPES.HASHTAG_PREFERENCES, (event) => {
|
||||
if (event.content !== "nip44:ciphertext-blob-simulated") throw new Error("Content mismtach");
|
||||
const dTag = event.tags.find(t => t[0] === 'd');
|
||||
if (!dTag || dTag[1] !== 'bitvid:tag-preferences') throw new Error("Incorrect 'd' tag identifier");
|
||||
})) failures++;
|
||||
|
||||
|
||||
// --- Standard Checks ---
|
||||
|
||||
// 4. HTTP Auth
|
||||
const httpAuthInput = {
|
||||
@@ -339,6 +375,33 @@ async function main() {
|
||||
};
|
||||
if (!runTest('buildNip71VideoEvent', buildNip71VideoEvent, nip71Input, NOTE_TYPES.NIP71_VIDEO)) failures++;
|
||||
|
||||
// 26. NIP-17 Gift Wrap
|
||||
const giftWrapInput = {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
recipientPubkey: TEST_PUBKEY,
|
||||
ciphertext: 'encrypted-seal',
|
||||
relayHint: 'wss://relay.example.com'
|
||||
};
|
||||
if (!runTest('buildGiftWrapEvent', buildGiftWrapEvent, giftWrapInput, NOTE_TYPES.GIFT_WRAP)) failures++;
|
||||
|
||||
// 27. NIP-17 Seal
|
||||
const sealInput = {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
ciphertext: 'encrypted-rumor'
|
||||
};
|
||||
if (!runTest('buildSealEvent', buildSealEvent, sealInput, NOTE_TYPES.SEAL)) failures++;
|
||||
|
||||
// 28. NIP-17 Chat Message
|
||||
const chatMessageInput = {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
recipientPubkey: TEST_PUBKEY,
|
||||
content: 'Hello World'
|
||||
};
|
||||
if (!runTest('buildChatMessageEvent', buildChatMessageEvent, chatMessageInput, NOTE_TYPES.CHAT_MESSAGE)) failures++;
|
||||
|
||||
|
||||
console.log('\n-----------------------------------');
|
||||
if (failures === 0) {
|
||||
|
||||
@@ -305,3 +305,98 @@ await (async () => {
|
||||
assert.ok(detail);
|
||||
assert.equal(detail.pubkey, SAMPLE_PUBKEY);
|
||||
})();
|
||||
|
||||
// === Tests for loadOwnProfile logic ===
|
||||
|
||||
await (async () => {
|
||||
resetState();
|
||||
const PUBKEY = SAMPLE_PUBKEY;
|
||||
|
||||
// Test Fast Relay Success
|
||||
{
|
||||
const mockNostrClient = {
|
||||
relays: ["wss://fast1.com", "wss://fast2.com", "wss://slow.com"],
|
||||
pool: {
|
||||
list: async (relays) => {
|
||||
const url = relays[0];
|
||||
if (url === "wss://fast1.com") {
|
||||
return [{
|
||||
pubkey: PUBKEY,
|
||||
created_at: 100,
|
||||
content: JSON.stringify({ name: "Fast User" })
|
||||
}];
|
||||
}
|
||||
return new Promise(() => {}); // Hang others
|
||||
}
|
||||
},
|
||||
handleEvent: () => {}
|
||||
};
|
||||
|
||||
const service = new AuthService({ nostrClient: mockNostrClient });
|
||||
// Override setProfileCacheEntry to avoid side effects or errors from real implementation if not needed,
|
||||
// but here we just let it run. However, the service needs nostrClient.pool
|
||||
|
||||
// We need to make sure setProfileCacheEntry doesn't fail.
|
||||
// It imports from cache.js which uses profileCache.
|
||||
// profileCache is imported from state/profileCache.js.
|
||||
|
||||
// The previous tests didn't seem to mock setProfileCacheEntry.
|
||||
|
||||
const profile = await service.loadOwnProfile(PUBKEY);
|
||||
assert.equal(profile.name, "Fast User");
|
||||
}
|
||||
|
||||
// Test All Relays Fail
|
||||
{
|
||||
const mockNostrClient = {
|
||||
relays: ["wss://fail1.com", "wss://fail2.com"],
|
||||
pool: {
|
||||
list: async () => {
|
||||
throw new Error("Failed");
|
||||
}
|
||||
},
|
||||
handleEvent: () => {}
|
||||
};
|
||||
|
||||
const service = new AuthService({ nostrClient: mockNostrClient });
|
||||
const profile = await service.loadOwnProfile(PUBKEY);
|
||||
assert.equal(profile.name, "Unknown");
|
||||
}
|
||||
|
||||
// Test Timeout Fallback
|
||||
{
|
||||
let backgroundResolve;
|
||||
const backgroundPromise = new Promise(r => backgroundResolve = r);
|
||||
|
||||
const mockNostrClient = {
|
||||
relays: ["wss://fast-fail.com", "wss://fast-fail2.com", "wss://background-ok.com", "wss://background-ok-2.com"],
|
||||
pool: {
|
||||
list: async (relays) => {
|
||||
const url = relays[0];
|
||||
if (url.includes("fast")) {
|
||||
// Simulate timeout or error
|
||||
throw new Error("Fast fail");
|
||||
}
|
||||
if (url.includes("background")) {
|
||||
await backgroundPromise;
|
||||
return [{
|
||||
pubkey: PUBKEY,
|
||||
created_at: 200,
|
||||
content: JSON.stringify({ name: "Background User" })
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
},
|
||||
handleEvent: () => {}
|
||||
};
|
||||
|
||||
const service = new AuthService({ nostrClient: mockNostrClient });
|
||||
|
||||
const profile = await service.loadOwnProfile(PUBKEY);
|
||||
assert.equal(profile.name, "Unknown");
|
||||
|
||||
// We resolve background promise to let background logic finish eventually (though test finishes here)
|
||||
backgroundResolve();
|
||||
}
|
||||
})();
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
listMessagesByConversation,
|
||||
updateConversationFromMessage,
|
||||
writeMessages,
|
||||
closeDmDb,
|
||||
} from "../js/storage/dmDb.js";
|
||||
|
||||
const { indexedDB, IDBKeyRange } = await import("fake-indexeddb");
|
||||
@@ -25,6 +26,7 @@ async function deleteDatabase(name) {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await closeDmDb();
|
||||
await deleteDatabase("bitvidDm");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createKidsScorerStage } from "../../js/feedEngine/kidsScoring.js";
|
||||
|
||||
test("kids scorer correctly identifies dominant positive component", async () => {
|
||||
const stage = createKidsScorerStage({
|
||||
weights: {
|
||||
w_age: 0.2,
|
||||
w_edu: 0.2,
|
||||
w_author: 0.2,
|
||||
w_pop: 0.2,
|
||||
w_fresh: 0.2,
|
||||
w_risk: 0
|
||||
}
|
||||
});
|
||||
|
||||
// Create an item where one component is clearly dominant
|
||||
// Age appropriateness: 1.0 (perfect duration + tags)
|
||||
// Others: 0
|
||||
const items = [
|
||||
{
|
||||
video: {
|
||||
id: "v1",
|
||||
pubkey: "pk1",
|
||||
duration: 300, // 5 min (perfect for toddler/preschool)
|
||||
tags: [["t", "preschool"], ["t", "learning"]], // Matches preferred
|
||||
created_at: Date.now() / 1000 - 10000000, // Old
|
||||
viewCount: 0
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
const why = [];
|
||||
const context = {
|
||||
runtime: {
|
||||
ageGroup: "preschool",
|
||||
trustedAuthors: []
|
||||
},
|
||||
addWhy: (detail) => why.push(detail)
|
||||
};
|
||||
|
||||
await stage(items, context);
|
||||
|
||||
assert.equal(why.length, 1);
|
||||
assert.equal(why[0].reason, "age-appropriateness");
|
||||
assert.ok(why[0].value > 0);
|
||||
});
|
||||
|
||||
test("kids scorer picks educational-boost when dominant", async () => {
|
||||
const stage = createKidsScorerStage({
|
||||
educationalTags: ["math"],
|
||||
weights: { w_age: 0, w_edu: 1, w_author: 0, w_pop: 0, w_fresh: 0, w_risk: 0 }
|
||||
});
|
||||
|
||||
const items = [{
|
||||
video: {
|
||||
id: "v2",
|
||||
tags: [["t", "math"]],
|
||||
duration: 300
|
||||
},
|
||||
metadata: {}
|
||||
}];
|
||||
|
||||
const why = [];
|
||||
await stage(items, {
|
||||
runtime: { educationalTags: ["math"] },
|
||||
addWhy: (d) => why.push(d)
|
||||
});
|
||||
|
||||
assert.equal(why.length, 1);
|
||||
assert.equal(why[0].reason, "educational-boost");
|
||||
});
|
||||
@@ -165,6 +165,53 @@ describe("NostrClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchVideos", () => {
|
||||
it("should delegate to subscribeVideos and return result on eose", async () => {
|
||||
let subOpts = null;
|
||||
// Override subscribeVideos to verify delegation
|
||||
client.subscribeVideos = (cb, opts) => {
|
||||
subOpts = opts;
|
||||
return {
|
||||
on: (evt, handler) => {
|
||||
if (evt === "eose") setTimeout(handler, 0);
|
||||
},
|
||||
unsub: () => {},
|
||||
};
|
||||
};
|
||||
|
||||
// Mock helpers
|
||||
client.getActiveVideos = () => [{ id: "v1" }];
|
||||
client.populateNip71MetadataForVideos = async () => {};
|
||||
client.applyRootCreatedAt = () => {};
|
||||
|
||||
const result = await client.fetchVideos({ limit: 5 });
|
||||
assert.equal(result[0].id, "v1");
|
||||
assert.equal(subOpts.limit, 5);
|
||||
// Ensure we force full fetch by default
|
||||
assert.equal(subOpts.since, 0);
|
||||
});
|
||||
|
||||
it("should respect since parameter if provided", async () => {
|
||||
let subOpts = null;
|
||||
client.subscribeVideos = (cb, opts) => {
|
||||
subOpts = opts;
|
||||
return {
|
||||
on: (evt, handler) => {
|
||||
if (evt === "eose") setTimeout(handler, 0);
|
||||
},
|
||||
unsub: () => {},
|
||||
};
|
||||
};
|
||||
|
||||
client.getActiveVideos = () => [];
|
||||
client.populateNip71MetadataForVideos = async () => {};
|
||||
client.applyRootCreatedAt = () => {};
|
||||
|
||||
await client.fetchVideos({ since: 12345 });
|
||||
assert.equal(subOpts.since, 12345);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishVideo", () => {
|
||||
it("should throw if not logged in", async () => {
|
||||
await assert.rejects(
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
|
||||
import { describe, it, beforeEach, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Nip46Connector } from "../../js/nostr/nip46Connector.js";
|
||||
|
||||
const VALID_HEX_KEY = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const VALID_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000002";
|
||||
|
||||
// Mock dependencies
|
||||
const mockDeps = {
|
||||
parseNip46ConnectionString: mock.fn(),
|
||||
generateNip46Secret: mock.fn(),
|
||||
readStoredNip46Session: mock.fn(),
|
||||
writeStoredNip46Session: mock.fn(),
|
||||
clearStoredNip46Session: mock.fn(),
|
||||
normalizeNip46EncryptionAlgorithm: mock.fn(),
|
||||
decryptNip46Session: mock.fn(),
|
||||
attemptDecryptNip46HandshakePayload: mock.fn(),
|
||||
sanitizeNip46Metadata: mock.fn((m) => m || {}),
|
||||
resolveNip46Relays: mock.fn((r) => r || []),
|
||||
normalizeNostrPubkey: mock.fn((k) => k),
|
||||
encodeHexToNpub: mock.fn((k) => "npub" + k),
|
||||
bytesToHex: mock.fn((b) => VALID_HEX_KEY),
|
||||
signEventWithPrivateKey: mock.fn(),
|
||||
Nip46RpcClient: class MockRpcClient {
|
||||
constructor() {
|
||||
this.destroy = mock.fn();
|
||||
this.ensureSubscription = mock.fn(() => Promise.resolve());
|
||||
this.connect = mock.fn(() => Promise.resolve());
|
||||
this.getUserPubkey = mock.fn(() => Promise.resolve(VALID_PUBKEY));
|
||||
this.metadata = {};
|
||||
}
|
||||
},
|
||||
ensureNostrTools: mock.fn(async () => ({
|
||||
generateSecretKey: () => new Uint8Array(32),
|
||||
getPublicKey: () => VALID_PUBKEY,
|
||||
})),
|
||||
getCachedNostrTools: mock.fn(() => ({
|
||||
generateSecretKey: () => new Uint8Array(32),
|
||||
getPublicKey: () => VALID_PUBKEY,
|
||||
})),
|
||||
NIP46_RPC_KIND: 24133,
|
||||
NIP46_HANDSHAKE_TIMEOUT_MS: 100,
|
||||
NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS: 3,
|
||||
};
|
||||
|
||||
describe("Nip46Connector", () => {
|
||||
let connector;
|
||||
let mockNostrClient;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
Object.values(mockDeps).forEach((m) => {
|
||||
if (m.mock) m.mock.resetCalls();
|
||||
});
|
||||
|
||||
mockNostrClient = {
|
||||
pool: {
|
||||
publish: mock.fn(() => Promise.resolve()),
|
||||
sub: mock.fn(() => ({
|
||||
on: mock.fn(),
|
||||
unsub: mock.fn(),
|
||||
})),
|
||||
},
|
||||
ensurePool: mock.fn(async () => mockNostrClient.pool),
|
||||
connectToRelays: mock.fn(async () => []),
|
||||
emitRemoteSignerChange: mock.fn(),
|
||||
relays: ["wss://relay.example.com"],
|
||||
};
|
||||
|
||||
connector = new Nip46Connector(mockNostrClient, mockDeps);
|
||||
});
|
||||
|
||||
describe("createKeyPair", () => {
|
||||
it("should generate a new key pair if none provided", async () => {
|
||||
mockDeps.generateNip46Secret.mock.mockImplementationOnce(() => "mock-secret");
|
||||
|
||||
const result = await connector.createKeyPair();
|
||||
|
||||
assert.deepEqual(result, {
|
||||
privateKey: VALID_HEX_KEY,
|
||||
publicKey: VALID_PUBKEY,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use existing keys if provided", async () => {
|
||||
const result = await connector.createKeyPair(
|
||||
VALID_HEX_KEY,
|
||||
VALID_PUBKEY
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
privateKey: VALID_HEX_KEY,
|
||||
publicKey: VALID_PUBKEY,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareHandshake", () => {
|
||||
it("should parse connection string and return details", async () => {
|
||||
mockDeps.generateNip46Secret.mock.mockImplementationOnce(() => "handshake-secret");
|
||||
|
||||
const result = await connector.prepareHandshake({
|
||||
connectionString: "bunker://...",
|
||||
});
|
||||
|
||||
assert.equal(result.clientPrivateKey, VALID_HEX_KEY);
|
||||
assert.equal(result.secret, "handshake-secret");
|
||||
assert.match(result.connectionString, /^nostrconnect:\/\//);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connect", () => {
|
||||
it("should connect directly if remote pubkey is known", async () => {
|
||||
const options = {
|
||||
connectionString: "bunker://remote-pub?relay=wss://relay.com",
|
||||
clientPrivateKey: VALID_HEX_KEY,
|
||||
};
|
||||
|
||||
mockDeps.parseNip46ConnectionString.mock.mockImplementationOnce(() => ({
|
||||
relays: ["wss://relay.com"],
|
||||
remotePubkey: VALID_PUBKEY,
|
||||
type: "bunker",
|
||||
}));
|
||||
mockDeps.normalizeNip46EncryptionAlgorithm.mock.mockImplementationOnce(() => "nip44");
|
||||
mockDeps.normalizeNostrPubkey.mock.mockImplementation((k) => k);
|
||||
|
||||
const result = await connector.connect(options);
|
||||
|
||||
assert.equal(result.pubkey, VALID_PUBKEY);
|
||||
assert.ok(result.client instanceof mockDeps.Nip46RpcClient);
|
||||
assert.equal(mockDeps.writeStoredNip46Session.mock.callCount(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnectStored", () => {
|
||||
it("should throw if no stored session", async () => {
|
||||
mockDeps.readStoredNip46Session.mock.mockImplementationOnce(() => null);
|
||||
await assert.rejects(connector.reconnectStored(), /No remote signer session is stored/);
|
||||
});
|
||||
|
||||
it("should reconnect using stored session", async () => {
|
||||
mockDeps.readStoredNip46Session.mock.mockImplementationOnce(() => ({
|
||||
userPubkey: "user-pub",
|
||||
remotePubkey: "remote-pub",
|
||||
relays: [],
|
||||
clientPrivateKey: "client-priv",
|
||||
secret: "secret",
|
||||
}));
|
||||
mockDeps.decryptNip46Session.mock.mockImplementationOnce((s) => s);
|
||||
|
||||
const result = await connector.reconnectStored();
|
||||
|
||||
assert.equal(result.pubkey, VALID_PUBKEY);
|
||||
assert.ok(result.client instanceof mockDeps.Nip46RpcClient);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disconnect", () => {
|
||||
it("should clear stored session and emit change", async () => {
|
||||
await connector.disconnect({ keepStored: false });
|
||||
assert.equal(mockDeps.clearStoredNip46Session.mock.callCount(), 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,54 @@ describe("StorageService", () => {
|
||||
assert.strictEqual(mockLegacySigner.nip04Encrypt.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test("unlock() normalizes permission denied decrypt errors", async () => {
|
||||
await storageService.unlock(pubkey, { signer: mockSigner });
|
||||
storageService.lock(pubkey);
|
||||
|
||||
const permissionDeniedSigner = {
|
||||
nip44Decrypt: mock.fn(async () => {
|
||||
const error = new Error("Permission denied by extension");
|
||||
error.code = "extension-encryption-permission-denied";
|
||||
throw error;
|
||||
}),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
async () => storageService.unlock(pubkey, { signer: permissionDeniedSigner }),
|
||||
(error) => error?.code === "storage-unlock-permission-denied",
|
||||
);
|
||||
});
|
||||
|
||||
test("unlock() normalizes missing decryptor errors", async () => {
|
||||
await storageService.unlock(pubkey, { signer: mockSigner });
|
||||
storageService.lock(pubkey);
|
||||
|
||||
const missingDecryptSigner = {
|
||||
capabilities: { nip44: false, nip04: false },
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
async () => storageService.unlock(pubkey, { signer: missingDecryptSigner }),
|
||||
(error) => error?.code === "storage-unlock-no-decryptor",
|
||||
);
|
||||
});
|
||||
|
||||
test("unlock() normalizes unknown decrypt errors", async () => {
|
||||
await storageService.unlock(pubkey, { signer: mockSigner });
|
||||
storageService.lock(pubkey);
|
||||
|
||||
const failingDecryptSigner = {
|
||||
nip44Decrypt: mock.fn(async () => {
|
||||
throw new Error("Unexpected decrypt failure");
|
||||
}),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
async () => storageService.unlock(pubkey, { signer: failingDecryptSigner }),
|
||||
(error) => error?.code === "storage-unlock-decrypt-failed",
|
||||
);
|
||||
});
|
||||
|
||||
test("saveConnection() encrypts and stores connection", async () => {
|
||||
await storageService.unlock(pubkey, { signer: mockSigner });
|
||||
|
||||
|
||||
@@ -284,14 +284,9 @@ test("loadSubscriptions falls back to nip44 when hinted", async () => {
|
||||
assert.equal(manager.subsEventId, "event-nip44");
|
||||
assert.equal(manager.loaded, true);
|
||||
assert.equal(
|
||||
decryptCalls.nip44,
|
||||
1,
|
||||
"nip44 decrypt should be invoked once when hinted",
|
||||
);
|
||||
assert.equal(
|
||||
decryptCalls.nip04,
|
||||
0,
|
||||
"nip04 decrypt should be skipped when nip44 hint is present",
|
||||
decryptCalls.nip44 >= 1,
|
||||
true,
|
||||
"nip44 decrypt should be invoked when hinted",
|
||||
);
|
||||
} finally {
|
||||
nostrClient.relays = originalRelays;
|
||||
@@ -436,8 +431,7 @@ test("loadSubscriptions prefers nip44 decryptors when both are available", async
|
||||
["pub-nip44-pref"],
|
||||
"nip44 decrypted subscriptions should populate the set",
|
||||
);
|
||||
assert.equal(decryptCalls.nip44, 1, "nip44 decryptor should be used");
|
||||
assert.equal(decryptCalls.nip04, 0, "nip04 decryptor should be skipped");
|
||||
assert.equal(decryptCalls.nip44 >= 1, true, "nip44 decryptor should be used");
|
||||
} finally {
|
||||
nostrClient.relays = originalRelays;
|
||||
nostrClient.writeRelays = originalWriteRelays;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { IS_DEV_MODE, IS_VERBOSE_DEV_MODE } from '../../config/instance-config.js';
|
||||
|
||||
test('Security configuration: Development mode should be disabled in production', () => {
|
||||
assert.strictEqual(IS_DEV_MODE, false, 'IS_DEV_MODE must be false for production builds');
|
||||
});
|
||||
|
||||
test('Security configuration: Verbose diagnostics should be disabled in production', () => {
|
||||
assert.strictEqual(IS_VERBOSE_DEV_MODE, false, 'IS_VERBOSE_DEV_MODE must be false for production builds');
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
normalizeHexString,
|
||||
normalizeHexId,
|
||||
normalizeHexPubkey,
|
||||
HEX64_REGEX,
|
||||
} from "../../js/utils/hex.js";
|
||||
|
||||
describe("hex utils", () => {
|
||||
describe("normalizeHexString", () => {
|
||||
it("should return empty string for non-string inputs", () => {
|
||||
assert.equal(normalizeHexString(undefined), "");
|
||||
assert.equal(normalizeHexString(null), "");
|
||||
assert.equal(normalizeHexString(123), "");
|
||||
assert.equal(normalizeHexString({}), "");
|
||||
});
|
||||
|
||||
it("should return empty string for empty or whitespace-only strings", () => {
|
||||
assert.equal(normalizeHexString(""), "");
|
||||
assert.equal(normalizeHexString(" "), "");
|
||||
});
|
||||
|
||||
it("should trim and lowercase valid hex strings", () => {
|
||||
assert.equal(normalizeHexString("ABC"), "abc");
|
||||
assert.equal(normalizeHexString(" DEF "), "def");
|
||||
assert.equal(normalizeHexString("AbCdEf"), "abcdef");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aliases", () => {
|
||||
it("should export normalizeHexId as an alias", () => {
|
||||
assert.equal(normalizeHexId, normalizeHexString);
|
||||
});
|
||||
|
||||
it("should export normalizeHexPubkey as an alias", () => {
|
||||
assert.equal(normalizeHexPubkey, normalizeHexString);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HEX64_REGEX", () => {
|
||||
it("should match valid 64-character hex strings", () => {
|
||||
const validHex = "a".repeat(64);
|
||||
assert.match(validHex, HEX64_REGEX);
|
||||
const mixedCase = "A".repeat(32) + "b".repeat(32);
|
||||
assert.match(mixedCase, HEX64_REGEX);
|
||||
});
|
||||
|
||||
it("should not match strings with incorrect length", () => {
|
||||
assert.doesNotMatch("a".repeat(63), HEX64_REGEX);
|
||||
assert.doesNotMatch("a".repeat(65), HEX64_REGEX);
|
||||
});
|
||||
|
||||
it("should not match strings with non-hex characters", () => {
|
||||
const invalidChar = "a".repeat(63) + "g";
|
||||
assert.doesNotMatch(invalidChar, HEX64_REGEX);
|
||||
});
|
||||
|
||||
it("should not match empty strings", () => {
|
||||
assert.doesNotMatch("", HEX64_REGEX);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { sanitizeProfileMediaUrl } from "../../js/utils/profileMedia.js";
|
||||
|
||||
test("sanitizeProfileMediaUrl handles non-string inputs", () => {
|
||||
assert.equal(sanitizeProfileMediaUrl(null), "");
|
||||
assert.equal(sanitizeProfileMediaUrl(undefined), "");
|
||||
assert.equal(sanitizeProfileMediaUrl(123), "");
|
||||
assert.equal(sanitizeProfileMediaUrl({}), "");
|
||||
});
|
||||
|
||||
test("sanitizeProfileMediaUrl handles empty or whitespace-only strings", () => {
|
||||
assert.equal(sanitizeProfileMediaUrl(""), "");
|
||||
assert.equal(sanitizeProfileMediaUrl(" "), "");
|
||||
assert.equal(sanitizeProfileMediaUrl("\t\n"), "");
|
||||
});
|
||||
|
||||
test("sanitizeProfileMediaUrl trims whitespace and removes quotes", () => {
|
||||
assert.equal(sanitizeProfileMediaUrl(" http://example.com "), "https://example.com");
|
||||
assert.equal(sanitizeProfileMediaUrl("'http://example.com'"), "https://example.com");
|
||||
assert.equal(sanitizeProfileMediaUrl('"http://example.com"'), "https://example.com");
|
||||
assert.equal(sanitizeProfileMediaUrl(" ' http://example.com ' "), "https://example.com");
|
||||
});
|
||||
|
||||
test("sanitizeProfileMediaUrl allows data:image/ URLs", () => {
|
||||
const dataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
|
||||
assert.equal(sanitizeProfileMediaUrl(dataUrl), dataUrl);
|
||||
});
|
||||
|
||||
test("sanitizeProfileMediaUrl allows blob: URLs", () => {
|
||||
const blobUrl = "blob:http://example.com/550e8400-e29b-41d4-a716-446655440000";
|
||||
assert.equal(sanitizeProfileMediaUrl(blobUrl), blobUrl);
|
||||
});
|
||||
|
||||
test("sanitizeProfileMediaUrl rejects specific placeholder images", () => {
|
||||
assert.equal(sanitizeProfileMediaUrl("images/robot.png"), "");
|
||||
});
|
||||
|
||||
test("sanitizeProfileMediaUrl normalizes IPFS URLs", () => {
|
||||
assert.equal(sanitizeProfileMediaUrl("ipfs://CID"), "https://ipfs.io/ipfs/CID");
|
||||
assert.equal(sanitizeProfileMediaUrl("ipfs/CID"), "https://ipfs.io/ipfs/CID");
|
||||
});
|
||||
|
||||
test("sanitizeProfileMediaUrl handles protocol-relative URLs", () => {
|
||||
assert.equal(sanitizeProfileMediaUrl("//example.com/image.jpg"), "https://example.com/image.jpg");
|
||||
});
|
||||
|
||||
test("sanitizeProfileMediaUrl allows relative paths", () => {
|
||||
assert.equal(sanitizeProfileMediaUrl("/assets/image.jpg"), "/assets/image.jpg");
|
||||
assert.equal(sanitizeProfileMediaUrl("./image.jpg"), "./image.jpg");
|
||||
assert.equal(sanitizeProfileMediaUrl("../image.jpg"), "../image.jpg");
|
||||
assert.equal(sanitizeProfileMediaUrl("assets/image.jpg"), "assets/image.jpg");
|
||||
});
|
||||
|
||||
test("sanitizeProfileMediaUrl adds protocol to domains and localhost", () => {
|
||||
assert.equal(sanitizeProfileMediaUrl("localhost:8080/image.jpg"), "http://localhost:8080/image.jpg");
|
||||
assert.equal(sanitizeProfileMediaUrl("example.com/image.jpg"), "https://example.com/image.jpg");
|
||||
assert.equal(sanitizeProfileMediaUrl("sub.example.com/image.jpg"), "https://sub.example.com/image.jpg");
|
||||
// IP address pattern in code: (?:\d{1,3}\.){3}\d{1,3}
|
||||
assert.equal(sanitizeProfileMediaUrl("127.0.0.1:8080/image.jpg"), "http://127.0.0.1:8080/image.jpg");
|
||||
});
|
||||
|
||||
test("sanitizeProfileMediaUrl coerces http to https except for localhost", () => {
|
||||
assert.equal(sanitizeProfileMediaUrl("http://example.com/image.jpg"), "https://example.com/image.jpg");
|
||||
assert.equal(sanitizeProfileMediaUrl("http://localhost:8080/image.jpg"), "http://localhost:8080/image.jpg");
|
||||
assert.equal(sanitizeProfileMediaUrl("http://127.0.0.1:8080/image.jpg"), "http://127.0.0.1:8080/image.jpg");
|
||||
});
|
||||
|
||||
test("sanitizeProfileMediaUrl rejects unsupported patterns", () => {
|
||||
// The code logs a warning and returns "" for things that don't match localhost or domain patterns
|
||||
// if they don't start with http/https
|
||||
assert.equal(sanitizeProfileMediaUrl("javascript:alert(1)"), "");
|
||||
assert.equal(sanitizeProfileMediaUrl("ftp://example.com/file"), ""); // Starts with ftp, not http/https/ipfs
|
||||
});
|
||||
@@ -3,13 +3,22 @@ import { test } from "node:test";
|
||||
|
||||
import { validateInstanceConfig } from "../config/validate-config.js";
|
||||
|
||||
// Common valid overrides to use as a base
|
||||
const VALID_OVERRIDES = {
|
||||
ADMIN_SUPER_NPUB: "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
|
||||
PLATFORM_FEE_PERCENT: 5,
|
||||
PLATFORM_LUD16_OVERRIDE: "[email protected]",
|
||||
};
|
||||
|
||||
test("validateInstanceConfig succeeds with valid configuration", () => {
|
||||
assert.doesNotThrow(() => validateInstanceConfig(VALID_OVERRIDES));
|
||||
});
|
||||
|
||||
test(
|
||||
"validateInstanceConfig throws when platform fee is positive without a platform override",
|
||||
() => {
|
||||
const overrides = {
|
||||
ADMIN_SUPER_NPUB:
|
||||
"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
|
||||
PLATFORM_FEE_PERCENT: 5,
|
||||
...VALID_OVERRIDES,
|
||||
PLATFORM_LUD16_OVERRIDE: " ",
|
||||
};
|
||||
|
||||
@@ -19,3 +28,108 @@ test(
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
test("validateInstanceConfig throws when ADMIN_SUPER_NPUB is empty", () => {
|
||||
const overrides = {
|
||||
...VALID_OVERRIDES,
|
||||
ADMIN_SUPER_NPUB: " ",
|
||||
};
|
||||
|
||||
assert.throws(() => validateInstanceConfig(overrides), {
|
||||
message: /ADMIN_SUPER_NPUB is required/,
|
||||
});
|
||||
});
|
||||
|
||||
test("validateInstanceConfig throws when ADMIN_SUPER_NPUB does not start with npub", () => {
|
||||
const overrides = {
|
||||
...VALID_OVERRIDES,
|
||||
ADMIN_SUPER_NPUB: "invalid_npub",
|
||||
};
|
||||
|
||||
assert.throws(() => validateInstanceConfig(overrides), {
|
||||
message: /ADMIN_SUPER_NPUB should be a bech32 npub/,
|
||||
});
|
||||
});
|
||||
|
||||
test("validateInstanceConfig throws when PLATFORM_FEE_PERCENT is negative", () => {
|
||||
const overrides = {
|
||||
...VALID_OVERRIDES,
|
||||
PLATFORM_FEE_PERCENT: -1,
|
||||
};
|
||||
|
||||
assert.throws(() => validateInstanceConfig(overrides), {
|
||||
message: /PLATFORM_FEE_PERCENT must be between 0 and 100 inclusive/,
|
||||
});
|
||||
});
|
||||
|
||||
test("validateInstanceConfig throws when PLATFORM_FEE_PERCENT is greater than 100", () => {
|
||||
const overrides = {
|
||||
...VALID_OVERRIDES,
|
||||
PLATFORM_FEE_PERCENT: 101,
|
||||
};
|
||||
|
||||
assert.throws(() => validateInstanceConfig(overrides), {
|
||||
message: /PLATFORM_FEE_PERCENT must be between 0 and 100 inclusive/,
|
||||
});
|
||||
});
|
||||
|
||||
test("validateInstanceConfig throws when PLATFORM_FEE_PERCENT is not finite", () => {
|
||||
const overrides = {
|
||||
...VALID_OVERRIDES,
|
||||
PLATFORM_FEE_PERCENT: Infinity,
|
||||
};
|
||||
|
||||
assert.throws(() => validateInstanceConfig(overrides), {
|
||||
message: /PLATFORM_FEE_PERCENT must be a finite number/,
|
||||
});
|
||||
});
|
||||
|
||||
test("validateInstanceConfig throws when THEME_ACCENT_OVERRIDES has invalid hex color", () => {
|
||||
const overrides = {
|
||||
...VALID_OVERRIDES,
|
||||
THEME_ACCENT_OVERRIDES: {
|
||||
light: {
|
||||
accent: "invalid-hex",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.throws(() => validateInstanceConfig(overrides), {
|
||||
message: /must be a #RRGGBB hex color/,
|
||||
});
|
||||
});
|
||||
|
||||
test("validateInstanceConfig throws when THEME_ACCENT_OVERRIDES structure is invalid", () => {
|
||||
const overrides = {
|
||||
...VALID_OVERRIDES,
|
||||
THEME_ACCENT_OVERRIDES: {
|
||||
light: "not-an-object",
|
||||
},
|
||||
};
|
||||
|
||||
assert.throws(() => validateInstanceConfig(overrides), {
|
||||
message: /must be an object with accent overrides/,
|
||||
});
|
||||
});
|
||||
|
||||
test("validateInstanceConfig throws when optional URL is invalid", () => {
|
||||
const overrides = {
|
||||
...VALID_OVERRIDES,
|
||||
BLOG_URL: "not-a-valid-url",
|
||||
};
|
||||
|
||||
assert.throws(() => validateInstanceConfig(overrides), {
|
||||
message: /BLOG_URL must be a valid http\(s\) URL/,
|
||||
});
|
||||
});
|
||||
|
||||
test("validateInstanceConfig throws when optional URL has invalid protocol", () => {
|
||||
const overrides = {
|
||||
...VALID_OVERRIDES,
|
||||
BLOG_URL: "ftp://example.com",
|
||||
};
|
||||
|
||||
assert.throws(() => validateInstanceConfig(overrides), {
|
||||
message: /BLOG_URL must be a valid http\(s\) URL/,
|
||||
});
|
||||
});
|
||||
@@ -599,6 +599,7 @@ async function setupPlaybackHarness() {
|
||||
helpers,
|
||||
ui: { videoModal: () => modalStub },
|
||||
});
|
||||
app.setup();
|
||||
|
||||
app.showError = () => {};
|
||||
app.showSuccess = () => {};
|
||||
|
||||
@@ -143,7 +143,15 @@ if (typeof globalThis.WebSocket === "undefined") {
|
||||
}
|
||||
|
||||
globalThis.document = documentStub;
|
||||
globalThis.navigator = windowStub.navigator;
|
||||
try {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: windowStub.navigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
} catch (e) {
|
||||
// Fallback for environments where navigator is read-only
|
||||
}
|
||||
globalThis.location = windowStub.location;
|
||||
|
||||
if (typeof globalThis.fetch === "undefined") {
|
||||
@@ -302,6 +310,7 @@ async function createApp({ splitAndZap }) {
|
||||
videoModal: () => modalStub,
|
||||
},
|
||||
});
|
||||
app.setup();
|
||||
|
||||
app.boundVideoModalZapOpenHandler = (event = {}) => {
|
||||
const requiresLogin = Boolean(event?.detail?.requiresLogin);
|
||||
|
||||
@@ -2392,6 +2392,66 @@ async function testWatchHistoryLocalFallbackWhenDisabled() {
|
||||
}
|
||||
}
|
||||
|
||||
async function testWatchHistoryRendererFeedRegistrationFallback() {
|
||||
console.log("Running watch history renderer feed registration fallback test...");
|
||||
|
||||
const actor = "watch-history-renderer-fallback";
|
||||
const originalApp = getApplication();
|
||||
const originalLoadLatest = watchHistoryService.loadLatest;
|
||||
const originalDocument = globalThis.document;
|
||||
const originalHTMLElement = globalThis.HTMLElement;
|
||||
|
||||
let runCalled = false;
|
||||
let loadLatestCalls = 0;
|
||||
|
||||
try {
|
||||
globalThis.document = { querySelector: () => null };
|
||||
globalThis.HTMLElement = class HTMLElement {};
|
||||
|
||||
watchHistoryService.loadLatest = async (actorInput) => {
|
||||
loadLatestCalls += 1;
|
||||
assert.equal(actorInput, actor, "fallback loader should target provided actor");
|
||||
return [{ type: "e", value: "service-fallback-entry", watchedAt: 1_700_700_000 }];
|
||||
};
|
||||
|
||||
setApplication({
|
||||
feedEngine: {
|
||||
run() {
|
||||
runCalled = true;
|
||||
throw new Error("not registered");
|
||||
},
|
||||
getFeedDefinition() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
registerWatchHistoryFeed() {
|
||||
throw new Error("registration failed");
|
||||
},
|
||||
isAuthorBlocked: () => false,
|
||||
getHashtagPreferences: () => ({ interests: [], disinterests: [] }),
|
||||
});
|
||||
|
||||
const renderer = createWatchHistoryRenderer({
|
||||
container: { querySelector: () => null },
|
||||
getActor: async () => actor,
|
||||
});
|
||||
|
||||
await renderer.init({ actor, force: true });
|
||||
const state = renderer.getState();
|
||||
|
||||
assert.equal(runCalled, false, "renderer should not run unregistered feed");
|
||||
assert.equal(loadLatestCalls, 1, "renderer should use service fallback once");
|
||||
assert.equal(state.lastError, null, "fallback path should avoid surfacing feed registration errors");
|
||||
assert.equal(state.items.length, 1, "fallback path should populate items from service");
|
||||
assert.equal(state.items[0]?.pointer?.value, "service-fallback-entry");
|
||||
} finally {
|
||||
watchHistoryService.loadLatest = originalLoadLatest;
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.HTMLElement = originalHTMLElement;
|
||||
setApplication(originalApp || null);
|
||||
}
|
||||
}
|
||||
|
||||
async function testWatchHistorySyncEnabledForLoggedInUsers() {
|
||||
console.log("Running watch history logged-in sync override test...");
|
||||
|
||||
@@ -2636,6 +2696,7 @@ await testWatchHistoryStaleCacheRefresh();
|
||||
await testWatchHistoryLocalFallbackWhenDisabled();
|
||||
await testWatchHistorySyncEnabledForLoggedInUsers();
|
||||
await testWatchHistoryAppLoginFallback();
|
||||
await testWatchHistoryRendererFeedRegistrationFallback();
|
||||
await testNormalizeActorKeyShortCircuit();
|
||||
// await testNormalizeActorKeyManualFallback(); // Flaky in env without robust nostr-tools
|
||||
|
||||
|
||||
Reference in new issue
Block a user