mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
68 files changed
+2740
-1195
No files matched your search
@@ -34,6 +34,15 @@ jobs:
|
||||
git remote add upstream https://github.com/PR0M3TH3AN/bitvid.git || true
|
||||
git fetch upstream
|
||||
|
||||
- name: Verify upstream branch and diff
|
||||
run: |
|
||||
if git ls-remote --heads upstream "${{ github.base_ref }}" | grep "${{ github.base_ref }}" > /dev/null; then
|
||||
git fetch upstream "${{ github.base_ref }}"
|
||||
git diff --name-only upstream/${{ github.base_ref }}...HEAD
|
||||
else
|
||||
echo "::warning::Branch upstream/${{ github.base_ref }} not found. Skipping diff."
|
||||
fi
|
||||
|
||||
- name: Run Agent Review
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -53,6 +53,32 @@ For **Mode 2** (Manual upload) or external content, provide a title plus an HTTP
|
||||
|
||||
Hosted URLs remain the preferred playback path, and you can still add a magnet or supplemental web seeds when using any mode. Use the **Private** toggle to keep the resulting card visible only to you, and lean on the repeaters whenever you want to surface richer context or alternate assets as outlined in the event schema reference.
|
||||
|
||||
### User content identifiers & storage layout
|
||||
|
||||
Every video note ships with a stable series identifier so edits and deletes can be stitched together reliably. bitvid sets both the `videoRootId` (content payload) and the NIP-33 `d` tag to the same value. When you publish, the identifier is chosen from the first provided value in `videoRootId`, `seriesId`, or `seriesIdentifier`; if none are supplied, bitvid generates a timestamp-based fallback. Keep this identifier stable across edits so older versions collapse correctly in the UI.【F:js/nostr/client.js†L5348-L5389】
|
||||
|
||||
Storage tags hang off that identifier to make asset discovery deterministic:
|
||||
|
||||
- **Storage pointer (`s` tag)**: The note includes `['s', '<provider>:<prefix>']`. If you don’t supply a pointer, bitvid derives one from the hosted URL (preferred), the torrent info hash (as `btih:<hash>`), or finally the `videoRootId` fallback (`nostr:<videoRootId>`).【F:js/nostr/client.js†L823-L862】
|
||||
- **Prefix derivation**: For URL-based assets, the prefix is the public base URL plus the object key **without** its file extension. For example, `/uploads/demo/video.mp4` becomes `/uploads/demo/video`.【F:js/utils/storagePointer.js†L56-L88】
|
||||
- **`info.json` location**: The companion metadata file lives at `<prefix>.info.json`. If the storage prefix is not an absolute URL, the UI uses the hosted URL’s origin to build the prefix before appending `.info.json` (and falls back to the raw prefix when no URL is available).【F:js/utils/storagePointer.js†L90-L129】
|
||||
- **`info.json` contents**: The file is expected to describe the stored assets that back the note—typically the hosted URLs and optional WebTorrent metadata (info hash, web seeds, torrent URL) along with the derived prefix—so the UI can hydrate asset metadata even when the note only carries minimal fields.
|
||||
|
||||
**How the UI resolves assets**
|
||||
|
||||
1. The NIP-71 parser reads the `s` tag and hosted URL from the note, then computes `infoJsonUrl` from the storage pointer and URL fallback rules.【F:js/nostr/nip71.js†L1479-L1510】【F:js/utils/storagePointer.js†L90-L129】
|
||||
2. Video cards embed the hosted URL, magnet, and `infoJsonUrl` as `data-play-url`, `data-play-magnet`, and `data-info-json` attributes for click handlers to consume.【F:js/ui/components/VideoCard.js†L2641-L2687】
|
||||
3. When a card is clicked, the view extracts those attributes; if `infoJsonUrl` is missing, playback still proceeds using the hosted URL/magnet from the note without the extra metadata file.【F:js/ui/views/VideoListView.js†L1032-L1072】
|
||||
|
||||
**Example object key layout**
|
||||
|
||||
```
|
||||
Storage pointer (s tag): s3:https://cdn.example.com/uploads/demo/video
|
||||
Hosted asset: https://cdn.example.com/uploads/demo/video.mp4
|
||||
Torrent metadata: https://cdn.example.com/uploads/demo/video.torrent
|
||||
Info JSON: https://cdn.example.com/uploads/demo/video.info.json
|
||||
```
|
||||
|
||||
### How playback works
|
||||
|
||||
1. **URL-first**: `playVideoWithFallback({ url, magnet })` attempts the hosted URL immediately. Healthy URLs deliver the full experience without touching P2P resources.
|
||||
|
||||
+56
-56
@@ -1,93 +1,93 @@
|
||||
{
|
||||
"timestamp": "2026-01-25T19:12:13.640Z",
|
||||
"timestamp": "2026-01-26T19:39:32.887Z",
|
||||
"config": {
|
||||
"duration": 60,
|
||||
"clients": 100,
|
||||
"rate": 1,
|
||||
"clients": 1000,
|
||||
"rate": 0.1,
|
||||
"relay": "ws://localhost:8889",
|
||||
"force": false
|
||||
},
|
||||
"metrics": {
|
||||
"total_sent": 6219,
|
||||
"total_received": 6219,
|
||||
"total_sent": 6467,
|
||||
"total_received": 6467,
|
||||
"errors": 0,
|
||||
"throughput_sent": 103.65,
|
||||
"throughput_recv": 103.65,
|
||||
"throughput_sent": 107.78333333333333,
|
||||
"throughput_recv": 107.78333333333333,
|
||||
"latency_ms": {
|
||||
"p50": 0.4525159999998323,
|
||||
"p95": 0.9470979999969131,
|
||||
"p99": 7.444698999999673
|
||||
"p50": 0.9700979999979609,
|
||||
"p95": 14.598196999999345,
|
||||
"p99": 27.9113440000001
|
||||
},
|
||||
"operation_times_ms": {
|
||||
"build_avg": 0.05414333365494229,
|
||||
"sign_avg": 5.486003140054635
|
||||
"build_avg": 0.10325683753031617,
|
||||
"sign_avg": 6.474848927014066
|
||||
},
|
||||
"resource_usage": [
|
||||
{
|
||||
"timestamp": 1769368278643,
|
||||
"cpuPercent": 63.94,
|
||||
"rss": 97239040,
|
||||
"heapUsed": 16337816
|
||||
"timestamp": 1769456317885,
|
||||
"cpuPercent": 74.54,
|
||||
"rss": 129236992,
|
||||
"heapUsed": 26275344
|
||||
},
|
||||
{
|
||||
"timestamp": 1769368283643,
|
||||
"cpuPercent": 64.36,
|
||||
"rss": 98402304,
|
||||
"heapUsed": 20259576
|
||||
"timestamp": 1769456322898,
|
||||
"cpuPercent": 71.77,
|
||||
"rss": 130256896,
|
||||
"heapUsed": 30151424
|
||||
},
|
||||
{
|
||||
"timestamp": 1769368288642,
|
||||
"cpuPercent": 63.86,
|
||||
"rss": 98762752,
|
||||
"heapUsed": 17659632
|
||||
"timestamp": 1769456327916,
|
||||
"cpuPercent": 70.52,
|
||||
"rss": 131235840,
|
||||
"heapUsed": 30829208
|
||||
},
|
||||
{
|
||||
"timestamp": 1769368293643,
|
||||
"cpuPercent": 61.32,
|
||||
"rss": 99790848,
|
||||
"heapUsed": 18155016
|
||||
"timestamp": 1769456332917,
|
||||
"cpuPercent": 72.98,
|
||||
"rss": 131637248,
|
||||
"heapUsed": 38053280
|
||||
},
|
||||
{
|
||||
"timestamp": 1769368298645,
|
||||
"cpuPercent": 61.19,
|
||||
"rss": 100999168,
|
||||
"heapUsed": 15496016
|
||||
"timestamp": 1769456337917,
|
||||
"cpuPercent": 78.13,
|
||||
"rss": 132292608,
|
||||
"heapUsed": 25903752
|
||||
},
|
||||
{
|
||||
"timestamp": 1769368303646,
|
||||
"cpuPercent": 63.12,
|
||||
"rss": 102313984,
|
||||
"heapUsed": 16290368
|
||||
"timestamp": 1769456342918,
|
||||
"cpuPercent": 92.71,
|
||||
"rss": 133566464,
|
||||
"heapUsed": 28111320
|
||||
},
|
||||
{
|
||||
"timestamp": 1769368308649,
|
||||
"cpuPercent": 61.61,
|
||||
"rss": 105914368,
|
||||
"heapUsed": 22963352
|
||||
"timestamp": 1769456347922,
|
||||
"cpuPercent": 94.4,
|
||||
"rss": 133959680,
|
||||
"heapUsed": 33235048
|
||||
},
|
||||
{
|
||||
"timestamp": 1769368313653,
|
||||
"cpuPercent": 60.4,
|
||||
"rss": 106831872,
|
||||
"heapUsed": 21787144
|
||||
"timestamp": 1769456352924,
|
||||
"cpuPercent": 77.78,
|
||||
"rss": 134717440,
|
||||
"heapUsed": 27477160
|
||||
},
|
||||
{
|
||||
"timestamp": 1769368318653,
|
||||
"cpuPercent": 60.86,
|
||||
"rss": 120463360,
|
||||
"heapUsed": 17511208
|
||||
"timestamp": 1769456357925,
|
||||
"cpuPercent": 86.22,
|
||||
"rss": 135241728,
|
||||
"heapUsed": 36644992
|
||||
},
|
||||
{
|
||||
"timestamp": 1769368323657,
|
||||
"cpuPercent": 60.52,
|
||||
"rss": 120987648,
|
||||
"heapUsed": 19454880
|
||||
"timestamp": 1769456362926,
|
||||
"cpuPercent": 79.21,
|
||||
"rss": 136028160,
|
||||
"heapUsed": 28313856
|
||||
},
|
||||
{
|
||||
"timestamp": 1769368328657,
|
||||
"cpuPercent": 59.65,
|
||||
"rss": 121643008,
|
||||
"heapUsed": 21375032
|
||||
"timestamp": 1769456367927,
|
||||
"cpuPercent": 71.57,
|
||||
"rss": 136814592,
|
||||
"heapUsed": 39710304
|
||||
}
|
||||
],
|
||||
"error_breakdown": {}
|
||||
|
||||
+13
-6
@@ -1,6 +1,13 @@
|
||||
tests/visual/moderation.spec.ts:89: // TODO: Investigate why visibility check fails in fixture environment (headless)
|
||||
tests/visual/moderation.spec.ts:93: // TODO: Investigate why click times out in fixture environment (headless)
|
||||
tests/visual/moderation.spec.ts:166: // TODO: Investigate visibility check failure
|
||||
tests/visual/moderation.spec.ts:170: // TODO: Investigate click timeout failure
|
||||
tests/visual/moderation.spec.ts:219: // TODO: Investigate visibility check failure
|
||||
tests/visual/moderation.spec.ts:223: // TODO: Investigate click timeout failure
|
||||
./scan_todos.cjs:19: if (line.includes('TODO') || line.includes('FIXME') || line.includes('XXX')) {
|
||||
./package-lock.json:3843: "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
./tests/visual/baselines.json:2: "contrast": "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 truncated
|
||||
./tests/visual/baselines.json:3: "default": "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 truncated
|
||||
./tests/visual/baselines.json:4: "light": "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 truncated
|
||||
./tests/visual/baselines.json:5: "video-modal-mobile": "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 truncated
|
||||
./tests/visual/moderation.spec.ts:89: // TODO: Investigate why visibility check fails in fixture environment (headless)
|
||||
./tests/visual/moderation.spec.ts:93: // TODO: Investigate why click times out in fixture environment (headless)
|
||||
./tests/visual/moderation.spec.ts:166: // TODO: Investigate visibility check failure
|
||||
./tests/visual/moderation.spec.ts:170: // TODO: Investigate click timeout failure
|
||||
./tests/visual/moderation.spec.ts:219: // TODO: Investigate visibility check failure
|
||||
./tests/visual/moderation.spec.ts:223: // TODO: Investigate click timeout failure
|
||||
./js/ui/profileModalController.js:203: "Formats npub strings for display using the canonical short representation (npubXXXX…XXXX).",
|
||||
@@ -33,7 +33,12 @@ const event = buildVideoPostEvent({
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
dTagValue: "debug-video",
|
||||
content: { version: 3, title: "Test", videoRootId: "debug" },
|
||||
content: {
|
||||
version: 3,
|
||||
title: "Test",
|
||||
videoRootId: "debug",
|
||||
infoHash: "0123456789abcdef0123456789abcdef01234567",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -145,7 +150,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
|
||||
|
||||
| Note | Kind (default) | Tags | Content format |
|
||||
| --- | --- | --- | --- |
|
||||
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `isNsfw`, `isForKids`, `enableComments`, `ws`, `xs`) |
|
||||
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]`, `['s', <storage pointer>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `isNsfw`, `isForKids`, `enableComments`, `infoHash`, `fileSha256`, `originalFileSha256`, `ws`, `xs`) |
|
||||
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
|
||||
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | JSON-serialized event being reposted (or empty if unavailable) |
|
||||
| Generic Repost (`NOTE_TYPES.GENERIC_REPOST`) | `16` | Same as Repost; specific for non-text kinds (e.g. videos). | Same as Repost. |
|
||||
@@ -158,6 +163,10 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
|
||||
|
||||
> **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).
|
||||
|
||||
Video posts now include a required storage pointer tag (`['s', '<provider>:<prefix>']`). The `<prefix>` should resolve to the public storage base for the video assets (for example, a public bucket URL plus the object key without its extension). Clients derive `info.json` by appending `.info.json` to the prefix (or by combining a path-style prefix with the hosted URL origin if needed).
|
||||
|
||||
Video posts should treat `videoRootId` as the stable series identifier that remains unchanged across edits and deletes. File fingerprints such as `infoHash` or `fileSha256` live alongside the content payload so media swaps do not break the version chain.
|
||||
|
||||
| NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', <title>]`, optional `['published_at', <unix seconds>]`, optional `['alt', <text>]`, repeated `['imeta', ...]` entries describing NIP-92 media variants, optional `['duration', <seconds>]`, repeated `['text-track', <url>, <kind>, <language>]`, optional `['content-warning', <reason>]`, repeated `['segment', <start>, <end>, <title>, <thumbnail>]`, repeated hashtags `['t', <tag>]`, repeated participants `['p', <pubkey>, <relay?>]`, repeated references `['r', <url>]` | Plain text summary carried in the content field. Publishing is gated by the `FEATURE_PUBLISH_NIP71` runtime flag while the rollout stabilizes. |
|
||||
| NIP-71 short video (`NOTE_TYPES.NIP71_SHORT_VIDEO`) | `22` | Same as `NOTE_TYPES.NIP71_VIDEO`; the kind differentiates short-form presentations. | Plain text summary; gated by `FEATURE_PUBLISH_NIP71`. |
|
||||
| Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content |
|
||||
|
||||
+16
-23
@@ -548,29 +548,22 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") {
|
||||
}
|
||||
|
||||
let events = [];
|
||||
try {
|
||||
if (pubkey) {
|
||||
events = await nostrClient.fetchListIncrementally({
|
||||
kind,
|
||||
pubkey,
|
||||
dTag,
|
||||
relayUrls: relays
|
||||
});
|
||||
} else {
|
||||
// Fallback for non-specific queries
|
||||
const normalizedFilter = {
|
||||
kinds: [kind],
|
||||
limit: 50
|
||||
};
|
||||
if (dTag) normalizedFilter["#d"] = [dTag];
|
||||
if (filter.authors) normalizedFilter.authors = filter.authors;
|
||||
events = await nostrClient.pool.list(relays, [normalizedFilter]);
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
`[adminListStore] Fetch failed for ${contextLabel}:`,
|
||||
error,
|
||||
);
|
||||
if (pubkey) {
|
||||
events = await nostrClient.fetchListIncrementally({
|
||||
kind,
|
||||
pubkey,
|
||||
dTag,
|
||||
relayUrls: relays
|
||||
});
|
||||
} else {
|
||||
// Fallback for non-specific queries
|
||||
const normalizedFilter = {
|
||||
kinds: [kind],
|
||||
limit: 50
|
||||
};
|
||||
if (dTag) normalizedFilter["#d"] = [dTag];
|
||||
if (filter.authors) normalizedFilter.authors = filter.authors;
|
||||
events = await nostrClient.pool.list(relays, [normalizedFilter]);
|
||||
}
|
||||
|
||||
if (!events || !events.length) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ALLOW_NSFW_CONTENT,
|
||||
} from "./config.js";
|
||||
import { accessControl } from "./accessControl.js";
|
||||
import { safeDecodeMagnet } from "./magnetUtils.js";
|
||||
import { extractBtihFromMagnet, safeDecodeMagnet } from "./magnetUtils.js";
|
||||
import { deriveTorrentPlaybackConfig } from "./playbackUtils.js";
|
||||
import {
|
||||
URL_FIRST_ENABLED,
|
||||
@@ -113,6 +113,7 @@ import {
|
||||
} from "./ui/components/staticModalAccessibility.js";
|
||||
import LoginModalController from "./ui/loginModalController.js";
|
||||
import EditModalController from "./ui/editModalController.js";
|
||||
import RevertModalController from "./ui/revertModalController.js";
|
||||
import TagPreferenceMenuController from "./ui/tagPreferenceMenuController.js";
|
||||
import ReactionController from "./ui/reactionController.js";
|
||||
import { pointerArrayToKey } from "./utils/pointer.js";
|
||||
@@ -420,6 +421,32 @@ class Application {
|
||||
},
|
||||
});
|
||||
|
||||
this.revertModalController = new RevertModalController({
|
||||
revertModal: this.modalManager?.revertModal,
|
||||
services: {
|
||||
nostrService: this.nostrService,
|
||||
nostrClient: nostrClient,
|
||||
},
|
||||
state: {
|
||||
getPubkey: () => this.pubkey,
|
||||
getBlacklistedEventIds: () => this.blacklistedEventIds,
|
||||
},
|
||||
ui: {
|
||||
showError: (msg) => this.showError(msg),
|
||||
showSuccess: (msg) => this.showSuccess(msg),
|
||||
},
|
||||
callbacks: {
|
||||
loadVideos: () => this.loadVideos(),
|
||||
forceRefreshAllProfiles: () => this.forceRefreshAllProfiles(),
|
||||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||||
},
|
||||
helpers: {
|
||||
normalizeActionTarget: (t) => this.normalizeActionTarget(t),
|
||||
resolveVideoActionTarget: (opts) => this.resolveVideoActionTarget(opts),
|
||||
formatAbsoluteTimestamp: (ts) => this.formatAbsoluteTimestamp(ts),
|
||||
},
|
||||
});
|
||||
|
||||
this.handleShareNostrSignerChange = () => {
|
||||
this.updateShareNostrAuthState({ reason: "signer-change" });
|
||||
};
|
||||
@@ -7817,96 +7844,14 @@ class Application {
|
||||
}
|
||||
|
||||
async handleRevertVideo(target) {
|
||||
try {
|
||||
const normalizedTarget = this.normalizeActionTarget(target);
|
||||
const { triggerElement } = normalizedTarget;
|
||||
const activeVideos = await this.nostrService.fetchVideos({
|
||||
blacklistedEventIds: this.blacklistedEventIds,
|
||||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||||
});
|
||||
const video = await this.resolveVideoActionTarget({
|
||||
...normalizedTarget,
|
||||
preloadedList: activeVideos,
|
||||
});
|
||||
|
||||
if (!this.pubkey) {
|
||||
this.showError("Please login to revert.");
|
||||
return;
|
||||
}
|
||||
const userPubkey = (this.pubkey || "").toLowerCase();
|
||||
const videoPubkey = (video?.pubkey || "").toLowerCase();
|
||||
if (!video || !videoPubkey || videoPubkey !== userPubkey) {
|
||||
this.showError("You do not own this video.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.revertModal) {
|
||||
this.showError("Revert modal is not available right now.");
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded = await this.revertModal.load();
|
||||
if (!loaded) {
|
||||
this.showError("Revert modal is not available right now.");
|
||||
return;
|
||||
}
|
||||
|
||||
const history = await nostrClient.hydrateVideoHistory(video);
|
||||
|
||||
this.revertModal.setHistory(video, history);
|
||||
this.revertModal.open({ video }, { triggerElement });
|
||||
} catch (err) {
|
||||
devLogger.error("Failed to revert video:", err);
|
||||
this.showError("Failed to load revision history. Please try again.");
|
||||
if (this.revertModalController) {
|
||||
return this.revertModalController.open(target);
|
||||
}
|
||||
}
|
||||
|
||||
async handleRevertModalConfirm(event) {
|
||||
const detail = event?.detail || {};
|
||||
const target = detail.target;
|
||||
const entries = Array.isArray(detail.entries)
|
||||
? detail.entries.slice()
|
||||
: [];
|
||||
|
||||
if (!target || !entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.pubkey) {
|
||||
this.showError("Please login to revert.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.revertModal) {
|
||||
this.showError("Revert modal is not available right now.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.revertModal.setBusy(true, "Reverting…");
|
||||
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
await nostrClient.revertVideo(
|
||||
{
|
||||
id: entry.id,
|
||||
pubkey: entry.pubkey,
|
||||
tags: entry.tags,
|
||||
},
|
||||
this.pubkey
|
||||
);
|
||||
}
|
||||
|
||||
await this.loadVideos();
|
||||
|
||||
const timestampLabel = this.formatAbsoluteTimestamp(target.created_at);
|
||||
this.showSuccess(`Reverted to revision from ${timestampLabel}.`);
|
||||
this.revertModal.close();
|
||||
this.forceRefreshAllProfiles();
|
||||
} catch (err) {
|
||||
devLogger.error("Failed to revert video:", err);
|
||||
this.showError("Failed to revert video. Please try again.");
|
||||
} finally {
|
||||
this.revertModal.setBusy(false);
|
||||
if (this.revertModalController) {
|
||||
return this.revertModalController.handleConfirm(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8571,9 +8516,9 @@ class Application {
|
||||
typeof video.infoHash === "string" ? video.infoHash.trim().toLowerCase() : "";
|
||||
const fallbackMagnetForCandidate = fallbackMagnetCandidate || "";
|
||||
if (!legacyInfoHash && fallbackMagnetForCandidate) {
|
||||
const match = fallbackMagnetForCandidate.match(/xt=urn:btih:([0-9a-z]+)/i);
|
||||
if (match && match[1]) {
|
||||
legacyInfoHash = match[1].toLowerCase();
|
||||
const extracted = extractBtihFromMagnet(fallbackMagnetForCandidate);
|
||||
if (extracted) {
|
||||
legacyInfoHash = extracted;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-1
@@ -4163,11 +4163,28 @@ function renderSubscribeButton(channelHex) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic UI update
|
||||
const wasSubscribed = toggleBtn.dataset.state === "subscribed";
|
||||
const nextState = wasSubscribed ? "unsubscribed" : "subscribed";
|
||||
const label = toggleBtn.querySelector("span");
|
||||
|
||||
toggleBtn.dataset.state = nextState;
|
||||
if (label) {
|
||||
label.textContent = nextState === "subscribed" ? "Unsubscribe" : "Subscribe";
|
||||
}
|
||||
|
||||
try {
|
||||
await subscriptions.toggleChannel(channelHex, currentApp.pubkey);
|
||||
// Re-render the button so it toggles state
|
||||
// Re-render to ensure state is perfectly synced with the service
|
||||
renderSubscribeButton(channelHex);
|
||||
} catch (err) {
|
||||
// Revert UI on failure
|
||||
toggleBtn.dataset.state = wasSubscribed ? "subscribed" : "unsubscribed";
|
||||
if (label) {
|
||||
label.textContent = wasSubscribed ? "Unsubscribe" : "Subscribe";
|
||||
}
|
||||
|
||||
userLogger.error("Failed to update subscription:", err);
|
||||
const permissionErrorCodes = new Set([
|
||||
"extension-permission-denied",
|
||||
|
||||
+39
-27
@@ -192,16 +192,20 @@ export async function loadDirectMessageSnapshot(pubkey) {
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (db) {
|
||||
const stored = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(dbKey);
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () =>
|
||||
reject(request.error || new Error("Failed to load snapshot"));
|
||||
});
|
||||
try {
|
||||
const stored = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(dbKey);
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () =>
|
||||
reject(request.error || new Error("Failed to load snapshot"));
|
||||
});
|
||||
|
||||
return normalizeSnapshotList(stored);
|
||||
return normalizeSnapshotList(stored);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
@@ -244,15 +248,19 @@ export async function saveDirectMessageSnapshot(pubkey, snapshot) {
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (db) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.put(normalizedSnapshot, dbKey);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () =>
|
||||
reject(request.error || new Error("Failed to write snapshot"));
|
||||
});
|
||||
return normalizedSnapshot;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.put(normalizedSnapshot, dbKey);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () =>
|
||||
reject(request.error || new Error("Failed to write snapshot"));
|
||||
});
|
||||
return normalizedSnapshot;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
@@ -290,15 +298,19 @@ export async function clearDirectMessageSnapshot(pubkey) {
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (db) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.delete(dbKey);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () =>
|
||||
reject(request.error || new Error("Failed to clear snapshot"));
|
||||
});
|
||||
cleared = true;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.delete(dbKey);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () =>
|
||||
reject(request.error || new Error("Failed to clear snapshot"));
|
||||
});
|
||||
cleared = true;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
|
||||
+92
-1
@@ -5,9 +5,11 @@ import {
|
||||
} from "./utils/safeDecode.js";
|
||||
|
||||
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
|
||||
const BASE32_INFO_HASH = /^[a-z2-7]{32}$/i;
|
||||
const BTIH_PREFIX = "urn:btih:";
|
||||
const MAGNET_SCHEME = "magnet:";
|
||||
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
|
||||
const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
||||
|
||||
export function safeDecodeMagnet(value) {
|
||||
if (typeof value !== "string") {
|
||||
@@ -56,6 +58,96 @@ export function normalizeForComparison(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase32ToHex(value) {
|
||||
if (typeof value !== "string" || !value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
let bits = 0;
|
||||
let buffer = 0;
|
||||
const bytes = [];
|
||||
|
||||
for (const char of normalized) {
|
||||
const index = BASE32_ALPHABET.indexOf(char);
|
||||
if (index === -1) {
|
||||
return "";
|
||||
}
|
||||
buffer = (buffer << 5) | index;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
const byte = (buffer >> bits) & 0xff;
|
||||
bytes.push(byte);
|
||||
}
|
||||
}
|
||||
|
||||
if (!bytes.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function normalizeInfoHash(value) {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (HEX_INFO_HASH.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
if (BASE32_INFO_HASH.test(trimmed)) {
|
||||
const hex = decodeBase32ToHex(trimmed);
|
||||
return hex ? hex.toLowerCase() : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractInfoHashFromXt(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (!lower.startsWith(BTIH_PREFIX)) {
|
||||
return "";
|
||||
}
|
||||
return normalizeInfoHash(trimmed.slice(BTIH_PREFIX.length));
|
||||
}
|
||||
|
||||
export function extractBtihFromMagnet(rawValue) {
|
||||
const decoded = safeDecodeMagnet(rawValue);
|
||||
const { initial, isMagnet, params } = normalizeMagnetInput(decoded);
|
||||
|
||||
if (!initial) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!isMagnet) {
|
||||
if (initial.toLowerCase().startsWith(BTIH_PREFIX)) {
|
||||
return normalizeInfoHash(initial.slice(BTIH_PREFIX.length));
|
||||
}
|
||||
return normalizeInfoHash(initial);
|
||||
}
|
||||
|
||||
for (const param of params) {
|
||||
if (param.lowerKey !== "xt") {
|
||||
continue;
|
||||
}
|
||||
const candidate = param.decoded || param.value;
|
||||
const normalized = extractInfoHashFromXt(candidate);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function createParam(key, value) {
|
||||
const trimmedValue = typeof value === "string" ? value.trim() : "";
|
||||
const decoded = safeDecodeURIComponentLoose(trimmedValue);
|
||||
@@ -342,4 +434,3 @@ export function extractMagnetHints(rawValue) {
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,17 @@ import {
|
||||
ensureTrackers,
|
||||
ensureTorrentHint,
|
||||
ensureWebSeeds,
|
||||
extractBtihFromMagnet as sharedExtractBtihFromMagnet,
|
||||
normalizeMagnetInput,
|
||||
normalizeInfoHash as sharedNormalizeInfoHash,
|
||||
resolveAppProtocol,
|
||||
safeDecodeMagnet as sharedSafeDecodeMagnet,
|
||||
} from "./magnetShared.js";
|
||||
|
||||
export { WSS_TRACKERS };
|
||||
export const safeDecodeMagnet = sharedSafeDecodeMagnet;
|
||||
export const extractBtihFromMagnet = sharedExtractBtihFromMagnet;
|
||||
export const normalizeInfoHash = sharedNormalizeInfoHash;
|
||||
|
||||
export function normalizeAndAugmentMagnet(
|
||||
rawValue,
|
||||
|
||||
+3
-54
@@ -1,35 +1,4 @@
|
||||
const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
||||
|
||||
function decodeBase32ToHex(value) {
|
||||
if (typeof value !== "string" || !value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
let bits = 0;
|
||||
let buffer = 0;
|
||||
const bytes = [];
|
||||
|
||||
for (const char of normalized) {
|
||||
const index = BASE32_ALPHABET.indexOf(char);
|
||||
if (index === -1) {
|
||||
return "";
|
||||
}
|
||||
buffer = (buffer << 5) | index;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
const byte = (buffer >> bits) & 0xff;
|
||||
bytes.push(byte);
|
||||
}
|
||||
}
|
||||
|
||||
if (bytes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
import { extractBtihFromMagnet, normalizeInfoHash } from "./magnetShared.js";
|
||||
|
||||
function extractInfoHashFromXt(value) {
|
||||
if (typeof value !== "string") {
|
||||
@@ -50,32 +19,12 @@ function extractInfoHashFromXt(value) {
|
||||
return normalizeInfoHash(payload);
|
||||
}
|
||||
|
||||
function normalizeInfoHash(candidate) {
|
||||
const trimmed = typeof candidate === "string" ? candidate.trim() : "";
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (/^[0-9a-f]{40}$/i.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
if (/^[a-z2-7]{32}$/i.test(trimmed)) {
|
||||
const hex = decodeBase32ToHex(trimmed);
|
||||
return hex ? hex.toLowerCase() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function infoHashFromMagnet(magnet) {
|
||||
if (typeof magnet !== "string") {
|
||||
return null;
|
||||
}
|
||||
const direct = normalizeInfoHash(magnet);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const parsed = parseMagnetLite(magnet);
|
||||
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
|
||||
return hash ? hash.toLowerCase() : null;
|
||||
const extracted = extractBtihFromMagnet(magnet);
|
||||
return extracted || null;
|
||||
}
|
||||
|
||||
export function trackersFromMagnet(magnet) {
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
// js/nostr/adapters/nip07Adapter.js
|
||||
|
||||
import { normalizeActorKey } from "../watchHistory.js";
|
||||
import { runNip07WithRetry } from "../nip07Permissions.js";
|
||||
|
||||
async function retryNip07Call(operation, label) {
|
||||
let lastError = null;
|
||||
const attempts = 2; // Retry once on failure
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return await runNip07WithRetry(operation, { label });
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
// Don't retry if user explicitly rejected
|
||||
if (
|
||||
error?.message?.toLowerCase().includes("denied") ||
|
||||
error?.message?.toLowerCase().includes("rejected")
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (i < attempts - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function resolveNip44Module(extension) {
|
||||
const nip44 = extension?.nip44 && typeof extension.nip44 === "object" ? extension.nip44 : null;
|
||||
@@ -16,9 +40,19 @@ function resolveNip44Module(extension) {
|
||||
return nip44;
|
||||
}
|
||||
|
||||
export async function createNip07Adapter(extension) {
|
||||
const resolvedExtension = extension || (typeof window !== "undefined" ? window?.nostr : null);
|
||||
if (!resolvedExtension) {
|
||||
export async function createNip07Adapter(initialExtension) {
|
||||
const getExtension = () => {
|
||||
if (typeof window !== "undefined" && window.nostr) {
|
||||
return window.nostr;
|
||||
}
|
||||
return initialExtension || null;
|
||||
};
|
||||
|
||||
// We use the initial extension state to determine which methods to expose.
|
||||
// This matches standard behavior where capabilities are negotiated at login.
|
||||
const bootstrapExtension = getExtension();
|
||||
|
||||
if (!bootstrapExtension) {
|
||||
return {
|
||||
type: "nip07",
|
||||
pubkey: "",
|
||||
@@ -41,40 +75,89 @@ export async function createNip07Adapter(extension) {
|
||||
};
|
||||
}
|
||||
|
||||
const getPublicKey =
|
||||
typeof resolvedExtension.getPublicKey === "function"
|
||||
? resolvedExtension.getPublicKey.bind(resolvedExtension)
|
||||
: null;
|
||||
const pubkey = getPublicKey ? normalizeActorKey(await getPublicKey()) : "";
|
||||
const getPublicKey = async () => {
|
||||
const ext = getExtension();
|
||||
if (typeof ext?.getPublicKey !== "function") {
|
||||
throw new Error("NIP-07 extension missing getPublicKey.");
|
||||
}
|
||||
return ext.getPublicKey();
|
||||
};
|
||||
|
||||
const nip04 =
|
||||
resolvedExtension.nip04 && typeof resolvedExtension.nip04 === "object"
|
||||
? resolvedExtension.nip04
|
||||
: null;
|
||||
const nip44 = resolveNip44Module(resolvedExtension);
|
||||
const pubkey =
|
||||
typeof bootstrapExtension.getPublicKey === "function"
|
||||
? normalizeActorKey(await getPublicKey())
|
||||
: "";
|
||||
|
||||
const nip04Encrypt =
|
||||
nip04 && typeof nip04.encrypt === "function" ? nip04.encrypt.bind(nip04) : null;
|
||||
const nip04Decrypt =
|
||||
nip04 && typeof nip04.decrypt === "function" ? nip04.decrypt.bind(nip04) : null;
|
||||
const hasNip04 =
|
||||
bootstrapExtension.nip04 && typeof bootstrapExtension.nip04 === "object";
|
||||
|
||||
const nip44Encrypt =
|
||||
nip44 && typeof nip44.encrypt === "function" ? nip44.encrypt.bind(nip44) : null;
|
||||
const nip44Decrypt =
|
||||
nip44 && typeof nip44.decrypt === "function" ? nip44.decrypt.bind(nip44) : null;
|
||||
const bootstrapNip44 = resolveNip44Module(bootstrapExtension);
|
||||
const hasNip44 = !!bootstrapNip44;
|
||||
|
||||
// Wrapper builders to ensure dynamic lookup at call time
|
||||
const createNip04Encrypt = () => (pubkey, plaintext) => {
|
||||
const ext = getExtension();
|
||||
const nip04 = ext?.nip04;
|
||||
if (!nip04 || typeof nip04.encrypt !== "function") {
|
||||
throw new Error("NIP-04 encryption unavailable on active extension.");
|
||||
}
|
||||
return retryNip07Call(() => nip04.encrypt(pubkey, plaintext), "nip04.encrypt");
|
||||
};
|
||||
|
||||
const createNip04Decrypt = () => (pubkey, ciphertext) => {
|
||||
const ext = getExtension();
|
||||
const nip04 = ext?.nip04;
|
||||
if (!nip04 || typeof nip04.decrypt !== "function") {
|
||||
throw new Error("NIP-04 decryption unavailable on active extension.");
|
||||
}
|
||||
return retryNip07Call(() => nip04.decrypt(pubkey, ciphertext), "nip04.decrypt");
|
||||
};
|
||||
|
||||
const createNip44Encrypt = () => (pubkey, plaintext) => {
|
||||
const ext = getExtension();
|
||||
const nip44 = resolveNip44Module(ext);
|
||||
if (!nip44 || typeof nip44.encrypt !== "function") {
|
||||
throw new Error("NIP-44 encryption unavailable on active extension.");
|
||||
}
|
||||
return retryNip07Call(() => nip44.encrypt(pubkey, plaintext), "nip44.encrypt");
|
||||
};
|
||||
|
||||
const createNip44Decrypt = () => (pubkey, ciphertext) => {
|
||||
const ext = getExtension();
|
||||
const nip44 = resolveNip44Module(ext);
|
||||
if (!nip44 || typeof nip44.decrypt !== "function") {
|
||||
throw new Error("NIP-44 decryption unavailable on active extension.");
|
||||
}
|
||||
return retryNip07Call(() => nip44.decrypt(pubkey, ciphertext), "nip44.decrypt");
|
||||
};
|
||||
|
||||
const requestPermissions = async (methods = []) => {
|
||||
if (typeof resolvedExtension.requestPermissions === "function") {
|
||||
return resolvedExtension.requestPermissions({
|
||||
permissions: Array.isArray(methods) ? methods : [],
|
||||
});
|
||||
const ext = getExtension();
|
||||
if (!ext) {
|
||||
return { ok: false, error: new Error("extension-unavailable") };
|
||||
}
|
||||
|
||||
if (typeof resolvedExtension.enable === "function") {
|
||||
if (typeof ext.requestPermissions === "function") {
|
||||
return retryNip07Call(
|
||||
() =>
|
||||
ext.requestPermissions({
|
||||
permissions: Array.isArray(methods) ? methods : [],
|
||||
}),
|
||||
"extension.requestPermissions",
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof ext.enable === "function") {
|
||||
if (Array.isArray(methods) && methods.length) {
|
||||
return resolvedExtension.enable({ permissions: methods.map((method) => ({ method })) });
|
||||
return retryNip07Call(
|
||||
() =>
|
||||
ext.enable({
|
||||
permissions: methods.map((method) => ({ method })),
|
||||
}),
|
||||
"extension.enable",
|
||||
);
|
||||
}
|
||||
return resolvedExtension.enable();
|
||||
return retryNip07Call(() => ext.enable(), "extension.enable");
|
||||
}
|
||||
|
||||
return { ok: false, error: new Error("permission-request-unavailable") };
|
||||
@@ -83,41 +166,51 @@ export async function createNip07Adapter(extension) {
|
||||
const signer = {
|
||||
type: "nip07",
|
||||
pubkey,
|
||||
metadata: async () =>
|
||||
typeof resolvedExtension.getMetadata === "function"
|
||||
? resolvedExtension.getMetadata()
|
||||
: null,
|
||||
relays: async () =>
|
||||
typeof resolvedExtension.getRelays === "function"
|
||||
? resolvedExtension.getRelays()
|
||||
: null,
|
||||
metadata: async () => {
|
||||
const ext = getExtension();
|
||||
return typeof ext?.getMetadata === "function"
|
||||
? retryNip07Call(() => ext.getMetadata(), "extension.getMetadata")
|
||||
: null;
|
||||
},
|
||||
relays: async () => {
|
||||
const ext = getExtension();
|
||||
return typeof ext?.getRelays === "function"
|
||||
? retryNip07Call(() => ext.getRelays(), "extension.getRelays")
|
||||
: null;
|
||||
},
|
||||
signEvent: async (event) => {
|
||||
if (typeof resolvedExtension.signEvent !== "function") {
|
||||
const ext = getExtension();
|
||||
if (typeof ext?.signEvent !== "function") {
|
||||
throw new Error("NIP-07 extension missing signEvent.");
|
||||
}
|
||||
return resolvedExtension.signEvent(event);
|
||||
return retryNip07Call(() => ext.signEvent(event), "extension.signEvent");
|
||||
},
|
||||
requestPermissions,
|
||||
destroy: async () => {},
|
||||
canSign: () => typeof resolvedExtension.signEvent === "function",
|
||||
canSign: () => typeof getExtension()?.signEvent === "function",
|
||||
capabilities: {
|
||||
sign: typeof resolvedExtension.signEvent === "function",
|
||||
nip44: Boolean(nip44Encrypt || nip44Decrypt),
|
||||
nip04: Boolean(nip04Encrypt || nip04Decrypt),
|
||||
sign: typeof bootstrapExtension.signEvent === "function",
|
||||
nip44: hasNip44,
|
||||
nip04: hasNip04,
|
||||
},
|
||||
};
|
||||
|
||||
if (nip04Encrypt) {
|
||||
signer.nip04Encrypt = nip04Encrypt;
|
||||
if (hasNip04) {
|
||||
if (typeof bootstrapExtension.nip04.encrypt === "function") {
|
||||
signer.nip04Encrypt = createNip04Encrypt();
|
||||
}
|
||||
if (typeof bootstrapExtension.nip04.decrypt === "function") {
|
||||
signer.nip04Decrypt = createNip04Decrypt();
|
||||
}
|
||||
}
|
||||
if (nip04Decrypt) {
|
||||
signer.nip04Decrypt = nip04Decrypt;
|
||||
}
|
||||
if (nip44Encrypt) {
|
||||
signer.nip44Encrypt = nip44Encrypt;
|
||||
}
|
||||
if (nip44Decrypt) {
|
||||
signer.nip44Decrypt = nip44Decrypt;
|
||||
|
||||
if (hasNip44) {
|
||||
if (typeof bootstrapNip44.encrypt === "function") {
|
||||
signer.nip44Encrypt = createNip44Encrypt();
|
||||
}
|
||||
if (typeof bootstrapNip44.decrypt === "function") {
|
||||
signer.nip44Decrypt = createNip44Decrypt();
|
||||
}
|
||||
}
|
||||
|
||||
return signer;
|
||||
|
||||
+265
-197
@@ -21,6 +21,7 @@ import { isDevMode } from "../config.js";
|
||||
import { FEATURE_PUBLISH_NIP71 } from "../constants.js";
|
||||
import { accessControl } from "../accessControl.js";
|
||||
import { bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js";
|
||||
import { infoHashFromMagnet } from "../magnets.js";
|
||||
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
|
||||
import {
|
||||
buildNip71MetadataTags,
|
||||
@@ -35,6 +36,12 @@ import {
|
||||
buildVideoPointerValue,
|
||||
stringFromInput,
|
||||
} from "./nip71.js";
|
||||
import {
|
||||
buildStoragePointerValue,
|
||||
deriveStoragePointerFromUrl,
|
||||
getStoragePointerFromTags,
|
||||
normalizeStoragePointer,
|
||||
} from "../utils/storagePointer.js";
|
||||
import {
|
||||
createWatchHistoryManager,
|
||||
normalizePointerInput,
|
||||
@@ -817,6 +824,40 @@ 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;
|
||||
|
||||
@@ -1219,6 +1260,7 @@ export class NostrClient {
|
||||
this.sessionActorCipherClosures = null;
|
||||
this.sessionActorCipherClosuresPrivateKey = null;
|
||||
this.countUnsupportedRelays = new Set();
|
||||
this.unreachableRelays = new Set();
|
||||
this.isInitialized = false;
|
||||
this.nip46Client = null;
|
||||
this.remoteSignerListeners = new Set();
|
||||
@@ -2582,6 +2624,16 @@ export class NostrClient {
|
||||
);
|
||||
|
||||
if (!outstanding.length) {
|
||||
// Even if permissions are cached, we must ensure the extension object is actually
|
||||
// injected and available on the window before returning success.
|
||||
// This prevents race conditions where the app loads faster than the extension.
|
||||
if (typeof window !== "undefined" && !window.nostr) {
|
||||
try {
|
||||
await waitForNip07Extension(3000);
|
||||
} catch (error) {
|
||||
return { ok: false, error: new Error("extension-unavailable") };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -2616,8 +2668,26 @@ export class NostrClient {
|
||||
return existingSigner;
|
||||
}
|
||||
|
||||
const extension =
|
||||
let extension =
|
||||
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
|
||||
|
||||
// If the extension is missing but we have cached permissions, it implies the user
|
||||
// previously logged in with an extension. We should wait briefly for injection
|
||||
// to resolve the common race condition where the app loads faster than the extension.
|
||||
if (!extension && this.extensionPermissionCache && this.extensionPermissionCache.size > 0) {
|
||||
try {
|
||||
extension = await waitForNip07Extension(3000);
|
||||
} catch (error) {
|
||||
// Fall through to existing signer check
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a signer appeared while we were waiting (e.g. NIP-46 or concurrent NIP-07 logic)
|
||||
const recheckedSigner = resolveActiveSigner(normalizedPubkey);
|
||||
if (recheckedSigner && typeof recheckedSigner.signEvent === "function") {
|
||||
return recheckedSigner;
|
||||
}
|
||||
|
||||
if (!extension) {
|
||||
return existingSigner;
|
||||
}
|
||||
@@ -3481,7 +3551,14 @@ export class NostrClient {
|
||||
* @param {function} [params.fetchFn] - Custom fetch function (mocks or specialized logic). Defaults to `pool.list`.
|
||||
* @returns {Promise<import("nostr-tools").Event[]>} Deduplicated list of events found across all relays.
|
||||
*/
|
||||
async fetchListIncrementally({ kind, pubkey, dTag, relayUrls, fetchFn } = {}) {
|
||||
async fetchListIncrementally({
|
||||
kind,
|
||||
pubkey,
|
||||
dTag,
|
||||
relayUrls,
|
||||
fetchFn,
|
||||
since,
|
||||
} = {}) {
|
||||
if (!kind || !pubkey) {
|
||||
throw new Error("fetchListIncrementally requires kind and pubkey");
|
||||
}
|
||||
@@ -3495,9 +3572,9 @@ export class NostrClient {
|
||||
? relayUrls
|
||||
: this.relays;
|
||||
|
||||
const normalizedRelays = sanitizeRelayList(relaysToUse);
|
||||
const normalizedRelays = sanitizeRelayList(this.getHealthyRelays(relaysToUse));
|
||||
const results = [];
|
||||
const concurrencyLimit = 4;
|
||||
const concurrencyLimit = 8;
|
||||
|
||||
// We'll use the pool's list method if fetchFn isn't provided,
|
||||
// but we need to call it per-relay.
|
||||
@@ -3511,9 +3588,28 @@ export class NostrClient {
|
||||
chunks.push(normalizedRelays.slice(i, i + concurrencyLimit));
|
||||
}
|
||||
|
||||
let anySuccess = false;
|
||||
const failures = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const promises = chunk.map(async (relayUrl) => {
|
||||
const lastSeen = this.getSyncLastSeen(kind, normalizedPubkey, dTag, 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],
|
||||
@@ -3538,7 +3634,13 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
try {
|
||||
let events = await actualFetchFn(relayUrl, filter);
|
||||
// Wrap fetch with a timeout to prevent hanging on slow relays
|
||||
let events = await withRequestTimeout(
|
||||
actualFetchFn(relayUrl, filter),
|
||||
6000,
|
||||
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.
|
||||
@@ -3562,13 +3664,18 @@ export class NostrClient {
|
||||
this.updateSyncLastSeen(kind, normalizedPubkey, dTag, relayUrl, maxCreated);
|
||||
}
|
||||
|
||||
return events;
|
||||
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 actualFetchFn(relayUrl, filter);
|
||||
const events = await withRequestTimeout(
|
||||
actualFetchFn(relayUrl, filter),
|
||||
6000,
|
||||
null,
|
||||
`Full fetch fallback from ${relayUrl} timed out`
|
||||
);
|
||||
// Update lastSeen on success of fallback
|
||||
let maxCreated = 0;
|
||||
if (Array.isArray(events)) {
|
||||
@@ -3581,26 +3688,38 @@ export class NostrClient {
|
||||
if (maxCreated > 0) {
|
||||
this.updateSyncLastSeen(kind, normalizedPubkey, dTag, relayUrl, maxCreated);
|
||||
}
|
||||
return events || [];
|
||||
return { events: events || [], ok: true };
|
||||
} catch (fallbackError) {
|
||||
devLogger.warn(`[fetchListIncrementally] Full fetch fallback failed for ${relayUrl}`, fallbackError);
|
||||
return [];
|
||||
return { error: fallbackError, ok: false };
|
||||
}
|
||||
} else {
|
||||
devLogger.warn(`[fetchListIncrementally] Fetch failed for ${relayUrl}`, error);
|
||||
return [];
|
||||
return { error, ok: false };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const chunkResults = await Promise.all(promises);
|
||||
for (const res of chunkResults) {
|
||||
if (Array.isArray(res)) {
|
||||
results.push(...res);
|
||||
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) {
|
||||
@@ -3919,7 +4038,7 @@ export class NostrClient {
|
||||
// `unsub` to avoid leaking subscriptions. Note: any future change must still
|
||||
// provide a lightweight readiness check with similar timeout semantics.
|
||||
async connectToRelays() {
|
||||
return Promise.all(
|
||||
const results = await Promise.all(
|
||||
this.relays.map(
|
||||
(url) =>
|
||||
new Promise((resolve) => {
|
||||
@@ -3939,170 +4058,27 @@ export class NostrClient {
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.success) {
|
||||
this.unreachableRelays.delete(result.url);
|
||||
} else {
|
||||
this.unreachableRelays.add(result.url);
|
||||
if (isDevMode) {
|
||||
devLogger.warn(`[nostr] Marked relay as unreachable: ${result.url}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates the user via a NIP-07 browser extension (e.g., Alby, nos2x).
|
||||
*
|
||||
* **Process:**
|
||||
* 1. Checks for `window.nostr` presence.
|
||||
* 2. Requests permissions (sign_event, nip04, etc.) via `ensureExtensionPermissions`.
|
||||
* 3. Retrieves the public key.
|
||||
* 4. Validates access control (whitelist/blacklist) via `accessControl`.
|
||||
* 5. Sets the active signer to a `Nip07Adapter`.
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.allowAccountSelection=false] - Whether to prompt the extension to select an account (NIP-07 extension).
|
||||
* @param {string} [options.expectPubkey] - Enforce that the login matches a specific pubkey.
|
||||
* @returns {Promise<string>} The authenticated public key (hex).
|
||||
* @throws {Error} If extension is missing, permissions denied, or account blocked.
|
||||
*/
|
||||
async login(options = {}) {
|
||||
try {
|
||||
let extension = null;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 3;
|
||||
|
||||
while (!extension && attempts < maxAttempts) {
|
||||
try {
|
||||
await waitForNip07Extension();
|
||||
extension = window.nostr;
|
||||
} catch (waitError) {
|
||||
devLogger.log(
|
||||
`Timed out waiting for extension injection (attempt ${
|
||||
attempts + 1
|
||||
}/${maxAttempts}):`,
|
||||
waitError,
|
||||
);
|
||||
}
|
||||
|
||||
if (extension) {
|
||||
break;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
devLogger.log("Retrying NIP-07 detection...");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
extension = window.nostr;
|
||||
|
||||
if (!extension) {
|
||||
devLogger.log("No Nostr extension found");
|
||||
throw new Error(
|
||||
"Please install a Nostr extension (Alby, nos2x, etc.)."
|
||||
);
|
||||
}
|
||||
|
||||
const { allowAccountSelection = false, expectPubkey } =
|
||||
typeof options === "object" && options !== null ? options : {};
|
||||
const normalizedExpectedPubkey =
|
||||
typeof expectPubkey === "string" && expectPubkey.trim()
|
||||
? expectPubkey.trim().toLowerCase()
|
||||
: null;
|
||||
|
||||
if (typeof extension.getPublicKey !== "function") {
|
||||
throw new Error(
|
||||
"This NIP-07 extension is missing getPublicKey support. Please update the extension."
|
||||
);
|
||||
}
|
||||
|
||||
const permissionResult = await this.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
if (!permissionResult.ok) {
|
||||
const denialMessage =
|
||||
'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.';
|
||||
const denialError = new Error(denialMessage);
|
||||
if (permissionResult.error) {
|
||||
denialError.cause = permissionResult.error;
|
||||
}
|
||||
throw denialError;
|
||||
}
|
||||
|
||||
if (allowAccountSelection && typeof extension.selectAccounts === "function") {
|
||||
try {
|
||||
const selection = await runNip07WithRetry(
|
||||
() => extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined),
|
||||
{ label: "extension.selectAccounts" }
|
||||
);
|
||||
|
||||
const didCancelSelection =
|
||||
selection === undefined ||
|
||||
selection === null ||
|
||||
selection === false ||
|
||||
(Array.isArray(selection) && selection.length === 0);
|
||||
|
||||
if (didCancelSelection) {
|
||||
throw new Error("Account selection was cancelled.");
|
||||
}
|
||||
} catch (selectionErr) {
|
||||
const message =
|
||||
selectionErr && typeof selectionErr.message === "string"
|
||||
? selectionErr.message
|
||||
: "Account selection was cancelled.";
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), {
|
||||
label: "extension.getPublicKey",
|
||||
});
|
||||
if (!pubkey || typeof pubkey !== "string") {
|
||||
throw new Error(
|
||||
"The NIP-07 extension did not return a public key. Please try again."
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedExpectedPubkey &&
|
||||
pubkey.toLowerCase() !== normalizedExpectedPubkey
|
||||
) {
|
||||
throw new Error(
|
||||
"The selected account doesn't match the expected profile. Please try again."
|
||||
);
|
||||
}
|
||||
const nip19Tools = await ensureNostrTools();
|
||||
const npubEncode = nip19Tools?.nip19?.npubEncode;
|
||||
if (typeof npubEncode !== "function") {
|
||||
throw new Error("NostrTools nip19 encoder is unavailable.");
|
||||
}
|
||||
const npub = npubEncode(pubkey);
|
||||
|
||||
devLogger.log("Got pubkey:", pubkey);
|
||||
devLogger.log("Converted to npub:", npub);
|
||||
devLogger.log("Whitelist:", accessControl.getWhitelist());
|
||||
devLogger.log("Blacklist:", accessControl.getBlacklist());
|
||||
// Access control
|
||||
if (!accessControl.canAccess(npub)) {
|
||||
if (accessControl.isBlacklisted(npub)) {
|
||||
throw new Error("Your account has been blocked on this platform.");
|
||||
} else {
|
||||
throw new Error("Access restricted to admins and moderators users only.");
|
||||
}
|
||||
}
|
||||
this.pubkey = pubkey;
|
||||
devLogger.log("Logged in with extension. Pubkey:", this.pubkey);
|
||||
|
||||
const adapter = await createNip07Adapter(extension);
|
||||
adapter.pubkey = pubkey;
|
||||
setActiveSigner(adapter);
|
||||
|
||||
const postLoginPermissions = await this.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
if (!postLoginPermissions.ok && postLoginPermissions.error) {
|
||||
userLogger.warn(
|
||||
"[nostr] Extension permissions were not fully granted after login:",
|
||||
postLoginPermissions.error,
|
||||
);
|
||||
}
|
||||
return this.pubkey;
|
||||
} catch (err) {
|
||||
userLogger.error("Login error:", err);
|
||||
throw err;
|
||||
getHealthyRelays(candidates) {
|
||||
const source = Array.isArray(candidates) ? candidates : this.relays;
|
||||
if (!this.unreachableRelays.size) {
|
||||
return source;
|
||||
}
|
||||
return source.filter((url) => !this.unreachableRelays.has(url));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4402,7 +4378,7 @@ export class NostrClient {
|
||||
: Array.isArray(this.readRelays) && this.readRelays.length
|
||||
? this.readRelays
|
||||
: this.relays;
|
||||
const relays = sanitizeRelayList(relayCandidates);
|
||||
const relays = sanitizeRelayList(this.getHealthyRelays(relayCandidates));
|
||||
const relaysToUse = relays.length ? relays : Array.from(DEFAULT_RELAY_URLS);
|
||||
|
||||
const filters = buildDmFilters(
|
||||
@@ -4474,7 +4450,7 @@ export class NostrClient {
|
||||
: Array.isArray(this.readRelays) && this.readRelays.length
|
||||
? this.readRelays
|
||||
: this.relays;
|
||||
const relays = sanitizeRelayList(relayCandidates);
|
||||
const relays = sanitizeRelayList(this.getHealthyRelays(relayCandidates));
|
||||
const relaysToUse = relays.length ? relays : Array.from(DEFAULT_RELAY_URLS);
|
||||
|
||||
const filters = buildDmFilters(actorPubkeyInput, options);
|
||||
@@ -5326,7 +5302,8 @@ export class NostrClient {
|
||||
*
|
||||
* **Payload Construction:**
|
||||
* - Creates a V3 video note with `magnet`, `url` (WebSeed), and core metadata.
|
||||
* - Generates a unique `d` tag and `videoRootId` for this new series.
|
||||
* - Generates a unique `d` tag and `videoRootId` for this new series unless
|
||||
* an explicit identifier is provided in the upload payload.
|
||||
*
|
||||
* **Side Effects (in order):**
|
||||
* 1. **Primary Event**: Signs and publishes the Kind 30078 Video Note.
|
||||
@@ -5383,9 +5360,26 @@ export class NostrClient {
|
||||
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
// brand-new root & d
|
||||
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const seriesIdentifierCandidates = [
|
||||
videoData.videoRootId,
|
||||
videoData.seriesId,
|
||||
videoData.seriesIdentifier,
|
||||
];
|
||||
let seriesIdentifier = "";
|
||||
for (const candidate of seriesIdentifierCandidates) {
|
||||
const normalized = typeof candidate === "string" ? candidate.trim() : "";
|
||||
if (normalized) {
|
||||
seriesIdentifier = normalized;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!seriesIdentifier) {
|
||||
seriesIdentifier = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
const videoRootId = seriesIdentifier;
|
||||
const dTagValue = seriesIdentifier;
|
||||
|
||||
const finalEnableComments =
|
||||
videoData.enableComments === false ? false : true;
|
||||
@@ -5397,6 +5391,27 @@ export class NostrClient {
|
||||
typeof videoData.ws === "string" ? videoData.ws.trim() : "";
|
||||
const finalXs =
|
||||
typeof videoData.xs === "string" ? videoData.xs.trim() : "";
|
||||
const infoHashCandidates = [videoData.infoHash];
|
||||
let infoHash = "";
|
||||
for (const candidate of infoHashCandidates) {
|
||||
const normalized = infoHashFromMagnet(candidate);
|
||||
if (normalized) {
|
||||
infoHash = normalized;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!infoHash && finalMagnet) {
|
||||
infoHash = infoHashFromMagnet(finalMagnet) || "";
|
||||
}
|
||||
const fileSha256 = normalizeHexHash(videoData.fileSha256);
|
||||
const originalFileSha256 = normalizeHexHash(videoData.originalFileSha256);
|
||||
const storagePointer = resolveStoragePointerValue({
|
||||
storagePointer: videoData.storagePointer,
|
||||
url: finalUrl,
|
||||
infoHash,
|
||||
fallbackId: videoRootId,
|
||||
provider: videoData.storageProvider,
|
||||
});
|
||||
|
||||
const contentObject = {
|
||||
version: 3,
|
||||
@@ -5414,6 +5429,18 @@ export class NostrClient {
|
||||
enableComments: finalEnableComments,
|
||||
};
|
||||
|
||||
if (infoHash) {
|
||||
contentObject.infoHash = infoHash;
|
||||
}
|
||||
|
||||
if (fileSha256) {
|
||||
contentObject.fileSha256 = fileSha256;
|
||||
}
|
||||
|
||||
if (originalFileSha256) {
|
||||
contentObject.originalFileSha256 = originalFileSha256;
|
||||
}
|
||||
|
||||
if (finalWs) {
|
||||
contentObject.ws = finalWs;
|
||||
}
|
||||
@@ -5426,15 +5453,18 @@ export class NostrClient {
|
||||
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null
|
||||
);
|
||||
|
||||
const additionalTags = storagePointer
|
||||
? [["s", storagePointer], ...nip71Tags]
|
||||
: nip71Tags;
|
||||
const event = buildVideoPostEvent({
|
||||
pubkey: normalizedPubkey,
|
||||
created_at: createdAt,
|
||||
dTagValue,
|
||||
content: contentObject,
|
||||
additionalTags: nip71Tags,
|
||||
additionalTags,
|
||||
});
|
||||
|
||||
devLogger.log("Publish event with brand-new root:", videoRootId);
|
||||
devLogger.log("Publish event with series identifier:", videoRootId);
|
||||
devLogger.log("Event content:", event.content);
|
||||
|
||||
try {
|
||||
@@ -5787,8 +5817,31 @@ export class NostrClient {
|
||||
? false
|
||||
: true;
|
||||
|
||||
const updatedInfoHash = infoHashFromMagnet(updatedData?.infoHash || "");
|
||||
let infoHash =
|
||||
updatedInfoHash ||
|
||||
infoHashFromMagnet(finalMagnet) ||
|
||||
infoHashFromMagnet(baseEvent.infoHash) ||
|
||||
"";
|
||||
const fileSha256 =
|
||||
normalizeHexHash(updatedData?.fileSha256) ||
|
||||
normalizeHexHash(baseEvent.fileSha256);
|
||||
const originalFileSha256 =
|
||||
normalizeHexHash(updatedData?.originalFileSha256) ||
|
||||
normalizeHexHash(baseEvent.originalFileSha256);
|
||||
|
||||
// Use the existing videoRootId (or fall back to the base event's ID)
|
||||
const oldRootId = baseEvent.videoRootId || baseEvent.id;
|
||||
const storagePointer = resolveStoragePointerValue({
|
||||
storagePointer:
|
||||
updatedData?.storagePointer ||
|
||||
baseEvent.storagePointer ||
|
||||
getStoragePointerFromTags(baseEvent.tags),
|
||||
url: finalUrl,
|
||||
infoHash,
|
||||
fallbackId: oldRootId,
|
||||
provider: updatedData?.storageProvider || baseEvent.storageProvider,
|
||||
});
|
||||
|
||||
const preservedDTag = this.resolveEventDTag(baseEvent, originalEventStub);
|
||||
const fallbackDTag =
|
||||
@@ -5819,6 +5872,18 @@ export class NostrClient {
|
||||
enableComments: finalEnableComments,
|
||||
};
|
||||
|
||||
if (infoHash) {
|
||||
contentObject.infoHash = infoHash;
|
||||
}
|
||||
|
||||
if (fileSha256) {
|
||||
contentObject.fileSha256 = fileSha256;
|
||||
}
|
||||
|
||||
if (originalFileSha256) {
|
||||
contentObject.originalFileSha256 = originalFileSha256;
|
||||
}
|
||||
|
||||
if (finalWs) {
|
||||
contentObject.ws = finalWs;
|
||||
}
|
||||
@@ -5842,12 +5907,15 @@ export class NostrClient {
|
||||
|
||||
const nip71Tags = buildNip71MetadataTags(metadataForTags);
|
||||
|
||||
const additionalTags = storagePointer
|
||||
? [["s", storagePointer], ...nip71Tags]
|
||||
: nip71Tags;
|
||||
const event = buildVideoPostEvent({
|
||||
pubkey: userPubkeyLower,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
dTagValue: finalDTagValue,
|
||||
content: contentObject,
|
||||
additionalTags: nip71Tags,
|
||||
additionalTags,
|
||||
});
|
||||
|
||||
devLogger.log("Creating edited event with root ID:", oldRootId);
|
||||
@@ -6017,11 +6085,19 @@ export class NostrClient {
|
||||
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({
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
dTagValue: stableDTag,
|
||||
content: contentObject,
|
||||
additionalTags,
|
||||
});
|
||||
|
||||
await this.ensureActiveSignerForPubkey(pubkey);
|
||||
@@ -6656,7 +6732,7 @@ export class NostrClient {
|
||||
|
||||
devLogger.log("[subscribeVideos] Subscribing with filter:", filter);
|
||||
|
||||
const sub = this.pool.sub(this.relays, [filter]);
|
||||
const sub = this.pool.sub(this.getHealthyRelays(this.relays), [filter]);
|
||||
const invalidDuringSub = [];
|
||||
|
||||
// BUFFERING STATE
|
||||
@@ -6924,7 +7000,7 @@ export class NostrClient {
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
this.getHealthyRelays(this.relays).map(async (url) => {
|
||||
const events = await this.pool.list([url], [filter]);
|
||||
for (const evt of events) {
|
||||
if (evt && evt.id) {
|
||||
@@ -7024,15 +7100,7 @@ export class NostrClient {
|
||||
Array.isArray(options?.relays) && options.relays.length
|
||||
? options.relays
|
||||
: this.relays;
|
||||
const relays = Array.isArray(relayCandidatesRaw)
|
||||
? Array.from(
|
||||
new Set(
|
||||
relayCandidatesRaw
|
||||
.map((url) => (typeof url === "string" ? url.trim() : ""))
|
||||
.filter(Boolean)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
const relays = sanitizeRelayList(this.getHealthyRelays(relayCandidatesRaw));
|
||||
|
||||
if (!relays.length) {
|
||||
return null;
|
||||
@@ -7672,7 +7740,7 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
try {
|
||||
const rootEvent = await this.pool.get(this.relays, { ids: [normalizedRoot] });
|
||||
const rootEvent = await this.pool.get(this.getHealthyRelays(this.relays), { ids: [normalizedRoot] });
|
||||
if (rootEvent && rootEvent.id === normalizedRoot) {
|
||||
this.rawEvents.set(rootEvent.id, rootEvent);
|
||||
const parsed = convertEventToVideo(rootEvent);
|
||||
@@ -7715,7 +7783,7 @@ export class NostrClient {
|
||||
|
||||
try {
|
||||
const perRelay = await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
this.getHealthyRelays(this.relays).map(async (url) => {
|
||||
try {
|
||||
const events = await this.pool.list([url], [filter]);
|
||||
return events || [];
|
||||
|
||||
@@ -43,6 +43,13 @@ export function logCountTimeoutCleanupFailure(error) {
|
||||
export function logRelayCountFailure(url, error) {
|
||||
const normalizedUrl =
|
||||
typeof url === "string" && url.trim() ? url.trim() : "(unknown relay)";
|
||||
|
||||
const errorMessage = error && typeof error.message === "string" ? error.message : String(error);
|
||||
if (errorMessage.includes("Failed to connect to relay")) {
|
||||
// Suppress connection failures from warning logs to reduce noise
|
||||
return;
|
||||
}
|
||||
|
||||
logCountWarning(`[nostr] COUNT request failed on ${normalizedUrl}:`, [error], {
|
||||
key: `relay:${normalizedUrl}`,
|
||||
});
|
||||
|
||||
@@ -270,12 +270,21 @@ export async function requestEnablePermissions(
|
||||
|
||||
let lastError = null;
|
||||
for (const options of permissionVariants) {
|
||||
// If specific permissions are requested (variants 1 & 2), fail fast (3s)
|
||||
// to avoid hanging if the extension doesn't support the structured format.
|
||||
// If we fall back to standard enable() (null), allow the full user interaction time.
|
||||
const variantTimeoutOverrides = options
|
||||
? {
|
||||
timeoutMs: Math.min(NIP07_LOGIN_TIMEOUT_MS, getEnableVariantTimeoutMs()),
|
||||
timeoutMs: Math.min(3000, getEnableVariantTimeoutMs()),
|
||||
retryMultiplier: 1,
|
||||
}
|
||||
: { retryMultiplier: 1 };
|
||||
: {
|
||||
timeoutMs: Math.min(
|
||||
NIP07_LOGIN_TIMEOUT_MS,
|
||||
getEnableVariantTimeoutMs(),
|
||||
),
|
||||
retryMultiplier: 1,
|
||||
};
|
||||
|
||||
try {
|
||||
await runNip07WithRetry(
|
||||
|
||||
+30
-4
@@ -1,6 +1,10 @@
|
||||
import { deriveTitleFromEvent } from "../videoEventUtils.js";
|
||||
import { extractMagnetHints } from "../magnetShared.js";
|
||||
import { extractBtihFromMagnet, extractMagnetHints } from "../magnetShared.js";
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
import {
|
||||
getStoragePointerFromTags,
|
||||
resolveInfoJsonUrl,
|
||||
} from "../utils/storagePointer.js";
|
||||
import { getCachedNostrTools } from "./toolkit.js";
|
||||
|
||||
function stringFromInput(value) {
|
||||
@@ -1403,6 +1407,17 @@ export function convertEventToVideo(event = {}) {
|
||||
const enableComments =
|
||||
parsedContent.enableComments === false ? false : true;
|
||||
|
||||
const normalizeSha256 = (candidate) => {
|
||||
if (typeof candidate !== "string") {
|
||||
return "";
|
||||
}
|
||||
const normalized = candidate.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return /^[0-9a-f]{64}$/.test(normalized) ? normalized : "";
|
||||
};
|
||||
|
||||
let infoHash = "";
|
||||
const pushInfoHash = (candidate) => {
|
||||
if (typeof candidate !== "string") {
|
||||
@@ -1417,11 +1432,13 @@ export function convertEventToVideo(event = {}) {
|
||||
};
|
||||
|
||||
pushInfoHash(parsedContent.infoHash);
|
||||
const fileSha256 = normalizeSha256(parsedContent.fileSha256);
|
||||
const originalFileSha256 = normalizeSha256(parsedContent.originalFileSha256);
|
||||
|
||||
if (!infoHash && magnet) {
|
||||
const match = magnet.match(/xt=urn:btih:([0-9a-z]+)/i);
|
||||
if (match && match[1]) {
|
||||
pushInfoHash(match[1]);
|
||||
const extracted = extractBtihFromMagnet(magnet);
|
||||
if (extracted) {
|
||||
infoHash = extracted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1463,6 +1480,11 @@ export function convertEventToVideo(event = {}) {
|
||||
: { ws: "", xs: "" };
|
||||
const ws = wsField || magnetHints.ws || "";
|
||||
const xs = xsField || magnetHints.xs || "";
|
||||
const storagePointer = getStoragePointerFromTags(tags);
|
||||
const infoJsonUrl = resolveInfoJsonUrl({
|
||||
storagePointer,
|
||||
url,
|
||||
});
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
@@ -1476,12 +1498,16 @@ export function convertEventToVideo(event = {}) {
|
||||
magnet,
|
||||
rawMagnet,
|
||||
infoHash,
|
||||
fileSha256,
|
||||
originalFileSha256,
|
||||
thumbnail,
|
||||
description,
|
||||
mode,
|
||||
deleted,
|
||||
ws,
|
||||
xs,
|
||||
storagePointer,
|
||||
infoJsonUrl,
|
||||
enableComments,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
|
||||
@@ -265,6 +265,7 @@ const BASE_SCHEMAS = {
|
||||
kind: 30078,
|
||||
topicTag: { name: "t", value: "video" },
|
||||
identifierTag: { name: "d" },
|
||||
requiredTagNames: ["s"],
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "json",
|
||||
@@ -283,6 +284,9 @@ const BASE_SCHEMAS = {
|
||||
{ key: "isNsfw", type: "boolean", required: false },
|
||||
{ key: "isForKids", type: "boolean", required: false },
|
||||
{ key: "enableComments", type: "boolean", required: false },
|
||||
{ key: "infoHash", type: "string", required: false },
|
||||
{ key: "fileSha256", type: "string", required: false },
|
||||
{ key: "originalFileSha256", type: "string", required: false },
|
||||
{ key: "ws", type: "string", required: false },
|
||||
{ key: "xs", type: "string", required: false },
|
||||
],
|
||||
@@ -2518,6 +2522,19 @@ function hasTag(tags, tagName, tagValue = null) {
|
||||
);
|
||||
}
|
||||
|
||||
function hasTagName(tags, tagName) {
|
||||
if (!Array.isArray(tags)) return false;
|
||||
return tags.some((tag) => {
|
||||
if (!Array.isArray(tag) || tag[0] !== tagName) {
|
||||
return false;
|
||||
}
|
||||
if (tag.length < 2 || typeof tag[1] !== "string") {
|
||||
return false;
|
||||
}
|
||||
return tag[1].trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateEventStructure(type, event) {
|
||||
const schema = getNostrEventSchema(type);
|
||||
const errors = [];
|
||||
@@ -2555,6 +2572,14 @@ export function validateEventStructure(type, event) {
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.requiredTagNames)) {
|
||||
schema.requiredTagNames.forEach((tagName) => {
|
||||
if (!hasTagName(event.tags, tagName)) {
|
||||
errors.push(`Missing required tag for ${type}: ${tagName}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (schema.appendTags) {
|
||||
schema.appendTags.forEach((appendTag) => {
|
||||
if (Array.isArray(appendTag) && appendTag.length >= 2) {
|
||||
|
||||
@@ -258,6 +258,50 @@ const loadScriptBundle = (scope, url, label) => {
|
||||
);
|
||||
};
|
||||
|
||||
const createMockToolkit = () => ({
|
||||
SimplePool: class MockSimplePool {
|
||||
sub() {
|
||||
return { on: () => {}, off: () => {}, unsub: () => {} };
|
||||
}
|
||||
list() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
publish() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
get() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
ensureRelay() {
|
||||
return Promise.resolve({ connect: () => Promise.resolve() });
|
||||
}
|
||||
},
|
||||
nip19: {
|
||||
decode: () => ({ type: "npub", data: "mock" }),
|
||||
npubEncode: () => "npub1mock",
|
||||
neventEncode: () => "nevent1mock",
|
||||
naddrEncode: () => "naddr1mock",
|
||||
nprofileEncode: () => "nprofile1mock",
|
||||
},
|
||||
nip04: {
|
||||
encrypt: async () => "mock_ciphertext",
|
||||
decrypt: async () => "mock_plaintext",
|
||||
},
|
||||
nip44: {
|
||||
v2: {
|
||||
encrypt: () => "mock_ciphertext",
|
||||
decrypt: () => "mock_plaintext",
|
||||
},
|
||||
encrypt: () => "mock_ciphertext",
|
||||
decrypt: () => "mock_plaintext",
|
||||
},
|
||||
getPublicKey: () => "mock_pubkey",
|
||||
generateSecretKey: () => new Uint8Array(32),
|
||||
generatePrivateKey: () => "mock_private_key",
|
||||
finalizeEvent: () => ({ id: "mock_id", sig: "mock_sig" }),
|
||||
verifyEvent: () => true,
|
||||
});
|
||||
|
||||
export function bootstrapNostrTools() {
|
||||
if (bootstrapPromise) {
|
||||
return bootstrapPromise;
|
||||
@@ -266,7 +310,17 @@ export function bootstrapNostrTools() {
|
||||
const scope = getGlobalScope();
|
||||
|
||||
bootstrapPromise = (async () => {
|
||||
// Test Environment / Fallback Guard
|
||||
// If we are clearly in a test runner that doesn't support remote fetching,
|
||||
// we return a mock immediately to avoid timeouts.
|
||||
const isTestEnv =
|
||||
(typeof process !== "undefined" && process.env?.NODE_ENV === "test") ||
|
||||
(scope && scope.__TEST_MODE__);
|
||||
|
||||
if (!scope) {
|
||||
if (isTestEnv) {
|
||||
return createMockToolkit();
|
||||
}
|
||||
const error = {
|
||||
ok: false,
|
||||
reason: "Global scope is unavailable.",
|
||||
@@ -493,6 +547,9 @@ export function bootstrapNostrTools() {
|
||||
}
|
||||
|
||||
if (resolvedModules.length === 0 && !resolvedNip04 && !resolvedNip44) {
|
||||
if (isTestEnv) {
|
||||
return createMockToolkit();
|
||||
}
|
||||
const failure = {
|
||||
ok: false,
|
||||
reason: "Failed to resolve any nostr-tools helpers.",
|
||||
|
||||
@@ -211,13 +211,13 @@ function guessExtension(file) {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildR2Key(npub, file) {
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
export function buildR2Key(npub, file, identifier = "") {
|
||||
const safeNpub = String(npub || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
const safeIdentifier = String(identifier || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
const baseName = typeof file?.name === "string" ? file.name : "video";
|
||||
const withoutExt = baseName.replace(/\.[^/.]+$/, "");
|
||||
const slug = withoutExt
|
||||
@@ -226,7 +226,8 @@ export function buildR2Key(npub, file) {
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const safeSlug = slug || "video";
|
||||
const ext = guessExtension(file);
|
||||
return `u/${safeNpub}/${year}/${month}/${safeSlug}.${ext}`;
|
||||
const namespace = safeIdentifier || "uploads";
|
||||
return `u/${safeNpub}/${namespace}/${safeSlug}.${ext}`;
|
||||
}
|
||||
|
||||
export function buildPublicUrl(baseUrl, key) {
|
||||
|
||||
@@ -356,7 +356,8 @@ export async function uploadAttachment({
|
||||
}
|
||||
|
||||
const prepared = await prepareAttachmentUpload({ file, encrypt });
|
||||
const key = typeof buildKey === "function" ? buildKey(pubkey, file) : "";
|
||||
const key =
|
||||
typeof buildKey === "function" ? buildKey(pubkey, file, prepared.sha256) : "";
|
||||
if (!key) {
|
||||
throw new Error("Unable to derive storage key for attachment.");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import {
|
||||
waitForNip07Extension,
|
||||
runNip07WithRetry,
|
||||
requestEnablePermissions,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
} from "../../nostr/nip07Permissions.js";
|
||||
import { createNip07Adapter } from "../../nostr/adapters/nip07Adapter.js";
|
||||
import { setActiveSigner } from "../../nostrClientRegistry.js";
|
||||
import { ensureNostrTools } from "../../nostr/toolkit.js";
|
||||
import { accessControl } from "../../accessControl.js";
|
||||
import { devLogger, userLogger } from "../../utils/logger.js";
|
||||
|
||||
const PROVIDER_ID = "nip07";
|
||||
const PROVIDER_LABEL = "extension (nip-07)";
|
||||
const PROVIDER_DESCRIPTION =
|
||||
@@ -43,6 +55,180 @@ function normalizePubkey(result) {
|
||||
return "";
|
||||
}
|
||||
|
||||
async function performNip07Login(options = {}, nostrClient = null) {
|
||||
let extension = null;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 3;
|
||||
|
||||
// Optimistic check: if window.nostr is already there, use it immediately.
|
||||
if (typeof window !== "undefined" && window.nostr) {
|
||||
extension = window.nostr;
|
||||
} else {
|
||||
while (!extension && attempts < maxAttempts) {
|
||||
try {
|
||||
// Fast wait for the first attempt, slightly longer for retries
|
||||
const timeout = attempts === 0 ? 1500 : 3000;
|
||||
await waitForNip07Extension(timeout);
|
||||
extension = window.nostr;
|
||||
} catch (waitError) {
|
||||
devLogger.log(
|
||||
`Timed out waiting for extension injection (attempt ${
|
||||
attempts + 1
|
||||
}/${maxAttempts}):`,
|
||||
waitError,
|
||||
);
|
||||
}
|
||||
|
||||
if (extension) {
|
||||
break;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
devLogger.log("Retrying NIP-07 detection...");
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!extension) {
|
||||
devLogger.log("No Nostr extension found");
|
||||
throw new Error(
|
||||
"Please install a Nostr extension (Alby, nos2x, etc.)."
|
||||
);
|
||||
}
|
||||
|
||||
const { allowAccountSelection = false, expectPubkey } =
|
||||
typeof options === "object" && options !== null ? options : {};
|
||||
const normalizedExpectedPubkey =
|
||||
typeof expectPubkey === "string" && expectPubkey.trim()
|
||||
? expectPubkey.trim().toLowerCase()
|
||||
: null;
|
||||
|
||||
if (typeof extension.getPublicKey !== "function") {
|
||||
throw new Error(
|
||||
"This NIP-07 extension is missing getPublicKey support. Please update the extension."
|
||||
);
|
||||
}
|
||||
|
||||
// Use the client instance if available to ensure permission cache is updated
|
||||
if (nostrClient && typeof nostrClient.ensureExtensionPermissions === "function") {
|
||||
const permissionResult = await nostrClient.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
if (!permissionResult.ok) {
|
||||
const denialMessage =
|
||||
'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.';
|
||||
const denialError = new Error(denialMessage);
|
||||
if (permissionResult.error) {
|
||||
denialError.cause = permissionResult.error;
|
||||
}
|
||||
throw denialError;
|
||||
}
|
||||
} else {
|
||||
const permissionResult = await requestEnablePermissions(
|
||||
extension,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
if (!permissionResult.ok) {
|
||||
const denialMessage =
|
||||
'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.';
|
||||
const denialError = new Error(denialMessage);
|
||||
if (permissionResult.error) {
|
||||
denialError.cause = permissionResult.error;
|
||||
}
|
||||
throw denialError;
|
||||
}
|
||||
}
|
||||
|
||||
if (allowAccountSelection && typeof extension.selectAccounts === "function") {
|
||||
try {
|
||||
const selection = await runNip07WithRetry(
|
||||
() => extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined),
|
||||
{ label: "extension.selectAccounts" }
|
||||
);
|
||||
|
||||
const didCancelSelection =
|
||||
selection === undefined ||
|
||||
selection === null ||
|
||||
selection === false ||
|
||||
(Array.isArray(selection) && selection.length === 0);
|
||||
|
||||
if (didCancelSelection) {
|
||||
throw new Error("Account selection was cancelled.");
|
||||
}
|
||||
} catch (selectionErr) {
|
||||
const message =
|
||||
selectionErr && typeof selectionErr.message === "string"
|
||||
? selectionErr.message
|
||||
: "Account selection was cancelled.";
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), {
|
||||
label: "extension.getPublicKey",
|
||||
});
|
||||
|
||||
if (!pubkey || typeof pubkey !== "string") {
|
||||
throw new Error(
|
||||
"The NIP-07 extension did not return a public key. Please try again."
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedExpectedPubkey &&
|
||||
pubkey.toLowerCase() !== normalizedExpectedPubkey
|
||||
) {
|
||||
throw new Error(
|
||||
"The selected account doesn't match the expected profile. Please try again."
|
||||
);
|
||||
}
|
||||
|
||||
const nip19Tools = await ensureNostrTools();
|
||||
const npubEncode = nip19Tools?.nip19?.npubEncode;
|
||||
if (typeof npubEncode !== "function") {
|
||||
throw new Error("NostrTools nip19 encoder is unavailable.");
|
||||
}
|
||||
const npub = npubEncode(pubkey);
|
||||
|
||||
devLogger.log("Got pubkey:", pubkey);
|
||||
devLogger.log("Converted to npub:", npub);
|
||||
|
||||
// Access control
|
||||
if (!accessControl.canAccess(npub)) {
|
||||
if (accessControl.isBlacklisted(npub)) {
|
||||
throw new Error("Your account has been blocked on this platform.");
|
||||
} else {
|
||||
throw new Error("Access restricted to admins and moderators users only.");
|
||||
}
|
||||
}
|
||||
|
||||
const adapter = await createNip07Adapter(extension);
|
||||
adapter.pubkey = pubkey;
|
||||
setActiveSigner(adapter);
|
||||
|
||||
// Ensure full permissions are requested post-login (e.g. encryption)
|
||||
// This is non-blocking to avoid stalling if the user already approved
|
||||
if (nostrClient && typeof nostrClient.ensureExtensionPermissions === "function") {
|
||||
nostrClient.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS).catch((err) => {
|
||||
userLogger.warn(
|
||||
"[nostr] Extension permissions were not fully granted after login:",
|
||||
err
|
||||
);
|
||||
});
|
||||
} else {
|
||||
requestEnablePermissions(extension, DEFAULT_NIP07_PERMISSION_METHODS).catch((err) => {
|
||||
userLogger.warn(
|
||||
"[nostr] Extension permissions were not fully granted after login:",
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return { pubkey, signer: adapter };
|
||||
}
|
||||
|
||||
export default {
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
@@ -55,13 +241,7 @@ export default {
|
||||
messages: PROVIDER_MESSAGES,
|
||||
errorMessage: PROVIDER_MESSAGES.error,
|
||||
async login({ nostrClient, options } = {}) {
|
||||
if (!nostrClient || typeof nostrClient.login !== "function") {
|
||||
const error = new Error("Nostr login is not available.");
|
||||
error.code = "provider-unavailable";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await nostrClient.login(options || {});
|
||||
const result = await performNip07Login(options, nostrClient);
|
||||
const pubkey = normalizePubkey(result);
|
||||
const signer =
|
||||
result && typeof result === "object" && result.signer
|
||||
|
||||
@@ -374,18 +374,6 @@ export default class AuthService {
|
||||
const detailAuthType =
|
||||
typeof authType === "string" && authType.trim() ? authType.trim() : "nip07";
|
||||
|
||||
if (authType === "nip07") {
|
||||
const permissionResult = await requestDefaultExtensionPermissions();
|
||||
if (!permissionResult.ok) {
|
||||
const error = new Error(
|
||||
"The NIP-07 extension denied the permission request required to finish logging in.",
|
||||
);
|
||||
error.code = "extension-permission-denied";
|
||||
error.cause = permissionResult.error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = typeof pubkey === "string" ? pubkey.trim() : "";
|
||||
if (!trimmed) {
|
||||
return { pubkey: null, providerId, authType: detailAuthType, signer };
|
||||
|
||||
@@ -285,7 +285,7 @@ class HashtagPreferencesService {
|
||||
loadFromCache(pubkey) {
|
||||
// profileCache internally uses activePubkey, but we should verify if it matches
|
||||
try {
|
||||
let cached = profileCache.get("interests");
|
||||
let cached = profileCache.getProfileData(pubkey, "interests");
|
||||
|
||||
// Legacy fallback migration
|
||||
if (!cached && typeof localStorage !== "undefined") {
|
||||
@@ -341,6 +341,9 @@ class HashtagPreferencesService {
|
||||
|
||||
if (!hadInterest || removedFromDisinterests) {
|
||||
this.emitChange("interest-added", { tag: normalized });
|
||||
this.publish().catch((err) =>
|
||||
userLogger.warn(`${LOG_PREFIX} Auto-save failed`, err),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -355,6 +358,9 @@ class HashtagPreferencesService {
|
||||
|
||||
this.interests.delete(normalized);
|
||||
this.emitChange("interest-removed", { tag: normalized });
|
||||
this.publish().catch((err) =>
|
||||
userLogger.warn(`${LOG_PREFIX} Auto-save failed`, err),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -370,6 +376,9 @@ class HashtagPreferencesService {
|
||||
|
||||
if (!hadDisinterest || removedFromInterests) {
|
||||
this.emitChange("disinterest-added", { tag: normalized });
|
||||
this.publish().catch((err) =>
|
||||
userLogger.warn(`${LOG_PREFIX} Auto-save failed`, err),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -384,6 +393,9 @@ class HashtagPreferencesService {
|
||||
|
||||
this.disinterests.delete(normalized);
|
||||
this.emitChange("disinterest-removed", { tag: normalized });
|
||||
this.publish().catch((err) =>
|
||||
userLogger.warn(`${LOG_PREFIX} Auto-save failed`, err),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -477,12 +489,20 @@ class HashtagPreferencesService {
|
||||
// fetchListIncrementally takes a single kind.
|
||||
// kinds is array of [canonicalKind, legacyKind] if different.
|
||||
|
||||
const promises = kinds.map(kind => nostrClient.fetchListIncrementally({
|
||||
kind,
|
||||
pubkey: normalized,
|
||||
dTag: HASHTAG_IDENTIFIER,
|
||||
relayUrls: relays
|
||||
}));
|
||||
// Anchor the fetch to the service's current state to prevent desync.
|
||||
// If we have cached data, we ask for updates since that timestamp.
|
||||
// If we have no data, we force a full fetch (since=0).
|
||||
const since = wasLoadedForUser ? Number(this.eventCreatedAt) || 0 : 0;
|
||||
|
||||
const promises = kinds.map((kind) =>
|
||||
nostrClient.fetchListIncrementally({
|
||||
kind,
|
||||
pubkey: normalized,
|
||||
dTag: HASHTAG_IDENTIFIER,
|
||||
relayUrls: relays,
|
||||
since,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
events = results.flat();
|
||||
@@ -497,6 +517,14 @@ class HashtagPreferencesService {
|
||||
}
|
||||
|
||||
if (!events.length) {
|
||||
if (fetchError) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Failed to load hashtag preferences (keeping local state if any).`,
|
||||
fetchError,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we failed to fetch (network error) but already have data for this user,
|
||||
// preserve the existing state instead of wiping it.
|
||||
// If fetchListIncrementally returns empty, it means no new updates or full fetch yielded nothing.
|
||||
@@ -551,9 +579,11 @@ class HashtagPreferencesService {
|
||||
const currentCreatedAt = Number(this.eventCreatedAt) || 0;
|
||||
const latestCreatedAt = Number(latest.created_at) || 0;
|
||||
|
||||
if (wasLoadedForUser && currentCreatedAt > latestCreatedAt) {
|
||||
userLogger.warn(
|
||||
`${LOG_PREFIX} Ignoring stale preferences event (remote: ${latestCreatedAt}, local: ${currentCreatedAt}).`,
|
||||
// Protection against overwriting optimistic updates:
|
||||
// If the local version is newer (because user just edited), ignore the remote fetch.
|
||||
if (wasLoadedForUser && currentCreatedAt >= latestCreatedAt) {
|
||||
devLogger.log(
|
||||
`${LOG_PREFIX} Ignoring stale/concurrent preferences event (remote: ${latestCreatedAt}, local: ${currentCreatedAt}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
+125
-142
@@ -528,6 +528,7 @@ export class ModerationService {
|
||||
|
||||
this.contactSubscription = null;
|
||||
this.contactListPromise = null;
|
||||
this.lastContactEventId = null;
|
||||
|
||||
this.reportEvents = new Map();
|
||||
this.reportSummaries = new Map();
|
||||
@@ -536,9 +537,12 @@ export class ModerationService {
|
||||
|
||||
this.trustedMuteLists = new Map();
|
||||
this.trustedMutedAuthors = new Map();
|
||||
this.trustedMuteSubscriptions = new Map();
|
||||
this.trustedMuteSubscriptions = new Map(); // Kept for legacy/individual manual subs if needed, but primary logic is now batched
|
||||
this.trustedSeedOnly = false;
|
||||
|
||||
this.batchedMuteSubscriptions = [];
|
||||
this.muteRefreshTimer = null;
|
||||
|
||||
this.emitter = new SimpleEventEmitter((message, error) => {
|
||||
try {
|
||||
this.log(message, error);
|
||||
@@ -1010,6 +1014,7 @@ export class ModerationService {
|
||||
clearTrustedMuteTracking({ previousContacts = null } = {}) {
|
||||
void previousContacts;
|
||||
|
||||
// Clear legacy individual subscriptions
|
||||
for (const [pubkey, entry] of this.trustedMuteSubscriptions.entries()) {
|
||||
if (entry && typeof entry.unsub === "function") {
|
||||
try {
|
||||
@@ -1019,9 +1024,16 @@ export class ModerationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.trustedMuteSubscriptions.clear();
|
||||
|
||||
// Clear batched subscriptions
|
||||
this.teardownBatchedMuteSubscriptions();
|
||||
|
||||
if (this.muteRefreshTimer) {
|
||||
clearTimeout(this.muteRefreshTimer);
|
||||
this.muteRefreshTimer = null;
|
||||
}
|
||||
|
||||
for (const [author, aggregate] of this.trustedMutedAuthors.entries()) {
|
||||
if (aggregate && aggregate.muters instanceof Map) {
|
||||
aggregate.muters.clear();
|
||||
@@ -1040,6 +1052,21 @@ export class ModerationService {
|
||||
this.rebuildTrustedContacts(new Set(), { previous: emptyPrevious });
|
||||
}
|
||||
|
||||
teardownBatchedMuteSubscriptions() {
|
||||
if (Array.isArray(this.batchedMuteSubscriptions)) {
|
||||
for (const sub of this.batchedMuteSubscriptions) {
|
||||
if (sub && typeof sub.unsub === "function") {
|
||||
try {
|
||||
sub.unsub();
|
||||
} catch (error) {
|
||||
this.log("[moderationService] failed to unsub batched subscription", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.batchedMuteSubscriptions = [];
|
||||
}
|
||||
|
||||
isTrustedMuteOwner(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized) {
|
||||
@@ -1052,34 +1079,78 @@ export class ModerationService {
|
||||
}
|
||||
|
||||
reconcileTrustedMuteSubscriptions(previousSet, nextSet) {
|
||||
const previous = previousSet instanceof Set ? new Set(previousSet) : new Set();
|
||||
const next = nextSet instanceof Set ? new Set(nextSet) : new Set();
|
||||
this.scheduleTrustedMuteSubscriptionRefresh();
|
||||
}
|
||||
|
||||
this.log("[moderationService] reconcileTrustedMuteSubscriptions", {
|
||||
previous: previous.size,
|
||||
next: next.size,
|
||||
});
|
||||
|
||||
if (this.viewerPubkey) {
|
||||
if (this.trustedMuteSubscriptions.has(this.viewerPubkey)) {
|
||||
previous.add(this.viewerPubkey);
|
||||
}
|
||||
next.add(this.viewerPubkey);
|
||||
scheduleTrustedMuteSubscriptionRefresh() {
|
||||
if (this.muteRefreshTimer) {
|
||||
clearTimeout(this.muteRefreshTimer);
|
||||
}
|
||||
|
||||
for (const value of previous) {
|
||||
if (!next.has(value)) {
|
||||
this.teardownTrustedMuteSubscription(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of next) {
|
||||
this.subscribeToTrustedMuteList(value).catch((error) => {
|
||||
this.log(
|
||||
`(moderationService) failed to subscribe to trusted mute list for ${value}`,
|
||||
error,
|
||||
);
|
||||
this.muteRefreshTimer = setTimeout(() => {
|
||||
this.refreshTrustedMuteSubscriptions().catch((error) => {
|
||||
this.log("[moderationService] refreshTrustedMuteSubscriptions failed", error);
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async refreshTrustedMuteSubscriptions() {
|
||||
this.muteRefreshTimer = null;
|
||||
|
||||
const targets = new Set();
|
||||
if (this.trustedContacts instanceof Set) {
|
||||
for (const contact of this.trustedContacts) {
|
||||
targets.add(contact);
|
||||
}
|
||||
}
|
||||
if (this.viewerPubkey) {
|
||||
targets.add(this.viewerPubkey);
|
||||
}
|
||||
|
||||
if (targets.size === 0) {
|
||||
this.teardownBatchedMuteSubscriptions();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensurePool();
|
||||
} catch (error) {
|
||||
this.log("[moderationService] ensurePool failed during mute refresh", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const relays = resolveRelayList(this.nostrClient);
|
||||
if (!relays.length) {
|
||||
this.log("[moderationService] no relays available for mute refresh");
|
||||
return;
|
||||
}
|
||||
|
||||
this.teardownBatchedMuteSubscriptions();
|
||||
|
||||
const authors = Array.from(targets);
|
||||
const BATCH_SIZE = 400;
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < authors.length; i += BATCH_SIZE) {
|
||||
chunks.push(authors.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
this.log(`[moderationService] Refreshing mute subscriptions for ${authors.length} authors in ${chunks.length} batches`);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const filter = { kinds: [10000], authors: chunk };
|
||||
try {
|
||||
const sub = this.nostrClient.pool.sub(relays, [filter]);
|
||||
sub.on("event", (event) => {
|
||||
this.ingestTrustedMuteEvent(event);
|
||||
});
|
||||
sub.on("eose", () => {
|
||||
// Optional: handle EOSE if needed, e.g., for loading indicators
|
||||
});
|
||||
this.batchedMuteSubscriptions.push(sub);
|
||||
} catch (error) {
|
||||
this.log("[moderationService] failed to create batched subscription", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1101,125 +1172,13 @@ export class ModerationService {
|
||||
}
|
||||
|
||||
async subscribeToTrustedMuteList(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized || !this.isTrustedMuteOwner(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let record = this.trustedMuteSubscriptions.get(normalized);
|
||||
if (!record) {
|
||||
record = { unsub: null, promise: null };
|
||||
this.trustedMuteSubscriptions.set(normalized, record);
|
||||
} else if (record.promise) {
|
||||
try {
|
||||
await record.promise;
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
return;
|
||||
} else if (typeof record.unsub === "function") {
|
||||
this.log(`[moderationService] already subscribed to ${normalized}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(`[moderationService] subscribing to trusted mute list for ${normalized}`);
|
||||
|
||||
record.promise = (async () => {
|
||||
try {
|
||||
await this.ensurePool();
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`(moderationService) ensurePool failed while subscribing to trusted mute list for ${normalized}`,
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isTrustedMuteOwner(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const relays = resolveRelayList(this.nostrClient);
|
||||
if (!relays.length) {
|
||||
this.log(`[moderationService] no relays available for ${normalized}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = { kinds: [10000], authors: [normalized], limit: 1 };
|
||||
|
||||
let events = [];
|
||||
try {
|
||||
events = await this.nostrClient.pool.list(relays, [filter]);
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`(moderationService) failed to backfill trusted mute list for ${normalized}`,
|
||||
error,
|
||||
);
|
||||
events = [];
|
||||
}
|
||||
|
||||
if (Array.isArray(events) && events.length) {
|
||||
let latest = null;
|
||||
for (const event of events) {
|
||||
if (!event || ensureNumber(event.created_at) <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!latest || ensureNumber(event.created_at) > ensureNumber(latest.created_at)) {
|
||||
latest = event;
|
||||
}
|
||||
}
|
||||
if (latest) {
|
||||
this.ingestTrustedMuteEvent(latest);
|
||||
}
|
||||
} else {
|
||||
this.replaceTrustedMuteList(normalized, new Set(), { createdAt: 0, eventId: "" });
|
||||
}
|
||||
|
||||
if (!this.isTrustedMuteOwner(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sub = this.nostrClient.pool.sub(relays, [filter]);
|
||||
sub.on("event", (event) => {
|
||||
this.ingestTrustedMuteEvent(event);
|
||||
});
|
||||
sub.on("eose", () => {});
|
||||
record.unsub = typeof sub.unsub === "function" ? () => sub.unsub() : null;
|
||||
this.log(`[moderationService] subscribed to trusted mute list for ${normalized}`);
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`(moderationService) failed to subscribe to trusted mute list for ${normalized}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await record.promise;
|
||||
} catch (error) {
|
||||
this.log("[moderationService] trusted mute subscription promise rejected", error);
|
||||
} finally {
|
||||
record.promise = null;
|
||||
}
|
||||
// Deprecated: Logic moved to batched subscription refresh
|
||||
return this.scheduleTrustedMuteSubscriptionRefresh();
|
||||
}
|
||||
|
||||
teardownTrustedMuteSubscription(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = this.trustedMuteSubscriptions.get(normalized);
|
||||
if (record && typeof record.unsub === "function") {
|
||||
try {
|
||||
record.unsub();
|
||||
} catch (error) {
|
||||
this.log("[moderationService] failed to teardown trusted mute subscription", error);
|
||||
}
|
||||
}
|
||||
this.trustedMuteSubscriptions.delete(normalized);
|
||||
this.replaceTrustedMuteList(normalized, new Set(), { createdAt: 0, eventId: "" });
|
||||
// Deprecated: Logic moved to batched subscription refresh
|
||||
return this.scheduleTrustedMuteSubscriptionRefresh();
|
||||
}
|
||||
|
||||
replaceTrustedMuteList(ownerPubkey, mutedAuthors, { createdAt = 0, eventId = "" } = {}) {
|
||||
@@ -1988,6 +1947,10 @@ export class ModerationService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.id && event.id === this.lastContactEventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSet = new Set();
|
||||
for (const tag of event.tags) {
|
||||
if (!Array.isArray(tag) || tag[0] !== "p") {
|
||||
@@ -1999,6 +1962,26 @@ export class ModerationService {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.viewerContacts instanceof Set && this.viewerContacts.size === nextSet.size) {
|
||||
let changed = false;
|
||||
for (const value of nextSet) {
|
||||
if (!this.viewerContacts.has(value)) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
if (event.id) {
|
||||
this.lastContactEventId = event.id;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.id) {
|
||||
this.lastContactEventId = event.id;
|
||||
}
|
||||
|
||||
this.rebuildTrustedContacts(nextSet, { previous: previousContacts });
|
||||
}
|
||||
|
||||
|
||||
@@ -38,12 +38,17 @@ import {
|
||||
import { ensureS3SdkLoaded, makeS3Client } from "../storage/s3-client.js";
|
||||
import { truncateMiddle } from "../utils/formatters.js";
|
||||
import { userLogger, devLogger } from "../utils/logger.js";
|
||||
import {
|
||||
buildStoragePointerValue,
|
||||
buildStoragePrefixFromKey,
|
||||
} from "../utils/storagePointer.js";
|
||||
import {
|
||||
getVideoNoteErrorMessage,
|
||||
normalizeVideoNotePayload,
|
||||
VIDEO_NOTE_ERROR_CODES,
|
||||
} from "./videoNotePayload.js";
|
||||
import storageService from "./storageService.js";
|
||||
import { calculateTorrentInfoHash } from "../utils/torrentHash.js";
|
||||
|
||||
const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]);
|
||||
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
|
||||
@@ -56,6 +61,26 @@ function isValidInfoHash(value) {
|
||||
return INFO_HASH_PATTERN.test(value);
|
||||
}
|
||||
|
||||
async function resolveUploadIdentifier({ infoHash = "", file = null } = {}) {
|
||||
const normalizedInfoHash = normalizeInfoHash(infoHash);
|
||||
if (isValidInfoHash(normalizedInfoHash)) {
|
||||
return normalizedInfoHash;
|
||||
}
|
||||
if (!file) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const computedHash = await calculateTorrentInfoHash(file);
|
||||
const normalizedComputed = normalizeInfoHash(computedHash);
|
||||
if (isValidInfoHash(normalizedComputed)) {
|
||||
return normalizedComputed;
|
||||
}
|
||||
} catch (err) {
|
||||
userLogger.warn("Failed to precompute info hash for storage key:", err);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildCorsGuidance({ accountId } = {}) {
|
||||
const origin =
|
||||
typeof window !== "undefined" && window.location
|
||||
@@ -844,6 +869,10 @@ class R2Service {
|
||||
this.updateCloudflareProgress(0);
|
||||
this.setCloudflareUploading(true);
|
||||
|
||||
const keyIdentifier = await resolveUploadIdentifier({ infoHash, file });
|
||||
const normalizedInfoHash = normalizeInfoHash(infoHash || keyIdentifier);
|
||||
const hasValidInfoHash = isValidInfoHash(normalizedInfoHash);
|
||||
|
||||
let bucketResult = null;
|
||||
try {
|
||||
bucketResult = await this.ensureBucketConfigForNpub(npub, {
|
||||
@@ -880,7 +909,7 @@ class R2Service {
|
||||
this.setCloudflareUploadStatus(statusMessage, "info");
|
||||
|
||||
// Use forced keys if provided, otherwise generate them
|
||||
const key = forcedVideoKey || buildR2Key(npub, file);
|
||||
const key = forcedVideoKey || buildR2Key(npub, file, keyIdentifier);
|
||||
const publicUrl = forcedVideoUrl || buildPublicUrl(bucketEntry.publicBaseUrl, key);
|
||||
|
||||
const buildTorrentKey = () => {
|
||||
@@ -978,8 +1007,6 @@ class R2Service {
|
||||
// - `xs` (eXact Source): The URL to the .torrent file on R2. This allows
|
||||
// clients to fetch metadata (file structure, piece hashes) instantly via HTTP
|
||||
// instead of waiting to fetch it from the DHT (which can take minutes).
|
||||
const normalizedInfoHash = normalizeInfoHash(infoHash);
|
||||
const hasValidInfoHash = isValidInfoHash(normalizedInfoHash);
|
||||
let generatedMagnet = "";
|
||||
let generatedWs = "";
|
||||
|
||||
@@ -1010,14 +1037,25 @@ class R2Service {
|
||||
);
|
||||
}
|
||||
|
||||
const storagePrefix = buildStoragePrefixFromKey({
|
||||
publicBaseUrl: bucketEntry.publicBaseUrl,
|
||||
key,
|
||||
});
|
||||
const storagePointer = buildStoragePointerValue({
|
||||
provider: "r2",
|
||||
prefix: storagePrefix,
|
||||
});
|
||||
|
||||
const rawVideoPayload = {
|
||||
title,
|
||||
url: publicUrl, // Primary URL
|
||||
magnet: generatedMagnet || (metadata?.magnet ?? ""),
|
||||
thumbnail: metadata?.thumbnail ?? "",
|
||||
description: metadata?.description ?? "",
|
||||
storagePointer,
|
||||
ws: generatedWs || (metadata?.ws ?? ""),
|
||||
xs: torrentUrl || (metadata?.xs ?? ""),
|
||||
infoHash: hasValidInfoHash ? normalizedInfoHash : "",
|
||||
enableComments: metadata?.enableComments,
|
||||
isNsfw: metadata?.isNsfw,
|
||||
isForKids: metadata?.isForKids,
|
||||
|
||||
@@ -8,11 +8,16 @@ import {
|
||||
validateS3Connection,
|
||||
} from "./s3Service.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import {
|
||||
buildStoragePointerValue,
|
||||
buildStoragePrefixFromKey,
|
||||
} from "../utils/storagePointer.js";
|
||||
import {
|
||||
getVideoNoteErrorMessage,
|
||||
normalizeVideoNotePayload,
|
||||
VIDEO_NOTE_ERROR_CODES,
|
||||
} from "./videoNotePayload.js";
|
||||
import { calculateTorrentInfoHash } from "../utils/torrentHash.js";
|
||||
|
||||
const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]);
|
||||
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
|
||||
@@ -25,6 +30,26 @@ function isValidInfoHash(value) {
|
||||
return INFO_HASH_PATTERN.test(value);
|
||||
}
|
||||
|
||||
async function resolveUploadIdentifier({ infoHash = "", file = null } = {}) {
|
||||
const normalizedInfoHash = normalizeInfoHash(infoHash);
|
||||
if (isValidInfoHash(normalizedInfoHash)) {
|
||||
return normalizedInfoHash;
|
||||
}
|
||||
if (!file) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const computedHash = await calculateTorrentInfoHash(file);
|
||||
const normalizedComputed = normalizeInfoHash(computedHash);
|
||||
if (isValidInfoHash(normalizedComputed)) {
|
||||
return normalizedComputed;
|
||||
}
|
||||
} catch (err) {
|
||||
userLogger.warn("Failed to precompute info hash for storage key:", err);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
class S3UploadService {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
@@ -215,6 +240,10 @@ class S3UploadService {
|
||||
try {
|
||||
await ensureS3SdkLoaded();
|
||||
|
||||
const keyIdentifier = await resolveUploadIdentifier({ infoHash, file });
|
||||
const normalizedInfoHash = normalizeInfoHash(infoHash || keyIdentifier);
|
||||
const hasValidInfoHash = isValidInfoHash(normalizedInfoHash);
|
||||
|
||||
const s3 = makeS3Client({
|
||||
endpoint: normalized.endpoint,
|
||||
region: normalized.region,
|
||||
@@ -223,7 +252,7 @@ class S3UploadService {
|
||||
forcePathStyle: Boolean(normalized.forcePathStyle),
|
||||
});
|
||||
|
||||
const key = forcedVideoKey || buildR2Key(npub, file);
|
||||
const key = forcedVideoKey || buildR2Key(npub, file, keyIdentifier);
|
||||
const publicUrl =
|
||||
forcedVideoUrl ||
|
||||
buildS3ObjectUrl({
|
||||
@@ -322,8 +351,6 @@ class S3UploadService {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedInfoHash = normalizeInfoHash(infoHash);
|
||||
const hasValidInfoHash = isValidInfoHash(normalizedInfoHash);
|
||||
let generatedMagnet = "";
|
||||
let generatedWs = "";
|
||||
|
||||
@@ -347,14 +374,25 @@ class S3UploadService {
|
||||
);
|
||||
}
|
||||
|
||||
const storagePrefix = buildStoragePrefixFromKey({
|
||||
publicBaseUrl: normalized.publicBaseUrl,
|
||||
key,
|
||||
});
|
||||
const storagePointer = buildStoragePointerValue({
|
||||
provider: "s3",
|
||||
prefix: storagePrefix,
|
||||
});
|
||||
|
||||
const rawVideoPayload = {
|
||||
title,
|
||||
url: publicUrl,
|
||||
magnet: generatedMagnet || (metadata?.magnet ?? ""),
|
||||
thumbnail: metadata?.thumbnail ?? "",
|
||||
description: metadata?.description ?? "",
|
||||
storagePointer,
|
||||
ws: generatedWs || (metadata?.ws ?? ""),
|
||||
xs: torrentUrl || (metadata?.xs ?? ""),
|
||||
infoHash: hasValidInfoHash ? normalizedInfoHash : "",
|
||||
enableComments: metadata?.enableComments,
|
||||
isNsfw: metadata?.isNsfw,
|
||||
isForKids: metadata?.isForKids,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { extractMagnetHints } from "../magnetShared.js";
|
||||
import { normalizeAndAugmentMagnet } from "../magnetUtils.js";
|
||||
import { infoHashFromMagnet } from "../magnets.js";
|
||||
import {
|
||||
deriveStoragePointerFromUrl,
|
||||
normalizeStoragePointer,
|
||||
} from "../utils/storagePointer.js";
|
||||
|
||||
export const VIDEO_NOTE_ERROR_CODES = {
|
||||
MISSING_TITLE: "missing_title",
|
||||
@@ -29,6 +34,25 @@ function normalizeString(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizeSha256Hex(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
const normalized = normalizeString(value).toLowerCase();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return /^[0-9a-f]{64}$/.test(normalized) ? normalized : "";
|
||||
}
|
||||
|
||||
function normalizeInfoHash(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
const normalized = infoHashFromMagnet(normalizeString(value));
|
||||
return typeof normalized === "string" && normalized ? normalized : "";
|
||||
}
|
||||
|
||||
function parseNumberOrNull(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
@@ -320,6 +344,17 @@ export function normalizeVideoNotePayload(input) {
|
||||
const description = normalizeString(legacyPayload?.description || "");
|
||||
const ws = normalizeString(legacyPayload?.ws || "");
|
||||
const xs = normalizeString(legacyPayload?.xs || "");
|
||||
const storagePointer = normalizeStoragePointer(
|
||||
legacyPayload?.storagePointer || legacyPayload?.storage || ""
|
||||
);
|
||||
const storageProvider = normalizeString(
|
||||
legacyPayload?.storageProvider || ""
|
||||
);
|
||||
const infoHash = normalizeInfoHash(legacyPayload?.infoHash || "");
|
||||
const fileSha256 = normalizeSha256Hex(legacyPayload?.fileSha256 || "");
|
||||
const originalFileSha256 = normalizeSha256Hex(
|
||||
legacyPayload?.originalFileSha256 || ""
|
||||
);
|
||||
const rawMode = normalizeString(legacyPayload?.mode || "");
|
||||
const normalizedMode = rawMode && rawMode.toLowerCase() === "dev" ? "dev" : "live";
|
||||
const enableComments = normalizeBooleanFlag(legacyPayload?.enableComments, true);
|
||||
@@ -401,6 +436,30 @@ export function normalizeVideoNotePayload(input) {
|
||||
legacyFormData.xs = "";
|
||||
}
|
||||
|
||||
const derivedStoragePointer =
|
||||
storagePointer ||
|
||||
deriveStoragePointerFromUrl(
|
||||
legacyFormData.url,
|
||||
storageProvider || "url"
|
||||
);
|
||||
if (derivedStoragePointer) {
|
||||
legacyFormData.storagePointer = derivedStoragePointer;
|
||||
}
|
||||
|
||||
const derivedInfoHash =
|
||||
infoHash || normalizeInfoHash(legacyFormData.magnet) || "";
|
||||
if (derivedInfoHash) {
|
||||
legacyFormData.infoHash = derivedInfoHash;
|
||||
}
|
||||
|
||||
if (fileSha256) {
|
||||
legacyFormData.fileSha256 = fileSha256;
|
||||
}
|
||||
|
||||
if (originalFileSha256) {
|
||||
legacyFormData.originalFileSha256 = originalFileSha256;
|
||||
}
|
||||
|
||||
const publishPayload = {
|
||||
legacyFormData,
|
||||
};
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import { normalizeS3PublicBaseUrl } from "./s3-url.js";
|
||||
|
||||
const DB_NAME = "bitvidSettings";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "kv";
|
||||
const SETTINGS_KEY = "s3Settings";
|
||||
const LOCALSTORAGE_FALLBACK_KEY = "bitvid:s3Settings";
|
||||
|
||||
function normalizeSettings(raw) {
|
||||
const base = raw && typeof raw === "object" ? raw : {};
|
||||
const forcePathStyle = Boolean(base.forcePathStyle);
|
||||
const createBucketIfMissing = Boolean(base.createBucketIfMissing);
|
||||
const endpoint = String(base.endpoint || "").trim();
|
||||
const region = String(base.region || "").trim() || "auto";
|
||||
const bucket = String(base.bucket || "").trim();
|
||||
const accessKeyId = String(base.accessKeyId || "").trim();
|
||||
const secretAccessKey = String(base.secretAccessKey || "").trim();
|
||||
const publicBaseUrl = normalizeS3PublicBaseUrl(base.publicBaseUrl || "");
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
forcePathStyle,
|
||||
publicBaseUrl,
|
||||
createBucketIfMissing,
|
||||
};
|
||||
}
|
||||
|
||||
function isIndexedDbAvailable() {
|
||||
try {
|
||||
return typeof indexedDB !== "undefined";
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function openSettingsDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isIndexedDbAvailable()) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error("Failed to open settings DB"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadS3Settings() {
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (db) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.get(SETTINGS_KEY);
|
||||
req.onsuccess = () => {
|
||||
resolve(normalizeSettings(req.result));
|
||||
};
|
||||
req.onerror = () => {
|
||||
reject(req.error || new Error("Failed to load S3 settings"));
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
userLogger.warn("Failed to open IndexedDB for S3 settings, falling back:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
const raw = localStorage.getItem(LOCALSTORAGE_FALLBACK_KEY);
|
||||
if (raw) {
|
||||
return normalizeSettings(JSON.parse(raw));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
userLogger.warn("Failed to read fallback S3 settings:", err);
|
||||
}
|
||||
|
||||
return normalizeSettings(null);
|
||||
}
|
||||
|
||||
export async function saveS3Settings(settings) {
|
||||
const normalized = normalizeSettings(settings);
|
||||
|
||||
let db = null;
|
||||
try {
|
||||
db = await openSettingsDb();
|
||||
} catch (err) {
|
||||
userLogger.warn("Unable to open IndexedDB for S3 settings, continuing with fallback:", err);
|
||||
}
|
||||
|
||||
if (db) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.put(normalized, SETTINGS_KEY);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () =>
|
||||
reject(req.error || new Error("Failed to save S3 settings"));
|
||||
});
|
||||
} else if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(LOCALSTORAGE_FALLBACK_KEY, JSON.stringify(normalized));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function clearS3Settings() {
|
||||
let cleared = false;
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (db) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.delete(SETTINGS_KEY);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error || new Error("Failed to clear"));
|
||||
});
|
||||
cleared = true;
|
||||
}
|
||||
} catch (err) {
|
||||
userLogger.warn("Failed to clear IndexedDB S3 settings:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(LOCALSTORAGE_FALLBACK_KEY);
|
||||
cleared = true;
|
||||
}
|
||||
} catch (err) {
|
||||
userLogger.warn("Failed to clear fallback S3 settings:", err);
|
||||
}
|
||||
|
||||
return cleared;
|
||||
}
|
||||
+65
-1
@@ -215,6 +215,50 @@ const listVideoViewEventsApi = (pointer, options) =>
|
||||
const subscribeVideoViewEventsApi = (pointer, options) =>
|
||||
subscribeVideoViewEvents(nostrClient, pointer, options);
|
||||
|
||||
class TinyEventEmitter {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
if (typeof eventName !== "string" || typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!this.listeners.has(eventName)) {
|
||||
this.listeners.set(eventName, new Set());
|
||||
}
|
||||
|
||||
const handlers = this.listeners.get(eventName);
|
||||
handlers.add(handler);
|
||||
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
const handlers = this.listeners.get(eventName);
|
||||
if (!handlers || !handlers.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const handler of Array.from(handlers)) {
|
||||
try {
|
||||
handler(detail);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
`[SubscriptionsManager] listener for "${eventName}" threw an error`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the user's subscription list (kind=30000 follow set) *privately*,
|
||||
* using encrypted NIP-51 tag arrays (NIP-04/NIP-44) for the content field.
|
||||
@@ -237,6 +281,7 @@ class SubscriptionsManager {
|
||||
this.scheduledRefreshDetail = null;
|
||||
this.isRunningFeed = false;
|
||||
this.hasRenderedOnce = false;
|
||||
this.emitter = new TinyEventEmitter();
|
||||
this.ensureNostrServiceListener();
|
||||
|
||||
profileCache.subscribe((event, detail) => {
|
||||
@@ -265,7 +310,7 @@ class SubscriptionsManager {
|
||||
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
|
||||
|
||||
// 1. Attempt to load from cache first
|
||||
const cached = profileCache.get("subscriptions");
|
||||
const cached = profileCache.getProfileData(normalizedUserPubkey, "subscriptions");
|
||||
if (Array.isArray(cached)) {
|
||||
devLogger.log("[SubscriptionsManager] Loaded subscriptions from cache.");
|
||||
this.subscribedPubkeys = new Set(cached);
|
||||
@@ -288,6 +333,10 @@ class SubscriptionsManager {
|
||||
profileCache.set("subscriptions", Array.from(this.subscribedPubkeys));
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
return this.emitter.on(eventName, handler);
|
||||
}
|
||||
|
||||
async updateFromRelays(userPubkey) {
|
||||
if (!userPubkey) return;
|
||||
|
||||
@@ -458,6 +507,10 @@ class SubscriptionsManager {
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this.emitter.emit("change", {
|
||||
action: "sync",
|
||||
subscribedPubkeys: Array.from(this.subscribedPubkeys),
|
||||
});
|
||||
devLogger.log("[SubscriptionsManager] Subscription list updated from relays, refreshing feed.");
|
||||
this.refreshActiveFeed({ reason: "subscription-update-background" }).catch((error) => {
|
||||
userLogger.warn(
|
||||
@@ -480,6 +533,7 @@ class SubscriptionsManager {
|
||||
this.lastRunOptions = null;
|
||||
this.lastResult = null;
|
||||
this.hasRenderedOnce = false;
|
||||
this.emitter.emit("change", { action: "reset", subscribedPubkeys: [] });
|
||||
}
|
||||
|
||||
async ensureLoaded(actorHex) {
|
||||
@@ -644,6 +698,11 @@ class SubscriptionsManager {
|
||||
}
|
||||
this.subscribedPubkeys.add(normalizedChannel);
|
||||
this.saveToCache(userPubkey);
|
||||
this.emitter.emit("change", {
|
||||
action: "add",
|
||||
channel: normalizedChannel,
|
||||
subscribedPubkeys: Array.from(this.subscribedPubkeys),
|
||||
});
|
||||
await this.publishSubscriptionList(userPubkey);
|
||||
this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => {
|
||||
userLogger.warn(
|
||||
@@ -669,6 +728,11 @@ class SubscriptionsManager {
|
||||
}
|
||||
this.subscribedPubkeys.delete(normalizedChannel);
|
||||
this.saveToCache(userPubkey);
|
||||
this.emitter.emit("change", {
|
||||
action: "remove",
|
||||
channel: normalizedChannel,
|
||||
subscribedPubkeys: Array.from(this.subscribedPubkeys),
|
||||
});
|
||||
await this.publishSubscriptionList(userPubkey);
|
||||
this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => {
|
||||
userLogger.warn(
|
||||
|
||||
@@ -705,10 +705,12 @@ export default class ModalManager {
|
||||
|
||||
if (this.uploadModal && this.uploadSubmitHandler) {
|
||||
try {
|
||||
this.uploadModal.removeEventListener(
|
||||
"upload:submit",
|
||||
this.uploadSubmitHandler,
|
||||
);
|
||||
if (typeof this.uploadModal.removeEventListener === "function") {
|
||||
this.uploadModal.removeEventListener(
|
||||
"upload:submit",
|
||||
this.uploadSubmitHandler,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[ModalManager] Failed to remove upload submit handler:", error);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,19 @@ import {
|
||||
getVideoNoteErrorMessage,
|
||||
normalizeVideoNotePayload,
|
||||
} from "../../services/videoNotePayload.js";
|
||||
import { createTorrentMetadata } from "../../utils/torrentHash.js";
|
||||
import {
|
||||
calculateTorrentInfoHash,
|
||||
createTorrentMetadata,
|
||||
} from "../../utils/torrentHash.js";
|
||||
import { sanitizeBucketName } from "../../storage/r2-mgmt.js";
|
||||
import { buildR2Key, buildPublicUrl } from "../../r2.js";
|
||||
import { buildS3ObjectUrl } from "../../services/s3Service.js";
|
||||
import { PROVIDERS } from "../../services/storageService.js";
|
||||
import {
|
||||
buildStoragePointerValue,
|
||||
buildStoragePrefixFromKey,
|
||||
deriveStoragePointerFromUrl,
|
||||
} from "../../utils/storagePointer.js";
|
||||
import {
|
||||
getActiveSigner,
|
||||
requestDefaultExtensionPermissions,
|
||||
@@ -512,7 +520,12 @@ export class UploadModal {
|
||||
this.updateVideoProgress(0, "Preparing upload...");
|
||||
|
||||
try {
|
||||
// 1. Prepare Upload (Get Creds & Bucket)
|
||||
// 1. Compute Identifier (Info Hash / Fingerprint) before generating keys
|
||||
const identifier = await this.resolveUploadIdentifier(file);
|
||||
|
||||
if (this.videoUploadId !== currentUploadId) return; // Zombie guard
|
||||
|
||||
// 2. Prepare Upload (Get Creds & Bucket)
|
||||
const pubkey = this.getCurrentPubkey();
|
||||
const npub = this.safeEncodeNpub(pubkey);
|
||||
const service = this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2"
|
||||
@@ -521,10 +534,10 @@ export class UploadModal {
|
||||
|
||||
const { settings, bucketEntry } = await service.prepareUpload(npub, { credentials: this.activeCredentials });
|
||||
|
||||
if (this.videoUploadId !== currentUploadId) return;
|
||||
if (this.videoUploadId !== currentUploadId) return; // Zombie guard
|
||||
|
||||
// 2. Determine Keys
|
||||
const videoKey = buildR2Key(npub, file);
|
||||
// 3. Determine Keys
|
||||
const videoKey = buildR2Key(npub, file, identifier);
|
||||
const baseDomain = bucketEntry.publicBaseUrl;
|
||||
|
||||
// Note: buildPublicUrl works for both if the base is clean
|
||||
@@ -580,12 +593,24 @@ export class UploadModal {
|
||||
|
||||
const [uploadResult, torrentResult] = await Promise.all([uploadPromise, torrentPromise]);
|
||||
|
||||
if (this.videoUploadId !== currentUploadId) return;
|
||||
if (this.videoUploadId !== currentUploadId) return; // Zombie guard
|
||||
|
||||
// Video Complete
|
||||
this.videoUploadState.status = 'complete';
|
||||
this.videoUploadState.url = videoPublicUrl;
|
||||
this.videoUploadState.key = videoKey;
|
||||
const storagePrefix = buildStoragePrefixFromKey({
|
||||
publicBaseUrl: baseDomain,
|
||||
key: videoKey,
|
||||
});
|
||||
const providerLabel =
|
||||
this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2"
|
||||
? "r2"
|
||||
: "s3";
|
||||
this.videoUploadState.storagePointer = buildStoragePointerValue({
|
||||
provider: providerLabel,
|
||||
prefix: storagePrefix,
|
||||
});
|
||||
this.results.videoUrl.value = videoPublicUrl;
|
||||
this.updateVideoProgress(1, "Video uploaded.");
|
||||
|
||||
@@ -626,7 +651,7 @@ export class UploadModal {
|
||||
createBucketIfMissing: true,
|
||||
});
|
||||
|
||||
if (this.videoUploadId !== currentUploadId) return;
|
||||
if (this.videoUploadId !== currentUploadId) return; // Zombie guard
|
||||
|
||||
this.torrentState.status = 'complete';
|
||||
this.torrentState.infoHash = torrentResult.infoHash;
|
||||
@@ -653,7 +678,7 @@ export class UploadModal {
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (this.videoUploadId !== currentUploadId) return;
|
||||
if (this.videoUploadId !== currentUploadId) return; // Zombie guard
|
||||
|
||||
userLogger.error("Video upload sequence failed:", err);
|
||||
this.videoUploadState.status = 'error';
|
||||
@@ -1015,8 +1040,10 @@ export class UploadModal {
|
||||
// These might be empty depending on mode, filled below
|
||||
url: "",
|
||||
magnet: "",
|
||||
storagePointer: "",
|
||||
ws: this.inputs.ws?.value?.trim() || "",
|
||||
xs: this.inputs.xs?.value?.trim() || "",
|
||||
infoHash: this.torrentState.infoHash || "",
|
||||
};
|
||||
|
||||
// NIP-71 Advanced Data
|
||||
@@ -1030,6 +1057,13 @@ export class UploadModal {
|
||||
// We already have the URLs in state
|
||||
metadata.url = this.videoUploadState.url;
|
||||
metadata.magnet = this.torrentState.magnet || "";
|
||||
const providerLabel =
|
||||
this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2"
|
||||
? "r2"
|
||||
: "s3";
|
||||
metadata.storagePointer =
|
||||
this.videoUploadState.storagePointer ||
|
||||
deriveStoragePointerFromUrl(metadata.url, providerLabel);
|
||||
metadata.ws = this.videoUploadState.url; // WebSeed is same as R2 URL
|
||||
metadata.xs = this.torrentState.url || "";
|
||||
|
||||
@@ -1087,9 +1121,24 @@ export class UploadModal {
|
||||
};
|
||||
}
|
||||
|
||||
async resolveUploadIdentifier(file) {
|
||||
try {
|
||||
const infoHash = await calculateTorrentInfoHash(file);
|
||||
const normalized = normalizeInfoHash(infoHash);
|
||||
if (isValidInfoHash(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
} catch (hashErr) {
|
||||
userLogger.warn("Failed to precompute info hash for storage key:", hashErr);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async handleExternalFlow(metadata) {
|
||||
metadata.url = this.inputs.url?.value?.trim() || "";
|
||||
metadata.magnet = this.inputs.magnet?.value?.trim() || "";
|
||||
metadata.storagePointer =
|
||||
metadata.storagePointer || deriveStoragePointerFromUrl(metadata.url, "url");
|
||||
|
||||
const hasUrl = metadata.url.length > 0;
|
||||
const hasMagnet = metadata.magnet.length > 0;
|
||||
@@ -1140,6 +1189,7 @@ export class UploadModal {
|
||||
progress: 0,
|
||||
url: '',
|
||||
key: '',
|
||||
storagePointer: '',
|
||||
file: null,
|
||||
};
|
||||
this.thumbnailUploadState = {
|
||||
|
||||
@@ -2667,6 +2667,13 @@ export class VideoCard {
|
||||
if (!el) return;
|
||||
el.dataset.playUrl = encodeURIComponent(this.playbackUrl || "");
|
||||
el.dataset.playMagnet = this.playbackMagnet || "";
|
||||
const infoJsonUrl =
|
||||
typeof this.video?.infoJsonUrl === "string" ? this.video.infoJsonUrl : "";
|
||||
if (infoJsonUrl) {
|
||||
el.dataset.infoJson = encodeURIComponent(infoJsonUrl);
|
||||
} else if (el.dataset.infoJson) {
|
||||
delete el.dataset.infoJson;
|
||||
}
|
||||
if (this.magnetProvided) {
|
||||
el.dataset.torrentSupported = this.magnetSupported ? "true" : "false";
|
||||
} else if (el.dataset.torrentSupported) {
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
formatShortNpub as defaultFormatShortNpub,
|
||||
} from "../../utils/formatters.js";
|
||||
import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js";
|
||||
import { normalizeHashtag } from "../../utils/hashtagNormalization.js";
|
||||
import { getBreakpointLg } from "../../designSystem/metrics.js";
|
||||
import { LinkPreviewService } from "../../services/linkPreviewService.js";
|
||||
import {
|
||||
@@ -3224,19 +3223,29 @@ export class VideoModal {
|
||||
}
|
||||
|
||||
const raw = typeof entry === "string" ? entry : String(entry ?? "");
|
||||
const trimmed = normalizeHashtag(raw);
|
||||
const trimmed = raw.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmed.toLocaleLowerCase();
|
||||
let key = trimmed.toLowerCase();
|
||||
if (key.startsWith("#")) {
|
||||
key = key.slice(1);
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
normalized.push(trimmed);
|
||||
|
||||
const display = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
|
||||
normalized.push(display);
|
||||
}
|
||||
|
||||
return normalized.sort((a, b) =>
|
||||
|
||||
@@ -63,11 +63,12 @@ function createTagButton({ doc, tag, onTagActivate, getTagState }) {
|
||||
button.type = "button";
|
||||
button.classList.add("pill", "video-tag-pill", "focus-ring");
|
||||
button.dataset.tag = tag;
|
||||
button.title = `#${tag}`;
|
||||
const displayTag = tag.startsWith("#") ? tag : `#${tag}`;
|
||||
button.title = displayTag;
|
||||
|
||||
const label = doc.createElement("span");
|
||||
label.className = "video-tag-pill__label";
|
||||
label.textContent = `#${tag}`;
|
||||
label.textContent = displayTag;
|
||||
|
||||
const icon = doc.createElement("span");
|
||||
icon.className = "video-tag-pill__icon";
|
||||
|
||||
@@ -320,7 +320,9 @@ export function createPopover(trigger, render, options = {}) {
|
||||
}
|
||||
|
||||
function getView() {
|
||||
return documentRef?.defaultView || globalThis;
|
||||
if (typeof window !== "undefined") return window;
|
||||
if (typeof globalThis !== "undefined") return globalThis;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function focusWithoutScroll(node) {
|
||||
@@ -774,10 +776,18 @@ export function createPopover(trigger, render, options = {}) {
|
||||
}
|
||||
|
||||
async function updatePosition() {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!anchor || !panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (anchor.isConnected === false || panel.isConnected === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const effectiveGap = Number.isFinite(gap) ? gap : 0;
|
||||
const safePadding = Number.isFinite(viewportPadding) ? viewportPadding : 0;
|
||||
@@ -848,6 +858,9 @@ export function createPopover(trigger, render, options = {}) {
|
||||
|
||||
syncPopoverStyles();
|
||||
} catch (error) {
|
||||
if (error instanceof ReferenceError && error.message.includes("window")) {
|
||||
return;
|
||||
}
|
||||
logger.user.error("[popover] positioning failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1407,6 +1407,24 @@ export class ProfileModalController {
|
||||
this.addAccountButtonState = null;
|
||||
this.adminEmptyMessages = new Map();
|
||||
this.hashtagPreferencesUnsubscribe = null;
|
||||
|
||||
if (
|
||||
this.subscriptionsService &&
|
||||
typeof this.subscriptionsService.on === "function"
|
||||
) {
|
||||
this.subscriptionsService.on("change", () => {
|
||||
void this.populateSubscriptionsList();
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.services.userBlocks &&
|
||||
typeof this.services.userBlocks.on === "function"
|
||||
) {
|
||||
this.services.userBlocks.on("change", () => {
|
||||
this.populateBlockedList();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
@@ -5026,10 +5044,22 @@ export class ProfileModalController {
|
||||
|
||||
handleDirectMessagesError(detail = {}) {
|
||||
const error = detail?.error || detail?.failure || detail;
|
||||
userLogger.warn(
|
||||
"[profileModal] Direct message sync issue detected:",
|
||||
error,
|
||||
);
|
||||
const reason = detail?.context?.reason || "";
|
||||
const errorCode = error?.code || "";
|
||||
|
||||
const isBenign =
|
||||
reason === "no-decryptors" ||
|
||||
errorCode === "decryption-failed" ||
|
||||
(typeof error === "string" && error.includes("no-decryptors"));
|
||||
|
||||
if (isBenign) {
|
||||
devLogger.info("[profileModal] Direct message sync info:", error);
|
||||
} else {
|
||||
userLogger.warn(
|
||||
"[profileModal] Direct message sync issue detected:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.activeMessagesRequest) {
|
||||
return;
|
||||
@@ -6765,11 +6795,16 @@ export class ProfileModalController {
|
||||
const shouldStayInMenu = keepMenuView && isMobile;
|
||||
this.setMobileView(shouldStayInMenu ? "menu" : "pane");
|
||||
|
||||
const activeHex = this.normalizeHexPubkey(this.getActivePubkey());
|
||||
|
||||
if (target === "history") {
|
||||
void this.populateProfileWatchHistory();
|
||||
} else if (target === "relays") {
|
||||
this.populateProfileRelays();
|
||||
void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "pane-select" });
|
||||
void this.refreshRelayHealthPanel({
|
||||
forceRefresh: true,
|
||||
reason: "pane-select",
|
||||
});
|
||||
} else if (target === "messages") {
|
||||
this.resumeProfileMessages();
|
||||
void this.populateProfileMessages({ reason: "pane-select" });
|
||||
@@ -6781,8 +6816,14 @@ export class ProfileModalController {
|
||||
} else if (target === "hashtags") {
|
||||
this.populateHashtagPreferences();
|
||||
} else if (target === "subscriptions") {
|
||||
if (activeHex && this.subscriptionsService) {
|
||||
this.subscriptionsService.loadSubscriptions(activeHex).catch(noop);
|
||||
}
|
||||
void this.populateSubscriptionsList();
|
||||
} else if (target === "blocked") {
|
||||
if (activeHex && this.services.userBlocks) {
|
||||
this.services.userBlocks.loadBlocks(activeHex).catch(noop);
|
||||
}
|
||||
this.populateBlockedList();
|
||||
} else if (target === "safety") {
|
||||
this.refreshModerationSettingsUi();
|
||||
@@ -8008,17 +8049,6 @@ export class ProfileModalController {
|
||||
if (Array.isArray(subscriptions) && subscriptions.length) {
|
||||
sourceEntries = subscriptions;
|
||||
} else {
|
||||
if (typeof service.ensureLoaded === "function") {
|
||||
try {
|
||||
await service.ensureLoaded(activeHex);
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[profileModal] Failed to ensure subscriptions before populating subscriptions list:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof service.getSubscribedAuthors === "function") {
|
||||
try {
|
||||
sourceEntries = service.getSubscribedAuthors() || [];
|
||||
@@ -12697,14 +12727,21 @@ export class ProfileModalController {
|
||||
this.setActivePubkey(activePubkey);
|
||||
}
|
||||
|
||||
await this.hydrateActiveWalletSettings(activePubkey);
|
||||
const walletPromise = this.hydrateActiveWalletSettings(activePubkey);
|
||||
const adminPromise = this.refreshAdminPaneState().catch((error) => {
|
||||
userLogger.warn("Failed to refresh admin pane after login:", error);
|
||||
});
|
||||
|
||||
this.renderSavedProfiles();
|
||||
|
||||
try {
|
||||
await this.refreshAdminPaneState();
|
||||
} catch (error) {
|
||||
userLogger.warn("Failed to refresh admin pane after login:", error);
|
||||
// Trigger aggressive parallel fetches
|
||||
if (activePubkey) {
|
||||
if (this.services.userBlocks) {
|
||||
this.services.userBlocks.loadBlocks(activePubkey).catch(noop);
|
||||
}
|
||||
if (this.subscriptionsService) {
|
||||
this.subscriptionsService.loadSubscriptions(activePubkey).catch(noop);
|
||||
}
|
||||
}
|
||||
|
||||
this.populateBlockedList();
|
||||
@@ -12716,8 +12753,10 @@ export class ProfileModalController {
|
||||
this.handleActiveDmIdentityChanged(activePubkey);
|
||||
void this.refreshDmRelayPreferences({ force: true });
|
||||
|
||||
postLoginPromise
|
||||
// Ensure critical state is settled if possible, but don't block initial rendering
|
||||
Promise.all([walletPromise, adminPromise, postLoginPromise])
|
||||
.then(() => {
|
||||
// Re-run population to ensure any late-arriving data is reflected
|
||||
this.populateBlockedList();
|
||||
void this.populateSubscriptionsList();
|
||||
void this.populateFriendsList();
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
export default class RevertModalController {
|
||||
constructor({
|
||||
revertModal,
|
||||
services = {},
|
||||
state = {},
|
||||
ui = {},
|
||||
callbacks = {},
|
||||
helpers = {},
|
||||
}) {
|
||||
this.revertModal = revertModal;
|
||||
this.services = services;
|
||||
this.state = state;
|
||||
this.ui = ui;
|
||||
this.callbacks = callbacks;
|
||||
this.helpers = helpers;
|
||||
}
|
||||
|
||||
async open(target) {
|
||||
try {
|
||||
const normalizedTarget = this.helpers.normalizeActionTarget(target);
|
||||
const { triggerElement } = normalizedTarget;
|
||||
|
||||
const activeVideos = await this.services.nostrService.fetchVideos({
|
||||
blacklistedEventIds: this.state.getBlacklistedEventIds(),
|
||||
isAuthorBlocked: (pubkey) => this.callbacks.isAuthorBlocked(pubkey),
|
||||
});
|
||||
|
||||
const video = await this.helpers.resolveVideoActionTarget({
|
||||
...normalizedTarget,
|
||||
preloadedList: activeVideos,
|
||||
});
|
||||
|
||||
const pubkey = this.state.getPubkey();
|
||||
if (!pubkey) {
|
||||
this.ui.showError("Please login to revert.");
|
||||
return;
|
||||
}
|
||||
|
||||
const userPubkey = (pubkey || "").toLowerCase();
|
||||
const videoPubkey = (video?.pubkey || "").toLowerCase();
|
||||
if (!video || !videoPubkey || videoPubkey !== userPubkey) {
|
||||
this.ui.showError("You do not own this video.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.revertModal) {
|
||||
this.ui.showError("Revert modal is not available right now.");
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded = await this.revertModal.load();
|
||||
if (!loaded) {
|
||||
this.ui.showError("Revert modal is not available right now.");
|
||||
return;
|
||||
}
|
||||
|
||||
const history = await this.services.nostrClient.hydrateVideoHistory(video);
|
||||
|
||||
this.revertModal.setHistory(video, history);
|
||||
this.revertModal.open({ video }, { triggerElement });
|
||||
} catch (err) {
|
||||
devLogger.error("Failed to revert video:", err);
|
||||
this.ui.showError("Failed to load revision history. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
async handleConfirm(event) {
|
||||
const detail = event?.detail || {};
|
||||
const target = detail.target;
|
||||
const entries = Array.isArray(detail.entries)
|
||||
? detail.entries.slice()
|
||||
: [];
|
||||
|
||||
if (!target || !entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pubkey = this.state.getPubkey();
|
||||
if (!pubkey) {
|
||||
this.ui.showError("Please login to revert.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.revertModal) {
|
||||
this.ui.showError("Revert modal is not available right now.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.revertModal.setBusy(true, "Reverting…");
|
||||
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
await this.services.nostrClient.revertVideo(
|
||||
{
|
||||
id: entry.id,
|
||||
pubkey: entry.pubkey,
|
||||
tags: entry.tags,
|
||||
},
|
||||
pubkey,
|
||||
);
|
||||
}
|
||||
|
||||
await this.callbacks.loadVideos();
|
||||
|
||||
const timestampLabel = this.helpers.formatAbsoluteTimestamp(target.created_at);
|
||||
this.ui.showSuccess(`Reverted to revision from ${timestampLabel}.`);
|
||||
this.revertModal.close();
|
||||
this.callbacks.forceRefreshAllProfiles();
|
||||
} catch (err) {
|
||||
devLogger.error("Failed to revert video:", err);
|
||||
this.ui.showError("Failed to revert video. Please try again.");
|
||||
} finally {
|
||||
this.revertModal.setBusy(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,16 +237,16 @@ export default class TagPreferenceMenuController {
|
||||
try {
|
||||
switch (action) {
|
||||
case TAG_PREFERENCE_ACTIONS.ADD_INTEREST:
|
||||
result = service.addInterest(tag);
|
||||
result = await service.addInterest(tag);
|
||||
break;
|
||||
case TAG_PREFERENCE_ACTIONS.REMOVE_INTEREST:
|
||||
result = service.removeInterest(tag);
|
||||
result = await service.removeInterest(tag);
|
||||
break;
|
||||
case TAG_PREFERENCE_ACTIONS.ADD_DISINTEREST:
|
||||
result = service.addDisinterest(tag);
|
||||
result = await service.addDisinterest(tag);
|
||||
break;
|
||||
case TAG_PREFERENCE_ACTIONS.REMOVE_DISINTEREST:
|
||||
result = service.removeDisinterest(tag);
|
||||
result = await service.removeDisinterest(tag);
|
||||
break;
|
||||
default:
|
||||
userLogger.warn(`[TagPreferenceMenu] Unhandled action: ${action}`);
|
||||
|
||||
@@ -1041,6 +1041,10 @@ export class VideoListView {
|
||||
(target?.dataset && typeof target.dataset.playMagnet === "string"
|
||||
? target.dataset.playMagnet
|
||||
: null) ?? target?.getAttribute?.("data-play-magnet") ?? "";
|
||||
const rawInfoJsonValue =
|
||||
(target?.dataset && typeof target.dataset.infoJson === "string"
|
||||
? target.dataset.infoJson
|
||||
: null) ?? target?.getAttribute?.("data-info-json") ?? "";
|
||||
|
||||
let url = "";
|
||||
if (rawUrlValue) {
|
||||
@@ -1052,10 +1056,18 @@ export class VideoListView {
|
||||
}
|
||||
|
||||
const magnet = typeof rawMagnetValue === "string" ? rawMagnetValue : "";
|
||||
let infoJsonUrl = "";
|
||||
if (rawInfoJsonValue) {
|
||||
try {
|
||||
infoJsonUrl = decodeURIComponent(rawInfoJsonValue);
|
||||
} catch (error) {
|
||||
infoJsonUrl = rawInfoJsonValue;
|
||||
}
|
||||
}
|
||||
const videoId =
|
||||
target?.dataset?.videoId || target?.getAttribute?.("data-video-id") || video?.id || "";
|
||||
|
||||
return { videoId, url, magnet, video, trigger: element };
|
||||
return { videoId, url, magnet, infoJsonUrl, video, trigger: element };
|
||||
}
|
||||
|
||||
emitSelected(detail) {
|
||||
|
||||
+9
-6
@@ -729,6 +729,8 @@ class UserBlockListManager {
|
||||
emitStatus({ status: "loading", relays: Array.from(nostrClient.relays || []) });
|
||||
|
||||
const localBlocks = this._loadLocal(normalized);
|
||||
const hasLocalData = !!localBlocks;
|
||||
|
||||
if (localBlocks) {
|
||||
this.blockedPubkeys = localBlocks;
|
||||
this.loaded = true;
|
||||
@@ -737,8 +739,6 @@ class UserBlockListManager {
|
||||
blockedPubkeys: Array.from(this.blockedPubkeys),
|
||||
source: "local",
|
||||
});
|
||||
emitStatus({ status: "settled" });
|
||||
return;
|
||||
}
|
||||
|
||||
const applyBlockedPubkeys = (nextValues, meta = {}) => {
|
||||
@@ -1069,18 +1069,24 @@ class UserBlockListManager {
|
||||
}
|
||||
};
|
||||
|
||||
// If we don't have local data, we must ignore the sync metadata and force a full fetch (since: 0).
|
||||
// Otherwise, we might miss data if the metadata store says we are up-to-date but the actual list is missing.
|
||||
const fetchSince = hasLocalData ? undefined : 0;
|
||||
|
||||
// Concurrent incremental fetch for both kinds
|
||||
const [muteEvents, blockEvents] = await Promise.all([
|
||||
nostrClient.fetchListIncrementally({
|
||||
kind: 10000,
|
||||
pubkey: normalized,
|
||||
relayUrls: relays,
|
||||
since: fetchSince,
|
||||
}),
|
||||
nostrClient.fetchListIncrementally({
|
||||
kind: 30002,
|
||||
pubkey: normalized,
|
||||
dTag: BLOCK_LIST_IDENTIFIER,
|
||||
relayUrls: relays,
|
||||
since: fetchSince,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -1098,10 +1104,7 @@ class UserBlockListManager {
|
||||
|
||||
|
||||
} catch (error) {
|
||||
userLogger.error("[UserBlockList] loadBlocks failed:", error);
|
||||
applyBlockedPubkeys([], { source: "fast", reason: "load-error", error });
|
||||
this.blockEventId = null;
|
||||
this.blockEventCreatedAt = null;
|
||||
userLogger.error("[UserBlockList] loadBlocks failed (keeping local state):", error);
|
||||
emitStatus({ status: "error", error });
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
const INFO_JSON_SUFFIX = ".info.json";
|
||||
|
||||
function safeTrim(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function normalizeStoragePointer(value) {
|
||||
return safeTrim(value);
|
||||
}
|
||||
|
||||
export function parseStoragePointer(value) {
|
||||
const normalized = normalizeStoragePointer(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const separatorIndex = normalized.indexOf(":");
|
||||
if (separatorIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = normalized.slice(0, separatorIndex).trim().toLowerCase();
|
||||
const prefix = normalized.slice(separatorIndex + 1).trim();
|
||||
if (!provider || !prefix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { provider, prefix };
|
||||
}
|
||||
|
||||
export function buildStoragePointerValue({ provider, prefix } = {}) {
|
||||
const normalizedProvider = safeTrim(provider).toLowerCase();
|
||||
const normalizedPrefix = safeTrim(prefix);
|
||||
if (!normalizedProvider || !normalizedPrefix) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `${normalizedProvider}:${normalizedPrefix}`;
|
||||
}
|
||||
|
||||
export function deriveStoragePrefixFromUrl(url) {
|
||||
const trimmed = safeTrim(url);
|
||||
if (!trimmed || !/^https?:\/\//i.test(trimmed)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const path = parsed.pathname || "";
|
||||
if (!path || path === "/") {
|
||||
return "";
|
||||
}
|
||||
const normalizedPath = path.replace(/\/+$/, "");
|
||||
const withoutExtension = normalizedPath.replace(/\.[^/.]+$/, "");
|
||||
const cleanedPath = withoutExtension.startsWith("/")
|
||||
? withoutExtension
|
||||
: `/${withoutExtension}`;
|
||||
return `${parsed.origin}${cleanedPath}`;
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveStoragePointerFromUrl(url, provider = "url") {
|
||||
const prefix = deriveStoragePrefixFromUrl(url);
|
||||
return buildStoragePointerValue({ provider, prefix });
|
||||
}
|
||||
|
||||
export function buildStoragePrefixFromKey({ publicBaseUrl, key } = {}) {
|
||||
const base = safeTrim(publicBaseUrl).replace(/\/+$/, "");
|
||||
const normalizedKey = safeTrim(key).replace(/^\//, "");
|
||||
if (!base || !normalizedKey) {
|
||||
return "";
|
||||
}
|
||||
const baseKey = normalizedKey.replace(/\.[^/.]+$/, "");
|
||||
if (!baseKey) {
|
||||
return "";
|
||||
}
|
||||
return `${base}/${baseKey}`;
|
||||
}
|
||||
|
||||
export function getStoragePointerFromTags(tags = []) {
|
||||
if (!Array.isArray(tags)) {
|
||||
return "";
|
||||
}
|
||||
const tag = tags.find(
|
||||
(entry) =>
|
||||
Array.isArray(entry) &&
|
||||
entry[0] === "s" &&
|
||||
typeof entry[1] === "string"
|
||||
);
|
||||
return normalizeStoragePointer(tag?.[1]);
|
||||
}
|
||||
|
||||
function normalizeInfoJsonBase(base) {
|
||||
const trimmed = safeTrim(base);
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.toLowerCase().endsWith(INFO_JSON_SUFFIX)) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.replace(/\/+$/, "")}${INFO_JSON_SUFFIX}`;
|
||||
}
|
||||
|
||||
export function resolveInfoJsonUrl({ storagePointer, url } = {}) {
|
||||
const parsed = parseStoragePointer(storagePointer);
|
||||
if (!parsed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const prefix = parsed.prefix;
|
||||
if (/^https?:\/\//i.test(prefix)) {
|
||||
return normalizeInfoJsonBase(prefix);
|
||||
}
|
||||
|
||||
const fallbackPrefix = deriveStoragePrefixFromUrl(url);
|
||||
if (fallbackPrefix) {
|
||||
return normalizeInfoJsonBase(fallbackPrefix);
|
||||
}
|
||||
|
||||
return normalizeInfoJsonBase(prefix);
|
||||
}
|
||||
@@ -166,6 +166,9 @@ export class TorrentClient {
|
||||
ensureClientForProbe() {
|
||||
if (!this.probeClient) {
|
||||
this.probeClient = new this.WebTorrentClass();
|
||||
if (typeof this.probeClient.setMaxListeners === "function") {
|
||||
this.probeClient.setMaxListeners(0);
|
||||
}
|
||||
}
|
||||
return this.probeClient;
|
||||
}
|
||||
@@ -366,6 +369,9 @@ export class TorrentClient {
|
||||
async init() {
|
||||
if (!this.client) {
|
||||
this.client = new this.WebTorrentClass();
|
||||
if (typeof this.client.setMaxListeners === "function") {
|
||||
this.client.setMaxListeners(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.serviceWorkerDisabled) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { spawnSync, execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const REPORT_FILE = 'PR_REVIEW.md';
|
||||
const APPLY_FIXES = process.argv.includes('--apply-fixes');
|
||||
|
||||
function run(command, args, opts = {}) {
|
||||
console.log(`> ${command} ${args.join(' ')}`);
|
||||
return spawnSync(command, args, { encoding: 'utf-8', stdio: 'pipe', ...opts });
|
||||
}
|
||||
|
||||
function getDiffFiles() {
|
||||
try {
|
||||
// Try to compare against origin/main, fallback to HEAD~1 or just check current changes if no upstream
|
||||
// Since this runs on a checkout, we want to see what this branch touches compared to main.
|
||||
// If origin/main is not available, we might fail.
|
||||
const hasMain = run('git', ['rev-parse', '--verify', 'origin/main']).status === 0;
|
||||
const base = hasMain ? 'origin/main' : 'HEAD';
|
||||
|
||||
// Check if we are on main? No, we are on a PR branch usually.
|
||||
// We want the diff of the *current* code vs main.
|
||||
const res = run('git', ['diff', '--name-only', base, 'HEAD']);
|
||||
if (res.error || res.status !== 0) return [];
|
||||
return res.stdout.trim().split('\n').filter(Boolean);
|
||||
} catch (e) {
|
||||
console.error("Error getting diff:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const report = [];
|
||||
report.push('# Automated PR Review');
|
||||
report.push(`Date: ${new Date().toISOString()}`);
|
||||
report.push('');
|
||||
|
||||
// 1. npm ci
|
||||
console.log('Running npm ci...');
|
||||
const ci = run('npm', ['ci']);
|
||||
if (ci.status !== 0) {
|
||||
report.push('## 🚨 Dependency Install Failed');
|
||||
report.push('```');
|
||||
report.push(ci.stderr || ci.stdout);
|
||||
report.push('```');
|
||||
// If ci fails, we probably can't run other scripts, but we try.
|
||||
} else {
|
||||
report.push('## ✅ Dependencies Installed');
|
||||
}
|
||||
|
||||
// 2. Format
|
||||
console.log('Running npm run format...');
|
||||
run('npm', ['run', 'format']); // This modifies files
|
||||
|
||||
const status = run('git', ['status', '--porcelain']).stdout;
|
||||
const formattedFiles = status.split('\n').filter(l => l.trim()).map(l => l.slice(3));
|
||||
|
||||
if (formattedFiles.length > 0) {
|
||||
report.push('## ⚠️ Formatting Changes');
|
||||
report.push('The following files were reformatted:');
|
||||
report.push(formattedFiles.map(f => `- ${f}`).join('\n'));
|
||||
|
||||
if (APPLY_FIXES) {
|
||||
console.log('Applying formatting fixes...');
|
||||
run('git', ['add', '.']);
|
||||
run('git', ['commit', '-m', 'fix(ai): automated formatting (agent)']);
|
||||
report.push('\n**Changes have been automatically committed.**');
|
||||
} else {
|
||||
report.push('\n**Run `npm run format` to fix these issues.**');
|
||||
}
|
||||
} else {
|
||||
report.push('## ✅ Formatting Passed');
|
||||
}
|
||||
|
||||
// 3. Lint
|
||||
console.log('Running npm run lint...');
|
||||
const lint = run('npm', ['run', 'lint']);
|
||||
if (lint.status !== 0) {
|
||||
report.push('## ⚠️ Lint Warnings/Errors');
|
||||
report.push('<details><summary>Show Lint Output</summary>');
|
||||
report.push('');
|
||||
report.push('```');
|
||||
report.push(lint.stdout + '\n' + lint.stderr);
|
||||
report.push('```');
|
||||
report.push('</details>');
|
||||
} else {
|
||||
report.push('## ✅ Lint Passed');
|
||||
}
|
||||
|
||||
// 4. Unit Tests
|
||||
console.log('Running npm run test:unit...');
|
||||
const test = run('npm', ['run', 'test:unit']);
|
||||
if (test.status !== 0) {
|
||||
report.push('## ❌ Unit Tests Failed');
|
||||
report.push('<details><summary>Show Test Output</summary>');
|
||||
report.push('');
|
||||
report.push('```');
|
||||
report.push(test.stdout + '\n' + test.stderr);
|
||||
report.push('```');
|
||||
report.push('</details>');
|
||||
} else {
|
||||
report.push('## ✅ Unit Tests Passed');
|
||||
}
|
||||
|
||||
// 5. Guardrails
|
||||
const diffFiles = getDiffFiles();
|
||||
const sensitivePatterns = [
|
||||
/^js\/nostr\//,
|
||||
/^js\/services\/storageService/,
|
||||
/^js\/services\/r2Service/,
|
||||
/crypto/i
|
||||
];
|
||||
|
||||
const sensitiveChanges = diffFiles.filter(f => sensitivePatterns.some(p => p.test(f)));
|
||||
|
||||
if (sensitiveChanges.length > 0) {
|
||||
report.push('## 🛡️ Security & Protocol Review Required');
|
||||
report.push('The following sensitive files were modified:');
|
||||
report.push(sensitiveChanges.map(f => `- ${f}`).join('\n'));
|
||||
report.push('\n**Flags:** `requires-security-review`, `requires-protocol-review`');
|
||||
}
|
||||
|
||||
fs.writeFileSync(REPORT_FILE, report.join('\n'));
|
||||
console.log(`Report generated at ${REPORT_FILE}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -12,7 +12,8 @@ import { performance } from 'node:perf_hooks';
|
||||
global.NostrTools = NostrTools;
|
||||
|
||||
// Import Schema Builders
|
||||
import { buildVideoPostEvent, buildViewEvent } from '../../js/nostrEventSchemas.js';
|
||||
import { buildVideoPostEvent, buildViewEvent, buildVideoMirrorEvent } from '../../js/nostrEventSchemas.js';
|
||||
import { buildNip71VideoEvent } from '../../js/nostr/nip71.js';
|
||||
|
||||
// --- Configuration ---
|
||||
const ARTIFACTS_DIR = 'artifacts';
|
||||
@@ -253,6 +254,8 @@ async function run() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const hexPk = client.pk;
|
||||
|
||||
const eventsToSend = [];
|
||||
|
||||
if (isHeavy) {
|
||||
// Video Post
|
||||
const content = {
|
||||
@@ -260,17 +263,56 @@ async function run() {
|
||||
title: `Load Test Video ${Date.now()}`,
|
||||
description: 'A description for load testing purposes. '.repeat(50),
|
||||
magnet: `magnet:?xt=urn:btih:${Math.random().toString(16).slice(2).repeat(10)}`,
|
||||
url: `https://example.com/video-${Date.now()}.mp4`,
|
||||
thumbnail: `https://example.com/thumb-${Date.now()}.jpg`,
|
||||
mode: 'live',
|
||||
videoRootId: `load-${Date.now()}-${Math.random()}`,
|
||||
isNsfw: false,
|
||||
isForKids: false,
|
||||
};
|
||||
event = buildVideoPostEvent({
|
||||
|
||||
const rootEvent = buildVideoPostEvent({
|
||||
pubkey: hexPk,
|
||||
created_at: now,
|
||||
content,
|
||||
dTagValue: content.videoRootId
|
||||
});
|
||||
eventsToSend.push(rootEvent);
|
||||
|
||||
// Mirror Event (NIP-94)
|
||||
const mirrorEvent = buildVideoMirrorEvent({
|
||||
pubkey: hexPk,
|
||||
created_at: now,
|
||||
tags: [['e', rootEvent.id], ['d', content.videoRootId]],
|
||||
content: {
|
||||
url: content.url,
|
||||
magnet: content.magnet,
|
||||
thumbnail: content.thumbnail,
|
||||
title: content.title
|
||||
}
|
||||
});
|
||||
eventsToSend.push(mirrorEvent);
|
||||
|
||||
// NIP-71 Metadata
|
||||
const nip71Event = buildNip71VideoEvent({
|
||||
metadata: {
|
||||
kind: 21,
|
||||
title: content.title,
|
||||
summary: content.description.slice(0, 200),
|
||||
publishedAt: now,
|
||||
hashtags: ['loadtest', 'stress'],
|
||||
},
|
||||
pubkey: hexPk,
|
||||
title: content.title,
|
||||
createdAt: now,
|
||||
pointerIdentifiers: {
|
||||
videoRootId: content.videoRootId,
|
||||
dTag: content.videoRootId,
|
||||
eventId: rootEvent.id
|
||||
}
|
||||
});
|
||||
if (nip71Event) eventsToSend.push(nip71Event);
|
||||
|
||||
} else {
|
||||
// View Event
|
||||
event = buildViewEvent({
|
||||
@@ -279,18 +321,26 @@ async function run() {
|
||||
pointerValue: `load-video-${Math.random()}`,
|
||||
pointerTag: ['d', `load-video-${Math.random()}`]
|
||||
});
|
||||
eventsToSend.push(event);
|
||||
}
|
||||
metrics.operationTimes.build.push(performance.now() - buildStart);
|
||||
|
||||
// Sign
|
||||
const signStart = performance.now();
|
||||
const signedEvent = NostrTools.finalizeEvent(event, client.sk);
|
||||
metrics.operationTimes.sign.push(performance.now() - signStart);
|
||||
const signedEvents = [];
|
||||
for (const ev of eventsToSend) {
|
||||
const t0 = performance.now();
|
||||
const signed = NostrTools.finalizeEvent(ev, client.sk);
|
||||
const t1 = performance.now();
|
||||
metrics.operationTimes.sign.push(t1 - t0);
|
||||
signedEvents.push(signed);
|
||||
}
|
||||
|
||||
// Publish
|
||||
client.pendingEvents.set(signedEvent.id, performance.now());
|
||||
client.ws.send(JSON.stringify(['EVENT', signedEvent]));
|
||||
metrics.sent++;
|
||||
for (const signedEvent of signedEvents) {
|
||||
client.pendingEvents.set(signedEvent.id, performance.now());
|
||||
client.ws.send(JSON.stringify(['EVENT', signedEvent]));
|
||||
metrics.sent++;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error generating/sending event:', e);
|
||||
|
||||
@@ -1,29 +1,4 @@
|
||||
import { WebSocket } from 'ws';
|
||||
global.WebSocket = WebSocket;
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
if (!global.crypto) {
|
||||
global.crypto = crypto.webcrypto || crypto;
|
||||
}
|
||||
|
||||
// Polyfill localStorage
|
||||
if (typeof global.localStorage === "undefined") {
|
||||
global.localStorage = {
|
||||
_data: new Map(),
|
||||
getItem(key) { return this._data.get(key) || null; },
|
||||
setItem(key, value) { this._data.set(key, String(value)); },
|
||||
removeItem(key) { this._data.delete(key); },
|
||||
clear() { this._data.clear(); },
|
||||
key(n) { return Array.from(this._data.keys())[n] || null; },
|
||||
get length() { return this._data.size; }
|
||||
};
|
||||
}
|
||||
|
||||
// Polyfill window for code that checks window.nostr etc
|
||||
if (typeof global.window === "undefined") {
|
||||
global.window = global;
|
||||
}
|
||||
|
||||
import './setup-test-env.js';
|
||||
import "fake-indexeddb/auto";
|
||||
import { startRelay } from './simple-relay.mjs';
|
||||
import { NostrClient } from '../../js/nostr/client.js';
|
||||
@@ -181,7 +156,7 @@ async function runSmokeTest() {
|
||||
relay: RELAY_URL
|
||||
};
|
||||
const viewResult = await client.publishViewEvent(videoPointer);
|
||||
log(`View Event Published: ${viewResult.id}`);
|
||||
log(`View Event Published: ${viewResult.event ? viewResult.event.id : 'undefined'}`);
|
||||
summary.steps.view_flow = "SUCCESS";
|
||||
|
||||
// C. DM Flow
|
||||
@@ -212,23 +187,6 @@ async function runSmokeTest() {
|
||||
if (!rawDmEvent) throw new Error("Failed to fetch raw DM event");
|
||||
|
||||
log("Decrypting raw DM event manually...");
|
||||
// We need decryptors context.
|
||||
// We can re-use NostrClient's logic or manually invoke the decryptor using the private key.
|
||||
// Since decryptDM requires a context with decryptors, let's assume we can build it similarly to the client.
|
||||
// However, accessing private key from client might be tricky if it's encapsulated in adapter.
|
||||
// But we have `privateKey` variable here!
|
||||
|
||||
// We can define a decryptor function using the known private key.
|
||||
const myDecryptor = {
|
||||
scheme: 'nip04',
|
||||
decrypt: async (pk, ciphertext) => {
|
||||
return await NostrTools.nip04.decrypt(privateKey, pk, ciphertext);
|
||||
}
|
||||
};
|
||||
|
||||
// Note: NostrTools.nip04.decrypt(privateKey, senderPubkey, ciphertext)
|
||||
// Here sender is self, so `pubkey`.
|
||||
|
||||
const decryptContext = {
|
||||
actorPubkey: pubkey,
|
||||
decryptors: [
|
||||
@@ -304,10 +262,6 @@ async function runSmokeTest() {
|
||||
// --- Cleanup ---
|
||||
log("Cleaning up...");
|
||||
|
||||
// Try to close client connections to allow relay to close gracefully
|
||||
// Note: client variable is not in scope here if defined inside try block.
|
||||
// But for this script, we can rely on process.exit() to clean up if we skip await.
|
||||
|
||||
try {
|
||||
if (relay && relay.close) {
|
||||
// Don't await indefinitely
|
||||
|
||||
@@ -15,7 +15,12 @@ const SENSITIVE_PATHS = [
|
||||
|
||||
function runCommand(command, args = [], options = {}) {
|
||||
console.log(`\n> ${command} ${args.join(' ')}`);
|
||||
return spawnSync(command, args, { stdio: 'pipe', encoding: 'utf-8', ...options });
|
||||
const result = spawnSync(command, args, { stdio: 'pipe', encoding: 'utf-8', ...options });
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to execute command "${command}": ${result.error.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getChangedFiles() {
|
||||
@@ -33,14 +38,14 @@ function getChangedFiles() {
|
||||
}
|
||||
console.log(`Fetching ${remote}/${baseRef} from ${baseUrl}...`);
|
||||
try {
|
||||
execSync(`git fetch ${remote} ${baseRef}`, { stdio: 'ignore' });
|
||||
execSync(`git fetch ${remote} ${baseRef}`, { stdio: 'pipe', encoding: 'utf-8' });
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch ${remote}/${baseRef}:`, e.message);
|
||||
console.error(`Failed to fetch ${remote}/${baseRef}:`, e.stderr || e.message);
|
||||
}
|
||||
} else {
|
||||
// Fallback for local testing or if env var missing
|
||||
try {
|
||||
execSync(`git fetch origin ${baseRef} --depth=1`, { stdio: 'ignore' });
|
||||
execSync(`git fetch origin ${baseRef} --depth=1`, { stdio: 'pipe', encoding: 'utf-8' });
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
@@ -53,7 +58,7 @@ function getChangedFiles() {
|
||||
const output = execSync(cmd, { encoding: 'utf-8' });
|
||||
return output.split('\n').filter(Boolean).map(f => f.trim());
|
||||
} catch (error) {
|
||||
console.error('Error getting changed files:', error.message);
|
||||
console.error('Error getting changed files:', error.stderr || error.message);
|
||||
// Fallback: try 2-dot diff if 3-dot failed (e.g. shallow clone issues)
|
||||
try {
|
||||
const baseRef = process.env.GITHUB_BASE_REF || 'main';
|
||||
@@ -63,7 +68,7 @@ function getChangedFiles() {
|
||||
const output = execSync(cmd, { encoding: 'utf-8' });
|
||||
return output.split('\n').filter(Boolean).map(f => f.trim());
|
||||
} catch (e) {
|
||||
console.error('Fallback diff failed:', e.message);
|
||||
console.error('Fallback diff failed:', e.stderr || e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -86,9 +91,10 @@ function checkReleaseChannel(changedFiles) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Starting Automated PR Review...');
|
||||
let commentBody = '## 🤖 Automated PR Review\n\n';
|
||||
let hasFailures = false;
|
||||
try {
|
||||
console.log('Starting Automated PR Review...');
|
||||
let commentBody = '## 🤖 Automated PR Review\n\n';
|
||||
let hasFailures = false;
|
||||
let hasFormatChanges = false;
|
||||
let microFixesStatus = '';
|
||||
|
||||
@@ -167,6 +173,9 @@ async function main() {
|
||||
|
||||
// 6. Post Comment (if in CI)
|
||||
if (process.env.GITHUB_TOKEN && process.env.PR_NUMBER) {
|
||||
// Verify gh CLI is available
|
||||
console.log('Verifying GitHub CLI...');
|
||||
runCommand('gh', ['--version']);
|
||||
|
||||
// Attempt Micro-fixes
|
||||
if (hasFormatChanges) {
|
||||
@@ -233,9 +242,16 @@ async function main() {
|
||||
console.log('---------------------------------------------------');
|
||||
}
|
||||
|
||||
if (hasFailures) {
|
||||
if (hasFailures) {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Automated PR Review failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
main().catch(error => {
|
||||
console.error('Unhandled error in main execution:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { setApplication } from "../../js/applicationContext.js";
|
||||
import { normalizeVideoModerationContext } from "../../js/ui/moderationUiHelpers.js";
|
||||
import { buildModerationBadgeText } from "../../js/ui/moderationCopy.js";
|
||||
import { withMockedNostrTools, createModerationAppHarness } from "../helpers/moderation-test-helpers.mjs";
|
||||
import { waitFor } from "../test-helpers/wait-for.mjs";
|
||||
|
||||
function setupDom(t) {
|
||||
const dom = new JSDOM(
|
||||
@@ -92,8 +93,7 @@ function createVideoFixture(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// flaky: JSDOM/CI timeout issues and potential leaks
|
||||
test.skip("renderChannelVideosFromList decorates videos before storing", async (t) => {
|
||||
test("renderChannelVideosFromList decorates videos before storing", async (t) => {
|
||||
const { document } = setupDom(t);
|
||||
withMockedNostrTools(t);
|
||||
|
||||
@@ -147,8 +147,7 @@ test.skip("renderChannelVideosFromList decorates videos before storing", async (
|
||||
assert.equal(stored.moderation.decoratedByTest, true);
|
||||
});
|
||||
|
||||
// flaky: JSDOM/CI timeout issues and potential leaks
|
||||
test.skip("renderChannelVideosFromList applies moderation blur without existing metadata", async (t) => {
|
||||
test("renderChannelVideosFromList applies moderation blur without existing metadata", async (t) => {
|
||||
const { document } = setupDom(t);
|
||||
withMockedNostrTools(t);
|
||||
|
||||
@@ -235,8 +234,7 @@ test.skip("renderChannelVideosFromList applies moderation blur without existing
|
||||
assert.equal(bannerEl.dataset.visualState, "blurred");
|
||||
});
|
||||
|
||||
// flaky: JSDOM/CI timeout issues and potential leaks
|
||||
test.skip("applyChannelVisualBlur blurs banner and avatar when viewer mutes author", async (t) => {
|
||||
test("applyChannelVisualBlur blurs banner and avatar when viewer mutes author", async (t) => {
|
||||
const { document } = setupDom(t);
|
||||
withMockedNostrTools(t);
|
||||
|
||||
@@ -261,8 +259,7 @@ test.skip("applyChannelVisualBlur blurs banner and avatar when viewer mutes auth
|
||||
assert.equal(avatarEl.dataset.visualState, "blurred");
|
||||
});
|
||||
|
||||
// flaky: JSDOM/CI timeout issues and potential leaks
|
||||
test.skip("moderation override clears channel blur via event wiring", async (t) => {
|
||||
test("moderation override clears channel blur via event wiring", async (t) => {
|
||||
const { document } = setupDom(t);
|
||||
withMockedNostrTools(t);
|
||||
|
||||
@@ -329,8 +326,7 @@ test.skip("moderation override clears channel blur via event wiring", async (t)
|
||||
assert.equal(avatarEl.dataset.visualState, undefined);
|
||||
});
|
||||
|
||||
// flaky: JSDOM/CI timeout issues and potential leaks
|
||||
test.skip("channel header moderation badge reflects blur state and override actions", async (t) => {
|
||||
test("channel header moderation badge reflects blur state and override actions", async (t) => {
|
||||
const { document } = setupDom(t);
|
||||
withMockedNostrTools(t);
|
||||
|
||||
@@ -413,8 +409,9 @@ test.skip("channel header moderation badge reflects blur state and override acti
|
||||
assert.ok(overrideButton, "override control is available");
|
||||
|
||||
overrideButton.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(overrideCalls, 1);
|
||||
await waitFor(() => {
|
||||
assert.equal(overrideCalls, 1);
|
||||
});
|
||||
const updatedVideo = app.videosMap.get(video.id);
|
||||
if (!updatedVideo.moderation || typeof updatedVideo.moderation !== "object") {
|
||||
updatedVideo.moderation = {};
|
||||
@@ -440,8 +437,7 @@ test.skip("channel header moderation badge reflects blur state and override acti
|
||||
assert.ok(overrideText.startsWith("Showing despite"));
|
||||
});
|
||||
|
||||
// flaky: JSDOM/CI timeout issues and potential leaks
|
||||
test.skip("channel video cards update moderation state in place when summary changes", async (t) => {
|
||||
test("channel video cards update moderation state in place when summary changes", async (t) => {
|
||||
const { document } = setupDom(t);
|
||||
withMockedNostrTools(t);
|
||||
|
||||
|
||||
@@ -34,11 +34,36 @@ function restoreNostrClient() {
|
||||
: originalWriteRelays;
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
test.beforeEach(async () => {
|
||||
hashtagPreferences.reset();
|
||||
restoreNostrClient();
|
||||
window.nostr = originalWindowNostr;
|
||||
clearActiveSigner();
|
||||
|
||||
// Setup default mock signer to prevent auto-save errors
|
||||
setActiveSigner({
|
||||
signEvent: async (e) => ({ ...e, id: "fake" }),
|
||||
nip44Encrypt: async () => "encrypted",
|
||||
nip04Encrypt: async () => "encrypted",
|
||||
});
|
||||
|
||||
// Setup default pool mock for publish and list
|
||||
nostrClient.pool = {
|
||||
list: async () => [],
|
||||
publish: (urls, event) => ({
|
||||
on: (eventName, handler) => {
|
||||
if (eventName === "ok") {
|
||||
setTimeout(() => handler(), 0);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
// Ensure writeRelays are set
|
||||
nostrClient.writeRelays = ["wss://mock.relay"];
|
||||
|
||||
// Ensure active pubkey is set for publish operations
|
||||
await hashtagPreferences.load("0".repeat(64));
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
@@ -242,16 +267,19 @@ test(
|
||||
const signedEvent = await hashtagPreferences.publish();
|
||||
assert.equal(signedEvent.id, "signed-event");
|
||||
|
||||
assert.equal(plaintextCalls.length, 1);
|
||||
assert.equal(plaintextCalls[0].target, pubkey);
|
||||
const payload = JSON.parse(plaintextCalls[0].plaintext);
|
||||
// Expect 3 calls: addInterest (auto-save), addDisinterest (auto-save), manual publish
|
||||
assert.equal(plaintextCalls.length, 3);
|
||||
const lastCall = plaintextCalls[plaintextCalls.length - 1];
|
||||
assert.equal(lastCall.target, pubkey);
|
||||
const payload = JSON.parse(lastCall.plaintext);
|
||||
assert.equal(payload.version, 1);
|
||||
assert.deepEqual(payload.interests, ["taga"]);
|
||||
assert.deepEqual(payload.disinterests, ["tagb"]);
|
||||
|
||||
assert.equal(publishedEvents.length, 1);
|
||||
const publishedEvent = publishedEvents[0].event;
|
||||
assert.deepEqual(publishedEvents[0].urls, ["wss://relay.publish"]);
|
||||
// Expect 3 publish events
|
||||
assert.equal(publishedEvents.length, 3);
|
||||
const publishedEvent = publishedEvents[publishedEvents.length - 1].event;
|
||||
assert.deepEqual(publishedEvents[publishedEvents.length - 1].urls, ["wss://relay.publish"]);
|
||||
assert.equal(publishedEvent.kind, 30015);
|
||||
const encryptedTag = publishedEvent.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "encrypted",
|
||||
@@ -310,16 +338,18 @@ test(
|
||||
const signedEvent = await hashtagPreferences.publish();
|
||||
|
||||
assert.equal(signedEvent.id, "signed-window");
|
||||
// We expect 1 call since the first scheme (nip44_v2) succeeds
|
||||
assert.equal(encryptCalls.length, 1);
|
||||
assert.equal(encryptCalls[0].scheme, "nip44_call");
|
||||
assert.equal(encryptCalls[0].target, pubkey);
|
||||
assert.ok(encryptCalls[0].plaintext.includes("windowtag"));
|
||||
// Expect 2 calls: addInterest (auto-save) + manual publish
|
||||
assert.equal(encryptCalls.length, 2);
|
||||
const lastEncryptCall = encryptCalls[encryptCalls.length - 1];
|
||||
assert.equal(lastEncryptCall.scheme, "nip44_call");
|
||||
assert.equal(lastEncryptCall.target, pubkey);
|
||||
assert.ok(lastEncryptCall.plaintext.includes("windowtag"));
|
||||
|
||||
assert.equal(publishedEvents.length, 1);
|
||||
assert.deepEqual(publishedEvents[0].urls, ["wss://relay.window"]);
|
||||
assert.equal(publishedEvents[0].event.kind, 30015);
|
||||
const encryptedTag = publishedEvents[0].event.tags.find(
|
||||
assert.equal(publishedEvents.length, 2);
|
||||
const lastPublished = publishedEvents[publishedEvents.length - 1];
|
||||
assert.deepEqual(lastPublished.urls, ["wss://relay.window"]);
|
||||
assert.equal(lastPublished.event.kind, 30015);
|
||||
const encryptedTag = lastPublished.event.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "encrypted",
|
||||
);
|
||||
// nip44_v2 is the first tried scheme, so it is the one used
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
|
||||
import { nostrClient } from "../js/nostrClientFacade.js";
|
||||
import { NostrClient, __testExports } from "../js/nostr/client.js";
|
||||
import nip07Provider from "../js/services/authProviders/nip07.js";
|
||||
import { accessControl } from "../js/accessControl.js";
|
||||
|
||||
const {
|
||||
@@ -150,7 +151,8 @@ function setupLoginEnvironment({ enableImpl, getPublicKey = HEX_PUBKEY } = {}) {
|
||||
test("NIP-07 login requests decrypt permissions upfront", async () => {
|
||||
const env = setupLoginEnvironment();
|
||||
try {
|
||||
const pubkey = await nostrClient.login();
|
||||
const result = await nip07Provider.login({ nostrClient });
|
||||
const pubkey = result.pubkey;
|
||||
assert.equal(pubkey, HEX_PUBKEY);
|
||||
assert.ok(env.enableCalls.length >= 1, "extension.enable should be invoked");
|
||||
assert.ok(
|
||||
@@ -208,7 +210,8 @@ test("NIP-07 decrypt reuses cached extension permissions", async () => {
|
||||
},
|
||||
};
|
||||
|
||||
const pubkey = await nostrClient.login();
|
||||
const result = await nip07Provider.login({ nostrClient });
|
||||
const pubkey = result.pubkey;
|
||||
assert.equal(pubkey, HEX_PUBKEY);
|
||||
for (const method of EXPECTED_ENCRYPTION_PERMISSIONS) {
|
||||
assert.ok(
|
||||
@@ -306,7 +309,8 @@ test("NIP-07 login falls back when structured permissions fail", async () => {
|
||||
});
|
||||
|
||||
try {
|
||||
const pubkey = await nostrClient.login();
|
||||
const result = await nip07Provider.login({ nostrClient });
|
||||
const pubkey = result.pubkey;
|
||||
assert.equal(pubkey, HEX_PUBKEY);
|
||||
assert.ok(env.enableCalls.length >= 2, "should retry with alternate payloads");
|
||||
const [objectCall, stringCall, plainCall] = env.enableCalls;
|
||||
@@ -346,7 +350,8 @@ test("NIP-07 login supports extensions that only allow enable() without payload"
|
||||
});
|
||||
|
||||
try {
|
||||
const pubkey = await nostrClient.login();
|
||||
const result = await nip07Provider.login({ nostrClient });
|
||||
const pubkey = result.pubkey;
|
||||
assert.equal(pubkey, HEX_PUBKEY);
|
||||
assert.equal(
|
||||
env.enableCalls.length,
|
||||
@@ -398,7 +403,8 @@ test("NIP-07 login quickly retries when a permission payload stalls", async () =
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const pubkey = await nostrClient.login();
|
||||
const result = await nip07Provider.login({ nostrClient });
|
||||
const pubkey = result.pubkey;
|
||||
const duration = Date.now() - start;
|
||||
assert.equal(pubkey, HEX_PUBKEY);
|
||||
assert.ok(
|
||||
@@ -432,7 +438,7 @@ test("NIP-07 login surfaces enable permission errors", async () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await assert.rejects(() => nostrClient.login(), /permission denied/);
|
||||
await assert.rejects(() => nip07Provider.login({ nostrClient }), /permission denied/);
|
||||
} finally {
|
||||
env.restore();
|
||||
nostrClient.logout();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { NostrClient, setActiveSigner, getActiveSigner } from "../js/nostr/client.js";
|
||||
|
||||
const HEX_PUBKEY = "a".repeat(64);
|
||||
|
||||
test("ensureActiveSignerForPubkey prefers concurrent login over late extension injection", async () => {
|
||||
// 1. Setup Environment
|
||||
const originalWindow = global.window;
|
||||
global.window = {};
|
||||
|
||||
try {
|
||||
const client = new NostrClient();
|
||||
client.pubkey = HEX_PUBKEY;
|
||||
// Pre-condition: Cache permissions to trigger the waitForNip07Extension logic
|
||||
client.extensionPermissionCache.add("sign_event");
|
||||
|
||||
// Mock a NIP-46 signer that might be set during the race
|
||||
const mockNip46Signer = {
|
||||
type: "nip46",
|
||||
pubkey: HEX_PUBKEY,
|
||||
signEvent: async () => ({ id: "mock-signed" }),
|
||||
nip04: { encrypt: async () => "enc", decrypt: async () => "dec" }
|
||||
};
|
||||
|
||||
// Mock the NIP-07 extension that appears late
|
||||
const mockExtension = {
|
||||
getPublicKey: async () => HEX_PUBKEY,
|
||||
signEvent: async () => ({ id: "ext-signed" }),
|
||||
nip04: { encrypt: async () => "ext-enc", decrypt: async () => "ext-dec" }
|
||||
};
|
||||
|
||||
// 2. Start the race
|
||||
// Calling ensureActiveSignerForPubkey while window.nostr is missing but permissions exist
|
||||
// causes it to enter waitForNip07Extension loop (polling).
|
||||
const ensurePromise = client.ensureActiveSignerForPubkey(HEX_PUBKEY);
|
||||
|
||||
// 3. Simulate Concurrent Login (e.g. NIP-46 restore completes)
|
||||
// We wait a small amount to ensure ensureActiveSignerForPubkey has started polling
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
setActiveSigner(mockNip46Signer);
|
||||
|
||||
// 4. Simulate Extension Loading late
|
||||
global.window.nostr = mockExtension;
|
||||
|
||||
// 5. Await result
|
||||
const resultSigner = await ensurePromise;
|
||||
|
||||
// 6. Assertions
|
||||
const finalActiveSigner = getActiveSigner();
|
||||
|
||||
// We expect the NIP-46 signer to be preserved and not overwritten by the NIP-07 fallback
|
||||
assert.equal(finalActiveSigner.type, "nip46", "Race condition! NIP-46 signer was overwritten by NIP-07 fallback.");
|
||||
assert.equal(resultSigner.type, "nip46", "Returned signer should be the NIP-46 one.");
|
||||
} finally {
|
||||
if (originalWindow) {
|
||||
global.window = originalWindow;
|
||||
} else {
|
||||
delete global.window;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -85,6 +85,40 @@ describe("NostrClient", () => {
|
||||
// pool.list is called with ([url], [filter]), so arguments[1] is an array
|
||||
assert.equal(filter[0].since, 101);
|
||||
});
|
||||
|
||||
it("should override lastSeen with explicit since parameter", async () => {
|
||||
// Mock syncMetadataStore to return something high
|
||||
client.syncMetadataStore.getLastSeen = () => 500;
|
||||
|
||||
await client.fetchListIncrementally({
|
||||
kind: 1,
|
||||
pubkey: "abc",
|
||||
relayUrls: ["wss://relay.example.com"],
|
||||
since: 100 // Explicit override, should be used instead of 500
|
||||
});
|
||||
|
||||
// Verify filter used 'since + 1'
|
||||
const call = mockPool.list.mock.calls[0];
|
||||
const filter = call.arguments[1];
|
||||
assert.equal(filter[0].since, 101);
|
||||
});
|
||||
|
||||
it("should force full fetch if since is 0", async () => {
|
||||
// Mock syncMetadataStore to return something
|
||||
client.syncMetadataStore.getLastSeen = () => 500;
|
||||
|
||||
await client.fetchListIncrementally({
|
||||
kind: 1,
|
||||
pubkey: "abc",
|
||||
relayUrls: ["wss://relay.example.com"],
|
||||
since: 0 // Force full fetch
|
||||
});
|
||||
|
||||
const call = mockPool.list.mock.calls[0];
|
||||
const filter = call.arguments[1];
|
||||
// Should NOT have 'since' property
|
||||
assert.equal(filter[0].since, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeVideos", () => {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import "../../scripts/agent/setup-test-env.js";
|
||||
import assert from "node:assert/strict";
|
||||
import { test, afterEach } from "node:test";
|
||||
import { indexedDB, IDBKeyRange } from "fake-indexeddb";
|
||||
|
||||
if (!globalThis.indexedDB) {
|
||||
globalThis.indexedDB = indexedDB;
|
||||
globalThis.IDBKeyRange = IDBKeyRange;
|
||||
}
|
||||
|
||||
import { NostrClient } from "../../js/nostr/client.js";
|
||||
import { NostrService } from "../../js/services/nostrService.js";
|
||||
@@ -11,13 +18,6 @@ import {
|
||||
listMessagesByConversation,
|
||||
} from "../../js/storage/dmDb.js";
|
||||
|
||||
const { indexedDB, IDBKeyRange } = await import("fake-indexeddb");
|
||||
|
||||
if (!globalThis.indexedDB) {
|
||||
globalThis.indexedDB = indexedDB;
|
||||
globalThis.IDBKeyRange = IDBKeyRange;
|
||||
}
|
||||
|
||||
async function deleteDatabase(name) {
|
||||
await new Promise((resolve) => {
|
||||
const request = indexedDB.deleteDatabase(name);
|
||||
@@ -27,6 +27,15 @@ async function deleteDatabase(name) {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(predicate, timeoutMs = 2000, intervalMs = 50, errorMessage = "Timed out") {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (predicate()) return;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteDatabase("bitvidDm");
|
||||
await deleteDatabase("bitvidSettings");
|
||||
@@ -46,7 +55,12 @@ class FakeRelayPool {
|
||||
return {
|
||||
on(eventName, handler) {
|
||||
if (eventName === "ok") {
|
||||
setTimeout(() => handler(), 0);
|
||||
// Call synchronously so tests don't depend on scheduling/timers.
|
||||
try {
|
||||
handler();
|
||||
} catch (err) {
|
||||
// Swallow to avoid breaking tests that don't expect thrown errors here.
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
@@ -231,10 +245,12 @@ test("DM relay duplication is deduped", async () => {
|
||||
relayPool.flushQueue({ duplicate: true });
|
||||
|
||||
// Poll for the expected state
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (received.length === 1 && notifications.length === 1) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
await waitFor(
|
||||
() => received.length === 1 && notifications.length === 1,
|
||||
2000,
|
||||
50,
|
||||
`received=${received.length}, notifications=${notifications.length}`
|
||||
);
|
||||
|
||||
assert.equal(received.length, 1, "duplicate relay events should be deduped");
|
||||
assert.equal(notifications.length, 1, "incoming messages should notify once");
|
||||
@@ -291,11 +307,15 @@ test("out-of-order DM delivery keeps newest messages first", async () => {
|
||||
relayPool.queue = [];
|
||||
|
||||
let messages = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await waitFor(
|
||||
() => {
|
||||
messages = receiverService.getDirectMessages();
|
||||
if (messages.length === 2) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return messages.length === 2;
|
||||
},
|
||||
2000,
|
||||
50,
|
||||
"two DM messages should be present"
|
||||
);
|
||||
|
||||
assert.equal(messages.length, 2, "two DM messages should be present");
|
||||
assert.equal(messages[0].plaintext, "Second message");
|
||||
@@ -354,10 +374,12 @@ test("DM reconnect replays do not duplicate messages or reset seen state", async
|
||||
relayPool.flushQueue();
|
||||
|
||||
let conversationId = `dm:${[senderPubkey, receiverPubkey].sort().join(":")}`;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (receiverService.getDirectMessageUnseenCount(conversationId) === 1) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
await waitFor(
|
||||
() => receiverService.getDirectMessageUnseenCount(conversationId) === 1,
|
||||
2000,
|
||||
50,
|
||||
"Waiting for unseen count 1"
|
||||
);
|
||||
|
||||
assert.equal(receiverService.getDirectMessageUnseenCount(conversationId), 1);
|
||||
|
||||
@@ -374,6 +396,9 @@ test("DM reconnect replays do not duplicate messages or reset seen state", async
|
||||
subscription.unsub();
|
||||
subscribe();
|
||||
|
||||
if (!relayPool.published.length) {
|
||||
throw new Error("No published events to replay (relayPool.published is empty)");
|
||||
}
|
||||
relayPool.deliver(relayPool.published[0]);
|
||||
|
||||
// Ensure no duplication happens
|
||||
@@ -394,7 +419,3 @@ test("DM reconnect replays do not duplicate messages or reset seen state", async
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
});
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
const {
|
||||
nostrClient,
|
||||
} = await import("../../js/nostrClientFacade.js");
|
||||
const { default: nip07Provider } = await import(
|
||||
"../../js/services/authProviders/nip07.js"
|
||||
);
|
||||
const { clearActiveSigner } = await import("../../js/nostr/client.js");
|
||||
const { Nip46RpcClient } = await import("../../js/nostr/nip46Client.js");
|
||||
const { accessControl } = await import("../../js/accessControl.js");
|
||||
const { WATCH_HISTORY_KIND } = await import("../../js/config.js");
|
||||
const { normalizeActorKey } = await import("../../js/nostr/watchHistory.js");
|
||||
@@ -224,13 +228,16 @@ test("nostr login + remote signing + publish + watch history integration", async
|
||||
}
|
||||
});
|
||||
|
||||
const loginResult = await nostrClient.login();
|
||||
const loginResponse = await nip07Provider.login({ nostrClient });
|
||||
const loginResult = loginResponse.pubkey;
|
||||
nostrClient.pubkey = loginResult;
|
||||
nostrClient.sessionActor = null;
|
||||
assert.equal(loginResult, userPubkey);
|
||||
extensionSignCount = 0;
|
||||
|
||||
const remoteSigner = {
|
||||
type: "nip46",
|
||||
pubkey: userPubkey,
|
||||
const mockRpcClient = Object.create(Nip46RpcClient.prototype);
|
||||
Object.assign(mockRpcClient, {
|
||||
userPubkey,
|
||||
async signEvent(event) {
|
||||
remoteSignCount += 1;
|
||||
return finalizeWithKey(event, userSecret, userPubkey);
|
||||
@@ -242,15 +249,16 @@ test("nostr login + remote signing + publish + watch history integration", async
|
||||
nip04Decrypt(pubkey, ciphertext) {
|
||||
return nip04.decrypt(userSecret, pubkey, ciphertext);
|
||||
},
|
||||
};
|
||||
|
||||
nostrClient.installNip46Client(
|
||||
{
|
||||
getActiveSigner: () => remoteSigner,
|
||||
destroy() {},
|
||||
async getUserPubkey() {
|
||||
return userPubkey;
|
||||
},
|
||||
{ userPubkey },
|
||||
);
|
||||
destroy() {},
|
||||
destroyed: false,
|
||||
relays: [],
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
nostrClient.installNip46Client(mockRpcClient, { userPubkey });
|
||||
|
||||
const viewPointer = {
|
||||
type: "e",
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
getWatchHistoryStorage,
|
||||
} from "../../js/nostr/watchHistory.js";
|
||||
import { WATCH_HISTORY_KIND } from "../../js/config.js";
|
||||
import { NOTE_TYPES } from "../../js/nostrEventSchemas.js";
|
||||
import { profileCache } from "../../js/state/profileCache.js";
|
||||
|
||||
const WATCH_HISTORY_STORAGE_KEY = "bitvid:watch-history:v5";
|
||||
|
||||
@@ -21,6 +23,11 @@ function ensureLocalStorageCleared() {
|
||||
|
||||
test.beforeEach(() => {
|
||||
ensureLocalStorageCleared();
|
||||
// Clear profile cache memory
|
||||
if (profileCache) {
|
||||
profileCache.activePubkey = null;
|
||||
profileCache.memoryCache.clear();
|
||||
}
|
||||
});
|
||||
|
||||
test("buildWatchHistoryPayload enforces byte limits and records skipped entries", () => {
|
||||
@@ -78,7 +85,9 @@ test("buildWatchHistoryPayload enforces byte limits and records skipped entries"
|
||||
});
|
||||
|
||||
test("getWatchHistoryStorage prunes entries that exceed the configured TTL", () => {
|
||||
const manager = createWatchHistoryManager();
|
||||
const manager = createWatchHistoryManager({
|
||||
getActivePubkey: () => profileCache.getActiveProfile(),
|
||||
});
|
||||
const ttl = getWatchHistoryCacheTtlMs(manager);
|
||||
const originalNow = Date.now;
|
||||
const baseNow = Date.now();
|
||||
@@ -88,61 +97,55 @@ test("getWatchHistoryStorage prunes entries that exceed the configured TTL", ()
|
||||
try {
|
||||
Date.now = () => baseNow;
|
||||
|
||||
const storagePayload = {
|
||||
version: 2,
|
||||
actors: {
|
||||
[freshActor]: {
|
||||
actor: freshActor,
|
||||
snapshotId: "snap-fresh",
|
||||
fingerprint: "fingerprint-fresh",
|
||||
savedAt: baseNow - (ttl - 1000),
|
||||
items: [{ type: "e", value: "fresh-pointer", watchedAt: 123 }],
|
||||
metadata: { status: "ok" },
|
||||
},
|
||||
[staleActor]: {
|
||||
actor: staleActor,
|
||||
snapshotId: "snap-stale",
|
||||
fingerprint: "fingerprint-stale",
|
||||
savedAt: baseNow - (ttl + 1000),
|
||||
items: [{ type: "e", value: "stale-pointer", watchedAt: 456 }],
|
||||
metadata: { status: "stale" },
|
||||
},
|
||||
},
|
||||
// Test Fresh Actor
|
||||
profileCache.setActiveProfile(freshActor);
|
||||
const freshPayload = {
|
||||
actor: freshActor,
|
||||
snapshotId: "snap-fresh",
|
||||
fingerprint: "fingerprint-fresh",
|
||||
savedAt: baseNow - (ttl - 1000),
|
||||
items: [{ type: "e", value: "fresh-pointer", watchedAt: 123 }],
|
||||
metadata: { status: "ok" },
|
||||
};
|
||||
localStorage.setItem(WATCH_HISTORY_STORAGE_KEY, JSON.stringify(storagePayload));
|
||||
const freshKey = profileCache.getStorageKey(freshActor, NOTE_TYPES.WATCH_HISTORY);
|
||||
localStorage.setItem(freshKey, JSON.stringify(freshPayload));
|
||||
|
||||
const storage = getWatchHistoryStorage(manager);
|
||||
const freshStorage = getWatchHistoryStorage(manager);
|
||||
const freshEntry = freshStorage.actors[freshActor];
|
||||
|
||||
assert.deepStrictEqual(
|
||||
Object.keys(storage.actors),
|
||||
[freshActor],
|
||||
"hydrated storage should drop actors whose savedAt exceeds the TTL",
|
||||
);
|
||||
|
||||
const freshEntry = storage.actors[freshActor];
|
||||
assert.ok(freshEntry, "fresh actor should remain present after hydration");
|
||||
assert.equal(freshEntry.snapshotId, "snap-fresh");
|
||||
assert.equal(freshEntry.fingerprint, "fingerprint-fresh");
|
||||
assert.equal(freshEntry.items.length, 1);
|
||||
assert.equal(freshEntry.items[0]?.value, "fresh-pointer");
|
||||
assert.equal(freshEntry.metadata.status, "ok");
|
||||
|
||||
|
||||
// Test Stale Actor
|
||||
profileCache.setActiveProfile(staleActor);
|
||||
const stalePayload = {
|
||||
actor: staleActor,
|
||||
snapshotId: "snap-stale",
|
||||
fingerprint: "fingerprint-stale",
|
||||
savedAt: baseNow - (ttl + 1000),
|
||||
items: [{ type: "e", value: "stale-pointer", watchedAt: 456 }],
|
||||
metadata: { status: "stale" },
|
||||
};
|
||||
const staleKey = profileCache.getStorageKey(staleActor, NOTE_TYPES.WATCH_HISTORY);
|
||||
localStorage.setItem(staleKey, JSON.stringify(stalePayload));
|
||||
|
||||
const staleStorage = getWatchHistoryStorage(manager);
|
||||
const staleEntry = staleStorage.actors[staleActor];
|
||||
|
||||
assert.strictEqual(
|
||||
storage.actors[staleActor],
|
||||
staleEntry,
|
||||
undefined,
|
||||
"stale actor entry should be removed",
|
||||
"stale actor entry should be removed (pruned)",
|
||||
);
|
||||
|
||||
const persistedRaw = localStorage.getItem(WATCH_HISTORY_STORAGE_KEY);
|
||||
assert.ok(persistedRaw, "storage rewrite should persist sanitized payload");
|
||||
const persisted = JSON.parse(persistedRaw);
|
||||
assert.deepStrictEqual(
|
||||
Object.keys(persisted.actors),
|
||||
[freshActor],
|
||||
"persisted storage should match hydrated storage",
|
||||
);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
manager.clear();
|
||||
profileCache.activePubkey = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -347,6 +350,7 @@ test("publishWatchHistorySnapshot uses injected nostr-tools helpers when signer
|
||||
return this;
|
||||
},
|
||||
}),
|
||||
async list() { return []; },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -372,6 +376,8 @@ test("publishWatchHistorySnapshot uses injected nostr-tools helpers when signer
|
||||
|
||||
test("publishWatchHistorySnapshot caches successful snapshot results", async () => {
|
||||
const actorPubkey = "c".repeat(64);
|
||||
profileCache.setActiveProfile(actorPubkey);
|
||||
|
||||
const sessionPrivateKey = "s".repeat(64);
|
||||
const relayUrls = ["wss://relay.cache"];
|
||||
const publishCalls = [];
|
||||
@@ -406,6 +412,7 @@ test("publishWatchHistorySnapshot caches successful snapshot results", async ()
|
||||
},
|
||||
};
|
||||
},
|
||||
async list() { return []; },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -438,5 +445,6 @@ test("publishWatchHistorySnapshot caches successful snapshot results", async ()
|
||||
assert.equal(fingerprint, cacheEntry.fingerprint);
|
||||
} finally {
|
||||
manager.clear();
|
||||
profileCache.activePubkey = null;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import RevertModalController from '../js/ui/revertModalController.js';
|
||||
|
||||
test('RevertModalController', async (t) => {
|
||||
const mockVideo = { id: 'v1', pubkey: 'p1' };
|
||||
const mockHistory = [{ id: 'v1' }, { id: 'v0' }];
|
||||
|
||||
const mocks = {
|
||||
revertModal: {
|
||||
load: async () => true,
|
||||
setHistory: () => {},
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
setBusy: () => {},
|
||||
},
|
||||
services: {
|
||||
nostrService: {
|
||||
fetchVideos: async () => [mockVideo],
|
||||
},
|
||||
nostrClient: {
|
||||
hydrateVideoHistory: async () => mockHistory,
|
||||
revertVideo: async () => {},
|
||||
},
|
||||
},
|
||||
state: {
|
||||
getPubkey: () => 'p1', // must match video pubkey
|
||||
getBlacklistedEventIds: () => new Set(),
|
||||
},
|
||||
ui: {
|
||||
showError: () => {},
|
||||
showSuccess: () => {},
|
||||
},
|
||||
callbacks: {
|
||||
loadVideos: async () => {},
|
||||
forceRefreshAllProfiles: () => {},
|
||||
isAuthorBlocked: () => false,
|
||||
},
|
||||
helpers: {
|
||||
normalizeActionTarget: () => ({ triggerElement: null }),
|
||||
resolveVideoActionTarget: async () => mockVideo,
|
||||
formatAbsoluteTimestamp: () => 'timestamp',
|
||||
},
|
||||
};
|
||||
|
||||
await t.test('open() fetches history and opens modal', async () => {
|
||||
let historySet = null;
|
||||
let modalOpened = false;
|
||||
|
||||
const controller = new RevertModalController({
|
||||
...mocks,
|
||||
revertModal: {
|
||||
...mocks.revertModal,
|
||||
setHistory: (_v, h) => {
|
||||
historySet = h;
|
||||
},
|
||||
open: () => {
|
||||
modalOpened = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await controller.open('v1');
|
||||
|
||||
assert.deepEqual(historySet, mockHistory, 'History should be set');
|
||||
assert.equal(modalOpened, true, 'Modal should be opened');
|
||||
});
|
||||
|
||||
await t.test('open() shows error if not logged in', async () => {
|
||||
let errorMsg = '';
|
||||
const controller = new RevertModalController({
|
||||
...mocks,
|
||||
state: { ...mocks.state, getPubkey: () => null },
|
||||
ui: {
|
||||
...mocks.ui,
|
||||
showError: (m) => {
|
||||
errorMsg = m;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await controller.open('v1');
|
||||
assert.match(errorMsg, /login/, 'Should show login error');
|
||||
});
|
||||
|
||||
await t.test('handleConfirm() calls revertVideo and refreshes', async () => {
|
||||
let revertCalled = false;
|
||||
let refreshCalled = false;
|
||||
let modalClosed = false;
|
||||
let modalBusyState = null;
|
||||
|
||||
const controller = new RevertModalController({
|
||||
...mocks,
|
||||
services: {
|
||||
...mocks.services,
|
||||
nostrClient: {
|
||||
...mocks.services.nostrClient,
|
||||
revertVideo: async () => {
|
||||
revertCalled = true;
|
||||
},
|
||||
},
|
||||
},
|
||||
callbacks: {
|
||||
...mocks.callbacks,
|
||||
loadVideos: async () => {
|
||||
refreshCalled = true;
|
||||
},
|
||||
},
|
||||
revertModal: {
|
||||
...mocks.revertModal,
|
||||
close: () => {
|
||||
modalClosed = true;
|
||||
},
|
||||
setBusy: (busy) => {
|
||||
modalBusyState = busy;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await controller.handleConfirm({
|
||||
detail: {
|
||||
target: mockVideo,
|
||||
entries: [{ id: 'v1', pubkey: 'p1', tags: [] }],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(revertCalled, true, 'revertVideo should be called');
|
||||
assert.equal(refreshCalled, true, 'loadVideos should be called');
|
||||
assert.equal(modalClosed, true, 'Modal should be closed');
|
||||
assert.equal(modalBusyState, false, 'Modal should not be busy at end');
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,28 @@ globalThis.DOMParser = dom.window.DOMParser;
|
||||
globalThis.URL = URL;
|
||||
|
||||
test("LinkPreviewService", async (t) => {
|
||||
const service = new LinkPreviewService({ ttlMs: 1000 });
|
||||
const originalFetch = globalThis.fetch;
|
||||
let service;
|
||||
|
||||
t.beforeEach(() => {
|
||||
service = new LinkPreviewService({ ttlMs: 1000 });
|
||||
});
|
||||
|
||||
t.afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (service && service.db) {
|
||||
service.db.close();
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase("bitvid-link-previews");
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onblocked = () => {
|
||||
// Log warning if blocked, though strictly closing connections should prevent this
|
||||
console.warn("[LinkPreviewService Test] Database deletion blocked");
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
await t.test("initializes IndexedDB", async () => {
|
||||
const db = await service.init();
|
||||
@@ -77,6 +98,10 @@ test("LinkPreviewService", async (t) => {
|
||||
|
||||
const cached = await expiredService.getCachedPreview("https://expired.com");
|
||||
assert.equal(cached, null);
|
||||
|
||||
if (expiredService.db) {
|
||||
expiredService.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
await t.test("deletePreview removes from cache", async () => {
|
||||
|
||||
@@ -4,12 +4,13 @@ import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { nostrClient } from "../js/nostrClientFacade.js";
|
||||
import { getActiveSigner, setActiveSigner } from "../js/nostr/client.js";
|
||||
import { getActiveSigner, setActiveSigner, clearActiveSigner } from "../js/nostr/client.js";
|
||||
import { subscriptions } from "../js/subscriptions.js";
|
||||
import { setApplication } from "../js/applicationContext.js";
|
||||
import nostrService from "../js/services/nostrService.js";
|
||||
import moderationService from "../js/services/moderationService.js";
|
||||
import { getNostrEventSchema, NOTE_TYPES } from "../js/nostrEventSchemas.js";
|
||||
import { profileCache } from "../js/state/profileCache.js";
|
||||
|
||||
test("loadSubscriptions aggregates relay results when one rejects", async () => {
|
||||
const SubscriptionsManager = subscriptions.constructor;
|
||||
@@ -71,6 +72,8 @@ test("loadSubscriptions aggregates relay results when one rejects", async () =>
|
||||
|
||||
const decryptCalls = [];
|
||||
globalThis.window.nostr = {
|
||||
getPublicKey: async () => "0000000000000000000000000000000000000000000000000000000000000123",
|
||||
signEvent: async () => ({}),
|
||||
nip04: {
|
||||
async decrypt(_pubkey, ciphertext) {
|
||||
decryptCalls.push(ciphertext);
|
||||
@@ -83,7 +86,7 @@ test("loadSubscriptions aggregates relay results when one rejects", async () =>
|
||||
};
|
||||
|
||||
try {
|
||||
await manager.loadSubscriptions("user-pubkey-123");
|
||||
await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000123");
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from(manager.subscribedPubkeys),
|
||||
@@ -130,6 +133,9 @@ test("loadSubscriptions aggregates relay results when one rejects", async () =>
|
||||
delete globalThis.window;
|
||||
}
|
||||
localStorage.clear();
|
||||
profileCache.memoryCache.clear();
|
||||
profileCache.activePubkey = null;
|
||||
clearActiveSigner();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -170,7 +176,7 @@ test("loadSubscriptions queries the correct subscription list kind", async () =>
|
||||
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
|
||||
|
||||
try {
|
||||
await manager.loadSubscriptions("user-pubkey-legacy");
|
||||
await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000002");
|
||||
|
||||
assert.ok(
|
||||
capturedFilters.length > 0,
|
||||
@@ -210,6 +216,9 @@ test("loadSubscriptions queries the correct subscription list kind", async () =>
|
||||
delete globalThis.window;
|
||||
}
|
||||
localStorage.clear();
|
||||
profileCache.memoryCache.clear();
|
||||
profileCache.activePubkey = null;
|
||||
clearActiveSigner();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -226,12 +235,6 @@ test("loadSubscriptions falls back to nip44 when hinted", async () => {
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
|
||||
|
||||
const hadWindow = typeof globalThis.window !== "undefined";
|
||||
if (!hadWindow) {
|
||||
globalThis.window = {};
|
||||
}
|
||||
const originalWindowNostr = globalThis.window.nostr;
|
||||
|
||||
const relayUrls = ["wss://relay-nip44.example"];
|
||||
nostrClient.relays = relayUrls;
|
||||
nostrClient.writeRelays = relayUrls;
|
||||
@@ -252,23 +255,20 @@ test("loadSubscriptions falls back to nip44 when hinted", async () => {
|
||||
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
|
||||
|
||||
const decryptCalls = { nip04: 0, nip44: 0 };
|
||||
globalThis.window.nostr = {
|
||||
nip04: {
|
||||
async decrypt() {
|
||||
decryptCalls.nip04 += 1;
|
||||
throw new Error("nip04 unavailable");
|
||||
},
|
||||
setActiveSigner({
|
||||
pubkey: "0000000000000000000000000000000000000000000000000000000000000005",
|
||||
async nip04Decrypt() {
|
||||
decryptCalls.nip04 += 1;
|
||||
throw new Error("nip04 unavailable");
|
||||
},
|
||||
nip44: {
|
||||
async decrypt(_pubkey, ciphertext) {
|
||||
decryptCalls.nip44 += 1;
|
||||
return JSON.stringify([["p", "pub-nip44", ciphertext]]);
|
||||
},
|
||||
async nip44Decrypt(_pubkey, ciphertext) {
|
||||
decryptCalls.nip44 += 1;
|
||||
return JSON.stringify([["p", "pub-nip44", ciphertext]]);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.loadSubscriptions("user-pubkey-123");
|
||||
await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000005");
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from(manager.subscribedPubkeys),
|
||||
@@ -292,15 +292,10 @@ test("loadSubscriptions falls back to nip44 when hinted", async () => {
|
||||
nostrClient.writeRelays = originalWriteRelays;
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
|
||||
if (typeof originalWindowNostr === "undefined") {
|
||||
delete globalThis.window.nostr;
|
||||
} else {
|
||||
globalThis.window.nostr = originalWindowNostr;
|
||||
}
|
||||
if (!hadWindow) {
|
||||
delete globalThis.window;
|
||||
}
|
||||
localStorage.clear();
|
||||
profileCache.memoryCache.clear();
|
||||
profileCache.activePubkey = null;
|
||||
clearActiveSigner();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -317,12 +312,6 @@ test("loadSubscriptions handles nip44.v2 decryptors", async () => {
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
|
||||
|
||||
const hadWindow = typeof globalThis.window !== "undefined";
|
||||
if (!hadWindow) {
|
||||
globalThis.window = {};
|
||||
}
|
||||
const originalWindowNostr = globalThis.window.nostr;
|
||||
|
||||
const relayUrls = ["wss://relay-nip44-v2.example"];
|
||||
nostrClient.relays = relayUrls;
|
||||
nostrClient.writeRelays = relayUrls;
|
||||
@@ -343,19 +332,16 @@ test("loadSubscriptions handles nip44.v2 decryptors", async () => {
|
||||
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
|
||||
|
||||
const decryptCalls = { nip04: 0, nip44: 0, nip44_v2: 0 };
|
||||
globalThis.window.nostr = {
|
||||
nip44: {
|
||||
v2: {
|
||||
async decrypt(_pubkey, ciphertext) {
|
||||
decryptCalls.nip44_v2 += 1;
|
||||
return JSON.stringify([["p", "pub-nip44-v2"], ["t", ciphertext]]);
|
||||
},
|
||||
},
|
||||
setActiveSigner({
|
||||
pubkey: "0000000000000000000000000000000000000000000000000000000000000006",
|
||||
async nip44Decrypt(_pubkey, ciphertext) {
|
||||
decryptCalls.nip44_v2 += 1;
|
||||
return JSON.stringify([["p", "pub-nip44-v2"], ["t", ciphertext]]);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.loadSubscriptions("user-pubkey-123");
|
||||
await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000006");
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from(manager.subscribedPubkeys),
|
||||
@@ -384,15 +370,10 @@ test("loadSubscriptions handles nip44.v2 decryptors", async () => {
|
||||
nostrClient.writeRelays = originalWriteRelays;
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
|
||||
if (typeof originalWindowNostr === "undefined") {
|
||||
delete globalThis.window.nostr;
|
||||
} else {
|
||||
globalThis.window.nostr = originalWindowNostr;
|
||||
}
|
||||
if (!hadWindow) {
|
||||
delete globalThis.window;
|
||||
}
|
||||
localStorage.clear();
|
||||
profileCache.memoryCache.clear();
|
||||
profileCache.activePubkey = null;
|
||||
clearActiveSigner();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -409,12 +390,6 @@ test("loadSubscriptions prefers nip44 decryptors when both are available", async
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
|
||||
|
||||
const hadWindow = typeof globalThis.window !== "undefined";
|
||||
if (!hadWindow) {
|
||||
globalThis.window = {};
|
||||
}
|
||||
const originalWindowNostr = globalThis.window.nostr;
|
||||
|
||||
const relayUrls = ["wss://relay-nip44-pref.example"];
|
||||
nostrClient.relays = relayUrls;
|
||||
nostrClient.writeRelays = relayUrls;
|
||||
@@ -435,25 +410,20 @@ test("loadSubscriptions prefers nip44 decryptors when both are available", async
|
||||
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
|
||||
|
||||
const decryptCalls = { nip04: 0, nip44: 0 };
|
||||
globalThis.window.nostr = {
|
||||
nip04: {
|
||||
async decrypt() {
|
||||
decryptCalls.nip04 += 1;
|
||||
return JSON.stringify([["p", "pub-nip04"]]);
|
||||
},
|
||||
setActiveSigner({
|
||||
pubkey: "0000000000000000000000000000000000000000000000000000000000000003",
|
||||
async nip04Decrypt() {
|
||||
decryptCalls.nip04 += 1;
|
||||
return JSON.stringify([["p", "pub-nip04"]]);
|
||||
},
|
||||
nip44: {
|
||||
v2: {
|
||||
async decrypt(_pubkey, ciphertext) {
|
||||
decryptCalls.nip44 += 1;
|
||||
return JSON.stringify([["p", "pub-nip44-pref", ciphertext]]);
|
||||
},
|
||||
},
|
||||
async nip44Decrypt(_pubkey, ciphertext) {
|
||||
decryptCalls.nip44 += 1;
|
||||
return JSON.stringify([["p", "pub-nip44-pref", ciphertext]]);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.loadSubscriptions("user-pubkey-123");
|
||||
await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000003");
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from(manager.subscribedPubkeys),
|
||||
@@ -467,15 +437,10 @@ test("loadSubscriptions prefers nip44 decryptors when both are available", async
|
||||
nostrClient.writeRelays = originalWriteRelays;
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
|
||||
if (typeof originalWindowNostr === "undefined") {
|
||||
delete globalThis.window.nostr;
|
||||
} else {
|
||||
globalThis.window.nostr = originalWindowNostr;
|
||||
}
|
||||
if (!hadWindow) {
|
||||
delete globalThis.window;
|
||||
}
|
||||
localStorage.clear();
|
||||
profileCache.memoryCache.clear();
|
||||
profileCache.activePubkey = null;
|
||||
clearActiveSigner();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -526,7 +491,7 @@ test(
|
||||
|
||||
const decryptCalls = [];
|
||||
setActiveSigner({
|
||||
pubkey: "user-pubkey-123",
|
||||
pubkey: "0000000000000000000000000000000000000000000000000000000000000004",
|
||||
async nip04Decrypt(pubkey, ciphertext) {
|
||||
decryptCalls.push({ pubkey, ciphertext });
|
||||
return JSON.stringify([["p", "pub-direct"]]);
|
||||
@@ -534,13 +499,13 @@ test(
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.loadSubscriptions("user-pubkey-123");
|
||||
await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000004");
|
||||
|
||||
assert.equal(permissionCalls, 0, "should not request extension permissions");
|
||||
assert.equal(decryptCalls.length, 1, "signer decrypt should be invoked once");
|
||||
assert.deepEqual(
|
||||
decryptCalls[0],
|
||||
{ pubkey: "user-pubkey-123", ciphertext: "cipher-direct" },
|
||||
{ pubkey: "0000000000000000000000000000000000000000000000000000000000000004", ciphertext: "cipher-direct" },
|
||||
"signer decrypt should receive the expected arguments",
|
||||
);
|
||||
assert.deepEqual(
|
||||
@@ -563,6 +528,9 @@ test(
|
||||
delete globalThis.window;
|
||||
}
|
||||
localStorage.clear();
|
||||
profileCache.memoryCache.clear();
|
||||
profileCache.activePubkey = null;
|
||||
clearActiveSigner();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export async function waitFor(callback, { timeout = 2000, interval = 50, message = "waitFor timed out" } = {}) {
|
||||
const start = Date.now();
|
||||
let lastError;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const result = await callback();
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
if (Date.now() - start > timeout) {
|
||||
const errorMsg = lastError ? `${message}: ${lastError.message}` : message;
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
|
||||
url: "https://example.com",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
globalThis.window = dom.window;
|
||||
globalThis.document = dom.window.document;
|
||||
try {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: dom.window.navigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
} catch (e) {
|
||||
// If we can't overwrite it, we can't.
|
||||
}
|
||||
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.Node = dom.window.Node;
|
||||
|
||||
// Pre-stub scrollTo
|
||||
try {
|
||||
dom.window.scrollTo = () => {};
|
||||
} catch (e) {}
|
||||
|
||||
// Stub pause/load early as well, just in case
|
||||
if (dom.window.HTMLMediaElement && dom.window.HTMLMediaElement.prototype) {
|
||||
dom.window.HTMLMediaElement.prototype.pause = () => {};
|
||||
dom.window.HTMLMediaElement.prototype.load = () => {};
|
||||
}
|
||||
@@ -29,8 +29,10 @@ describe("UploadModal Reset Logic", () => {
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
dom = createUiDom();
|
||||
global.window = dom.window;
|
||||
global.self = dom.window;
|
||||
global.document = dom.document;
|
||||
global.EventTarget = dom.window.EventTarget;
|
||||
global.alert = (msg) => { console.log("ALERT CALLED:", msg); };
|
||||
@@ -167,6 +169,10 @@ describe("UploadModal Reset Logic", () => {
|
||||
modal.activeProvider = "cloudflare_r2";
|
||||
modal.activeCredentials = { accountId: 'acc', accessKeyId: 'key', secretAccessKey: 'sec' };
|
||||
|
||||
// Mock generateTorrentMetadata BEFORE calling handleVideoSelection
|
||||
modal.generateTorrentMetadata = async () => ({ hasValidInfoHash: false });
|
||||
modal.resolveUploadIdentifier = async () => "mock-identifier";
|
||||
|
||||
// Start upload
|
||||
const file = { name: "video.mp4" };
|
||||
const event = { target: { files: [file], value: "video.mp4" } };
|
||||
@@ -186,12 +192,6 @@ describe("UploadModal Reset Logic", () => {
|
||||
// Now let the upload promise resolve
|
||||
resolveUpload({ bucket: 'b', key: 'k' });
|
||||
|
||||
// Also need to handle generateTorrentMetadata which might be awaited
|
||||
// Assuming it resolves quickly or we mock it too.
|
||||
// UploadModal mocks `generateTorrentMetadata` internally? No, it's a method on instance or imported.
|
||||
// It calls `this.generateTorrentMetadata`. We can spy/mock it.
|
||||
modal.generateTorrentMetadata = async () => ({ hasValidInfoHash: false });
|
||||
|
||||
await uploadPromise;
|
||||
|
||||
// Assert that state remains 'idle' and wasn't overwritten by 'complete'
|
||||
|
||||
@@ -25,6 +25,18 @@ export async function setupModal({ lazyLoad = false } = {}) {
|
||||
|
||||
globalThis.window = window;
|
||||
globalThis.document = document;
|
||||
globalThis.navigator = window.navigator || { userAgent: "node.js" };
|
||||
|
||||
// stub window.scrollTo so floating/popover code doesn't throw
|
||||
window.scrollTo = () => {};
|
||||
|
||||
// jsdom defines HTMLMediaElement but .pause/.load throw "Not implemented".
|
||||
// Replace with safe no-ops for tests that just depend on calls happening.
|
||||
if (window.HTMLMediaElement && window.HTMLMediaElement.prototype) {
|
||||
window.HTMLMediaElement.prototype.pause = function () {};
|
||||
window.HTMLMediaElement.prototype.load = function () {};
|
||||
}
|
||||
|
||||
globalThis.HTMLElement = window.HTMLElement;
|
||||
globalThis.HTMLVideoElement = window.HTMLVideoElement;
|
||||
globalThis.Element = window.Element;
|
||||
@@ -33,6 +45,7 @@ export async function setupModal({ lazyLoad = false } = {}) {
|
||||
globalThis.Event = window.Event;
|
||||
globalThis.Node = window.Node;
|
||||
globalThis.EventTarget = window.EventTarget;
|
||||
|
||||
try {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: window.navigator,
|
||||
@@ -142,6 +155,8 @@ export async function setupModal({ lazyLoad = false } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
window.scrollTo = () => {};
|
||||
|
||||
applyDesignSystemAttributes(document);
|
||||
|
||||
if (!window.ResizeObserver) {
|
||||
@@ -159,7 +174,11 @@ export async function setupModal({ lazyLoad = false } = {}) {
|
||||
|
||||
// Force override getContext to silence JSDOM "Not implemented" error
|
||||
try {
|
||||
window.HTMLCanvasElement.prototype.getContext = () => null;
|
||||
window.HTMLCanvasElement.prototype.getContext = () => ({
|
||||
fillStyle: "",
|
||||
fillRect: () => {},
|
||||
drawImage: () => {},
|
||||
});
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
@@ -432,6 +451,12 @@ async function setupPlaybackHarness() {
|
||||
globalThis.Event = window.Event;
|
||||
globalThis.Node = window.Node;
|
||||
globalThis.EventTarget = window.EventTarget;
|
||||
|
||||
if (window.HTMLMediaElement && window.HTMLMediaElement.prototype) {
|
||||
window.HTMLMediaElement.prototype.pause = function () {};
|
||||
window.HTMLMediaElement.prototype.load = function () {};
|
||||
}
|
||||
|
||||
try {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: window.navigator,
|
||||
@@ -531,6 +556,8 @@ async function setupPlaybackHarness() {
|
||||
};
|
||||
}
|
||||
|
||||
window.scrollTo = () => {};
|
||||
|
||||
window.NostrTools = {
|
||||
nip19: {
|
||||
neventEncode: () => "nevent1example",
|
||||
|
||||
@@ -247,12 +247,10 @@ test(
|
||||
t.after(cleanup);
|
||||
|
||||
const visibilityCalls = [];
|
||||
const instrumentedModal = {
|
||||
...modal,
|
||||
setCommentsVisibility: (isVisible) => {
|
||||
visibilityCalls.push(isVisible);
|
||||
modal.setCommentsVisibility(isVisible);
|
||||
},
|
||||
const instrumentedModal = Object.create(modal);
|
||||
instrumentedModal.setCommentsVisibility = (isVisible) => {
|
||||
visibilityCalls.push(isVisible);
|
||||
modal.setCommentsVisibility(isVisible);
|
||||
};
|
||||
|
||||
const snapshot = {
|
||||
@@ -398,6 +396,7 @@ test(
|
||||
},
|
||||
],
|
||||
]),
|
||||
childrenByParent: new Map([[null, ["c1"]]]),
|
||||
commentIds: ["c1"],
|
||||
profiles: new Map([["ABCD", { name: "Mixed Case" }]]),
|
||||
});
|
||||
@@ -477,6 +476,7 @@ test(
|
||||
},
|
||||
],
|
||||
]),
|
||||
childrenByParent: new Map([[null, ["comment-2"]]]),
|
||||
commentIds: ["comment-2"],
|
||||
profiles: new Map(),
|
||||
};
|
||||
|
||||
@@ -85,10 +85,10 @@ test("VideoModal trims tag strip to fit modal width", async (t) => {
|
||||
assert.ok(root, "tag container should be available after hydrate");
|
||||
|
||||
modal.setTagPreferenceStateResolver((tag) => {
|
||||
if (tag === "alpha") {
|
||||
if (tag === "#alpha") {
|
||||
return "interest";
|
||||
}
|
||||
if (tag === "beta") {
|
||||
if (tag === "#beta") {
|
||||
return "disinterest";
|
||||
}
|
||||
return "neutral";
|
||||
@@ -143,7 +143,7 @@ test("VideoModal trims tag strip to fit modal width", async (t) => {
|
||||
assert.equal(trimmedButtons.length, 2, "tag list should be trimmed to fit");
|
||||
assert.deepEqual(
|
||||
trimmedButtons.map((button) => button.dataset.tag),
|
||||
["alpha", "beta"],
|
||||
["#alpha", "#beta"],
|
||||
"leading tags should remain after trimming",
|
||||
);
|
||||
assert.equal(
|
||||
@@ -185,7 +185,7 @@ test("VideoModal trims tag strip to fit modal width", async (t) => {
|
||||
);
|
||||
assert.deepEqual(
|
||||
expandedButtons.map((button) => button.dataset.tag),
|
||||
["alpha", "beta", "delta"],
|
||||
["#alpha", "#beta", "#delta"],
|
||||
"reflow should restore full list order before trimming",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import "./test-setup.mjs";
|
||||
|
||||
import { setupModal } from "./video-modal-accessibility.test.mjs";
|
||||
|
||||
@@ -46,7 +47,7 @@ test("VideoModal blurs and restores playback when moderation overlay toggles", a
|
||||
assert.ok(stage, "video stage should exist");
|
||||
assert.equal(stage.dataset.visualState, "blurred");
|
||||
|
||||
const overlay = document.querySelector("[data-moderation-overlay]");
|
||||
const overlay = document.querySelector("[data-moderation-bar]");
|
||||
assert.ok(overlay, "moderation overlay should be rendered");
|
||||
assert.equal(overlay.hasAttribute("hidden"), false);
|
||||
|
||||
@@ -87,7 +88,8 @@ test("VideoModal blurs and restores playback when moderation overlay toggles", a
|
||||
document.dispatchEvent(overrideSignal);
|
||||
|
||||
assert.equal(stage.dataset.visualState, undefined);
|
||||
assert.equal(overlay.hasAttribute("hidden"), true);
|
||||
assert.equal(overlay.hasAttribute("hidden"), false);
|
||||
assert.equal(overlay.dataset.overlayState, "override");
|
||||
|
||||
const restoreButton = document.querySelector(
|
||||
"[data-moderation-action='hide']",
|
||||
@@ -140,7 +142,7 @@ test("VideoModal blurs and restores playback when moderation overlay toggles", a
|
||||
assert.ok(stage, "video stage should exist");
|
||||
assert.equal(stage.dataset.visualState, "blurred");
|
||||
|
||||
const overlay = document.querySelector("[data-moderation-overlay]");
|
||||
const overlay = document.querySelector("[data-moderation-bar]");
|
||||
assert.ok(overlay, "moderation overlay should be rendered");
|
||||
assert.equal(overlay.hasAttribute("hidden"), false);
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
"contrast": "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 truncated
|
||||
"default": "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 truncated
|
||||
"light": "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 truncated
|
||||
"video-modal-mobile": "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 truncated
|
||||
"video-modal-mobile": "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 truncated
|
||||
}
|
||||
@@ -121,12 +121,17 @@ test.describe("overlay layering tokens", () => {
|
||||
expect(initialLayout.state).toBe("collapsed");
|
||||
expect(Math.abs(initialLayout.marginLeft - initialLayout.collapsedMargin)).toBeLessThan(0.5);
|
||||
|
||||
// Ensure the toggle is initialized
|
||||
await expect(collapseToggle).toHaveAttribute("data-state", /.*/);
|
||||
await collapseToggle.click();
|
||||
|
||||
await page.waitForFunction(() =>
|
||||
document.getElementById("sidebar")?.classList.contains("sidebar-expanded")
|
||||
);
|
||||
|
||||
// Wait for sidebar transition to complete
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const expandedLayout = await page.evaluate(() => {
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const app = document.getElementById("app");
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
|
||||
import { subscriptions } from './js/subscriptions.js';
|
||||
import { hashtagPreferences } from './js/services/hashtagPreferencesService.js';
|
||||
import { profileCache } from './js/state/profileCache.js';
|
||||
|
||||
// Mock profileCache.getProfileData
|
||||
const originalGetProfileData = profileCache.getProfileData;
|
||||
const originalGet = profileCache.get;
|
||||
|
||||
let calls = [];
|
||||
|
||||
profileCache.getProfileData = (pubkey, section) => {
|
||||
calls.push({ method: 'getProfileData', pubkey, section });
|
||||
return null;
|
||||
};
|
||||
|
||||
profileCache.get = (section) => {
|
||||
calls.push({ method: 'get', section });
|
||||
return null;
|
||||
};
|
||||
|
||||
async function runVerify() {
|
||||
console.log("Verifying fix...");
|
||||
|
||||
// Test SubscriptionsManager
|
||||
const subPubkey = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
// We need to mock updateFromRelays to avoid network calls, or just catch error
|
||||
subscriptions.updateFromRelays = async () => {};
|
||||
|
||||
await subscriptions.loadSubscriptions(subPubkey);
|
||||
|
||||
const subCall = calls.find(c => c.section === "subscriptions");
|
||||
if (subCall && subCall.method === "getProfileData" && subCall.pubkey === subPubkey) {
|
||||
console.log("PASS: subscriptions.loadSubscriptions used getProfileData with correct pubkey.");
|
||||
} else {
|
||||
console.error("FAIL: subscriptions.loadSubscriptions did not use getProfileData correctly.", calls);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
calls = []; // Reset
|
||||
|
||||
// Test HashtagPreferencesService
|
||||
const tagPubkey = "0000000000000000000000000000000000000000000000000000000000000002";
|
||||
// Mock load logic to avoid network
|
||||
hashtagPreferences.loadFromCache(tagPubkey);
|
||||
|
||||
const tagCall = calls.find(c => c.section === "interests");
|
||||
if (tagCall && tagCall.method === "getProfileData" && tagCall.pubkey === tagPubkey) {
|
||||
console.log("PASS: hashtagPreferences.loadFromCache used getProfileData with correct pubkey.");
|
||||
} else {
|
||||
console.error("FAIL: hashtagPreferences.loadFromCache did not use getProfileData correctly.", calls);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("All verifications passed.");
|
||||
}
|
||||
|
||||
runVerify().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user