mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f20907f35 | ||
|
|
fbac54dd48 | ||
|
|
58922e55c3 | ||
|
|
5e69fa4a7d | ||
|
|
8d40d85079 | ||
|
|
0592c0eed1 | ||
|
|
847a855ec8 | ||
|
|
5cce3b726a | ||
|
|
975b996f4c | ||
|
|
2eb7d09865 | ||
|
|
e99ba0e817 | ||
|
|
ab120a03e1 | ||
|
|
5d0709f5f4 | ||
|
|
350feae907 | ||
|
|
31be84ba82 | ||
|
|
f48150ed6b | ||
|
|
244f527cf6 | ||
|
|
99f8e88969 | ||
|
|
c0d006a5fc | ||
|
|
04075ff56b | ||
|
|
e5784893a8 | ||
|
|
d141c316a9 | ||
|
|
b84558e928 | ||
|
|
38b51f13a5 | ||
|
|
4404728410 | ||
|
|
726c0d26ed | ||
|
|
bb6614e3d3 | ||
|
|
687f8c438d | ||
|
|
2c4aefe872 | ||
|
|
b774de6d76 | ||
|
|
5f439d6863 | ||
|
|
fe976b3ab7 | ||
|
|
4525c1c657 | ||
|
|
3735d57410 | ||
|
|
4dd150b010 | ||
|
|
c200c84fc5 | ||
|
|
d19040c625 | ||
|
|
5764482f2a | ||
|
|
4b975c17dc | ||
|
|
87b3012e34 | ||
|
|
e8e2b04de7 | ||
|
|
2f3028f2e4 | ||
|
|
f0a3ede3e5 | ||
|
|
d8e5113efd | ||
|
|
04c8511a50 | ||
|
|
ce14004725 | ||
|
|
4458b269be | ||
|
|
f373bb9ce7 | ||
|
|
2cda5025d4 | ||
|
|
7739a934f9 | ||
|
|
98c623c85a | ||
|
|
0513c7fcd8 | ||
|
|
855022e911 | ||
|
|
589bda8cb0 | ||
|
|
18e08bf2cb | ||
|
|
f25c2ac242 | ||
|
|
4d1116530b | ||
|
|
f2d390ff18 | ||
|
|
ca7b711b8a | ||
|
|
88c906fd34 |
No files matched your search
@@ -0,0 +1,95 @@
|
||||
# Regenerate the visual-regression baselines IN the environment that compares
|
||||
# them.
|
||||
#
|
||||
# The baselines in tests/visual/baselines.json are pixel-compared on
|
||||
# ubuntu-latest CI, but they were historically captured on a developer machine.
|
||||
# Chromium builds and font fallbacks differ between the two ("Inter"/"Fira
|
||||
# Code" are not shipped or installed anywhere, so every environment renders its
|
||||
# own system fallback), so the kitchen-sink diff drifted past MAX_DIFF_RATIO on
|
||||
# CI while passing locally — ci.yml even documents the mismatch as the reason
|
||||
# visual-regression must not block deploys. Capturing the baselines on the same
|
||||
# runner image, browser install and font stack as the comparison removes the
|
||||
# environment variable from the equation entirely.
|
||||
#
|
||||
# Two explicit triggers, both requiring deliberate human intent — regenerating
|
||||
# baselines must never be a side effect of ordinary work:
|
||||
#
|
||||
# 1. workflow_dispatch — only usable once this file reaches the default
|
||||
# branch (GitHub does not index dispatchable workflows on other branches).
|
||||
# 2. A push whose head commit message contains [update-visual-baselines] —
|
||||
# works from unstable today, before any promotion. The workflow's own
|
||||
# commit omits the token, so it cannot retrigger itself.
|
||||
name: Update visual baselines
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [unstable]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-baselines:
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.head_commit.message, '[update-visual-baselines]')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Mirror the visual-regression job's caches so the regenerated baselines
|
||||
# come from the same dependency tree the comparison job will use.
|
||||
- name: Cache npm directory
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-22-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-22-npm-
|
||||
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-22-modules-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-22-modules-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Regenerate baselines
|
||||
run: npm run test:visual:update
|
||||
|
||||
- name: Verify the fresh baselines pass in this environment
|
||||
run: npx playwright test --project=visual
|
||||
|
||||
- name: Commit updated baselines
|
||||
run: |
|
||||
if git diff --quiet -- tests/visual/baselines.json; then
|
||||
echo "Baselines unchanged; nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add tests/visual/baselines.json
|
||||
git commit \
|
||||
-m "test(visual): regenerate baselines on ubuntu-latest" \
|
||||
-m "Captured by the update-visual-baselines workflow so the baseline environment matches the visual-regression job that compares against them."
|
||||
git push
|
||||
@@ -22,3 +22,4 @@ TEST_LOG.mdtest_logs/nip-hits.txt
|
||||
|
||||
# Local site archive for manual nsite upload (npm run archive)
|
||||
bitvid-site.zip
|
||||
.env*
|
||||
+26
-14
@@ -3,11 +3,10 @@
|
||||
## Test Infrastructure
|
||||
|
||||
### Missing `jsdom` Dependency
|
||||
- **Status:** Active
|
||||
- **Status:** Resolved (2026-07-30)
|
||||
- **Detected:** 2026-02-26
|
||||
- **Description:** Multiple unit tests fail with `ERR_MODULE_NOT_FOUND` because `jsdom` is imported but not installed in the project.
|
||||
- **Impact:** Prevents running unit tests that rely on DOM simulation.
|
||||
- **Remediation:** `npm install --save-dev jsdom`
|
||||
- **Description:** Unit tests failed with `ERR_MODULE_NOT_FOUND` because `jsdom` was imported but not installed.
|
||||
- **Resolution:** `jsdom` has been a devDependency for some time (28.1.0 installed and in `package.json`); the jsdom-based suites run clean.
|
||||
|
||||
## Run Notes (2026-03-04)
|
||||
|
||||
@@ -22,19 +21,32 @@
|
||||
- **Description:** `tests/nostr-send-direct-message.test.mjs` must pass `Uint8Array` secret keys to `getPublicKey/finalizeEvent` for current `nostr-tools`.
|
||||
- **Impact:** Passing hex strings causes failures like `expected Uint8Array, got type=string`.
|
||||
|
||||
## Run Notes (2026-06-20)
|
||||
## Run Notes (2026-06-20, updated 2026-07-30)
|
||||
|
||||
### Additional E2E specs flake under full parallel load (pass in isolation)
|
||||
### `login-flows.spec.ts:149` — upload/profile buttons stay visible after logout
|
||||
- **Status:** Active — NOT merely a parallel-load flake
|
||||
- **Description:** Previously filed as a worker-contention flake that "passes deterministically in isolation." On 2026-07-28 it failed **in isolation** as well (chromium, local and CI `e2e-headless`), while passing in the CI `e2e-tests (e2e)` job — environment-sensitive, not load-sensitive. Direct probes of the same flow (raw `__bitvidTest__.loginWithNsec` → `logout()` against a mock relay) hide the button correctly, so the failure is specific to something in the Playwright fixture path. An attempted fix (running `applyLoggedOutUiState()` before the awaited NWC/profile teardown in `_executeAuthLogout`) did not change the outcome and was reverted rather than shipped unverified.
|
||||
- **Impact:** One e2e failure in `e2e-headless`; logout hides gated UI correctly in manual testing and direct harness probes.
|
||||
- **Next step:** Investigate alongside pre-launch TODO #33 ("logout logs out everyone" / NIP-46 session persistence) — same subsystem.
|
||||
|
||||
### `video-upload-publish-lifecycle.spec.ts:49` / `:79` flake under full parallel load
|
||||
- **Status:** Active
|
||||
- **Description:** Under the full `test:e2e` run (378 tests, 4 workers) these intermittently fail on `not.toBeVisible`/feed-appearance timeouts, but pass deterministically when re-run in isolation (`--workers=1`): `login-flows.spec.ts:149` (upload/profile buttons hide after logout), `video-upload-publish-lifecycle.spec.ts:49` and `:79` (URL-first publish appears / owner delete). Same root cause as the Firefox feed-hydration flakes above — worker resource contention, not an app regression (verified: pass in isolation on the current branch).
|
||||
- **Impact:** Full-suite e2e is flaky; isolated reruns are green.
|
||||
- **Description:** Under the full `test:e2e` run these intermittently fail on feed-appearance timeouts but pass on retry (CI marks them flaky). Worker resource contention.
|
||||
- **Impact:** Full-suite e2e occasionally needs retries; isolated reruns are green.
|
||||
|
||||
### `webseed-playback.spec.ts:31` fails on Firefox headless (CDN mode)
|
||||
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
|
||||
- **Description:** "plays directly from HTML link (CDN mode)" fails on `--project=e2e-firefox` with `#modalVideo` `src` empty instead of the test's archive.org URL. The Chromium variant and the WebTorrent-fallback variant (`:63`) both pass. Firefox-headless-specific (external URL reachability/codec), not an app regression.
|
||||
- **Impact:** One Firefox-only e2e failure; CDN playback works on Chromium and in manual testing.
|
||||
- **Status:** Resolved (2026-07-28, `f48150ed`)
|
||||
- **Description:** The test asserted the **final** `src` equals the CDN URL while simultaneously tolerating "No playable source found" in its status assertion — contradictory, because a failed hosted attempt calls `resetVideoElement()`, which clears `src`. Headless CI cannot decode the archive.org H.264 sample, so the cleared-src path was routine there.
|
||||
- **Resolution:** The test now records every `src` the player attaches via a MutationObserver and asserts the CDN URL was attached — the actual behavior under test. Green in both `e2e` and `e2e-firefox` CI jobs.
|
||||
|
||||
### `uploadModal-reset.test.mjs` ("UploadModal Reset Logic") hangs/cancels
|
||||
- **Status:** Active (pre-existing — reproduced on pre-refactor commit `1b11cb1b`)
|
||||
- **Description:** The jsdom-based UploadModal reset suite intermittently fails with `cancelledByParent` / "Promise resolution is still pending but the event loop has already resolved" (~12s hang). It passes or fails depending on full-suite test ordering/timing (an async-hang flake, likely a torrent-metadata/upload promise not settling under the mocked services). Not a regression from the MediaUploader extraction — it fails identically on `1b11cb1b`.
|
||||
- **Impact:** One unit suite can report `not ok 1`; the runner still exits 0. Other tests unaffected.
|
||||
- **Status:** Not reproducible (2026-07-30)
|
||||
- **Description:** Intermittent `cancelledByParent` / "Promise resolution is still pending" hang, order/timing dependent.
|
||||
- **Current state:** Passes cleanly (`pass=3 fail=0 cancelled=0`) in isolation and across repeated full-suite sweeps on 2026-07-28–30. Left on file because the original failure was ordering-dependent; if it recurs, capture the full-suite ordering that produced it.
|
||||
|
||||
## Run Notes (2026-07-30)
|
||||
|
||||
### Visual baselines are CI-canonical — local `test:visual` may fail the kitchen-sink diff
|
||||
- **Status:** By design (since `ab120a03`)
|
||||
- **Description:** `tests/visual/baselines.json` is regenerated on ubuntu-latest by the `update-visual-baselines` workflow (trigger: `workflow_dispatch` once promoted, or a commit containing `[update-visual-baselines]`). Baselines can only match one environment, and the CI gate is the one that matters — so a dev machine with different Chromium/font rendering may now fail `npm run test:visual` locally (typically the kitchen-sink default theme) even though CI is green.
|
||||
- **Workaround:** Run visual tests in the pinned container via `scripts/run-playwright-docker.sh`, or rely on CI. Never regenerate baselines from a dev machine.
|
||||
@@ -0,0 +1,363 @@
|
||||
// Per-video OpenGraph / Twitter-player rendering for crawlers.
|
||||
//
|
||||
// bitvid is a static SPA: index.html carries one set of site-wide OG tags, so
|
||||
// every `/?v=<nevent>` link previewed as the same generic bitvid card. Crawlers
|
||||
// don't run JS, so the client can't fix that — this module renders the tags a
|
||||
// crawler needs, and a thin host adapter (api/og.js on Vercel) feeds it.
|
||||
//
|
||||
// PURE by design: no network, no host globals. All I/O lives in the adapter so
|
||||
// the escaping and gating rules below stay unit-testable.
|
||||
//
|
||||
// Rollback: remove the crawler rewrite from vercel.json. The static site keeps
|
||||
// serving its existing tags and nothing else changes — this is never on the
|
||||
// path of a real user's page load, and `npm run archive` (nsite) is unaffected.
|
||||
|
||||
// Player card dimensions. Twitter refuses a player card without explicit
|
||||
// dimensions, and 16:9 matches bitvid's embed shell.
|
||||
export const DEFAULT_PLAYER_WIDTH = 1280;
|
||||
export const DEFAULT_PLAYER_HEIGHT = 720;
|
||||
|
||||
const MAX_TITLE_LENGTH = 120;
|
||||
const MAX_DESCRIPTION_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* Escape a value for use inside a double-quoted HTML attribute.
|
||||
*
|
||||
* Titles and descriptions come from arbitrary nostr events — treat every one as
|
||||
* hostile. A title of `"><script>…` would otherwise break out of the meta tag
|
||||
* and inject markup into a page we serve from our own origin.
|
||||
*/
|
||||
export function escapeHtmlAttribute(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Only http(s) URLs may reach a meta tag. Anything else (javascript:, data:,
|
||||
* relative paths) is dropped — a crawler resolving them is at best useless and
|
||||
* at worst a vector.
|
||||
*/
|
||||
export function sanitizeAbsoluteUrl(value) {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch (err) {
|
||||
return "";
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return "";
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
/** Collapse whitespace and hard-truncate for preview surfaces. */
|
||||
export function normalizeText(value, maxLength) {
|
||||
const collapsed =
|
||||
typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
||||
if (!collapsed || !Number.isFinite(maxLength) || maxLength <= 0) {
|
||||
return collapsed;
|
||||
}
|
||||
if (collapsed.length <= maxLength) {
|
||||
return collapsed;
|
||||
}
|
||||
return `${collapsed.slice(0, maxLength - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a video may be described in a link preview.
|
||||
*
|
||||
* A crawler never authenticates, so anything emitted here is fully public and
|
||||
* outlives us in third-party caches. The rule is fail-closed: only a video that
|
||||
* is affirmatively public gets a rich card; everything else — including the
|
||||
* "we couldn't determine it" case — falls back to the generic bitvid card.
|
||||
*
|
||||
* @returns {{allowed: boolean, reason: string}}
|
||||
*/
|
||||
export function isPubliclyShareable(video, options = {}) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return { allowed: false, reason: "missing-video" };
|
||||
}
|
||||
if (video.invalid) {
|
||||
return { allowed: false, reason: "invalid-video" };
|
||||
}
|
||||
if (video.deleted === true) {
|
||||
return { allowed: false, reason: "deleted" };
|
||||
}
|
||||
if (video.isPrivate === true) {
|
||||
return { allowed: false, reason: "private" };
|
||||
}
|
||||
// NSFW thumbnails must never be injected into an unsuspecting timeline.
|
||||
if (video.isNsfw === true) {
|
||||
return { allowed: false, reason: "nsfw" };
|
||||
}
|
||||
|
||||
const pubkey =
|
||||
typeof video.pubkey === "string" ? video.pubkey.trim().toLowerCase() : "";
|
||||
if (!pubkey) {
|
||||
return { allowed: false, reason: "missing-author" };
|
||||
}
|
||||
|
||||
const blacklist = options.blacklist instanceof Set ? options.blacklist : null;
|
||||
if (blacklist && blacklist.has(pubkey)) {
|
||||
return { allowed: false, reason: "blacklisted" };
|
||||
}
|
||||
|
||||
if (options.whitelistEnabled) {
|
||||
const whitelist =
|
||||
options.whitelist instanceof Set ? options.whitelist : null;
|
||||
// No usable whitelist while whitelist mode is on means we cannot prove the
|
||||
// author is allowed. Fail closed rather than leak a gated video's metadata.
|
||||
if (!whitelist || whitelist.size === 0) {
|
||||
return { allowed: false, reason: "whitelist-unavailable" };
|
||||
}
|
||||
if (!whitelist.has(pubkey)) {
|
||||
return { allowed: false, reason: "not-whitelisted" };
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, reason: "ok" };
|
||||
}
|
||||
|
||||
// bitvid's own notes are kind 30078 with a v3 JSON body. Other clients
|
||||
// (nostube/slidestr and anything NIP-71 native) publish kind 21/22/34235/34236
|
||||
// with the metadata in TAGS and the description in `content`. bitvid normalizes
|
||||
// both in the browser, so the preview must understand both too — parsing only
|
||||
// the JSON shape made every NIP-71 video fall back to the generic card.
|
||||
export const NIP71_VIDEO_KINDS = Object.freeze([21, 22, 34235, 34236]);
|
||||
|
||||
function firstTagValue(tags, name) {
|
||||
if (!Array.isArray(tags)) {
|
||||
return "";
|
||||
}
|
||||
for (const tag of tags) {
|
||||
if (Array.isArray(tag) && tag[0] === name && typeof tag[1] === "string") {
|
||||
const value = tag[1].trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function hasTag(tags, name) {
|
||||
return (
|
||||
Array.isArray(tags) &&
|
||||
tags.some((tag) => Array.isArray(tag) && tag[0] === name)
|
||||
);
|
||||
}
|
||||
|
||||
/** First `image <url>` entry inside any imeta tag. */
|
||||
function imetaImageUrl(tags) {
|
||||
if (!Array.isArray(tags)) {
|
||||
return "";
|
||||
}
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag[0] !== "imeta") {
|
||||
continue;
|
||||
}
|
||||
for (let i = 1; i < tag.length; i += 1) {
|
||||
const entry = typeof tag[i] === "string" ? tag[i].trim() : "";
|
||||
if (/^image\s+/i.test(entry)) {
|
||||
const url = sanitizeAbsoluteUrl(entry.replace(/^image\s+/i, ""));
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw nostr event into the fields the preview needs, for both the
|
||||
* bitvid v3 JSON shape and the NIP-71 tag shape. Returns null when the event is
|
||||
* not a recognizable video note (caller renders the generic card).
|
||||
*/
|
||||
export function parseVideoEvent(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base = { pubkey: event.pubkey, deleted: false, isPrivate: false };
|
||||
|
||||
// --- bitvid native (kind 30078, JSON content) ---
|
||||
if (typeof event.content === "string" && event.content.trim().startsWith("{")) {
|
||||
try {
|
||||
const content = JSON.parse(event.content);
|
||||
if (content && typeof content === "object") {
|
||||
return {
|
||||
...base,
|
||||
title: content.title,
|
||||
description: content.description,
|
||||
thumbnail: content.thumbnail,
|
||||
deleted: content.deleted === true,
|
||||
isPrivate: content.isPrivate === true,
|
||||
isNsfw: content.isNsfw === true || hasTag(event.tags, "content-warning"),
|
||||
invalid: !content.title || (!content.url && !content.magnet),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
// Not JSON after all — fall through to the NIP-71 reading.
|
||||
}
|
||||
}
|
||||
|
||||
// --- NIP-71 (metadata in tags, description in content) ---
|
||||
if (NIP71_VIDEO_KINDS.includes(event.kind)) {
|
||||
const title = firstTagValue(event.tags, "title");
|
||||
const thumbnail =
|
||||
imetaImageUrl(event.tags) ||
|
||||
sanitizeAbsoluteUrl(firstTagValue(event.tags, "image")) ||
|
||||
sanitizeAbsoluteUrl(firstTagValue(event.tags, "thumb"));
|
||||
const description =
|
||||
(typeof event.content === "string" && event.content.trim()) ||
|
||||
firstTagValue(event.tags, "summary") ||
|
||||
firstTagValue(event.tags, "alt");
|
||||
return {
|
||||
...base,
|
||||
title,
|
||||
description,
|
||||
thumbnail,
|
||||
// NIP-71 has no privacy flag; `content-warning` is the NSFW signal.
|
||||
isNsfw: hasTag(event.tags, "content-warning"),
|
||||
invalid: !title,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function metaTag(attribute, name, content) {
|
||||
return ` <meta ${attribute}="${escapeHtmlAttribute(name)}" content="${escapeHtmlAttribute(content)}" />`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the crawler-facing HTML document.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object|null} params.video Normalized video object, or null.
|
||||
* @param {string} params.shareUrl Canonical `/?v=<nevent>` URL.
|
||||
* @param {string} [params.embedUrl] `/embed.html?pointer=<nevent>` player URL.
|
||||
* @param {object} params.fallback Site-wide defaults (title/description/image/url).
|
||||
* @param {object} [params.gate] Result of isPubliclyShareable().
|
||||
* @returns {string} A complete HTML document.
|
||||
*/
|
||||
export function renderOgHtml({
|
||||
video = null,
|
||||
shareUrl = "",
|
||||
embedUrl = "",
|
||||
fallback = {},
|
||||
gate = null,
|
||||
} = {}) {
|
||||
const siteName = normalizeText(fallback.siteName || "bitvid", 60);
|
||||
const fallbackTitle = normalizeText(
|
||||
fallback.title || "bitvid - Decentralized Video Sharing",
|
||||
MAX_TITLE_LENGTH
|
||||
);
|
||||
const fallbackDescription = normalizeText(
|
||||
fallback.description || "Decentralized video sharing on Nostr.",
|
||||
MAX_DESCRIPTION_LENGTH
|
||||
);
|
||||
const fallbackImage = sanitizeAbsoluteUrl(fallback.image);
|
||||
const canonical = sanitizeAbsoluteUrl(shareUrl) || sanitizeAbsoluteUrl(fallback.url);
|
||||
|
||||
const decision = gate || isPubliclyShareable(video);
|
||||
const useVideo = decision.allowed === true;
|
||||
|
||||
const title = useVideo
|
||||
? normalizeText(video.title, MAX_TITLE_LENGTH) || fallbackTitle
|
||||
: fallbackTitle;
|
||||
const description = useVideo
|
||||
? normalizeText(video.description, MAX_DESCRIPTION_LENGTH) ||
|
||||
fallbackDescription
|
||||
: fallbackDescription;
|
||||
const image = useVideo
|
||||
? sanitizeAbsoluteUrl(video.thumbnail) || fallbackImage
|
||||
: fallbackImage;
|
||||
const player = useVideo ? sanitizeAbsoluteUrl(embedUrl) : "";
|
||||
|
||||
const lines = [
|
||||
"<!doctype html>",
|
||||
'<html lang="en">',
|
||||
" <head>",
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
` <title>${escapeHtmlAttribute(title)}</title>`,
|
||||
];
|
||||
|
||||
if (canonical) {
|
||||
lines.push(` <link rel="canonical" href="${escapeHtmlAttribute(canonical)}" />`);
|
||||
}
|
||||
|
||||
lines.push(metaTag("name", "description", description));
|
||||
lines.push(metaTag("property", "og:site_name", siteName));
|
||||
lines.push(metaTag("property", "og:type", useVideo ? "video.other" : "website"));
|
||||
lines.push(metaTag("property", "og:title", title));
|
||||
lines.push(metaTag("property", "og:description", description));
|
||||
if (canonical) {
|
||||
lines.push(metaTag("property", "og:url", canonical));
|
||||
}
|
||||
if (image) {
|
||||
lines.push(metaTag("property", "og:image", image));
|
||||
lines.push(metaTag("property", "og:image:alt", title));
|
||||
}
|
||||
|
||||
if (player) {
|
||||
// A player card is what makes a shared bitvid link behave like a YouTube
|
||||
// link: the video plays inline in the timeline instead of showing a still.
|
||||
lines.push(metaTag("property", "og:video:url", player));
|
||||
lines.push(metaTag("property", "og:video:secure_url", player));
|
||||
lines.push(metaTag("property", "og:video:type", "text/html"));
|
||||
lines.push(metaTag("property", "og:video:width", String(DEFAULT_PLAYER_WIDTH)));
|
||||
lines.push(metaTag("property", "og:video:height", String(DEFAULT_PLAYER_HEIGHT)));
|
||||
lines.push(metaTag("name", "twitter:card", "player"));
|
||||
lines.push(metaTag("name", "twitter:player", player));
|
||||
lines.push(metaTag("name", "twitter:player:width", String(DEFAULT_PLAYER_WIDTH)));
|
||||
lines.push(metaTag("name", "twitter:player:height", String(DEFAULT_PLAYER_HEIGHT)));
|
||||
} else {
|
||||
lines.push(metaTag("name", "twitter:card", image ? "summary_large_image" : "summary"));
|
||||
}
|
||||
|
||||
lines.push(metaTag("name", "twitter:title", title));
|
||||
lines.push(metaTag("name", "twitter:description", description));
|
||||
if (image) {
|
||||
lines.push(metaTag("name", "twitter:image", image));
|
||||
}
|
||||
|
||||
lines.push(" </head>");
|
||||
lines.push(" <body>");
|
||||
// A human should never land here (only crawler user-agents are rewritten),
|
||||
// but if one does, give them a real link rather than a blank page.
|
||||
lines.push(
|
||||
canonical
|
||||
? ` <p><a href="${escapeHtmlAttribute(canonical)}">${escapeHtmlAttribute(title)}</a></p>`
|
||||
: ` <p>${escapeHtmlAttribute(title)}</p>`
|
||||
);
|
||||
lines.push(" </body>");
|
||||
lines.push("</html>");
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
// Conservative crawler match. Being wrong in the "not a bot" direction is free
|
||||
// (the visitor gets the normal SPA); being wrong the other way would serve a
|
||||
// human this stub page, so the pattern stays narrow and explicit.
|
||||
export const CRAWLER_USER_AGENT_PATTERN =
|
||||
/(facebookexternalhit|twitterbot|slackbot|discordbot|telegrambot|whatsapp|linkedinbot|pinterest|redditbot|embedly|quora link preview|nostrbot|primal|iframely|opengraph|mastodon|bluesky|skypeuripreview|vkshare|googlebot|bingbot|applebot|yandexbot|duckduckbot|baiduspider|petalbot|semrushbot|ahrefsbot|developers\.google\.com\/\+\/web\/snippet)/i;
|
||||
|
||||
export function isCrawlerUserAgent(userAgent) {
|
||||
return typeof userAgent === "string"
|
||||
? CRAWLER_USER_AGENT_PATTERN.test(userAgent)
|
||||
: false;
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// Vercel serverless handler: renders per-video OpenGraph / Twitter-player tags
|
||||
// for crawlers hitting `/?v=<nevent>`.
|
||||
//
|
||||
// Nothing is pre-generated and nothing is persisted. This is a stateless
|
||||
// function invoked per crawler request; `dist/` is byte-identical with or
|
||||
// without it. The only caching is the CDN edge cache via Cache-Control below.
|
||||
//
|
||||
// This must never become load-bearing for the app: real users are not routed
|
||||
// here (see the user-agent rewrite in vercel.json), and a static/nsite deploy
|
||||
// without any functions keeps working exactly as it does today, just with the
|
||||
// site-wide card.
|
||||
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { SimplePool, useWebSocketImplementation } from "nostr-tools/pool";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import {
|
||||
isCrawlerUserAgent,
|
||||
isPubliclyShareable,
|
||||
parseVideoEvent,
|
||||
renderOgHtml,
|
||||
} from "./_lib/ogRenderer.mjs";
|
||||
|
||||
// Imported, not duplicated: these modules are plain ESM with no DOM access, so
|
||||
// the function reads the SAME admin-list identity and relay set the browser
|
||||
// does. A hardcoded copy would silently drift the moment an operator retunes
|
||||
// the instance, and the failure mode (previews gated forever, or worse, a stale
|
||||
// whitelist) would be invisible.
|
||||
import {
|
||||
ADMIN_LIST_NAMESPACE,
|
||||
ADMIN_SUPER_NPUB,
|
||||
} from "../js/config.js";
|
||||
import { DEFAULT_RELAY_URLS } from "../js/nostr/toolkit.js";
|
||||
import { ADMIN_LIST_IDENTIFIERS } from "../js/nostrEventSchemas.js";
|
||||
|
||||
useWebSocketImplementation(WebSocket);
|
||||
|
||||
const RELAYS = Array.from(DEFAULT_RELAY_URLS);
|
||||
|
||||
// Admin moderation lists are addressable kind-30000 sets published by the
|
||||
// instance's super admin (js/adminListStore.js).
|
||||
const ADMIN_LIST_KIND = 30000;
|
||||
|
||||
// Measured: a COLD pool's first read across these relays can take ~3s (WSS
|
||||
// handshake + REQ round trip on four connections). 1.5s and 2.5s both cut it
|
||||
// off and served the generic card every time. Twitter allows ~5s and Facebook
|
||||
// ~10s, so 4s buys a correct card on a cold container while still leaving
|
||||
// headroom under Vercel's function limit. Warm containers answer in <600ms.
|
||||
const RELAY_TIMEOUT_MS = 4000;
|
||||
|
||||
// Admin lists are identical for every request, so re-reading them on each crawl
|
||||
// is pure waste (and it was starving the video read of its budget). Memoized in
|
||||
// module scope: warm invocations skip the relay round trip entirely. This is a
|
||||
// per-container, in-memory cache of PUBLIC data that dies with the container —
|
||||
// nothing is persisted anywhere.
|
||||
const ADMIN_LIST_TTL_MS = 5 * 60 * 1000;
|
||||
let adminListCache = { expiresAt: 0, whitelist: null, blacklist: null };
|
||||
|
||||
const SITE_ORIGIN = process.env.BITVID_SITE_ORIGIN || "https://bitvid.network";
|
||||
const FALLBACK = Object.freeze({
|
||||
siteName: "bitvid",
|
||||
title: "bitvid - Decentralized Video Sharing",
|
||||
description:
|
||||
"bitvid is a decentralized video sharing platform built on Nostr and WebTorrent.",
|
||||
image: `${SITE_ORIGIN}/assets/jpg/bitvid.jpg`,
|
||||
url: SITE_ORIGIN,
|
||||
});
|
||||
|
||||
function withTimeout(promise, ms, fallbackValue) {
|
||||
return Promise.race([
|
||||
promise.catch(() => fallbackValue),
|
||||
new Promise((resolve) => setTimeout(() => resolve(fallbackValue), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Decode `?v=` (nevent/naddr/note/raw hex) into a relay filter. */
|
||||
function pointerToFilter(pointer) {
|
||||
const trimmed = typeof pointer === "string" ? pointer.trim() : "";
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||
return { ids: [trimmed.toLowerCase()] };
|
||||
}
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type === "nevent" && decoded.data?.id) {
|
||||
return { ids: [decoded.data.id] };
|
||||
}
|
||||
if (decoded.type === "note" && typeof decoded.data === "string") {
|
||||
return { ids: [decoded.data] };
|
||||
}
|
||||
if (decoded.type === "naddr" && decoded.data?.identifier) {
|
||||
return {
|
||||
kinds: [decoded.data.kind],
|
||||
authors: [decoded.data.pubkey],
|
||||
"#d": [decoded.data.identifier],
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
// Unparseable pointer -> generic card.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractPubkeySet(event) {
|
||||
const out = new Set();
|
||||
if (!event || !Array.isArray(event.tags)) {
|
||||
return out;
|
||||
}
|
||||
for (const tag of event.tags) {
|
||||
if (!Array.isArray(tag) || tag[0] !== "p" || typeof tag[1] !== "string") {
|
||||
continue;
|
||||
}
|
||||
const value = tag[1].trim().toLowerCase();
|
||||
if (/^[0-9a-f]{64}$/.test(value)) {
|
||||
out.add(value);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const decoded = nip19.decode(value);
|
||||
if (decoded.type === "npub" && typeof decoded.data === "string") {
|
||||
out.add(decoded.data.toLowerCase());
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore malformed entries
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const host = req.headers?.host || new URL(SITE_ORIGIN).host;
|
||||
const proto = req.headers?.["x-forwarded-proto"] || "https";
|
||||
const requestUrl = new URL(req.url || "/", `${proto}://${host}`);
|
||||
const pointer = requestUrl.searchParams.get("v") || "";
|
||||
|
||||
// Derive the origin from the REQUEST, not from a constant. Otherwise every
|
||||
// non-production deployment (unstable, previews) advertises production's
|
||||
// canonical URL and — worse — a twitter:player pointing at production's
|
||||
// embed, so testing a preview link silently exercises the live site.
|
||||
const origin = `${proto}://${host}`;
|
||||
|
||||
const shareUrl = pointer
|
||||
? `${origin}/?v=${encodeURIComponent(pointer)}`
|
||||
: origin;
|
||||
const embedUrl = pointer
|
||||
? `${origin}/embed.html?pointer=${encodeURIComponent(pointer)}`
|
||||
: "";
|
||||
|
||||
const renderFallback = (reason) => {
|
||||
// Short TTL: a video may be published, edited or moderated at any time, and
|
||||
// we would rather re-ask the relays than pin a wrong card in someone's CDN.
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "public, s-maxage=300, stale-while-revalidate=600");
|
||||
res.setHeader("X-Bitvid-OG", reason);
|
||||
res.status(200).send(
|
||||
renderOgHtml({
|
||||
video: null,
|
||||
shareUrl,
|
||||
fallback: { ...FALLBACK, url: origin, image: `${origin}/assets/jpg/bitvid.jpg` },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Defensive: the rewrite should only send crawlers here, but never serve a
|
||||
// human this stub if the rule ever widens.
|
||||
if (!isCrawlerUserAgent(req.headers?.["user-agent"])) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.redirect(302, shareUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = pointerToFilter(pointer);
|
||||
if (!filter) {
|
||||
renderFallback("no-pointer");
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = new SimplePool();
|
||||
let superAdminHex = "";
|
||||
try {
|
||||
const decoded = nip19.decode(ADMIN_SUPER_NPUB);
|
||||
superAdminHex = decoded.type === "npub" ? decoded.data : "";
|
||||
} catch (err) {
|
||||
superAdminHex = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const listFilter = (identifier) => ({
|
||||
kinds: [ADMIN_LIST_KIND],
|
||||
"#d": [`${ADMIN_LIST_NAMESPACE}:${identifier}`],
|
||||
...(superAdminHex ? { authors: [superAdminHex] } : {}),
|
||||
});
|
||||
|
||||
// One shared deadline across all three reads.
|
||||
const now = Date.now();
|
||||
const adminListsFresh =
|
||||
adminListCache.expiresAt > now && adminListCache.whitelist instanceof Set;
|
||||
|
||||
const [videoEvent, whitelistEvent, blacklistEvent] = await Promise.all([
|
||||
withTimeout(pool.get(RELAYS, filter), RELAY_TIMEOUT_MS, null),
|
||||
adminListsFresh
|
||||
? Promise.resolve(null)
|
||||
: withTimeout(
|
||||
pool.get(RELAYS, listFilter(ADMIN_LIST_IDENTIFIERS.whitelist)),
|
||||
RELAY_TIMEOUT_MS,
|
||||
null
|
||||
),
|
||||
adminListsFresh
|
||||
? Promise.resolve(null)
|
||||
: withTimeout(
|
||||
pool.get(RELAYS, listFilter(ADMIN_LIST_IDENTIFIERS.blacklist)),
|
||||
RELAY_TIMEOUT_MS,
|
||||
null
|
||||
),
|
||||
]);
|
||||
|
||||
let whitelist = adminListsFresh ? adminListCache.whitelist : null;
|
||||
let blacklist = adminListsFresh ? adminListCache.blacklist : null;
|
||||
if (!adminListsFresh) {
|
||||
whitelist = extractPubkeySet(whitelistEvent);
|
||||
blacklist = extractPubkeySet(blacklistEvent);
|
||||
// Only cache a whitelist we actually got. Caching an empty one would pin
|
||||
// "whitelist-unavailable" for the whole TTL after a single slow read.
|
||||
if (whitelist.size > 0) {
|
||||
adminListCache = {
|
||||
expiresAt: now + ADMIN_LIST_TTL_MS,
|
||||
whitelist,
|
||||
blacklist,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const video = parseVideoEvent(videoEvent);
|
||||
if (!video) {
|
||||
renderFallback("event-not-found");
|
||||
return;
|
||||
}
|
||||
|
||||
const gate = isPubliclyShareable(video, {
|
||||
whitelistEnabled: true,
|
||||
whitelist,
|
||||
blacklist,
|
||||
});
|
||||
|
||||
if (!gate.allowed) {
|
||||
renderFallback(`gated:${gate.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "public, s-maxage=300, stale-while-revalidate=600");
|
||||
res.setHeader("X-Bitvid-OG", "video");
|
||||
res.status(200).send(
|
||||
renderOgHtml({
|
||||
video,
|
||||
shareUrl,
|
||||
embedUrl,
|
||||
fallback: { ...FALLBACK, url: origin, image: `${origin}/assets/jpg/bitvid.jpg` },
|
||||
gate,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
renderFallback("error");
|
||||
} finally {
|
||||
try {
|
||||
pool.close(RELAYS);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,18 +47,48 @@
|
||||
>
|
||||
<div class="space-y-2 text-left">
|
||||
<p class="text-sm text-text-muted">
|
||||
Choose how you'd like to sign in. Connect a browser extension, link a
|
||||
remote signer, or work directly with your keys when you need to.
|
||||
Use BitLogin for a password-style sign-in, or connect the Nostr
|
||||
identity you already use. Both keep you in control of your keys.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Permanent, statically-declared mount for <bitlogin-auth> -- never
|
||||
written by an innerHTML re-render (this markup never changes after
|
||||
the page loads), so the element is only ever hidden/shown, never
|
||||
destroyed and recreated. Removing it from the DOM tears down its
|
||||
crypto Web Worker (see BitLogin's README).
|
||||
-->
|
||||
<div
|
||||
id="bitloginMount"
|
||||
class="rounded-3xl border border-border bg-overlay-panel-soft/90 p-4 shadow-lg backdrop-blur-sm sm:p-5"
|
||||
>
|
||||
<div class="mb-3 space-y-1">
|
||||
<h3 class="text-base font-semibold text-text">BitLogin</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
A portable login name and password for BitLogin-enabled sites.
|
||||
</p>
|
||||
</div>
|
||||
<bitlogin-auth
|
||||
id="bitloginWidget"
|
||||
vault-relays="wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band,wss://nostr.wine,wss://relay.snort.social"
|
||||
discovery-relays="wss://purplepag.es,wss://relay.nostr.band,wss://nostr-pub.wellorder.net"
|
||||
></bitlogin-auth>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="space-y-5 rounded-3xl border border-border bg-overlay-panel-soft/90 p-4 shadow-lg backdrop-blur-sm sm:p-5"
|
||||
class="space-y-5 rounded-3xl border border-accent/40 bg-overlay-panel-soft/90 p-4 shadow-lg backdrop-blur-sm sm:p-5"
|
||||
aria-labelledby="nostrLoginOptionsTitle"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-semibold text-text">Available methods</h3>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-accent">
|
||||
Nostr sign-in
|
||||
</p>
|
||||
<h3 id="nostrLoginOptionsTitle" class="text-base font-semibold text-text">
|
||||
Use your existing Nostr identity
|
||||
</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
Pick the option that matches how you manage your Nostr identity.
|
||||
Browser extension (NIP-07), remote signer (NIP-46), or your own key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -100,7 +100,15 @@
|
||||
>
|
||||
<!-- Left navigation column -->
|
||||
<div class="profile-modal__menu" data-profile-mobile-menu aria-hidden="false">
|
||||
<nav class="profile-modal__nav" aria-label="Profile sections">
|
||||
<div class="profile-modal__menu-heading">
|
||||
<h3 id="profileModalMenuHeading" class="profile-modal__menu-title">
|
||||
Profile & settings
|
||||
</h3>
|
||||
<p class="profile-modal__menu-hint">
|
||||
Choose what you want to manage
|
||||
</p>
|
||||
</div>
|
||||
<nav class="profile-modal__nav" aria-labelledby="profileModalMenuHeading">
|
||||
<button
|
||||
id="profileNavAccount"
|
||||
type="button"
|
||||
@@ -108,6 +116,7 @@
|
||||
aria-controls="profilePaneAccount"
|
||||
aria-selected="true"
|
||||
data-state="active"
|
||||
data-mobile-description="Profile and account settings"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -130,6 +139,7 @@
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneRelays"
|
||||
aria-selected="false"
|
||||
data-mobile-description="Manage your relay connections"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -152,6 +162,7 @@
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneWallet"
|
||||
aria-selected="false"
|
||||
data-mobile-description="Manage your wallet and payments"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -173,6 +184,7 @@
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneStorage"
|
||||
aria-selected="false"
|
||||
data-mobile-description="Manage your stored files"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -195,6 +207,7 @@
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneMyVideos"
|
||||
aria-selected="false"
|
||||
data-mobile-description="View and manage your videos"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -217,6 +230,7 @@
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePanePlaylists"
|
||||
aria-selected="false"
|
||||
data-mobile-description="Organize your playlists"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -239,6 +253,7 @@
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneHashtags"
|
||||
aria-selected="false"
|
||||
data-mobile-description="Explore and follow hashtags"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -261,6 +276,7 @@
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneSubscriptions"
|
||||
aria-selected="false"
|
||||
data-mobile-description="Manage your subscriptions"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -286,6 +302,7 @@
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneFriends"
|
||||
aria-selected="false"
|
||||
data-mobile-description="Find and manage your friends"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -308,6 +325,7 @@
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneSafety"
|
||||
aria-selected="false"
|
||||
data-mobile-description="Privacy and safety settings"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -330,6 +348,7 @@
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneBlocked"
|
||||
aria-selected="false"
|
||||
data-mobile-description="Manage blocked users"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -352,6 +371,7 @@
|
||||
class="btn-ghost focus-ring relative w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneMessages"
|
||||
aria-selected="false"
|
||||
data-mobile-description="View your messages"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -379,6 +399,7 @@
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneHistory"
|
||||
aria-selected="false"
|
||||
data-mobile-description="View your watch history"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -408,6 +429,7 @@
|
||||
class="btn-ghost focus-ring hidden w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneAdmin"
|
||||
aria-selected="false"
|
||||
data-mobile-description="Moderate users and content"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -429,10 +451,12 @@
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="profile-modal__mobile-actions" role="group" aria-label="Session actions">
|
||||
<button
|
||||
id="profileMobileLockNowBtn"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-6 lg:hidden hidden"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium lg:hidden hidden"
|
||||
title="Forget your unlocked key on this device and re-lock storage + wallet now"
|
||||
>
|
||||
<svg
|
||||
@@ -453,7 +477,7 @@
|
||||
<button
|
||||
id="profileMobileLogoutBtn"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-2 lg:hidden"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium lg:hidden"
|
||||
data-variant="critical"
|
||||
>
|
||||
<svg
|
||||
@@ -471,7 +495,7 @@
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right content panes -->
|
||||
@@ -1179,7 +1203,7 @@ https://another.blossom.example"></textarea>
|
||||
></p>
|
||||
<label id="profileMyVideosAutoShareRow" class="hidden flex items-start gap-2 text-sm text-muted-strong">
|
||||
<input id="profileMyVideosAutoShare" type="checkbox" class="checkbox mt-0.5" />
|
||||
<span>Auto-share new public videos to other Nostr apps (NIP-71)</span>
|
||||
<span>Mirror new public videos to other Nostr apps by default (NIP-71)</span>
|
||||
</label>
|
||||
<p
|
||||
id="profileMyVideosLoading"
|
||||
|
||||
@@ -309,6 +309,10 @@
|
||||
<input type="checkbox" id="check-kids" class="checkbox" />
|
||||
<span class="text-sm text-text">For Kids</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input type="checkbox" id="check-no-nip71-mirror" class="checkbox" />
|
||||
<span class="text-sm text-text">Do not share this video to other Nostr apps</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options -->
|
||||
|
||||
@@ -276,6 +276,20 @@ export const FEATURE_AUDIO_INGEST = false;
|
||||
*/
|
||||
export const FEATURE_BITCOIN_CONNECT = true;
|
||||
|
||||
/**
|
||||
* Whether the login modal offers BitLogin (a portable Nostr identity behind a
|
||||
* login name/password, https://github.com/PR0M3TH3AN/bitlogin) as the primary
|
||||
* sign-in method, with the NIP-07 extension, nsec, and NIP-46 options
|
||||
* collapsed under "Other login options" (js/ui/bitloginModalIntegration.js).
|
||||
* The vendored widget bundle (vendor/bitlogin/, scripts/build-bitlogin-widget.mjs)
|
||||
* only lazy-loads once the login modal's HTML is present in the DOM.
|
||||
*
|
||||
* Enabled on unstable for the final production-relay smoke test. Keep this
|
||||
* gated from beta/main until that manual login, reload, logout, and recovery
|
||||
* pass is confirmed.
|
||||
*/
|
||||
export const FEATURE_BITLOGIN = true;
|
||||
|
||||
/**
|
||||
* Whether this deployment offers Blossom (nostr-native blob storage) as a
|
||||
* first-class upload provider alongside Cloudflare R2 / generic S3 / Backblaze B2
|
||||
@@ -359,9 +373,9 @@ export const DEFAULT_TRUST_SEED_NPUBS = [
|
||||
* When a video receives at least this many trusted reports for the active
|
||||
* category (for example, `nudity`), thumbnails render with the blurred preview
|
||||
* by default. Align this value with your moderation policy — the upstream
|
||||
* deployment targets 1 trusted report before blurring.
|
||||
* deployment requires 3 trusted reports before blurring.
|
||||
*/
|
||||
export const DEFAULT_BLUR_THRESHOLD = 1;
|
||||
export const DEFAULT_BLUR_THRESHOLD = 3;
|
||||
|
||||
/**
|
||||
* Baseline trusted-report count that blocks autoplay during browsing.
|
||||
@@ -369,9 +383,9 @@ export const DEFAULT_BLUR_THRESHOLD = 1;
|
||||
* Videos that meet or exceed this count will not autoplay in feeds unless the
|
||||
* viewer explicitly opts in. Increase the number for a more permissive stance
|
||||
* or decrease it if you want autoplay to stop sooner. The upstream deployment
|
||||
* targets 1 trusted report before blocking autoplay.
|
||||
* requires 3 trusted reports before blocking autoplay.
|
||||
*/
|
||||
export const DEFAULT_AUTOPLAY_BLOCK_THRESHOLD = 1;
|
||||
export const DEFAULT_AUTOPLAY_BLOCK_THRESHOLD = 3;
|
||||
|
||||
/**
|
||||
* Trusted mute threshold that hides creators globally.
|
||||
@@ -388,10 +402,10 @@ export const DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD = 20;
|
||||
*
|
||||
* When a video's trusted report count reaches this number, bitvid treats it as
|
||||
* spam or policy violating content and removes it from default feeds. Align
|
||||
* the value with your moderation policy — 1 trusted report is the upstream
|
||||
* the value with your moderation policy — 5 trusted reports is the current
|
||||
* baseline.
|
||||
*/
|
||||
export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD = 1;
|
||||
export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD = 5;
|
||||
|
||||
/**
|
||||
* Optional theme accent overrides for light and dark mode.
|
||||
|
||||
@@ -56,7 +56,7 @@ To maintain a functional and ethical platform, the following types of content ar
|
||||
|
||||
bitvid enforces these guidelines through client-side visibility rules that match the current `moreMenuController` and `userBlocks` behavior:
|
||||
|
||||
- **Blurred previews:** When trusted NIP-56 `nudity` reports reach the configured blur threshold (`DEFAULT_BLUR_THRESHOLD` in [`config/instance-config.js`](../config/instance-config.js)), thumbnails render blurred and autoplay previews pause. Every blur includes a “show anyway” override. The upstream configuration currently uses one trusted report, but adjust the export to match your policy.
|
||||
- **Blurred previews:** When trusted NIP-56 `nudity` reports reach the configured blur threshold (`DEFAULT_BLUR_THRESHOLD` in [`config/instance-config.js`](../config/instance-config.js)), thumbnails render blurred and autoplay previews pause. Every blur includes a “show anyway” override. The hosted default requires three trusted reports.
|
||||
- **Downranked authors:** Muting a creator adds them to your NIP-51 mute list (kind `10000`). Videos from muted creators are deprioritized but still visible with clear badges so you can reverse the action.
|
||||
- **Personal blocks:** Blocking a creator adds them to your private block list (`userBlocks`). Videos from blocked creators are hidden from feeds, carousels, and modals unless you explicitly unblock them.
|
||||
- **Admin hard hides:** Moderator-maintained blacklists (NIP-51 kind `30000`) remove creators from default feeds for everyone using the hosted client. Moderators can still review the content through “show anyway” controls when auditing decisions.
|
||||
@@ -88,7 +88,7 @@ You can manage moderation directly from the client using the existing menus rend
|
||||
|
||||
### **5.5 Safety & Moderation thresholds**
|
||||
|
||||
- Open your profile avatar → **Profile** → **Safety & Moderation** to adjust blur and autoplay thresholds. Leave a field blank to return to the defaults exported from [`config/instance-config.js`](../config/instance-config.js) (`DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`). The upstream repo ships example values of 1 trusted report for blur, 1 for autoplay blocking, 20 trusted mutes to hide authors, and 1 trusted spam report to hide videos—tune the config file before deployment if your community needs different baselines.
|
||||
- Open your profile avatar → **Profile** → **Safety & Moderation** to adjust blur and autoplay thresholds. Leave a field blank to return to the defaults exported from [`config/instance-config.js`](../config/instance-config.js) (`DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`). The hosted defaults require 3 trusted reports for blur and autoplay blocking, 20 trusted mutes to hide authors, and 5 trusted spam reports to hide videos—tune the config file before deployment if your community needs different baselines.
|
||||
- These controls update immediately and sync with the moderation overlays applied by `moreMenuController` and `VideoCard` components. Operators can edit the config file to change the baseline before deploying their own instance.
|
||||
|
||||
## **6. Appeals and Feedback**
|
||||
|
||||
+306
-36
@@ -751,6 +751,68 @@
|
||||
transition-property: border-color, box-shadow, transform;
|
||||
}
|
||||
|
||||
.site-update-notice {
|
||||
@apply fixed bottom-5 right-5 z-50 flex max-w-sm flex-col items-end gap-3;
|
||||
}
|
||||
|
||||
.site-update-notice__button {
|
||||
@apply btn focus-ring rounded-full px-4 py-3 text-sm shadow-lg;
|
||||
}
|
||||
|
||||
/* Fixed mobile actions need to clear the browser home indicator as well as
|
||||
the visual spacing requested by their component. */
|
||||
@media (max-width: calc(theme("screens.md") - 0.02px)) {
|
||||
.mobile-safe-bottom {
|
||||
bottom: calc(var(--space-lg) + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
.site-update-notice__panel {
|
||||
@apply w-full rounded-2xl border border-border bg-overlay-panel-soft p-4 text-left text-text shadow-modal backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.site-update-notice__close {
|
||||
@apply btn-ghost focus-ring -mr-2 -mt-2 h-9 w-9 rounded-full p-0 text-lg leading-none;
|
||||
}
|
||||
|
||||
.site-tagline__text {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
@media (max-width: calc(theme("screens.md") - 0.02px)) {
|
||||
/* Both controls are fixed to the lower-right corner on phones. Keep the
|
||||
site-update notice above the search FAB so neither tap target obscures
|
||||
the other. */
|
||||
.site-update-notice {
|
||||
right: var(--space-lg);
|
||||
bottom: calc(
|
||||
var(--space-lg) + var(--space-2xl-plus) + var(--space-sm)
|
||||
);
|
||||
max-inline-size: min(
|
||||
var(--size-20rem),
|
||||
calc(100vw - (var(--space-lg) * 2))
|
||||
);
|
||||
}
|
||||
|
||||
.site-update-notice__button {
|
||||
min-block-size: var(--size-11);
|
||||
padding-inline: var(--space-md);
|
||||
padding-block: var(--space-sm);
|
||||
}
|
||||
|
||||
.site-tagline {
|
||||
margin-block: var(--space-lg) var(--space-xl);
|
||||
padding-inline: var(--space-md);
|
||||
}
|
||||
|
||||
.site-tagline .site-tagline__text {
|
||||
font-size: var(--font-size-lg);
|
||||
line-height: var(--line-height-tight);
|
||||
letter-spacing: var(--tracking-normal);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.login-option::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -880,6 +942,29 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Maps the <bitlogin-auth> widget's own theming hooks onto bitvid's design
|
||||
tokens (components/login-modal.html), so it reads as part of this site
|
||||
rather than a foreign embed, and follows the light/dark theme toggle
|
||||
automatically since every token below is itself theme-aware. */
|
||||
bitlogin-auth {
|
||||
display: block;
|
||||
--bl-accent: var(--color-accent-strong);
|
||||
--bl-accent-hover: var(--color-accent);
|
||||
--bl-accent-fg: rgb(255 255 255);
|
||||
--bl-bg: var(--surface-panel);
|
||||
--bl-fg: var(--text-primary);
|
||||
--bl-muted: var(--text-muted);
|
||||
--bl-border: var(--border-default);
|
||||
--bl-input-bg: var(--surface-sunken);
|
||||
--bl-danger: var(--status-danger);
|
||||
--bl-danger-bg: var(--status-danger-surface);
|
||||
--bl-warn: var(--status-warning);
|
||||
--bl-warn-bg: var(--status-warning-surface);
|
||||
--bl-radius: var(--radius-lg);
|
||||
--bl-font-family: inherit;
|
||||
--bl-max-width: none;
|
||||
}
|
||||
|
||||
.btn-ghost[data-state="active"],
|
||||
.btn-ghost[aria-pressed="true"] {
|
||||
@apply border-transparent bg-primary text-white shadow-md;
|
||||
@@ -944,7 +1029,7 @@
|
||||
}
|
||||
|
||||
.moderation-badge {
|
||||
@apply inline-flex flex-col items-start gap-sm rounded-xl border px-3 py-1.5 text-xs font-semibold normal-case tracking-tight shadow-sm;
|
||||
@apply inline-flex max-w-full flex-col items-start gap-sm rounded-xl border px-3 py-1.5 text-xs font-semibold normal-case tracking-tight shadow-sm;
|
||||
}
|
||||
|
||||
.moderation-badge--interactive {
|
||||
@@ -952,11 +1037,11 @@
|
||||
}
|
||||
|
||||
.moderation-badge__actions {
|
||||
@apply inline-flex flex-nowrap items-center gap-sm;
|
||||
@apply flex max-w-full flex-wrap items-center gap-sm;
|
||||
}
|
||||
|
||||
.moderation-badge__label {
|
||||
@apply inline-flex items-center gap-xs whitespace-nowrap;
|
||||
@apply inline-flex min-w-0 items-center gap-xs;
|
||||
}
|
||||
|
||||
.moderation-badge[data-variant="warning"] {
|
||||
@@ -996,11 +1081,12 @@
|
||||
}
|
||||
|
||||
.moderation-badge__text {
|
||||
@apply whitespace-nowrap text-xs font-semibold tracking-tight;
|
||||
@apply min-w-0 text-xs font-semibold tracking-tight;
|
||||
}
|
||||
|
||||
.moderation-badge__action {
|
||||
@apply inline-flex items-center gap-1.5 rounded-full border border-status-warning-border bg-status-warning-surface px-3 py-1 text-xs font-semibold normal-case tracking-tight text-status-warning-on transition duration-150 ease-out;
|
||||
@apply inline-flex max-w-full items-center gap-1.5 rounded-full border border-status-warning-border bg-status-warning-surface px-3 py-1 text-xs font-semibold normal-case tracking-tight text-status-warning-on transition duration-150 ease-out;
|
||||
overflow-wrap: anywhere;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--status-warning) 16%,
|
||||
@@ -1487,6 +1573,9 @@
|
||||
.modal-sheet {
|
||||
inline-size: 100vw;
|
||||
block-size: 100vh;
|
||||
block-size: 100dvh;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
@@ -3553,6 +3642,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.profile-modal__menu-heading,
|
||||
.profile-modal__mobile-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.profile-modal__pane-wrapper {
|
||||
@@ -3733,55 +3828,185 @@
|
||||
Chromium drops the whole block. `theme("screens.lg")` resolves to a literal at
|
||||
build time, which is valid. */
|
||||
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
|
||||
.profile-modal__identity {
|
||||
align-self: stretch;
|
||||
max-inline-size: none;
|
||||
min-block-size: var(--size-6rem);
|
||||
margin-inline: var(--profile-modal-padding-inline);
|
||||
margin-block: 0;
|
||||
padding: var(--space-md);
|
||||
border: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
color: var(--color-white);
|
||||
background:
|
||||
radial-gradient(
|
||||
100% 130% at 100% 0%,
|
||||
color-mix(in srgb, var(--color-accent) 82%, transparent),
|
||||
transparent 58%
|
||||
),
|
||||
linear-gradient(
|
||||
120deg,
|
||||
var(--color-text-strong) 0%,
|
||||
color-mix(in srgb, var(--color-text-strong) 82%, var(--color-accent)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.profile-modal__identity-avatar {
|
||||
inline-size: var(--space-2xl-plus);
|
||||
block-size: var(--space-2xl-plus);
|
||||
border-color: var(--color-white);
|
||||
}
|
||||
|
||||
.profile-modal__identity-kicker {
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.profile-modal__identity-name {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.profile-modal__menu {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block-end: max(var(--space-lg), env(safe-area-inset-bottom));
|
||||
scroll-padding-block-end: max(
|
||||
var(--space-lg),
|
||||
env(safe-area-inset-bottom)
|
||||
);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.profile-modal__menu-heading {
|
||||
display: block;
|
||||
padding-block: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.profile-modal__menu-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.profile-modal__menu-hint {
|
||||
margin: var(--space-3xs) 0 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.profile-modal__nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
/* Every tile the SAME height, so a 2-line label can't stretch its row
|
||||
taller than the others (the "ragged grid of ellipses" fix). */
|
||||
grid-auto-rows: var(--profile-modal-nav-tile-height);
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.profile-modal__nav > button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: var(--space-2xl-plus) minmax(0, 1fr) var(--space-lg);
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: var(--space-2xs);
|
||||
justify-content: start;
|
||||
column-gap: var(--space-md);
|
||||
row-gap: var(--space-3xs);
|
||||
width: 100%;
|
||||
block-size: 100%;
|
||||
/* Consistent rounded-square tiles, not rounded-full ellipses (btn's
|
||||
default) that turn into circles once a tile gets taller. */
|
||||
border-radius: var(--radius-xl);
|
||||
/* Subtle fill so they read as solid cards, not floating outlines. The
|
||||
active tile's bg-primary (higher specificity) still wins. */
|
||||
min-block-size: var(--size-5-75rem);
|
||||
text-align: start;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
line-height: var(--line-height-tight);
|
||||
border-radius: var(--radius-lg);
|
||||
border-color: var(--color-border-subtle);
|
||||
background-color: var(--color-panel);
|
||||
padding-block: var(--space-xs);
|
||||
padding-inline: var(--space-3xs);
|
||||
line-height: 1.15;
|
||||
padding: var(--space-sm);
|
||||
overflow: hidden;
|
||||
/* wrap labels at spaces, not mid-word */
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
hyphens: none;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.profile-modal__nav > button[data-state="active"] {
|
||||
color: var(--color-text);
|
||||
border-color: color-mix(in srgb, var(--color-accent) 36%, transparent);
|
||||
background-color: color-mix(in srgb, var(--color-accent) 9%, var(--color-panel));
|
||||
box-shadow:
|
||||
inset var(--size-3px) 0 0 var(--color-accent),
|
||||
var(--shadow-md);
|
||||
}
|
||||
|
||||
.profile-modal__nav > button:focus-visible {
|
||||
outline-offset: calc(var(--space-3xs) * -1);
|
||||
}
|
||||
|
||||
/* slightly larger glyph for the tile */
|
||||
.profile-modal__nav > button svg {
|
||||
width: var(--space-lg);
|
||||
height: var(--space-lg);
|
||||
flex-shrink: 0;
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 2;
|
||||
width: var(--space-xl);
|
||||
height: var(--space-xl);
|
||||
padding: var(--space-sm);
|
||||
box-sizing: content-box;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-page-alt);
|
||||
}
|
||||
|
||||
.profile-modal__nav > button::before {
|
||||
content: "";
|
||||
grid-column: 3;
|
||||
grid-row: 1 / span 2;
|
||||
inline-size: var(--space-xs);
|
||||
block-size: var(--space-xs);
|
||||
border-block-start: var(--border-width-hairline) solid currentColor;
|
||||
border-inline-end: var(--border-width-hairline) solid currentColor;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.profile-modal__nav > button::after {
|
||||
content: attr(data-mobile-description);
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
align-self: start;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 400;
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.profile-modal__nav > button[data-state="active"] svg {
|
||||
color: var(--color-white);
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* In the tile grid the unread dot rides the tile's top-right corner instead
|
||||
of the vertical-nav left edge (where it landed in the grid gutter). */
|
||||
.profile-modal__nav > button .profile-nav__unread-dot {
|
||||
top: var(--space-2xs);
|
||||
top: var(--space-sm);
|
||||
left: auto;
|
||||
right: var(--space-2xs);
|
||||
right: calc(var(--space-sm) + var(--space-lg));
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.profile-modal__mobile-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-xs);
|
||||
padding-block-start: var(--space-xs);
|
||||
padding-block-end: max(
|
||||
var(--space-sm),
|
||||
env(safe-area-inset-bottom)
|
||||
);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--color-page) 0%, transparent),
|
||||
var(--color-page) var(--space-sm)
|
||||
);
|
||||
}
|
||||
|
||||
.profile-modal__mobile-actions > button {
|
||||
flex: 1 1 0;
|
||||
justify-content: center;
|
||||
min-block-size: var(--size-11);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: theme("screens.lg")) {
|
||||
@@ -4491,6 +4716,39 @@
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* The player owns a single scroll region. On mobile, `100vh` can extend
|
||||
underneath the browser controls/home indicator, which previously made the
|
||||
final composer action hard to reach. Prefer the dynamic viewport and keep
|
||||
scrolling on the panel (where the sticky player navigation listens). */
|
||||
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
|
||||
#playerModal {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#playerModal > .bv-modal__panel {
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#playerModal .player-modal__container {
|
||||
block-size: auto;
|
||||
min-block-size: 100%;
|
||||
}
|
||||
|
||||
#playerModal .comment-composer {
|
||||
padding-bottom: max(var(--space-lg), env(safe-area-inset-bottom));
|
||||
scroll-margin-block-end: max(var(--space-lg), env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
#modalVideo {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
@@ -6662,6 +6920,18 @@
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
@media (max-width: calc(theme("screens.lg") - 0.02px)) {
|
||||
#profileModal {
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
#profileModal .bv-modal__panel {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
#profileModal .bv-modal__panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -39,6 +39,10 @@ Welcome to the bitvid documentation. This directory contains detailed architectu
|
||||
|
||||
## Active plans
|
||||
|
||||
- **[Performance & Efficiency Plan](./performance-efficiency-plan.md)**: A
|
||||
measurement-first staged plan for reducing client CPU, memory, and network
|
||||
work without degrading Nostr discovery, feed freshness, moderation, or
|
||||
playback. Tracked as TODO #34.
|
||||
- **[BitUnlock Permission-Layer TODO](../todo/TODO_2026-07-23_bitunlock-permission-layer.md)**: Ordered implementation checklist for the BitUnlock-backed media permission and subscription work.
|
||||
|
||||
For more details on contributing, please see [CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||
@@ -0,0 +1,113 @@
|
||||
# Channel subscriber counts — feature scope
|
||||
|
||||
> Status: **Scoped; not implemented** · Created 2026-07-26
|
||||
|
||||
## Goal
|
||||
|
||||
Show a useful subscriber/follower count on public BitVid channel profiles without
|
||||
misrepresenting an estimate as a global Nostr fact or weakening private
|
||||
subscriptions.
|
||||
|
||||
## Constraint that determines the design
|
||||
|
||||
BitVid subscriptions are encrypted, private NIP-51 lists. Relays cannot answer
|
||||
"how many people subscribe to this creator?" from those lists. Querying public
|
||||
follow lists, where they exist, would be incomplete, expensive, sybil-prone,
|
||||
and would encourage users to make private follows discoverable. Therefore this
|
||||
feature must not derive a global count by scanning followers' events.
|
||||
|
||||
## Recommended product definition
|
||||
|
||||
Ship a clearly labelled **BitVid followers** count, not an unqualified global
|
||||
"Subscribers" count. It should count only voluntary, public follow attestations
|
||||
that BitVid can verify and should remain an estimate of the BitVid-visible
|
||||
audience. Keep the existing private subscription feature private and do not use
|
||||
it as a counting source.
|
||||
|
||||
Before implementation, make one explicit product decision:
|
||||
|
||||
1. **Public attestation count (recommended):** a viewer may opt in to publish a
|
||||
public, signed follow attestation; BitVid counts valid unique authors for a
|
||||
creator. This has a real verifiable numerator but is necessarily partial.
|
||||
2. **Creator-reported count:** creator publishes a signed number. This is cheap,
|
||||
but is self-reported and must be labelled as such.
|
||||
3. **No public count:** retain private subscriptions only. This is the strongest
|
||||
privacy option.
|
||||
|
||||
Do not silently fall back from option 1 to option 2.
|
||||
|
||||
## Proposed implementation slice (option 1)
|
||||
|
||||
### Data model and protocol
|
||||
|
||||
- Research existing Nostr follow/interest conventions first; reuse an
|
||||
interoperable event if one supports a public creator follow without exposing
|
||||
the private BitVid list.
|
||||
- If no suitable convention exists, write a small BitVid event proposal before
|
||||
coding: event kind, tags, replaceability/deletion semantics, creator target,
|
||||
timestamp rules, relay selection, and migration/version tag.
|
||||
- Count at most one current attestation per follower/creator relationship.
|
||||
- Ignore malformed events, self-follows, deleted/replaced events, and events
|
||||
outside the selected protocol version.
|
||||
- Treat counts as eventually consistent and relay-scoped. Never claim a global
|
||||
Nostr total.
|
||||
|
||||
### Read path and caching
|
||||
|
||||
- Add a dedicated count service/store; do not add follower scans to
|
||||
`channelProfile.js` rendering or the normal feed bootstrap.
|
||||
- Fetch only when a channel profile is opened, with a bounded relay set,
|
||||
cancellation on profile navigation, in-flight deduplication, and a short
|
||||
cache TTL.
|
||||
- Render a compact skeleton or omit the count while loading. A failed count
|
||||
lookup must never block profile metadata, video loading, zap controls, or
|
||||
subscriptions.
|
||||
- Expose source/coverage only in a low-noise detail surface if needed (for
|
||||
example, "BitVid-visible followers"), not as a misleading exact total.
|
||||
|
||||
### Channel-profile UI
|
||||
|
||||
- Place the count in the creator summary/stat row near videos/zaps, not in the
|
||||
profile modal.
|
||||
- Use the chosen product label consistently: `BitVid followers` for public
|
||||
attestations; `Creator-reported subscribers` for self-reported data.
|
||||
- Provide an accessible label and compact mobile layout; large counts use
|
||||
locale-aware formatting.
|
||||
- The viewer's private Subscribe/Unsubscribe control remains unchanged. If an
|
||||
opt-in public follow is added, it is a separate, explicit privacy choice.
|
||||
|
||||
### Abuse and privacy safeguards
|
||||
|
||||
- Do not expose who follows whom by default beyond the public attestations the
|
||||
viewer chose to publish.
|
||||
- Do not rank or moderate creators solely by this count.
|
||||
- Rate-limit/bound relay queries and deduplicate by author; document that sybil
|
||||
resistance is not provided by a raw public count.
|
||||
- Preserve viewer blocks/mutes and creator visibility rules in any optional
|
||||
follower preview; the count itself must not leak a viewer's private list.
|
||||
|
||||
## Tests and acceptance criteria
|
||||
|
||||
- Unit tests: unique-author counting, replacement/deletion, malformed events,
|
||||
self-follow exclusion, cache TTL, and formatted display values.
|
||||
- Service tests: bounded/cancelled relay queries, timeout/failure returns an
|
||||
unavailable state without clearing a good cached count.
|
||||
- UI tests: loading, available, unavailable, and mobile wrapping states.
|
||||
- Manual QA: opening a channel profile has no visible impact on Home feed or
|
||||
private subscription list loading; toggling a private subscription publishes
|
||||
no public attestation unless the viewer explicitly opts in.
|
||||
|
||||
## Non-goals for the first release
|
||||
|
||||
- A global Nostr subscriber count.
|
||||
- Inferring counts from private encrypted lists.
|
||||
- Follower directories, recommendations, notifications, or monetized tiers.
|
||||
- Using counts as a trust, moderation, or discovery ranking signal.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Approve the product definition and public/private wording.
|
||||
2. Complete protocol/convention research and record the selected event shape.
|
||||
3. Build the bounded count store with tests and fixtures.
|
||||
4. Add channel-profile presentation and responsive UI tests.
|
||||
5. Run privacy/performance QA before enabling public-attestation publishing.
|
||||
@@ -9,7 +9,7 @@ Instead of a centralized moderation authority, the service builds a local "trust
|
||||
- **Viewer**: The currently logged-in user.
|
||||
- **Trusted Contacts**: Users followed by the Viewer (Kind 3 contact list).
|
||||
- **Trusted Seeds**: An optional set of admin-defined pubkeys that provide a baseline of trust (e.g., project maintainers).
|
||||
- **Admin Whitelist/Blacklist**: Hardcoded lists in `accessControl` that override dynamic trust.
|
||||
- **Admin Whitelist/Blacklist**: Nostr-backed lists in `accessControl`. The creator whitelist controls access only; a blacklist always filters first and suppresses its members' content and reports.
|
||||
|
||||
### 2. Trust Aggregation
|
||||
When a piece of content (Event) is reported (Kind 1984), the service aggregates these reports but *only counts reports from trusted users*.
|
||||
|
||||
@@ -6,7 +6,7 @@ bitvid is follow-centric. Your Home feed comes from people you follow (F1). Disc
|
||||
## Core principles
|
||||
- **Freedom to choose**: User picks filters. Defaults are safe but reversible.
|
||||
- **Explain decisions**: Every blur/hide shows a “why” badge and a “show anyway” control.
|
||||
- **Blacklist > whitelist**: Admin blacklists and moderation gates always win; whitelists only influence Discovery ranking.
|
||||
- **Separate access and trust**: The creator whitelist controls site and content access only; moderation uses the viewer's trust graph and admin blacklists.
|
||||
- **Minimal central power**: Admin lists are opt-in; users can unsubscribe.
|
||||
|
||||
## Threat model (short)
|
||||
@@ -36,7 +36,7 @@ bitvid is follow-centric. Your Home feed comes from people you follow (F1). Disc
|
||||
- The badge tooltip and `aria-label` continue to list the specific trusted contacts who muted or reported the content when that metadata is available.
|
||||
- Viewers who override a trusted mute hide keep a **Block** pill on the same badge so they can block the creator without hunting through another menu.
|
||||
- Selecting **Block** immediately publishes an account block for the creator, clears any temporary overrides, and reloads the feed so the author disappears from subsequent fetches.
|
||||
- Trusted mute decisions now block autoplay even when report thresholds have not fired, and any associated avatars or channel banners adopt the blurred state alongside the primary thumbnail so the entire card reflects the moderation choice.
|
||||
- Trusted mutes are a ranking-only signal until their hide threshold is reached. A single stale or bad-faith mute never blurs a creator, blocks playback, or adds a warning badge; feed sorters place those items later. At the configured mute-hide threshold, the card is hidden and its visuals are blurred like any other hide decision.
|
||||
|
||||
## Where to extend
|
||||
Thread new moderation behaviors through the same service → stage → app → UI flow above. Extending the existing layers keeps overrides, feature flags, and QA hooks consistent—avoid spinning up parallel moderation modules unless the architecture document explicitly calls for it.
|
||||
@@ -47,10 +47,10 @@ Thread new moderation behaviors through the same service → stage → app → U
|
||||
- Hide videos when `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` trusted contacts mute the author. Cards render with `data-moderation-hidden="true"`, the badge reads `Hidden · {count} trusted mute(s)`, and a "Show anyway" button becomes available alongside a "Restore default moderation" control once the override is active.
|
||||
- Hide videos when `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` trusted contacts file spam reports. The badge copy escalates to `Hidden · {count} trusted spam report(s)` and the card stays hidden until the viewer overrides it.
|
||||
- Downrank author when any F1 has them in mute list (10000).
|
||||
- Opt-in admin lists (30000 with `d=bitvid:admin:*`) can hard-hide content, but whitelist entries no longer bypass moderation gates—they only influence Discovery rankings when a viewer opts into that list.
|
||||
- Opt-in admin lists (30000 with `d=bitvid:admin:*`) can hard-hide content through the blacklist. The creator whitelist is access-only and does not change web-of-trust blur, autoplay, or hide decisions.
|
||||
- Trust seeds now come from the Super Admin plus every active moderator, so their reports shape anonymous/default visitor filters automatically. The `DEFAULT_TRUST_SEED_NPUBS` export only activates as an emergency fallback when those live lists cannot be loaded (toggle via `FEATURE_TRUST_SEEDS`).
|
||||
|
||||
> Default thresholds now come directly from [`config/instance-config.js`](../../config/instance-config.js). Look for the exports `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` to adjust the instance-wide behavior before deploying. The upstream repo currently publishes example values of 1 trusted report for blur, 1 for autoplay blocking, 20 trusted mutes to hide authors, and 1 trusted spam report to hide videos.
|
||||
> Default thresholds now come directly from [`config/instance-config.js`](../../config/instance-config.js). Look for the exports `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` to adjust the instance-wide behavior before deploying. The hosted defaults require 3 trusted reports for blur and autoplay blocking, 20 trusted mutes to hide authors, and 5 trusted spam reports to hide videos.
|
||||
|
||||
> Operators can still override these thresholds on a per-viewer basis in **Settings → Safety & Moderation**; leaving the UI fields blank restores the config-driven defaults. Update [`config/instance-config.js`](../../config/instance-config.js) to change what new viewers receive out of the box.
|
||||
|
||||
@@ -89,7 +89,7 @@ The trusted mute hide helper copy clarifies the escalation path: once the truste
|
||||
### Runtime flags
|
||||
|
||||
- `TRUSTED_MUTE_HIDE_THRESHOLD` — numeric default for the trusted mute hide control. Initialized from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the upstream config sets this to `20`, but adjust the export to match your policy.
|
||||
- `TRUSTED_SPAM_HIDE_THRESHOLD` — numeric default for the trusted spam hide control. Initialized from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the upstream config example is `1`.
|
||||
- `TRUSTED_SPAM_HIDE_THRESHOLD` — numeric default for the trusted spam hide control. Initialized from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the hosted default is `5`.
|
||||
- `FEATURE_TRUSTED_HIDE_CONTROLS` — boolean toggle to hide/show the new trusted hide controls in the UI (default `true`).
|
||||
|
||||
## Files in this folder
|
||||
|
||||
@@ -40,8 +40,8 @@ Create three distinct F1 reporter keys; each sends:
|
||||
Expected:
|
||||
|
||||
* After 1 report → no blur; autoplay allowed.
|
||||
* Once reports reach `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` → autoplay blocked (the upstream configuration currently sets this to 2, but rely on your `config/instance-config.js` value).
|
||||
* Once reports reach `DEFAULT_BLUR_THRESHOLD` → thumbnail blurred; reason chip shown (upstream example: 1). At the spam threshold (`TRUSTED_SPAM_HIDE_THRESHOLD` sourced from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js)) the card hides entirely and the badge copy switches to "Hidden · {count} trusted spam report(s)" until the viewer overrides it.
|
||||
* Once reports reach `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` → autoplay blocked (the hosted configuration currently sets this to 3, but rely on your `config/instance-config.js` value).
|
||||
* Once reports reach `DEFAULT_BLUR_THRESHOLD` → thumbnail blurred; reason chip shown (the hosted default is 3). At the spam threshold (`TRUSTED_SPAM_HIDE_THRESHOLD` sourced from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js), currently 5) the card hides entirely and the badge copy switches to "Hidden · {count} trusted spam report(s)" until the viewer overrides it.
|
||||
|
||||
**Exercises:** [`ModerationService.ingestReportEvent()`](../../js/services/moderationService.js) and [`ModerationService.getTrustedReportSummary()`](../../js/services/moderationService.js) wire through [`createModerationStage()`](../../js/feedEngine/stages.js) so [`bitvidApp.decorateVideoModeration()`](../../js/app.js) can hand the summary to [`VideoCard.refreshModerationUi()`](../../js/ui/components/VideoCard.js).
|
||||
|
||||
@@ -56,7 +56,7 @@ Expected:
|
||||
}
|
||||
```
|
||||
|
||||
Expected: author’s items are downranked/blurred for this viewer. When the mute threshold (`TRUSTED_MUTE_HIDE_THRESHOLD`, sourced from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); upstream example: 20) is met, cards render with "Hidden · {count} trusted mute(s)" and require a "Show anyway" override to display content plus a "Restore default moderation" control to revert.
|
||||
Expected: author’s items are downranked for this viewer without blur, playback blocking, or a warning badge. When the mute threshold (`TRUSTED_MUTE_HIDE_THRESHOLD`, sourced from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); upstream example: 20) is met, cards render with "Hidden · {count} trusted mute(s)" and require a "Show anyway" override to display content plus a "Restore default moderation" control to revert.
|
||||
|
||||
**Exercises:** [`ModerationService.ingestTrustedMuteEvent()`](../../js/services/moderationService.js) updates [`ModerationService.isAuthorMutedByTrusted()`](../../js/services/moderationService.js) / [`getTrustedMutersForAuthor()`](../../js/services/moderationService.js), which [`createModerationStage()`](../../js/feedEngine/stages.js) propagates to the video item before [`VideoCard.refreshModerationUi()`](../../js/ui/components/VideoCard.js) renders the mute state.
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ list. Blocking still follows the precedence rules above.
|
||||
- A video has 3 `nudity` reports from those seed accounts.
|
||||
|
||||
Result:
|
||||
- Thumbnail is blurred if `DEFAULT_BLUR_THRESHOLD` is 1 (met).
|
||||
- Autoplay is blocked if `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` is 1 (met).
|
||||
- Thumbnail is blurred because the 3 trusted reports meet `DEFAULT_BLUR_THRESHOLD`.
|
||||
- Autoplay is blocked because the 3 trusted reports meet `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`.
|
||||
- No hard hide unless trusted mute/spam thresholds are met.
|
||||
|
||||
### Example 2: Logged-in viewer with personal blocks
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- `hideIfTrustedMuteCount(author, category) >= trustedMuteHideThresholds[category] ?? DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`
|
||||
- `hideIfTrustedSpamReports(event) >= DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`
|
||||
|
||||
Threshold constants are exported from [`config/instance-config.js`](../../config/instance-config.js) so operators can change the defaults without touching moderation code. Inspect the `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` exports to set your policy. The upstream repo includes example values (blur at 1, autoplay block at 1, trusted mute hide at 20, trusted spam hide at 1), but treat those as guidance rather than hard-coded requirements. Per-category trusted mute hide thresholds now live in moderation settings as `trustedMuteHideThresholds` (e.g., `{ spam: 5, nudity: 10 }`), and any category not present falls back to the default trusted mute hide threshold. When evaluating trusted mutes we use the content's report type if available, otherwise we fall back to any category metadata attached to trusted mute list entries.
|
||||
Threshold constants are exported from [`config/instance-config.js`](../../config/instance-config.js) so operators can change the defaults without touching moderation code. The hosted baseline requires 3 trusted reports to blur or block autoplay, 20 trusted mutes to hide an author, and 5 trusted spam reports to hide a video. Per-category trusted mute hide thresholds now live in moderation settings as `trustedMuteHideThresholds` (e.g., `{ spam: 5, nudity: 10 }`), and any category not present falls back to the default trusted mute hide threshold. When evaluating trusted mutes we use the content's report type if available, otherwise we fall back to any category metadata attached to trusted mute list entries.
|
||||
|
||||
### Trusted mute decay window
|
||||
- Trusted mute counts are **time-bounded**: only mute lists updated within the rolling window (currently 60 days) contribute to `trustedMuteCount`.
|
||||
@@ -34,7 +34,7 @@ Threshold constants are exported from [`config/instance-config.js`](../../config
|
||||
## Admin lists (opt-in)
|
||||
- We recognize curated lists using `30000` events:
|
||||
- `['d','bitvid:admin:blacklist']` → hard-hide when subscribed.
|
||||
- `['d','bitvid:admin:whitelist']` → improves Discovery ranking when subscribed but no longer bypasses moderation gates.
|
||||
- `['d','bitvid:admin:whitelist']` → controls creator access only; it does not add a reporter to the trust graph or alter moderation decisions.
|
||||
- `['d','bitvid:admin:editors']` → trusted channel editors.
|
||||
- Users can subscribe/unsubscribe any time.
|
||||
|
||||
@@ -42,7 +42,7 @@ Threshold constants are exported from [`config/instance-config.js`](../../config
|
||||
|
||||
1. **Personal blocks win first.** If a viewer blocks an author or reporter, we ignore their content and reports regardless of admin lists.
|
||||
2. **Admin blacklist applies next.** Entries on `bitvid:admin:blacklist` are hard-hidden and their reports suppressed before looking at thresholds.
|
||||
3. **F1 thresholds run last.** Blur/autoplay gating only evaluates trusted-report counts after personal blocks and admin blacklists. Admin whitelists contribute to Discovery ranking when a viewer subscribes, but they never override moderation gates or a viewer block.
|
||||
3. **F1 thresholds run last.** Blur/autoplay gating evaluates trusted-report counts after personal blocks and admin blacklists. The creator whitelist is not an input to these decisions.
|
||||
|
||||
## Pseudocode
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Instead of a central authority deciding what is "safe," the service aggregates r
|
||||
- Users can report content for reasons like `nudity`, `spam`, `illegal`, etc.
|
||||
- Reports are public events signed by the reporter.
|
||||
- The service subscribes to reports for active content and aggregates them locally.
|
||||
- **Crucially**: Reports from users *outside* the trusted contact list are ignored (unless whitelisted by admin).
|
||||
- **Crucially**: Reports from users *outside* the trusted contact list are ignored. Creator whitelist membership does not establish moderation trust.
|
||||
|
||||
### NIP-51 Mutes (Kind 10000)
|
||||
- Users can mute other users to hide their content entirely.
|
||||
@@ -28,7 +28,7 @@ Instead of a central authority deciding what is "safe," the service aggregates r
|
||||
- It also supports "Trusted Mutes" where mutes from trusted contacts can influence visibility (though this feature is evolving).
|
||||
|
||||
### Admin Overrides
|
||||
- The service integrates with `AccessControl` to respect an instance-wide **Blacklist** (always hidden) and **Whitelist** (always trusted).
|
||||
- The service integrates with `AccessControl` to respect an instance-wide **Blacklist** (always hidden). The creator whitelist is access-only and never establishes moderation trust.
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
||||
@@ -89,7 +89,7 @@ await moderationService.submitReport({
|
||||
## Invariants
|
||||
|
||||
- **Trust is Subjective**: A report only counts if the *viewer* follows the reporter. Changing the viewer changes all trust scores immediately.
|
||||
- **Admin Trumps Trust**: Blacklisted users are ignored even if followed. Whitelisted users are trusted even if not followed.
|
||||
- **Admin Trumps Trust**: Blacklisted users are ignored even if followed. Creator whitelist membership neither grants nor removes trust.
|
||||
- **Reactive**: The service emits events (`summary`, `contacts`, `user-blocks`) to drive UI updates; it does not directly manipulate the DOM.
|
||||
|
||||
## When to Change
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
# BitVid Performance & Efficiency Plan
|
||||
|
||||
Status: **Stage 2 shipped; Stage 3 in progress — observer teardown shipped, probe cancellation next**
|
||||
|
||||
## Progress snapshot — 2026-07-26
|
||||
|
||||
The plan is being delivered as independent, test-gated slices on `unstable`.
|
||||
|
||||
### Shipped
|
||||
|
||||
- **Stage 0 foundation:** the opt-in local performance harness is available;
|
||||
it remains disabled unless the loopback-only storage flag is set.
|
||||
- **Stage 1 complete:** localhost relay-request monitoring is explicit opt-in.
|
||||
- **Stage 2 complete:** `ExploreDataService` is demand-created for For You and
|
||||
Explore, pauses outside active recommendation views, coalesces concurrent
|
||||
history/IDF refreshes, and no longer blocks cached For You rendering.
|
||||
- **Stage 3 first slice:** card, URL-health, and grid-health observers are
|
||||
disposed when feed markup is replaced or unmounted.
|
||||
- **Stage 7 first slice:** recommendation refresh bursts are coalesced into one
|
||||
active pass plus one follow-up; Most Zapped ordering has regression coverage.
|
||||
|
||||
### Remaining
|
||||
|
||||
- Capture and retain the complete Stage 0 baseline scenarios, including cache
|
||||
size, worker lifetime, probe counts, and browser CPU/RSS.
|
||||
- Finish Stage 3 with owner-aware queue pruning and abort signals for URL and
|
||||
WebTorrent probes, then verify hidden-card reveal and fallback behavior.
|
||||
- Finish Stage 7 with bounded, category-aware event-cache eviction and same-feed
|
||||
refresh coalescing without swallowing explicit refreshes.
|
||||
- Implement Stage 5's service-worker update cadence and image-cache freshness
|
||||
policy after measuring warm-feed revalidation.
|
||||
- Profile before changing Stage 4 (modal visuals) or Stage 6 (bundle/lazy
|
||||
loading); these remain unstarted and measurement-dependent.
|
||||
|
||||
### Current release evidence
|
||||
|
||||
- `5f439d68`: lazy recommendation indexes.
|
||||
- `687f8c43`: lifecycle disposal, refresh coalescing, and Most Zapped ordering.
|
||||
- `bb6614e3`: cache-first For You navigation rendering.
|
||||
- Targeted regression suites and production builds pass for each shipped slice;
|
||||
the current remote tip is `origin/unstable` at `bb6614e3`.
|
||||
|
||||
## Goal
|
||||
|
||||
Reduce idle CPU, GPU, memory, relay traffic, and unnecessary network activity
|
||||
without making feeds stale or weakening BitVid's Nostr-native recommendation
|
||||
system.
|
||||
|
||||
BitVid must continue to ingest relevant notes, follows, zaps, watch events, and
|
||||
moderation signals promptly. The optimization is about scheduling and bounded
|
||||
work, not removing recommendation inputs or live feed updates.
|
||||
|
||||
## Baseline findings
|
||||
|
||||
Local observations on 2026-07-26 while serving the production build:
|
||||
|
||||
| Component | Observed load | Conclusion |
|
||||
| --- | --- | --- |
|
||||
| Static `serve dist` process | under 1% CPU, about 115 MB RSS | Not the heat source. |
|
||||
| Browser main/renderer/GPU processes | renderer about 22% CPU, GPU about 7% CPU, renderer about 430 MB RSS | Primary area for profiling and improvement. |
|
||||
| System memory | 24 GiB available; no swap in use | Not currently memory pressured. |
|
||||
|
||||
Code audit candidates:
|
||||
|
||||
- `ExploreDataService` starts for every session and refreshes watch-history tags
|
||||
every minute and tag-IDF every five minutes, even outside Explore/For You.
|
||||
- `devReqMonitor.js` automatically patches WebSocket sends on localhost and
|
||||
wakes every two seconds. This is development-only but makes local testing
|
||||
noisier and more expensive than production.
|
||||
- App startup registers/checks the service worker and explicitly calls
|
||||
`registration.update()` on every load.
|
||||
- URL/torrent health checks can allocate media probes and WebTorrent tracker
|
||||
work; they must remain viewport-driven, bounded, and cancellable.
|
||||
- The video-modal ambient background can perform recurring canvas draws while a
|
||||
video plays. It is a likely GPU hotspot and needs measurement before tuning.
|
||||
- The image service-worker cache revalidates external images in the background;
|
||||
warm feeds can still cause many network/cache operations.
|
||||
|
||||
## Deeper system audit
|
||||
|
||||
This table separates verified mechanics from hypotheses. It is deliberately not
|
||||
a list of features to remove: every item has a product boundary that must stay
|
||||
intact.
|
||||
|
||||
| System | Verified finding | Risk / opportunity | Planned boundary |
|
||||
| --- | --- | --- | --- |
|
||||
| Nostr subscriptions and feed loading | Feed loading deduplicates normal in-flight requests and active feeds need live relay updates. | Over-throttling would make Home, follows, zaps, and moderation stale. | Preserve subscriptions and event ingestion; coalesce only duplicate refresh/re-rank work. |
|
||||
| For You / Explore indexes | `ExploreDataService` is constructed during bootstrap, clones the video set into a worker, refreshes history tags each minute and IDF each five minutes. Event, visibility, and timer paths can request the same broad work. | Unused views pay recurrent CPU, worker-message, and allocation cost. | Keep lightweight dirty/version signals live; create and refresh the worker only for an active or imminent recommendation view, with one serialized refresh per index. |
|
||||
| Event persistence and relay cache | IndexedDB writes are differential and idle-scheduled, but the payload is built from the complete `allEvents` and tombstone maps; restore likewise rehydrates the complete unexpired cache. TTL expiry is 10 minutes, with no explicit record/byte budget. | Large relay sessions can raise startup, structured-clone/serialization, IndexedDB, and renderer-memory cost. | Measure actual record/byte growth first, then add an eviction budget that preserves active/latest, user-owned, watched, and moderation-relevant events. Never replace the cache with a smaller unscoped relay query. |
|
||||
| URL liveness cards | The observer is viewport/prefetch-margin driven, and URL checks are deduplicated at controller level. Hidden-until-verified cards intentionally start an eager check so they can become visible. | A large hidden-card batch can still fill work queues; discarded cards cannot presently express cancellation through the generic observer. | Retain the reveal guarantee, but add queue cancellation, page/feed budgets, and primary-source-first fallback. |
|
||||
| WebTorrent stream health | Torrent probes are globally queued (two at a time), cached, deduplicated, capped at four peer connections and destroy each probe torrent. A reusable probe client remains after first use. | The safeguards are good, but queue entries may outlive a discarded feed and a health badge can still pull WebTorrent into an otherwise hosted-video session. | Add cancellation/queue pruning and explicit health-probe telemetry before changing limits; evaluate lazy WebTorrent loading separately from playback correctness. |
|
||||
| WebTorrent startup bundle | The WebTorrent facade imports the roughly 212 KB minified implementation at module evaluation time. | Browsers can download/parse a torrent stack before the viewer plays or probes a torrent. | Measure module transfer/parse on cold Home, then introduce a lazy implementation loader only if hosted playback and health-badge behavior remain correct. |
|
||||
| Service worker and external images | Boot checks existing registrations and calls `update()`; the image strategy returns cache quickly but starts a network revalidation for each cached image request. | Warm feeds can produce avoidable fetch/cache churn even when their images are already fresh. | Keep deploy reliability and bounded cache trimming; add a tested freshness policy rather than disabling updates or image caching. |
|
||||
| Modal visuals and UI rendering | The ambient-video helper schedules animation-frame callbacks while active, although its current modal wiring needs trace validation. Feed rerenders replace card DOM and attach health observers again. | Canvas work may be a GPU cost; observer lifecycle needs a retained-node/queue profile before calling it a leak. | Instrument active animation callbacks and observed-card counts. Tune only if a playback trace shows material cost; add explicit observer teardown if retained cards are confirmed. |
|
||||
| Development diagnostics | On localhost, `devReqMonitor` patches WebSocket sends and wakes every two seconds. | It contaminates local CPU and relay measurements but is not a production heat source. | Make it opt-in so normal development reproduces production behavior. |
|
||||
|
||||
### What is *not* a candidate for removal
|
||||
|
||||
- Nostr notes, follows, zaps, reactions, watch events, and moderation signals
|
||||
remain the raw material for feeds and recommendations.
|
||||
- Active-feed subscriptions, source fallback, WebTorrent playback, and the
|
||||
stale-while-revalidate cache remain product capabilities.
|
||||
- A timer or worker is changed only after a trace identifies it as active in a
|
||||
relevant scenario; no optimization is justified merely because code exists.
|
||||
|
||||
## Design rules
|
||||
|
||||
1. **Nostr ingestion stays live.** Keep subscriptions for active feeds and
|
||||
logged-in viewer context. New relevant events update local indexes promptly.
|
||||
2. **Expensive work is demand-driven.** A new event marks an index stale; it
|
||||
does not force a whole recommendation rebuild until the associated feed is
|
||||
active or about to become active.
|
||||
3. **Inactive views do no recurring work.** Hidden tabs, closed modals, and
|
||||
inactive feed views must pause timers, observers, and probe queues.
|
||||
4. **Every background task has a bound.** Cap concurrency, use TTL caches,
|
||||
cancel obsolete work, and destroy listeners/workers when their feature is no
|
||||
longer in use.
|
||||
5. **No silent product degradation.** Feed freshness, source fallback,
|
||||
WebTorrent playback, moderation, and user-visible recommendation inputs are
|
||||
acceptance criteria in every stage.
|
||||
6. **Use cancellation, not just lower limits.** A task belonging to a removed
|
||||
card, closed modal, inactive feed, or hidden document must be removable from
|
||||
the queue; lowering concurrency alone can leave obsolete work running.
|
||||
7. **Measure both wall time and retained state.** A faster task that retains a
|
||||
large worker snapshot, observer target, cache record, or probe client is not
|
||||
an efficiency win.
|
||||
8. **Preserve discovery boundaries.** A lifecycle or batching optimization must
|
||||
use the same scoped relay/source selection for historical fetch and live
|
||||
subscription. Never make a feature appear faster by querying a smaller,
|
||||
unrelated source set and silently omitting valid user data.
|
||||
Every affected feature needs a regression fixture containing data available
|
||||
only through its scoped historical source and data arriving live afterward.
|
||||
|
||||
## Measurement harness (Stage 0) — foundation shipped; baseline capture remains
|
||||
|
||||
Create a local-only, opt-in performance harness. It must be disabled by
|
||||
default and absent from production behavior.
|
||||
|
||||
Implemented baseline surface: on `localhost`, `127.0.0.1`, or `::1`, set
|
||||
`localStorage.__bitvidPerformanceHarness__ = "1"` and reload. The console API
|
||||
`window.__bitvidPerformance.snapshot()` reports bounded long-task, resource,
|
||||
navigation, heap (when supported), mark, and manually supplied gauge data.
|
||||
`mark`, `setGauge`, and `increment` are available for later stage-specific
|
||||
instrumentation. The harness does not initialize on a public host, even if the
|
||||
storage key is set.
|
||||
|
||||
### Measurements
|
||||
|
||||
- Long tasks via `PerformanceObserver`.
|
||||
- JS heap, where the browser exposes it.
|
||||
- Active workers, timers, observers, URL media probes, torrent probes, and
|
||||
relay subscription/request counts.
|
||||
- Feed refresh duration, item count, and recomputation reason.
|
||||
- Recommendation-index snapshots: input size, queue/coalescing count, worker
|
||||
lifetime, and stale-to-fresh latency.
|
||||
- Event-cache record count, estimated serialized bytes, restore duration, IDB
|
||||
writes/deletes, and protected-versus-evicted record categories.
|
||||
- Browser process CPU/RSS sampled externally during test scenarios.
|
||||
|
||||
### Standard scenarios
|
||||
|
||||
1. Cold load to Home; wait 60 seconds without interaction.
|
||||
2. Scroll through two feed pages, then stop.
|
||||
3. Open and close Profile; remain on Home.
|
||||
4. Open Explore and For You; receive a new relevant note, follow, zap, and
|
||||
moderation update.
|
||||
5. Open hosted playback, then close it.
|
||||
6. Open torrent-backed playback, then close it.
|
||||
7. Background the tab for two minutes and restore it.
|
||||
8. Load an inbox with both legacy NIP-04 and historical NIP-17 conversations,
|
||||
then receive a live NIP-17 message; verify all conversations are present.
|
||||
|
||||
### Stage gate
|
||||
|
||||
Record baseline results before any optimization. The harness itself must have a
|
||||
test proving it does not initialize unless its explicit local debug flag is
|
||||
set.
|
||||
|
||||
## Stage 1 — Make localhost relay monitoring explicit — complete
|
||||
|
||||
### Change
|
||||
|
||||
Change `js/devReqMonitor.js` so it only installs when
|
||||
`localStorage.__bitvidReqMonitor__ === "1"` on a loopback host. Do not
|
||||
auto-enable merely because the host is localhost, and never permit the monitor
|
||||
on a public host.
|
||||
|
||||
### Why
|
||||
|
||||
It patches every WebSocket send, parses REQ frames, and has a two-second timer.
|
||||
It is useful during a relay-storm investigation but should not distort ordinary
|
||||
local performance testing.
|
||||
|
||||
### Tests and acceptance
|
||||
|
||||
- With the flag off: no WebSocket patch and no monitoring timer.
|
||||
- With the flag on: existing relay-storm warnings still identify REQ kinds.
|
||||
- No production host can enable it without the explicit local flag.
|
||||
- Compare idle local CPU/long-task baseline before and after.
|
||||
|
||||
Implemented with `isRequestMonitorEnabled()` / `installRequestMonitor()` and
|
||||
regression coverage for disabled localhost, enabled loopback, and public-host
|
||||
rejection. To use it during a targeted local relay investigation, set
|
||||
`localStorage.__bitvidReqMonitor__ = "1"` and reload; remove the key or set it
|
||||
to any other value for normal profiling.
|
||||
|
||||
### Rollback
|
||||
|
||||
One isolated commit; restoring the previous activation condition restores the
|
||||
old diagnostic behavior.
|
||||
|
||||
## Stage 2 — Lazy recommendation indexes
|
||||
|
||||
Implemented: `ExploreDataService` is no longer created during
|
||||
ordinary bootstrap. Home/Profile sessions therefore create no recommendation
|
||||
worker or recommendation intervals. Opening For You or Explore creates and
|
||||
activates the service; events received while inactive mark it dirty and are
|
||||
included in the activation refresh. Concurrent history/IDF signals share one
|
||||
running rebuild and retain one forced follow-up pass. Focused lifecycle,
|
||||
feed-coordinator, production-build, and browser navigation checks pass; manual
|
||||
For You/Explore validation confirmed normal recommendations and responsive Home.
|
||||
|
||||
### Change
|
||||
|
||||
Do not initialize `ExploreDataService` globally in application bootstrap.
|
||||
Start it when Explore or For You is first opened, and stop/pause its intervals
|
||||
when neither view is active or the document is hidden.
|
||||
|
||||
Keep Nostr ingestion active. Incoming videos, watch-history changes, follows,
|
||||
zaps, preference changes, and relevant moderation changes mark the associated
|
||||
index stale. Refresh the index immediately when its feed is visible, or just
|
||||
before rendering it.
|
||||
|
||||
Use a small lifecycle coordinator with these states: `not-created`, `idle`,
|
||||
`dirty`, `refreshing`, and `paused-hidden`. A refresh request while refreshing
|
||||
sets one follow-up dirty bit rather than starting another full worker message.
|
||||
Snapshot only the minimal fields required by the worker and record snapshot
|
||||
size in the local-only harness.
|
||||
|
||||
### Why
|
||||
|
||||
The current worker receives cloned video/history data on a one-minute and
|
||||
five-minute cadence even when the viewer is using Home, Profile, or a modal.
|
||||
|
||||
### Tests and acceptance
|
||||
|
||||
- Home/Profile-only session creates no Explore worker or recommendation
|
||||
intervals.
|
||||
- Opening Explore or For You initializes the worker once and produces the same
|
||||
ranking inputs as today.
|
||||
- A new relevant note, follow, watch event, zap, or moderation update is
|
||||
reflected on the next active-feed refresh.
|
||||
- Backgrounding the tab pauses the intervals; restoring it performs one bounded
|
||||
catch-up refresh.
|
||||
- Existing For You tiering, Trending, and recommendation tests remain green.
|
||||
- Timer, visibility, and event triggers coalesce into at most one active and
|
||||
one follow-up index calculation per index.
|
||||
|
||||
### Rollback
|
||||
|
||||
Feature-scoped lifecycle change behind a small coordinator boundary; restore
|
||||
eager initialization if feed freshness regresses.
|
||||
|
||||
## Stage 3 — Bound source-health work to visible cards
|
||||
|
||||
**Progress:** first lifecycle-disposal slice shipped in `687f8c43`; queue
|
||||
ownership, cancellation, and abortable active probes remain.
|
||||
|
||||
### Audit findings (2026-07-26)
|
||||
|
||||
- `createCardObserver` retains an `IntersectionObserver` per grid container but
|
||||
has no disposal API. `VideoListView.render()` replaces card DOM without
|
||||
disconnecting the old observer, so detached cards can stay observed.
|
||||
- `gridHealth` has a globally bounded, priority-ordered torrent queue, but its
|
||||
queued jobs have no owner/cancellation handle. A discarded card can therefore
|
||||
still start a real tracker/WebRTC probe.
|
||||
- URL health uses an independent observer and video-probe wait queue with the
|
||||
same missing ownership boundary. Its fetch timeout aborts only on timeout,
|
||||
not when the card/grid disappears.
|
||||
|
||||
Implementation order: add disposable observer registrations and call them from
|
||||
grid replacement/unmount; associate queued jobs with card owners and prune jobs
|
||||
when their last owner disappears; finally pass an abort signal into active URL
|
||||
and torrent probes. Cache/in-flight deduplication stays shared so one visible
|
||||
card never cancels work still needed by another visible card.
|
||||
|
||||
### Change
|
||||
|
||||
Keep the existing viewport/preload observer and current two-at-a-time torrent
|
||||
queue. Add an abort/cancellation token through URL probes and queue pruning for
|
||||
WebTorrent work when cards leave the feed or its container is discarded. Hidden
|
||||
cards that must be verified before reveal receive a small, observable per-feed
|
||||
budget rather than an unbounded eager batch. Preserve deduplication by
|
||||
event/source and separate positive/negative TTLs.
|
||||
|
||||
Do not probe every alternate mirror eagerly. Probe the primary source first and
|
||||
only test an alternate when the primary is unavailable or playback needs it.
|
||||
|
||||
### Why
|
||||
|
||||
Media-element probes and WebTorrent tracker probes are useful for accurate
|
||||
source badges and fallback, but are among the most expensive background tasks.
|
||||
|
||||
### Tests and acceptance
|
||||
|
||||
- Scrolling a large feed never exceeds the configured URL/torrent concurrency
|
||||
limit.
|
||||
- Off-screen and removed cards do not start or retain probes.
|
||||
- Cached results render badges without a second request.
|
||||
- Dead URL still falls back to a working mirror or WebTorrent source.
|
||||
- Closing a feed/modal tears down pending health work.
|
||||
- Hidden-card verification is bounded and still reveals cards whose source
|
||||
proves healthy.
|
||||
|
||||
### Rollback
|
||||
|
||||
Keep the existing probe implementation available behind the observer boundary
|
||||
until playback and badge tests pass on Chromium and Firefox.
|
||||
|
||||
## Stage 4 — Reduce playback visual overhead
|
||||
|
||||
### Change
|
||||
|
||||
First trace whether the video-modal ambient canvas is actually wired into the
|
||||
current modal path, then profile it. If it appears in the hot path,
|
||||
replace the free-running animation loop with `requestVideoFrameCallback` where
|
||||
available, a capped fallback cadence, and automatic disablement for
|
||||
`prefers-reduced-motion` or a low-power preference.
|
||||
|
||||
The effect must exist only while the modal video is actively playing and be
|
||||
fully torn down on pause, close, source change, and hidden document.
|
||||
|
||||
### Tests and acceptance
|
||||
|
||||
- Hosted and torrent playback remain visually correct.
|
||||
- Pause/close/source-change cancel all animation callbacks and observers.
|
||||
- Background tabs make no canvas draws.
|
||||
- Reduced-motion mode does not attach the visual effect.
|
||||
- Compare renderer/GPU CPU while playing the same short clip before and after.
|
||||
|
||||
### Rollback
|
||||
|
||||
Keep the visual treatment behind a feature/config gate until manual playback QA
|
||||
is complete.
|
||||
|
||||
## Stage 5 — Service-worker and image-cache cadence
|
||||
|
||||
### Change
|
||||
|
||||
- Keep service-worker registration, but avoid forcing a full update check on
|
||||
every application boot. Use normal browser update cadence or a bounded
|
||||
version/age policy.
|
||||
- Add an image-cache freshness window so a cached external image is served
|
||||
immediately and only revalidated when stale or needed by a visible card.
|
||||
|
||||
### Tests and acceptance
|
||||
|
||||
- A fresh deploy still activates normally without a hard refresh.
|
||||
- Rollback deploy still works with hashed assets and normal reload.
|
||||
- Cached thumbnails/avatars paint immediately.
|
||||
- Warm feed loads do not revalidate every external image at once.
|
||||
- Image cache remains bounded and recovers safely from quota failures.
|
||||
|
||||
### Rollback
|
||||
|
||||
Keep cache format/version semantics intact; any regression can revert the
|
||||
freshness policy without deleting user-visible image caching.
|
||||
|
||||
## Stage 6 — Bundle and feature activation review
|
||||
|
||||
### Change
|
||||
|
||||
Start with the WebTorrent implementation, because it is currently imported by
|
||||
the facade at module evaluation time. Review other heavy feature modules only
|
||||
from measured cold-load transfer/parse evidence. Dynamically import at first
|
||||
use where safe: profile management, DMs, upload, torrent playback, and
|
||||
feature-specific Nostr helpers. Do not defer the active feed, authentication,
|
||||
or basic playback path in a way that creates visible delay.
|
||||
|
||||
### Tests and acceptance
|
||||
|
||||
- Initial Home load has fewer transferred/parsed bytes.
|
||||
- Opening each deferred UI initializes once and still works offline/static-hosted.
|
||||
- No feature code is fetched when its flag is off.
|
||||
- Build artifact, cache, E2E, and visual tests pass.
|
||||
|
||||
## Stage 7 — Cache budget and feed-refresh coalescing
|
||||
|
||||
**Progress:** recommendation-index refresh coalescing shipped in `5f439d68`;
|
||||
the event-cache budget and full same-feed refresh coordinator remain.
|
||||
|
||||
### Change
|
||||
|
||||
After Stage 0 records real cache growth, add explicit event-cache record and
|
||||
byte budgets plus category-aware eviction. Protect currently active feed items,
|
||||
the newest active version of an event, local/user-owned content, recent watch
|
||||
history pointers, and moderation state needed for the current viewer. Evict
|
||||
older, unreferenced relay data first. In the feed coordinator, coalesce
|
||||
same-feed refresh requests and record the initiating reason, without delaying
|
||||
an explicit user refresh.
|
||||
|
||||
### Why
|
||||
|
||||
The cache is already incremental at write time, but its full-map snapshot and
|
||||
restore behavior can become expensive as a long-lived relay session grows.
|
||||
Coalescing protects against several valid live signals causing redundant
|
||||
render/rank passes together.
|
||||
|
||||
### Tests and acceptance
|
||||
|
||||
- Seeded oversized caches restore only the protected/bounded record set and
|
||||
issue normal scoped relay fetches for missing items.
|
||||
- Feed data, deletions, replacements, moderation decisions, and recent watch
|
||||
history produce the same visible result before and after eviction.
|
||||
- A burst of equivalent refresh signals produces one running refresh plus, at
|
||||
most, one follow-up; an explicit refresh is never swallowed.
|
||||
- IndexedDB and localStorage fallback behavior remain covered.
|
||||
|
||||
### Rollback
|
||||
|
||||
Version cache metadata and keep the current restore format readable. Reverting
|
||||
the budget must not erase cached user data.
|
||||
|
||||
## Release procedure
|
||||
|
||||
Each stage ships independently. The recommended order is Stage 0, Stage 1,
|
||||
Stage 2, Stage 3, Stage 7, Stage 5, then the measurement-dependent visual and
|
||||
bundle work (Stages 4 and 6):
|
||||
|
||||
1. Capture baseline and define the expected improvement.
|
||||
2. Make one focused implementation commit.
|
||||
3. Run the stage's targeted tests plus relevant browser QA.
|
||||
4. Compare measurement results to the baseline.
|
||||
5. Run `npm run build` and the applicable design checks.
|
||||
6. Promote only when no feed freshness, playback, moderation, or cache
|
||||
regression is observed.
|
||||
|
||||
Do not combine stages in one commit. A failed stage is reverted independently
|
||||
without discarding validated improvements from earlier stages.
|
||||
@@ -94,8 +94,133 @@ sequenceDiagram
|
||||
end
|
||||
```
|
||||
|
||||
## HLS (.m3u8) sources
|
||||
|
||||
Hosted sources whose path ends in `.m3u8` are HLS playlists and cannot be
|
||||
assigned to `videoEl.src` on every browser. `js/services/hlsPlayback.js` owns
|
||||
the decision and the hls.js lifecycle.
|
||||
|
||||
**Engine choice.** hls.js (MSE) wins whenever `MediaSource` can back it; native
|
||||
playback is used only when MSE is unavailable. Do NOT branch on `canPlayType`
|
||||
alone — measured behavior differs sharply:
|
||||
|
||||
| Browser | `canPlayType("application/vnd.apple.mpegurl")` | bare `<video src=…m3u8>` |
|
||||
| --- | --- | --- |
|
||||
| Firefox 146 | `""` | `MEDIA_ERR_SRC_NOT_SUPPORTED` (code 4) |
|
||||
| Chrome 149 | `"maybe"` | plays natively |
|
||||
|
||||
Before this existed, Firefox reported `No playable source found.` for every HLS
|
||||
note (and the URL-health badge showed `❌ CDN`), while Chrome happened to work.
|
||||
|
||||
**Wiring.**
|
||||
|
||||
- `js/services/playbackService.js` — `attemptHostedPlayback` routes HLS
|
||||
candidates through `attachHlsSource()`; `resetVideoElement()` calls
|
||||
`detachHls()` before clearing `src`.
|
||||
- `js/app/modalCoordinator.js` — `teardownVideoElement` calls `detachHls()`
|
||||
first; otherwise the transmuxer worker and segment fetches outlive the modal.
|
||||
- `js/ui/urlHealthController.js` — probes HLS by fetching the playlist and
|
||||
checking for `#EXTM3U` instead of using a bare `<video>` (which cannot load it
|
||||
and, on Chrome, emits no events at all — burning the whole probe timeout).
|
||||
- `HLS_PLAYBACK_START_TIMEOUT` (`js/constants.js`) widens the 3s start window,
|
||||
which fires mid-startup for HLS: the bundle, master playlist, variant
|
||||
playlist, init segment and first media segment are serial round trips.
|
||||
|
||||
**Vendoring.** `npm run build:hls` regenerates `vendor/hls.bundle.min.js` from
|
||||
the pinned devDependency. It is the FULL hls.js build, not `hls.js/light` —
|
||||
light drops the alternate-audio stream controller, and real ladders (e.g.
|
||||
nostube/slidestr) demux audio into its own `EXT-X-MEDIA` rendition group. The
|
||||
bundle is lazy-imported only when an HLS URL is encountered, so ordinary
|
||||
MP4/WebTorrent playback never pays for it.
|
||||
|
||||
**Rollback.** Stop calling `attachHlsSource()` from `playbackService.js` /
|
||||
`playbackCoordinator.js` and the previous `videoEl.src = url` behavior returns;
|
||||
revert the `urlHealthController.js` branch to restore the old element probe.
|
||||
Nothing else imports `hlsPlayback.js`, and the vendored bundle is inert once
|
||||
unreferenced.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- The playback session is responsible for cleanup and emits events (`status`, `sourcechange`, `error`, `finished`) that `bitvidApp` listens to for UI updates.
|
||||
- `bitvidApp.playVideoWithFallback` keeps the current video card and modal state in sync with the normalized magnet values so copy actions and stats reflect the actual playback payload.
|
||||
|
||||
|
||||
## Link previews (NIP-92 imeta + per-video OpenGraph)
|
||||
|
||||
Two independent surfaces make a shared bitvid video render richly.
|
||||
|
||||
**1. Inside nostr clients — `js/nostrEventSchemas.js`.**
|
||||
`buildShareEvent()` appends a NIP-92 `imeta` entry describing the thumbnail URL
|
||||
that the note body ends with. Many clients only inline such a URL when an imeta
|
||||
entry backs it; without it the viewer sees a bare link. The media type is
|
||||
inferred from the extension (`inferImageMimeTypeFromUrl`, `js/utils/mime.js`)
|
||||
and OMITTED when unknown — a wrong `m` is worse than a missing one, because
|
||||
clients use it to decide whether to render inline at all. Extensionless
|
||||
Blossom-style URLs therefore ship `url` + `alt` only.
|
||||
|
||||
**2. Everywhere else — `api/og.js` + `api/_lib/ogRenderer.mjs`.**
|
||||
`index.html` carries one set of site-wide OG tags, so every `/?v=<nevent>` link
|
||||
previewed as the same generic card. Crawlers don't run JS, so this is rendered
|
||||
server-side on demand.
|
||||
|
||||
- **Nothing is pre-generated or stored.** One stateless function serves all
|
||||
videos; the video is a query param, not a path. `dist/` is byte-identical
|
||||
with or without it. The only caching is the CDN edge (`s-maxage=300`) plus a
|
||||
per-container in-memory memo of the admin lists.
|
||||
- **Never load-bearing.** Only crawler user-agents are rewritten to it
|
||||
(`vercel.json`), humans get the untouched SPA, and a static/nsite deploy with
|
||||
no functions keeps working — it just serves the site-wide card. Rollback is
|
||||
deleting the `rewrites` block.
|
||||
- **Player card, not just an image.** It emits `twitter:player` / `og:video:url`
|
||||
pointing at `embed.html?pointer=<nevent>`, so a shared link plays inline on
|
||||
Twitter/Discord/Telegram. `frame-ancestors *` (`_headers`) already permits it.
|
||||
- **Fails closed.** A crawler never authenticates, so anything emitted is public
|
||||
forever in third-party caches. `isPubliclyShareable()` requires an
|
||||
affirmatively public video: `deleted`, `isPrivate`, `isNsfw`/`content-warning`,
|
||||
blacklisted authors, and — while whitelist mode is on — an unavailable or
|
||||
empty whitelist all fall back to the generic card.
|
||||
- **Both event shapes.** `parseVideoEvent()` reads bitvid's kind-30078 v3 JSON
|
||||
AND the NIP-71 tag shape (kinds 21/22/34235/34236) that nostube/slidestr
|
||||
publish, where the title lives in a `title` tag and the thumbnail in an
|
||||
`imeta image` entry.
|
||||
- **Timeouts.** A cold pool's first relay read measured ~3s, so the budget is
|
||||
4s (Twitter allows ~5s); warm containers answer in <600ms because the admin
|
||||
lists are memoized for 5 minutes.
|
||||
|
||||
Config is imported from `js/config.js`, `js/nostr/toolkit.js` and
|
||||
`js/nostrEventSchemas.js` rather than duplicated, and `tests/og-renderer.test.mjs`
|
||||
fails if that regresses to hardcoded literals.
|
||||
|
||||
### Quoting the NIP-71 mirror
|
||||
|
||||
When a video has a NIP-71 mirror, the share note embeds `nostr:naddr1…` for it.
|
||||
Nostr clients render that 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 OG endpoint above mainly serves Twitter/Discord/Telegram).
|
||||
|
||||
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 — a plain
|
||||
`nevent` would rot on the next replace.
|
||||
|
||||
Rules that keep this from looking worse than the plain link:
|
||||
|
||||
- **Verify before pointing.** `ShareNostrController.resolveMirrorPointer()`
|
||||
confirms the mirror exists via `nip71MirrorService.findMirror()` before
|
||||
embedding anything. A pointer to a missing event renders as a broken/empty
|
||||
quote box. Every failure path — service disabled, lookup throws, lookup times
|
||||
out (2.5s), no `videoRootId` — falls back to the thumbnail-URL note.
|
||||
- **Use the observed kind.** `resolveMirrorKind()` prefers the kinds actually
|
||||
seen on relays over the portrait heuristic. Only when nothing was observed does
|
||||
it fall back to the SAME rule the mirror builder uses (`nip71Mirror.js`:
|
||||
`hasDims && height > width`), so a derived pointer matches what would be
|
||||
published rather than guessing independently.
|
||||
- **Never emit both images.** 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 keeping both rendered the same image twice.
|
||||
- An `a` tag (`kind:pubkey:d`) links the note to the quoted coordinate for
|
||||
quote-aware clients and indexers.
|
||||
|
||||
Known limitation: a client rendering the quote card still needs to play the
|
||||
video, and mirrors whose `imeta url` is an `.m3u8` hit the same HLS wall bitvid
|
||||
did — the thumbnail and title render, playback depends on the client.
|
||||
@@ -38,7 +38,7 @@ Bootstrapping is intentionally defensive so the UI can initialize quickly:
|
||||
- **Access control hydration**: `waitForAccessControl()` races `accessControl.ensureReady()` against a 3.5s timeout. If hydration times out, bootstrapping logs a dev warning and falls back to defaults.
|
||||
- **Immediate apply**: Seeds are applied immediately after the first hydration attempt, even if the admin list hydration failed, so moderation does not block on relay availability.
|
||||
- **Retry when relays are ready**: If hydration failed, `bootstrapTrustedSeeds()` checks for relay readiness via `nostrService.nostrClient.relays` (and pool existence). If relays become available within 3.5s, it retries admin list hydration once. If relays never become ready, it logs a dev warning and re-applies the current seeds to keep moderation functional.
|
||||
- **Change listeners**: `accessControl.onWhitelistChange()` and `accessControl.onEditorsChange()` are registered so any subsequent admin/editor updates re-run seed application and recompute moderation summaries.
|
||||
- **Change listeners**: `accessControl.onEditorsChange()` and `accessControl.onBlacklistChange()` are registered so changes affecting the moderator roster or eligibility re-run seed application and recompute moderation summaries. Creator-whitelist changes do not affect trust seeds.
|
||||
|
||||
## Relay list persistence and refresh
|
||||
|
||||
|
||||
+3
-3
@@ -374,8 +374,8 @@
|
||||
|
||||
<!-- Tagline fade matches the cards. -->
|
||||
|
||||
<div class="fade-in fade-in-delay-300 mb-8 text-center">
|
||||
<h2 class="text-2xl font-bold tracking-wide text-muted-strong">
|
||||
<div class="site-tagline fade-in fade-in-delay-300 mb-8 text-center">
|
||||
<h2 class="site-tagline__text text-2xl font-bold tracking-wide text-muted-strong">
|
||||
seed. zap. subscribe.
|
||||
</h2>
|
||||
</div>
|
||||
@@ -386,7 +386,7 @@
|
||||
<!-- Mobile Search FAB -->
|
||||
<button
|
||||
id="mobileSearchFab"
|
||||
class="focus-ring fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-accent-strong shadow-lg transition-transform duration-200 hover:scale-105 active:scale-95 md:hidden"
|
||||
class="mobile-safe-bottom focus-ring fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-accent-strong shadow-lg transition-transform duration-200 hover:scale-105 active:scale-95 md:hidden"
|
||||
aria-label="Open search"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
normalizeVideoNotePayload,
|
||||
} from "./services/videoNotePayload.js";
|
||||
import getAuthProvider, {
|
||||
providers as authProviders,
|
||||
providersForModal as authProviders,
|
||||
} from "./services/authProviders/index.js";
|
||||
import hashtagPreferences, {
|
||||
HASHTAG_PREFERENCES_EVENTS,
|
||||
@@ -735,6 +735,7 @@ class Application {
|
||||
ModerationActionController,
|
||||
setModerationOverride,
|
||||
clearModerationOverride,
|
||||
clearAuthorModerationOverride,
|
||||
userBlocks,
|
||||
buildVideoAddressPointer,
|
||||
VIDEO_EVENT_KIND,
|
||||
|
||||
@@ -176,6 +176,22 @@ export function createAuthSessionCoordinator(deps) {
|
||||
|
||||
return {
|
||||
async handleAuthLogin(detail = {}) {
|
||||
const requestedPubkey =
|
||||
this.normalizeHexPubkey?.(detail?.pubkey || this.pubkey) ||
|
||||
detail?.pubkey ||
|
||||
this.pubkey ||
|
||||
"";
|
||||
const inFlightLogin = this._authLoginSync;
|
||||
if (
|
||||
requestedPubkey &&
|
||||
inFlightLogin?.pubkey === requestedPubkey &&
|
||||
inFlightLogin?.promise
|
||||
) {
|
||||
userLogger.info("[auth-login-duplicate-ignored]", {
|
||||
pubkey: requestedPubkey,
|
||||
});
|
||||
return inFlightLogin.promise;
|
||||
}
|
||||
const authLoginStart = now();
|
||||
userLogger.info("[auth-login-start]", {
|
||||
pubkey: detail?.pubkey || this.pubkey || null,
|
||||
@@ -232,7 +248,18 @@ export function createAuthSessionCoordinator(deps) {
|
||||
}
|
||||
|
||||
this.applyAuthenticatedUiState();
|
||||
this.commentController?.refreshAuthState?.();
|
||||
const activeModalVideo =
|
||||
typeof this.videoModal?.getCurrentVideo === "function"
|
||||
? this.videoModal.getCurrentVideo()
|
||||
: this.commentController?.currentVideo || null;
|
||||
if (this.commentController && activeModalVideo) {
|
||||
// Login can reconfigure relay-backed services. Reload the active thread
|
||||
// instead of only enabling its composer so readable comments survive
|
||||
// the transition from guest to authenticated state.
|
||||
this.commentController.load(activeModalVideo);
|
||||
} else {
|
||||
this.commentController?.refreshAuthState?.();
|
||||
}
|
||||
this.updateShareNostrAuthState({ reason: "auth-login" });
|
||||
if (typeof this.refreshUnreadDmIndicator === "function") {
|
||||
void this.refreshUnreadDmIndicator({ reason: "auth-login" });
|
||||
@@ -710,6 +737,20 @@ export function createAuthSessionCoordinator(deps) {
|
||||
return listSyncDetail;
|
||||
});
|
||||
|
||||
// A provider/widget can repeat its success event while the first login is
|
||||
// still hydrating lists. The identity is already active, so replaying the
|
||||
// full lifecycle only doubles relay work and grid refreshes. Join the
|
||||
// original lifecycle until its required list sync settles instead.
|
||||
const authLoginSync = Promise.resolve(listStatePromise).finally(() => {
|
||||
if (this._authLoginSync?.promise === authLoginSync) {
|
||||
this._authLoginSync = null;
|
||||
}
|
||||
});
|
||||
this._authLoginSync = {
|
||||
pubkey: activePubkey || requestedPubkey,
|
||||
promise: authLoginSync,
|
||||
};
|
||||
|
||||
// DMs are intentionally NOT eagerly loaded at login. A cold DM load
|
||||
// (limit: 50) is a burst of nip-07 decrypts that competes with the
|
||||
// feed-driving lists (blocks/subscriptions/hashtags) for the SAME
|
||||
|
||||
@@ -1014,6 +1014,27 @@ export function createFeedCoordinator(deps) {
|
||||
devLogger.log(`Starting loadFeedVideos(${feedType})... (forceFetch =`, forceFetch, ")");
|
||||
this.setFeedTelemetryContext(feedType);
|
||||
|
||||
const recommendationFeed =
|
||||
feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.EXPLORE;
|
||||
let exploreActivation = null;
|
||||
if (recommendationFeed && typeof this.ensureExploreDataService === "function") {
|
||||
// Recommendation indexes are an enhancement, not a prerequisite for
|
||||
// showing cached videos. Activating the worker here used to leave a
|
||||
// returning For You view stuck on its static loading markup whenever
|
||||
// worker startup was slow or unavailable.
|
||||
exploreActivation = Promise.resolve(this.ensureExploreDataService()).catch(
|
||||
(error) => {
|
||||
devLogger.warn(
|
||||
`[Application] Recommendation data activation failed for ${feedType}:`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
} else if (this.exploreDataService?.setActive) {
|
||||
this.exploreDataService.setActive(false);
|
||||
}
|
||||
|
||||
let includeTags = false;
|
||||
let loadingMessage = "Fetching videos\u2026";
|
||||
let refreshMethod = null;
|
||||
@@ -1109,6 +1130,23 @@ export function createFeedCoordinator(deps) {
|
||||
if (this.videoListView) {
|
||||
this.videoListView.state.videosMap = this.videosMap;
|
||||
}
|
||||
|
||||
// Re-score once the optional recommendation indexes are ready, but only
|
||||
// if the user is still looking at this feed. The first render above is
|
||||
// deliberately cache-first and must not wait for worker startup.
|
||||
if (exploreActivation) {
|
||||
void exploreActivation.then(() => {
|
||||
if (!this.isFeedActive(feedType)) {
|
||||
return;
|
||||
}
|
||||
return this.refreshFeed(feedType, { reason: "recommendation-data-ready" });
|
||||
}).catch((error) => {
|
||||
devLogger.warn(
|
||||
`[Application] Failed to refresh ${feedType} after recommendation data activation:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
/**
|
||||
* Modal open/close lifecycle and controller routing.
|
||||
*
|
||||
* All module-level dependencies are injected from the Application
|
||||
* composition root rather than imported at module scope.
|
||||
* Stateful collaborators (clients, services, app state) are injected from the
|
||||
* Application composition root rather than imported at module scope. Pure,
|
||||
* stateless helper modules are imported directly, as in the other coordinators.
|
||||
*
|
||||
* Methods use `this` which is bound to the Application instance.
|
||||
*/
|
||||
import { detachHls } from "../services/hlsPlayback.js";
|
||||
|
||||
/**
|
||||
* @param {object} deps - Injected dependencies.
|
||||
@@ -562,6 +564,11 @@ export function createModalCoordinator(deps) {
|
||||
`[teardownVideoElement] Resetting video (replaceNode=${replaceNode}) readyState=${videoElement.readyState} networkState=${videoElement.networkState} src=${describeSource()}`
|
||||
);
|
||||
|
||||
// Destroy any hls.js instance FIRST. It owns a MediaSource, a transmuxing
|
||||
// worker and in-flight segment fetches; clearing `src` without destroying
|
||||
// it leaves those running (and downloading) after the modal is closed.
|
||||
safe(() => detachHls(videoElement));
|
||||
|
||||
safe(() => videoElement.pause());
|
||||
|
||||
safe(() => {
|
||||
|
||||
@@ -19,6 +19,7 @@ export function createModerationCoordinator(deps) {
|
||||
ModerationActionController,
|
||||
setModerationOverride,
|
||||
clearModerationOverride,
|
||||
clearAuthorModerationOverride,
|
||||
userBlocks,
|
||||
buildVideoAddressPointer,
|
||||
VIDEO_EVENT_KIND,
|
||||
@@ -210,6 +211,7 @@ export function createModerationCoordinator(deps) {
|
||||
services: {
|
||||
setModerationOverride,
|
||||
clearModerationOverride,
|
||||
clearAuthorModerationOverride,
|
||||
userBlocks,
|
||||
},
|
||||
selectors: {
|
||||
|
||||
@@ -3,11 +3,17 @@
|
||||
/**
|
||||
* URL-first + magnet fallback playback pipeline.
|
||||
*
|
||||
* All module-level dependencies are injected from the Application
|
||||
* composition root rather than imported at module scope.
|
||||
* Stateful collaborators (clients, services, app state) are injected from the
|
||||
* Application composition root rather than imported at module scope. Pure,
|
||||
* stateless helper modules are imported directly, as in the other coordinators.
|
||||
*
|
||||
* Methods use `this` which is bound to the Application instance.
|
||||
*/
|
||||
import {
|
||||
attachHlsSource,
|
||||
detachHls,
|
||||
isHlsUrl,
|
||||
} from "../services/hlsPlayback.js";
|
||||
|
||||
/**
|
||||
* Normalize a deep-link / caller "force this playback source" value to the
|
||||
@@ -197,7 +203,41 @@ export function createPlaybackCoordinator(deps) {
|
||||
if (!base) {
|
||||
return "";
|
||||
}
|
||||
return `${base}?v=${encodeURIComponent(nevent)}`;
|
||||
// getShareUrlBase() strips the trailing slash, so add the path separator
|
||||
// back. `https://bitvid.network?v=...` is legal and browsers normalize
|
||||
// it, but it reads as a typo in a shared note and some link parsers
|
||||
// mis-detect where the URL ends.
|
||||
return `${base}/?v=${encodeURIComponent(nevent)}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Relay hints to embed in a shared pointer.
|
||||
*
|
||||
* Without these an `nevent` is just a bare id: a viewer whose relay set
|
||||
* doesn't happen to carry the event cannot resolve it, and bitvid falls
|
||||
* back to the home feed — the "shared link just opens bitvid.network"
|
||||
* report. Capped to a few because the hints inflate every shared URL.
|
||||
*/
|
||||
getSharePointerRelays() {
|
||||
const candidates = Array.isArray(nostrClient?.writeRelays)
|
||||
? nostrClient.writeRelays
|
||||
: Array.isArray(nostrClient?.relays)
|
||||
? nostrClient.relays
|
||||
: [];
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const entry of candidates) {
|
||||
const url = typeof entry === "string" ? entry.trim() : "";
|
||||
if (!url || !/^wss?:\/\//i.test(url) || seen.has(url)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
if (out.length >= 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
buildShareUrlFromEventId(eventId) {
|
||||
@@ -206,7 +246,24 @@ export function createPlaybackCoordinator(deps) {
|
||||
}
|
||||
|
||||
try {
|
||||
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
|
||||
const pointer = { id: eventId };
|
||||
|
||||
// Author + relay hints make the pointer resolvable by someone who does
|
||||
// not already follow this creator. The author also lets clients look
|
||||
// the event up by author+kind if the id itself has aged out.
|
||||
const known = this.videosMap?.get?.(eventId);
|
||||
const author =
|
||||
typeof known?.pubkey === "string" ? known.pubkey.trim() : "";
|
||||
if (/^[0-9a-f]{64}$/i.test(author)) {
|
||||
pointer.author = author.toLowerCase();
|
||||
}
|
||||
|
||||
const relays = this.getSharePointerRelays();
|
||||
if (relays.length) {
|
||||
pointer.relays = relays;
|
||||
}
|
||||
|
||||
const nevent = window.NostrTools.nip19.neventEncode(pointer);
|
||||
return this.buildShareUrlFromNevent(nevent);
|
||||
} catch (err) {
|
||||
devLogger.error("Error generating nevent for share URL:", err);
|
||||
@@ -237,7 +294,7 @@ export function createPlaybackCoordinator(deps) {
|
||||
this.log("Autoplay failed:", err);
|
||||
if (!this.modalVideo.muted) {
|
||||
this.log("Falling back to muted autoplay.");
|
||||
this.modalVideo.muted = true;
|
||||
this.playbackService.muteForAutoplayFallback(this.modalVideo);
|
||||
this.modalVideo.play().catch((err2) => {
|
||||
this.log("Muted autoplay also failed:", err2);
|
||||
});
|
||||
@@ -355,7 +412,23 @@ export function createPlaybackCoordinator(deps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
target.src = sanitizedUrl;
|
||||
// HLS playlists cannot be assigned to `src` outside Safari; route them
|
||||
// through hls.js/MSE (see js/services/hlsPlayback.js).
|
||||
if (isHlsUrl(sanitizedUrl)) {
|
||||
const handle = await attachHlsSource(target, sanitizedUrl, {
|
||||
log: (...args) => devLogger.log(...args),
|
||||
});
|
||||
if (!handle) {
|
||||
devLogger.warn(
|
||||
"[playHttp] HLS source could not be attached:",
|
||||
sanitizedUrl
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
detachHls(target);
|
||||
target.src = sanitizedUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
await target.play();
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
FEATURE_NIP71_INGEST,
|
||||
FEATURE_AUDIO_INGEST,
|
||||
FEATURE_BITCOIN_CONNECT,
|
||||
FEATURE_BITLOGIN,
|
||||
FEATURE_BLOSSOM_STORAGE,
|
||||
FEATURE_BLOSSOM_TORRENT_METADATA,
|
||||
CARD_LIVENESS_POLICY,
|
||||
@@ -127,6 +128,7 @@ export { ALLOW_NSFW_CONTENT };
|
||||
export { FEATURE_NIP71_INGEST };
|
||||
export { FEATURE_AUDIO_INGEST };
|
||||
export { FEATURE_BITCOIN_CONNECT };
|
||||
export { FEATURE_BITLOGIN };
|
||||
export { FEATURE_BLOSSOM_STORAGE };
|
||||
export { FEATURE_BLOSSOM_TORRENT_METADATA };
|
||||
export { CARD_LIVENESS_POLICY };
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FEATURE_NIP71_INGEST as CONFIG_FEATURE_NIP71_INGEST,
|
||||
FEATURE_AUDIO_INGEST as CONFIG_FEATURE_AUDIO_INGEST,
|
||||
FEATURE_BITCOIN_CONNECT as CONFIG_FEATURE_BITCOIN_CONNECT,
|
||||
FEATURE_BITLOGIN as CONFIG_FEATURE_BITLOGIN,
|
||||
FEATURE_BLOSSOM_STORAGE as CONFIG_FEATURE_BLOSSOM_STORAGE,
|
||||
FEATURE_BLOSSOM_TORRENT_METADATA as CONFIG_FEATURE_BLOSSOM_TORRENT_METADATA,
|
||||
CARD_LIVENESS_POLICY as CONFIG_CARD_LIVENESS_POLICY,
|
||||
@@ -188,6 +189,17 @@ export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD =
|
||||
export const DEFAULT_TRUST_SEED_NPUBS = SANITIZED_DEFAULT_TRUST_SEED_NPUBS;
|
||||
export const PLAYBACK_START_TIMEOUT = SANITIZED_DEFAULT_PLAYBACK_START_TIMEOUT;
|
||||
|
||||
// HLS needs a bigger start window than a progressive MP4. Before the first
|
||||
// frame can render, hls.js must lazy-load its (vendored) bundle, fetch the
|
||||
// master playlist, fetch the chosen variant playlist, then fetch the init
|
||||
// segment and the first media segment — four or more serial round trips versus
|
||||
// the single ranged GET a plain MP4 needs. The 3s default fires mid-startup and
|
||||
// reports a perfectly healthy stream as "No playable source found."
|
||||
export const HLS_PLAYBACK_START_TIMEOUT = Math.max(
|
||||
SANITIZED_DEFAULT_PLAYBACK_START_TIMEOUT,
|
||||
15000
|
||||
);
|
||||
|
||||
const DEFAULT_FLAGS = Object.freeze({
|
||||
URL_FIRST_ENABLED: DEFAULT_PLAYBACK_SOURCE !== "torrent", // try URL before magnet in the player
|
||||
FEATURE_WATCH_HISTORY_V2: true,
|
||||
@@ -205,6 +217,9 @@ const DEFAULT_FLAGS = Object.freeze({
|
||||
// Bitcoin Connect NWC "Connect wallet" flow (docs/bitcoin-connect-plan.md, #61).
|
||||
// Default OFF; only an explicit `true` in the instance config enables it.
|
||||
FEATURE_BITCOIN_CONNECT: CONFIG_FEATURE_BITCOIN_CONNECT === true,
|
||||
// BitLogin primary sign-in method (js/ui/bitloginModalIntegration.js).
|
||||
// Default OFF; only an explicit `true` in the instance config enables it.
|
||||
FEATURE_BITLOGIN: CONFIG_FEATURE_BITLOGIN === true,
|
||||
// Blossom (nostr-native blob storage) provider (docs/blossom-plan.md, #30).
|
||||
// Default OFF; only an explicit `true` in the instance config enables it.
|
||||
FEATURE_BLOSSOM_STORAGE: CONFIG_FEATURE_BLOSSOM_STORAGE === true,
|
||||
@@ -241,6 +256,7 @@ const runtimeFlags = (() => {
|
||||
FEATURE_NIP71_INGEST: DEFAULT_FLAGS.FEATURE_NIP71_INGEST,
|
||||
FEATURE_AUDIO_INGEST: DEFAULT_FLAGS.FEATURE_AUDIO_INGEST,
|
||||
FEATURE_BITCOIN_CONNECT: DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT,
|
||||
FEATURE_BITLOGIN: DEFAULT_FLAGS.FEATURE_BITLOGIN,
|
||||
FEATURE_BLOSSOM_STORAGE: DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE,
|
||||
FEATURE_BLOSSOM_TORRENT_METADATA:
|
||||
DEFAULT_FLAGS.FEATURE_BLOSSOM_TORRENT_METADATA,
|
||||
@@ -299,6 +315,11 @@ export let FEATURE_BITCOIN_CONNECT = coerceBoolean(
|
||||
DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT
|
||||
);
|
||||
|
||||
export let FEATURE_BITLOGIN = coerceBoolean(
|
||||
runtimeFlags.FEATURE_BITLOGIN,
|
||||
DEFAULT_FLAGS.FEATURE_BITLOGIN
|
||||
);
|
||||
|
||||
export let FEATURE_BLOSSOM_STORAGE = coerceBoolean(
|
||||
runtimeFlags.FEATURE_BLOSSOM_STORAGE,
|
||||
DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE
|
||||
|
||||
+79
-44
@@ -1,69 +1,104 @@
|
||||
// Dev-only relay REQ-rate monitor.
|
||||
// Local-only relay REQ-rate monitor.
|
||||
//
|
||||
// Patches WebSocket.send to count outgoing Nostr REQ frames and, every 2s,
|
||||
// console.warns when the rate exceeds a threshold — naming the storming filter
|
||||
// kinds. This runs inside the real browser session against real relays/lists,
|
||||
// which the headless harness cannot fully reproduce. It is purely observational
|
||||
// and only activates on localhost (or when `localStorage.__bitvidReqMonitor__`
|
||||
// is set), so production is unaffected.
|
||||
//
|
||||
// Remove this import from index.js once the login-perf work is done.
|
||||
// It patches WebSocket.send for a real-session relay-storm investigation, so
|
||||
// normal localhost development must opt in explicitly. Public deployments can
|
||||
// never enable it, even if a storage key is present.
|
||||
|
||||
(function installReqMonitor() {
|
||||
if (typeof window === "undefined" || typeof WebSocket === "undefined") return;
|
||||
export const REQUEST_MONITOR_STORAGE_KEY = "__bitvidReqMonitor__";
|
||||
|
||||
const enabled =
|
||||
location.hostname === "localhost" ||
|
||||
location.hostname === "127.0.0.1" ||
|
||||
(() => {
|
||||
try {
|
||||
return localStorage.getItem("__bitvidReqMonitor__") === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
if (!enabled || window.__bitvidReqMonitorInstalled) return;
|
||||
window.__bitvidReqMonitorInstalled = true;
|
||||
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
const THRESHOLD = 15;
|
||||
const WINDOW_MS = 2000;
|
||||
|
||||
function readStorage(storage) {
|
||||
try {
|
||||
return storage?.getItem?.(REQUEST_MONITOR_STORAGE_KEY) ?? null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRequestMonitorEnabled({ locationRef, storage } = {}) {
|
||||
const hostname = typeof locationRef?.hostname === "string"
|
||||
? locationRef.hostname.toLowerCase()
|
||||
: "";
|
||||
return LOCAL_HOSTS.has(hostname) && readStorage(storage) === "1";
|
||||
}
|
||||
|
||||
export function installRequestMonitor({
|
||||
scope = typeof window !== "undefined" ? window : null,
|
||||
locationRef = scope?.location,
|
||||
storage = scope?.localStorage,
|
||||
WebSocketClass = scope?.WebSocket,
|
||||
setIntervalFn = scope?.setInterval?.bind(scope),
|
||||
clearIntervalFn = scope?.clearInterval?.bind(scope),
|
||||
logger = scope?.console || console,
|
||||
} = {}) {
|
||||
if (
|
||||
!scope ||
|
||||
!WebSocketClass?.prototype ||
|
||||
typeof WebSocketClass.prototype.send !== "function" ||
|
||||
typeof setIntervalFn !== "function" ||
|
||||
!isRequestMonitorEnabled({ locationRef, storage })
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (scope.__bitvidReqMonitorInstalled) {
|
||||
return scope.__bitvidReqMonitorInstalled;
|
||||
}
|
||||
|
||||
const THRESHOLD = 15; // REQ/s above this is a storm worth reporting
|
||||
const WINDOW_MS = 2000;
|
||||
let windowReqs = 0;
|
||||
const byKind = new Map();
|
||||
const originalSend = WebSocketClass.prototype.send;
|
||||
|
||||
const origSend = WebSocket.prototype.send;
|
||||
WebSocket.prototype.send = function (data) {
|
||||
WebSocketClass.prototype.send = function monitoredSend(data) {
|
||||
try {
|
||||
if (typeof data === "string" && data.startsWith('["REQ"')) {
|
||||
windowReqs += 1;
|
||||
const msg = JSON.parse(data);
|
||||
for (const f of msg.slice(2)) {
|
||||
const k = Array.isArray(f?.kinds) ? `kind ${f.kinds.join("/")}` : "kind ?";
|
||||
byKind.set(k, (byKind.get(k) || 0) + 1);
|
||||
const message = JSON.parse(data);
|
||||
for (const filter of message.slice(2)) {
|
||||
const kind = Array.isArray(filter?.kinds)
|
||||
? `kind ${filter.kinds.join("/")}`
|
||||
: "kind ?";
|
||||
byKind.set(kind, (byKind.get(kind) || 0) + 1);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
} catch (_) {
|
||||
// Diagnostics must never affect a WebSocket send.
|
||||
}
|
||||
return origSend.apply(this, arguments);
|
||||
return originalSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
setInterval(() => {
|
||||
const perSec = (windowReqs / WINDOW_MS) * 1000;
|
||||
if (perSec >= THRESHOLD) {
|
||||
const intervalId = setIntervalFn(() => {
|
||||
const perSecond = (windowReqs / WINDOW_MS) * 1000;
|
||||
if (perSecond >= THRESHOLD) {
|
||||
const top = [...byKind.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 6)
|
||||
.map(([k, n]) => `${k}=${n}`)
|
||||
.map(([kind, count]) => `${kind}=${count}`)
|
||||
.join(", ");
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[req-monitor] RELAY STORM ${perSec.toFixed(0)} REQ/s over ${WINDOW_MS / 1000}s → ${top}`,
|
||||
logger?.warn?.(
|
||||
`[req-monitor] RELAY STORM ${perSecond.toFixed(0)} REQ/s over ${WINDOW_MS / 1000}s → ${top}`,
|
||||
);
|
||||
}
|
||||
windowReqs = 0;
|
||||
byKind.clear();
|
||||
}, WINDOW_MS);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.info("[req-monitor] active — will warn if relay REQ rate spikes.");
|
||||
})();
|
||||
const monitor = {
|
||||
stop() {
|
||||
WebSocketClass.prototype.send = originalSend;
|
||||
if (typeof clearIntervalFn === "function") {
|
||||
clearIntervalFn(intervalId);
|
||||
}
|
||||
if (scope.__bitvidReqMonitorInstalled === monitor) {
|
||||
delete scope.__bitvidReqMonitorInstalled;
|
||||
}
|
||||
},
|
||||
};
|
||||
scope.__bitvidReqMonitorInstalled = monitor;
|
||||
logger?.info?.("[req-monitor] active — will warn if relay REQ rate spikes.");
|
||||
return monitor;
|
||||
}
|
||||
|
||||
installRequestMonitor();
|
||||
+29
-1
@@ -26,6 +26,7 @@ export function createCardObserver(options = {}) {
|
||||
prepareEntries,
|
||||
onCardVisible,
|
||||
onCardRegister,
|
||||
onCardUnregister,
|
||||
createState,
|
||||
} = options;
|
||||
|
||||
@@ -37,7 +38,9 @@ export function createCardObserver(options = {}) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const observedCards = new WeakSet();
|
||||
// A Set is intentional here: disposal must enumerate every card so the
|
||||
// browser observer cannot retain detached feed nodes after a rerender.
|
||||
const observedCards = new Set();
|
||||
const customState = normalizeState(
|
||||
typeof createState === "function" ? createState(container) : {}
|
||||
);
|
||||
@@ -201,9 +204,34 @@ export function createCardObserver(options = {}) {
|
||||
return state ? state.customState : null;
|
||||
}
|
||||
|
||||
function disconnect(container) {
|
||||
const state = containerState.get(container);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const card of state.observedCards) {
|
||||
if (typeof onCardUnregister === "function") {
|
||||
try {
|
||||
onCardUnregister({
|
||||
card,
|
||||
state: state.customState,
|
||||
context: { container: state.container, observer: state.observer },
|
||||
});
|
||||
} catch (err) {
|
||||
userLogger.warn("[cardObserver] onCardUnregister failed", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.observedCards.clear();
|
||||
state.observer.disconnect();
|
||||
containerState.delete(container);
|
||||
}
|
||||
|
||||
return {
|
||||
observe,
|
||||
refresh,
|
||||
getState,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
} from "./sorters.js";
|
||||
export {
|
||||
createActiveNostrSource,
|
||||
createWhitelistedAuthorsNostrSource,
|
||||
createSubscriptionAuthorsSource,
|
||||
createWatchHistoryPointerSource,
|
||||
} from "./sources.js";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// js/feedEngine/mostZappedFeed.js
|
||||
//
|
||||
// The "Most Zapped" tab (#47): the recently-added (active) source ranked by
|
||||
// ZAP TOTAL (sats from kind-9735 receipts). Mirrors trendingFeed.js — lives
|
||||
// outside feedCoordinator.js (size cap) and is wired in via thin delegations.
|
||||
// The "Most Zapped" tab (#47): the catalog of approved creators ranked by
|
||||
// ZAP TOTAL (sats from kind-9735 receipts). It is intentionally scoped to the
|
||||
// creator whitelist for discovery, but still runs the ordinary moderation
|
||||
// stages — whitelisting does not waive Web-of-Trust handling.
|
||||
// The metric getter uses requestVideoZapTotal, which returns the cached total
|
||||
// AND schedules a batched receipt fetch for unknown pointers; the Most Zapped
|
||||
// view re-runs the feed on zapTotals' change signal so the order settles as
|
||||
@@ -11,7 +12,7 @@
|
||||
import { FEED_TYPES } from "../constants.js";
|
||||
import { requestVideoZapTotal } from "../zapTotals.js";
|
||||
import {
|
||||
createActiveNostrSource,
|
||||
createWhitelistedAuthorsNostrSource,
|
||||
createBlacklistFilterStage,
|
||||
createDedupeByRootStage,
|
||||
createModerationStage,
|
||||
@@ -43,6 +44,19 @@ function makeGetZapTotal(app) {
|
||||
};
|
||||
}
|
||||
|
||||
function getWhitelistedAuthors(app) {
|
||||
const accessControl = app?.nostrService?.accessControl;
|
||||
if (typeof accessControl?.getWhitelistPubkeys !== "function") {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const authors = accessControl.getWhitelistPubkeys();
|
||||
return Array.isArray(authors) ? authors : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMostZappedFeedRuntime(app) {
|
||||
const blacklist =
|
||||
app.blacklistedEventIds instanceof Set
|
||||
@@ -59,6 +73,7 @@ export function buildMostZappedFeedRuntime(app) {
|
||||
? { ...moderationThresholds }
|
||||
: undefined,
|
||||
getZapTotal: makeGetZapTotal(app),
|
||||
whitelistedAuthors: getWhitelistedAuthors(app),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,7 +109,7 @@ export function registerMostZappedFeed(app) {
|
||||
|
||||
try {
|
||||
return app.feedEngine.registerFeed(FEED_TYPES.MOST_ZAPPED, {
|
||||
source: createActiveNostrSource({ service: app.nostrService }),
|
||||
source: createWhitelistedAuthorsNostrSource({ service: app.nostrService }),
|
||||
stages: [
|
||||
createBlacklistFilterStage({
|
||||
shouldIncludeVideo: (video, options) =>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { markAsNormalized } from "./utils.js";
|
||||
// re-runs the feed on the metric cache's debounced change signal).
|
||||
// - Trending (#27): runtime.getViewCount (viewCounter cache)
|
||||
// - Most Zapped (#47): runtime.getZapTotal (zapTotals cache)
|
||||
function createCountRankedSorter(resolveMetric) {
|
||||
function createCountRankedSorter(resolveMetric, { spreadByAuthor = true } = {}) {
|
||||
return function countRankedSorter(items = [], context = {}) {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
@@ -54,9 +54,12 @@ function createCountRankedSorter(resolveMetric) {
|
||||
|
||||
const live = items.filter((entry) => !isMuted(entry));
|
||||
const muted = items.filter((entry) => isMuted(entry));
|
||||
// Rank by the metric, then spread authors so the tab isn't walls of one
|
||||
// creator. The top-ranked item is still chosen first, so it stays topmost.
|
||||
return [...spreadAuthors(live.sort(compare)), ...muted.sort(compare)];
|
||||
const rankedLive = live.sort(compare);
|
||||
// Trending benefits from a little creator diversity. Most Zapped is a
|
||||
// literal leaderboard, so it must retain strict total ordering instead of
|
||||
// moving a larger zap total below a smaller one to avoid repeat authors.
|
||||
const orderedLive = spreadByAuthor ? spreadAuthors(rankedLive) : rankedLive;
|
||||
return [...orderedLive, ...muted.sort(compare)];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,7 +69,10 @@ export function createTrendingSorter() {
|
||||
|
||||
// "Most Zapped" (#47): same ranking mechanics over sats totals.
|
||||
export function createMostZappedSorter() {
|
||||
return createCountRankedSorter((context) => context?.runtime?.getZapTotal);
|
||||
return createCountRankedSorter(
|
||||
(context) => context?.runtime?.getZapTotal,
|
||||
{ spreadByAuthor: false },
|
||||
);
|
||||
}
|
||||
|
||||
// Round-robin interleave by author: one video per creator per pass, each pass in
|
||||
|
||||
@@ -62,6 +62,89 @@ export function createActiveNostrSource({ service } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// Bounded discovery source for curated surfaces such as Most Zapped. It never
|
||||
// falls back to an unscoped relay request: the caller must provide approved
|
||||
// creator pubkeys in runtime.whitelistedAuthors. The successful remote result
|
||||
// is retained briefly so an asynchronous ranking update does not turn into a
|
||||
// second full relay scan for the same author set.
|
||||
export function createWhitelistedAuthorsNostrSource({
|
||||
service,
|
||||
refreshTtlMs = 2 * 60 * 1000,
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
const resolvedService = resolveService(service, nostrService);
|
||||
let cachedAuthorsKey = "";
|
||||
let cachedVideos = [];
|
||||
let fetchedAt = 0;
|
||||
let inFlight = null;
|
||||
|
||||
const normalizeAuthors = (input) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
toArray(input)
|
||||
.map(normalizeAuthor)
|
||||
.filter(Boolean),
|
||||
),
|
||||
).sort();
|
||||
|
||||
return async function whitelistedAuthorsNostrSource(context = {}) {
|
||||
const authors = normalizeAuthors(context?.runtime?.whitelistedAuthors);
|
||||
const authorsKey = authors.join(",");
|
||||
if (!authors.length) {
|
||||
cachedAuthorsKey = "";
|
||||
cachedVideos = [];
|
||||
fetchedAt = 0;
|
||||
return [];
|
||||
}
|
||||
|
||||
const cacheIsCurrent =
|
||||
authorsKey === cachedAuthorsKey &&
|
||||
now() - fetchedAt < Math.max(0, Number(refreshTtlMs) || 0);
|
||||
|
||||
if (!cacheIsCurrent && !inFlight) {
|
||||
const fetchByAuthors = resolvedService?.fetchVideosByAuthors;
|
||||
inFlight = Promise.resolve(
|
||||
typeof fetchByAuthors === "function"
|
||||
? fetchByAuthors.call(resolvedService, authors)
|
||||
: [],
|
||||
)
|
||||
.then((videos) => {
|
||||
const allowed = new Set(authors);
|
||||
cachedAuthorsKey = authorsKey;
|
||||
cachedVideos = (Array.isArray(videos) ? videos : []).filter(
|
||||
(video) => allowed.has(normalizeAuthor(video?.pubkey)),
|
||||
);
|
||||
fetchedAt = now();
|
||||
return cachedVideos;
|
||||
})
|
||||
.catch((error) => {
|
||||
context?.log?.(
|
||||
"[whitelisted-authors-source] Failed to fetch approved creators",
|
||||
error,
|
||||
);
|
||||
return authorsKey === cachedAuthorsKey ? cachedVideos : [];
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
const videos = cacheIsCurrent ? cachedVideos : await inFlight;
|
||||
// The whitelist may change while a prior author-scoped request is still in
|
||||
// flight. Filter again at return time so that stale results can never
|
||||
// briefly render after an administrator removes a creator.
|
||||
const allowed = new Set(authors);
|
||||
return (Array.isArray(videos) ? videos : [])
|
||||
.filter((video) => allowed.has(normalizeAuthor(video?.pubkey)))
|
||||
.map((video) => ({
|
||||
video,
|
||||
metadata: {
|
||||
source: "nostr:whitelisted-authors",
|
||||
},
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export function createSubscriptionAuthorsSource({ service } = {}) {
|
||||
const resolvedService = resolveService(service, nostrService);
|
||||
|
||||
|
||||
@@ -1135,13 +1135,17 @@ export function createModerationStage({
|
||||
trustedMuteCategory,
|
||||
);
|
||||
|
||||
const blockAutoplay =
|
||||
trustedCount >= normalizedAutoplayThreshold || trustedMuted || viewerMuted;
|
||||
const adminWhitelist = adminStatus?.whitelisted === true;
|
||||
const adminWhitelistBypass = false;
|
||||
|
||||
// A trusted mute is a ranking signal, not a content verdict. It sinks an
|
||||
// author in feed sorters, but a single stale or bad-faith mute must not
|
||||
// blur a creator or stop playback. Only the report threshold, the
|
||||
// viewer's own mute, or the higher mute-hide threshold can do that.
|
||||
const blockAutoplay = trustedCount >= normalizedAutoplayThreshold || viewerMuted;
|
||||
const blurFromReports = trustedCount >= normalizedBlurThreshold;
|
||||
let blurThumbnail = blurFromReports;
|
||||
let blurReason = blurThumbnail ? "trusted-report" : "";
|
||||
const adminWhitelist = adminStatus?.whitelisted === true;
|
||||
const adminWhitelistBypass = false;
|
||||
|
||||
if (!item.metadata || typeof item.metadata !== "object") {
|
||||
item.metadata = {};
|
||||
@@ -1268,22 +1272,18 @@ export function createModerationStage({
|
||||
}
|
||||
}
|
||||
|
||||
if (!blurThumbnail && (viewerMuted || trustedMuted || hideTriggered)) {
|
||||
if (!blurThumbnail && (viewerMuted || hideTriggered)) {
|
||||
blurThumbnail = true;
|
||||
if (hideTriggered) {
|
||||
blurReason = hideReason || "trusted-hide";
|
||||
} else if (viewerMuted) {
|
||||
blurReason = "viewer-mute";
|
||||
} else if (trustedMuted) {
|
||||
blurReason = "trusted-mute";
|
||||
}
|
||||
} else if (blurThumbnail) {
|
||||
if (hideTriggered) {
|
||||
blurReason = hideReason || "trusted-hide";
|
||||
} else if (viewerMuted && !blurFromReports) {
|
||||
blurReason = "viewer-mute";
|
||||
} else if (trustedMuted && !blurFromReports) {
|
||||
blurReason = "trusted-mute";
|
||||
} else if (!blurReason && blurFromReports) {
|
||||
blurReason = "trusted-report";
|
||||
}
|
||||
|
||||
+2
-1
@@ -573,10 +573,11 @@ const gridCardObserver = createCardObserver({
|
||||
|
||||
export function attachHealthBadges(container, options = {}) {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
if (options && typeof options.onUpdate === "function") {
|
||||
badgeUpdateListeners.add(options.onUpdate);
|
||||
}
|
||||
gridCardObserver.observe(container);
|
||||
return () => gridCardObserver.disconnect(container);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { validateInstanceConfig } from "../config/validate-config.js";
|
||||
import "./bufferPolyfill.js";
|
||||
import "./devReqMonitor.js"; // dev-only relay REQ-rate monitor (localhost only)
|
||||
import { initPerformanceHarness } from "./performanceHarness.js";
|
||||
import {
|
||||
getApplication,
|
||||
setApplication,
|
||||
@@ -54,10 +55,15 @@ import { userBlocks } from "./userBlocks.js";
|
||||
import { relayManager } from "./relayManager.js";
|
||||
import createApplication from "./bootstrap.js";
|
||||
import SearchFilterModal from "./ui/components/SearchFilterModal.js";
|
||||
import { initializeSiteUpdateNotice } from "./ui/siteUpdateNotice.js";
|
||||
import { installTestHarness } from "./testHarness.js";
|
||||
|
||||
validateInstanceConfig();
|
||||
|
||||
// Local-only, explicit opt-in measurement. This is a no-op in production and
|
||||
// does not patch application APIs or change scheduling.
|
||||
initPerformanceHarness();
|
||||
|
||||
// Install test harness early so relay overrides apply before connections.
|
||||
// Only activates when ?__test__=1 or localStorage __bitvidTestMode__ is set.
|
||||
installTestHarness();
|
||||
@@ -1320,6 +1326,7 @@ async function bootstrapInterface() {
|
||||
}
|
||||
|
||||
async function initializeInterface() {
|
||||
initializeSiteUpdateNotice();
|
||||
startApplication();
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// js/nostr/adapters/bitloginAdapter.js
|
||||
//
|
||||
// Wraps a live <bitlogin-auth> element (already signed in) as a signer matching
|
||||
// the same shape as nip07Adapter/nsecAdapter/nip46Adapter. Never touches
|
||||
// window.nostr and never sees the private key -- every call is proxied through
|
||||
// the element's own crypto Web Worker (see BitLogin's README, "Embedding
|
||||
// BitLogin" -> element-scoped methods).
|
||||
|
||||
export function createBitloginAdapter(widgetElement, pubkey) {
|
||||
const signer = {
|
||||
type: "bitlogin",
|
||||
pubkey: typeof pubkey === "string" ? pubkey : "",
|
||||
metadata: async () => null,
|
||||
relays: async () => null,
|
||||
signEvent: async (event) => widgetElement.signEvent(event),
|
||||
nip44Encrypt: async (peerPubkey, plaintext) =>
|
||||
widgetElement.nip44Encrypt(peerPubkey, plaintext),
|
||||
nip44Decrypt: async (peerPubkey, payload) =>
|
||||
widgetElement.nip44Decrypt(peerPubkey, payload),
|
||||
nip04Encrypt: async (peerPubkey, plaintext) =>
|
||||
widgetElement.nip04Encrypt(peerPubkey, plaintext),
|
||||
nip04Decrypt: async (peerPubkey, payload) =>
|
||||
widgetElement.nip04Decrypt(peerPubkey, payload),
|
||||
requestPermissions: async () => ({ ok: true }),
|
||||
destroy: async () => {
|
||||
try {
|
||||
await widgetElement.logout();
|
||||
} catch (error) {
|
||||
// The element is permanently mounted and reused for the next sign-in;
|
||||
// a failed logout here shouldn't block switching to another signer.
|
||||
}
|
||||
},
|
||||
canSign: () => true,
|
||||
capabilities: {
|
||||
sign: true,
|
||||
nip44: true,
|
||||
nip04: true,
|
||||
},
|
||||
};
|
||||
|
||||
return signer;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Addressable pointers to a video's NIP-71 mirror.
|
||||
//
|
||||
// The mirror (js/nostr/nip71Mirror.js) is an ADDRESSABLE event: kind
|
||||
// 34235/34236, author = the video's pubkey, `d` = videoRootId. That makes its
|
||||
// coordinate fully derivable from a video object with no relay lookup, and the
|
||||
// pointer keeps resolving after the author edits the video (a plain `nevent`
|
||||
// would rot on the next replace).
|
||||
//
|
||||
// Used by the share flow so a shared note can embed `nostr:naddr1…`, which
|
||||
// nostr clients render as a native video quote card instead of a bare link.
|
||||
|
||||
import {
|
||||
NIP71_NORMAL_VIDEO_KIND,
|
||||
NIP71_SHORT_VIDEO_KIND,
|
||||
} from "./nip71Mirror.js";
|
||||
|
||||
export { NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND };
|
||||
|
||||
const str = (value) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
/**
|
||||
* Which mirror kind a video's pointer should address.
|
||||
*
|
||||
* Prefers `kinds` — the kinds actually observed on relays (nip71MirrorService
|
||||
* `findMirror`), which is ground truth. Only when that is unavailable does it
|
||||
* fall back to the SAME portrait heuristic the mirror builder uses
|
||||
* (nip71Mirror.js: `hasDims && height > width`), so a derived pointer matches
|
||||
* the event that would be published rather than guessing independently.
|
||||
*
|
||||
* Returns 0 when there is nothing to point at.
|
||||
*/
|
||||
export function resolveMirrorKind({ kinds = [], width, height } = {}) {
|
||||
const observed = (Array.isArray(kinds) ? kinds : []).filter(
|
||||
(kind) => kind === NIP71_NORMAL_VIDEO_KIND || kind === NIP71_SHORT_VIDEO_KIND
|
||||
);
|
||||
if (observed.length === 1) {
|
||||
return observed[0];
|
||||
}
|
||||
if (observed.length > 1) {
|
||||
// Cross-kind duplicate (nip71MirrorService flags this as `duplicate`).
|
||||
// Prefer the normal kind so the pointer is stable rather than arbitrary.
|
||||
return observed.includes(NIP71_NORMAL_VIDEO_KIND)
|
||||
? NIP71_NORMAL_VIDEO_KIND
|
||||
: observed[0];
|
||||
}
|
||||
|
||||
const w = Number(width);
|
||||
const h = Number(height);
|
||||
const hasDims = Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0;
|
||||
if (!hasDims) {
|
||||
return NIP71_NORMAL_VIDEO_KIND;
|
||||
}
|
||||
return h > w ? NIP71_SHORT_VIDEO_KIND : NIP71_NORMAL_VIDEO_KIND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `a`-tag coordinate (`kind:pubkey:d`) for a video's mirror.
|
||||
* Returns "" when the inputs cannot address anything.
|
||||
*/
|
||||
export function buildMirrorCoordinate({ pubkey, videoRootId, kind } = {}) {
|
||||
const author = str(pubkey);
|
||||
const identifier = str(videoRootId);
|
||||
const resolvedKind = Number(kind);
|
||||
if (!author || !identifier || !Number.isFinite(resolvedKind) || resolvedKind <= 0) {
|
||||
return "";
|
||||
}
|
||||
return `${resolvedKind}:${author}:${identifier}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build `naddr1…` for a video's mirror.
|
||||
*
|
||||
* `nip19` is injected so this stays testable without a browser; callers in the
|
||||
* app pass `window.NostrTools.nip19`. Returns "" on any failure — a share note
|
||||
* must never embed a malformed pointer, because clients render that as a broken
|
||||
* quote box, which is strictly worse than the plain-link fallback.
|
||||
*
|
||||
* Relay hints matter here: without them a client that doesn't already have the
|
||||
* mirror has nowhere to fetch it from, and the quote card renders empty.
|
||||
*/
|
||||
export function buildMirrorNaddr({
|
||||
pubkey,
|
||||
videoRootId,
|
||||
kind,
|
||||
relays = [],
|
||||
nip19,
|
||||
} = {}) {
|
||||
const author = str(pubkey);
|
||||
const identifier = str(videoRootId);
|
||||
const resolvedKind = Number(kind);
|
||||
if (!author || !identifier || !Number.isFinite(resolvedKind) || resolvedKind <= 0) {
|
||||
return "";
|
||||
}
|
||||
if (!nip19 || typeof nip19.naddrEncode !== "function") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const relayHints = (Array.isArray(relays) ? relays : [])
|
||||
.map((entry) => str(entry))
|
||||
.filter((entry) => /^wss?:\/\//i.test(entry))
|
||||
// A handful is plenty; an naddr carrying a user's whole relay list gets
|
||||
// unwieldy in note bodies and leaks more than it helps.
|
||||
.slice(0, 3);
|
||||
|
||||
try {
|
||||
return (
|
||||
nip19.naddrEncode({
|
||||
kind: resolvedKind,
|
||||
pubkey: author,
|
||||
identifier,
|
||||
relays: relayHints,
|
||||
}) || ""
|
||||
);
|
||||
} catch (err) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -1465,6 +1465,17 @@ function inferMimeTypeFromUrl(url) {
|
||||
// (kind 30078) can carry `imeta` variants with an `m <mime>` field just like
|
||||
// NIP-71 events; a podcast/music note advertises `m audio/*`. Mirrors the
|
||||
// video/audio split in nip71IngestAdapter.js so the native path stays consistent.
|
||||
// HLS playlist MIME types. `audio/mpegurl` / `audio/x-mpegurl` are LEGACY
|
||||
// aliases for an HLS playlist of any kind — they do NOT assert audio-only
|
||||
// content, and a publisher using one for a video ladder would otherwise be
|
||||
// misfiled as a podcast and dropped from the video feeds by
|
||||
// isAudioOnlyVideoObject(). A playlist is a container, so it votes for neither
|
||||
// side and we let the URL/`imeta m video/*` signals decide.
|
||||
// Kept inline (not imported from js/services/hlsPlayback.js) so this parser
|
||||
// stays free of any DOM/logger dependency — it also runs inside workers.
|
||||
const HLS_PLAYLIST_MIME_PATTERN =
|
||||
/^(?:application|audio|video)\/(?:x-|vnd\.apple\.)?mpegurl$/i;
|
||||
|
||||
function detectImetaMediaKinds(tags = []) {
|
||||
let hasVideo = false;
|
||||
let hasAudio = false;
|
||||
@@ -1482,6 +1493,9 @@ function detectImetaMediaKinds(tags = []) {
|
||||
continue;
|
||||
}
|
||||
const mime = trimmed.replace(/^m\s+/i, "").trim().toLowerCase();
|
||||
if (HLS_PLAYLIST_MIME_PATTERN.test(mime.split(";")[0].trim())) {
|
||||
continue;
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
hasVideo = true;
|
||||
} else if (mime.startsWith("audio/")) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
buildStoragePointerValue,
|
||||
deriveStoragePointerFromUrl,
|
||||
} from "./utils/storagePointer.js";
|
||||
import { inferImageMimeTypeFromUrl } from "./utils/mime.js";
|
||||
|
||||
/**
|
||||
* Enumeration of all supported Nostr event types in the application.
|
||||
@@ -1800,6 +1801,52 @@ export function buildShareEvent(params) {
|
||||
tags.push(["p", normalizedVideoPubkey, "", "mention"]);
|
||||
}
|
||||
|
||||
// Addressable coordinate of the video's NIP-71 mirror, when one exists. Lets
|
||||
// quote-aware clients and indexers link the share note to the video event
|
||||
// that its body quotes via `nostr:naddr1…`.
|
||||
const mirrorCoordinate =
|
||||
typeof video?.mirrorCoordinate === "string"
|
||||
? video.mirrorCoordinate.trim()
|
||||
: "";
|
||||
if (/^\d+:[0-9a-f]{64}:/i.test(mirrorCoordinate)) {
|
||||
tags.push(["a", mirrorCoordinate, "", "mention"]);
|
||||
}
|
||||
|
||||
// NIP-92 `imeta` for the thumbnail. The share content ends with the raw
|
||||
// thumbnail URL, and many clients only render such a URL inline when an
|
||||
// imeta entry describes it — without this the viewer just sees a bare link.
|
||||
// Only emitted for http(s) thumbnails: relative paths and data: URIs are
|
||||
// meaningless to a reader on another client.
|
||||
//
|
||||
// SKIPPED when the note quotes a mirror: the quote card already renders the
|
||||
// video with its own thumbnail, and the body carries no bare image URL to
|
||||
// describe. Emitting both showed the same image twice.
|
||||
const quotesMirror =
|
||||
typeof video?.mirrorNaddr === "string" && video.mirrorNaddr.trim() !== "";
|
||||
const thumbnailUrl =
|
||||
typeof video?.thumbnail === "string" ? video.thumbnail.trim() : "";
|
||||
if (!quotesMirror && /^https?:\/\//i.test(thumbnailUrl)) {
|
||||
const imeta = ["imeta", `url ${thumbnailUrl}`];
|
||||
|
||||
// Omit `m` entirely when the extension is unrecognized. A wrong media type
|
||||
// is worse than a missing one — clients use it to decide whether to render
|
||||
// the URL as an image at all.
|
||||
const mimeType = inferImageMimeTypeFromUrl(thumbnailUrl);
|
||||
if (mimeType) {
|
||||
imeta.push(`m ${mimeType}`);
|
||||
}
|
||||
|
||||
// Alt text for screen readers, and a caption fallback in clients that show
|
||||
// it. Newlines would corrupt the space-delimited imeta entry, so collapse.
|
||||
const altText =
|
||||
typeof video?.title === "string" ? video.title.replace(/\s+/g, " ").trim() : "";
|
||||
if (altText) {
|
||||
imeta.push(`alt ${altText}`);
|
||||
}
|
||||
|
||||
tags.push(imeta);
|
||||
}
|
||||
|
||||
const seenRelayTags = new Set();
|
||||
|
||||
const pushRelayTag = (url, marker) => {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Local-only performance measurement harness.
|
||||
//
|
||||
// This intentionally observes rather than changes application behavior. It is
|
||||
// available only on a loopback host *and* after an explicit localStorage opt-in,
|
||||
// so a production visitor cannot accidentally enable it.
|
||||
|
||||
const STORAGE_KEY = "__bitvidPerformanceHarness__";
|
||||
const GLOBAL_KEY = "__bitvidPerformance";
|
||||
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
const MAX_LONG_TASKS = 200;
|
||||
const MAX_MARKS = 200;
|
||||
|
||||
function readStorage(storage, key) {
|
||||
try {
|
||||
return storage?.getItem?.(key) ?? null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function finiteNumber(value, fallback = 0) {
|
||||
return Number.isFinite(value) ? Number(value) : fallback;
|
||||
}
|
||||
|
||||
function normalizeMarkName(name) {
|
||||
return typeof name === "string" ? name.trim().slice(0, 120) : "";
|
||||
}
|
||||
|
||||
export function isLocalPerformanceHarnessEnabled({
|
||||
locationRef = typeof location !== "undefined" ? location : null,
|
||||
storage = typeof localStorage !== "undefined" ? localStorage : null,
|
||||
} = {}) {
|
||||
const hostname = typeof locationRef?.hostname === "string"
|
||||
? locationRef.hostname.toLowerCase()
|
||||
: "";
|
||||
return LOCAL_HOSTS.has(hostname) && readStorage(storage, STORAGE_KEY) === "1";
|
||||
}
|
||||
|
||||
export class PerformanceHarness {
|
||||
constructor({ scope, performanceRef, PerformanceObserverClass } = {}) {
|
||||
this.scope = scope || null;
|
||||
this.performance = performanceRef || this.scope?.performance || null;
|
||||
this.PerformanceObserverClass =
|
||||
PerformanceObserverClass || this.scope?.PerformanceObserver || null;
|
||||
this.startedAt = 0;
|
||||
this.longTasks = [];
|
||||
this.marks = [];
|
||||
this.gauges = new Map();
|
||||
this.longTaskObserver = null;
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started) {
|
||||
return this;
|
||||
}
|
||||
this.started = true;
|
||||
this.startedAt = this.now();
|
||||
this.observeLongTasks();
|
||||
this.mark("harness-start");
|
||||
if (this.scope) {
|
||||
this.scope[GLOBAL_KEY] = this.publicApi();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
stop() {
|
||||
try {
|
||||
this.longTaskObserver?.disconnect?.();
|
||||
} catch (_) {
|
||||
// Diagnostics must never affect application teardown.
|
||||
}
|
||||
this.longTaskObserver = null;
|
||||
this.started = false;
|
||||
if (this.scope?.[GLOBAL_KEY]) {
|
||||
delete this.scope[GLOBAL_KEY];
|
||||
}
|
||||
}
|
||||
|
||||
now() {
|
||||
return finiteNumber(this.performance?.now?.(), Date.now());
|
||||
}
|
||||
|
||||
observeLongTasks() {
|
||||
if (typeof this.PerformanceObserverClass !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const observer = new this.PerformanceObserverClass((list) => {
|
||||
const entries = list?.getEntries?.() || [];
|
||||
for (const entry of entries) {
|
||||
this.recordLongTask(entry);
|
||||
}
|
||||
});
|
||||
observer.observe({ type: "longtask", buffered: true });
|
||||
this.longTaskObserver = observer;
|
||||
} catch (_) {
|
||||
// Long Task timing is optional and unsupported in several browsers.
|
||||
}
|
||||
}
|
||||
|
||||
recordLongTask(entry) {
|
||||
const task = {
|
||||
startTime: finiteNumber(entry?.startTime),
|
||||
duration: finiteNumber(entry?.duration),
|
||||
name: typeof entry?.name === "string" ? entry.name : "longtask",
|
||||
};
|
||||
this.longTasks.push(task);
|
||||
if (this.longTasks.length > MAX_LONG_TASKS) {
|
||||
this.longTasks.splice(0, this.longTasks.length - MAX_LONG_TASKS);
|
||||
}
|
||||
}
|
||||
|
||||
mark(name, detail = {}) {
|
||||
const normalizedName = normalizeMarkName(name);
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
const entry = {
|
||||
name: normalizedName,
|
||||
at: this.now(),
|
||||
detail: detail && typeof detail === "object" ? { ...detail } : {},
|
||||
};
|
||||
this.marks.push(entry);
|
||||
if (this.marks.length > MAX_MARKS) {
|
||||
this.marks.splice(0, this.marks.length - MAX_MARKS);
|
||||
}
|
||||
try {
|
||||
this.performance?.mark?.(`bitvid:${normalizedName}`);
|
||||
} catch (_) {
|
||||
// User Timing is also optional; retain the local mark either way.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
setGauge(name, value) {
|
||||
const normalizedName = normalizeMarkName(name);
|
||||
if (!normalizedName || !Number.isFinite(value)) {
|
||||
return false;
|
||||
}
|
||||
this.gauges.set(normalizedName, Number(value));
|
||||
return true;
|
||||
}
|
||||
|
||||
increment(name, amount = 1) {
|
||||
const normalizedName = normalizeMarkName(name);
|
||||
if (!normalizedName || !Number.isFinite(amount)) {
|
||||
return false;
|
||||
}
|
||||
return this.setGauge(normalizedName, (this.gauges.get(normalizedName) || 0) + amount);
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
const resources = this.performance?.getEntriesByType?.("resource") || [];
|
||||
const navigation = this.performance?.getEntriesByType?.("navigation")?.[0] || null;
|
||||
const memory = this.performance?.memory;
|
||||
const longTaskDurationMs = this.longTasks.reduce(
|
||||
(total, task) => total + finiteNumber(task.duration),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
enabled: this.started,
|
||||
startedAt: this.startedAt,
|
||||
elapsedMs: Math.max(0, this.now() - this.startedAt),
|
||||
longTasks: {
|
||||
count: this.longTasks.length,
|
||||
totalDurationMs: longTaskDurationMs,
|
||||
recent: this.longTasks.slice(-20),
|
||||
},
|
||||
marks: this.marks.slice(-50),
|
||||
gauges: Object.fromEntries(this.gauges.entries()),
|
||||
resources: {
|
||||
count: resources.length,
|
||||
transferSize: resources.reduce(
|
||||
(total, entry) => total + finiteNumber(entry?.transferSize),
|
||||
0,
|
||||
),
|
||||
},
|
||||
navigation: navigation
|
||||
? {
|
||||
domContentLoadedMs: finiteNumber(navigation.domContentLoadedEventEnd),
|
||||
loadEventMs: finiteNumber(navigation.loadEventEnd),
|
||||
}
|
||||
: null,
|
||||
memory: memory
|
||||
? {
|
||||
usedJsHeapSize: finiteNumber(memory.usedJSHeapSize),
|
||||
totalJsHeapSize: finiteNumber(memory.totalJSHeapSize),
|
||||
jsHeapSizeLimit: finiteNumber(memory.jsHeapSizeLimit),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.longTasks.length = 0;
|
||||
this.marks.length = 0;
|
||||
this.gauges.clear();
|
||||
this.mark("harness-reset");
|
||||
}
|
||||
|
||||
publicApi() {
|
||||
return Object.freeze({
|
||||
snapshot: () => this.snapshot(),
|
||||
mark: (name, detail) => this.mark(name, detail),
|
||||
setGauge: (name, value) => this.setGauge(name, value),
|
||||
increment: (name, amount) => this.increment(name, amount),
|
||||
reset: () => this.reset(),
|
||||
stop: () => this.stop(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initPerformanceHarness({
|
||||
scope = typeof window !== "undefined" ? window : null,
|
||||
locationRef = scope?.location,
|
||||
storage = scope?.localStorage,
|
||||
performanceRef = scope?.performance,
|
||||
PerformanceObserverClass = scope?.PerformanceObserver,
|
||||
} = {}) {
|
||||
if (!scope || !isLocalPerformanceHarnessEnabled({ locationRef, storage })) {
|
||||
return null;
|
||||
}
|
||||
if (scope[GLOBAL_KEY]) {
|
||||
return scope[GLOBAL_KEY];
|
||||
}
|
||||
const harness = new PerformanceHarness({
|
||||
scope,
|
||||
performanceRef,
|
||||
PerformanceObserverClass,
|
||||
});
|
||||
harness.start();
|
||||
return scope[GLOBAL_KEY];
|
||||
}
|
||||
|
||||
export const PERFORMANCE_HARNESS_STORAGE_KEY = STORAGE_KEY;
|
||||
+94
-38
@@ -19,11 +19,21 @@ function clonePointerDescriptor(pointer) {
|
||||
value:
|
||||
typeof pointer.value === "string" && pointer.value.trim()
|
||||
? pointer.value.trim()
|
||||
: "",
|
||||
: ""
|
||||
};
|
||||
if (typeof pointer.relay === "string" && pointer.relay.trim()) {
|
||||
descriptor.relay = pointer.relay.trim();
|
||||
}
|
||||
const eventId =
|
||||
typeof pointer.eventId === "string" && pointer.eventId.trim()
|
||||
? pointer.eventId.trim()
|
||||
: typeof pointer.pointerEventId === "string" &&
|
||||
pointer.pointerEventId.trim()
|
||||
? pointer.pointerEventId.trim()
|
||||
: "";
|
||||
if (eventId) {
|
||||
descriptor.eventId = eventId;
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
@@ -36,7 +46,16 @@ function canonicalizePointerForState(pointerInput) {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const descriptor = clonePointerDescriptor(normalized);
|
||||
const descriptor = clonePointerDescriptor({
|
||||
...normalized,
|
||||
eventId:
|
||||
typeof pointerInput?.eventId === "string" && pointerInput.eventId.trim()
|
||||
? pointerInput.eventId
|
||||
: typeof pointerInput?.pointerEventId === "string" &&
|
||||
pointerInput.pointerEventId.trim()
|
||||
? pointerInput.pointerEventId
|
||||
: ""
|
||||
});
|
||||
return descriptor ? { key, pointer: descriptor } : null;
|
||||
}
|
||||
|
||||
@@ -50,7 +69,7 @@ function ensurePointerState(key, pointer) {
|
||||
lastUpdatedAt: 0,
|
||||
latestCreatedAt: 0,
|
||||
lastSyncedAt: 0,
|
||||
hydrated: false,
|
||||
hydrated: false
|
||||
};
|
||||
pointerStates.set(key, state);
|
||||
return state;
|
||||
@@ -59,8 +78,16 @@ function ensurePointerState(key, pointer) {
|
||||
const descriptor = clonePointerDescriptor(pointer);
|
||||
if (!state.pointer) {
|
||||
state.pointer = descriptor;
|
||||
} else if (descriptor?.relay && !state.pointer.relay) {
|
||||
state.pointer = { ...state.pointer, relay: descriptor.relay };
|
||||
} else {
|
||||
state.pointer = {
|
||||
...state.pointer,
|
||||
...(descriptor?.relay && !state.pointer.relay
|
||||
? { relay: descriptor.relay }
|
||||
: {}),
|
||||
...(descriptor?.eventId && !state.pointer.eventId
|
||||
? { eventId: descriptor.eventId }
|
||||
: {})
|
||||
};
|
||||
}
|
||||
}
|
||||
return state;
|
||||
@@ -174,7 +201,10 @@ function applyReactionToState(key, pointer, event) {
|
||||
state.lastUpdatedAt = Date.now();
|
||||
if (Number.isFinite(normalized.created_at)) {
|
||||
const created = Math.max(0, Math.floor(normalized.created_at));
|
||||
if (!Number.isFinite(state.latestCreatedAt) || created > state.latestCreatedAt) {
|
||||
if (
|
||||
!Number.isFinite(state.latestCreatedAt) ||
|
||||
created > state.latestCreatedAt
|
||||
) {
|
||||
state.latestCreatedAt = created;
|
||||
}
|
||||
}
|
||||
@@ -198,7 +228,7 @@ function snapshotState(state) {
|
||||
reactions[pubkey] = {
|
||||
content: record.content,
|
||||
created_at: record.created_at,
|
||||
eventId: record.eventId,
|
||||
eventId: record.eventId
|
||||
};
|
||||
}
|
||||
const latestCreatedAt = Number.isFinite(state.latestCreatedAt)
|
||||
@@ -212,7 +242,7 @@ function snapshotState(state) {
|
||||
lastUpdatedAt: state.lastUpdatedAt,
|
||||
latestCreatedAt,
|
||||
lastSyncedAt: state.lastSyncedAt,
|
||||
hydrated: Boolean(state.hydrated),
|
||||
hydrated: Boolean(state.hydrated)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -271,7 +301,7 @@ function subscribeToPointer(pointerInput, handler) {
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[reactionCounter] Failed to hydrate reaction history:",
|
||||
error,
|
||||
error
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -299,7 +329,7 @@ function subscribeToPointer(pointerInput, handler) {
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[reactionCounter] Failed to hydrate reaction history after pool init:",
|
||||
error,
|
||||
error
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -314,7 +344,7 @@ function subscribeToPointer(pointerInput, handler) {
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[reactionCounter] Failed to initialize nostr pool for reactions:",
|
||||
error,
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -372,12 +402,19 @@ function ingestLocalReaction({ event, pointer }) {
|
||||
if (!canonical) {
|
||||
return;
|
||||
}
|
||||
const changed = applyReactionToState(canonical.key, canonical.pointer, event);
|
||||
const changed = applyReactionToState(
|
||||
canonical.key,
|
||||
canonical.pointer,
|
||||
event
|
||||
);
|
||||
if (changed) {
|
||||
notifyHandlers(canonical.key);
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn("[reactionCounter] Failed to ingest local reaction:", error);
|
||||
userLogger.warn(
|
||||
"[reactionCounter] Failed to ingest local reaction:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +483,7 @@ function gatherRelayCandidates(pointer) {
|
||||
return relays;
|
||||
}
|
||||
|
||||
function buildReactionFilters(pointer, { limit, since } = {}) {
|
||||
export function buildReactionFilters(pointer, { limit, since } = {}) {
|
||||
if (!pointer || typeof pointer !== "object") {
|
||||
return [];
|
||||
}
|
||||
@@ -460,23 +497,29 @@ function buildReactionFilters(pointer, { limit, since } = {}) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filter = { kinds: [REACTION_EVENT_KIND] };
|
||||
const withBounds = (filter) => {
|
||||
if (Number.isFinite(limit) && limit > 0) {
|
||||
filter.limit = Math.max(1, Math.floor(limit));
|
||||
}
|
||||
if (Number.isFinite(since) && since >= 0) {
|
||||
filter.since = Math.max(0, Math.floor(since));
|
||||
}
|
||||
return filter;
|
||||
};
|
||||
|
||||
if (type === "a") {
|
||||
filter["#a"] = [value];
|
||||
} else {
|
||||
filter["#e"] = [value];
|
||||
if (type === "e") {
|
||||
return [withBounds({ kinds: [REACTION_EVENT_KIND], "#e": [value] })];
|
||||
}
|
||||
|
||||
if (Number.isFinite(limit) && limit > 0) {
|
||||
filter.limit = Math.max(1, Math.floor(limit));
|
||||
const filters = [withBounds({ kinds: [REACTION_EVENT_KIND], "#a": [value] })];
|
||||
const eventId =
|
||||
typeof pointer.eventId === "string" && pointer.eventId.trim()
|
||||
? pointer.eventId.trim()
|
||||
: "";
|
||||
if (eventId) {
|
||||
filters.push(withBounds({ kinds: [REACTION_EVENT_KIND], "#e": [eventId] }));
|
||||
}
|
||||
|
||||
if (Number.isFinite(since) && since >= 0) {
|
||||
filter.since = Math.max(0, Math.floor(since));
|
||||
}
|
||||
|
||||
return [filter];
|
||||
return filters;
|
||||
}
|
||||
|
||||
function buildPointerQuery(pointer, options = {}) {
|
||||
@@ -556,7 +599,10 @@ function ensureReactionHydration(key, pointer, state) {
|
||||
|
||||
const promise = hydratePointerState(key, pointer, state)
|
||||
.catch((error) => {
|
||||
devLogger.warn("[reactionCounter] Failed to hydrate reaction history:", error);
|
||||
devLogger.warn(
|
||||
"[reactionCounter] Failed to hydrate reaction history:",
|
||||
error
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
pointerHydrationPromises.delete(key);
|
||||
@@ -577,7 +623,10 @@ function teardownLiveSubscription(key) {
|
||||
try {
|
||||
unsub();
|
||||
} catch (error) {
|
||||
devLogger.warn("[reactionCounter] Failed to tear down reaction stream:", error);
|
||||
devLogger.warn(
|
||||
"[reactionCounter] Failed to tear down reaction stream:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -618,7 +667,10 @@ function ensureReactionLiveSubscription(key, pointer) {
|
||||
try {
|
||||
subscription = query.pool.sub(query.relays, query.filters);
|
||||
} catch (error) {
|
||||
devLogger.warn("[reactionCounter] Failed to subscribe to reaction events:", error);
|
||||
devLogger.warn(
|
||||
"[reactionCounter] Failed to subscribe to reaction events:",
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -660,7 +712,7 @@ function ensureReactionLiveSubscription(key, pointer) {
|
||||
|
||||
pointerSubscriptions.set(key, {
|
||||
signature,
|
||||
unsub: unsubscribe,
|
||||
unsub: unsubscribe
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
@@ -688,7 +740,8 @@ export const reactionCounter = {
|
||||
})();
|
||||
|
||||
const pointerRelay =
|
||||
typeof publishOptions.pointerRelay === "string" && publishOptions.pointerRelay.trim()
|
||||
typeof publishOptions.pointerRelay === "string" &&
|
||||
publishOptions.pointerRelay.trim()
|
||||
? publishOptions.pointerRelay.trim()
|
||||
: pointerRelayFromPointer;
|
||||
|
||||
@@ -750,7 +803,7 @@ export const reactionCounter = {
|
||||
|
||||
const result = await nostrClient.publishVideoReaction(
|
||||
enrichedPointer,
|
||||
publishOptions,
|
||||
publishOptions
|
||||
);
|
||||
if (!result || !result.ok) {
|
||||
userLogger.warn(
|
||||
@@ -768,21 +821,24 @@ export const reactionCounter = {
|
||||
? publishOptions.actorPubkey
|
||||
: undefined,
|
||||
content: publishOptions.content,
|
||||
created_at: publishOptions.created_at,
|
||||
created_at: publishOptions.created_at
|
||||
};
|
||||
if (eventPayload && eventPayload.pubkey) {
|
||||
ingestLocalReaction({
|
||||
pointer: enrichedPointer,
|
||||
event: eventPayload,
|
||||
event: eventPayload
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
userLogger.warn("[reactionCounter] Failed to publish reaction event:", error);
|
||||
userLogger.warn(
|
||||
"[reactionCounter] Failed to publish reaction event:",
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
reactionCounter.subscribe = subscribeToPointer;
|
||||
@@ -793,5 +849,5 @@ export default reactionCounter;
|
||||
export {
|
||||
subscribeToPointer as subscribeToReactions,
|
||||
unsubscribeFromPointer as unsubscribeFromReactions,
|
||||
getPointerSnapshot as getReactionSnapshot,
|
||||
getPointerSnapshot as getReactionSnapshot
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { accessControl } from "../../accessControl.js";
|
||||
|
||||
const PROVIDER_ID = "bitlogin";
|
||||
const PROVIDER_LABEL = "BitLogin";
|
||||
const PROVIDER_DESCRIPTION =
|
||||
"A portable Nostr identity behind a familiar login name and password.";
|
||||
const PROVIDER_CAPABILITIES = Object.freeze([
|
||||
Object.freeze({
|
||||
id: "signing",
|
||||
label: "Signs Nostr events",
|
||||
variant: "info",
|
||||
}),
|
||||
Object.freeze({
|
||||
id: "portable",
|
||||
label: "Works on every BitLogin site",
|
||||
variant: "neutral",
|
||||
}),
|
||||
]);
|
||||
const PROVIDER_BUTTON = Object.freeze({
|
||||
variant: "primary",
|
||||
});
|
||||
const PROVIDER_MESSAGES = Object.freeze({
|
||||
loading: "Signing in with BitLogin…",
|
||||
slow: "Still waiting on BitLogin…",
|
||||
error: "Failed to sign in with BitLogin. Please try again.",
|
||||
});
|
||||
|
||||
// Set by the widget's own "bitlogin-login" listener (js/ui/applicationBootstrap.js)
|
||||
// right before it calls authService.requestLogin({ providerId: "bitlogin" }): by
|
||||
// the time that reaches this provider's login(), the widget has already run its
|
||||
// own multi-screen sign-in/create/recover flow and produced a pubkey + signer.
|
||||
// This provider exists so that result goes through the exact same session-
|
||||
// establishment path (AuthService.login -> handleAuthLogin) as every other
|
||||
// signer, without BitLogin also rendering as a redundant button in the login
|
||||
// grid -- see providersForModal in ./index.js.
|
||||
let pendingResult = null;
|
||||
|
||||
export function setPendingBitloginResult(result) {
|
||||
pendingResult = result && typeof result === "object" ? result : null;
|
||||
}
|
||||
|
||||
export default {
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
description: PROVIDER_DESCRIPTION,
|
||||
eyebrow: "Recommended",
|
||||
tone: "accent",
|
||||
badgeVariant: "info",
|
||||
capabilities: PROVIDER_CAPABILITIES,
|
||||
button: PROVIDER_BUTTON,
|
||||
messages: PROVIDER_MESSAGES,
|
||||
errorMessage: PROVIDER_MESSAGES.error,
|
||||
async login({ nostrClient } = {}) {
|
||||
const result = pendingResult;
|
||||
pendingResult = null;
|
||||
|
||||
if (!result || typeof result.pubkey !== "string" || !result.pubkey.trim()) {
|
||||
const error = new Error("BitLogin sign-in hasn't completed yet.");
|
||||
error.code = "provider-unavailable";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const pubkey = result.pubkey.trim();
|
||||
|
||||
if (!accessControl.canAccess(pubkey)) {
|
||||
if (accessControl.isBlacklisted(pubkey)) {
|
||||
throw new Error("Your account has been blocked on this platform.");
|
||||
}
|
||||
throw new Error("Access restricted to admins and moderators users only.");
|
||||
}
|
||||
|
||||
if (
|
||||
nostrClient?.signerManager &&
|
||||
typeof nostrClient.signerManager.setActiveSigner === "function"
|
||||
) {
|
||||
nostrClient.signerManager.setActiveSigner(result.signer);
|
||||
}
|
||||
|
||||
return {
|
||||
authType: PROVIDER_ID,
|
||||
pubkey,
|
||||
signer: result.signer,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import nip07 from "./nip07.js";
|
||||
import nip46 from "./nip46.js";
|
||||
import nsec from "./nsec.js";
|
||||
import generate from "./generate.js";
|
||||
import bitlogin from "./bitlogin.js";
|
||||
|
||||
function normalizeProviderId(provider, fallbackId) {
|
||||
if (provider && typeof provider.id === "string") {
|
||||
@@ -39,7 +40,7 @@ function normalizeProviderMetadata(provider, fallbackId) {
|
||||
return Object.freeze({ id, label, description, badgeVariant });
|
||||
}
|
||||
|
||||
const providerList = [nip07, nip46, nsec, generate].filter(Boolean);
|
||||
const providerList = [nip07, nip46, nsec, generate, bitlogin].filter(Boolean);
|
||||
const providerMap = {};
|
||||
const metadataMap = {};
|
||||
|
||||
@@ -97,6 +98,19 @@ builtInMetadata.forEach((entry) => {
|
||||
export const providers = Object.freeze({ ...providerMap });
|
||||
export const providerMetadata = Object.freeze({ ...metadataMap });
|
||||
|
||||
// BitLogin gets its own permanent, always-visible widget in the login modal
|
||||
// (components/login-modal.html) rather than a grid button -- it isn't a single
|
||||
// button-click-then-await-a-promise flow like the others, it's a whole
|
||||
// multi-screen sign-in/create/recover UI. Excluding it here keeps the modal's
|
||||
// provider grid ("Other login options") free of a redundant, do-nothing button
|
||||
// for it, while `providers.bitlogin` above still resolves normally for
|
||||
// AuthService.requestLogin({ providerId: "bitlogin" }).
|
||||
export const providersForModal = Object.freeze(
|
||||
Object.fromEntries(
|
||||
Object.entries(providerMap).filter(([id]) => id !== "bitlogin"),
|
||||
),
|
||||
);
|
||||
|
||||
const unknownMetadataCache = new Map();
|
||||
|
||||
function getFallbackMetadata(providerId) {
|
||||
|
||||
@@ -49,7 +49,6 @@ export default {
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
description: PROVIDER_DESCRIPTION,
|
||||
eyebrow: "Recommended",
|
||||
tone: "accent",
|
||||
badgeVariant: "info",
|
||||
capabilities: PROVIDER_CAPABILITIES,
|
||||
|
||||
@@ -144,26 +144,51 @@ export default class ExploreDataService {
|
||||
this.tagIdfInterval = null;
|
||||
this.unsubscribeHandlers = [];
|
||||
this.handleVisibility = this.handleVisibility.bind(this);
|
||||
this.initialized = false;
|
||||
this.active = false;
|
||||
this.historyDirty = true;
|
||||
this.idfDirty = true;
|
||||
this.historyRefreshInFlight = null;
|
||||
this.idfRefreshInFlight = null;
|
||||
this.historyRefreshFollowUp = null;
|
||||
this.idfRefreshFollowUp = null;
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.refreshWatchHistoryTagCounts({ force: true, reason: "init" });
|
||||
this.refreshTagIdf({ force: true, reason: "init" });
|
||||
initialize({ active = false } = {}) {
|
||||
if (this.initialized) {
|
||||
return this.setActive(active);
|
||||
}
|
||||
this.initialized = true;
|
||||
this.subscribeToUpdates();
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", this.handleVisibility);
|
||||
}
|
||||
return this.setActive(active);
|
||||
}
|
||||
|
||||
async setActive(active) {
|
||||
this.active = active === true;
|
||||
if (!this.active || (typeof document !== "undefined" && document.hidden)) {
|
||||
this.clearIntervals();
|
||||
return;
|
||||
}
|
||||
this.startIntervals();
|
||||
const forceHistory = this.historyDirty || !this.watchHistoryTagCountsUpdatedAt;
|
||||
const forceIdf = this.idfDirty || !this.tagIdfUpdatedAt;
|
||||
this.historyDirty = false;
|
||||
this.idfDirty = false;
|
||||
await Promise.all([
|
||||
this.refreshWatchHistoryTagCounts({ force: forceHistory, reason: "active" }),
|
||||
this.refreshTagIdf({ force: forceIdf, reason: "active" }),
|
||||
]);
|
||||
}
|
||||
|
||||
handleVisibility() {
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.hidden) {
|
||||
this.clearIntervals();
|
||||
} else {
|
||||
this.refreshWatchHistoryTagCounts({ reason: "visibility" });
|
||||
this.refreshTagIdf({ reason: "visibility" });
|
||||
this.startIntervals();
|
||||
} else if (this.active) {
|
||||
void this.setActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +251,10 @@ export default class ExploreDataService {
|
||||
}
|
||||
|
||||
queueWatchHistoryRefresh(reason) {
|
||||
if (!this.active) {
|
||||
this.historyDirty = true;
|
||||
return;
|
||||
}
|
||||
if (this.watchHistoryRefreshHandle) {
|
||||
return;
|
||||
}
|
||||
@@ -236,6 +265,10 @@ export default class ExploreDataService {
|
||||
}
|
||||
|
||||
queueTagIdfRefresh(reason) {
|
||||
if (!this.active) {
|
||||
this.idfDirty = true;
|
||||
return;
|
||||
}
|
||||
if (this.tagIdfRefreshHandle) {
|
||||
return;
|
||||
}
|
||||
@@ -245,7 +278,33 @@ export default class ExploreDataService {
|
||||
}, this.refreshDebounceMs);
|
||||
}
|
||||
|
||||
async refreshWatchHistoryTagCounts({ force = false, reason } = {}) {
|
||||
async refreshWatchHistoryTagCounts(options = {}) {
|
||||
if (this.historyRefreshInFlight) {
|
||||
// Keep only one fresh pass after the current worker task. Rebuilding for
|
||||
// every relay/event/timer signal is exactly the burst this lifecycle is
|
||||
// intended to prevent.
|
||||
this.historyRefreshFollowUp = {
|
||||
force: true,
|
||||
reason: options.reason || "coalesced",
|
||||
};
|
||||
return this.historyRefreshInFlight;
|
||||
}
|
||||
|
||||
const run = this._refreshWatchHistoryTagCounts(options);
|
||||
this.historyRefreshInFlight = Promise.resolve(run).finally(() => {
|
||||
this.historyRefreshInFlight = null;
|
||||
const followUp = this.historyRefreshFollowUp;
|
||||
this.historyRefreshFollowUp = null;
|
||||
if (followUp && this.active) {
|
||||
void this.refreshWatchHistoryTagCounts(followUp);
|
||||
} else if (followUp) {
|
||||
this.historyDirty = true;
|
||||
}
|
||||
});
|
||||
return this.historyRefreshInFlight;
|
||||
}
|
||||
|
||||
async _refreshWatchHistoryTagCounts({ force = false, reason } = {}) {
|
||||
const now = Date.now();
|
||||
if (!force && this.watchHistoryTagCountsUpdatedAt) {
|
||||
const elapsed = now - this.watchHistoryTagCountsUpdatedAt;
|
||||
@@ -279,7 +338,30 @@ export default class ExploreDataService {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshTagIdf({ force = false, videos, reason } = {}) {
|
||||
async refreshTagIdf(options = {}) {
|
||||
if (this.idfRefreshInFlight) {
|
||||
this.idfRefreshFollowUp = {
|
||||
force: true,
|
||||
reason: options.reason || "coalesced",
|
||||
};
|
||||
return this.idfRefreshInFlight;
|
||||
}
|
||||
|
||||
const run = this._refreshTagIdf(options);
|
||||
this.idfRefreshInFlight = Promise.resolve(run).finally(() => {
|
||||
this.idfRefreshInFlight = null;
|
||||
const followUp = this.idfRefreshFollowUp;
|
||||
this.idfRefreshFollowUp = null;
|
||||
if (followUp && this.active) {
|
||||
void this.refreshTagIdf(followUp);
|
||||
} else if (followUp) {
|
||||
this.idfDirty = true;
|
||||
}
|
||||
});
|
||||
return this.idfRefreshInFlight;
|
||||
}
|
||||
|
||||
async _refreshTagIdf({ force = false, videos, reason } = {}) {
|
||||
const now = Date.now();
|
||||
if (!force && this.tagIdfUpdatedAt) {
|
||||
const elapsed = now - this.tagIdfUpdatedAt;
|
||||
@@ -332,6 +414,8 @@ export default class ExploreDataService {
|
||||
clearTimeout(this.tagIdfRefreshHandle);
|
||||
this.tagIdfRefreshHandle = null;
|
||||
}
|
||||
this.historyRefreshFollowUp = null;
|
||||
this.idfRefreshFollowUp = null;
|
||||
|
||||
this.unsubscribeHandlers.forEach((unsubscribe) => {
|
||||
if (typeof unsubscribe === "function") {
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
// HLS (.m3u8) playback support.
|
||||
//
|
||||
// bitvid historically attached every hosted source with `videoEl.src = url`.
|
||||
// That works for progressive MP4/WebM but NOT for HLS: only Safari/iOS can play
|
||||
// an .m3u8 natively, so on Chrome/Firefox the element fails immediately with
|
||||
// MEDIA_ERR_SRC_NOT_SUPPORTED ("Hosted playback failed: source not supported or
|
||||
// blocked."). Nostr clients that publish bitvid's v3 schema with HLS ladders
|
||||
// (e.g. nostube/slidestr) were therefore unplayable everywhere but Safari.
|
||||
//
|
||||
// This module owns the decision + the hls.js lifecycle. hls.js is vendored
|
||||
// (scripts/build-hls.mjs) and lazy-imported ONLY when an HLS URL is actually
|
||||
// encountered, so the ~500KB bundle never touches a normal MP4/WebTorrent load.
|
||||
//
|
||||
// Rollback: stop calling attachHlsSource() from playbackService/playbackCoordinator
|
||||
// and the old `videoEl.src = url` behavior returns; nothing else imports this.
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
// Vendored, pinned hls.js bundle (scripts/build-hls.mjs). Lazy-imported.
|
||||
const HLS_BUNDLE_URL = "../../vendor/hls.bundle.min.js";
|
||||
|
||||
// Canonical + legacy HLS media types. Servers are wildly inconsistent here —
|
||||
// the slidestr CDN serves playlists as `text/plain` — so the URL extension is
|
||||
// the primary signal and the MIME type is only ever corroborating evidence.
|
||||
export const HLS_MIME_TYPES = Object.freeze([
|
||||
"application/vnd.apple.mpegurl",
|
||||
"application/x-mpegurl",
|
||||
"audio/mpegurl",
|
||||
"audio/x-mpegurl",
|
||||
"application/mpegurl",
|
||||
"vnd.apple.mpegurl",
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when the URL's *path* ends in .m3u8. Query strings and fragments are
|
||||
* ignored (signed CDN URLs routinely carry `?token=...`), and a bare .m3u8
|
||||
* substring elsewhere in the URL must not match.
|
||||
*/
|
||||
export function isHlsUrl(url) {
|
||||
const trimmed = typeof url === "string" ? url.trim() : "";
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
// Strip fragment then query, without `new URL()` — relative/odd inputs must
|
||||
// not throw, and bitvid never runs URL parsers over user-supplied media
|
||||
// strings (see the magnet rules in AGENTS.md).
|
||||
const path = trimmed.split("#")[0].split("?")[0];
|
||||
return /\.m3u8$/i.test(path);
|
||||
}
|
||||
|
||||
/** True when a declared MIME type is one of the HLS playlist types. */
|
||||
export function isHlsMimeType(mimeType) {
|
||||
const normalized =
|
||||
typeof mimeType === "string" ? mimeType.trim().toLowerCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const bare = normalized.split(";")[0].trim();
|
||||
return HLS_MIME_TYPES.includes(bare);
|
||||
}
|
||||
|
||||
/** Combined check used by callers that may have an imeta `m` hint. */
|
||||
export function isHlsSource({ url = "", mimeType = "" } = {}) {
|
||||
return isHlsUrl(url) || isHlsMimeType(mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `canPlayType` claims native HLS support.
|
||||
*
|
||||
* DO NOT branch on this alone. Chrome LIES here: Chrome 149 returns "maybe" for
|
||||
* every HLS MIME type, but assigning an .m3u8 to `video.src` then fires NO
|
||||
* events at all — not `loadeddata`, not even `error`. The element simply hangs,
|
||||
* which is exactly the silent "nothing happens" failure this module exists to
|
||||
* fix (and why the URL-health probe timed out instead of erroring, leaving the
|
||||
* card visible but unplayable). Use prefersNativeHls() to make decisions.
|
||||
*/
|
||||
export function supportsNativeHls(videoElement) {
|
||||
const el =
|
||||
videoElement ||
|
||||
(typeof document !== "undefined" ? document.createElement("video") : null);
|
||||
if (!el || typeof el.canPlayType !== "function") {
|
||||
return false;
|
||||
}
|
||||
for (const type of HLS_MIME_TYPES) {
|
||||
try {
|
||||
if (el.canPlayType(type)) {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
// canPlayType must never throw, but never let a probe kill playback.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A representative baseline codec: every MSE implementation that can run hls.js
|
||||
// supports fragmented-MP4 H.264. This mirrors what Hls.isSupported() checks, so
|
||||
// we can predict the engine choice WITHOUT paying to load the bundle (the
|
||||
// URL-health probe needs the answer for every visible card).
|
||||
const MSE_PROBE_TYPE = 'video/mp4; codecs="avc1.42E01E"';
|
||||
|
||||
/** True when Media Source Extensions can back hls.js in this browser. */
|
||||
export function supportsMse() {
|
||||
const MediaSourceImpl =
|
||||
(typeof globalThis !== "undefined" &&
|
||||
(globalThis.MediaSource ||
|
||||
globalThis.ManagedMediaSource ||
|
||||
globalThis.WebKitMediaSource)) ||
|
||||
null;
|
||||
if (!MediaSourceImpl || typeof MediaSourceImpl.isTypeSupported !== "function") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return MediaSourceImpl.isTypeSupported(MSE_PROBE_TYPE);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to use the browser's own HLS implementation instead of hls.js.
|
||||
*
|
||||
* Only when MSE is genuinely unavailable (older iOS Safari, some TV browsers).
|
||||
* hls.js is the default everywhere else — this is hls.js's own recommended
|
||||
* ordering, and on Chrome it is mandatory: see supportsNativeHls() for the
|
||||
* canPlayType lie that makes a native-first branch hang forever.
|
||||
*/
|
||||
export function prefersNativeHls(videoElement) {
|
||||
return !supportsMse() && supportsNativeHls(videoElement);
|
||||
}
|
||||
|
||||
let hlsEnginePromise = null;
|
||||
|
||||
/**
|
||||
* Lazy-import the vendored hls.js bundle. Cached across calls (including the
|
||||
* rejection case, deliberately reset so a transient network failure can retry).
|
||||
*/
|
||||
export async function loadHlsEngine() {
|
||||
if (!hlsEnginePromise) {
|
||||
hlsEnginePromise = import(HLS_BUNDLE_URL)
|
||||
.then((mod) => mod?.default || mod?.Hls || null)
|
||||
.catch((err) => {
|
||||
hlsEnginePromise = null;
|
||||
devLogger.warn("[hlsPlayback] Failed to load hls.js bundle:", err);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return hlsEnginePromise;
|
||||
}
|
||||
|
||||
/** Test seam: drop the cached engine promise. */
|
||||
export function __resetHlsEngineCacheForTests() {
|
||||
hlsEnginePromise = null;
|
||||
}
|
||||
|
||||
// videoEl -> active hls.js instance. A WeakMap keeps teardown paths (which only
|
||||
// ever hold the element) from having to thread an extra handle around, and lets
|
||||
// a discarded element be collected without leaking the instance.
|
||||
const attachedInstances = new WeakMap();
|
||||
|
||||
export function getAttachedHls(videoElement) {
|
||||
return videoElement ? attachedInstances.get(videoElement) || null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down any hls.js instance bound to this element. Safe to call
|
||||
* unconditionally on every reset path — it is a no-op for non-HLS playback.
|
||||
*/
|
||||
export function detachHls(videoElement) {
|
||||
if (!videoElement) {
|
||||
return false;
|
||||
}
|
||||
const hls = attachedInstances.get(videoElement);
|
||||
if (!hls) {
|
||||
return false;
|
||||
}
|
||||
attachedInstances.delete(videoElement);
|
||||
try {
|
||||
hls.destroy();
|
||||
} catch (err) {
|
||||
devLogger.warn("[hlsPlayback] hls.destroy() threw during detach:", err);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function describeHlsError(data) {
|
||||
const type = data?.type || "unknown";
|
||||
const details = data?.details || "unknown";
|
||||
if (data?.type === "networkError") {
|
||||
return "Hosted playback failed: the HLS stream could not be reached (network or CORS).";
|
||||
}
|
||||
if (data?.type === "mediaError") {
|
||||
return "Hosted playback failed: this HLS stream uses a codec your browser cannot decode.";
|
||||
}
|
||||
return `Hosted playback failed: HLS error (${type}/${details}).`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach `url` to `videoElement`, using native HLS where available and hls.js
|
||||
* (MSE) otherwise.
|
||||
*
|
||||
* Resolves once the manifest is parsed — the meaningful "this is playable"
|
||||
* signal for both the player and the URL-health probe. Callers are still
|
||||
* responsible for calling play(); this only wires up the source.
|
||||
*
|
||||
* Returns null when HLS cannot be supported at all (no native support and MSE
|
||||
* unavailable), so the caller can fall back to its normal `src` assignment and
|
||||
* surface a real media error.
|
||||
*
|
||||
* `createEngine` is a test seam: it resolves the hls.js constructor and defaults
|
||||
* to the lazy import of the vendored bundle. Tests inject a fake so the MSE
|
||||
* branch can be specified without a real MediaSource.
|
||||
*
|
||||
* @returns {Promise<{mode: "native"|"mse", destroy: () => void}|null>}
|
||||
*/
|
||||
export async function attachHlsSource(
|
||||
videoElement,
|
||||
url,
|
||||
{
|
||||
onFatalError,
|
||||
log = () => {},
|
||||
manifestTimeoutMs = 15000,
|
||||
createEngine = loadHlsEngine,
|
||||
} = {}
|
||||
) {
|
||||
if (!videoElement) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = typeof url === "string" ? url.trim() : "";
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Always clear a previous instance before rebinding — reusing the modal's
|
||||
// <video> across videos is the norm in bitvid.
|
||||
detachHls(videoElement);
|
||||
|
||||
const useNative = () => {
|
||||
log("[hlsPlayback] Using native HLS playback.");
|
||||
videoElement.src = trimmed;
|
||||
return { mode: "native", destroy: () => {} };
|
||||
};
|
||||
|
||||
// Native only when MSE genuinely cannot back hls.js. Never trust canPlayType
|
||||
// on its own — Chrome claims HLS support and then hangs silently.
|
||||
if (prefersNativeHls(videoElement)) {
|
||||
return useNative();
|
||||
}
|
||||
|
||||
const Hls = await createEngine();
|
||||
if (!Hls || typeof Hls.isSupported !== "function" || !Hls.isSupported()) {
|
||||
// Last resort: if the bundle failed to load but the browser claims native
|
||||
// support, an attempt beats a guaranteed failure.
|
||||
if (supportsNativeHls(videoElement)) {
|
||||
log("[hlsPlayback] hls.js unavailable; falling back to native HLS.");
|
||||
return useNative();
|
||||
}
|
||||
log("[hlsPlayback] hls.js unavailable/unsupported; cannot play HLS.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
// Keep a bounded back-buffer so long VOD (this note is ~1h) does not grow
|
||||
// the SourceBuffer without limit on low-memory devices.
|
||||
backBufferLength: 90,
|
||||
});
|
||||
|
||||
attachedInstances.set(videoElement, hls);
|
||||
|
||||
let settled = false;
|
||||
let networkRetries = 0;
|
||||
let mediaRecoveries = 0;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timeoutId = null;
|
||||
|
||||
const finish = (value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const fail = (message) => {
|
||||
// Only tear down once we've given up; recoverable errors keep the
|
||||
// instance alive so hls.js can heal itself.
|
||||
detachHls(videoElement);
|
||||
if (typeof onFatalError === "function") {
|
||||
onFatalError(message);
|
||||
}
|
||||
finish(null);
|
||||
};
|
||||
|
||||
if (Number.isFinite(manifestTimeoutMs) && manifestTimeoutMs > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
log("[hlsPlayback] Manifest parse timed out.");
|
||||
fail("Hosted playback failed: the HLS manifest did not load in time.");
|
||||
}, manifestTimeoutMs);
|
||||
}
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => {
|
||||
log(
|
||||
`[hlsPlayback] Manifest parsed; ${data?.levels?.length ?? 0} playable level(s).`
|
||||
);
|
||||
// hls.js drops levels whose codecs MSE cannot decode (e.g. an HEVC
|
||||
// rendition on Firefox). Zero survivors means nothing is playable.
|
||||
if (Array.isArray(data?.levels) && data.levels.length === 0) {
|
||||
fail(
|
||||
"Hosted playback failed: no HLS rendition uses a codec your browser supports."
|
||||
);
|
||||
return;
|
||||
}
|
||||
finish({ mode: "mse", destroy: () => detachHls(videoElement) });
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (!data?.fatal) {
|
||||
log(
|
||||
`[hlsPlayback] Non-fatal HLS error (${data?.type}/${data?.details}); continuing.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`[hlsPlayback] Fatal HLS error (${data?.type}/${data?.details}).`);
|
||||
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && networkRetries < 2) {
|
||||
networkRetries += 1;
|
||||
log(`[hlsPlayback] Retrying load (attempt ${networkRetries}).`);
|
||||
try {
|
||||
hls.startLoad();
|
||||
return;
|
||||
} catch (err) {
|
||||
log("[hlsPlayback] startLoad() threw during recovery.", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && mediaRecoveries < 2) {
|
||||
mediaRecoveries += 1;
|
||||
log(`[hlsPlayback] Recovering media error (attempt ${mediaRecoveries}).`);
|
||||
try {
|
||||
if (mediaRecoveries === 2 && typeof hls.swapAudioCodec === "function") {
|
||||
hls.swapAudioCodec();
|
||||
}
|
||||
hls.recoverMediaError();
|
||||
return;
|
||||
} catch (err) {
|
||||
log("[hlsPlayback] recoverMediaError() threw during recovery.", err);
|
||||
}
|
||||
}
|
||||
|
||||
fail(describeHlsError(data));
|
||||
});
|
||||
|
||||
try {
|
||||
hls.attachMedia(videoElement);
|
||||
hls.loadSource(trimmed);
|
||||
} catch (err) {
|
||||
log("[hlsPlayback] attachMedia/loadSource threw:", err);
|
||||
fail("Hosted playback failed: the HLS stream could not be attached.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap playability check for the URL-health badge that does NOT pull in hls.js.
|
||||
*
|
||||
* On MSE browsers hls.js fetches the playlist with XHR, so a CORS-readable
|
||||
* `#EXTM3U` response is exactly the condition for playability — and a bare
|
||||
* <video> probe (the old path) would report every HLS URL as dead. On Safari,
|
||||
* native playback needs no CORS, so the caller should keep using the element
|
||||
* probe there.
|
||||
*
|
||||
* @returns {Promise<"ok"|"error"|"timeout">}
|
||||
*/
|
||||
export async function probeHlsManifest(url, timeoutMs = 4000) {
|
||||
const trimmed = typeof url === "string" ? url.trim() : "";
|
||||
if (!trimmed || typeof fetch !== "function") {
|
||||
return "error";
|
||||
}
|
||||
|
||||
const controller =
|
||||
typeof AbortController === "function" ? new AbortController() : null;
|
||||
let timedOut = false;
|
||||
const timeoutId =
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller?.abort();
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
|
||||
try {
|
||||
const response = await fetch(trimmed, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
signal: controller?.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return "error";
|
||||
}
|
||||
const text = await response.text();
|
||||
return text.trimStart().startsWith("#EXTM3U") ? "ok" : "error";
|
||||
} catch (err) {
|
||||
return timedOut ? "timeout" : "error";
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ export default class ModerationActionController {
|
||||
typeof services.clearModerationOverride === "function"
|
||||
? services.clearModerationOverride
|
||||
: null,
|
||||
clearAuthorModerationOverride:
|
||||
typeof services.clearAuthorModerationOverride === "function"
|
||||
? services.clearAuthorModerationOverride
|
||||
: null,
|
||||
userBlocks:
|
||||
services.userBlocks && typeof services.userBlocks === "object"
|
||||
? services.userBlocks
|
||||
@@ -240,6 +244,10 @@ export default class ModerationActionController {
|
||||
}
|
||||
|
||||
this.clearModerationOverride(video);
|
||||
// The same Restore control is used after either a per-video override or
|
||||
// an "Always show creator" override. Clear both scopes so the visible
|
||||
// state can actually return to the default moderation decision.
|
||||
this.clearAuthorModerationOverride(video);
|
||||
|
||||
const target = this.resolveTargetVideo(video);
|
||||
this.clearViewerOverride(target);
|
||||
@@ -503,6 +511,26 @@ export default class ModerationActionController {
|
||||
}
|
||||
}
|
||||
|
||||
clearAuthorModerationOverride(video) {
|
||||
if (!this.services.clearAuthorModerationOverride) {
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptor = this.getModerationOverrideDescriptor(video);
|
||||
if (!descriptor?.authorPubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.services.clearAuthorModerationOverride(descriptor.authorPubkey);
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[ModerationActionController] Failed to clear author moderation override:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
isUserLoggedIn() {
|
||||
if (!this.auth.isLoggedIn) {
|
||||
return false;
|
||||
|
||||
@@ -300,20 +300,23 @@ export class ModerationDecorator {
|
||||
: "";
|
||||
|
||||
const thresholds = this.getActiveModerationThresholds();
|
||||
const computedBlockAutoplayBase =
|
||||
trustedCount >= thresholds.autoplayBlockThreshold || trustedMuted;
|
||||
// Trusted mutes are deliberately limited to feed ranking. A single muted
|
||||
// list entry must not become a visible strike against a creator.
|
||||
const computedBlockAutoplayBase = trustedCount >= thresholds.autoplayBlockThreshold;
|
||||
const computedBlockAutoplay =
|
||||
computedBlockAutoplayBase || viewerMuted || existingBlockAutoplay;
|
||||
|
||||
const blurFromReports = trustedCount >= thresholds.blurThreshold;
|
||||
const preservesExistingBlur =
|
||||
existingBlurThumbnail &&
|
||||
existingBlurReason !== "trusted-mute" &&
|
||||
existingBlurReason !== "trusted-mute-hide";
|
||||
let computedBlurThumbnail =
|
||||
blurFromReports || trustedMuted || viewerMuted || existingBlurThumbnail;
|
||||
blurFromReports || viewerMuted || preservesExistingBlur;
|
||||
let computedBlurReason = "";
|
||||
|
||||
if (blurFromReports) {
|
||||
computedBlurReason = "trusted-report";
|
||||
} else if (trustedMuted) {
|
||||
computedBlurReason = "trusted-mute";
|
||||
} else if (viewerMuted) {
|
||||
computedBlurReason = "viewer-mute";
|
||||
} else if (existingBlurThumbnail && existingBlurReason) {
|
||||
@@ -352,22 +355,18 @@ export class ModerationDecorator {
|
||||
hideTriggered = true;
|
||||
}
|
||||
|
||||
if (!computedBlurThumbnail && (viewerMuted || trustedMuted || hideTriggered)) {
|
||||
if (!computedBlurThumbnail && (viewerMuted || hideTriggered)) {
|
||||
computedBlurThumbnail = true;
|
||||
if (hideTriggered) {
|
||||
computedBlurReason = hideReason || "trusted-hide";
|
||||
} else if (viewerMuted) {
|
||||
computedBlurReason = "viewer-mute";
|
||||
} else if (trustedMuted) {
|
||||
computedBlurReason = "trusted-mute";
|
||||
}
|
||||
} else if (computedBlurThumbnail) {
|
||||
if (hideTriggered) {
|
||||
computedBlurReason = hideReason || "trusted-hide";
|
||||
} else if (viewerMuted && !blurFromReports && !trustedMuted) {
|
||||
} else if (viewerMuted && !blurFromReports) {
|
||||
computedBlurReason = "viewer-mute";
|
||||
} else if (trustedMuted && !blurFromReports) {
|
||||
computedBlurReason = "trusted-mute";
|
||||
} else if (!computedBlurReason && blurFromReports) {
|
||||
computedBlurReason = "trusted-report";
|
||||
}
|
||||
|
||||
@@ -1910,7 +1910,7 @@ export class ModerationService {
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Iterate over all reports for the event.
|
||||
* 2. Check if the reporter is in the `trustedContacts` set (or Admin Whitelist).
|
||||
* 2. Check if the reporter is in the `trustedContacts` set.
|
||||
* 3. If trusted, increment the count for that report type (e.g., "nudity").
|
||||
* 4. Update the summary state and emit change events if thresholds are crossed.
|
||||
*
|
||||
@@ -1966,7 +1966,7 @@ export class ModerationService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isTrustedReporter = status.whitelisted || this.trustedContacts.has(reporterHex);
|
||||
const isTrustedReporter = this.trustedContacts.has(reporterHex);
|
||||
|
||||
for (const [type, detail] of typeMap.entries()) {
|
||||
let stats = typeStats.get(type);
|
||||
@@ -2092,7 +2092,7 @@ export class ModerationService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isTrustedReporter = status.whitelisted || this.trustedContacts.has(reporterHex);
|
||||
const isTrustedReporter = this.trustedContacts.has(reporterHex);
|
||||
if (!isTrustedReporter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ const MAX_OPEN_ATTEMPTS = 30; // ~60s of retries
|
||||
// (kind 30000), which during cold-start competes with the initial feed load; a
|
||||
// fallback covers the empty-feed case where no videos:updated ever fires.
|
||||
const FEED_READY_FALLBACK_MS = 12000;
|
||||
// Some relays do not reliably send EOSE. Do not hold the first visible update
|
||||
// forever, but give the initial relay burst time to settle before painting it.
|
||||
const INITIAL_SYNC_FALLBACK_MS = 5000;
|
||||
|
||||
export function createNip71IngestService({
|
||||
nostrClient,
|
||||
@@ -54,6 +57,7 @@ export function createNip71IngestService({
|
||||
maxOpenAttempts = MAX_OPEN_ATTEMPTS,
|
||||
refreshThrottleMs = REFRESH_THROTTLE_MS,
|
||||
feedReadyFallbackMs = FEED_READY_FALLBACK_MS,
|
||||
initialSyncFallbackMs = INITIAL_SYNC_FALLBACK_MS,
|
||||
} = {}) {
|
||||
let subscription = null;
|
||||
let buffer = [];
|
||||
@@ -67,6 +71,9 @@ export function createNip71IngestService({
|
||||
let deferArmed = false;
|
||||
let deferTimer = null;
|
||||
let offFeedReady = null;
|
||||
let initialSyncing = false;
|
||||
let initialRefreshPending = false;
|
||||
let initialSyncTimer = null;
|
||||
|
||||
function isAvailable() {
|
||||
return (
|
||||
@@ -183,7 +190,13 @@ export function createNip71IngestService({
|
||||
);
|
||||
|
||||
if (injected > 0) {
|
||||
requestRefresh();
|
||||
if (initialSyncing) {
|
||||
// Keep native BitVid, NIP-71 videos, and future NIP-71 media types in
|
||||
// the store immediately; defer only the expensive full-feed repaint.
|
||||
initialRefreshPending = true;
|
||||
} else {
|
||||
requestRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
return injected;
|
||||
@@ -211,6 +224,10 @@ export function createNip71IngestService({
|
||||
|
||||
function emitRefresh() {
|
||||
lastRefreshAt = Date.now();
|
||||
const performanceHarness = globalThis?.__bitvidPerformance;
|
||||
const performanceRef = globalThis?.performance;
|
||||
const startedAt = typeof performanceRef?.now === "function" ? performanceRef.now() : 0;
|
||||
performanceHarness?.mark?.("nip71-refresh-filter-start");
|
||||
let videos = [];
|
||||
try {
|
||||
videos =
|
||||
@@ -220,6 +237,11 @@ export function createNip71IngestService({
|
||||
} catch (error) {
|
||||
logger?.warn?.("[nip71Ingest] getFilteredActiveVideos failed", error);
|
||||
}
|
||||
const filteredAt = typeof performanceRef?.now === "function" ? performanceRef.now() : startedAt;
|
||||
performanceHarness?.mark?.("nip71-refresh-filter-end", {
|
||||
durationMs: Math.max(0, filteredAt - startedAt),
|
||||
videos: videos.length,
|
||||
});
|
||||
// Confirm ingested videos survive the feed filter (whitelist/NSFW/etc.).
|
||||
const foreignCount = Array.isArray(videos)
|
||||
? videos.filter((v) => v?.source === "nip71-ingest").length
|
||||
@@ -234,6 +256,37 @@ export function createNip71IngestService({
|
||||
reason: "nip71-ingest",
|
||||
});
|
||||
}
|
||||
const emittedAt = typeof performanceRef?.now === "function" ? performanceRef.now() : filteredAt;
|
||||
performanceHarness?.mark?.("nip71-refresh-dispatch-end", {
|
||||
filterMs: Math.max(0, filteredAt - startedAt),
|
||||
dispatchMs: Math.max(0, emittedAt - filteredAt),
|
||||
videos: videos.length,
|
||||
});
|
||||
if (performanceHarness && (filteredAt - startedAt > 50 || emittedAt - filteredAt > 50)) {
|
||||
userLogger.info(
|
||||
`[nip71Ingest] refresh timing: filter ${Math.round(filteredAt - startedAt)}ms, dispatch ${Math.round(emittedAt - filteredAt)}ms, videos ${videos.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function finishInitialSync() {
|
||||
if (!initialSyncing) {
|
||||
return;
|
||||
}
|
||||
if (initialSyncTimer) {
|
||||
clearTimeout(initialSyncTimer);
|
||||
initialSyncTimer = null;
|
||||
}
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
flush();
|
||||
}
|
||||
initialSyncing = false;
|
||||
if (initialRefreshPending) {
|
||||
initialRefreshPending = false;
|
||||
requestRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
// Defer the first start until the native feed has rendered (first
|
||||
@@ -255,7 +308,7 @@ export function createNip71IngestService({
|
||||
deferTimer = null;
|
||||
}
|
||||
deferArmed = false;
|
||||
start();
|
||||
start({ coalesceInitialSync: true });
|
||||
};
|
||||
|
||||
if (typeof nostrService?.on === "function") {
|
||||
@@ -265,12 +318,17 @@ export function createNip71IngestService({
|
||||
return true;
|
||||
}
|
||||
|
||||
function start() {
|
||||
function start({ coalesceInitialSync = false } = {}) {
|
||||
if (started || !isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
started = true;
|
||||
attemptsLeft = maxOpenAttempts;
|
||||
initialSyncing = coalesceInitialSync;
|
||||
initialRefreshPending = false;
|
||||
if (initialSyncing) {
|
||||
initialSyncTimer = setTimeout(finishInitialSync, initialSyncFallbackMs);
|
||||
}
|
||||
|
||||
// Re-subscribe when the whitelist changes so the author scope stays current.
|
||||
if (typeof accessControl?.onWhitelistChange === "function") {
|
||||
@@ -320,6 +378,7 @@ export function createNip71IngestService({
|
||||
scheduleFlush();
|
||||
}
|
||||
},
|
||||
onEose: finishInitialSync,
|
||||
});
|
||||
const authorCount = Array.isArray(filters[0]?.authors)
|
||||
? filters[0].authors.length
|
||||
@@ -348,6 +407,12 @@ export function createNip71IngestService({
|
||||
clearTimeout(pendingRefreshTimer);
|
||||
pendingRefreshTimer = null;
|
||||
}
|
||||
if (initialSyncTimer) {
|
||||
clearTimeout(initialSyncTimer);
|
||||
initialSyncTimer = null;
|
||||
}
|
||||
initialSyncing = false;
|
||||
initialRefreshPending = false;
|
||||
buffer = [];
|
||||
if (subscription && typeof subscription.close === "function") {
|
||||
try {
|
||||
|
||||
@@ -75,9 +75,10 @@ export function resolveMirrorToggle({ enabled, eligibility } = {}) {
|
||||
return { action: "publish" };
|
||||
}
|
||||
|
||||
// Account-level "auto-share new public videos" preference (per pubkey, off by
|
||||
// default). When on, newly published eligible public videos are mirrored
|
||||
// automatically — no per-video opt-in needed.
|
||||
// Account-level default for new public videos (per pubkey, on by default). A
|
||||
// missing value means "share" so existing users receive the new default; an
|
||||
// explicit false is the durable account-level opt-out. An individual upload can
|
||||
// still opt out without changing this preference.
|
||||
function readAutoShare() {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
@@ -93,7 +94,7 @@ function readAutoShare() {
|
||||
|
||||
export function isAutoShareEnabled(pubkey) {
|
||||
const pk = norm(pubkey);
|
||||
return pk ? readAutoShare()[pk] === true : false;
|
||||
return pk ? readAutoShare()[pk] !== false : false;
|
||||
}
|
||||
|
||||
export function setAutoShareEnabled(pubkey, enabled) {
|
||||
@@ -102,11 +103,7 @@ export function setAutoShareEnabled(pubkey, enabled) {
|
||||
return;
|
||||
}
|
||||
const map = readAutoShare();
|
||||
if (enabled === true) {
|
||||
map[pk] = true;
|
||||
} else {
|
||||
delete map[pk];
|
||||
}
|
||||
map[pk] = enabled === true;
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(AUTO_SHARE_KEY, JSON.stringify(map));
|
||||
@@ -116,8 +113,9 @@ export function setAutoShareEnabled(pubkey, enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
// On publish of a NEW video: mirror it only when auto-share is on AND it's
|
||||
// eligible (public + hosted URL + allowed). Otherwise do nothing.
|
||||
// On publish of a NEW video: mirror it only when the account default and the
|
||||
// per-upload choice permit it, and it is eligible (public + hosted URL +
|
||||
// allowed). Otherwise do nothing.
|
||||
export function resolvePublishSync({ featureOn, autoShare, eligible } = {}) {
|
||||
if (featureOn !== true || autoShare !== true) {
|
||||
return { action: "none" };
|
||||
|
||||
@@ -72,6 +72,36 @@ export function createNip71MirrorService({
|
||||
return [];
|
||||
}
|
||||
},
|
||||
// One relay query for all roots in the My Videos view. Kept separate from the
|
||||
// single-root lookup so publish/remove can remain narrowly scoped.
|
||||
fetchMirrorsByRoots = async ({ pubkey, roots }) => {
|
||||
const sm = getSubscriptionManager();
|
||||
const relays = getWriteRelays() || [];
|
||||
const uniqueRoots = [...new Set((Array.isArray(roots) ? roots : []).map(str).filter(Boolean))];
|
||||
if (!sm || typeof sm.list !== "function" || !relays.length || !uniqueRoots.length) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const events = await sm.list({
|
||||
filters: [
|
||||
{
|
||||
kinds: [NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND],
|
||||
authors: [pubkey],
|
||||
"#d": uniqueRoots,
|
||||
},
|
||||
],
|
||||
relays,
|
||||
});
|
||||
return (Array.isArray(events) ? events : []).map((event) => ({
|
||||
root: str(event?.tags?.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1]),
|
||||
kind: event?.kind,
|
||||
created_at: Number(event?.created_at) || 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
userLogger.warn("[nip71Mirror] mirror batch lookup failed:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
publishEventToRelays = defaultPublishEventToRelays,
|
||||
summarizePublishResults = defaultSummarize,
|
||||
signEvent = async (template) => {
|
||||
@@ -131,26 +161,37 @@ export function createNip71MirrorService({
|
||||
let forcedShort = options.short; // explicit caller override always wins
|
||||
let staleKind = null;
|
||||
let reusedExistingKind = false;
|
||||
if (forcedShort === undefined && pubkey && root) {
|
||||
let newestExisting = null;
|
||||
if (pubkey && root) {
|
||||
const existing = await fetchExistingMirrors({ pubkey, root });
|
||||
if (Array.isArray(existing) && existing.length) {
|
||||
const newest = existing.reduce((a, b) =>
|
||||
(b.created_at || 0) > (a.created_at || 0) ? b : a,
|
||||
);
|
||||
forcedShort = newest.kind === NIP71_SHORT_VIDEO_KIND;
|
||||
reusedExistingKind = true;
|
||||
const otherKind = forcedShort
|
||||
? NIP71_NORMAL_VIDEO_KIND
|
||||
: NIP71_SHORT_VIDEO_KIND;
|
||||
if (existing.some((e) => e.kind === otherKind)) {
|
||||
staleKind = otherKind;
|
||||
newestExisting = newest;
|
||||
if (forcedShort === undefined) {
|
||||
forcedShort = newest.kind === NIP71_SHORT_VIDEO_KIND;
|
||||
reusedExistingKind = true;
|
||||
const otherKind = forcedShort
|
||||
? NIP71_NORMAL_VIDEO_KIND
|
||||
: NIP71_SHORT_VIDEO_KIND;
|
||||
if (existing.some((e) => e.kind === otherKind)) {
|
||||
staleKind = otherKind;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Addressable events only replace an older coordinate. Match the canonical
|
||||
// edit path: a fast second edit or a clock behind an existing relay event
|
||||
// must still advance the mirror.
|
||||
const createdAt = Math.max(
|
||||
Math.floor(now() / 1000),
|
||||
(Number(newestExisting?.created_at) || 0) + 1,
|
||||
);
|
||||
|
||||
const built = buildMirrorEvent(
|
||||
{ ...video, pubkey },
|
||||
{ ...options, short: forcedShort, createdAt: Math.floor(now() / 1000) },
|
||||
{ ...options, short: forcedShort, createdAt },
|
||||
);
|
||||
if (!built.ok) {
|
||||
return { ok: false, reason: built.reason };
|
||||
@@ -181,7 +222,7 @@ export function createNip71MirrorService({
|
||||
const del = await signEvent({
|
||||
kind: 5,
|
||||
pubkey,
|
||||
created_at: Math.floor(now() / 1000),
|
||||
created_at: createdAt,
|
||||
content: "Removed duplicate NIP-71 mirror",
|
||||
tags: [
|
||||
["a", `${staleKind}:${pubkey}:${root}`],
|
||||
@@ -220,7 +261,13 @@ export function createNip71MirrorService({
|
||||
return { ok: false, error: "invalid" };
|
||||
}
|
||||
|
||||
const createdAt = Math.floor(now() / 1000);
|
||||
const existing = await fetchExistingMirrors({ pubkey, root });
|
||||
const newestCreatedAt = Array.isArray(existing)
|
||||
? existing.reduce((max, event) => Math.max(max, Number(event?.created_at) || 0), 0)
|
||||
: 0;
|
||||
// NIP-09 only deletes addressable versions at or before the delete event,
|
||||
// and empty replacements must likewise win their coordinate.
|
||||
const createdAt = Math.max(Math.floor(now() / 1000), newestCreatedAt + 1);
|
||||
const reason = str(options.reason) || "Removed NIP-71 mirror";
|
||||
|
||||
const deleteEvent = {
|
||||
@@ -236,20 +283,17 @@ export function createNip71MirrorService({
|
||||
],
|
||||
};
|
||||
|
||||
const width = Number(video?.width);
|
||||
const height = Number(video?.height);
|
||||
const portrait =
|
||||
Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 && height > width;
|
||||
const tombstoneKind =
|
||||
options.short === true || (options.short !== false && portrait) ? 34236 : 34235;
|
||||
const tombstone = {
|
||||
kind: tombstoneKind,
|
||||
// Empty-replace both coordinates. The NIP-09 delete is the canonical
|
||||
// teardown, but clients that ignore it must not retain an old mirror when
|
||||
// a source replacement changed portrait/landscape orientation.
|
||||
const tombstones = [NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND].map((kind) => ({
|
||||
kind,
|
||||
pubkey,
|
||||
created_at: createdAt,
|
||||
// No imeta → nothing plays; title kept so the entry reads as removed.
|
||||
tags: [["d", root], ["title", str(video?.title) || "[removed]"], ["client", "bitvid"]],
|
||||
content: "",
|
||||
};
|
||||
}));
|
||||
|
||||
const relays = getWriteRelays() || [];
|
||||
const pool = getPool();
|
||||
@@ -259,7 +303,7 @@ export function createNip71MirrorService({
|
||||
|
||||
let publishedOk = 0;
|
||||
let total = 0;
|
||||
for (const template of [deleteEvent, tombstone]) {
|
||||
for (const template of [deleteEvent, ...tombstones]) {
|
||||
total += 1;
|
||||
let signed;
|
||||
try {
|
||||
@@ -307,22 +351,15 @@ export function createNip71MirrorService({
|
||||
if (!pubkey || !roots.length) {
|
||||
return out;
|
||||
}
|
||||
// fetchExistingMirrors filters on a single #d; query each root but in one
|
||||
// round via the shared lookup (the SubscriptionManager batches internally).
|
||||
const results = await Promise.all(
|
||||
roots.map(async (root) => {
|
||||
const existing = await fetchExistingMirrors({ pubkey, root });
|
||||
const kinds = [
|
||||
...new Set(
|
||||
(Array.isArray(existing) ? existing : [])
|
||||
.map((e) => e.kind)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
return [root, kinds];
|
||||
}),
|
||||
);
|
||||
for (const [root, kinds] of results) {
|
||||
const events = await fetchMirrorsByRoots({ pubkey, roots });
|
||||
const kindsByRoot = new Map(roots.map((root) => [root, new Set()]));
|
||||
for (const event of Array.isArray(events) ? events : []) {
|
||||
if (kindsByRoot.has(event?.root) && event?.kind) {
|
||||
kindsByRoot.get(event.root).add(event.kind);
|
||||
}
|
||||
}
|
||||
for (const [root, kindsSet] of kindsByRoot) {
|
||||
const kinds = [...kindsSet];
|
||||
out.set(root, {
|
||||
mirrored: kinds.length > 0,
|
||||
kinds,
|
||||
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
} from "./nip71MirrorFlags.js";
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
// On publish of a NEW video, auto-mirror it when the account opted into
|
||||
// "auto-share new public videos" and it's eligible. Sets the per-video flag so
|
||||
// later edits/deletes keep it in sync. Best-effort.
|
||||
// On publish of a NEW video, mirror it when the account default permits it and
|
||||
// this upload has not explicitly opted out. Sets the per-video flag so later
|
||||
// edits/deletes keep it in sync. Best-effort.
|
||||
export async function syncNip71MirrorAfterPublish(detail = {}) {
|
||||
const pubkey = typeof detail?.pubkey === "string" ? detail.pubkey : "";
|
||||
const legacyEvent = detail?.result?.legacy;
|
||||
@@ -38,7 +38,8 @@ export async function syncNip71MirrorAfterPublish(detail = {}) {
|
||||
const withPubkey = { ...video, pubkey };
|
||||
const decision = resolvePublishSync({
|
||||
featureOn: FEATURE_NIP71_MIRROR,
|
||||
autoShare: isAutoShareEnabled(pubkey),
|
||||
autoShare:
|
||||
isAutoShareEnabled(pubkey) && detail?.payload?.mirrorNip71 !== false,
|
||||
eligible: nip71MirrorService.canMirror(withPubkey).ok,
|
||||
});
|
||||
if (decision.action !== "publish") {
|
||||
@@ -54,17 +55,38 @@ export async function syncNip71MirrorAfterPublish(detail = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncNip71MirrorAfterEdit({ updatedData, pubkey } = {}) {
|
||||
export async function syncNip71MirrorAfterEdit({
|
||||
updatedData,
|
||||
originalEvent,
|
||||
result,
|
||||
pubkey,
|
||||
} = {}) {
|
||||
if (!updatedData || typeof updatedData !== "object") {
|
||||
return;
|
||||
}
|
||||
let publishedVideo = null;
|
||||
try {
|
||||
if (result && typeof result === "object") {
|
||||
const parsed = convertEventToVideo(result);
|
||||
if (parsed && !parsed.invalid) {
|
||||
publishedVideo = parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[nip71MirrorSync] couldn't parse edited video", error);
|
||||
}
|
||||
const videoRootId =
|
||||
typeof updatedData.videoRootId === "string" ? updatedData.videoRootId : "";
|
||||
(typeof publishedVideo?.videoRootId === "string" && publishedVideo.videoRootId) ||
|
||||
(typeof updatedData.videoRootId === "string" && updatedData.videoRootId) ||
|
||||
(typeof originalEvent?.videoRootId === "string" && originalEvent.videoRootId) ||
|
||||
"";
|
||||
const authorPubkey = typeof pubkey === "string" ? pubkey : "";
|
||||
if (!videoRootId || !authorPubkey) {
|
||||
return;
|
||||
}
|
||||
const video = { ...updatedData, pubkey: authorPubkey, videoRootId };
|
||||
// Prefer the actual signed canonical result: it includes fields preserved by
|
||||
// the edit builder (root, dimensions, hashes) that the form does not resend.
|
||||
const video = { ...updatedData, ...publishedVideo, pubkey: authorPubkey, videoRootId };
|
||||
const decision = resolveEditSync({
|
||||
featureOn: FEATURE_NIP71_MIRROR,
|
||||
enabled: isMirrorEnabled(authorPubkey, videoRootId),
|
||||
@@ -78,8 +100,12 @@ export async function syncNip71MirrorAfterEdit({ updatedData, pubkey } = {}) {
|
||||
await nip71MirrorService.publish(video);
|
||||
} else {
|
||||
// "unshare": no longer eligible (e.g. flipped private) — pull it down.
|
||||
await nip71MirrorService.remove(video);
|
||||
setMirrorEnabled(authorPubkey, videoRootId, false);
|
||||
const removal = await nip71MirrorService.remove(video);
|
||||
// Keep the flag when the relay operation failed so a later edit can retry
|
||||
// rather than silently orphaning a previously public mirror.
|
||||
if (removal?.ok) {
|
||||
setMirrorEnabled(authorPubkey, videoRootId, false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[nip71MirrorSync] edit sync failed", error);
|
||||
@@ -95,8 +121,13 @@ export function initNip71MirrorSync(nostrService) {
|
||||
return () => {};
|
||||
}
|
||||
registered = true;
|
||||
const offEdit = nostrService.on("videos:edited", ({ updatedData, pubkey } = {}) => {
|
||||
void syncNip71MirrorAfterEdit({ updatedData, pubkey });
|
||||
const offEdit = nostrService.on("videos:edited", ({
|
||||
updatedData,
|
||||
originalEvent,
|
||||
result,
|
||||
pubkey,
|
||||
} = {}) => {
|
||||
void syncNip71MirrorAfterEdit({ updatedData, originalEvent, result, pubkey });
|
||||
});
|
||||
const offDelete = nostrService.on("videos:deleted", ({ videoRootId, video, pubkey } = {}) => {
|
||||
void syncNip71MirrorAfterDelete({ videoRootId, video, pubkey });
|
||||
|
||||
+49
-29
@@ -1071,6 +1071,12 @@ export class NostrService {
|
||||
|
||||
this.dmActorPubkey = normalizedActor;
|
||||
|
||||
const relaySelection = await this.resolveDirectMessageRelaySelection(
|
||||
normalizedActor,
|
||||
relays,
|
||||
);
|
||||
const resolvedRelays = relaySelection.relays;
|
||||
|
||||
let resolvedSince =
|
||||
Number.isFinite(sinceCandidate) && sinceCandidate >= 0
|
||||
? Math.floor(sinceCandidate)
|
||||
@@ -1095,7 +1101,7 @@ export class NostrService {
|
||||
let messages = [];
|
||||
try {
|
||||
messages = await this.nostrClient.listDirectMessages(normalizedActor, {
|
||||
relays,
|
||||
relays: resolvedRelays,
|
||||
...options,
|
||||
limit: resolvedLimit,
|
||||
decryptLimit: resolvedDecryptLimit,
|
||||
@@ -1133,6 +1139,14 @@ export class NostrService {
|
||||
messages: snapshot,
|
||||
actorPubkey: normalizedActor,
|
||||
});
|
||||
if (relaySelection.warning === DM_RELAY_WARNING_FALLBACK) {
|
||||
this.emit("directMessages:relayWarning", {
|
||||
actorPubkey: normalizedActor,
|
||||
warning: relaySelection.warning,
|
||||
relays: resolvedRelays,
|
||||
source: relaySelection.source,
|
||||
});
|
||||
}
|
||||
this.emit("directMessages:updated", {
|
||||
messages: snapshot,
|
||||
reason: "load",
|
||||
@@ -1156,6 +1170,35 @@ export class NostrService {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async resolveDirectMessageRelaySelection(actorPubkey, relays = null) {
|
||||
if (Array.isArray(relays)) {
|
||||
return { relays, source: "explicit", warning: null };
|
||||
}
|
||||
|
||||
const discoveryRelays = Array.isArray(this.nostrClient?.readRelays)
|
||||
? this.nostrClient.readRelays
|
||||
: Array.isArray(this.nostrClient?.relays)
|
||||
? this.nostrClient.relays
|
||||
: [];
|
||||
|
||||
let pool = this.nostrClient?.pool || null;
|
||||
if (!pool && typeof this.nostrClient?.ensurePool === "function") {
|
||||
try {
|
||||
pool = await this.nostrClient.ensurePool();
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostrService] Failed to initialize DM relay pool.", error);
|
||||
}
|
||||
}
|
||||
|
||||
return resolveDmRelaySelection({
|
||||
pubkey: actorPubkey,
|
||||
discoveryRelays,
|
||||
fallbackRelays: discoveryRelays,
|
||||
pool,
|
||||
log: { dev: devLogger, user: userLogger },
|
||||
});
|
||||
}
|
||||
|
||||
async ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) {
|
||||
const activeActor = actorPubkey || this.resolveActiveDmActor();
|
||||
const normalizedActor = normalizeHexPubkey(activeActor);
|
||||
@@ -1189,34 +1232,11 @@ export class NostrService {
|
||||
this.stopDirectMessageSubscription();
|
||||
}
|
||||
|
||||
let resolvedRelays = Array.isArray(relays) ? relays : null;
|
||||
let relaySelection = null;
|
||||
|
||||
if (!resolvedRelays) {
|
||||
const discoveryRelays = Array.isArray(this.nostrClient?.readRelays)
|
||||
? this.nostrClient.readRelays
|
||||
: Array.isArray(this.nostrClient?.relays)
|
||||
? this.nostrClient.relays
|
||||
: [];
|
||||
|
||||
let pool = this.nostrClient?.pool || null;
|
||||
if (!pool && typeof this.nostrClient?.ensurePool === "function") {
|
||||
try {
|
||||
pool = await this.nostrClient.ensurePool();
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostrService] Failed to initialize DM relay pool.", error);
|
||||
}
|
||||
}
|
||||
|
||||
relaySelection = await resolveDmRelaySelection({
|
||||
pubkey: normalizedActor,
|
||||
discoveryRelays,
|
||||
fallbackRelays: discoveryRelays,
|
||||
pool,
|
||||
log: { dev: devLogger, user: userLogger },
|
||||
});
|
||||
resolvedRelays = relaySelection.relays;
|
||||
}
|
||||
const relaySelection = await this.resolveDirectMessageRelaySelection(
|
||||
normalizedActor,
|
||||
relays,
|
||||
);
|
||||
const resolvedRelays = relaySelection.relays;
|
||||
|
||||
try {
|
||||
const subscription = this.nostrClient.subscribeDirectMessages(
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import { PLAYBACK_START_TIMEOUT } from "../constants.js";
|
||||
import {
|
||||
HLS_PLAYBACK_START_TIMEOUT,
|
||||
PLAYBACK_START_TIMEOUT,
|
||||
} from "../constants.js";
|
||||
import { getCurrentVideo, setCurrentVideo } from "../state/appState.js";
|
||||
import {
|
||||
consumeAutoplayFallbackMute,
|
||||
muteForAutoplayFallback,
|
||||
} from "../utils/autoplayPreference.js";
|
||||
import {
|
||||
SimpleEventEmitter,
|
||||
extractWebSeedsFromMagnet,
|
||||
} from "./playbackHelpers.js";
|
||||
import { attachHlsSource, detachHls, isHlsUrl } from "./hlsPlayback.js";
|
||||
// js/services/playbackService.js
|
||||
|
||||
/**
|
||||
@@ -330,11 +338,17 @@ export class PlaybackService {
|
||||
return;
|
||||
}
|
||||
const storedUnmuted = localStorage.getItem("unmutedAutoplay");
|
||||
const userWantsUnmuted = storedUnmuted === "true";
|
||||
// Sound is the default for an explicit watch action. Browsers may still
|
||||
// reject unmuted autoplay, in which case the coordinator retries muted.
|
||||
// Preserve a viewer's explicit mute choice across subsequent videos.
|
||||
const userWantsUnmuted = storedUnmuted !== "false";
|
||||
videoElement.muted = !userWantsUnmuted;
|
||||
|
||||
if (!videoElement.dataset.autoplayBound) {
|
||||
videoElement.addEventListener("volumechange", () => {
|
||||
if (consumeAutoplayFallbackMute(videoElement)) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(
|
||||
"unmutedAutoplay",
|
||||
(!videoElement.muted).toString()
|
||||
@@ -344,6 +358,10 @@ export class PlaybackService {
|
||||
}
|
||||
}
|
||||
|
||||
muteForAutoplayFallback(videoElement) {
|
||||
return muteForAutoplayFallback(videoElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of event listeners ("watchdogs") on the video element to detect
|
||||
* when playback has stalled or failed.
|
||||
@@ -841,6 +859,10 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
|
||||
const resetVideoElement = () => {
|
||||
if (!activeVideoEl) return;
|
||||
// Kill any hls.js instance BEFORE touching src/load(): a live instance
|
||||
// keeps appending to the MediaSource and would fight the reset (and
|
||||
// leak its worker) when we move to the next mirror or to WebTorrent.
|
||||
detachHls(activeVideoEl);
|
||||
try {
|
||||
activeVideoEl.pause();
|
||||
} catch (err) {
|
||||
@@ -1041,6 +1063,11 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
};
|
||||
});
|
||||
|
||||
// Whatever MediaError the element already carries from a previous
|
||||
// load. Identity comparison works because starting a new load clears
|
||||
// `error` to null and a fresh failure allocates a new MediaError.
|
||||
const staleMediaError = activeVideoEl.error || null;
|
||||
|
||||
const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
|
||||
this.registerWatchdogs(activeVideoEl, {
|
||||
stallMs,
|
||||
@@ -1057,7 +1084,17 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
attachWatchdogs({ stallMs: 0 });
|
||||
return;
|
||||
}
|
||||
const errorMessage = getHostedVideoErrorMessage(activeVideoEl);
|
||||
// Only report a MediaError this attempt actually produced.
|
||||
// teardownVideoElement()/resetVideoElement() clear `src` and
|
||||
// call load(), which leaves a stale MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
// ("Empty src attribute") on the element. Reading it blindly
|
||||
// reported "Hosted playback failed: source not supported or
|
||||
// blocked." for a stream that was playing perfectly — a
|
||||
// failure message attributed to the wrong load entirely.
|
||||
const errorMessage =
|
||||
activeVideoEl.error && activeVideoEl.error !== staleMediaError
|
||||
? getHostedVideoErrorMessage(activeVideoEl)
|
||||
: "";
|
||||
if (errorMessage) {
|
||||
this.emit("status", { message: errorMessage });
|
||||
}
|
||||
@@ -1066,9 +1103,6 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
});
|
||||
};
|
||||
|
||||
// Use default stall timeout here; the initial start timeout is handled by the race wrapper
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
|
||||
const handleFatalPlaybackError = (err) => {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Direct URL playback threw:",
|
||||
@@ -1077,6 +1111,14 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
outcomeResolver({ status: "fallback", reason: "play-error" });
|
||||
};
|
||||
|
||||
// Bind the source BEFORE arming the watchdogs. hls.js's attachMedia()
|
||||
// points `src` at a MediaSource blob URL, which aborts any load still
|
||||
// pending on the reused modal element and fires `abort`/`emptied`.
|
||||
// With watchdogs already live those read as a playback failure and
|
||||
// abandon a stream that is about to play fine. A plain `src =` is
|
||||
// safe either way: media events are dispatched asynchronously, so
|
||||
// listeners registered later in the same task still observe them.
|
||||
let hlsAttached = true;
|
||||
try {
|
||||
// Guarantee the direct load is CORS-free regardless of how we got
|
||||
// here (see resetVideoElement) so any hosted URL plays reliably.
|
||||
@@ -1086,12 +1128,65 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
// ignore
|
||||
}
|
||||
activeVideoEl.crossOrigin = null;
|
||||
activeVideoEl.src = candidateUrl;
|
||||
const playPromise = activeVideoEl.play();
|
||||
|
||||
// HLS cannot be assigned to `src` outside Safari — it needs hls.js
|
||||
// + MSE. attachHlsSource() handles both paths and resolves once the
|
||||
// manifest is parsed. A null result means HLS is unplayable here
|
||||
// (or already failed fatally), and the outcome promise is settled,
|
||||
// so skip play() and let the normal fallback plumbing take over.
|
||||
if (isHlsUrl(candidateUrl)) {
|
||||
const hlsHandle = await attachHlsSource(
|
||||
activeVideoEl,
|
||||
candidateUrl,
|
||||
{
|
||||
log: (...args) => this.service.log(...args),
|
||||
onFatalError: (message) => {
|
||||
this.emit("status", { message });
|
||||
outcomeResolver({
|
||||
status: "fallback",
|
||||
reason: "hls-error",
|
||||
errorMessage: message,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!hlsHandle) {
|
||||
hlsAttached = false;
|
||||
// A fatal HLS error already resolved the outcome with a
|
||||
// specific message; this only covers "no HLS support at all".
|
||||
const message =
|
||||
"Hosted playback failed: this browser cannot play HLS streams.";
|
||||
this.emit("status", { message });
|
||||
outcomeResolver({
|
||||
status: "fallback",
|
||||
reason: "hls-unsupported",
|
||||
errorMessage: message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
activeVideoEl.src = candidateUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
hlsAttached = false;
|
||||
handleFatalPlaybackError(err);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use default stall timeout here; the initial start timeout is
|
||||
// handled by the race wrapper.
|
||||
if (hlsAttached) {
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
}
|
||||
|
||||
const playPromise = hlsAttached ? activeVideoEl.play() : null;
|
||||
if (playPromise && typeof playPromise.catch === "function") {
|
||||
playPromise.catch((err) => {
|
||||
if (err?.name === "NotAllowedError") {
|
||||
autoplayBlocked = true;
|
||||
// Suspend the start timeout: we are waiting on the viewer,
|
||||
// not on a slow source, and falling back to P2P here strands
|
||||
// them on a swarm that may have no peers.
|
||||
startTimeoutGuard.deferred = true;
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Autoplay blocked by browser; awaiting user interaction.",
|
||||
err
|
||||
@@ -1105,6 +1200,7 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
activeVideoEl.removeEventListener("play", restoreOnPlay);
|
||||
this.cleanupWatchdog();
|
||||
autoplayBlocked = false;
|
||||
startTimeoutGuard.deferred = false;
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
};
|
||||
activeVideoEl.addEventListener("play", restoreOnPlay, {
|
||||
@@ -1157,10 +1253,29 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
};
|
||||
|
||||
// --- Timeout Wrapper ---
|
||||
// `startTimeoutGuard.deferred` is set when the browser blocks autoplay and
|
||||
// we are waiting on a user gesture. That is NOT a stalled source, and
|
||||
// treating it as one was breaking mobile outright: a deep link from a
|
||||
// nostr client arrives with no user gesture, so autoplay is refused, the
|
||||
// 3s start timeout fired anyway, and a video with a magnet got yanked into
|
||||
// WebTorrent -- which then found no peers and played nothing, while the
|
||||
// "Press play" prompt the viewer was meant to act on had already been
|
||||
// torn down. Desktop usually hides this because an established Media
|
||||
// Engagement score lets autoplay through.
|
||||
const startTimeoutGuard = { deferred: false };
|
||||
const withTimeout = (promise, ms, label = "Operation") => {
|
||||
if (!ms || ms <= 0) return promise;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (startTimeoutGuard.deferred) {
|
||||
// Waiting on the viewer, not on the network. Let the underlying
|
||||
// attempt settle on its own: it resolves when they press play, or
|
||||
// when a real media error/stall fires.
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] ${label} start timeout suppressed; autoplay is blocked and we are awaiting a user gesture.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.service.log(`[playVideoWithFallback] ${label} timed out after ${ms}ms.`);
|
||||
resolve({ status: "fallback", reason: "timeout", source: "timeout" });
|
||||
}, ms);
|
||||
@@ -1201,9 +1316,15 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
let lastUrlResult = null;
|
||||
for (let i = 0; i < this.hostedSourceCandidates.length; i += 1) {
|
||||
const candidateUrl = this.hostedSourceCandidates[i];
|
||||
// HLS starts slower by construction (see HLS_PLAYBACK_START_TIMEOUT).
|
||||
// `forcedSource` still means "wait indefinitely", so keep 0 as 0.
|
||||
const candidateTimeout =
|
||||
effectiveTimeout && isHlsUrl(candidateUrl)
|
||||
? Math.max(effectiveTimeout, HLS_PLAYBACK_START_TIMEOUT)
|
||||
: effectiveTimeout;
|
||||
const urlResult = await withTimeout(
|
||||
attemptHostedPlayback(candidateUrl),
|
||||
effectiveTimeout,
|
||||
candidateTimeout,
|
||||
"URL Playback"
|
||||
);
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ async function waitForAccessControl({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS }
|
||||
|
||||
// Cached admin lists (hydrated synchronously from localStorage at
|
||||
// construction) are enough to seed trust — don't stall boot behind the relay
|
||||
// refresh. If the refresh later changes editors/whitelist/blacklist, the
|
||||
// refresh. If the refresh later changes editors/blacklist, the
|
||||
// change listeners registered in bootstrapTrustedSeeds re-apply the seeds.
|
||||
if (
|
||||
typeof accessControl.isHydrated === "function" &&
|
||||
@@ -189,10 +189,6 @@ export async function bootstrapTrustedSeeds() {
|
||||
applySeeds();
|
||||
};
|
||||
|
||||
if (accessControl && typeof accessControl.onWhitelistChange === "function") {
|
||||
accessControl.onWhitelistChange(applyOnChange);
|
||||
}
|
||||
|
||||
if (accessControl && typeof accessControl.onEditorsChange === "function") {
|
||||
accessControl.onEditorsChange(applyOnChange);
|
||||
}
|
||||
|
||||
@@ -459,6 +459,11 @@ export function normalizeVideoNotePayload(input) {
|
||||
|
||||
const publishPayload = {
|
||||
legacyFormData,
|
||||
// This is publish-session control metadata, not canonical video content.
|
||||
// Keep it outside legacyFormData so it is never serialized into the 30078
|
||||
// event, but preserve the uploader's explicit NIP-71 opt-out for the
|
||||
// post-publish mirror hook.
|
||||
mirrorNip71: rawPayload.mirrorNip71 !== false,
|
||||
};
|
||||
|
||||
if (normalizedNip71) {
|
||||
|
||||
+31
-49
@@ -180,6 +180,7 @@ class SubscriptionsManager {
|
||||
this.subscribedPubkeys = new Set();
|
||||
this.subsEventId = null;
|
||||
this.subsEventCreatedAt = null;
|
||||
this.subsEventPubkey = null;
|
||||
// Last live-subscription event id we triggered a refresh for (loop guard).
|
||||
this._lastHandledListEventId = null;
|
||||
// Most recently fetched list ciphertext, reused by decrypt retries so they
|
||||
@@ -247,6 +248,7 @@ class SubscriptionsManager {
|
||||
this.subscribedPubkeys = new Set(cachedSnapshot.subscribedPubkeys);
|
||||
this.subsEventId = cachedSnapshot.eventId;
|
||||
this.subsEventCreatedAt = cachedSnapshot.createdAt;
|
||||
this.subsEventPubkey = cachedSnapshot.eventPubkey;
|
||||
this.currentUserPubkey = normalizedUserPubkey;
|
||||
this.uiReady = true;
|
||||
this.dataReady = true;
|
||||
@@ -281,6 +283,7 @@ class SubscriptionsManager {
|
||||
subscribedPubkeys: Array.from(this.subscribedPubkeys),
|
||||
eventId: this.subsEventId,
|
||||
createdAt: this.subsEventCreatedAt,
|
||||
eventPubkey: this.subsEventPubkey,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -389,63 +392,39 @@ class SubscriptionsManager {
|
||||
profileCache.getProfileData(normalizedUserPubkey, "subscriptions"),
|
||||
);
|
||||
hadCachedSnapshot = cachedSnapshot.hasSnapshot;
|
||||
const shouldForceFullFetch = !cachedSnapshot.hasSnapshot;
|
||||
// Legacy snapshots did not record the source author. Fetch their full
|
||||
// authoritative state once so a cache created by an older buggy build
|
||||
// cannot keep suppressing the real list with an invalid timestamp.
|
||||
const shouldForceFullFetch =
|
||||
!cachedSnapshot.hasSnapshot ||
|
||||
cachedSnapshot.eventPubkey !== normalizedUserPubkey;
|
||||
const incrementalSince = shouldForceFullFetch
|
||||
? 0
|
||||
: computeIncrementalSinceWithOverlap(cachedSnapshot.createdAt, 1);
|
||||
|
||||
// Fetch user and session actor subscription lists in parallel to reduce
|
||||
// total relay wait time during login.
|
||||
const fetchPromises = [
|
||||
nostrClient.fetchListIncrementally({
|
||||
kind: SUBSCRIPTION_SET_KIND,
|
||||
pubkey: normalizedUserPubkey,
|
||||
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
|
||||
relayUrls,
|
||||
since: incrementalSince,
|
||||
timeoutMs: 12000,
|
||||
}),
|
||||
];
|
||||
|
||||
const normalizedSessionActorPubkey = normalizeNostrPubkey(
|
||||
nostrClient?.sessionActor?.pubkey,
|
||||
);
|
||||
const shouldFetchSessionActor =
|
||||
normalizedSessionActorPubkey &&
|
||||
normalizedSessionActorPubkey !== normalizedUserPubkey;
|
||||
if (shouldFetchSessionActor) {
|
||||
const sessionCachedSnapshot = parseCachedSubscriptionSnapshot(
|
||||
profileCache.getProfileData(
|
||||
normalizedSessionActorPubkey,
|
||||
"subscriptions",
|
||||
),
|
||||
);
|
||||
const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot;
|
||||
const sessionSince = shouldForceSessionFetch
|
||||
? 0
|
||||
: computeIncrementalSinceWithOverlap(sessionCachedSnapshot.createdAt, 1);
|
||||
fetchPromises.push(
|
||||
nostrClient.fetchListIncrementally({
|
||||
kind: SUBSCRIPTION_SET_KIND,
|
||||
pubkey: normalizedSessionActorPubkey,
|
||||
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
|
||||
relayUrls,
|
||||
since: sessionSince,
|
||||
timeoutMs: 12000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const fetchResults = await Promise.all(fetchPromises);
|
||||
let events = fetchResults[0] || [];
|
||||
if (shouldFetchSessionActor && fetchResults[1]?.length) {
|
||||
events = events.concat(fetchResults[1]);
|
||||
}
|
||||
// Subscription lists are private, account-scoped state. In particular,
|
||||
// a BitLogin session actor can differ from the active profile pubkey.
|
||||
// Never mix its events into this read: choosing the newest event across
|
||||
// both accounts could overwrite the active account's subscriptions.
|
||||
const events = await nostrClient.fetchListIncrementally({
|
||||
kind: SUBSCRIPTION_SET_KIND,
|
||||
pubkey: normalizedUserPubkey,
|
||||
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
|
||||
relayUrls,
|
||||
since: incrementalSince,
|
||||
timeoutMs: 12000,
|
||||
});
|
||||
|
||||
if (events.length) {
|
||||
const deduped = new Map();
|
||||
for (const event of events) {
|
||||
if (!event || typeof event !== "object" || !event.id) {
|
||||
if (
|
||||
!event ||
|
||||
typeof event !== "object" ||
|
||||
!event.id ||
|
||||
(event.pubkey &&
|
||||
normalizeHexPubkey(event.pubkey) !== normalizedUserPubkey)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const existing = deduped.get(event.id);
|
||||
@@ -471,6 +450,7 @@ class SubscriptionsManager {
|
||||
this.subscribedPubkeys.clear();
|
||||
this.subsEventId = null;
|
||||
this.subsEventCreatedAt = null;
|
||||
this.subsEventPubkey = null;
|
||||
this.uiReady = true;
|
||||
this.dataReady = true;
|
||||
this.loaded = true;
|
||||
@@ -513,6 +493,7 @@ class SubscriptionsManager {
|
||||
this.subsEventCreatedAt = Number.isFinite(newest.created_at)
|
||||
? newest.created_at
|
||||
: null;
|
||||
this.subsEventPubkey = normalizedUserPubkey;
|
||||
|
||||
let decryptResult;
|
||||
try {
|
||||
@@ -687,6 +668,7 @@ class SubscriptionsManager {
|
||||
this.subscribedPubkeys.clear();
|
||||
this.subsEventId = null;
|
||||
this.subsEventCreatedAt = null;
|
||||
this.subsEventPubkey = null;
|
||||
this._lastHandledListEventId = null;
|
||||
this._pendingDecryptEvent = null;
|
||||
this.currentUserPubkey = null;
|
||||
|
||||
@@ -196,6 +196,7 @@ export function parseCachedSubscriptionSnapshot(cached) {
|
||||
subscribedPubkeys: normalized,
|
||||
eventId: null,
|
||||
createdAt: null,
|
||||
eventPubkey: null,
|
||||
hasSnapshot: true,
|
||||
};
|
||||
}
|
||||
@@ -220,6 +221,7 @@ export function parseCachedSubscriptionSnapshot(cached) {
|
||||
const createdAt = Number.isFinite(createdAtCandidate)
|
||||
? Math.floor(createdAtCandidate)
|
||||
: null;
|
||||
const eventPubkey = normalizeHexPubkey(cached.eventPubkey);
|
||||
const hasSnapshot =
|
||||
Array.isArray(cached.subscribedPubkeys) ||
|
||||
Array.isArray(cached.subPubkeys) ||
|
||||
@@ -229,6 +231,7 @@ export function parseCachedSubscriptionSnapshot(cached) {
|
||||
subscribedPubkeys: normalized,
|
||||
eventId,
|
||||
createdAt,
|
||||
eventPubkey,
|
||||
hasSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -237,6 +240,7 @@ export function parseCachedSubscriptionSnapshot(cached) {
|
||||
subscribedPubkeys: [],
|
||||
eventId: null,
|
||||
createdAt: null,
|
||||
eventPubkey: null,
|
||||
hasSnapshot: false,
|
||||
};
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import ProfileIdentityController from "./profileIdentityController.js";
|
||||
import VideoListViewController from "./videoListViewController.js";
|
||||
import ProfileModalController from "./profileModalController.js";
|
||||
import LoginModalController from "./loginModalController.js";
|
||||
import { wireBitloginLogin } from "./bitloginModalIntegration.js";
|
||||
import { VideoListView } from "./views/VideoListView.js";
|
||||
import MoreMenuController from "./moreMenuController.js";
|
||||
import VideoSettingsMenuController from "./videoSettingsMenuController.js";
|
||||
@@ -165,11 +166,10 @@ export default class ApplicationBootstrap {
|
||||
app.streamHealthSnapshots = new Map();
|
||||
app.boundStreamHealthBadgeHandler = (detail) =>
|
||||
app.handleStreamHealthBadgeUpdate(detail);
|
||||
app.attachHealthBadgesWithCache = (container) => {
|
||||
app.attachHealthBadgesWithCache = (container) =>
|
||||
attachHealthBadges(container, {
|
||||
onUpdate: app.boundStreamHealthBadgeHandler,
|
||||
});
|
||||
};
|
||||
app.defaultModerationSettings = getDefaultModerationSettings();
|
||||
app.moderationSettings = { ...app.defaultModerationSettings };
|
||||
app.relayManager = relayManager;
|
||||
@@ -359,18 +359,22 @@ export default class ApplicationBootstrap {
|
||||
onViewLogged: (detail) => app.handleFeedViewTelemetry(detail),
|
||||
});
|
||||
|
||||
app.exploreDataService =
|
||||
this.services.exploreDataService ||
|
||||
new ExploreDataService({
|
||||
watchHistoryService,
|
||||
nostrService: app.nostrService,
|
||||
getActiveActor: () =>
|
||||
typeof app.pubkey === "string" && app.pubkey ? app.pubkey : "",
|
||||
logger: devLogger,
|
||||
});
|
||||
if (app.exploreDataService && typeof app.exploreDataService.initialize === "function") {
|
||||
app.exploreDataService.initialize();
|
||||
}
|
||||
app.exploreDataService = this.services.exploreDataService || null;
|
||||
app.ensureExploreDataService = async () => {
|
||||
if (!app.exploreDataService) {
|
||||
app.exploreDataService = new ExploreDataService({
|
||||
watchHistoryService,
|
||||
nostrService: app.nostrService,
|
||||
getActiveActor: () =>
|
||||
typeof app.pubkey === "string" && app.pubkey ? app.pubkey : "",
|
||||
logger: devLogger,
|
||||
});
|
||||
}
|
||||
if (typeof app.exploreDataService.initialize === "function") {
|
||||
await app.exploreDataService.initialize({ active: true });
|
||||
}
|
||||
return app.exploreDataService;
|
||||
};
|
||||
|
||||
const playbackDependencies = {
|
||||
torrentClient: this.services.torrentClient || torrentClient,
|
||||
@@ -429,6 +433,7 @@ export default class ApplicationBootstrap {
|
||||
authProviders: this.services.authProviders || authProviders,
|
||||
getAuthProvider: this.services.getAuthProvider || getAuthProvider,
|
||||
});
|
||||
wireBitloginLogin(app);
|
||||
app.authEventUnsubscribes.push(
|
||||
app.authService.on("auth:login", (detail) => {
|
||||
const maybePromise = app.handleAuthLogin(detail);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// js/ui/bitloginModalIntegration.js
|
||||
//
|
||||
// Wires the permanent <bitlogin-auth id="bitloginWidget"> element -- declared
|
||||
// once, statically, in components/login-modal.html, never written by an
|
||||
// innerHTML re-render -- into the exact same session-establishment path every
|
||||
// other sign-in method uses (AuthService.requestLogin -> handleAuthLogin).
|
||||
// BitLogin isn't a single button-click-then-await-a-promise flow like the
|
||||
// other providers, it's a whole multi-screen sign-in/create/recover UI, so it
|
||||
// gets its own widget in the modal instead of a grid button (see
|
||||
// providersForModal in js/services/authProviders/index.js) and its own small
|
||||
// integration module here rather than special-casing LoginModalController.
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import { closeStaticModal } from "./components/staticModalAccessibility.js";
|
||||
import { setModalState as setGlobalModalState } from "../state/appState.js";
|
||||
import { createBitloginAdapter } from "../nostr/adapters/bitloginAdapter.js";
|
||||
import { setPendingBitloginResult } from "../services/authProviders/bitlogin.js";
|
||||
import bitloginProvider from "../services/authProviders/bitlogin.js";
|
||||
import { FEATURE_BITLOGIN } from "../constants.js";
|
||||
|
||||
// Vendored, pinned BitLogin widget bundle (scripts/build-bitlogin-widget.mjs).
|
||||
// Lazy-imported so its ~350KB never weighs on the main bundle for visitors who
|
||||
// never open the login modal, mirroring the existing Bitcoin Connect vendor
|
||||
// step (js/ui/profileModal/ProfileWalletController.js). A plain dynamic
|
||||
// import is enough: the bundle's only job is the customElements.define("bitlogin-auth")
|
||||
// side effect, which retroactively upgrades the already-present <bitlogin-auth>
|
||||
// tag in the modal's static HTML once it resolves.
|
||||
const BITLOGIN_WIDGET_BUNDLE_URL = "../../vendor/bitlogin/bitlogin.js";
|
||||
const PUBKEY_PATTERN = /^[0-9a-f]{64}$/i;
|
||||
|
||||
let widgetLoadPromise = null;
|
||||
|
||||
function loadBitloginWidgetOnce() {
|
||||
if (!widgetLoadPromise) {
|
||||
widgetLoadPromise = import(BITLOGIN_WIDGET_BUNDLE_URL).catch((error) => {
|
||||
widgetLoadPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return widgetLoadPromise;
|
||||
}
|
||||
|
||||
function closeLoginModal() {
|
||||
const modal = document.getElementById("loginModal");
|
||||
if (modal && closeStaticModal(modal)) {
|
||||
setGlobalModalState("login", false);
|
||||
}
|
||||
}
|
||||
|
||||
function isHtmlElement(value, documentRef = document) {
|
||||
const HTMLElementConstructor =
|
||||
documentRef?.defaultView?.HTMLElement || globalThis.HTMLElement;
|
||||
return typeof HTMLElementConstructor === "function"
|
||||
? value instanceof HTMLElementConstructor
|
||||
: Boolean(value && value.nodeType === 1);
|
||||
}
|
||||
|
||||
// Cheap insurance against wireBitloginLogin() ever running twice for the same
|
||||
// element (e.g. a future bootstrap change that calls it more than once): the
|
||||
// same <bitlogin-auth> instance should only ever get one "bitlogin-login"
|
||||
// listener, since two would each race the shared, single-read `pendingResult`
|
||||
// and each independently call requestLogin() for one real sign-in.
|
||||
const wiredWidgets = new WeakSet();
|
||||
|
||||
/**
|
||||
* A CustomEvent's detail is not an authentication boundary: any same-origin
|
||||
* script can dispatch one. Re-read the key from the widget's crypto worker and
|
||||
* require it to match before BitVid adopts the identity or its signer.
|
||||
*/
|
||||
export async function resolveVerifiedBitloginPubkey(widget, claimedPubkey) {
|
||||
const claimed = typeof claimedPubkey === "string" ? claimedPubkey.trim() : "";
|
||||
if (!PUBKEY_PATTERN.test(claimed) || typeof widget?.getPublicKey !== "function") {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const actual = await widget.getPublicKey();
|
||||
const normalizedActual = typeof actual === "string" ? actual.trim() : "";
|
||||
if (!PUBKEY_PATTERN.test(normalizedActual) || normalizedActual.toLowerCase() !== claimed.toLowerCase()) {
|
||||
return "";
|
||||
}
|
||||
return normalizedActual.toLowerCase();
|
||||
} catch (error) {
|
||||
devLogger.warn("[BitLogin] Unable to verify the widget signer:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function attachWidget(app, widget) {
|
||||
const mount = document.getElementById("bitloginMount");
|
||||
if (!FEATURE_BITLOGIN) {
|
||||
// Unstable-branch experiment (AGENTS.md §1, config/instance-config.js) --
|
||||
// leave the permanent, statically-declared markup in place but never load
|
||||
// the vendored bundle or show the (otherwise-empty, unupgraded) element.
|
||||
if (isHtmlElement(mount)) {
|
||||
mount.hidden = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isHtmlElement(mount)) {
|
||||
mount.hidden = false;
|
||||
}
|
||||
|
||||
if (wiredWidgets.has(widget)) {
|
||||
return;
|
||||
}
|
||||
wiredWidgets.add(widget);
|
||||
|
||||
loadBitloginWidgetOnce().catch((error) => {
|
||||
devLogger.warn("[BitLogin] Failed to load the widget bundle:", error);
|
||||
});
|
||||
|
||||
widget.addEventListener("bitlogin-login", async (event) => {
|
||||
const claimedPubkey =
|
||||
event?.detail && typeof event.detail.publicKey === "string"
|
||||
? event.detail.publicKey.trim()
|
||||
: "";
|
||||
const pubkey = await resolveVerifiedBitloginPubkey(widget, claimedPubkey);
|
||||
if (!pubkey) {
|
||||
userLogger.warn("[BitLogin] Ignored an unverifiable login event.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingBitloginResult({
|
||||
pubkey,
|
||||
signer: createBitloginAdapter(widget, pubkey),
|
||||
});
|
||||
|
||||
try {
|
||||
await app.authService.requestLogin({ providerId: "bitlogin" });
|
||||
closeLoginModal();
|
||||
} catch (error) {
|
||||
// bitvid rejecting the sign-in (e.g. the invite-only access-control
|
||||
// check) leaves the widget itself still showing its own "Signed in"
|
||||
// screen -- BitLogin succeeded on its own terms, bitvid just didn't
|
||||
// accept the identity. Reset the widget and surface the real reason
|
||||
// through the same error-banner path every other sign-in method uses
|
||||
// (devLogger.warn is a silent no-op outside dev mode -- see
|
||||
// IS_DEV_MODE in config/instance-config.js -- so relying on it here
|
||||
// meant a rejected user saw no feedback at all).
|
||||
userLogger.warn("[BitLogin] Sign-in failed to apply:", error);
|
||||
const maybePromise = app?.handleLoginModalError?.({
|
||||
error,
|
||||
provider: bitloginProvider,
|
||||
});
|
||||
if (maybePromise && typeof maybePromise.then === "function") {
|
||||
maybePromise.catch((handlerError) => {
|
||||
devLogger.warn(
|
||||
"[BitLogin] handleLoginModalError threw:",
|
||||
handlerError,
|
||||
);
|
||||
});
|
||||
}
|
||||
try {
|
||||
await widget.logout();
|
||||
} catch (logoutError) {
|
||||
devLogger.warn(
|
||||
"[BitLogin] Failed to reset widget after a rejected sign-in:",
|
||||
logoutError,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Call once during app bootstrap, after `app.authService` exists. The login
|
||||
* modal's HTML (including the permanent #bitloginWidget element) loads
|
||||
* asynchronously (js/index.js bootstrapInterface()) and isn't guaranteed to be
|
||||
* in the DOM yet at this point, so this waits for it rather than requiring a
|
||||
* specific call order between the two.
|
||||
* @param {{ authService: { requestLogin: Function } }} app
|
||||
*/
|
||||
export function wireBitloginLogin(app) {
|
||||
if (!app?.authService || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentRef = document;
|
||||
const existing = documentRef.getElementById("bitloginWidget");
|
||||
if (isHtmlElement(existing, documentRef)) {
|
||||
attachWidget(app, existing);
|
||||
return;
|
||||
}
|
||||
|
||||
const Observer =
|
||||
documentRef.defaultView?.MutationObserver || globalThis.MutationObserver;
|
||||
if (typeof Observer !== "function" || !documentRef.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new Observer(() => {
|
||||
const widget = documentRef.getElementById("bitloginWidget");
|
||||
if (isHtmlElement(widget, documentRef)) {
|
||||
observer.disconnect();
|
||||
attachWidget(app, widget);
|
||||
}
|
||||
});
|
||||
observer.observe(documentRef.body, { childList: true, subtree: true });
|
||||
}
|
||||
@@ -351,8 +351,28 @@ export class ShareNostrModal {
|
||||
typeof payload?.shareUrl === "string" ? payload.shareUrl.trim() : "";
|
||||
const thumbnail =
|
||||
typeof payload?.thumbnail === "string" ? payload.thumbnail.trim() : "";
|
||||
const mirrorNaddr =
|
||||
typeof payload?.mirrorNaddr === "string" ? payload.mirrorNaddr.trim() : "";
|
||||
|
||||
return `${title} — Check out this video on bitvid 👇\n\n${shareUrl}\n\n${thumbnail}`;
|
||||
const lines = [`${title} — Check out this video on bitvid 👇`];
|
||||
if (shareUrl) {
|
||||
lines.push(shareUrl);
|
||||
}
|
||||
|
||||
if (mirrorNaddr) {
|
||||
// A `nostr:naddr…` reference renders as a NATIVE video quote card in
|
||||
// nostr clients — the closest thing to a rich preview that doesn't
|
||||
// depend on any client fetching OpenGraph tags. The raw thumbnail URL is
|
||||
// deliberately omitted here: the quote card already shows the thumbnail,
|
||||
// and including both rendered the same image twice.
|
||||
lines.push(`nostr:${mirrorNaddr}`);
|
||||
} else if (thumbnail) {
|
||||
// No mirror to quote — fall back to the bare thumbnail URL, which the
|
||||
// note's NIP-92 `imeta` tag describes so clients inline it as an image.
|
||||
lines.push(thumbnail);
|
||||
}
|
||||
|
||||
return lines.join("\n\n");
|
||||
}
|
||||
|
||||
setVideo(payload) {
|
||||
@@ -380,6 +400,8 @@ export class ShareNostrModal {
|
||||
title,
|
||||
shareUrl,
|
||||
thumbnail,
|
||||
mirrorNaddr:
|
||||
typeof payload?.mirrorNaddr === "string" ? payload.mirrorNaddr : "",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -318,6 +318,7 @@ export class UploadModal {
|
||||
nsfw: $("#check-nsfw"),
|
||||
kids: $("#check-kids"),
|
||||
comments: $("#check-comments"),
|
||||
noNip71Mirror: $("#check-no-nip71-mirror"),
|
||||
summaryUnlock: $("#check-summary-unlock"),
|
||||
|
||||
advanced: $("#btn-advanced-toggle"),
|
||||
@@ -1212,6 +1213,10 @@ export class UploadModal {
|
||||
this.inputs.thumbnail?.value?.trim() ||
|
||||
"",
|
||||
enableComments: this.toggles.comments?.checked || true,
|
||||
// Public hosted videos mirror to NIP-71 by default. This per-upload
|
||||
// checkbox is an explicit opt-out; eligibility still enforces the
|
||||
// private/NSFW/HTTPS policy at the mirror boundary.
|
||||
mirrorNip71: this.toggles.noNip71Mirror?.checked !== true,
|
||||
...audienceFlags,
|
||||
|
||||
// Auto-captured at file-select (best-effort; absent ⇒ omitted). Drives
|
||||
|
||||
@@ -3453,6 +3453,32 @@ export class VideoModal {
|
||||
// Delegated click handler for the share popover's menu-action buttons
|
||||
// (Copy URL / Copy Magnet / Copy CDN / Share on Nostr). The forced-source
|
||||
// test links self-wire their own copy, so they are skipped here.
|
||||
/**
|
||||
* Update the login/signer gate for the share menu's "Share on Nostr" item.
|
||||
*
|
||||
* This state was initialized to `false` and never written, so
|
||||
* `createVideoShareMenuPanel` always rendered the item disabled — the entry
|
||||
* looked like inert text no matter who was logged in, and the click
|
||||
* delegation in bindShareMenuActions() bails on `button.disabled`. The app
|
||||
* pushes real state here from syncAuthUiState().
|
||||
*/
|
||||
setShareNostrAuthState({ isLoggedIn = false, hasSigner = false } = {}) {
|
||||
const next = { isLoggedIn: !!isLoggedIn, hasSigner: !!hasSigner };
|
||||
const changed =
|
||||
next.isLoggedIn !== this.shareNostrAuthState.isLoggedIn ||
|
||||
next.hasSigner !== this.shareNostrAuthState.hasSigner;
|
||||
this.shareNostrAuthState = next;
|
||||
|
||||
// The popover renders its panel on open, so a menu that is already open
|
||||
// would keep showing the stale (disabled) item. Close it so the next open
|
||||
// renders against current state.
|
||||
if (changed) {
|
||||
this.modalSharePopover?.close?.();
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
bindShareMenuActions(panel) {
|
||||
if (!panel || typeof panel.addEventListener !== "function") {
|
||||
return;
|
||||
|
||||
@@ -160,7 +160,6 @@ export function normalizeVideoModerationContext(moderationInput) {
|
||||
context.originalBlur ||
|
||||
context.originalBlockAutoplay ||
|
||||
context.trustedCount > 0 ||
|
||||
context.trustedMuted ||
|
||||
context.overrideActive ||
|
||||
context.originalHidden ||
|
||||
context.activeHidden ||
|
||||
|
||||
@@ -601,10 +601,14 @@ export class MyVideosController {
|
||||
}
|
||||
} else {
|
||||
const result = await nip71MirrorService.remove(video);
|
||||
setMirrorEnabled(this.pubkey, video.videoRootId, false);
|
||||
this.mainController.showSuccess?.(
|
||||
result?.ok ? "Removed from other Nostr apps." : "Stopped sharing locally.",
|
||||
);
|
||||
if (result?.ok) {
|
||||
setMirrorEnabled(this.pubkey, video.videoRootId, false);
|
||||
this.mainController.showSuccess?.("Removed from other Nostr apps.");
|
||||
} else {
|
||||
this.mainController.showError?.(
|
||||
"Couldn't remove the shared mirror. Please try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[myVideos] mirror toggle failed:", error);
|
||||
|
||||
@@ -753,7 +753,11 @@ export class ProfileDirectMessageActions {
|
||||
handleDirectMessagesUpdated(detail = {}) {
|
||||
if (
|
||||
this.controller.activeMessagesRequest &&
|
||||
detail?.reason !== "load-incremental"
|
||||
detail?.reason !== "load-incremental" &&
|
||||
// A persisted, locally encrypted snapshot is safe to show while the
|
||||
// slower relay history/decryption pass is still running. Suppressing it
|
||||
// made the DM pane appear blank until that full pass completed.
|
||||
detail?.reason !== "snapshot"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -857,7 +857,7 @@ export class ProfileModerationController {
|
||||
if (isBlocked(status.hex)) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(status.whitelisted || trustedContacts.has(status.hex));
|
||||
return trustedContacts.has(status.hex);
|
||||
};
|
||||
|
||||
if (service.trustedMuteLists instanceof Map) {
|
||||
|
||||
@@ -79,6 +79,8 @@ export class ProfilePlaylistsController {
|
||||
this.playlists = [];
|
||||
this.expanded = new Set();
|
||||
this.loading = false;
|
||||
this.refreshRequestId = 0;
|
||||
this.fetchCreatorPlaylists = fetchCreatorPlaylists;
|
||||
this._drag = null;
|
||||
this._dropTarget = null;
|
||||
this._onChanged = () => {
|
||||
@@ -139,16 +141,22 @@ export class ProfilePlaylistsController {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++this.refreshRequestId;
|
||||
this.setLoading(true);
|
||||
let playlists = [];
|
||||
try {
|
||||
playlists = await fetchCreatorPlaylists(this.pubkey, {
|
||||
playlists = await this.fetchCreatorPlaylists(this.pubkey, {
|
||||
includeEmpty: true,
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[playlists] Failed to load playlists for pane:", error);
|
||||
}
|
||||
this.playlists = playlists;
|
||||
this.setLoading(false);
|
||||
// Show the playlist structure as soon as the list itself is available.
|
||||
// Resolving every referenced video only improves labels/thumbnails and
|
||||
// must not keep the management pane blank on a cold cache.
|
||||
this.render();
|
||||
|
||||
// Fetch the referenced creators' videos so the expanded sublists can show
|
||||
// real titles/thumbnails (cache may be cold in the modal).
|
||||
@@ -165,15 +173,16 @@ export class ProfilePlaylistsController {
|
||||
}
|
||||
const nostrService = this.mainController.services?.nostrService;
|
||||
if (authors.size && typeof nostrService?.fetchVideosByAuthors === "function") {
|
||||
try {
|
||||
await nostrService.fetchVideosByAuthors([...authors]);
|
||||
} catch (error) {
|
||||
// best effort — fall back to coordinate labels
|
||||
}
|
||||
void nostrService.fetchVideosByAuthors([...authors])
|
||||
.catch(() => {
|
||||
// Best effort — keep the coordinate labels if metadata is unavailable.
|
||||
})
|
||||
.then(() => {
|
||||
if (this.refreshRequestId === requestId) {
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.setLoading(false);
|
||||
this.render();
|
||||
}
|
||||
|
||||
// coord -> { title, thumbnail } from the app's active videos.
|
||||
|
||||
+105
-79
@@ -4,16 +4,16 @@ import { pointerArrayToKey } from "../utils/pointer.js";
|
||||
export default class ReactionController {
|
||||
constructor({ services = {}, ui = {}, state = {}, callbacks = {} } = {}) {
|
||||
this.services = {
|
||||
reactionCounter: services.reactionCounter,
|
||||
reactionCounter: services.reactionCounter
|
||||
};
|
||||
this.ui = {
|
||||
getVideoModal: ui.getVideoModal || (() => null),
|
||||
showError: ui.showError || (() => {}),
|
||||
showError: ui.showError || (() => {})
|
||||
};
|
||||
this.state = {
|
||||
getCurrentVideo: state.getCurrentVideo || (() => null),
|
||||
getCurrentVideoPointer: state.getCurrentVideoPointer || (() => null),
|
||||
getCurrentVideoPointerKey: state.getCurrentVideoPointerKey || (() => null),
|
||||
getCurrentVideoPointerKey: state.getCurrentVideoPointerKey || (() => null)
|
||||
};
|
||||
this.callbacks = {
|
||||
isUserLoggedIn: callbacks.isUserLoggedIn || (() => false),
|
||||
@@ -22,13 +22,13 @@ export default class ReactionController {
|
||||
// Ensure a sign-capable signer before publishing a reaction (kind 7). A
|
||||
// reloaded nsec session can lose its in-memory key; this re-unlocks it with
|
||||
// one passphrase prompt instead of the reaction silently failing (TODO #54).
|
||||
ensureSigner: callbacks.ensureSigner || (async () => ({ ok: true })),
|
||||
ensureSigner: callbacks.ensureSigner || (async () => ({ ok: true }))
|
||||
};
|
||||
|
||||
this.reactionState = {
|
||||
counts: { "+": 0, "-": 0 },
|
||||
total: 0,
|
||||
userReaction: "",
|
||||
userReaction: ""
|
||||
};
|
||||
this.unsub = null;
|
||||
this.pointerKey = null;
|
||||
@@ -46,14 +46,14 @@ export default class ReactionController {
|
||||
this.reactionState = {
|
||||
counts: { "+": 0, "-": 0 },
|
||||
total: 0,
|
||||
userReaction: "",
|
||||
userReaction: ""
|
||||
};
|
||||
const videoModal = this.ui.getVideoModal();
|
||||
if (videoModal?.updateReactionSummary) {
|
||||
videoModal.updateReactionSummary({
|
||||
total: 0,
|
||||
counts: { "+": 0, "-": 0 },
|
||||
userReaction: "",
|
||||
userReaction: ""
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export default class ReactionController {
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[reaction] Failed to tear down modal subscription:",
|
||||
error,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -88,59 +88,83 @@ export default class ReactionController {
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedUser = this.callbacks.normalizeHexPubkey(this.callbacks.getPubkey());
|
||||
const unsubscribe = this.services.reactionCounter.subscribe(pointer, (snapshot) => {
|
||||
const counts = { ...this.reactionState.counts };
|
||||
if (snapshot?.counts && typeof snapshot.counts === "object") {
|
||||
for (const [key, value] of Object.entries(snapshot.counts)) {
|
||||
counts[key] = this.normalizeReactionCount(value);
|
||||
}
|
||||
const currentVideo = this.state.getCurrentVideo();
|
||||
const reactionTarget = (() => {
|
||||
if (!Array.isArray(pointer) || pointer[0] !== "a") {
|
||||
return pointer;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(counts, "+")) {
|
||||
counts["+"] = 0;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(counts, "-")) {
|
||||
counts["-"] = 0;
|
||||
}
|
||||
|
||||
let total = Number.isFinite(snapshot?.total)
|
||||
? Math.max(0, Number(snapshot.total))
|
||||
: 0;
|
||||
if (!Number.isFinite(total) || total === 0) {
|
||||
total = 0;
|
||||
for (const value of Object.values(counts)) {
|
||||
total += this.normalizeReactionCount(value);
|
||||
}
|
||||
}
|
||||
|
||||
let userReaction = "";
|
||||
if (normalizedUser && snapshot?.reactions) {
|
||||
const record = snapshot.reactions[normalizedUser] || null;
|
||||
if (record && typeof record.content === "string") {
|
||||
userReaction =
|
||||
record.content === "+"
|
||||
? "+"
|
||||
: record.content === "-"
|
||||
? "-"
|
||||
: "";
|
||||
}
|
||||
}
|
||||
|
||||
this.reactionState = {
|
||||
counts,
|
||||
total,
|
||||
userReaction,
|
||||
const eventId =
|
||||
typeof currentVideo?.id === "string" && currentVideo.id.trim()
|
||||
? currentVideo.id.trim()
|
||||
: typeof currentVideo?.eventId === "string" &&
|
||||
currentVideo.eventId.trim()
|
||||
? currentVideo.eventId.trim()
|
||||
: "";
|
||||
return {
|
||||
type: "a",
|
||||
value: pointer[1],
|
||||
relay: pointer[2],
|
||||
eventId
|
||||
};
|
||||
})();
|
||||
const normalizedUser = this.callbacks.normalizeHexPubkey(
|
||||
this.callbacks.getPubkey()
|
||||
);
|
||||
const unsubscribe = this.services.reactionCounter.subscribe(
|
||||
reactionTarget,
|
||||
(snapshot) => {
|
||||
const counts = { ...this.reactionState.counts };
|
||||
if (snapshot?.counts && typeof snapshot.counts === "object") {
|
||||
for (const [key, value] of Object.entries(snapshot.counts)) {
|
||||
counts[key] = this.normalizeReactionCount(value);
|
||||
}
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(counts, "+")) {
|
||||
counts["+"] = 0;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(counts, "-")) {
|
||||
counts["-"] = 0;
|
||||
}
|
||||
|
||||
const currentModal = this.ui.getVideoModal();
|
||||
if (currentModal?.updateReactionSummary) {
|
||||
currentModal.updateReactionSummary({
|
||||
total,
|
||||
let total = Number.isFinite(snapshot?.total)
|
||||
? Math.max(0, Number(snapshot.total))
|
||||
: 0;
|
||||
if (!Number.isFinite(total) || total === 0) {
|
||||
total = 0;
|
||||
for (const value of Object.values(counts)) {
|
||||
total += this.normalizeReactionCount(value);
|
||||
}
|
||||
}
|
||||
|
||||
let userReaction = "";
|
||||
if (normalizedUser && snapshot?.reactions) {
|
||||
const record = snapshot.reactions[normalizedUser] || null;
|
||||
if (record && typeof record.content === "string") {
|
||||
userReaction =
|
||||
record.content === "+"
|
||||
? "+"
|
||||
: record.content === "-"
|
||||
? "-"
|
||||
: "";
|
||||
}
|
||||
}
|
||||
|
||||
this.reactionState = {
|
||||
counts,
|
||||
userReaction,
|
||||
});
|
||||
total,
|
||||
userReaction
|
||||
};
|
||||
|
||||
const currentModal = this.ui.getVideoModal();
|
||||
if (currentModal?.updateReactionSummary) {
|
||||
currentModal.updateReactionSummary({
|
||||
total,
|
||||
counts,
|
||||
userReaction
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
this.pointerKey = pointerKey;
|
||||
this.unsub = () => {
|
||||
@@ -149,7 +173,7 @@ export default class ReactionController {
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[reaction] Failed to tear down modal subscription:",
|
||||
error,
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
this.unsub = null;
|
||||
@@ -159,7 +183,7 @@ export default class ReactionController {
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[reaction] Failed to subscribe modal reaction counter:",
|
||||
error,
|
||||
error
|
||||
);
|
||||
this.resetState();
|
||||
}
|
||||
@@ -171,7 +195,7 @@ export default class ReactionController {
|
||||
}
|
||||
|
||||
const previousCounts = {
|
||||
...(this.reactionState?.counts || {}),
|
||||
...(this.reactionState?.counts || {})
|
||||
};
|
||||
const previousTotalValue = Number.isFinite(this.reactionState?.total)
|
||||
? Math.max(0, Number(this.reactionState.total))
|
||||
@@ -206,7 +230,7 @@ export default class ReactionController {
|
||||
const updatedCounts = {
|
||||
...previousCounts,
|
||||
"+": likeCount,
|
||||
"-": dislikeCount,
|
||||
"-": dislikeCount
|
||||
};
|
||||
|
||||
let updatedTotal = likeCount + dislikeCount;
|
||||
@@ -217,7 +241,7 @@ export default class ReactionController {
|
||||
this.reactionState = {
|
||||
counts: updatedCounts,
|
||||
total: updatedTotal,
|
||||
userReaction: nextReaction,
|
||||
userReaction: nextReaction
|
||||
};
|
||||
|
||||
const videoModal = this.ui.getVideoModal();
|
||||
@@ -225,7 +249,7 @@ export default class ReactionController {
|
||||
videoModal.updateReactionSummary({
|
||||
total: updatedTotal,
|
||||
counts: updatedCounts,
|
||||
userReaction: nextReaction,
|
||||
userReaction: nextReaction
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,7 +265,7 @@ export default class ReactionController {
|
||||
return {
|
||||
counts: previousCounts,
|
||||
total: fallbackPreviousTotal,
|
||||
userReaction: previousReaction,
|
||||
userReaction: previousReaction
|
||||
};
|
||||
}
|
||||
|
||||
@@ -285,7 +309,7 @@ export default class ReactionController {
|
||||
this.reactionState = {
|
||||
counts,
|
||||
total,
|
||||
userReaction,
|
||||
userReaction
|
||||
};
|
||||
|
||||
const videoModal = this.ui.getVideoModal();
|
||||
@@ -293,7 +317,7 @@ export default class ReactionController {
|
||||
videoModal.updateReactionSummary({
|
||||
total,
|
||||
counts,
|
||||
userReaction,
|
||||
userReaction
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -307,11 +331,7 @@ export default class ReactionController {
|
||||
const requestedReaction =
|
||||
typeof detail.reaction === "string" ? detail.reaction : "";
|
||||
const normalizedReaction =
|
||||
requestedReaction === "+"
|
||||
? "+"
|
||||
: requestedReaction === "-"
|
||||
? "-"
|
||||
: "";
|
||||
requestedReaction === "+" ? "+" : requestedReaction === "-" ? "-" : "";
|
||||
|
||||
if (!normalizedReaction) {
|
||||
return;
|
||||
@@ -319,14 +339,16 @@ export default class ReactionController {
|
||||
|
||||
const previousReaction = this.reactionState?.userReaction || "";
|
||||
const pointer = this.state.getCurrentVideoPointer();
|
||||
const pointerKey = this.state.getCurrentVideoPointerKey() || (pointer ? pointerArrayToKey(pointer) : null);
|
||||
const pointerKey =
|
||||
this.state.getCurrentVideoPointerKey() ||
|
||||
(pointer ? pointerArrayToKey(pointer) : null);
|
||||
|
||||
if (!pointer || !pointerKey) {
|
||||
if (videoModal) {
|
||||
videoModal.setUserReaction(previousReaction);
|
||||
}
|
||||
devLogger.info(
|
||||
"[reaction] Ignoring reaction request until modal pointer is available.",
|
||||
"[reaction] Ignoring reaction request until modal pointer is available."
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -350,7 +372,7 @@ export default class ReactionController {
|
||||
need: "sign",
|
||||
pubkey: this.callbacks.getPubkey(),
|
||||
promptMessage:
|
||||
"Re-enter your PIN / passphrase to unlock your key and react to videos.",
|
||||
"Re-enter your PIN / passphrase to unlock your key and react to videos."
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[reaction] Signer gate failed:", error);
|
||||
@@ -359,7 +381,10 @@ export default class ReactionController {
|
||||
if (ensured && ensured.ok === false) {
|
||||
videoModal.setUserReaction(previousReaction);
|
||||
// cancelled = user backed out; bad-passphrase already showed its own toast.
|
||||
if (ensured.reason !== "cancelled" && ensured.reason !== "bad-passphrase") {
|
||||
if (
|
||||
ensured.reason !== "cancelled" &&
|
||||
ensured.reason !== "bad-passphrase"
|
||||
) {
|
||||
this.ui.showError("Connect a signer to react to videos.");
|
||||
}
|
||||
return;
|
||||
@@ -367,11 +392,12 @@ export default class ReactionController {
|
||||
|
||||
let rollbackSnapshot = null;
|
||||
try {
|
||||
rollbackSnapshot = this.applyOptimisticUpdate(
|
||||
normalizedReaction
|
||||
);
|
||||
rollbackSnapshot = this.applyOptimisticUpdate(normalizedReaction);
|
||||
} catch (error) {
|
||||
devLogger.warn("[reaction] Failed to apply optimistic reaction state:", error);
|
||||
devLogger.warn(
|
||||
"[reaction] Failed to apply optimistic reaction state:",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -380,7 +406,7 @@ export default class ReactionController {
|
||||
content: normalizedReaction,
|
||||
video: currentVideo,
|
||||
currentVideoPubkey: currentVideo?.pubkey,
|
||||
pointerKey,
|
||||
pointerKey
|
||||
});
|
||||
|
||||
if (!result?.ok) {
|
||||
|
||||
@@ -11,6 +11,16 @@ import { queueSignEvent as defaultQueueSignEvent } from "../nostr/signRequestQue
|
||||
import { DEFAULT_NIP07_PERMISSION_METHODS as defaultPermissionMethods } from "../nostr/nip07Permissions.js";
|
||||
import { nostrClient as defaultNostrClient } from "../nostrClientFacade.js";
|
||||
import { userLogger as defaultUserLogger, devLogger as defaultDevLogger } from "../utils/logger.js";
|
||||
import { nip71MirrorService as defaultMirrorService } from "../services/nip71MirrorService.js";
|
||||
import {
|
||||
buildMirrorCoordinate,
|
||||
buildMirrorNaddr,
|
||||
resolveMirrorKind,
|
||||
} from "../nostr/mirrorPointer.js";
|
||||
|
||||
// Best-effort: a share must never hang on a relay read. If the mirror lookup
|
||||
// doesn't answer in time we simply fall back to the thumbnail-URL note.
|
||||
const MIRROR_LOOKUP_TIMEOUT_MS = 2500;
|
||||
|
||||
export default class ShareNostrController {
|
||||
constructor({ ui, state, services = {} }) {
|
||||
@@ -27,6 +37,87 @@ export default class ShareNostrController {
|
||||
nostrClient: services.nostrClient || defaultNostrClient,
|
||||
userLogger: services.userLogger || defaultUserLogger,
|
||||
devLogger: services.devLogger || defaultDevLogger,
|
||||
mirrorService:
|
||||
services.mirrorService === undefined
|
||||
? defaultMirrorService
|
||||
: services.mirrorService,
|
||||
nip19: services.nip19 || null,
|
||||
};
|
||||
}
|
||||
|
||||
resolveNip19() {
|
||||
return (
|
||||
this.services.nip19 ||
|
||||
(typeof window !== "undefined" ? window?.NostrTools?.nip19 : null) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `naddr` for the video's NIP-71 mirror, or "" when there isn't
|
||||
* one. Embedding this makes nostr clients render a native video quote card
|
||||
* instead of a bare link — but ONLY when the mirror actually exists on
|
||||
* relays. A pointer to a missing event renders as a broken/empty quote box,
|
||||
* which is worse than the plain-link fallback, so this verifies before
|
||||
* pointing and stays silent on any failure.
|
||||
*/
|
||||
async resolveMirrorPointer(video) {
|
||||
const mirrorService = this.services.mirrorService;
|
||||
const pubkey = typeof video?.pubkey === "string" ? video.pubkey.trim() : "";
|
||||
const videoRootId =
|
||||
typeof video?.videoRootId === "string" ? video.videoRootId.trim() : "";
|
||||
|
||||
if (!pubkey || !videoRootId || !mirrorService) {
|
||||
return { naddr: "", coordinate: "" };
|
||||
}
|
||||
if (
|
||||
typeof mirrorService.isAvailable === "function" &&
|
||||
!mirrorService.isAvailable()
|
||||
) {
|
||||
return { naddr: "", coordinate: "" };
|
||||
}
|
||||
if (typeof mirrorService.findMirror !== "function") {
|
||||
return { naddr: "", coordinate: "" };
|
||||
}
|
||||
|
||||
let result = null;
|
||||
try {
|
||||
result = await Promise.race([
|
||||
mirrorService.findMirror({ pubkey, videoRootId }),
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve(null), MIRROR_LOOKUP_TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
} catch (error) {
|
||||
this.services.devLogger.warn(
|
||||
"[ShareNostrController] Mirror lookup failed; sharing without a quote.",
|
||||
error
|
||||
);
|
||||
return { naddr: "", coordinate: "" };
|
||||
}
|
||||
|
||||
if (!result?.mirrored) {
|
||||
return { naddr: "", coordinate: "" };
|
||||
}
|
||||
|
||||
const kind = resolveMirrorKind({
|
||||
kinds: result.kinds,
|
||||
width: video?.width,
|
||||
height: video?.height,
|
||||
});
|
||||
const relays = Array.isArray(this.services.nostrClient?.writeRelays)
|
||||
? this.services.nostrClient.writeRelays
|
||||
: this.services.nostrClient?.relays || [];
|
||||
|
||||
return {
|
||||
naddr: buildMirrorNaddr({
|
||||
pubkey,
|
||||
videoRootId,
|
||||
kind,
|
||||
relays,
|
||||
nip19: this.resolveNip19(),
|
||||
}),
|
||||
coordinate: buildMirrorCoordinate({ pubkey, videoRootId, kind }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +143,8 @@ export default class ShareNostrController {
|
||||
? targetVideo.shareUrl.trim()
|
||||
: this.state.buildShareUrlFromEventId(targetVideo.id);
|
||||
|
||||
const mirror = await this.resolveMirrorPointer(targetVideo);
|
||||
|
||||
const payload = {
|
||||
id: targetVideo.id,
|
||||
title: targetVideo.title,
|
||||
@@ -59,6 +152,10 @@ export default class ShareNostrController {
|
||||
authorName: targetVideo.creatorName || targetVideo.authorName || "",
|
||||
thumbnail: targetVideo.thumbnail,
|
||||
shareUrl,
|
||||
// Present only when a NIP-71 mirror was confirmed on relays. Drives the
|
||||
// quote-card note body and the `a` tag on the published event.
|
||||
mirrorNaddr: mirror.naddr,
|
||||
mirrorCoordinate: mirror.coordinate,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -167,7 +264,23 @@ export default class ShareNostrController {
|
||||
pubkey: eventPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: typeof payload?.content === "string" ? payload.content : "",
|
||||
video: { id: videoId, pubkey: videoPubkey },
|
||||
// title/thumbnail drive the NIP-92 `imeta` hint so the thumbnail URL in
|
||||
// the note body renders as an image rather than a bare link. When a
|
||||
// mirror pointer is present the note quotes the mirror instead, and
|
||||
// buildShareEvent drops the imeta so the image isn't rendered twice.
|
||||
video: {
|
||||
id: videoId,
|
||||
pubkey: videoPubkey,
|
||||
title: videoTitle,
|
||||
thumbnail:
|
||||
typeof video?.thumbnail === "string" ? video.thumbnail.trim() : "",
|
||||
mirrorNaddr:
|
||||
typeof video?.mirrorNaddr === "string" ? video.mirrorNaddr.trim() : "",
|
||||
mirrorCoordinate:
|
||||
typeof video?.mirrorCoordinate === "string"
|
||||
? video.mirrorCoordinate.trim()
|
||||
: "",
|
||||
},
|
||||
relays: relayEntries,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
export const SITE_UPDATE_LAST_SEEN_KEY = "bitvid:site-update:last-seen";
|
||||
export const SITE_UPDATE_DISMISSED_KEY = "bitvid:site-update:dismissed";
|
||||
export const GITHUB_COMMITS_ENDPOINT =
|
||||
"https://api.github.com/repos/PR0M3TH3AN/bitvid/commits?sha=unstable&per_page=5";
|
||||
|
||||
export const LATEST_UPDATE_ITEMS = Object.freeze([
|
||||
"Clearer Nostr sign-in choices alongside BitLogin.",
|
||||
"Creator approval is now separate from web-of-trust moderation.",
|
||||
"Trusted-report thresholds now require broader agreement before action.",
|
||||
]);
|
||||
|
||||
function readStorage(storage, key) {
|
||||
try {
|
||||
return storage?.getItem?.(key) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(storage, key, value) {
|
||||
try {
|
||||
storage?.setItem?.(key, value);
|
||||
} catch {
|
||||
// Privacy mode or quota errors should never block the application.
|
||||
}
|
||||
}
|
||||
|
||||
function createElement(doc, tagName, className, text = "") {
|
||||
const element = doc.createElement(tagName);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
if (text) {
|
||||
element.textContent = text;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function renderUpdateItems(doc, list, items) {
|
||||
list.replaceChildren();
|
||||
for (const item of Array.isArray(items) ? items : []) {
|
||||
if (typeof item === "string" && item.trim()) {
|
||||
list.appendChild(createElement(doc, "li", "", item.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCommitItems(payload) {
|
||||
if (!Array.isArray(payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return payload
|
||||
.map((entry) => {
|
||||
const sha = typeof entry?.sha === "string" ? entry.sha.slice(0, 8) : "";
|
||||
const message =
|
||||
typeof entry?.commit?.message === "string"
|
||||
? entry.commit.message.split("\n")[0].trim()
|
||||
: "";
|
||||
return sha && message ? `${sha} — ${message}` : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function loadGitHubCommitItems({ doc, list, fetchImpl, fallbackItems }) {
|
||||
if (typeof fetchImpl !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(GITHUB_COMMITS_ENDPOINT, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
});
|
||||
if (!response?.ok) {
|
||||
throw new Error("github-commits-unavailable");
|
||||
}
|
||||
const commits = normalizeCommitItems(await response.json());
|
||||
if (!commits.length) {
|
||||
throw new Error("github-commits-empty");
|
||||
}
|
||||
renderUpdateItems(doc, list, commits);
|
||||
} catch {
|
||||
renderUpdateItems(doc, list, fallbackItems);
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeSiteUpdateNotice({
|
||||
doc = typeof document === "undefined" ? null : document,
|
||||
storage = typeof window === "undefined" ? null : window.localStorage,
|
||||
updateItems = LATEST_UPDATE_ITEMS,
|
||||
fetchImpl = typeof fetch === "function" ? fetch : null,
|
||||
} = {}) {
|
||||
if (!doc?.body) {
|
||||
return { shown: false, reason: "document-unavailable" };
|
||||
}
|
||||
|
||||
const versionMarker = doc.querySelector("[data-site-version]");
|
||||
const version = versionMarker?.dataset?.siteVersion?.trim() || "";
|
||||
const date = versionMarker?.dataset?.siteVersionDate?.trim() || "";
|
||||
if (!version) {
|
||||
return { shown: false, reason: "version-unavailable" };
|
||||
}
|
||||
|
||||
const lastSeen = readStorage(storage, SITE_UPDATE_LAST_SEEN_KEY);
|
||||
const dismissed = readStorage(storage, SITE_UPDATE_DISMISSED_KEY);
|
||||
if (!lastSeen) {
|
||||
writeStorage(storage, SITE_UPDATE_LAST_SEEN_KEY, version);
|
||||
return { shown: false, reason: "first-visit" };
|
||||
}
|
||||
|
||||
if (lastSeen === version || dismissed === version) {
|
||||
return { shown: false, reason: "already-seen" };
|
||||
}
|
||||
|
||||
const root = createElement(doc, "aside", "site-update-notice");
|
||||
root.setAttribute("aria-label", "Latest site update");
|
||||
|
||||
const panel = createElement(doc, "section", "site-update-notice__panel hidden");
|
||||
panel.id = "siteUpdateNoticePanel";
|
||||
panel.setAttribute("role", "status");
|
||||
panel.setAttribute("aria-live", "polite");
|
||||
|
||||
const panelHeader = createElement(doc, "div", "flex items-start justify-between gap-3");
|
||||
const title = createElement(doc, "h2", "text-base font-semibold", "Latest update");
|
||||
const closeButton = createElement(doc, "button", "site-update-notice__close", "×");
|
||||
closeButton.type = "button";
|
||||
closeButton.setAttribute("aria-label", "Dismiss this update");
|
||||
panelHeader.append(title, closeButton);
|
||||
|
||||
const intro = createElement(
|
||||
doc,
|
||||
"p",
|
||||
"mt-2 text-sm leading-relaxed text-text-muted",
|
||||
"BitVid has been updated. Here is what changed:",
|
||||
);
|
||||
const list = createElement(doc, "ul", "mt-3 list-disc space-y-1 pl-5 text-sm text-text-muted");
|
||||
renderUpdateItems(doc, list, updateItems);
|
||||
const build = createElement(
|
||||
doc,
|
||||
"p",
|
||||
"mt-3 text-xs font-mono text-text-muted",
|
||||
`Build ${version}${date ? ` • ${date}` : ""}`,
|
||||
);
|
||||
panel.append(panelHeader, intro, list, build);
|
||||
|
||||
const toggleButton = createElement(doc, "button", "site-update-notice__button", "Latest update");
|
||||
toggleButton.type = "button";
|
||||
toggleButton.setAttribute("aria-expanded", "false");
|
||||
toggleButton.setAttribute("aria-controls", panel.id);
|
||||
|
||||
const setPanelOpen = (open) => {
|
||||
panel.classList.toggle("hidden", !open);
|
||||
toggleButton.setAttribute("aria-expanded", String(open));
|
||||
};
|
||||
|
||||
let commitLoadStarted = false;
|
||||
toggleButton.addEventListener("click", () => {
|
||||
const nextOpen = panel.classList.contains("hidden");
|
||||
setPanelOpen(nextOpen);
|
||||
if (nextOpen && !commitLoadStarted) {
|
||||
commitLoadStarted = true;
|
||||
void loadGitHubCommitItems({
|
||||
doc,
|
||||
list,
|
||||
fetchImpl,
|
||||
fallbackItems: updateItems,
|
||||
});
|
||||
}
|
||||
});
|
||||
closeButton.addEventListener("click", () => {
|
||||
writeStorage(storage, SITE_UPDATE_LAST_SEEN_KEY, version);
|
||||
writeStorage(storage, SITE_UPDATE_DISMISSED_KEY, version);
|
||||
root.remove();
|
||||
});
|
||||
|
||||
root.append(panel, toggleButton);
|
||||
doc.body.appendChild(root);
|
||||
return { shown: true, version, root };
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { SHORT_TIMEOUT_MS } from "../constants.js";
|
||||
import {
|
||||
isHlsUrl,
|
||||
prefersNativeHls,
|
||||
probeHlsManifest,
|
||||
} from "../services/hlsPlayback.js";
|
||||
|
||||
export default class UrlHealthController {
|
||||
constructor({
|
||||
@@ -275,6 +280,21 @@ export default class UrlHealthController {
|
||||
return { outcome: "error" };
|
||||
}
|
||||
|
||||
// A bare <video> cannot load an .m3u8 on the browsers bitvid actually runs
|
||||
// hls.js on. Worse, Chrome neither plays it NOR errors — it emits no events
|
||||
// at all, so this probe burned the full timeout on every healthy HLS card.
|
||||
// Since playback will use hls.js (which fetches the playlist over CORS), a
|
||||
// readable #EXTM3U response is the accurate signal — and far cheaper than
|
||||
// pulling in the hls.js bundle just to paint a badge.
|
||||
if (isHlsUrl(trimmed) && !prefersNativeHls()) {
|
||||
const defaultTimeout =
|
||||
this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS;
|
||||
const effective =
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : defaultTimeout;
|
||||
const outcome = await probeHlsManifest(trimmed, effective);
|
||||
return { outcome };
|
||||
}
|
||||
|
||||
if (this.activeVideoProbes >= this.MAX_CONCURRENT_VIDEO_PROBES) {
|
||||
await new Promise((resolve) => {
|
||||
this.videoProbeQueue.push(resolve);
|
||||
|
||||
@@ -32,6 +32,8 @@ export class VideoListView {
|
||||
(typeof window !== "undefined" ? window : null);
|
||||
this.container = null;
|
||||
this.mediaLoader = mediaLoader;
|
||||
this.healthBadgeDetach = null;
|
||||
this.urlHealthBadgeDetach = null;
|
||||
|
||||
this.badgeHelpers = {
|
||||
attachHealthBadges: badgeHelpers.attachHealthBadges || (() => {}),
|
||||
@@ -236,6 +238,7 @@ export class VideoListView {
|
||||
}
|
||||
|
||||
if (this.container) {
|
||||
this.teardownHealthBadgeObservers();
|
||||
this.container.removeEventListener("click", this._boundClickHandler);
|
||||
}
|
||||
|
||||
@@ -262,6 +265,7 @@ export class VideoListView {
|
||||
|
||||
const markup = this.renderers.getLoadingMarkup(message);
|
||||
if (typeof markup === "string") {
|
||||
this.teardownHealthBadgeObservers();
|
||||
this.container.innerHTML = markup;
|
||||
}
|
||||
}
|
||||
@@ -280,6 +284,16 @@ export class VideoListView {
|
||||
}
|
||||
}
|
||||
|
||||
teardownHealthBadgeObservers() {
|
||||
for (const detach of [this.healthBadgeDetach, this.urlHealthBadgeDetach]) {
|
||||
if (typeof detach === "function") {
|
||||
detach();
|
||||
}
|
||||
}
|
||||
this.healthBadgeDetach = null;
|
||||
this.urlHealthBadgeDetach = null;
|
||||
}
|
||||
|
||||
setPlaybackHandler(handler) {
|
||||
this.handlers.playback = typeof handler === "function" ? handler : null;
|
||||
}
|
||||
@@ -374,6 +388,7 @@ export class VideoListView {
|
||||
return displayVideos;
|
||||
}
|
||||
this.lastRenderedVideoSignature = EMPTY_VIDEO_LIST_SIGNATURE;
|
||||
this.teardownHealthBadgeObservers();
|
||||
this.container.innerHTML = `
|
||||
<p class="flex justify-center items-center h-full w-full text-center text-subtle">
|
||||
No public videos available yet. Be the first to upload one!
|
||||
@@ -413,7 +428,10 @@ export class VideoListView {
|
||||
}));
|
||||
const signature = JSON.stringify(signaturePayload);
|
||||
|
||||
if (signature === this.lastRenderedVideoSignature) {
|
||||
const hasRenderedCards = Boolean(
|
||||
this.container.querySelector('[data-component="video-card"]'),
|
||||
);
|
||||
if (signature === this.lastRenderedVideoSignature && hasRenderedCards) {
|
||||
return displayVideos;
|
||||
}
|
||||
this.lastRenderedVideoSignature = signature;
|
||||
@@ -788,11 +806,12 @@ export class VideoListView {
|
||||
});
|
||||
}
|
||||
|
||||
this.teardownHealthBadgeObservers();
|
||||
this.container.innerHTML = "";
|
||||
this.container.appendChild(fragment);
|
||||
|
||||
this.badgeHelpers.attachHealthBadges(this.container);
|
||||
this.badgeHelpers.attachUrlHealthBadges(this.container, ({ badgeEl, url, eventId }) => {
|
||||
this.healthBadgeDetach = this.badgeHelpers.attachHealthBadges(this.container);
|
||||
this.urlHealthBadgeDetach = this.badgeHelpers.attachUrlHealthBadges(this.container, ({ badgeEl, url, eventId }) => {
|
||||
this.utils.handleUrlHealthBadge({
|
||||
video: this.state.videosMap.get(eventId) || { id: eventId },
|
||||
url,
|
||||
|
||||
@@ -75,12 +75,12 @@ function triggerUrlCheck(card, state) {
|
||||
|
||||
export function attachUrlHealthBadges(container, onCheck) {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const state = urlCardObserver.observe(container);
|
||||
if (state && typeof onCheck === "function") {
|
||||
state.onCheck = onCheck;
|
||||
}
|
||||
return () => urlCardObserver.disconnect(container);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const FALLBACK_MUTE_DATASET_KEY = "bitvidAutoplayFallbackMute";
|
||||
|
||||
/**
|
||||
* Mute playback after an unmuted autoplay rejection without recording the
|
||||
* browser-policy fallback as a viewer preference.
|
||||
*/
|
||||
export function muteForAutoplayFallback(videoElement) {
|
||||
if (!videoElement || videoElement.muted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (videoElement.dataset) {
|
||||
videoElement.dataset[FALLBACK_MUTE_DATASET_KEY] = "true";
|
||||
}
|
||||
videoElement.muted = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Consume the marker attached to the next fallback-generated volume change. */
|
||||
export function consumeAutoplayFallbackMute(videoElement) {
|
||||
if (videoElement?.dataset?.[FALLBACK_MUTE_DATASET_KEY] !== "true") {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete videoElement.dataset[FALLBACK_MUTE_DATASET_KEY];
|
||||
return true;
|
||||
}
|
||||
@@ -47,3 +47,56 @@ export function inferMimeTypeFromUrl(url) {
|
||||
const mimeType = EXTENSION_MIME_MAP[extension];
|
||||
return typeof mimeType === "string" ? mimeType : "";
|
||||
}
|
||||
|
||||
// Image types, kept in a SEPARATE map from EXTENSION_MIME_MAP on purpose: that
|
||||
// map gates video upload/publish paths (js/nostr/publishHelpers.js,
|
||||
// js/nostr/videoPayloadBuilder.js), so folding image types into it would let an
|
||||
// image pass a video check. Used for NIP-92 `imeta` hints on thumbnails.
|
||||
export const IMAGE_EXTENSION_MIME_MAP = Object.freeze(
|
||||
Object.fromEntries(
|
||||
Object.entries({
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
jfif: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
bmp: "image/bmp",
|
||||
svg: "image/svg+xml",
|
||||
ico: "image/x-icon",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
}).map(([extension, mimeType]) => [extension, mimeType.toLowerCase()]),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Best-effort image MIME type from a URL's extension. Returns "" when the URL
|
||||
* carries no recognizable image extension — callers must treat that as "unknown"
|
||||
* and omit the hint rather than guessing, since a wrong `m` value is worse than
|
||||
* a missing one (clients use it to decide whether to render inline at all).
|
||||
*/
|
||||
export function inferImageMimeTypeFromUrl(url) {
|
||||
if (!url || typeof url !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
let pathname = "";
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
pathname = parsed.pathname || "";
|
||||
} catch (err) {
|
||||
const sanitized = url.split("?")[0].split("#")[0];
|
||||
pathname = sanitized || "";
|
||||
}
|
||||
|
||||
const lastSegment = pathname.split("/").pop() || "";
|
||||
const match = lastSegment.match(/\.([a-z0-9]+)$/i);
|
||||
if (!match) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const mimeType = IMAGE_EXTENSION_MIME_MAP[match[1].toLowerCase()];
|
||||
return typeof mimeType === "string" ? mimeType : "";
|
||||
}
|
||||
+29
-10
@@ -30,6 +30,7 @@ import {
|
||||
waitForActiveController as waitForActiveControllerHelper,
|
||||
} from "./webtorrentHelpers.js";
|
||||
import { devLogger, userLogger } from "./utils/logger.js";
|
||||
import { muteForAutoplayFallback } from "./utils/autoplayPreference.js";
|
||||
import { emit } from "./embedDiagnostics.js";
|
||||
|
||||
const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]);
|
||||
@@ -137,6 +138,29 @@ export class TorrentClient {
|
||||
let timeoutId = null;
|
||||
let pollId = null;
|
||||
|
||||
// A successful probe commonly settles from WebTorrent's `wire` event.
|
||||
// Do not destroy the temporary torrent while that event is still being
|
||||
// dispatched: WebTorrent continues its handshake bookkeeping after
|
||||
// listeners return, and synchronous destruction can leave its bitfield
|
||||
// null (`recalculate(...).fill` in the bundled client). The next task
|
||||
// guarantees the callback has fully unwound before cleanup.
|
||||
const disposeProbeTorrent = (target, onDisposed = () => {}) => {
|
||||
if (!target) {
|
||||
onDisposed();
|
||||
return;
|
||||
}
|
||||
const dispose = () => {
|
||||
try {
|
||||
target.destroy({ destroyStore: true });
|
||||
} catch (err) {
|
||||
// A completed or already-destroyed probe is harmless.
|
||||
} finally {
|
||||
onDisposed();
|
||||
}
|
||||
};
|
||||
setTimeout(dispose, 0);
|
||||
};
|
||||
|
||||
const finalize = (overrides = {}) => {
|
||||
if (settled) {
|
||||
return;
|
||||
@@ -150,14 +174,6 @@ export class TorrentClient {
|
||||
clearInterval(pollId);
|
||||
pollId = null;
|
||||
}
|
||||
if (torrent) {
|
||||
try {
|
||||
torrent.destroy({ destroyStore: true });
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const endedAt =
|
||||
typeof performance !== "undefined" && performance?.now
|
||||
? performance.now()
|
||||
@@ -177,7 +193,10 @@ export class TorrentClient {
|
||||
...overrides,
|
||||
};
|
||||
emit("torrent-probe-result", result);
|
||||
resolve(result);
|
||||
// Keep the grid-health queue slot occupied until the delayed teardown
|
||||
// completes. Otherwise the next probe can begin before this probe has
|
||||
// detached its tracker listeners, briefly exceeding listener limits.
|
||||
disposeProbeTorrent(torrent, () => resolve(result));
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -561,7 +580,7 @@ export class TorrentClient {
|
||||
return;
|
||||
}
|
||||
this.log(`Retrying with muted autoplay (${context} path).`);
|
||||
videoElement.muted = true;
|
||||
muteForAutoplayFallback(videoElement);
|
||||
videoElement.play().catch((err2) => {
|
||||
this.log(`Muted autoplay also failed (${context} path):`, err2);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Routes crawler requests for `/?v=<nevent>` to the per-video OpenGraph
|
||||
// renderer (api/og.js).
|
||||
//
|
||||
// This has to be middleware rather than a `rewrites` entry in vercel.json:
|
||||
// rewrites are evaluated AFTER the filesystem, and `/` matches the static
|
||||
// index.html, so the rewrite never fired and crawlers kept getting the
|
||||
// site-wide card. Middleware runs before the filesystem check.
|
||||
//
|
||||
// Deliberately minimal. It matches only `/`, bails immediately when there is no
|
||||
// `v` param or the visitor is not a known preview crawler, and never touches
|
||||
// the response for a real viewer — they continue to the untouched SPA. If this
|
||||
// file is removed the site keeps working, just with the generic card.
|
||||
|
||||
import { next, rewrite } from "@vercel/edge";
|
||||
import { isCrawlerUserAgent } from "./api/_lib/ogRenderer.mjs";
|
||||
|
||||
export const config = {
|
||||
// Only the share-link path. Every other route skips middleware entirely.
|
||||
matcher: "/",
|
||||
};
|
||||
|
||||
export default function middleware(request) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(request.url);
|
||||
} catch (err) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// A bare homepage visit is not a share link.
|
||||
if (!url.searchParams.get("v")) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const userAgent = request.headers?.get?.("user-agent") || "";
|
||||
if (!isCrawlerUserAgent(userAgent)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const target = new URL(url.toString());
|
||||
target.pathname = "/api/og";
|
||||
return rewrite(target);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
[build]
|
||||
command = "npm run build && npm run verify:dist:deploy-artifact"
|
||||
publish = "dist"
|
||||
|
||||
[build.environment]
|
||||
NODE_ENV = "production"
|
||||
|
||||
[[headers]]
|
||||
for = "/*"
|
||||
[headers.values]
|
||||
Content-Security-Policy = "frame-ancestors *"
|
||||
|
||||
[[headers]]
|
||||
for = "/index.html"
|
||||
[headers.values]
|
||||
Cache-Control = "no-cache, must-revalidate"
|
||||
|
||||
[[headers]]
|
||||
for = "/embed.html"
|
||||
[headers.values]
|
||||
Cache-Control = "no-cache, must-revalidate"
|
||||
|
||||
[[headers]]
|
||||
for = "/*.html"
|
||||
[headers.values]
|
||||
Cache-Control = "no-cache, must-revalidate"
|
||||
|
||||
[[headers]]
|
||||
for = "/css/*"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "/js/*"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "/sw.min.js"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=0, must-revalidate"
|
||||
|
||||
[[headers]]
|
||||
for = "/site.webmanifest"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=0, must-revalidate"
|
||||
Generated
+15
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@vercel/edge": "^1.3.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild": "^0.27.3",
|
||||
@@ -25,6 +26,7 @@
|
||||
"blossom-client-sdk": "5.0.0",
|
||||
"cssnano": "^7.1.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"hls.js": "^1.6.16",
|
||||
"jsdom": "^28.1.0",
|
||||
"nostr-wasm": "^0.1.0",
|
||||
"pixelmatch": "^5.3.0",
|
||||
@@ -1477,6 +1479,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vercel/edge": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/edge/-/edge-1.3.1.tgz",
|
||||
"integrity": "sha512-k0tKWeYEWOv6qU6+Z8+hv2Nt/u3vFHRZdB0uvwE0DxoddU/5kWX/Chu8F6ojvqmRlnZwuX/7kyZFVeCP6DxqBg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||
@@ -3252,6 +3260,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/hookified": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"build:beacon": "npm run build:beacon:bundle && npm run lint:inline-styles",
|
||||
"build:bitcoin-connect": "node scripts/build-bitcoin-connect.mjs",
|
||||
"build:blossom-sdk": "node scripts/build-blossom-sdk.mjs",
|
||||
"build:hls": "node scripts/build-hls.mjs",
|
||||
"ci:beacon": "npm run build:beacon",
|
||||
"format": "prettier --cache --write css/tailwind.source.css css/tokens.css css/docs.css \"docs/**/*.html\" tailwind.config.cjs README.md CONTRIBUTING.md",
|
||||
"audit": "node scripts/daily-design-system-audit.mjs",
|
||||
@@ -65,6 +66,7 @@
|
||||
"blossom-client-sdk": "5.0.0",
|
||||
"cssnano": "^7.1.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"hls.js": "^1.6.16",
|
||||
"jsdom": "^28.1.0",
|
||||
"nostr-wasm": "^0.1.0",
|
||||
"pixelmatch": "^5.3.0",
|
||||
@@ -83,6 +85,7 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@vercel/edge": "^1.3.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild": "^0.27.3",
|
||||
|
||||
Generated
-3357
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,105 @@
|
||||
// Vendor the sibling @bitlogin/widget build into a pinned, same-origin bundle.
|
||||
//
|
||||
// bitvid's CSP is script-src 'self' (plus a couple of pinned CDN fallbacks for
|
||||
// nostr-tools) -- the same reasoning as the existing bitcoin-connect/blossom-sdk
|
||||
// vendor steps. Unlike those, BitLogin ships its own Vite-built ESM output
|
||||
// directly (bitlogin.js + cryptoWorker.js + a shared chunk they both import),
|
||||
// so this script copies that output verbatim instead of re-bundling it with
|
||||
// esbuild -- the worker's relative self-URL resolution depends on the three
|
||||
// files staying exactly as built and sitting side by side (see BitRoad's and
|
||||
// BitUnlock's identical vendor steps for this same reasoning).
|
||||
//
|
||||
// node scripts/build-bitlogin-widget.mjs
|
||||
// BITLOGIN_ENTRY=/path/to/bitlogin node scripts/build-bitlogin-widget.mjs
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const bitloginRoot = process.env.BITLOGIN_ENTRY
|
||||
? path.resolve(process.env.BITLOGIN_ENTRY)
|
||||
: path.resolve(repoRoot, "../bitlogin");
|
||||
const widgetDir = path.join(bitloginRoot, "packages/widget");
|
||||
const widgetDist = path.join(widgetDir, "dist");
|
||||
const vendorDir = path.join(repoRoot, "vendor/bitlogin");
|
||||
const integrityFile = path.join(repoRoot, "vendor/bitlogin.integrity.json");
|
||||
|
||||
const packageJson = JSON.parse(await readFile(path.join(widgetDir, "package.json"), "utf8"));
|
||||
if (packageJson.name !== "@bitlogin/widget") {
|
||||
throw new Error("BITLOGIN_ENTRY must point to a BitLogin checkout (packages/widget/package.json not found there).");
|
||||
}
|
||||
|
||||
// Provenance (BitUnlock entropy audit M1, ported). Hashes prove the vendored
|
||||
// bytes have not changed since they were committed; they cannot say WHICH
|
||||
// BitLogin commit produced them. @bitlogin/widget is a private package pinned
|
||||
// at 0.1.0 across many substantive changes, so the version string distinguishes
|
||||
// nothing. Resolved BEFORE the dist build and before vendorDir is deleted, so
|
||||
// refusing a dirty checkout leaves the committed vendored files untouched.
|
||||
const git = (args) => execSync(`git ${args}`, { cwd: bitloginRoot, encoding: "utf8" }).trim();
|
||||
|
||||
let source;
|
||||
try {
|
||||
const dirty = git("status --porcelain") !== "";
|
||||
if (dirty && process.env.BITLOGIN_ALLOW_DIRTY !== "1") {
|
||||
throw new Error(
|
||||
"DIRTY_CHECKOUT: the BitLogin checkout has uncommitted changes, so the artifact this "
|
||||
+ "would produce corresponds to no commit and cannot be reproduced. Commit there first, "
|
||||
+ "or set BITLOGIN_ALLOW_DIRTY=1 for a throwaway local build.",
|
||||
);
|
||||
}
|
||||
source = {
|
||||
repository: git("remote get-url origin"),
|
||||
commit: git("rev-parse HEAD"),
|
||||
commitDate: git("log -1 --format=%cI"),
|
||||
dirty,
|
||||
provenance: dirty ? "dirty" : "attributed",
|
||||
toolchain: { node: process.version, vite: packageJson.devDependencies?.vite },
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.message.startsWith("DIRTY_CHECKOUT:")) {
|
||||
throw new Error(error.message.replace("DIRTY_CHECKOUT: ", ""));
|
||||
}
|
||||
throw new Error(
|
||||
`Could not read BitLogin git provenance from ${bitloginRoot}: ${error.message}. `
|
||||
+ "Vendoring from a non-git source leaves the artifact unattributable; refusing.",
|
||||
);
|
||||
}
|
||||
console.log(`[vendor-bitlogin] vendoring @bitlogin/widget from ${source.commit.slice(0, 12)}`);
|
||||
|
||||
if (!existsSync(widgetDist)) {
|
||||
console.log("[build-bitlogin-widget] dist missing, building @bitlogin/core then @bitlogin/widget first...");
|
||||
execSync("npm run build -w @bitlogin/core", { cwd: bitloginRoot, stdio: "inherit" });
|
||||
execSync("npm run build -w @bitlogin/widget", { cwd: bitloginRoot, stdio: "inherit" });
|
||||
}
|
||||
|
||||
await rm(vendorDir, { recursive: true, force: true });
|
||||
await mkdir(vendorDir, { recursive: true });
|
||||
|
||||
const distEntries = (await readdir(widgetDist)).filter((name) => name.endsWith(".js"));
|
||||
if (!distEntries.includes("bitlogin.js") || !distEntries.includes("cryptoWorker.js")) {
|
||||
throw new Error("Expected bitlogin.js and cryptoWorker.js in @bitlogin/widget dist -- did the build layout change?");
|
||||
}
|
||||
|
||||
const files = {};
|
||||
for (const name of distEntries) {
|
||||
await copyFile(path.join(widgetDist, name), path.join(vendorDir, name));
|
||||
const bytes = await readFile(path.join(widgetDist, name));
|
||||
files[name] = { sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.byteLength };
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
integrityFile,
|
||||
`${JSON.stringify({ package: packageJson.name, version: packageJson.version, source, files }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
try {
|
||||
await copyFile(path.join(bitloginRoot, "LICENSE"), path.join(repoRoot, "vendor/bitlogin.LICENSE"));
|
||||
} catch (error) {
|
||||
console.warn("[build-bitlogin-widget] could not copy LICENSE:", error.message);
|
||||
}
|
||||
|
||||
console.log("[build-bitlogin-widget] wrote", path.relative(repoRoot, vendorDir), `(${distEntries.length} files)`);
|
||||
@@ -167,7 +167,7 @@ function injectVersionInfo() {
|
||||
let content = fs.readFileSync(indexHtmlPath, 'utf8');
|
||||
|
||||
const versionHtml = `
|
||||
<div class="mt-4 text-xs text-muted opacity-50 font-mono">
|
||||
<div class="mt-4 text-xs text-muted opacity-50 font-mono" data-site-version="${hash.slice(0, 8)}" data-site-version-date="${date}">
|
||||
v: ${hash.slice(0, 8)} • ${date}
|
||||
</div>`;
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Bundles hls.js into a single self-contained ESM file that bitvid lazy-imports
|
||||
// the first time a viewer opens an HLS (.m3u8) source. Mirrors the
|
||||
// blossom-sdk / bitcoin-connect / floating-ui vendor steps. Pinned via the exact
|
||||
// devDependency version in package.json.
|
||||
//
|
||||
// Lazy-import is the whole point: hls.js is ~530KB minified, and the vast
|
||||
// majority of bitvid videos are progressive MP4 or WebTorrent. Nothing here may
|
||||
// be imported from a module that loads on boot.
|
||||
import { build } from "esbuild";
|
||||
import { mkdir, copyFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const entryPoint = path.join(repoRoot, "scripts/vendor/hls-entry.mjs");
|
||||
const vendorDir = path.join(repoRoot, "vendor");
|
||||
const outFile = path.join(vendorDir, "hls.bundle.min.js");
|
||||
const licenseSrc = path.join(repoRoot, "node_modules/hls.js/LICENSE");
|
||||
const licenseOut = path.join(vendorDir, "hls.LICENSE");
|
||||
|
||||
await mkdir(vendorDir, { recursive: true });
|
||||
|
||||
await build({
|
||||
bundle: true,
|
||||
entryPoints: [entryPoint],
|
||||
outfile: outFile,
|
||||
format: "esm",
|
||||
splitting: false,
|
||||
platform: "browser",
|
||||
target: ["es2020"],
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
global: "globalThis",
|
||||
},
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
try {
|
||||
await copyFile(licenseSrc, licenseOut);
|
||||
} catch (error) {
|
||||
console.warn("[build-hls] could not copy LICENSE:", error.message);
|
||||
}
|
||||
|
||||
console.log("[build-hls] wrote", path.relative(repoRoot, outFile));
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
// Vendor entry for hls.js. We deliberately bundle the FULL build rather than
|
||||
// `hls.js/light`: the light build drops the alternate-audio stream controller,
|
||||
// and real-world HLS ladders (e.g. nostube/slidestr publishes an
|
||||
// `EXT-X-MEDIA:TYPE=AUDIO` rendition group) demux audio into its own playlist.
|
||||
// With the light build those streams play video-only or not at all.
|
||||
export { default } from "hls.js";
|
||||
@@ -296,9 +296,10 @@ test("moderation override clears channel blur via event wiring", async (t) => {
|
||||
|
||||
const container = document.getElementById("channelVideoList");
|
||||
const moderationState = {
|
||||
trustedCount: 2,
|
||||
// BitVid's current default blur threshold is three trusted reports.
|
||||
trustedCount: 3,
|
||||
summary: {
|
||||
types: { nudity: { trusted: 2 } },
|
||||
types: { nudity: { trusted: 3 } },
|
||||
},
|
||||
};
|
||||
const video = createVideoFixture({
|
||||
|
||||
@@ -85,3 +85,61 @@ test('createFeedCoordinator - loadForYouVideos', async (t) => {
|
||||
assert.strictEqual(app.videoSubscription, 'mock-subscription', 'Should update videoSubscription');
|
||||
});
|
||||
});
|
||||
|
||||
test("loadForYouVideos renders without waiting for recommendation worker activation", async () => {
|
||||
let resolveActivation;
|
||||
const activation = new Promise((resolve) => {
|
||||
resolveActivation = resolve;
|
||||
});
|
||||
let loadVideosStarted = false;
|
||||
const mockDeps = {
|
||||
devLogger: { log: mock.fn(), warn: mock.fn() },
|
||||
userLogger: { warn: mock.fn() },
|
||||
nostrClient: {},
|
||||
getSidebarLoadingMarkup: mock.fn(() => '<div>Loading...</div>'),
|
||||
watchHistoryService: {},
|
||||
subscriptions: {},
|
||||
createActiveNostrSource: mock.fn(),
|
||||
createBlacklistFilterStage: mock.fn(),
|
||||
createDisinterestFilterStage: mock.fn(),
|
||||
createDedupeByRootStage: mock.fn(),
|
||||
createExploreDiversitySorter: mock.fn(),
|
||||
createExploreScorerStage: mock.fn(),
|
||||
createKidsAudienceFilterStage: mock.fn(),
|
||||
createKidsScorerStage: mock.fn(),
|
||||
createKidsScoreSorter: mock.fn(),
|
||||
createModerationStage: mock.fn(),
|
||||
createResolvePostedAtStage: mock.fn(),
|
||||
createTagPreferenceFilterStage: mock.fn(),
|
||||
createWatchHistorySuppressionStage: mock.fn(),
|
||||
createChronologicalSorter: mock.fn(),
|
||||
createSubscriptionAuthorsSource: mock.fn(),
|
||||
registerWatchHistoryFeed: mock.fn(),
|
||||
};
|
||||
const coordinator = createFeedCoordinator(mockDeps);
|
||||
const app = {
|
||||
...coordinator,
|
||||
nostrService: {
|
||||
getFilteredActiveVideos: mock.fn(() => [{ id: "cached" }]),
|
||||
loadVideos: mock.fn(async ({ onVideos }) => {
|
||||
loadVideosStarted = true;
|
||||
onVideos([{ id: "cached", title: "Cached", created_at: 1 }], { reason: "cache" });
|
||||
return [{ id: "cached", title: "Cached", created_at: 1 }];
|
||||
}),
|
||||
getVideoSubscription: mock.fn(() => null),
|
||||
getVideosMap: mock.fn(() => new Map()),
|
||||
},
|
||||
videoListView: { state: {}, render: mock.fn() },
|
||||
mountVideoListView: mock.fn(() => ({ innerHTML: "" })),
|
||||
checkRelayHealthWarning: mock.fn(),
|
||||
setFeedTelemetryContext: mock.fn(),
|
||||
isAuthorBlocked: mock.fn(() => false),
|
||||
refreshFeed: mock.fn(async () => {}),
|
||||
ensureExploreDataService: mock.fn(() => activation),
|
||||
blacklistedEventIds: new Set(),
|
||||
};
|
||||
|
||||
await app.loadForYouVideos(false);
|
||||
assert.equal(loadVideosStarted, true);
|
||||
resolveActivation();
|
||||
});
|
||||
@@ -151,3 +151,64 @@ test("login does not eagerly load DM history; it is deferred", async () => {
|
||||
"the lightweight unread DM indicator should still run so the badge stays accurate",
|
||||
);
|
||||
});
|
||||
|
||||
test("login reloads the open video comment thread", async () => {
|
||||
globalThis.window.location = { search: "", href: "http://localhost/" };
|
||||
|
||||
const coord = makeCoordinator({
|
||||
loadDirectMessages: async () => ({ ok: true }),
|
||||
refreshUnreadDmIndicator: async () => {},
|
||||
});
|
||||
const activeVideo = { id: "b".repeat(64), title: "Open video" };
|
||||
const loadedVideos = [];
|
||||
coord.videoModal = { getCurrentVideo: () => activeVideo };
|
||||
coord.commentController = {
|
||||
load: (video) => loadedVideos.push(video),
|
||||
refreshAuthState: () => {
|
||||
throw new Error("an open thread should be reloaded, not only refreshed");
|
||||
},
|
||||
};
|
||||
|
||||
await coord.handleAuthLogin({
|
||||
pubkey: ACTIVE_PUBKEY,
|
||||
identityChanged: true,
|
||||
postLogin: { profile: { name: "tester" } },
|
||||
postLoginPromise: Promise.resolve({ profile: { name: "tester" } }),
|
||||
});
|
||||
|
||||
assert.deepEqual(loadedVideos, [activeVideo]);
|
||||
});
|
||||
|
||||
test("duplicate login for the active identity joins the existing list sync", async () => {
|
||||
const coord = makeCoordinator({
|
||||
loadDirectMessages: async () => ({ ok: true }),
|
||||
refreshUnreadDmIndicator: async () => {},
|
||||
});
|
||||
let releaseBlocks;
|
||||
const blocksGate = new Promise((resolve) => {
|
||||
releaseBlocks = resolve;
|
||||
});
|
||||
let blockLoads = 0;
|
||||
coord.authService.loadBlocksForPubkey = async () => {
|
||||
blockLoads += 1;
|
||||
await blocksGate;
|
||||
return true;
|
||||
};
|
||||
const detail = {
|
||||
pubkey: ACTIVE_PUBKEY,
|
||||
identityChanged: false,
|
||||
postLogin: { profile: { name: "tester" } },
|
||||
postLoginPromise: Promise.resolve({ profile: { name: "tester" } }),
|
||||
};
|
||||
|
||||
await coord.handleAuthLogin(detail);
|
||||
const duplicate = coord.handleAuthLogin({ ...detail });
|
||||
for (let attempt = 0; attempt < 20 && blockLoads === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(blockLoads, 1, "duplicate login must not start a second list sync");
|
||||
|
||||
releaseBlocks();
|
||||
await duplicate;
|
||||
assert.equal(coord._authLoginSync, null, "dedupe guard releases after list sync settles");
|
||||
});
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
createModerationAppHarness,
|
||||
} from "../helpers/moderation-test-helpers.mjs";
|
||||
import { userBlocks } from "../../js/userBlocks.js";
|
||||
import {
|
||||
clearAuthorModerationOverride,
|
||||
hasAuthorModerationOverride,
|
||||
setAuthorModerationOverride,
|
||||
} from "../../js/state/cache.js";
|
||||
|
||||
const REPORTER_HEX = "b".repeat(64);
|
||||
const MUTER_HEX = "c".repeat(64);
|
||||
@@ -109,6 +114,37 @@ test("handleModerationOverride resumes deferred playback", async (t) => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Restore default moderation clears both video and creator overrides", async (t) => {
|
||||
withMockedNostrTools(t);
|
||||
|
||||
const app = await createModerationAppHarness();
|
||||
app.moderationDecorator.updateSettings({
|
||||
autoplayBlockThreshold: 1,
|
||||
blurThreshold: 1,
|
||||
});
|
||||
const authorHex = "8".repeat(64);
|
||||
const videoId = "e".repeat(64);
|
||||
const video = {
|
||||
id: videoId,
|
||||
pubkey: authorHex,
|
||||
moderation: buildModerationState(),
|
||||
};
|
||||
|
||||
t.after(() => clearAuthorModerationOverride(authorHex));
|
||||
setAuthorModerationOverride(authorHex, { showAnyway: true });
|
||||
app.handleModerationOverride({ video });
|
||||
assert.equal(hasAuthorModerationOverride(authorHex), true);
|
||||
assert.equal(video.moderation.viewerOverride?.showAnyway, true);
|
||||
|
||||
const restored = app.handleModerationHide({ video });
|
||||
|
||||
assert.equal(restored, true);
|
||||
assert.equal(hasAuthorModerationOverride(authorHex), false);
|
||||
assert.equal(video.moderation.viewerOverride, undefined);
|
||||
assert.equal(video.moderation.blockAutoplay, true);
|
||||
assert.equal(video.moderation.blurThumbnail, true);
|
||||
});
|
||||
|
||||
test("handleModerationBlock requests a block, clears overrides, and refreshes hidden state", async (t) => {
|
||||
withMockedNostrTools(t);
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
|
||||
import {
|
||||
providers,
|
||||
providersForModal,
|
||||
} from "../js/services/authProviders/index.js";
|
||||
import bitloginProvider, {
|
||||
setPendingBitloginResult,
|
||||
} from "../js/services/authProviders/bitlogin.js";
|
||||
import { createBitloginAdapter } from "../js/nostr/adapters/bitloginAdapter.js";
|
||||
import { accessControl } from "../js/accessControl.js";
|
||||
|
||||
const HEX_PUBKEY = "a".repeat(64);
|
||||
|
||||
describe("BitLogin auth provider", () => {
|
||||
const originalCanAccess = accessControl.canAccess;
|
||||
const originalIsBlacklisted = accessControl.isBlacklisted;
|
||||
|
||||
afterEach(() => {
|
||||
setPendingBitloginResult(null);
|
||||
accessControl.canAccess = originalCanAccess;
|
||||
accessControl.isBlacklisted = originalIsBlacklisted;
|
||||
});
|
||||
|
||||
it("registers in the resolvable provider registry but not the modal grid", () => {
|
||||
// AuthService.resolveAuthProvider() must be able to find "bitlogin" ...
|
||||
assert.equal(providers.bitlogin, bitloginProvider);
|
||||
// ... but the login modal's grid (LoginModalController) must never render
|
||||
// a redundant button for it -- BitLogin gets its own permanent widget in
|
||||
// components/login-modal.html instead.
|
||||
assert.equal(providersForModal.bitlogin, undefined);
|
||||
assert.equal(providersForModal.nip07, providers.nip07);
|
||||
});
|
||||
|
||||
it("refuses to log in before the widget has actually produced a pubkey", async () => {
|
||||
accessControl.canAccess = () => true;
|
||||
setPendingBitloginResult(null);
|
||||
|
||||
await assert.rejects(
|
||||
() => bitloginProvider.login({ nostrClient: {} }),
|
||||
/hasn't completed yet/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("activates the signer and resolves once the widget's result is pending", async () => {
|
||||
accessControl.canAccess = () => true;
|
||||
let activated = null;
|
||||
const fakeSigner = { type: "bitlogin", pubkey: HEX_PUBKEY };
|
||||
const fakeNostrClient = {
|
||||
signerManager: {
|
||||
setActiveSigner: (signer) => {
|
||||
activated = signer;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setPendingBitloginResult({ pubkey: HEX_PUBKEY, signer: fakeSigner });
|
||||
|
||||
const result = await bitloginProvider.login({ nostrClient: fakeNostrClient });
|
||||
|
||||
assert.equal(result.authType, "bitlogin");
|
||||
assert.equal(result.pubkey, HEX_PUBKEY);
|
||||
assert.equal(result.signer, fakeSigner);
|
||||
assert.equal(activated, fakeSigner);
|
||||
});
|
||||
|
||||
it("consumes the pending result exactly once (no replay across calls)", async () => {
|
||||
accessControl.canAccess = () => true;
|
||||
const fakeNostrClient = { signerManager: { setActiveSigner: () => {} } };
|
||||
setPendingBitloginResult({ pubkey: HEX_PUBKEY, signer: {} });
|
||||
|
||||
await bitloginProvider.login({ nostrClient: fakeNostrClient });
|
||||
|
||||
await assert.rejects(
|
||||
() => bitloginProvider.login({ nostrClient: fakeNostrClient }),
|
||||
/hasn't completed yet/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("never activates a signer for a blacklisted pubkey", async () => {
|
||||
accessControl.canAccess = () => false;
|
||||
accessControl.isBlacklisted = () => true;
|
||||
let activated = null;
|
||||
const fakeNostrClient = {
|
||||
signerManager: {
|
||||
setActiveSigner: (signer) => {
|
||||
activated = signer;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setPendingBitloginResult({ pubkey: HEX_PUBKEY, signer: { type: "bitlogin" } });
|
||||
|
||||
await assert.rejects(
|
||||
() => bitloginProvider.login({ nostrClient: fakeNostrClient }),
|
||||
/blocked/i,
|
||||
);
|
||||
assert.equal(activated, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBitloginAdapter", () => {
|
||||
it("delegates signing and NIP-44/NIP-04 calls to the live widget element, never window.nostr", async () => {
|
||||
const calls = [];
|
||||
const fakeWidget = {
|
||||
signEvent: async (event) => {
|
||||
calls.push(["signEvent", event]);
|
||||
return { ...event, id: "signed" };
|
||||
},
|
||||
nip44Encrypt: async (peer, plaintext) => {
|
||||
calls.push(["nip44Encrypt", peer, plaintext]);
|
||||
return "ciphertext";
|
||||
},
|
||||
nip44Decrypt: async (peer, payload) => {
|
||||
calls.push(["nip44Decrypt", peer, payload]);
|
||||
return "plaintext";
|
||||
},
|
||||
nip04Encrypt: async (peer, plaintext) => {
|
||||
calls.push(["nip04Encrypt", peer, plaintext]);
|
||||
return "legacy-ciphertext";
|
||||
},
|
||||
nip04Decrypt: async (peer, payload) => {
|
||||
calls.push(["nip04Decrypt", peer, payload]);
|
||||
return "legacy-plaintext";
|
||||
},
|
||||
logout: async () => {
|
||||
calls.push(["logout"]);
|
||||
},
|
||||
};
|
||||
|
||||
const adapter = createBitloginAdapter(fakeWidget, HEX_PUBKEY);
|
||||
|
||||
assert.equal(adapter.type, "bitlogin");
|
||||
assert.equal(adapter.pubkey, HEX_PUBKEY);
|
||||
assert.equal(adapter.canSign(), true);
|
||||
// NIP-04 is real, load-bearing capability info: js/nostr/client.js only offers a
|
||||
// nip04Encrypt/Decrypt code path to callers when signerCapabilities.nip04 is true.
|
||||
assert.deepEqual(adapter.capabilities, { sign: true, nip44: true, nip04: true });
|
||||
|
||||
const signed = await adapter.signEvent({ kind: 1, content: "hi" });
|
||||
assert.equal(signed.id, "signed");
|
||||
|
||||
await adapter.nip44Encrypt("peer-pubkey", "hello");
|
||||
await adapter.nip44Decrypt("peer-pubkey", "ciphertext");
|
||||
await adapter.nip04Encrypt("peer-pubkey", "hello");
|
||||
await adapter.nip04Decrypt("peer-pubkey", "legacy-ciphertext");
|
||||
await adapter.destroy();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["signEvent", { kind: 1, content: "hi" }],
|
||||
["nip44Encrypt", "peer-pubkey", "hello"],
|
||||
["nip44Decrypt", "peer-pubkey", "ciphertext"],
|
||||
["nip04Encrypt", "peer-pubkey", "hello"],
|
||||
["nip04Decrypt", "peer-pubkey", "legacy-ciphertext"],
|
||||
["logout"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
resolveVerifiedBitloginPubkey,
|
||||
wireBitloginLogin,
|
||||
} from "../js/ui/bitloginModalIntegration.js";
|
||||
|
||||
const PUBKEY = "a".repeat(64);
|
||||
|
||||
test("BitLogin accepts a login identity only when the event and widget worker agree", async () => {
|
||||
const widget = { getPublicKey: async () => PUBKEY.toUpperCase() };
|
||||
assert.equal(await resolveVerifiedBitloginPubkey(widget, PUBKEY), PUBKEY);
|
||||
});
|
||||
|
||||
test("BitLogin rejects a forged event pubkey even when its claimed account has access", async () => {
|
||||
const widget = { getPublicKey: async () => "b".repeat(64) };
|
||||
assert.equal(await resolveVerifiedBitloginPubkey(widget, PUBKEY), "");
|
||||
});
|
||||
|
||||
test("BitLogin rejects malformed claims and widgets without a worker-backed public key", async () => {
|
||||
assert.equal(await resolveVerifiedBitloginPubkey({}, PUBKEY), "");
|
||||
assert.equal(await resolveVerifiedBitloginPubkey({ getPublicKey: async () => PUBKEY }, "npub1nope"), "");
|
||||
});
|
||||
|
||||
test("BitLogin bootstrap safely defers when MutationObserver is unavailable", () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalObserver = globalThis.MutationObserver;
|
||||
globalThis.document = {
|
||||
body: {},
|
||||
defaultView: {},
|
||||
getElementById: () => null,
|
||||
};
|
||||
try {
|
||||
delete globalThis.MutationObserver;
|
||||
assert.doesNotThrow(() => wireBitloginLogin({ authService: {} }));
|
||||
} finally {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalObserver === undefined) delete globalThis.MutationObserver;
|
||||
else globalThis.MutationObserver = originalObserver;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
installRequestMonitor,
|
||||
isRequestMonitorEnabled,
|
||||
REQUEST_MONITOR_STORAGE_KEY,
|
||||
} from "../js/devReqMonitor.js";
|
||||
|
||||
function storage(value = null) {
|
||||
return { getItem: (key) => (key === REQUEST_MONITOR_STORAGE_KEY ? value : null) };
|
||||
}
|
||||
|
||||
function createScope({ hostname = "localhost", enabled = false } = {}) {
|
||||
class FakeWebSocket {
|
||||
send(data) {
|
||||
this.sent = data;
|
||||
return "sent";
|
||||
}
|
||||
}
|
||||
const intervals = [];
|
||||
return {
|
||||
location: { hostname },
|
||||
localStorage: storage(enabled ? "1" : null),
|
||||
WebSocket: FakeWebSocket,
|
||||
setInterval: (callback, delay) => {
|
||||
intervals.push({ callback, delay });
|
||||
return intervals.length;
|
||||
},
|
||||
clearInterval: () => {},
|
||||
console: { info: () => {}, warn: () => {} },
|
||||
intervals,
|
||||
};
|
||||
}
|
||||
|
||||
test("request monitor stays disabled on localhost until explicitly enabled", () => {
|
||||
const scope = createScope();
|
||||
const originalSend = scope.WebSocket.prototype.send;
|
||||
assert.equal(installRequestMonitor({ scope }), null);
|
||||
assert.equal(scope.WebSocket.prototype.send, originalSend);
|
||||
assert.equal(scope.intervals.length, 0);
|
||||
});
|
||||
|
||||
test("request monitor cannot be enabled on a public host", () => {
|
||||
assert.equal(
|
||||
isRequestMonitorEnabled({
|
||||
locationRef: { hostname: "unstable.bitvid.network" },
|
||||
storage: storage("1"),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("explicit loopback opt-in patches REQ sends and can be stopped", () => {
|
||||
const scope = createScope({ enabled: true });
|
||||
const originalSend = scope.WebSocket.prototype.send;
|
||||
const monitor = installRequestMonitor({ scope });
|
||||
|
||||
assert.ok(monitor);
|
||||
assert.notEqual(scope.WebSocket.prototype.send, originalSend);
|
||||
assert.equal(scope.intervals.length, 1);
|
||||
assert.equal(scope.intervals[0].delay, 2000);
|
||||
|
||||
const socket = new scope.WebSocket();
|
||||
assert.equal(socket.send('["REQ","sub",{"kinds":[1]}]'), "sent");
|
||||
assert.equal(socket.sent, '["REQ","sub",{"kinds":[1]}]');
|
||||
|
||||
monitor.stop();
|
||||
assert.equal(scope.WebSocket.prototype.send, originalSend);
|
||||
assert.equal(scope.__bitvidReqMonitorInstalled, undefined);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { ProfileDirectMessageActions } from "../js/ui/profileModal/ProfileDirectMessageActions.js";
|
||||
|
||||
test("DM pane renders a persisted snapshot while the relay load is still active", () => {
|
||||
const scheduled = [];
|
||||
const actor = "a".repeat(64);
|
||||
const controller = {
|
||||
activeMessagesRequest: Symbol("relay-load"),
|
||||
directMessagesCache: [],
|
||||
directMessagesLastActor: null,
|
||||
helper: { resolveActiveDmActor: () => actor },
|
||||
renderer: {
|
||||
scheduleDirectMessagesRender: (detail) => scheduled.push(detail),
|
||||
setMessagesLoadingState: () => {},
|
||||
},
|
||||
};
|
||||
const actions = new ProfileDirectMessageActions({}, controller);
|
||||
const messages = [{ event: { id: "cached-message" } }];
|
||||
|
||||
actions.handleDirectMessagesUpdated({ messages, reason: "snapshot" });
|
||||
|
||||
assert.deepEqual(controller.directMessagesCache, messages);
|
||||
assert.equal(controller.directMessagesLastActor, actor);
|
||||
assert.deepEqual(scheduled, [{ messages, actorPubkey: actor, reason: "snapshot" }]);
|
||||
});
|
||||
|
||||
test("DM pane waits for relay increments but ignores unrelated updates during a load", () => {
|
||||
const controller = {
|
||||
activeMessagesRequest: Symbol("relay-load"),
|
||||
directMessagesCache: [],
|
||||
directMessagesLastActor: null,
|
||||
helper: { resolveActiveDmActor: () => "a".repeat(64) },
|
||||
renderer: {
|
||||
scheduleDirectMessagesRender: () => assert.fail("unrelated update must not render"),
|
||||
setMessagesLoadingState: () => {},
|
||||
},
|
||||
};
|
||||
const actions = new ProfileDirectMessageActions({}, controller);
|
||||
|
||||
actions.handleDirectMessagesUpdated({ messages: [{ event: { id: "other" } }], reason: "load" });
|
||||
|
||||
assert.deepEqual(controller.directMessagesCache, []);
|
||||
});
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user