"Share on Nostr" published a kind-1 note whose body ended with a bare thumbnail
URL. Many clients only inline such a URL when a NIP-92 `imeta` entry describes
it, so the viewer often saw a plain link instead of a preview image.
- buildShareEvent now emits an `imeta` entry for the thumbnail. The media type
is inferred from the extension and OMITTED when unknown -- a wrong `m` is
worse than a missing one, since clients use it to decide whether to render
inline at all, so extensionless Blossom URLs ship url + alt only.
- inferImageMimeTypeFromUrl lives in a SEPARATE map from EXTENSION_MIME_MAP:
that map gates video upload/publish paths, and folding image types into it
would let an image pass a video check.
- shareNostrController was passing only { id, pubkey } to buildShareEvent, so
the tag could never be built; it now forwards title and thumbnail.
When a video has a NIP-71 mirror, the note instead quotes it with
`nostr:naddr1...`, which nostr clients render as a native video quote card --
the closest thing to a rich preview that does not depend on any client fetching
OpenGraph tags (most never do).
- The mirror is addressable (kind 34235/34236, author = video pubkey,
d = videoRootId), so js/nostr/mirrorPointer.js derives the coordinate with no
relay lookup, and the pointer survives the author editing the video where a
plain `nevent` would rot on the next replace.
- The pointer is only embedded after nip71MirrorService.findMirror() confirms
the mirror exists. A pointer to a missing event renders as a broken/empty
quote box, which is worse than the plain-link fallback, so a disabled
service, a throwing lookup, a 2.5s timeout or a missing videoRootId all fall
back to the thumbnail note.
- resolveMirrorKind prefers the kinds actually observed on relays and only then
falls back to the same portrait rule the mirror builder uses, so a derived
pointer matches what would be published rather than guessing independently.
- While quoting, the raw thumbnail URL is dropped from the body and the imeta
tag is skipped: the quote card already shows the thumbnail, and emitting both
rendered the same image twice. An `a` tag links the note to the coordinate.
Known limitation: a client rendering the quote card still has to play the
video, so mirrors whose imeta url is an .m3u8 hit the same HLS wall -- title and
thumbnail render, playback depends on the client.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Posting an external URL used to skip the torrent/webseed benefit entirely. Now the
upload modal's external tab auto-derives a WebTorrent webseed from the URL at
publish time: fetch the remote file (CORS-permitting), stream it under a hard size
cap, compute the infoHash, and publish a magnet with the URL as the webseed (ws=).
Best-effort — CORS failure / over the cap / any error silently degrades to
URL-only. Only runs when the user didn't paste their own magnet.
- js/utils/externalUrlTorrent.js: fetchBlobWithCap (streams with a 500 MB cap,
aborts past it, short-circuits on content-length), buildWebseedMagnet, and
deriveExternalUrlTorrent (reuses createTorrentMetadata). Pure + injectable → unit
tested without network/WebTorrent.
- mediaUploader.deriveTorrentForExternalUrl: derives the ws= magnet and, when
storage is unlocked, also hosts the tiny .torrent so the webseed can actually
bootstrap P2P (xs=) — best-effort, falls back to ws=-only.
- UploadModal.handleExternalFlow calls it; deriveExternalWebseed only hosts the
.torrent when storage is unlocked, shows progress on the submit button, and
never throws.
Tests: tests/external-url-torrent.test.mjs (9 — magnet assembly, name parsing,
streamed size-cap enforcement + short-circuit, fetch-failed, derive-with-injected-
hash, failure propagation). Lint + build green.
Next: the same for editing a URL in the Edit modal.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The upload modal's Hashtags section now shows chips of the user's most-used past
hashtags ("Reuse a tag from your videos:"). Clicking one adds it to the NIP-71 't'
repeater; already-added chips dim + disable; the row is hidden for users with no
past tags.
- js/utils/hashtagSuggestions.js: pure rankHashtagsByFrequency(videos) — count =
number of the user's videos using each tag, normalized (case + '#') and deduped
per video, ranked by count then alphabetically. Reads video.nip71.hashtags, falls
back to raw event 't' tags minus bitvid's fixed t=video marker.
- app.getUserHashtagSuggestions() sources the user's own videos via
nostrService.getActiveVideosByAuthors([pubkey]) and ranks them (top 12), injected
into the modal as getHashtagSuggestions.
- UploadModal renders the chips on open and wires clicks through the existing
Nip71FormManager (addRepeaterEntry + setFieldValue, dedup via collectRepeaterValues).
Tests: tests/hashtag-suggestions.test.mjs (5 — per-video counting, normalization,
marker exclusion, tie-break + limit, empty input). Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Sending a NIP-17 DM builds a kind-1059 gift wrap, which needs a fresh ephemeral
keypair. generateEphemeralKeypair() hex-encodes the generated secret with
bytesToHex — but bytesToHex was never imported in client.js, so the call threw
ReferenceError. The try/catch swallowed it into privateKey="", keygen returned
null, and every NIP-17 send failed with "We couldn't create secure wrapper keys
for NIP-17 delivery." (nip17-keygen-failed). The seal (kind 13) signed fine
because it uses the active signer; only the ephemeral keygen was broken. Build
and lint don't parse JS, so the missing import wasn't caught.
- Add bytesToHex to the canonical js/utils/hex.js and import it in client.js.
- Add tests/nip17-send-gift-wrap.test.mjs: forces the NIP-17 path (useNip17 +
relay hints, nip44-capable signer) and asserts a kind-1059 gift wrap is
published. Reverting the import reproduces the exact nip17-keygen-failed error;
the existing DM tests only covered the legacy kind-4/NIP-04 path (they delete
nip44Encrypt), so this path had no coverage.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A WebTorrent-only card's sole liveness signal is the swarm probe (20s timeout,
5min cache), and P2P liveness is slow + intermittent — a live-but-slow video is
indistinguishable from a dead one until that probe finishes. Hiding such a card
until the swarm verifies could bury perfectly good P2P content for minutes.
hide-until-verified now only suppresses a card while the FAST, reliable CDN probe
is still running. Once the CDN resolves dead (or is absent, i.e. P2P-only), it
falls back to show-pending for the WebTorrent part: shown while the swarm is
checked, hidden only once it too is confirmed dead. Burying live content is worse
than a brief flash of a dead one, and only the slow P2P probe is at issue.
cardNeedsEagerLivenessProbe likewise skips P2P-only cards (they start visible).
Tests: hide-foreign does-not-bury-WebTorrent-only coverage in
tests/video-card-source-visibility.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Setting CARD_LIVENESS_POLICY=hide-foreign made all foreign/NIP-71 content
disappear: a hide-until-verified card starts display:none, which has no layout
box, so the IntersectionObserver never fires for it — its liveness probe never
runs, it never gets a healthy verdict, and it stays hidden forever (deadlock).
Fix: cardNeedsEagerLivenessProbe() identifies cards governed by a
hide-until-verified policy (foreign under hide-foreign; all non-owner under
hide-all). Both health observers now kick the probe for those cards from
onCardRegister — which fires for every card regardless of visibility — instead of
waiting for an intersection that a hidden card can never produce. The URL probe is
deferred one microtask so attachUrlHealthBadges has wired state.onCheck after
observe(). Probes stay concurrency-capped + priority-ordered; show-pending is
unchanged (no eager probing).
Tests: cardNeedsEagerLivenessProbe predicate coverage in
tests/video-card-source-visibility.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Makes the card liveness-visibility behavior switchable so it can be A/B
feel-tested, and probes ahead of the scroll for a snappier result.
- CARD_LIVENESS_POLICY (config/instance-config.js): "show-pending" (default,
unchanged) | "hide-foreign" (foreign/ingested cards hidden until a source
verifies; native cards stay show-pending) | "hide-all". Owner's own cards are
always visible. Flip live for testing without a rebuild:
window.__BITVID_CARD_LIVENESS_POLICY__ = "hide-foreign" then refresh/scroll.
Implemented via a data-foreign provenance flag on the card +
getCardLivenessPolicy() in cardSourceVisibility.js.
- LIVENESS_PROBE_PREFETCH_MARGIN (default 600px): both liveness probes were
already viewport-gated (IntersectionObserver, on-screen-first, WebTorrent
priority-queued + concurrency-capped); this expands their rootMargin so cards
just below the fold verify before the user reaches them.
- Tests: three policy scenarios in tests/video-card-source-visibility.test.mjs;
also un-broke 3 pre-existing VideoCard tests by adding the missing
IntersectionObserver JSDOM stub.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The embed submission forms accepted any free-text identifier and published it,
so they were an easy spam vector. Add a shared, testable NIP-19 validator and
require a well-formed npub before a submission is sent.
- js/utils/validateNpub.js: validateNpubHex() decodes via nostr-tools nip19 and
returns the 64-char hex pubkey, or null for anything that isn't a real npub
(free text, wrong prefix, bad checksum, wrong NIP-19 entity). Also exposed as
window.validateNpubHex for the classic iframe form scripts.
- Application form (required npub): submit is blocked unless valid; the decoded
hex is written into the application body for canonical storage/matching.
- Feature-request / bug-fix / feedback (optional npub): stays optional, but a
non-empty value must be a valid npub or submit is blocked with an inline error.
- Tests: tests/validate-npub.test.mjs (real nostr-tools, no implementation mirror).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Phase 1 of the on-boarding plan, and the shared layer for unified grid
loading. A video that exists as both a bitvid kind-30078 note and a NIP-71
mirror/original previously could appear twice (e.g. independent dual-posts).
js/utils/videoDeduper.js: collapseCrossEcosystem() groups events by a
per-author identity (fileSha256 / originalFileSha256 / infoHash, plus an
`eid` namespace that links an on-boarded note's `importedFrom` to the
original foreign event) via union-find, and keeps the bitvid-native version
(else newest). dedupeVideos() = root-dedup THEN cross-ecosystem collapse.
Routed through the shared chokepoint app.dedupeVideosByRoot (playback
coordinator), so the feed stage, channel profile, and search all collapse
duplicates identically — "show one, prefer bitvid" everywhere. Channel
fallback path updated too.
Limitation (documented): hash-less independent dual-posts can't be matched
until the bitvid side carries on-board provenance (Phase 2).
Tests + mutation-verified (prefer-bitvid, per-author identity, infohash,
provenance link, root+cross composition). 1262/1263 unit (known flake).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Auto-captures a video's width/height/duration at file-select and persists them on
the 30078 — the durable foundation for a future "shorts" vertical feed (filter on
height > width) and for the NIP-71 mirror's short-kind selection.
- js/utils/videoProbe.js: probeVideoMetadata(src) loads metadata into a hidden
<video> (element injectable for tests); resolves {width,height,duration} or null.
- UploadModal: probes the selected file (objectURL) in the background and threads
width/height/duration into the publish payload.
- videoPayloadBuilder: persists width/height/duration in content (publish) and
preserves them across edits; normalizers exported.
- convertEventToVideo: exposes video.width/height/duration → the mirror builder
already uses them (portrait ⇒ 34236 short + imeta dim).
tests: probe resolution (incl portrait + error/timeout → null); content persist +
parse; portrait⇒34236 / landscape⇒34235 mirror selection; edit preserves dims.
Mutation-verified earlier on the mirror short-selection.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two gaps where generic S3 trailed Cloudflare R2:
1. Object cleanup on delete/edit didn't work for generic S3. deleteVideoStorage
built its client via makeR2Client, which hard-forces path-style and ignores
the connection's forcePathStyle -- so vhost-style S3 deletes were misaddressed
and cleanup silently failed (S3 users' deleted/edited files orphaned). It is
now provider-aware (mirrors uploadFile): R2 -> makeR2Client (path-style),
generic S3 -> makeS3Client honoring forcePathStyle. makeS3Client is now
injectable for testing.
2. CORS guidance on upload failure was R2-only. Extracted isLikelyCorsError +
a generic-S3 guidance builder into js/utils/uploadErrorHints.js (re-exported
from r2Service for back-compat); s3UploadService now appends actionable CORS
guidance on an opaque "Failed to fetch" too.
Generic S3 is now at functional parity with R2 for upload -> publish -> cleanup.
Tests: r2-delete-video-storage (adds generic-S3 forcePathStyle routing),
s3-upload-cors-guidance (new). Full suite green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Deleting a video removed the Nostr note but left the actual file (plus its
thumbnail and .torrent) publicly downloadable at its R2/S3 URL forever — a
privacy gap and ongoing storage cost. Now the delete flow also removes the
backing objects, best-effort.
- collectVideoStorageKeys() (storagePointer.js): pure helper that derives the
object keys (video + sibling .torrent + thumbnail) from a note, ONLY for URLs
under the owner's bucket base — external/3rd-party URLs are never returned.
- R2Service.deleteVideoStorage(): resolves the owner's connection, and deletes
those keys. Skips cleanly when storage is locked / no creds / no owned
objects, and never throws (failures are recorded, not propagated) so storage
cleanup can't block note deletion. R2Service class now exported for testing.
- app.handleDeleteModalConfirm: calls it after handleFullDeleteVideo succeeds.
Tests: tests/storage-key-collection.test.mjs (4),
tests/services/r2-delete-video-storage.test.mjs (4). Full suite green.
Note: superseded versions orphaned by past edits aren't retroactively cleaned;
edit-time cleanup (next) prevents new orphans going forward.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A raw window.nostr probe in the user's browser (getPublicKey → nip04.encrypt
→ nip04.decrypt, bypassing ALL of bitvid) HUNG forever, with the extension's
own console showing `ObjectMultiplex - orphaned data for "background-liveness"`
+ `malformed chunk` + listener-leak warnings. That is the NIP-07 extension's
content-script ↔ background-service-worker channel being dead (classic MV3
worker death) — an extension/environment failure, not a bitvid bug. No client
change can force an unresponsive signer to answer; the fix is to reload/unlock
the extension.
What bitvid CAN do is stop failing silently. Add js/utils/signerHealthNotice.js:
after a few consecutive list-decrypt timeouts while the signer reports itself
present, emit ONE rate-limited, actionable user notice ("your signer isn't
responding to decryption — unlock/reload the extension and refresh") instead of
the silent 15-minute retry loop. Reset on any decrypt success so a transient
blip doesn't warn. Wired into blocks, hashtags, and subscriptions.
Also documents the definitive root-cause + all resilience increments in
KNOWN_BUGS #0. Test: tests/signer-health-notice.test.mjs (threshold, rate-limit,
success-reset). Full unit suite green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Replaces unsafe innerHTML usage with document.createElement and appendChild in the table drawing fallback.
- Adds a unit test `tests/qrcode_table_fallback.test.mjs` to verify the table generation logic using JSDOM.
- Addresses a potential security vulnerability where HTML injection could occur (though unlikely in this specific context, it is best practice).
- Updates package.json to include jsdom in devDependencies (if not already present).
Co-authored-by: PR0M3TH3AN <[email protected]>
- Refactored `js/channelProfile.js` to use `pMapSettled` from `js/utils/asyncUtils.js`, fixing both the concurrency issue (P1) and the file size lint failure.
- `pMapSettled` replaces a bulky inline `pMap` wrapper, reducing code volume.
- Ensures `js/channelProfile.js` stays within its grandfathered line count limit (5579 lines).
Co-authored-by: PR0M3TH3AN <[email protected]>
Extracted complex logic from `AuthService.login` into private helper methods (`_validateAndNormalizeLoginInputs`, `_checkAccessControl`, `_updateGlobalState`, `_updateSavedProfiles`, `_initiatePostLogin`) to improve readability and maintainability.
Moved `SimpleEventEmitter` to `js/utils/simpleEventEmitter.js` and auth constants/normalization helpers to `js/services/authUtils.js` to reduce file size and coupling.
Co-authored-by: PR0M3TH3AN <[email protected]>
Replaces the dependency on `offsetWidth` and `offsetHeight` with a deterministic calculation based on module count and cell size. This prevents incorrect (huge) margins when the QR code is generated inside a hidden container where offset dimensions are 0.
Fixes: Margin values calculation in QRCode drawing logic.
Co-authored-by: PR0M3TH3AN <[email protected]>
Extracted `fetchListIncrementally` from `js/nostr/client.js` into a new `RelayBatchFetcher` class in `js/nostr/relayBatchFetcher.js` to reduce complexity in `NostrClient`.
Moved `withRequestTimeout` to `js/utils/asyncUtils.js` to make it reusable and remove duplication.
Updated `js/nostr/client.js` to use these new components while preserving existing behavior and specific logging for COUNT diagnostics.
- Created `js/nostr/relayBatchFetcher.js`
- Updated `js/utils/asyncUtils.js`
- Refactored `js/nostr/client.js`
- Verified with existing tests
Co-authored-by: PR0M3TH3AN <[email protected]>
- Extracted payload construction logic from `revertVideo` in `js/nostr/client.js` to `buildRevertVideoPayload` in `js/nostr/publishHelpers.js`.
- Moved `resolveStoragePointerValue` from `js/nostr/client.js` to `js/utils/storagePointer.js` to fix circular dependencies and improve code organization.
- Updated imports in `js/nostr/client.js` and `js/nostr/publishHelpers.js`.
- Verified with existing tests.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Add `tests/utils/profileMedia.test.mjs` with comprehensive test cases.
- Fix `sanitizeProfileMediaUrl` to correctly trim whitespace after removing quotes.
- Fix `sanitizeProfileMediaUrl` to correctly detect and normalize `ipfs/` paths (without protocol).
Co-authored-by: PR0M3TH3AN <[email protected]>
Replaces the sequential loop in `forceRefreshAllProfiles` with `batchFetchProfiles` to reduce network requests.
Updates `batchFetchProfilesFromRelays` to support `forceRefresh`, ensuring fresh data is requested while maintaining optimistic UI updates.
Adds unit tests for `forceRefresh` behavior.
Co-authored-by: PR0M3TH3AN <[email protected]>
Moves the repeatedly defined HEX64_REGEX constant to a shared utility
module to improve maintainability and consistency. Updates all consumers
to import from the new location.
Signed-off-by: Jules <[email protected]>
Co-authored-by: PR0M3TH3AN <[email protected]>
- Moved heavy tag counting and IDF calculation to `js/workers/exploreData.worker.js`.
- Extracted `collectVideoTags` to `js/utils/videoTags.js` (preserved original behavior).
- Extracted pointer normalization logic to `js/utils/pointerNormalization.js`.
- Refactored `js/services/exploreDataService.js` to use the worker.
- Added benchmark test `tests/benchmark-explore-data.spec.mjs`.
Measured Improvement:
- `buildTagIdf` execution time on main thread reduced from ~130ms blocking to ~0ms blocking (141ms total async time).
- Unblocks UI during periodic data refreshes.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Synced `package-lock.json` with `package.json` to resolve `npm ci` failures.
- Updated `nostr-tools` dependencies to resolve conflicts.
- Fixed `SyntaxError` in `tests/nostr-login-permissions.test.mjs` caused by duplicate code.
- Updated `js/nostr/client.js`, `js/nostr/publishHelpers.js`, `js/nostr/adapters/nsecAdapter.js`, `js/nostr/nip46Client.js` to use `hexToBytes` helper when calling `nostr-tools` functions, as v2+ expects `Uint8Array` instead of hex strings.
- Added `hexToBytes` helper in `js/utils/hex.js`.
- Updated tests (`tests/nostr/dm-direct-message-flow.test.mjs`, `tests/nostr-send-direct-message.test.mjs`, `tests/nostr-private-key-signer.test.mjs`, `tests/nostr/nip46Client.test.js`) to use `Uint8Array` for keys where required.
Co-authored-by: PR0M3TH3AN <[email protected]>
Removed unused exports and functions identified as dead code.
- Removed unused exports from `js/utils/storage.js`, `js/utils/storagePointer.js`, `js/utils/videoTags.js`, `js/ui/moderationCopy.js`.
- Removed unused function `updateTagPillStrip` from `js/ui/components/tagPillList.js` and associated tests.
- Updated `tests/utils/video-tags.test.mjs` to remove test for private function.
- Added `artifacts/depcheck.json` showing dependency analysis.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Added a null check for `this.client` in `js/webtorrent.js` after `await this.init()` to prevent `TypeError` when cleanup occurs during initialization.
- Updated `js/utils/profileMedia.js` to silently ignore the legacy `images/robot.png` path, preventing "Unsupported media URL" warnings.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Add storage helpers for torrent probes and tracker health in `js/utils/storage.js`
- Update `js/gridHealth.js` to read/write torrent probes from localStorage
- Update `js/healthService.js` to read/write tracker health from localStorage
- Ensure TTLs (5m for probes, 2m for trackers) are respected across sessions
- Add verification test `tests/verify-storage-persistence.mjs` (removed before completion)
- Added `js/utils/lruCache.js` with hit/miss tracking.
- Updated `js/nostr/cachePolicies.js` to set 5-minute TTL for comments/reactions.
- Integrated caching and incremental fetching in `js/nostr/commentEvents.js`.
- Implemented `listVideoReactions` in `js/nostr/reactionEvents.js` with caching logic.
- Update `UploadModal.js` to pre-calculate R2 keys and public URLs before upload.
- Update `torrentHash.js` to embed the calculated video URL as a webseed (`url-list`) in the `.torrent` file.
- Update `r2Service.js` to accept forced keys/URLs and append `&xs=` (pointing to the .torrent file) to the magnet link.
- Ensures magnet links work for peerless downloads via WebSeed (BEP19) and HTTP Seeding (BEP17).
- Removed requirement for Cloudflare API Token, Zone ID, and Worker Token from Upload Modal and R2Service.
- Simplified R2 configuration to require only Account ID, S3 Access Keys, and a user-provided Public Bucket URL.
- Added a read-only field in Upload Modal displaying the derived target bucket name (npub-based) for user reference.
- Implemented client-side torrent info hash calculation using `WebTorrent` (via `js/utils/torrentHash.js`) to generate magnet links before upload.
- Added a visual upload progress bar to the Upload Modal.
- Updated `js/r2.js` to ensure the Public Bucket URL always includes the `https://` protocol.
Prioritize `published_at` metadata over event `created_at` timestamp.
Backfill `published_at` during edit if missing to preserve original date.
Add "(edited)" label to video cards for modified content.
Display "Last Edited: [Date]" on video player modal.