Merge pull request #1791 from PR0M3TH3AN/unstable

Unstable
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-26 23:05:14 -05:00
commit 6f3156b741
68 files changed
+2740 -1195

No files matched your search

+9
View File
@@ -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 }}
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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": "iVBORw0KGgoAAAANSUhEUgAABQwAAAXVCAIAAADPSQWyAAAQAElEQVR4nOy9Sbrsqg41KL1vDzY7OYFsZjcbOWT990SEbQpJSBQuIrTefWfbIARGFFqAHX//1//9/0AgEBgBZX+YG16+oZRVR8lV8peUXIj5hw0RS1Il46M4AVmhJQ2KNzeDuwruDmSuJijj708D+iTQmkpUtf2fj/33B1vJe7JdgIkVsyXG+WU9oWXZBm9OjIS0dEwgVEvuQyx9/v0Ev+YHSmI/ty+xPW4P+ag9Yl8qKuW0JUmV1yFb6KHtKFs+J6RlPlS0kTWOvZcUwxKm/+5JXn8xlUlD8F8IQhb173KL+kjhlnyLSrPbVOGuJ+2taak2JVV4Kg/cQyM2GjMawu48ZwYCV+P////+36bMHwQCgcB5+AIGKeOrH+6OSCs8PMJAIBAIBAKT8D8IBAKBe4L2rYrAVfjh2o+GFwh8I6JnBwIBC2InORD4ZhBztT6v6QnekjfdKgyP6x6o7eBvMHSrVnav0gQCGWLgCwQC343YSQ4EfhBXuzfUR6nvvbMcPqMRcUDA8PTRmr4MdzfojctH1cUK5YFAIFAgdpIDgYAVw/7EmII9Nd56Z/kbMHUPc0jZTB/WVJBzt29jszgwhOB4bkSfCwQCNgRJDgQC5yLcukfgcCWPxQnoBb+q8YyWMOhTn+ySn5jdsyrm0Ug+HH1+ttNUuNSlzeP2TSXaciDwlYjj1oHAGLx+RIffQR0xBilTYjLGG5R9JTnefyYFvhBPf67ec93THnr85MTXrigtebBv7YgLQNmfbwBdfdJpMqIlBwKXI3aSA4FAC3ebrj/r9vf4/Z+v92WyU+5D9VxU1Xk28z/CSVtDrWy+fIcqNuDuj5kvBAfvCwQCT0KQ5EDgG3GGN+L/cvbEUt3Cvf5Bn2/m2+DdNjTW+4RSPpLFLS90cFs3gh4ug94az2ir0R8CgS9FkORAYAoudIJOynr86OhkzORrvdn/lO9bvqU8h4SuA1NKjzt7nFe41AMmArytCz5WOYuqdp3FjM11/ahA7ojJma/OZ/Qbe51tYH1np5NP0AR7DwQGECQ5EAj0wvshloVleP05jUzQr24MZd/wuniVwoiqlDc5SR0ImDBrrInN7EAgEHAhPtwVCMzHuDtCemBvBs50ps3SG/le/3HX93+LCtX7Iajp+Uv/nV2U/OrOOKqop7ADD0gdMSOinQm8CsfHohmpA4EPmnMVNVN7cnKnsugbTha9KRCYiyDJgcCDMH8S7D1A2EO3zwO9CTNMwzOY4OmE+cjvUe6ZubCDTzVzDzD83x/BbEPb9dEkGSGdN+ldxtzJhYiOHAg8B0GSA4EBdMz7q7OYk3RuFlf5BTRnszXbOjjVdRsp++mVfhen1oCTq/M7/GJmM5n4GI9C+pLa6cSqx6f9HzF2UPlNce/V20Ag8CjEO8mBwE+idwfZrQGudUSofDN0xKE/EVPyeys5981YesaruOT5Ratlz/SQygpcj0cOQBO07Br8HWWgd5mGh+i9gcC3I3aSA4HJmOFdXHfkbr66y9fq6XE7J3PzO/Xo9efqviDmqiG/nXbuea52OvtJdV/+K6wgd6X+tSdasZtKsETnwrSOSri2m9mXV6cPY7ceWKbg+58wEHgUgiQHAuM4Y2qrnXv9RJ1PpynDMtKQ99Mn/bM9s0X5dZ/ZHsjwvghPdCLmrI3Qqmb/RAwXOxr4REyrTMtCiUs8EAisRhy3DgTOwuiO0eisOWnW9fizN5row+f4YO0hwStOeHfgUwnm3yB2nc7m8+qKvSUYG9P4b0r/N65gpXcYs9qjY8y7+0gztpgxU6yvADhDpifbyXq/blwIBL4PsZMcCPTiqbTLR9Z7n/InTsfNxWn1tXxjufdw8pnY9j/PeXfAeGhjRORkTCxRDBQ+9J77DyjoqyBPqjBBIPA0BEkOBGZi8jxIhhC3ijFQ8VcWC5/Ag2+rrSc8j4snjxB/avcWoyK4E7jjoTTA4Y5/JmNkSWjCSDbzkagjZnnWmczqcliyeAlM24l90tgcs24gMBFBkgOBe4HEV4DrkIFdXgvLNYf2igX+4dr1hIVZP+jHk//9qrattAse6sm9hfibsR33pad17YS5o2OSOXA6HtCKXKz1Id/It4TaOD0TPMGmMRUHAgMIkhwInIKOuWrM0RwDZX+8aWNifhQWsvTbtwRS7hrCZ8Of+wXs7P48Oc1H/69Dmy/ZkpGSBmIXZD2F62I7rxn5dB81CAQC34kgyYFAFyY4gw9E+6x1eAxPxRrLvXdob90q3Dy5b+noZ3oGda6xkajnxqCHlNONknQSH3wKUA0XYm026f0U3wg8yak7MhAIjCNIciAwDYvmrLN2VsbxnJIGOKzcT34QT74iE3sJ7liRsxfNiL18Hmjy4sg9KoMnliTKYjMtm8wezETHl6EDgcA4giQHAn50HMV7FozvWnWJBFLcrcJWlucpZ6+bm8kPei35tDon8X6YJ38/qLqYjnHW6OG32BJoatDkPc+CPYlqEKtzBWIKDQTugyDJgcCNQLbtpgveNVyS4NdxzwpbUqovIcgXo/MbARdhNONzh7sOUON+QNUVQEfov/D0tDPCKu6IeoSW6bxKjY3pQOAn8fdtfm2MZYHvwHDHPL1nB0UOqPgcusa7j9JkmEeoa66hq2YounhqtOZfyb0HFbz8Ab4Dy4Zo1HQj+5UtkqWt4BsFijdqYB41xPlHWmr6TLMafXSeQKAXX7eTTNx/gcBEkCt4UW5XovssdiBFVNiv4dstHi36ckw2AQ6obZBVG31F+TVj3773DIyd++5GnOgKBC7Dbxy3Jud/gYAVj2kuEwoaXSMwBXdtSNeWy5F7T0HPmdvGsyAp7O7DD5kDZ2aQYJCx6Z/Xar5pnMaiHFWEIncNxmyOO++jo3j9tBfoY1IOBFYi3knmEMw5cAUe3sriRFfAjBhRfwOjn+8KmNE6ecyn6Ri1taPIhpPMTLhBG8pRfEhCv1FKYgO6IwKBwJfgDwJGjMzxMZj+MsI7DHDAaBqBbjzxPcMnlvn5wOPNbtuAs8m1E/7HP+ljVSqEXrdpGCPTKkab9qOUdCWiDQcCP4MgyacgfOFvR1g40IHgybdEeMEzMYca03EW9ibm+aqeu9FdSAelmiWPDVgfngwtJdXR7nob2biTrew/z2xFK1vktO4TCAT8iOPWgcD9MHNKmzeBo+H0XiDw/Yh23otw1m8A1/FjR1vH/CQ2MsnbWWN5/lp81Tn9DSrkBepLXpvlIW/b6aNPBQLL8Pd9/Sv8l0DgAnw2GTAmbRd+rL6w+Hs/xAQSuDtMgwYrlAfWIprm5AT1cXY6PXEt64T83PX7zyckOR7QfNE4D5S7aouEK4qUzefr4N5OjjcbAoEp6DtufR+njhkHJvz8XiBwOh7ElJRX1IInBwQsOuk4E47PGMV0cj7C8c/Bs+D/yKdl9G0O0hKdlhOmzNlyPLu9q8xIH5vGCGXqOgTsX+3SiHIVE+0wEPgN/D3cnWWXLE2ipjSBgAHUGXdnHA5PHd5KZxC7AIMdfeHjfPuiwlOGWGEza67+QHBdBToFhS59OWUt9SRvI9fkdqPcqZC4mSxmCly+hpJXV8I6FkOOOUlUc+EEZrXTaO+BwEPxZR/uMq6gOtLE6BbQQY379TmuQPPUnUkF3Ib9jffjXcOSJ7oVT5436KFydyccbzUahQMuTOPF2+e7bmeD4d7bPQKwy5I92thvdxW3lnlhp+KVsHFKQKWE+QXa0mN9oRJm13BQpB0W6oJjtygQCDjwgx/uou2/BdKBH8PVDaNvMvSeb2PCUE/H7wJcApxdBlz0UHiDyoJ1ZbgxQ0bnKcoHuqB4Vvtan0VMxjNgavDSGhdWoR/WiqnajGpiNnewzZEJxExDzpCRK0CRHMvHZOe0ZpvFpo47IjpKIDCOX/66NXH/+aSDPwcy3Lg1TJ3a0SSC5dW5WJovPsdZcmBVI7n3HnJ92ZDvfRy8oB6+s6F+GzpMhLawmuChmqRim1iwQywuy6w0XopM7si2USwTJgwZj+eoHzjnzCDEA1QF2//q9cMmgWWdbMyjCO80EBhE/ARUgR4KHFT5N3Gu0U/3dCU6sDkFVk5xasFFp2txdjfW6Mn6UXrHkbja1tbcT5AHYr8Og0Pn3aZbcgW30SRmYoirHXE8lueQG+vE4j7tEklcQmi5PqOMcWxUdcq6VClsI2d/ubTt2lLTYBVyF4Q/GghMwpe9k7wCxbs5JjkwpgkERoErp8R345W+3uXKGhdP3Z5+1tEnyaJx5gOiMed5GS1RedPhL/FvrSVEp7wbPsV3rNifnOvWExIE/iOKtpTMRaYsDUHu7dZUHpOPV6fCWHzBC18BtCeH9w8+1cpb5U+pd0lUc/aLbEiurbxQA6e3ZrtC+tG+FAjcDkGSXWCnKZyfJnB7nP+9Li8msNLao0qjoPyoqa5nSokqvRNEerIiXWgJW56od834g82Ae6CD6i5lyOuq6XvnmTvQiBuM+eyQWvwKFMpOCLEC+08f73o+E8G/YI4aZ5Q7+foXfkb9/0D0ofpvUOup0r+Y9TyWIZdpi0PfhUIuZA/gFXLauuFV0NPYtzTBtwOBEfzd0LnvxzWDwT6NuNNIiEHtzljRYxb3wspNsv6YZlManWXHgWc9kRJ7MyIlehVhBr0ItlRjwO7I65BT3XYhO/achUwnVsgd6/b0Mv22+1+T1WJwLbZ/Mf3mNDI7zB9yuzPkInnxW1D51nGhM/1KNnzKhm+e/AaZRg7M/0kuMJODvHtmJ8Mr7otY5JLnh2xJGkBXmh9utIHA4/BdO8lXnnieuWH8TQsXgXnA1pZAS9CmziWExu3kxF2CubiDz6HtkLDlm9/FT6wG7Ii5Bxjv2ZmsM9M5Qvet4IvKdW+ejM5+znLdXi0NgYbUFs1tSiMiEZX7yDVPhnwDgbYj2OBYm8oLine 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).",
+11 -2
View File
@@ -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
View File
@@ -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) {
+35 -90
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+4
View File
@@ -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
View File
@@ -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) {
+145 -52
View File
@@ -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
View File
@@ -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 || [];
+7
View File
@@ -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}`,
});
+11 -2
View File
@@ -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
View File
@@ -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,
+25
View File
@@ -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) {
+57
View File
@@ -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.",
+6 -5
View File
@@ -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) {
+2 -1
View File
@@ -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.");
}
+187 -7
View File
@@ -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
-12
View File
@@ -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 };
+40 -10
View File
@@ -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
View File
@@ -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 });
}
+41 -3
View File
@@ -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,
+41 -3
View File
@@ -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,
+59
View File
@@ -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,
};
-155
View File
@@ -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
View File
@@ -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(
+6 -4
View File
@@ -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);
}
+58 -8
View File
@@ -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 = {
+7
View File
@@ -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) {
+13 -4
View File
@@ -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) =>
+3 -2
View File
@@ -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";
+14 -1
View File
@@ -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);
}
}
+61 -22
View File
@@ -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();
+118
View File
@@ -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);
}
}
}
+4 -4
View File
@@ -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}`);
+13 -1
View File
@@ -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
View File
@@ -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;
+123
View File
@@ -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);
}
+6
View File
@@ -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) {
+127
View File
@@ -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();
+58 -8
View File
@@ -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);
+2 -48
View File
@@ -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
+27 -11
View File
@@ -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);
});
+10 -14
View File
@@ -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);
+47 -17
View File
@@ -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
+12 -6
View File
@@ -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();
+65
View File
@@ -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;
}
}
});
+34
View File
@@ -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", () => {
+45 -24
View File
@@ -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);
});
+20 -12
View File
@@ -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",
+49 -41
View File
@@ -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;
}
});
+132
View File
@@ -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');
});
});
+26 -1
View File
@@ -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 () => {
+54 -86
View File
@@ -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();
}
},
);
+20
View File
@@ -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));
}
}
+33
View File
@@ -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 = () => {};
}
+6 -6
View File
@@ -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'
+28 -1
View File
@@ -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",
+6 -6
View File
@@ -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(),
};
+4 -4
View File
@@ -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",
);
});
+5 -3
View File
@@ -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);
+1 -1
View File
@@ -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
}
+5
View File
@@ -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");
+61
View File
@@ -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);
});