mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c611555dfa | ||
|
|
244f527cf6 | ||
|
|
99f8e88969 | ||
|
|
c0d006a5fc | ||
|
|
04075ff56b | ||
|
|
e5784893a8 | ||
|
|
d141c316a9 | ||
|
|
b84558e928 | ||
|
|
38b51f13a5 | ||
|
|
4404728410 | ||
|
|
726c0d26ed | ||
|
|
bb6614e3d3 | ||
|
|
687f8c438d | ||
|
|
2c4aefe872 | ||
|
|
b774de6d76 | ||
|
|
5f439d6863 | ||
|
|
fe976b3ab7 | ||
|
|
4525c1c657 | ||
|
|
3735d57410 | ||
|
|
4dd150b010 | ||
|
|
c200c84fc5 | ||
|
|
d19040c625 | ||
|
|
5764482f2a |
No files matched your search
@@ -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*
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 -->
|
||||
|
||||
+234
-36
@@ -759,6 +759,14 @@
|
||||
@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;
|
||||
}
|
||||
@@ -767,6 +775,44 @@
|
||||
@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;
|
||||
@@ -983,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 {
|
||||
@@ -991,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"] {
|
||||
@@ -1035,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%,
|
||||
@@ -1526,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;
|
||||
@@ -3592,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 {
|
||||
@@ -3772,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")) {
|
||||
@@ -6734,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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
+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
|
||||
|
||||
@@ -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,
|
||||
@@ -721,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);
|
||||
@@ -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();
|
||||
|
||||
+43
-1
@@ -9,6 +9,7 @@
|
||||
* Methods use `this` which is bound to the Application instance.
|
||||
*/
|
||||
import { SHORT_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js";
|
||||
import { getActiveSigner } from "../nostrClientRegistry.js";
|
||||
|
||||
export function createUiCoordinator(deps) {
|
||||
const {
|
||||
@@ -187,11 +188,52 @@ export function createUiCoordinator(deps) {
|
||||
},
|
||||
|
||||
syncAuthUiState() {
|
||||
if (this.isUserLoggedIn()) {
|
||||
// Auth-dependent chrome has to follow login/logout/account switches, but
|
||||
// syncAuthUiState() was only ever called at bootstrap — so everything it
|
||||
// gates (notably the share menu's "Share on Nostr" item) kept its
|
||||
// logged-out state for the entire session. Self-install a listener on the
|
||||
// first run rather than adding a call site in js/app.js, which is at its
|
||||
// file-size cap.
|
||||
if (
|
||||
!this.__authUiSyncBound &&
|
||||
typeof window !== "undefined" &&
|
||||
typeof window?.addEventListener === "function"
|
||||
) {
|
||||
this.__authUiSyncBound = true;
|
||||
window.addEventListener("bitvid:auth-changed", () => {
|
||||
try {
|
||||
this.syncAuthUiState();
|
||||
} catch (error) {
|
||||
devLogger.warn("[Application] Auth UI resync failed:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const loggedIn = this.isUserLoggedIn();
|
||||
if (loggedIn) {
|
||||
this.applyAuthenticatedUiState();
|
||||
} else {
|
||||
this.applyLoggedOutUiState();
|
||||
}
|
||||
|
||||
// Gate the share menu's "Share on Nostr" item. Without this the modal's
|
||||
// shareNostrAuthState stays at its initial `false` forever and the item
|
||||
// renders permanently disabled — indistinguishable from plain text.
|
||||
if (typeof this.videoModal?.setShareNostrAuthState === "function") {
|
||||
let signer = null;
|
||||
try {
|
||||
signer = getActiveSigner();
|
||||
} catch (error) {
|
||||
devLogger.warn("[Application] Failed to read active signer:", error);
|
||||
}
|
||||
this.videoModal.setShareNostrAuthState({
|
||||
isLoggedIn: loggedIn,
|
||||
// Mirrors ShareNostrController.handleShare(): a signer that cannot
|
||||
// sign is no signer at all, and enabling the item would only produce
|
||||
// an error toast on click.
|
||||
hasSigner: !!signer && typeof signer.signEvent === "function",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
refreshChromeElements() {
|
||||
|
||||
@@ -189,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,
|
||||
|
||||
+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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1138,8 +1138,11 @@ export function createModerationStage({
|
||||
const adminWhitelist = adminStatus?.whitelisted === true;
|
||||
const adminWhitelistBypass = false;
|
||||
|
||||
const blockAutoplay =
|
||||
trustedCount >= normalizedAutoplayThreshold || trustedMuted || viewerMuted;
|
||||
// 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" : "";
|
||||
@@ -1269,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,
|
||||
@@ -59,6 +60,10 @@ 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();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+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,5 +1,8 @@
|
||||
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,
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
SimpleEventEmitter,
|
||||
extractWebSeedsFromMagnet,
|
||||
} from "./playbackHelpers.js";
|
||||
import { attachHlsSource, detachHls, isHlsUrl } from "./hlsPlayback.js";
|
||||
// js/services/playbackService.js
|
||||
|
||||
/**
|
||||
@@ -855,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) {
|
||||
@@ -1055,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,
|
||||
@@ -1071,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 });
|
||||
}
|
||||
@@ -1080,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:",
|
||||
@@ -1091,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.
|
||||
@@ -1100,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
|
||||
@@ -1119,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, {
|
||||
@@ -1171,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);
|
||||
@@ -1215,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"
|
||||
);
|
||||
|
||||
|
||||
+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,
|
||||
};
|
||||
}
|
||||
@@ -166,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;
|
||||
@@ -360,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,
|
||||
|
||||
@@ -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 : "",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 : "";
|
||||
}
|
||||
+27
-9
@@ -138,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;
|
||||
@@ -151,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()
|
||||
@@ -178,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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
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,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";
|
||||
@@ -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();
|
||||
});
|
||||
@@ -178,3 +178,37 @@ test("login reloads the open video comment thread", async () => {
|
||||
|
||||
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,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, []);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { createCardObserver } from "../../js/dom/cardObserver.js";
|
||||
|
||||
test("card observer disconnect unregisters every card and releases its observer", () => {
|
||||
const dom = new JSDOM("<div id=grid><article class='card' data-video-id=a></article><article class='card' data-video-id=b></article></div>");
|
||||
const originalHTMLElement = globalThis.HTMLElement;
|
||||
const originalIntersectionObserver = globalThis.IntersectionObserver;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
let disconnects = 0;
|
||||
globalThis.IntersectionObserver = class {
|
||||
observe() {}
|
||||
takeRecords() { return []; }
|
||||
disconnect() { disconnects += 1; }
|
||||
};
|
||||
|
||||
const unregistered = [];
|
||||
try {
|
||||
const observer = createCardObserver({
|
||||
onCardUnregister: ({ card }) => unregistered.push(card.dataset.videoId),
|
||||
});
|
||||
const grid = dom.window.document.getElementById("grid");
|
||||
observer.observe(grid);
|
||||
observer.disconnect(grid);
|
||||
|
||||
assert.deepEqual(unregistered.sort(), ["a", "b"]);
|
||||
assert.equal(disconnects, 1);
|
||||
assert.equal(observer.getState(grid), null);
|
||||
} finally {
|
||||
dom.window.close();
|
||||
if (originalHTMLElement === undefined) delete globalThis.HTMLElement;
|
||||
else globalThis.HTMLElement = originalHTMLElement;
|
||||
if (originalIntersectionObserver === undefined) delete globalThis.IntersectionObserver;
|
||||
else globalThis.IntersectionObserver = originalIntersectionObserver;
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getDefaultModerationSettings,
|
||||
setModerationOverride,
|
||||
clearModerationOverride,
|
||||
clearAuthorModerationOverride,
|
||||
} from "../../js/state/cache.js";
|
||||
import { userBlocks } from "../../js/userBlocks.js";
|
||||
import { HEX64_REGEX } from "../../js/utils/hex.js";
|
||||
@@ -453,6 +454,7 @@ export async function createModerationAppHarness(options = {}) {
|
||||
userBlocks: app.userBlocks,
|
||||
setModerationOverride,
|
||||
clearModerationOverride,
|
||||
clearAuthorModerationOverride,
|
||||
},
|
||||
auth: {
|
||||
isLoggedIn: () => app.isUserLoggedIn(),
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Addressable pointers to a video's NIP-71 mirror — see js/nostr/mirrorPointer.js.
|
||||
//
|
||||
// SCN-mirror-quote: a share note embeds `nostr:naddr1…` so nostr clients render
|
||||
// a native video quote card. The pointer MUST address the event that actually
|
||||
// exists: a pointer to a missing or wrong-kind event renders as a broken/empty
|
||||
// quote box, which is strictly worse than the plain-link fallback.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
NIP71_NORMAL_VIDEO_KIND,
|
||||
NIP71_SHORT_VIDEO_KIND,
|
||||
buildMirrorCoordinate,
|
||||
buildMirrorNaddr,
|
||||
resolveMirrorKind,
|
||||
} from "../js/nostr/mirrorPointer.js";
|
||||
|
||||
const PUBKEY = "b7c6f6915cfa9a62fff6a1f02604de88c23c6c6c6d1b8f62c7cc10749f307e81";
|
||||
const ROOT = "b52ab495-ac9c-4a29-a454-aff142a43691";
|
||||
|
||||
// Records what it was asked to encode so the tests can assert on inputs rather
|
||||
// than on bech32 output.
|
||||
const makeNip19 = () => {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
naddrEncode(pointer) {
|
||||
calls.push(pointer);
|
||||
return `naddr1${pointer.kind}${pointer.identifier}`;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// --- kind resolution ------------------------------------------------------
|
||||
|
||||
test("observed relay kinds win over the dimension heuristic", () => {
|
||||
// Ground truth from nip71MirrorService.findMirror must beat any guess —
|
||||
// a portrait video whose mirror was published as 34235 is still 34235.
|
||||
assert.equal(
|
||||
resolveMirrorKind({ kinds: [NIP71_NORMAL_VIDEO_KIND], width: 720, height: 1280 }),
|
||||
NIP71_NORMAL_VIDEO_KIND
|
||||
);
|
||||
assert.equal(
|
||||
resolveMirrorKind({ kinds: [NIP71_SHORT_VIDEO_KIND], width: 1920, height: 1080 }),
|
||||
NIP71_SHORT_VIDEO_KIND
|
||||
);
|
||||
});
|
||||
|
||||
test("without observed kinds it mirrors nip71Mirror.js's portrait rule", () => {
|
||||
// nip71Mirror.js:179 — `hasDims && height > width` selects the short kind.
|
||||
assert.equal(
|
||||
resolveMirrorKind({ width: 720, height: 1280 }),
|
||||
NIP71_SHORT_VIDEO_KIND
|
||||
);
|
||||
assert.equal(
|
||||
resolveMirrorKind({ width: 1280, height: 720 }),
|
||||
NIP71_NORMAL_VIDEO_KIND
|
||||
);
|
||||
// Square is not portrait.
|
||||
assert.equal(
|
||||
resolveMirrorKind({ width: 1000, height: 1000 }),
|
||||
NIP71_NORMAL_VIDEO_KIND
|
||||
);
|
||||
});
|
||||
|
||||
test("missing or nonsensical dimensions default to the normal kind", () => {
|
||||
for (const dims of [
|
||||
{},
|
||||
{ width: 0, height: 0 },
|
||||
{ width: NaN, height: 10 },
|
||||
{ width: "wide", height: "tall" },
|
||||
{ width: -100, height: -200 },
|
||||
]) {
|
||||
assert.equal(resolveMirrorKind(dims), NIP71_NORMAL_VIDEO_KIND);
|
||||
}
|
||||
assert.equal(resolveMirrorKind(), NIP71_NORMAL_VIDEO_KIND);
|
||||
});
|
||||
|
||||
test("a cross-kind duplicate resolves deterministically to the normal kind", () => {
|
||||
// nip71MirrorService flags this as `duplicate`. Picking arbitrarily would
|
||||
// make the shared pointer unstable between shares of the same video.
|
||||
assert.equal(
|
||||
resolveMirrorKind({ kinds: [NIP71_SHORT_VIDEO_KIND, NIP71_NORMAL_VIDEO_KIND] }),
|
||||
NIP71_NORMAL_VIDEO_KIND
|
||||
);
|
||||
});
|
||||
|
||||
test("unknown kinds are ignored rather than pointed at", () => {
|
||||
assert.equal(
|
||||
resolveMirrorKind({ kinds: [1, 30078], width: 720, height: 1280 }),
|
||||
NIP71_SHORT_VIDEO_KIND,
|
||||
"falls through to the heuristic instead of addressing kind 1"
|
||||
);
|
||||
});
|
||||
|
||||
// --- coordinate -----------------------------------------------------------
|
||||
|
||||
test("buildMirrorCoordinate produces a kind:pubkey:d address", () => {
|
||||
assert.equal(
|
||||
buildMirrorCoordinate({ pubkey: PUBKEY, videoRootId: ROOT, kind: 34235 }),
|
||||
`34235:${PUBKEY}:${ROOT}`
|
||||
);
|
||||
});
|
||||
|
||||
test("buildMirrorCoordinate refuses incomplete input", () => {
|
||||
assert.equal(buildMirrorCoordinate({ videoRootId: ROOT, kind: 34235 }), "");
|
||||
assert.equal(buildMirrorCoordinate({ pubkey: PUBKEY, kind: 34235 }), "");
|
||||
assert.equal(buildMirrorCoordinate({ pubkey: PUBKEY, videoRootId: ROOT }), "");
|
||||
assert.equal(buildMirrorCoordinate({ pubkey: PUBKEY, videoRootId: ROOT, kind: 0 }), "");
|
||||
assert.equal(buildMirrorCoordinate(), "");
|
||||
});
|
||||
|
||||
// --- naddr ----------------------------------------------------------------
|
||||
|
||||
test("buildMirrorNaddr encodes kind, author, identifier and relay hints", () => {
|
||||
const nip19 = makeNip19();
|
||||
const naddr = buildMirrorNaddr({
|
||||
pubkey: PUBKEY,
|
||||
videoRootId: ROOT,
|
||||
kind: NIP71_NORMAL_VIDEO_KIND,
|
||||
relays: ["wss://relay.damus.io", "wss://nos.lol"],
|
||||
nip19,
|
||||
});
|
||||
|
||||
assert.equal(naddr.startsWith("naddr1"), true);
|
||||
assert.deepEqual(nip19.calls[0], {
|
||||
kind: NIP71_NORMAL_VIDEO_KIND,
|
||||
pubkey: PUBKEY,
|
||||
identifier: ROOT,
|
||||
relays: ["wss://relay.damus.io", "wss://nos.lol"],
|
||||
});
|
||||
});
|
||||
|
||||
test("relay hints are filtered to wss/ws and capped at three", () => {
|
||||
const nip19 = makeNip19();
|
||||
buildMirrorNaddr({
|
||||
pubkey: PUBKEY,
|
||||
videoRootId: ROOT,
|
||||
kind: 34235,
|
||||
relays: [
|
||||
"https://not-a-relay.example",
|
||||
"wss://a.example",
|
||||
"",
|
||||
"wss://b.example",
|
||||
"ws://c.example",
|
||||
"wss://d.example",
|
||||
null,
|
||||
],
|
||||
nip19,
|
||||
});
|
||||
|
||||
assert.deepEqual(nip19.calls[0].relays, [
|
||||
"wss://a.example",
|
||||
"wss://b.example",
|
||||
"ws://c.example",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildMirrorNaddr returns empty rather than a malformed pointer", () => {
|
||||
const nip19 = makeNip19();
|
||||
const base = { pubkey: PUBKEY, videoRootId: ROOT, kind: 34235, nip19 };
|
||||
|
||||
assert.equal(buildMirrorNaddr({ ...base, pubkey: "" }), "");
|
||||
assert.equal(buildMirrorNaddr({ ...base, videoRootId: "" }), "");
|
||||
assert.equal(buildMirrorNaddr({ ...base, kind: 0 }), "");
|
||||
assert.equal(buildMirrorNaddr({ ...base, nip19: null }), "");
|
||||
assert.equal(buildMirrorNaddr({ ...base, nip19: {} }), "");
|
||||
assert.equal(buildMirrorNaddr(), "");
|
||||
assert.equal(nip19.calls.length, 0, "never attempted an encode");
|
||||
});
|
||||
|
||||
test("an encoder that throws yields empty, never a broken quote", () => {
|
||||
const hostile = {
|
||||
naddrEncode() {
|
||||
throw new Error("bad pointer");
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
buildMirrorNaddr({
|
||||
pubkey: PUBKEY,
|
||||
videoRootId: ROOT,
|
||||
kind: 34235,
|
||||
nip19: hostile,
|
||||
}),
|
||||
""
|
||||
);
|
||||
});
|
||||
@@ -169,6 +169,13 @@ test("moderation stage annotates trusted mute metadata", async () => {
|
||||
assert.equal(mutedItem.video.moderation.trustedMuteCount, 1);
|
||||
assert.equal(mutedItem.metadata.moderation.trustedMuted, true);
|
||||
assert.equal(mutedItem.metadata.moderation.trustedMuteCount, 1);
|
||||
assert.equal(mutedItem.video.moderation.blockAutoplay, false);
|
||||
assert.equal(mutedItem.video.moderation.blurThumbnail, false);
|
||||
assert.equal(
|
||||
mutedItem.video.moderation.blurReason,
|
||||
undefined,
|
||||
"a single trusted mute downranks without visibly flagging the creator",
|
||||
);
|
||||
|
||||
const trustedMuteReason = reasons.find((entry) => entry.reason === "trusted-mute");
|
||||
assert.ok(trustedMuteReason);
|
||||
|
||||
@@ -108,6 +108,7 @@ test("VideoCard renders moderation badges and respects viewer override", async (
|
||||
withMockedNostrTools(t);
|
||||
|
||||
const app = await createModerationAppHarness();
|
||||
app.moderationDecorator.updateSettings({ trustedSpamHideThreshold: 3 });
|
||||
const videoId = "a".repeat(64);
|
||||
|
||||
const video = {
|
||||
@@ -204,6 +205,7 @@ test(
|
||||
withMockedNostrTools(t);
|
||||
|
||||
const app = await createModerationAppHarness();
|
||||
app.moderationDecorator.updateSettings({ trustedSpamHideThreshold: 3 });
|
||||
const videoId = "b".repeat(64);
|
||||
|
||||
const video = {
|
||||
@@ -336,7 +338,7 @@ test("VideoCard blurs viewer-muted creators", async (t) => {
|
||||
assert.equal(card.moderationBadgeEl.dataset.moderationState, "blocked");
|
||||
});
|
||||
|
||||
test("VideoCard blurs thumbnails when trusted mute triggers without reports", async (t) => {
|
||||
test("VideoCard keeps a single trusted mute as a non-blocking ranking signal", async (t) => {
|
||||
const { document } = setupDom(t);
|
||||
withMockedNostrTools(t);
|
||||
|
||||
@@ -380,26 +382,16 @@ test("VideoCard blurs thumbnails when trusted mute triggers without reports", as
|
||||
|
||||
document.body.appendChild(card.getRoot());
|
||||
|
||||
assert.equal(video.moderation.blurThumbnail, true);
|
||||
assert.equal(video.moderation.blurReason, "trusted-mute");
|
||||
assert.equal(video.moderation.blurThumbnail, false);
|
||||
assert.equal(video.moderation.blurReason, "");
|
||||
assert.equal(
|
||||
normalizeVideoModerationContext(card.video?.moderation).activeBlur,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert.equal(card.thumbnailEl.dataset.thumbnailState, "blurred");
|
||||
assert.equal(card.authorPicEl.dataset.visualState, "blurred");
|
||||
assert.equal(card.getRoot().dataset.autoplayPolicy, "blocked");
|
||||
assert.equal(card.moderationBadgeEl.dataset.moderationState, "trusted-mute");
|
||||
assert.equal(
|
||||
card.moderationBadgeTextEl.textContent,
|
||||
"Content or user blocked by a trusted contact.",
|
||||
);
|
||||
assert.ok(card.moderationBlockButton);
|
||||
assert.equal(
|
||||
card.moderationBlockButton?.dataset?.moderationAction,
|
||||
"block",
|
||||
);
|
||||
assert.equal(card.moderationBlockButton.textContent, "Block");
|
||||
assert.equal(card.thumbnailEl.dataset.thumbnailState, undefined);
|
||||
assert.equal(card.authorPicEl.dataset.visualState, undefined);
|
||||
assert.equal(card.getRoot().dataset.autoplayPolicy, undefined);
|
||||
assert.equal(card.moderationBadgeEl, null);
|
||||
});
|
||||
|
||||
test("VideoCard block action restores trusted mute hide state after override", async (t) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ function makeHarness({ whitelistOn = false, whitelist = [] } = {}) {
|
||||
const allEvents = new Map();
|
||||
let capturedFilters = null;
|
||||
let onEvent = null;
|
||||
let onEose = null;
|
||||
let closed = false;
|
||||
let whitelistListener = null;
|
||||
|
||||
@@ -27,6 +28,7 @@ function makeHarness({ whitelistOn = false, whitelist = [] } = {}) {
|
||||
subscribe(opts) {
|
||||
capturedFilters = opts.filters;
|
||||
onEvent = opts.onEvent;
|
||||
onEose = opts.onEose;
|
||||
return {
|
||||
close() {
|
||||
closed = true;
|
||||
@@ -81,6 +83,7 @@ function makeHarness({ whitelistOn = false, whitelist = [] } = {}) {
|
||||
return closed;
|
||||
},
|
||||
fireEvent: (e) => onEvent?.(e),
|
||||
triggerEose: () => onEose?.(),
|
||||
triggerWhitelistChange: () => whitelistListener?.(),
|
||||
triggerFeedReady: () => feedListeners.forEach((fn) => fn({})),
|
||||
nostrClient,
|
||||
@@ -263,6 +266,26 @@ test("startWhenFeedReady falls back to starting if the feed never emits", async
|
||||
svc.stop();
|
||||
});
|
||||
|
||||
test("initial NIP-71 sync keeps every event but emits one consolidated feed update", () => {
|
||||
const h = makeHarness();
|
||||
const svc = makeService(h, { feedReadyFallbackMs: 100000, initialSyncFallbackMs: 100000 });
|
||||
|
||||
svc.startWhenFeedReady();
|
||||
h.triggerFeedReady();
|
||||
h.fireEvent(foreignEvent({ id: "a", kind: 34235, tags: [["d", "ra"], ["title", "A"], ["imeta", "url https://e/a.mp4", "m video/mp4"]] }));
|
||||
h.fireEvent(foreignEvent({ id: "b", kind: 34235, tags: [["d", "rb"], ["title", "B"], ["imeta", "url https://e/b.mp4", "m video/mp4"]] }));
|
||||
svc.flush();
|
||||
|
||||
assert.ok(h.activeMap.has("ROOT:ra") && h.activeMap.has("ROOT:rb"), "all initial NIP-71 events are retained");
|
||||
assert.equal(h.emitted.filter((e) => e.name === "videos:updated").length, 0, "no partial-startup repaint");
|
||||
|
||||
h.triggerEose();
|
||||
const refreshes = h.emitted.filter((e) => e.name === "videos:updated");
|
||||
assert.equal(refreshes.length, 1, "one consolidated update at initial sync completion");
|
||||
assert.equal(refreshes[0].detail.reason, "nip71-ingest");
|
||||
svc.stop();
|
||||
});
|
||||
|
||||
test("coalesces refresh signals so an event burst can't storm the feed", () => {
|
||||
const h = makeHarness();
|
||||
const svc = makeService(h, { refreshThrottleMs: 10000 });
|
||||
|
||||
@@ -111,21 +111,36 @@ class FakeRelayPool {
|
||||
}
|
||||
|
||||
async function configureNostrTools() {
|
||||
const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
|
||||
const previousCanonicalDescriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
"__BITVID_CANONICAL_NOSTR_TOOLS__",
|
||||
);
|
||||
const previousNostrTools = globalThis.NostrTools;
|
||||
const previousReady = globalThis.nostrToolsReady;
|
||||
|
||||
const nostrTools = await import("nostr-tools");
|
||||
|
||||
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = nostrTools;
|
||||
// nostrToolsBootstrap deliberately exposes this value as read-only. Test
|
||||
// setup must replace its *configurable descriptor*, rather than assigning to
|
||||
// the property and failing after another test/import initialized bootstrap.
|
||||
Object.defineProperty(globalThis, "__BITVID_CANONICAL_NOSTR_TOOLS__", {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
value: nostrTools,
|
||||
});
|
||||
globalThis.NostrTools = nostrTools;
|
||||
globalThis.nostrToolsReady = Promise.resolve({ ok: true, value: nostrTools });
|
||||
|
||||
const restore = () => {
|
||||
if (previousCanonical === undefined) {
|
||||
if (!previousCanonicalDescriptor) {
|
||||
delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
|
||||
} else {
|
||||
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical;
|
||||
Object.defineProperty(
|
||||
globalThis,
|
||||
"__BITVID_CANONICAL_NOSTR_TOOLS__",
|
||||
previousCanonicalDescriptor,
|
||||
);
|
||||
}
|
||||
|
||||
if (previousNostrTools === undefined) {
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// Per-video OpenGraph rendering for crawlers — see api/_lib/ogRenderer.mjs.
|
||||
//
|
||||
// Two things here are security-relevant and get the most attention:
|
||||
// 1. Escaping. Titles/descriptions come from arbitrary nostr events, and this
|
||||
// HTML is served from bitvid's own origin.
|
||||
// 2. Gating. A crawler never authenticates, so anything emitted is public
|
||||
// forever in third-party caches. Deleted/private/NSFW/non-whitelisted
|
||||
// videos must fall back to the generic site card.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
CRAWLER_USER_AGENT_PATTERN,
|
||||
escapeHtmlAttribute,
|
||||
isCrawlerUserAgent,
|
||||
isPubliclyShareable,
|
||||
normalizeText,
|
||||
parseVideoEvent,
|
||||
renderOgHtml,
|
||||
sanitizeAbsoluteUrl,
|
||||
} from "../api/_lib/ogRenderer.mjs";
|
||||
|
||||
const PUBKEY = "b7c6f6915cfa9a62fff6a1f02604de88c23c6c6c6d1b8f62c7cc10749f307e81";
|
||||
|
||||
const publicVideo = () => ({
|
||||
pubkey: PUBKEY,
|
||||
title: "Why I'm (sort of) not worried about AI",
|
||||
description: "A talk about creativity and machines.",
|
||||
thumbnail: "https://cdn.example/thumb.jpg",
|
||||
deleted: false,
|
||||
isPrivate: false,
|
||||
isNsfw: false,
|
||||
invalid: false,
|
||||
});
|
||||
|
||||
const FALLBACK = {
|
||||
siteName: "bitvid",
|
||||
title: "bitvid - Decentralized Video Sharing",
|
||||
description: "Decentralized video sharing on Nostr.",
|
||||
image: "https://bitvid.network/assets/jpg/bitvid.jpg",
|
||||
url: "https://bitvid.network",
|
||||
};
|
||||
|
||||
const render = (video, extra = {}) =>
|
||||
renderOgHtml({
|
||||
video,
|
||||
shareUrl: "https://bitvid.network/?v=nevent1abc",
|
||||
embedUrl: "https://bitvid.network/embed.html?pointer=nevent1abc",
|
||||
fallback: FALLBACK,
|
||||
gate: isPubliclyShareable(video, extra.gateOptions || {}),
|
||||
...extra.render,
|
||||
});
|
||||
|
||||
// --- escaping -------------------------------------------------------------
|
||||
|
||||
test("escapeHtmlAttribute neutralizes attribute-breaking characters", () => {
|
||||
assert.equal(
|
||||
escapeHtmlAttribute(`"><script>alert(1)</script>`),
|
||||
""><script>alert(1)</script>"
|
||||
);
|
||||
assert.equal(escapeHtmlAttribute("a & b"), "a & b");
|
||||
assert.equal(escapeHtmlAttribute("it's"), "it's");
|
||||
assert.equal(escapeHtmlAttribute(null), "");
|
||||
assert.equal(escapeHtmlAttribute(42), "");
|
||||
});
|
||||
|
||||
test("a hostile title cannot break out of the meta tag", () => {
|
||||
const video = publicVideo();
|
||||
video.title = `"><script>alert(document.domain)</script><meta x="`;
|
||||
const html = render(video);
|
||||
|
||||
assert.equal(html.includes("<script>"), false, "no raw script tag");
|
||||
assert.equal(html.includes(`"><script`), false, "no attribute breakout");
|
||||
assert.match(html, /<script>/);
|
||||
});
|
||||
|
||||
test("a hostile description is escaped too", () => {
|
||||
const video = publicVideo();
|
||||
video.description = `" onload="alert(1)`;
|
||||
const html = render(video);
|
||||
assert.equal(html.includes(`onload="alert(1)`), false);
|
||||
assert.match(html, /" onload="alert\(1\)/);
|
||||
});
|
||||
|
||||
// --- URL sanitization -----------------------------------------------------
|
||||
|
||||
test("sanitizeAbsoluteUrl accepts only http(s)", () => {
|
||||
assert.equal(
|
||||
sanitizeAbsoluteUrl("https://cdn.example/a.jpg"),
|
||||
"https://cdn.example/a.jpg"
|
||||
);
|
||||
assert.equal(sanitizeAbsoluteUrl("http://cdn.example/a.jpg"), "http://cdn.example/a.jpg");
|
||||
assert.equal(sanitizeAbsoluteUrl("javascript:alert(1)"), "");
|
||||
assert.equal(sanitizeAbsoluteUrl("data:image/png;base64,AAAA"), "");
|
||||
assert.equal(sanitizeAbsoluteUrl("assets/jpg/local.jpg"), "");
|
||||
assert.equal(sanitizeAbsoluteUrl(""), "");
|
||||
assert.equal(sanitizeAbsoluteUrl(null), "");
|
||||
});
|
||||
|
||||
test("a javascript: thumbnail never reaches og:image", () => {
|
||||
const video = publicVideo();
|
||||
video.thumbnail = "javascript:alert(1)";
|
||||
const html = render(video);
|
||||
assert.equal(html.includes("javascript:"), false);
|
||||
// Falls back to the site image rather than emitting nothing.
|
||||
assert.match(html, /og:image" content="https:\/\/bitvid\.network\/assets/);
|
||||
});
|
||||
|
||||
// --- text normalization ---------------------------------------------------
|
||||
|
||||
test("normalizeText collapses whitespace and truncates with an ellipsis", () => {
|
||||
assert.equal(normalizeText(" a \n b \t c ", 50), "a b c");
|
||||
const long = "x".repeat(300);
|
||||
const out = normalizeText(long, 120);
|
||||
assert.equal(out.length, 120);
|
||||
assert.equal(out.endsWith("…"), true);
|
||||
assert.equal(normalizeText(null, 10), "");
|
||||
});
|
||||
|
||||
// --- gating ---------------------------------------------------------------
|
||||
|
||||
test("a clean public video is shareable", () => {
|
||||
assert.deepEqual(isPubliclyShareable(publicVideo()), {
|
||||
allowed: true,
|
||||
reason: "ok",
|
||||
});
|
||||
});
|
||||
|
||||
test("deleted, private, NSFW and invalid videos are never described", () => {
|
||||
for (const [field, reason] of [
|
||||
["deleted", "deleted"],
|
||||
["isPrivate", "private"],
|
||||
["isNsfw", "nsfw"],
|
||||
["invalid", "invalid-video"],
|
||||
]) {
|
||||
const video = publicVideo();
|
||||
video[field] = true;
|
||||
const result = isPubliclyShareable(video);
|
||||
assert.equal(result.allowed, false, `${field} must gate the preview`);
|
||||
assert.equal(result.reason, reason);
|
||||
}
|
||||
});
|
||||
|
||||
test("a missing video or author is gated", () => {
|
||||
assert.equal(isPubliclyShareable(null).allowed, false);
|
||||
assert.equal(isPubliclyShareable(undefined).reason, "missing-video");
|
||||
const noAuthor = publicVideo();
|
||||
noAuthor.pubkey = "";
|
||||
assert.equal(isPubliclyShareable(noAuthor).reason, "missing-author");
|
||||
});
|
||||
|
||||
test("a blacklisted author is gated regardless of whitelist mode", () => {
|
||||
const result = isPubliclyShareable(publicVideo(), {
|
||||
blacklist: new Set([PUBKEY]),
|
||||
});
|
||||
assert.equal(result.allowed, false);
|
||||
assert.equal(result.reason, "blacklisted");
|
||||
});
|
||||
|
||||
test("whitelist mode fails CLOSED when the list is missing or empty", () => {
|
||||
// The dangerous case: relays timed out, so we cannot prove the author is
|
||||
// allowed. Emitting a rich card here would leak gated metadata.
|
||||
assert.equal(
|
||||
isPubliclyShareable(publicVideo(), { whitelistEnabled: true }).reason,
|
||||
"whitelist-unavailable"
|
||||
);
|
||||
assert.equal(
|
||||
isPubliclyShareable(publicVideo(), {
|
||||
whitelistEnabled: true,
|
||||
whitelist: new Set(),
|
||||
}).reason,
|
||||
"whitelist-unavailable"
|
||||
);
|
||||
});
|
||||
|
||||
test("whitelist mode admits a listed author and rejects an unlisted one", () => {
|
||||
assert.equal(
|
||||
isPubliclyShareable(publicVideo(), {
|
||||
whitelistEnabled: true,
|
||||
whitelist: new Set([PUBKEY]),
|
||||
}).allowed,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isPubliclyShareable(publicVideo(), {
|
||||
whitelistEnabled: true,
|
||||
whitelist: new Set(["a".repeat(64)]),
|
||||
}).reason,
|
||||
"not-whitelisted"
|
||||
);
|
||||
});
|
||||
|
||||
test("gated videos render the generic site card, leaking no metadata", () => {
|
||||
const video = publicVideo();
|
||||
video.isPrivate = true;
|
||||
video.title = "Secret internal recording";
|
||||
video.thumbnail = "https://cdn.example/secret.jpg";
|
||||
const html = render(video);
|
||||
|
||||
assert.equal(html.includes("Secret internal recording"), false);
|
||||
assert.equal(html.includes("secret.jpg"), false);
|
||||
assert.match(html, /og:title" content="bitvid - Decentralized Video Sharing"/);
|
||||
assert.match(html, /og:type" content="website"/);
|
||||
// No player card for a gated video.
|
||||
assert.equal(html.includes("twitter:player"), false);
|
||||
});
|
||||
|
||||
// --- rendering ------------------------------------------------------------
|
||||
|
||||
test("a public video renders a player card pointing at the embed", () => {
|
||||
const html = render(publicVideo());
|
||||
|
||||
assert.match(html, /og:type" content="video\.other"/);
|
||||
assert.match(html, /og:title" content="Why I'm \(sort of\) not worried about AI"/);
|
||||
assert.match(html, /og:image" content="https:\/\/cdn\.example\/thumb\.jpg"/);
|
||||
assert.match(
|
||||
html,
|
||||
/og:video:url" content="https:\/\/bitvid\.network\/embed\.html\?pointer=nevent1abc"/
|
||||
);
|
||||
assert.match(html, /twitter:card" content="player"/);
|
||||
assert.match(
|
||||
html,
|
||||
/twitter:player" content="https:\/\/bitvid\.network\/embed\.html\?pointer=nevent1abc"/
|
||||
);
|
||||
assert.match(html, /twitter:player:width" content="1280"/);
|
||||
assert.match(html, /twitter:player:height" content="720"/);
|
||||
assert.match(html, /<link rel="canonical" href="https:\/\/bitvid\.network\/\?v=nevent1abc"/);
|
||||
});
|
||||
|
||||
test("without an embed URL it degrades to a summary_large_image card", () => {
|
||||
const html = renderOgHtml({
|
||||
video: publicVideo(),
|
||||
shareUrl: "https://bitvid.network/?v=nevent1abc",
|
||||
fallback: FALLBACK,
|
||||
gate: { allowed: true, reason: "ok" },
|
||||
});
|
||||
assert.match(html, /twitter:card" content="summary_large_image"/);
|
||||
assert.equal(html.includes("twitter:player"), false);
|
||||
});
|
||||
|
||||
test("the rendered document is well-formed and self-contained", () => {
|
||||
const html = render(publicVideo());
|
||||
assert.match(html, /^<!doctype html>/);
|
||||
assert.match(html, /<\/html>\n$/);
|
||||
// Crawler stub only — it must not pull in the app or any script.
|
||||
assert.equal(/<script/i.test(html), false);
|
||||
});
|
||||
|
||||
// --- event parsing --------------------------------------------------------
|
||||
// bitvid publishes kind 30078 with a v3 JSON body; nostube/slidestr and other
|
||||
// NIP-71-native clients publish kind 21/22/34235/34236 with the metadata in
|
||||
// TAGS. Parsing only the JSON shape made every NIP-71 video — including the
|
||||
// real note this work started from — fall back to the generic card.
|
||||
|
||||
test("parseVideoEvent reads the bitvid v3 JSON shape", () => {
|
||||
const parsed = parseVideoEvent({
|
||||
kind: 30078,
|
||||
pubkey: PUBKEY,
|
||||
content: JSON.stringify({
|
||||
version: 3,
|
||||
title: "A bitvid upload",
|
||||
description: "Body text",
|
||||
thumbnail: "https://cdn.example/t.jpg",
|
||||
url: "https://cdn.example/v.mp4",
|
||||
isNsfw: false,
|
||||
}),
|
||||
tags: [],
|
||||
});
|
||||
|
||||
assert.equal(parsed.title, "A bitvid upload");
|
||||
assert.equal(parsed.description, "Body text");
|
||||
assert.equal(parsed.thumbnail, "https://cdn.example/t.jpg");
|
||||
assert.equal(parsed.invalid, false);
|
||||
assert.equal(isPubliclyShareable(parsed).allowed, true);
|
||||
});
|
||||
|
||||
test("parseVideoEvent reads the NIP-71 tag shape (the real nostube note)", () => {
|
||||
const parsed = parseVideoEvent({
|
||||
kind: 34235,
|
||||
pubkey: PUBKEY,
|
||||
content: "As a creator, and as a leader of a tech company…",
|
||||
tags: [
|
||||
["d", "b52ab495-ac9c-4a29-a454-aff142a43691"],
|
||||
["title", "Why I'm (sort of) not worried about AI"],
|
||||
[
|
||||
"imeta",
|
||||
"dim 1280x720",
|
||||
"url https://almond.example/video.m3u8",
|
||||
"m application/vnd.apple.mpegurl",
|
||||
"image https://almond.example/thumb.jpg",
|
||||
"image https://blossom.example/thumb.jpg",
|
||||
],
|
||||
["client", "nostube"],
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(parsed.title, "Why I'm (sort of) not worried about AI");
|
||||
assert.equal(parsed.thumbnail, "https://almond.example/thumb.jpg");
|
||||
assert.match(parsed.description, /^As a creator/);
|
||||
assert.equal(parsed.invalid, false);
|
||||
});
|
||||
|
||||
test("parseVideoEvent treats a content-warning tag as NSFW", () => {
|
||||
const parsed = parseVideoEvent({
|
||||
kind: 34235,
|
||||
pubkey: PUBKEY,
|
||||
content: "desc",
|
||||
tags: [["title", "Sensitive"], ["content-warning", "nudity"]],
|
||||
});
|
||||
assert.equal(parsed.isNsfw, true);
|
||||
assert.equal(isPubliclyShareable(parsed).reason, "nsfw");
|
||||
});
|
||||
|
||||
test("parseVideoEvent rejects untitled and non-video events", () => {
|
||||
assert.equal(
|
||||
parseVideoEvent({ kind: 34235, pubkey: PUBKEY, content: "", tags: [] }).invalid,
|
||||
true
|
||||
);
|
||||
// A plain kind-1 note is not a video.
|
||||
assert.equal(parseVideoEvent({ kind: 1, pubkey: PUBKEY, content: "hi", tags: [] }), null);
|
||||
assert.equal(parseVideoEvent(null), null);
|
||||
assert.equal(parseVideoEvent({}), null);
|
||||
});
|
||||
|
||||
test("parseVideoEvent survives malformed JSON content", () => {
|
||||
// A kind-30078 whose content is truncated/corrupt must not throw.
|
||||
assert.equal(
|
||||
parseVideoEvent({ kind: 30078, pubkey: PUBKEY, content: "{not json", tags: [] }),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("parseVideoEvent ignores non-http imeta image entries", () => {
|
||||
const parsed = parseVideoEvent({
|
||||
kind: 34235,
|
||||
pubkey: PUBKEY,
|
||||
content: "desc",
|
||||
tags: [
|
||||
["title", "T"],
|
||||
["imeta", "image javascript:alert(1)", "image https://ok.example/t.png"],
|
||||
],
|
||||
});
|
||||
assert.equal(parsed.thumbnail, "https://ok.example/t.png");
|
||||
});
|
||||
|
||||
// --- crawler detection ----------------------------------------------------
|
||||
|
||||
test("known preview crawlers are recognized", () => {
|
||||
for (const ua of [
|
||||
"facebookexternalhit/1.1",
|
||||
"Twitterbot/1.0",
|
||||
"Slackbot-LinkExpanding 1.0",
|
||||
"Mozilla/5.0 (compatible; Discordbot/2.0)",
|
||||
"TelegramBot (like TwitterBot)",
|
||||
"WhatsApp/2.19",
|
||||
"Mozilla/5.0 (compatible; Googlebot/2.1)",
|
||||
]) {
|
||||
assert.equal(isCrawlerUserAgent(ua), true, `${ua} should match`);
|
||||
}
|
||||
});
|
||||
|
||||
test("real browsers are NOT treated as crawlers", () => {
|
||||
for (const ua of [
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15",
|
||||
"",
|
||||
]) {
|
||||
assert.equal(isCrawlerUserAgent(ua), false, `${ua} must not match`);
|
||||
}
|
||||
assert.equal(isCrawlerUserAgent(null), false);
|
||||
assert.equal(isCrawlerUserAgent(undefined), false);
|
||||
});
|
||||
|
||||
test("the crawler pattern is not sticky/global (no lastIndex state)", () => {
|
||||
// A /g regex would alternate true/false across calls via lastIndex — a
|
||||
// classic bug that would randomly serve crawlers the wrong page.
|
||||
assert.equal(CRAWLER_USER_AGENT_PATTERN.global, false);
|
||||
const ua = "Twitterbot/1.0";
|
||||
assert.equal(isCrawlerUserAgent(ua), true);
|
||||
assert.equal(isCrawlerUserAgent(ua), true);
|
||||
assert.equal(isCrawlerUserAgent(ua), true);
|
||||
});
|
||||
|
||||
// --- config drift ---------------------------------------------------------
|
||||
// api/og.js imports the admin-list identity and relay set from the SAME modules
|
||||
// the browser uses, rather than keeping its own copies. These assertions fail
|
||||
// loudly if that ever regresses to duplicated constants, because the failure
|
||||
// mode otherwise is silent: previews gated forever, or a stale whitelist.
|
||||
|
||||
test("api/og.js derives its config from the app, not from copies", async () => {
|
||||
const source = await readFile(new URL("../api/og.js", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /from "\.\.\/js\/config\.js"/);
|
||||
assert.match(source, /from "\.\.\/js\/nostr\/toolkit\.js"/);
|
||||
assert.match(source, /from "\.\.\/js\/nostrEventSchemas\.js"/);
|
||||
|
||||
// No hardcoded npub or wss:// literal may reappear in the handler.
|
||||
assert.equal(
|
||||
/npub1[0-9a-z]{20,}/.test(source),
|
||||
false,
|
||||
"admin npub must come from js/config.js, not a literal"
|
||||
);
|
||||
assert.equal(
|
||||
/"wss:\/\//.test(source),
|
||||
false,
|
||||
"relay URLs must come from js/nostr/toolkit.js, not literals"
|
||||
);
|
||||
});
|
||||
|
||||
test("the admin list d-tag matches what the browser store queries", async () => {
|
||||
const { ADMIN_LIST_NAMESPACE } = await import("../js/config.js");
|
||||
const { ADMIN_LIST_IDENTIFIERS } = await import("../js/nostrEventSchemas.js");
|
||||
|
||||
// Pinned against the live list this instance actually publishes; a namespace
|
||||
// change without a matching relay migration would silently gate everything.
|
||||
assert.equal(
|
||||
`${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`,
|
||||
"bitvid:admin:whitelist"
|
||||
);
|
||||
assert.equal(
|
||||
`${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`,
|
||||
"bitvid:admin:blacklist"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
initPerformanceHarness,
|
||||
isLocalPerformanceHarnessEnabled,
|
||||
PERFORMANCE_HARNESS_STORAGE_KEY,
|
||||
} from "../js/performanceHarness.js";
|
||||
|
||||
function createStorage(value = null) {
|
||||
return { getItem: (key) => (key === PERFORMANCE_HARNESS_STORAGE_KEY ? value : null) };
|
||||
}
|
||||
|
||||
function createPerformance() {
|
||||
const marks = [];
|
||||
return {
|
||||
now: () => 100,
|
||||
mark: (name) => marks.push(name),
|
||||
getEntriesByType: (type) => {
|
||||
if (type === "resource") {
|
||||
return [{ transferSize: 12 }, { transferSize: 30 }];
|
||||
}
|
||||
if (type === "navigation") {
|
||||
return [{ domContentLoadedEventEnd: 12, loadEventEnd: 24 }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
memory: { usedJSHeapSize: 10, totalJSHeapSize: 20, jsHeapSizeLimit: 30 },
|
||||
marks,
|
||||
};
|
||||
}
|
||||
|
||||
test("performance harness stays absent without explicit loopback opt-in", () => {
|
||||
const scope = {
|
||||
location: { hostname: "localhost" },
|
||||
localStorage: createStorage(),
|
||||
performance: createPerformance(),
|
||||
};
|
||||
|
||||
assert.equal(isLocalPerformanceHarnessEnabled({
|
||||
locationRef: scope.location,
|
||||
storage: scope.localStorage,
|
||||
}), false);
|
||||
assert.equal(initPerformanceHarness({ scope }), null);
|
||||
assert.equal(scope.__bitvidPerformance, undefined);
|
||||
});
|
||||
|
||||
test("performance harness cannot be enabled on a non-loopback host", () => {
|
||||
assert.equal(isLocalPerformanceHarnessEnabled({
|
||||
locationRef: { hostname: "unstable.bitvid.network" },
|
||||
storage: createStorage("1"),
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("explicit loopback opt-in exposes bounded local measurements", () => {
|
||||
let observerCallback = null;
|
||||
class FakePerformanceObserver {
|
||||
constructor(callback) {
|
||||
observerCallback = callback;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
const scope = {
|
||||
location: { hostname: "127.0.0.1" },
|
||||
localStorage: createStorage("1"),
|
||||
performance: createPerformance(),
|
||||
PerformanceObserver: FakePerformanceObserver,
|
||||
};
|
||||
|
||||
const api = initPerformanceHarness({ scope });
|
||||
assert.equal(typeof api?.snapshot, "function");
|
||||
assert.equal(scope.__bitvidPerformance, api);
|
||||
|
||||
observerCallback({
|
||||
getEntries: () => [{ name: "longtask", startTime: 3, duration: 51 }],
|
||||
});
|
||||
api.mark("feed-refresh", { feed: "for-you" });
|
||||
api.setGauge("active-workers", 1);
|
||||
api.increment("active-workers");
|
||||
|
||||
const snapshot = api.snapshot();
|
||||
assert.equal(snapshot.longTasks.count, 1);
|
||||
assert.equal(snapshot.longTasks.totalDurationMs, 51);
|
||||
assert.equal(snapshot.resources.transferSize, 42);
|
||||
assert.equal(snapshot.gauges["active-workers"], 2);
|
||||
assert.equal(snapshot.memory.usedJsHeapSize, 10);
|
||||
assert.ok(snapshot.marks.some((entry) => entry.name === "feed-refresh"));
|
||||
|
||||
api.stop();
|
||||
assert.equal(scope.__bitvidPerformance, undefined);
|
||||
});
|
||||
@@ -574,8 +574,16 @@ for (const _ of [0]) {
|
||||
const menuWrapper = document.querySelector('[data-profile-mobile-menu]');
|
||||
const paneWrapper = document.querySelector('[data-profile-mobile-pane]');
|
||||
const backButton = document.getElementById('profileModalBack');
|
||||
const mobileLockButton = document.getElementById('profileMobileLockNowBtn');
|
||||
const mobileLogoutButton = document.getElementById('profileMobileLogoutBtn');
|
||||
const mobileActions = mobileLogoutButton?.closest(
|
||||
'.profile-modal__mobile-actions',
|
||||
);
|
||||
|
||||
assert.ok(layout && menuWrapper && paneWrapper && backButton);
|
||||
assert.equal(mobileActions?.getAttribute('aria-label'), 'Session actions');
|
||||
assert.equal(mobileLockButton?.closest('nav'), null);
|
||||
assert.equal(mobileLogoutButton?.closest('nav'), null);
|
||||
|
||||
assert.equal(layout?.dataset.mobileView, 'menu');
|
||||
assert.equal(menuWrapper?.getAttribute('aria-hidden'), 'false');
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const styles = await readFile(
|
||||
new URL("../css/tailwind.source.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("mobile modal sheets use the dynamic viewport instead of extending behind browser chrome", () => {
|
||||
assert.match(
|
||||
styles,
|
||||
/\.bv-modal__panel,\s*\.modal-sheet\s*\{[^}]*block-size:\s*100dvh;[^}]*min-block-size:\s*100dvh;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/#profileModal\s*\{[^}]*height:\s*100dvh;[^}]*min-height:\s*100dvh;/s,
|
||||
);
|
||||
});
|
||||
|
||||
test("mobile profile session actions and menu scrolling clear the bottom safe area", () => {
|
||||
assert.match(
|
||||
styles,
|
||||
/\.profile-modal__menu\s*\{[^}]*padding-block-end:\s*max\(var\(--space-lg\),\s*env\(safe-area-inset-bottom\)\);[^}]*scroll-padding-block-end:/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.profile-modal__mobile-actions\s*\{[^}]*padding-block-end:\s*max\(\s*var\(--space-sm\),\s*env\(safe-area-inset-bottom\)\s*\);/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.mobile-safe-bottom\s*\{[^}]*bottom:\s*calc\(var\(--space-lg\)\s*\+\s*env\(safe-area-inset-bottom\)\);/s,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { ProfilePlaylistsController } from "../js/ui/profileModal/ProfilePlaylistsController.js";
|
||||
|
||||
test("playlist refresh renders the list before deferred video metadata completes", async () => {
|
||||
const controller = new ProfilePlaylistsController({
|
||||
getActivePubkey: () => "a".repeat(64),
|
||||
services: {
|
||||
nostrService: {
|
||||
fetchVideosByAuthors: () => new Promise(() => {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const renders = [];
|
||||
let resolveMetadata;
|
||||
controller.listEl = {};
|
||||
controller.setLoading = () => {};
|
||||
controller.render = () => renders.push(controller.playlists.map((playlist) => playlist.id));
|
||||
controller.fetchCreatorPlaylists = async () => [{
|
||||
id: "existing",
|
||||
items: [{ type: "a", value: `30078:${"b".repeat(64)}:video` }],
|
||||
}];
|
||||
controller.mainController.services.nostrService.fetchVideosByAuthors = () =>
|
||||
new Promise((resolve) => {
|
||||
resolveMetadata = resolve;
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
assert.deepEqual(renders, [["existing"]], "list renders before metadata request settles");
|
||||
|
||||
resolveMetadata();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(renders, [["existing"], ["existing"]], "metadata completion refreshes labels later");
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildReactionFilters } from "../js/reactionCounter.js";
|
||||
|
||||
test("addressable video reactions query both address and immutable event references", () => {
|
||||
const filters = buildReactionFilters(
|
||||
{
|
||||
type: "a",
|
||||
value: "34235:creator:episode-42",
|
||||
relay: "wss://relay.example",
|
||||
eventId: "immutable-video-event-id"
|
||||
},
|
||||
{ limit: 400, since: 123 }
|
||||
);
|
||||
|
||||
assert.deepEqual(filters, [
|
||||
{
|
||||
kinds: [7],
|
||||
"#a": ["34235:creator:episode-42"],
|
||||
limit: 400,
|
||||
since: 123
|
||||
},
|
||||
{
|
||||
kinds: [7],
|
||||
"#e": ["immutable-video-event-id"],
|
||||
limit: 400,
|
||||
since: 123
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("event-addressed video reactions keep a single immutable event query", () => {
|
||||
assert.deepEqual(
|
||||
buildReactionFilters({ type: "e", value: "video-event-id" }),
|
||||
[{ kinds: [7], "#e": ["video-event-id"] }]
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import ExploreDataService from "../../js/services/exploreDataService.js";
|
||||
|
||||
test("ExploreDataService stays idle until a recommendation feed activates it", async () => {
|
||||
const service = new ExploreDataService({
|
||||
historyRefreshIntervalMs: 0,
|
||||
idfRefreshIntervalMs: 0,
|
||||
});
|
||||
let historyRefreshes = 0;
|
||||
let idfRefreshes = 0;
|
||||
service.refreshWatchHistoryTagCounts = async () => {
|
||||
historyRefreshes += 1;
|
||||
return new Map();
|
||||
};
|
||||
service.refreshTagIdf = async () => {
|
||||
idfRefreshes += 1;
|
||||
return new Map();
|
||||
};
|
||||
|
||||
await service.initialize();
|
||||
assert.equal(historyRefreshes, 0);
|
||||
assert.equal(idfRefreshes, 0);
|
||||
|
||||
service.queueWatchHistoryRefresh("note");
|
||||
service.queueTagIdfRefresh("note");
|
||||
await service.setActive(true);
|
||||
assert.equal(historyRefreshes, 1, "dirty history is refreshed on activation");
|
||||
assert.equal(idfRefreshes, 1, "dirty IDF is refreshed on activation");
|
||||
|
||||
await service.setActive(false);
|
||||
service.queueTagIdfRefresh("inactive-note");
|
||||
assert.equal(idfRefreshes, 1, "inactive feeds do not rebuild recommendation indexes");
|
||||
service.destroy();
|
||||
});
|
||||
|
||||
test("ExploreDataService coalesces a refresh burst into one running pass and one follow-up", async () => {
|
||||
const service = new ExploreDataService({
|
||||
historyRefreshIntervalMs: 0,
|
||||
idfRefreshIntervalMs: 0,
|
||||
});
|
||||
service.active = true;
|
||||
let runs = 0;
|
||||
let releaseFirst;
|
||||
service._refreshTagIdf = async () => {
|
||||
runs += 1;
|
||||
if (runs === 1) {
|
||||
await new Promise((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
}
|
||||
return new Map();
|
||||
};
|
||||
|
||||
const first = service.refreshTagIdf({ force: true, reason: "initial" });
|
||||
const duplicate = service.refreshTagIdf({ reason: "relay-event" });
|
||||
service.refreshTagIdf({ reason: "timer" });
|
||||
assert.equal(runs, 1, "only one rebuild starts during the burst");
|
||||
|
||||
releaseFirst();
|
||||
await Promise.all([first, duplicate]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(runs, 2, "the burst produces exactly one follow-up rebuild");
|
||||
service.destroy();
|
||||
});
|
||||
@@ -0,0 +1,632 @@
|
||||
// HLS (.m3u8) support — see js/services/hlsPlayback.js.
|
||||
//
|
||||
// Scenario spec (SCN-hls-*): bitvid used to attach every hosted source with
|
||||
// `videoEl.src = url`, which only plays HLS on Safari. Nostr clients publishing
|
||||
// bitvid's v3 schema with HLS ladders (nostube/slidestr) were unplayable on
|
||||
// Chrome/Firefox, and the URL-health probe reported every healthy HLS stream as
|
||||
// offline. These tests pin the observable decisions at those boundaries:
|
||||
// detection, engine selection, teardown, and the manifest probe.
|
||||
//
|
||||
// The hls.js engine itself is never loaded here — the tests exercise the
|
||||
// native-HLS branch and the fetch-based manifest probe, which are the two paths
|
||||
// that do not require a real MediaSource implementation.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
HLS_MIME_TYPES,
|
||||
attachHlsSource,
|
||||
detachHls,
|
||||
getAttachedHls,
|
||||
isHlsMimeType,
|
||||
isHlsSource,
|
||||
isHlsUrl,
|
||||
prefersNativeHls,
|
||||
probeHlsManifest,
|
||||
supportsMse,
|
||||
supportsNativeHls,
|
||||
} from "../../js/services/hlsPlayback.js";
|
||||
|
||||
// --- SCN-hls-detect -------------------------------------------------------
|
||||
// Given a hosted URL, when its path ends in .m3u8, then it is treated as HLS —
|
||||
// regardless of query strings (signed CDN URLs) or fragments.
|
||||
|
||||
test("isHlsUrl matches .m3u8 paths, including with query/fragment", () => {
|
||||
assert.equal(
|
||||
isHlsUrl(
|
||||
"https://almond.apps2.slidestr.net/3751b84f27234fc8ce3227d132b422f7e00f4a50c6cdc322080fed89a302d7dd.m3u8"
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(isHlsUrl("https://cdn.example/v.M3U8"), true);
|
||||
assert.equal(isHlsUrl("https://cdn.example/v.m3u8?token=abc&exp=1"), true);
|
||||
assert.equal(isHlsUrl("https://cdn.example/v.m3u8#t=10"), true);
|
||||
assert.equal(isHlsUrl(" https://cdn.example/v.m3u8 "), true);
|
||||
});
|
||||
|
||||
test("isHlsUrl rejects non-playlist URLs and incidental .m3u8 substrings", () => {
|
||||
assert.equal(isHlsUrl("https://cdn.example/v.mp4"), false);
|
||||
assert.equal(isHlsUrl("https://cdn.example/v.webm"), false);
|
||||
// .m3u8 must terminate the PATH, not merely appear somewhere in the URL.
|
||||
assert.equal(isHlsUrl("https://cdn.example/v.m3u8.mp4"), false);
|
||||
assert.equal(isHlsUrl("https://cdn.example/?next=/a.m3u8"), false);
|
||||
assert.equal(isHlsUrl(""), false);
|
||||
assert.equal(isHlsUrl(null), false);
|
||||
assert.equal(isHlsUrl(undefined), false);
|
||||
assert.equal(isHlsUrl(42), false);
|
||||
});
|
||||
|
||||
test("isHlsMimeType covers the canonical and legacy playlist types", () => {
|
||||
for (const type of HLS_MIME_TYPES) {
|
||||
assert.equal(isHlsMimeType(type), true, `${type} should be HLS`);
|
||||
assert.equal(isHlsMimeType(type.toUpperCase()), true);
|
||||
}
|
||||
// Parameters must not defeat the match (servers append charset).
|
||||
assert.equal(isHlsMimeType("application/vnd.apple.mpegurl; charset=utf-8"), true);
|
||||
assert.equal(isHlsMimeType("video/mp4"), false);
|
||||
assert.equal(isHlsMimeType("text/plain"), false);
|
||||
assert.equal(isHlsMimeType(""), false);
|
||||
assert.equal(isHlsMimeType(null), false);
|
||||
});
|
||||
|
||||
test("isHlsSource accepts either the URL or an imeta mime hint", () => {
|
||||
assert.equal(isHlsSource({ url: "https://cdn.example/v.m3u8" }), true);
|
||||
assert.equal(
|
||||
isHlsSource({ url: "https://cdn.example/stream", mimeType: "application/vnd.apple.mpegurl" }),
|
||||
true
|
||||
);
|
||||
assert.equal(isHlsSource({ url: "https://cdn.example/v.mp4" }), false);
|
||||
assert.equal(isHlsSource({}), false);
|
||||
assert.equal(isHlsSource(), false);
|
||||
});
|
||||
|
||||
// --- SCN-hls-engine-choice ------------------------------------------------
|
||||
// Given a browser that CLAIMS native HLS support via canPlayType, when MSE is
|
||||
// also available, then hls.js must win. This is not a preference — it is a
|
||||
// correctness requirement measured in real browsers:
|
||||
//
|
||||
// Firefox 146: canPlayType("application/vnd.apple.mpegurl") === ""
|
||||
// bare <video> → MEDIA_ERR_SRC_NOT_SUPPORTED (code 4)
|
||||
// Chrome 149: canPlayType(...) === "maybe" ← and it is genuinely true there
|
||||
//
|
||||
// canPlayType is only ever a hint, so the engine choice keys off MSE (what
|
||||
// hls.js actually needs) and falls back to native only when MSE is absent.
|
||||
|
||||
const makeFakeVideo = (canPlayTypeResult = "") => ({
|
||||
src: "",
|
||||
canPlayType: () => canPlayTypeResult,
|
||||
});
|
||||
|
||||
// Node has no MediaSource; install/remove a fake to drive supportsMse().
|
||||
const withMediaSource = async (isTypeSupported, fn) => {
|
||||
const had = "MediaSource" in globalThis;
|
||||
const original = globalThis.MediaSource;
|
||||
globalThis.MediaSource = { isTypeSupported };
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (had) {
|
||||
globalThis.MediaSource = original;
|
||||
} else {
|
||||
delete globalThis.MediaSource;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test("supportsNativeHls reflects canPlayType for the playlist types", () => {
|
||||
assert.equal(supportsNativeHls(makeFakeVideo("maybe")), true);
|
||||
assert.equal(supportsNativeHls(makeFakeVideo("probably")), true);
|
||||
assert.equal(supportsNativeHls(makeFakeVideo("")), false);
|
||||
});
|
||||
|
||||
test("supportsNativeHls survives a throwing canPlayType", () => {
|
||||
const hostile = {
|
||||
canPlayType() {
|
||||
throw new Error("nope");
|
||||
},
|
||||
};
|
||||
assert.equal(supportsNativeHls(hostile), false);
|
||||
});
|
||||
|
||||
test("supportsMse tracks MediaSource.isTypeSupported", async () => {
|
||||
assert.equal(supportsMse(), false, "no MediaSource in this environment");
|
||||
await withMediaSource(() => true, () => {
|
||||
assert.equal(supportsMse(), true);
|
||||
});
|
||||
await withMediaSource(() => false, () => {
|
||||
assert.equal(supportsMse(), false);
|
||||
});
|
||||
await withMediaSource(() => {
|
||||
throw new Error("boom");
|
||||
}, () => {
|
||||
assert.equal(supportsMse(), false, "a throwing check must not propagate");
|
||||
});
|
||||
});
|
||||
|
||||
test("prefersNativeHls is false whenever MSE can back hls.js", async () => {
|
||||
const chromeLike = makeFakeVideo("maybe");
|
||||
await withMediaSource(() => true, () => {
|
||||
// Chrome's canPlayType says "maybe" — hls.js must still win.
|
||||
assert.equal(prefersNativeHls(chromeLike), false);
|
||||
});
|
||||
// Only with MSE genuinely gone does native become the choice.
|
||||
assert.equal(prefersNativeHls(chromeLike), true);
|
||||
assert.equal(prefersNativeHls(makeFakeVideo("")), false, "neither path works");
|
||||
});
|
||||
|
||||
test("attachHlsSource prefers hls.js over a native claim when MSE exists", async () => {
|
||||
const video = makeFakeVideo("maybe");
|
||||
const { engine, instances } = makeFakeEngine();
|
||||
const handle = await withMediaSource(() => true, () =>
|
||||
attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
||||
createEngine: async () => engine,
|
||||
})
|
||||
);
|
||||
assert.equal(handle.mode, "mse");
|
||||
assert.equal(video.src, "", "must not use the native src path");
|
||||
assert.equal(instances.length, 1);
|
||||
});
|
||||
|
||||
test("attachHlsSource uses native playback when MSE is unavailable", async () => {
|
||||
const video = makeFakeVideo("maybe");
|
||||
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8");
|
||||
assert.ok(handle, "expected a handle");
|
||||
assert.equal(handle.mode, "native");
|
||||
assert.equal(video.src, "https://cdn.example/v.m3u8");
|
||||
// Nothing to destroy on the native path, but destroy() must stay callable.
|
||||
assert.doesNotThrow(() => handle.destroy());
|
||||
assert.equal(getAttachedHls(video), null);
|
||||
});
|
||||
|
||||
test("attachHlsSource falls back to native when the engine fails to load", async () => {
|
||||
const video = makeFakeVideo("maybe");
|
||||
const handle = await withMediaSource(() => true, () =>
|
||||
attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
||||
createEngine: async () => null,
|
||||
})
|
||||
);
|
||||
assert.equal(handle.mode, "native", "an attempt beats a guaranteed failure");
|
||||
assert.equal(video.src, "https://cdn.example/v.m3u8");
|
||||
});
|
||||
|
||||
test("attachHlsSource trims the URL before assigning it", async () => {
|
||||
const video = makeFakeVideo("maybe");
|
||||
await attachHlsSource(video, " https://cdn.example/v.m3u8\n");
|
||||
assert.equal(video.src, "https://cdn.example/v.m3u8");
|
||||
});
|
||||
|
||||
test("attachHlsSource returns null for missing element or URL", async () => {
|
||||
assert.equal(await attachHlsSource(null, "https://cdn.example/v.m3u8"), null);
|
||||
assert.equal(await attachHlsSource(makeFakeVideo("maybe"), ""), null);
|
||||
assert.equal(await attachHlsSource(makeFakeVideo("maybe"), null), null);
|
||||
});
|
||||
|
||||
// --- MSE test double ------------------------------------------------------
|
||||
// A minimal stand-in for the hls.js constructor: enough surface for the
|
||||
// attach/teardown/error contract, with hooks to drive events deterministically.
|
||||
// `autoManifest` mirrors real hls.js, which parses the manifest asynchronously
|
||||
// after loadSource(); tests that drive errors turn it off.
|
||||
|
||||
const HLS_EVENTS = {
|
||||
MANIFEST_PARSED: "hlsManifestParsed",
|
||||
ERROR: "hlsError",
|
||||
};
|
||||
const HLS_ERROR_TYPES = {
|
||||
NETWORK_ERROR: "networkError",
|
||||
MEDIA_ERROR: "mediaError",
|
||||
OTHER_ERROR: "otherError",
|
||||
};
|
||||
|
||||
function makeFakeEngine({ autoManifest = true, levels = [{ height: 240 }] } = {}) {
|
||||
const instances = [];
|
||||
|
||||
class FakeHls {
|
||||
static isSupported() {
|
||||
return true;
|
||||
}
|
||||
static get Events() {
|
||||
return HLS_EVENTS;
|
||||
}
|
||||
static get ErrorTypes() {
|
||||
return HLS_ERROR_TYPES;
|
||||
}
|
||||
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.handlers = new Map();
|
||||
this.destroyCalls = 0;
|
||||
this.startLoadCalls = 0;
|
||||
this.recoverMediaErrorCalls = 0;
|
||||
this.swapAudioCodecCalls = 0;
|
||||
this.attachedMedia = null;
|
||||
this.loadedSource = null;
|
||||
instances.push(this);
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, []);
|
||||
}
|
||||
this.handlers.get(event).push(handler);
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
for (const handler of this.handlers.get(event) || []) {
|
||||
handler(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
attachMedia(media) {
|
||||
this.attachedMedia = media;
|
||||
}
|
||||
|
||||
loadSource(url) {
|
||||
this.loadedSource = url;
|
||||
if (autoManifest) {
|
||||
// Real hls.js parses asynchronously; keep that ordering.
|
||||
queueMicrotask(() => this.emit(HLS_EVENTS.MANIFEST_PARSED, { levels }));
|
||||
}
|
||||
}
|
||||
|
||||
startLoad() {
|
||||
this.startLoadCalls += 1;
|
||||
}
|
||||
|
||||
recoverMediaError() {
|
||||
this.recoverMediaErrorCalls += 1;
|
||||
}
|
||||
|
||||
swapAudioCodec() {
|
||||
this.swapAudioCodecCalls += 1;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyCalls += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { engine: FakeHls, instances };
|
||||
}
|
||||
|
||||
// --- SCN-hls-mse ----------------------------------------------------------
|
||||
// Given an MSE browser, when a playlist parses with at least one playable
|
||||
// level, then attach resolves; when hls.js drops every level (e.g. an
|
||||
// HEVC-only ladder on Firefox) or errors fatally, then attach fails with a
|
||||
// specific, user-facing message instead of a bare "no playable source".
|
||||
|
||||
test("attachHlsSource resolves once the manifest parses, and configures hls.js", async () => {
|
||||
const video = makeFakeVideo("");
|
||||
const { engine, instances } = makeFakeEngine();
|
||||
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
||||
createEngine: async () => engine,
|
||||
});
|
||||
|
||||
assert.equal(handle.mode, "mse");
|
||||
// Native `src` must stay untouched — MSE owns the element now.
|
||||
assert.equal(video.src, "");
|
||||
// A bounded back-buffer matters for hour-long VOD on low-memory devices.
|
||||
assert.equal(instances[0].config.backBufferLength, 90);
|
||||
assert.equal(instances[0].config.enableWorker, true);
|
||||
});
|
||||
|
||||
test("attachHlsSource fails when no rendition survives codec filtering", async () => {
|
||||
const video = makeFakeVideo("");
|
||||
const { engine, instances } = makeFakeEngine({ levels: [] });
|
||||
const messages = [];
|
||||
|
||||
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
||||
createEngine: async () => engine,
|
||||
onFatalError: (message) => messages.push(message),
|
||||
});
|
||||
|
||||
assert.equal(handle, null);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.match(messages[0], /codec your browser supports/i);
|
||||
assert.equal(instances[0].destroyCalls, 1, "must not leak the instance");
|
||||
assert.equal(getAttachedHls(video), null);
|
||||
});
|
||||
|
||||
test("attachHlsSource returns null when hls.js is unsupported here", async () => {
|
||||
const video = makeFakeVideo("");
|
||||
class Unsupported {
|
||||
static isSupported() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
||||
createEngine: async () => Unsupported,
|
||||
});
|
||||
assert.equal(handle, null);
|
||||
assert.equal(video.src, "", "must not fall back to an unplayable src");
|
||||
});
|
||||
|
||||
test("attachHlsSource returns null when the engine bundle fails to load", async () => {
|
||||
const video = makeFakeVideo("");
|
||||
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
||||
createEngine: async () => null,
|
||||
});
|
||||
assert.equal(handle, null);
|
||||
});
|
||||
|
||||
test("a fatal network error retries twice before giving up", async () => {
|
||||
const video = makeFakeVideo("");
|
||||
const { engine, instances } = makeFakeEngine({ autoManifest: false });
|
||||
const messages = [];
|
||||
|
||||
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
||||
createEngine: async () => engine,
|
||||
onFatalError: (message) => messages.push(message),
|
||||
});
|
||||
await Promise.resolve();
|
||||
const instance = instances[0];
|
||||
|
||||
const networkError = {
|
||||
fatal: true,
|
||||
type: HLS_ERROR_TYPES.NETWORK_ERROR,
|
||||
details: "manifestLoadError",
|
||||
};
|
||||
instance.emit(HLS_EVENTS.ERROR, networkError);
|
||||
assert.equal(instance.startLoadCalls, 1);
|
||||
instance.emit(HLS_EVENTS.ERROR, networkError);
|
||||
assert.equal(instance.startLoadCalls, 2);
|
||||
// Third fatal network error exhausts the retry budget.
|
||||
instance.emit(HLS_EVENTS.ERROR, networkError);
|
||||
assert.equal(instance.startLoadCalls, 2);
|
||||
|
||||
assert.equal(await pending, null);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.match(messages[0], /network or CORS/i);
|
||||
assert.equal(instance.destroyCalls, 1);
|
||||
});
|
||||
|
||||
test("a fatal media error recovers, then swaps audio codec, then gives up", async () => {
|
||||
const video = makeFakeVideo("");
|
||||
const { engine, instances } = makeFakeEngine({ autoManifest: false });
|
||||
const messages = [];
|
||||
|
||||
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
||||
createEngine: async () => engine,
|
||||
onFatalError: (message) => messages.push(message),
|
||||
});
|
||||
await Promise.resolve();
|
||||
const instance = instances[0];
|
||||
|
||||
const mediaError = {
|
||||
fatal: true,
|
||||
type: HLS_ERROR_TYPES.MEDIA_ERROR,
|
||||
details: "bufferAppendError",
|
||||
};
|
||||
instance.emit(HLS_EVENTS.ERROR, mediaError);
|
||||
assert.equal(instance.recoverMediaErrorCalls, 1);
|
||||
assert.equal(instance.swapAudioCodecCalls, 0);
|
||||
|
||||
instance.emit(HLS_EVENTS.ERROR, mediaError);
|
||||
assert.equal(instance.recoverMediaErrorCalls, 2);
|
||||
assert.equal(instance.swapAudioCodecCalls, 1, "second attempt swaps codec");
|
||||
|
||||
instance.emit(HLS_EVENTS.ERROR, mediaError);
|
||||
assert.equal(instance.recoverMediaErrorCalls, 2, "budget exhausted");
|
||||
|
||||
assert.equal(await pending, null);
|
||||
assert.match(messages[0], /codec your browser cannot decode/i);
|
||||
});
|
||||
|
||||
test("non-fatal errors never tear the stream down", async () => {
|
||||
const video = makeFakeVideo("");
|
||||
const { engine, instances } = makeFakeEngine({ autoManifest: false });
|
||||
const messages = [];
|
||||
|
||||
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
||||
createEngine: async () => engine,
|
||||
onFatalError: (message) => messages.push(message),
|
||||
});
|
||||
await Promise.resolve();
|
||||
const instance = instances[0];
|
||||
|
||||
instance.emit(HLS_EVENTS.ERROR, {
|
||||
fatal: false,
|
||||
type: HLS_ERROR_TYPES.NETWORK_ERROR,
|
||||
details: "fragLoadError",
|
||||
});
|
||||
|
||||
assert.equal(instance.destroyCalls, 0);
|
||||
assert.equal(messages.length, 0);
|
||||
|
||||
// A transient fragment error must still let the stream come up.
|
||||
instance.emit(HLS_EVENTS.MANIFEST_PARSED, { levels: [{ height: 720 }] });
|
||||
const handle = await pending;
|
||||
assert.equal(handle.mode, "mse");
|
||||
});
|
||||
|
||||
test("a stuck manifest fails via the attach timeout rather than hanging", async () => {
|
||||
const video = makeFakeVideo("");
|
||||
const { engine, instances } = makeFakeEngine({ autoManifest: false });
|
||||
const messages = [];
|
||||
|
||||
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
||||
createEngine: async () => engine,
|
||||
manifestTimeoutMs: 20,
|
||||
onFatalError: (message) => messages.push(message),
|
||||
});
|
||||
|
||||
assert.equal(handle, null);
|
||||
assert.match(messages[0], /did not load in time/i);
|
||||
assert.equal(instances[0].destroyCalls, 1);
|
||||
});
|
||||
|
||||
test("attachMedia throwing is reported, not propagated", async () => {
|
||||
const video = makeFakeVideo("");
|
||||
const { engine } = makeFakeEngine({ autoManifest: false });
|
||||
const messages = [];
|
||||
class Hostile extends engine {
|
||||
attachMedia() {
|
||||
throw new Error("MediaSource unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
||||
createEngine: async () => Hostile,
|
||||
onFatalError: (message) => messages.push(message),
|
||||
});
|
||||
|
||||
assert.equal(handle, null);
|
||||
assert.match(messages[0], /could not be attached/i);
|
||||
});
|
||||
|
||||
// --- SCN-hls-teardown -----------------------------------------------------
|
||||
// Given an attached hls.js instance, when the modal tears the element down,
|
||||
// then destroy() runs exactly once and the element is unbound — otherwise the
|
||||
// transmuxing worker and segment fetches keep running after the modal closes.
|
||||
|
||||
test("detachHls destroys the bound hls.js instance exactly once", async () => {
|
||||
const video = makeFakeVideo("");
|
||||
const { engine, instances } = makeFakeEngine();
|
||||
|
||||
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
||||
createEngine: async () => engine,
|
||||
});
|
||||
|
||||
assert.ok(handle, "expected an MSE handle");
|
||||
assert.equal(handle.mode, "mse");
|
||||
const instance = instances[0];
|
||||
assert.equal(getAttachedHls(video), instance);
|
||||
assert.equal(instance.loadedSource, "https://cdn.example/v.m3u8");
|
||||
assert.equal(instance.attachedMedia, video);
|
||||
|
||||
assert.equal(detachHls(video), true);
|
||||
assert.equal(instance.destroyCalls, 1);
|
||||
assert.equal(getAttachedHls(video), null);
|
||||
|
||||
// Second detach (e.g. modal teardown after the session already reset) must
|
||||
// not double-destroy — hls.js throws on a destroyed instance.
|
||||
assert.equal(detachHls(video), false);
|
||||
assert.equal(instance.destroyCalls, 1);
|
||||
});
|
||||
|
||||
test("handle.destroy() tears the instance down too", async () => {
|
||||
const video = makeFakeVideo("");
|
||||
const { engine, instances } = makeFakeEngine();
|
||||
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
||||
createEngine: async () => engine,
|
||||
});
|
||||
handle.destroy();
|
||||
assert.equal(instances[0].destroyCalls, 1);
|
||||
assert.equal(getAttachedHls(video), null);
|
||||
});
|
||||
|
||||
test("re-attaching to the same element destroys the previous instance", async () => {
|
||||
const video = makeFakeVideo("");
|
||||
const { engine, instances } = makeFakeEngine();
|
||||
await attachHlsSource(video, "https://cdn.example/a.m3u8", {
|
||||
createEngine: async () => engine,
|
||||
});
|
||||
await attachHlsSource(video, "https://cdn.example/b.m3u8", {
|
||||
createEngine: async () => engine,
|
||||
});
|
||||
assert.equal(instances.length, 2);
|
||||
assert.equal(instances[0].destroyCalls, 1, "first instance must be destroyed");
|
||||
assert.equal(instances[1].destroyCalls, 0);
|
||||
assert.equal(instances[1].loadedSource, "https://cdn.example/b.m3u8");
|
||||
assert.equal(getAttachedHls(video), instances[1]);
|
||||
});
|
||||
|
||||
test("detachHls is a safe no-op for non-HLS elements and null", () => {
|
||||
assert.equal(detachHls(null), false);
|
||||
assert.equal(detachHls(undefined), false);
|
||||
assert.equal(detachHls(makeFakeVideo("")), false);
|
||||
});
|
||||
|
||||
test("getAttachedHls returns null when nothing is bound", () => {
|
||||
assert.equal(getAttachedHls(makeFakeVideo("")), null);
|
||||
assert.equal(getAttachedHls(null), null);
|
||||
});
|
||||
|
||||
// --- SCN-hls-probe --------------------------------------------------------
|
||||
// Given the URL-health badge probing an HLS URL on an MSE browser, when the
|
||||
// playlist is CORS-readable and starts with #EXTM3U, then the source is "ok".
|
||||
// A bare <video> probe (the old path) reported every such URL as offline, which
|
||||
// combined with the magnet-less "unhealthy" stream badge marks the card
|
||||
// confirmed-dead and hides it from the grid.
|
||||
|
||||
const withFetch = async (impl, fn) => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = impl;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
};
|
||||
|
||||
test("probeHlsManifest returns ok for a readable #EXTM3U playlist", async () => {
|
||||
const result = await withFetch(
|
||||
async () => ({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=584301,CODECS="avc1.640015"\nlow.m3u8\n',
|
||||
}),
|
||||
() => probeHlsManifest("https://cdn.example/v.m3u8")
|
||||
);
|
||||
assert.equal(result, "ok");
|
||||
});
|
||||
|
||||
test("probeHlsManifest tolerates leading whitespace/BOM-ish padding", async () => {
|
||||
const result = await withFetch(
|
||||
async () => ({ ok: true, text: async () => "\n\n #EXTM3U\n" }),
|
||||
() => probeHlsManifest("https://cdn.example/v.m3u8")
|
||||
);
|
||||
assert.equal(result, "ok");
|
||||
});
|
||||
|
||||
test("probeHlsManifest reports error for a non-playlist body", async () => {
|
||||
const result = await withFetch(
|
||||
async () => ({ ok: true, text: async () => "<html>404</html>" }),
|
||||
() => probeHlsManifest("https://cdn.example/v.m3u8")
|
||||
);
|
||||
assert.equal(result, "error");
|
||||
});
|
||||
|
||||
test("probeHlsManifest reports error for a non-2xx response", async () => {
|
||||
const result = await withFetch(
|
||||
async () => ({ ok: false, status: 403, text: async () => "#EXTM3U" }),
|
||||
() => probeHlsManifest("https://cdn.example/v.m3u8")
|
||||
);
|
||||
assert.equal(result, "error");
|
||||
});
|
||||
|
||||
test("probeHlsManifest reports error when the fetch rejects (CORS/DNS)", async () => {
|
||||
const result = await withFetch(
|
||||
async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
},
|
||||
() => probeHlsManifest("https://cdn.example/v.m3u8")
|
||||
);
|
||||
assert.equal(result, "error");
|
||||
});
|
||||
|
||||
test("probeHlsManifest distinguishes a timeout from a hard error", async () => {
|
||||
const result = await withFetch(
|
||||
(_url, options) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
// Never settles on its own; the probe's own AbortController must fire.
|
||||
options?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
}),
|
||||
() => probeHlsManifest("https://cdn.example/v.m3u8", 30)
|
||||
);
|
||||
assert.equal(result, "timeout");
|
||||
});
|
||||
|
||||
test("probeHlsManifest rejects empty input without touching the network", async () => {
|
||||
let called = false;
|
||||
const result = await withFetch(
|
||||
async () => {
|
||||
called = true;
|
||||
return { ok: true, text: async () => "#EXTM3U" };
|
||||
},
|
||||
() => probeHlsManifest(" ")
|
||||
);
|
||||
assert.equal(result, "error");
|
||||
assert.equal(called, false);
|
||||
});
|
||||
@@ -9,6 +9,8 @@ describe("NostrService", () => {
|
||||
// Reset state
|
||||
nostrService.videosMap = new Map();
|
||||
nostrService.dmMessages = [];
|
||||
nostrService.dmMessageIndex = new Map();
|
||||
nostrService.dmActorPubkey = null;
|
||||
|
||||
// Mock the internal nostrClient
|
||||
mockClient = {
|
||||
@@ -145,4 +147,30 @@ describe("NostrService", () => {
|
||||
assert.equal(nostrService.dmMessageIndex.size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadDirectMessages", () => {
|
||||
it("uses the actor NIP-17 relay list for the initial historical inbox fetch", async () => {
|
||||
const actor = "a".repeat(64);
|
||||
const dmRelay = "wss://dm-relay.example.com";
|
||||
mockClient.readRelays = ["wss://discovery.example.com"];
|
||||
mockClient.pool.list.mock.mockImplementation(async () => [
|
||||
{
|
||||
pubkey: actor,
|
||||
created_at: 10,
|
||||
tags: [["relay", dmRelay]],
|
||||
},
|
||||
]);
|
||||
mockClient.listDirectMessages = mock.fn(async () => []);
|
||||
|
||||
await nostrService.loadDirectMessages({ actorPubkey: actor, initialLoad: true });
|
||||
|
||||
assert.equal(mockClient.listDirectMessages.mock.callCount(), 1);
|
||||
const [, options] = mockClient.listDirectMessages.mock.calls[0].arguments;
|
||||
assert.deepEqual(
|
||||
options.relays,
|
||||
[dmRelay],
|
||||
"historical NIP-17 messages must use the same relay list as the live subscription",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// SCN-autoplay-blocked-no-p2p-yank
|
||||
//
|
||||
// Given a video with BOTH a hosted URL and a magnet, when the browser refuses
|
||||
// autoplay (a deep link opened from a nostr client arrives with no user
|
||||
// gesture, so every mobile browser refuses), then bitvid must show "Press play"
|
||||
// and WAIT — not treat the pause as a stalled source.
|
||||
//
|
||||
// It used to: the 3s start timeout fired regardless, the video was yanked into
|
||||
// WebTorrent, and a swarm with no peers played nothing while the 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.
|
||||
|
||||
import { describe, test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { PlaybackService } from "../../js/services/playbackService.js";
|
||||
|
||||
function makeVideoStub({ autoplayAllowed = false } = {}) {
|
||||
const listeners = new Map();
|
||||
const el = {
|
||||
error: null,
|
||||
src: "",
|
||||
srcObject: null,
|
||||
currentTime: 0,
|
||||
readyState: 0,
|
||||
networkState: 0,
|
||||
muted: false,
|
||||
paused: true,
|
||||
dataset: {},
|
||||
playCalls: 0,
|
||||
addEventListener(type, handler) {
|
||||
if (!listeners.has(type)) listeners.set(type, []);
|
||||
listeners.get(type).push(handler);
|
||||
},
|
||||
removeEventListener(type, handler) {
|
||||
const arr = listeners.get(type) || [];
|
||||
const i = arr.indexOf(handler);
|
||||
if (i >= 0) arr.splice(i, 1);
|
||||
},
|
||||
dispatch(type) {
|
||||
for (const h of [...(listeners.get(type) || [])]) h({ type });
|
||||
},
|
||||
hasAttribute: () => false,
|
||||
removeAttribute() {},
|
||||
setAttribute() {},
|
||||
pause() {},
|
||||
load() {},
|
||||
play() {
|
||||
el.playCalls += 1;
|
||||
if (autoplayAllowed) {
|
||||
el.paused = false;
|
||||
// A real element signals playback started; the watchdog's success path
|
||||
// keys off this. Without it even a healthy source would (correctly)
|
||||
// fall back once the start timeout elapsed.
|
||||
setTimeout(() => el.dispatch("playing"), 0);
|
||||
return Promise.resolve();
|
||||
}
|
||||
const error = new Error("play() failed because the user didn't interact");
|
||||
error.name = "NotAllowedError";
|
||||
return Promise.reject(error);
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
const HOSTED_URL = "https://cdn.example/video.mp4";
|
||||
const MAGNET = "magnet:?xt=urn:btih:024c6044dfcbadef3ac5abbfa8a36766dc0606f1";
|
||||
|
||||
describe("PlaybackService autoplay-blocked handling", () => {
|
||||
before(() => {
|
||||
global.window = {
|
||||
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
||||
};
|
||||
global.document = { createElement: () => makeVideoStub() };
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete global.window;
|
||||
delete global.document;
|
||||
});
|
||||
|
||||
const run = async ({ autoplayAllowed }) => {
|
||||
const video = makeVideoStub({ autoplayAllowed });
|
||||
const statuses = [];
|
||||
let torrentAttempted = false;
|
||||
|
||||
const service = new PlaybackService({
|
||||
logger: () => {},
|
||||
urlFirstEnabled: true,
|
||||
isValidMagnetUri: () => true,
|
||||
// MUST be set on the service: createSession() overwrites any
|
||||
// playbackStartTimeout passed in its own options with this value, so a
|
||||
// per-session override is silently ignored (and the test would then race
|
||||
// the real 3s default and prove nothing).
|
||||
playbackStartTimeout: 60,
|
||||
});
|
||||
|
||||
const session = service.createSession({
|
||||
url: HOSTED_URL,
|
||||
magnet: MAGNET,
|
||||
videoElement: video,
|
||||
autoplay: true,
|
||||
playViaWebTorrent: async () => {
|
||||
torrentAttempted = true;
|
||||
await new Promise(() => {}); // a swarm with no peers
|
||||
},
|
||||
});
|
||||
session.on?.("status", (p) => statuses.push(p.message));
|
||||
|
||||
const outcome = await Promise.race([
|
||||
session.execute(),
|
||||
new Promise((r) => setTimeout(() => r({ source: "PENDING" }), 600)),
|
||||
]);
|
||||
|
||||
return { outcome, statuses, torrentAttempted, video };
|
||||
};
|
||||
|
||||
test("a blocked autoplay never falls back to WebTorrent", async () => {
|
||||
const { statuses, torrentAttempted } = await run({ autoplayAllowed: false });
|
||||
|
||||
assert.equal(
|
||||
torrentAttempted,
|
||||
false,
|
||||
`must not switch to P2P while awaiting a gesture; got: ${JSON.stringify(statuses)}`
|
||||
);
|
||||
assert.equal(
|
||||
statuses.some((m) => /switching to webtorrent/i.test(m)),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("the viewer is told to press play, and the prompt is the last word", async () => {
|
||||
const { statuses } = await run({ autoplayAllowed: false });
|
||||
|
||||
assert.equal(
|
||||
statuses.some((m) => /press play/i.test(m)),
|
||||
true,
|
||||
"viewer needs an actionable prompt"
|
||||
);
|
||||
// Nothing may overwrite that prompt with a failure message.
|
||||
assert.match(statuses[statuses.length - 1], /press play/i);
|
||||
});
|
||||
|
||||
test("the session stays pending rather than reporting no playable source", async () => {
|
||||
const { outcome } = await run({ autoplayAllowed: false });
|
||||
assert.equal(outcome.source, "PENDING");
|
||||
assert.notEqual(outcome.source, null);
|
||||
});
|
||||
|
||||
test("permitted autoplay is unaffected and still starts on the hosted URL", async () => {
|
||||
// The guard must not suppress the timeout for everyone -- only while
|
||||
// autoplay is actually blocked.
|
||||
const { outcome, torrentAttempted, statuses } = await run({
|
||||
autoplayAllowed: true,
|
||||
});
|
||||
assert.equal(torrentAttempted, false);
|
||||
assert.equal(
|
||||
statuses.some((m) => /press play/i.test(m)),
|
||||
false
|
||||
);
|
||||
assert.equal(outcome.source, "url", "a healthy source still starts normally");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
// SCN-stale-media-error
|
||||
//
|
||||
// Given the modal <video> is reused across videos, teardownVideoElement() /
|
||||
// resetVideoElement() clear `src` and call load(). Browsers respond by leaving a
|
||||
// MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute") MediaError on the element.
|
||||
//
|
||||
// When a watchdog fallback later fires for an unrelated reason (a stall, or an
|
||||
// `abort` raised by our own source swap), then the failure message must NOT be
|
||||
// derived from that leftover MediaError — doing so reported
|
||||
// "Hosted playback failed: source not supported or blocked."
|
||||
// over a stream that was playing correctly, blaming the current load for a
|
||||
// previous one's failure.
|
||||
//
|
||||
// Then: only a MediaError produced by THIS attempt may be surfaced.
|
||||
|
||||
import { describe, test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { PlaybackService } from "../../js/services/playbackService.js";
|
||||
|
||||
// Minimal <video> double: records listeners so the test can dispatch the exact
|
||||
// media events the real watchdogs bind to.
|
||||
function makeVideoStub({ initialError = null } = {}) {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
error: initialError,
|
||||
src: "",
|
||||
srcObject: null,
|
||||
currentTime: 0,
|
||||
readyState: 0,
|
||||
networkState: 0,
|
||||
muted: false,
|
||||
dataset: {},
|
||||
listeners,
|
||||
addEventListener(type, handler) {
|
||||
if (!listeners.has(type)) listeners.set(type, []);
|
||||
listeners.get(type).push(handler);
|
||||
},
|
||||
removeEventListener(type, handler) {
|
||||
const arr = listeners.get(type) || [];
|
||||
const i = arr.indexOf(handler);
|
||||
if (i >= 0) arr.splice(i, 1);
|
||||
},
|
||||
dispatch(type) {
|
||||
for (const h of [...(listeners.get(type) || [])]) h({ type });
|
||||
},
|
||||
hasAttribute: () => false,
|
||||
removeAttribute() {},
|
||||
setAttribute() {},
|
||||
play: () => Promise.resolve(),
|
||||
pause() {},
|
||||
load() {},
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_SRC_ERROR = Object.freeze({
|
||||
code: 4, // MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
message: "MEDIA_ELEMENT_ERROR: Empty src attribute",
|
||||
});
|
||||
|
||||
describe("PlaybackService stale MediaError handling", () => {
|
||||
before(() => {
|
||||
global.window = {
|
||||
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
||||
};
|
||||
global.document = { createElement: () => makeVideoStub() };
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete global.window;
|
||||
delete global.document;
|
||||
});
|
||||
|
||||
const runWatchdogFallback = async ({ initialError, errorAtFallback }) => {
|
||||
const video = makeVideoStub({ initialError });
|
||||
const service = new PlaybackService({
|
||||
logger: () => {},
|
||||
urlFirstEnabled: true,
|
||||
isValidMagnetUri: () => false,
|
||||
});
|
||||
|
||||
const statuses = [];
|
||||
let fallbackReason = null;
|
||||
|
||||
// Drive the watchdog contract directly: this is the boundary where the
|
||||
// message is chosen, independent of network or codec support.
|
||||
const cleanup = service.registerUrlPlaybackWatchdogs(video, {
|
||||
stallMs: 0,
|
||||
onSuccess: () => {},
|
||||
onFallback: (reason) => {
|
||||
fallbackReason = reason;
|
||||
},
|
||||
});
|
||||
|
||||
video.error = errorAtFallback;
|
||||
video.dispatch("stalled");
|
||||
cleanup();
|
||||
|
||||
return { statuses, fallbackReason, video };
|
||||
};
|
||||
|
||||
test("a fallback still reports its reason to the caller", async () => {
|
||||
const { fallbackReason } = await runWatchdogFallback({
|
||||
initialError: EMPTY_SRC_ERROR,
|
||||
errorAtFallback: EMPTY_SRC_ERROR,
|
||||
});
|
||||
assert.equal(fallbackReason, "stalled");
|
||||
});
|
||||
|
||||
test("the leftover empty-src MediaError is identity-distinguishable", () => {
|
||||
// The fix hinges on identity: starting a new load clears `error` to null,
|
||||
// and a genuine new failure allocates a DIFFERENT MediaError object. If a
|
||||
// browser ever reused the object this guard would silently stop working.
|
||||
const stale = EMPTY_SRC_ERROR;
|
||||
const fresh = { code: 4, message: "MEDIA_ELEMENT_ERROR: Format error" };
|
||||
assert.notEqual(stale, fresh);
|
||||
assert.equal(stale === EMPTY_SRC_ERROR, true);
|
||||
});
|
||||
|
||||
// Drive a real session to the watchdog-fallback branch with a stale error
|
||||
// already on the element, and capture what the viewer would be shown.
|
||||
const runSessionWithStall = async ({ initialError, errorAtFallback }) => {
|
||||
const video = makeVideoStub({ initialError });
|
||||
|
||||
// Fire `stalled` as soon as the session arms its watchdogs — this is the
|
||||
// spurious fallback the viewer hits while the stream is actually fine.
|
||||
const originalAdd = video.addEventListener.bind(video);
|
||||
video.addEventListener = (type, handler) => {
|
||||
originalAdd(type, handler);
|
||||
if (type === "stalled") {
|
||||
setTimeout(() => {
|
||||
video.error = errorAtFallback;
|
||||
video.dispatch("stalled");
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const service = new PlaybackService({
|
||||
logger: () => {},
|
||||
urlFirstEnabled: true,
|
||||
isValidMagnetUri: () => false,
|
||||
});
|
||||
|
||||
const statuses = [];
|
||||
const session = service.createSession({
|
||||
url: "https://cdn.example/video.mp4",
|
||||
magnet: "",
|
||||
videoElement: video,
|
||||
autoplay: true,
|
||||
playbackStartTimeout: 200,
|
||||
});
|
||||
session.on?.("status", (p) => statuses.push(p.message));
|
||||
|
||||
await session.execute();
|
||||
return statuses;
|
||||
};
|
||||
|
||||
test("a stale empty-src error is NOT blamed on the current load", async () => {
|
||||
const statuses = await runSessionWithStall({
|
||||
initialError: EMPTY_SRC_ERROR,
|
||||
errorAtFallback: EMPTY_SRC_ERROR, // unchanged => stale
|
||||
});
|
||||
assert.equal(
|
||||
statuses.some((m) => /source not supported or blocked/i.test(m)),
|
||||
false,
|
||||
`stale error must not be reported; got: ${JSON.stringify(statuses)}`
|
||||
);
|
||||
});
|
||||
|
||||
test("a MediaError raised by THIS load is still reported", async () => {
|
||||
// The guard must not silence genuine failures — a new MediaError object
|
||||
// appearing during the attempt is real and must reach the viewer.
|
||||
const statuses = await runSessionWithStall({
|
||||
initialError: null,
|
||||
errorAtFallback: {
|
||||
code: 4,
|
||||
message: "MEDIA_ELEMENT_ERROR: Format error",
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
statuses.some((m) => /source not supported or blocked/i.test(m)),
|
||||
true,
|
||||
`genuine error must be reported; got: ${JSON.stringify(statuses)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -91,3 +91,193 @@ test("buildShareEvent tolerates missing optional fields", () => {
|
||||
assert.equal(event.content, "");
|
||||
assert.deepEqual(event.tags, []);
|
||||
});
|
||||
|
||||
// SCN-share-imeta
|
||||
// Given a share note whose body ends with the raw thumbnail URL, when the
|
||||
// video has an http(s) thumbnail, then a NIP-92 `imeta` entry must describe it
|
||||
// — many clients only render such a URL inline when imeta backs it, otherwise
|
||||
// the viewer sees a bare link instead of a preview image.
|
||||
|
||||
const imetaOf = (event) => event.tags.find((tag) => tag[0] === "imeta") || null;
|
||||
|
||||
test("buildShareEvent emits a NIP-92 imeta entry for the thumbnail", () => {
|
||||
const event = buildShareEvent({
|
||||
pubkey: "share-author",
|
||||
created_at: 1700000804,
|
||||
video: {
|
||||
id: "a".repeat(64),
|
||||
pubkey: "b".repeat(64),
|
||||
title: "Why I'm (sort of) not worried about AI",
|
||||
thumbnail: "https://cdn.example/thumb.jpg",
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(imetaOf(event), [
|
||||
"imeta",
|
||||
"url https://cdn.example/thumb.jpg",
|
||||
"m image/jpeg",
|
||||
"alt Why I'm (sort of) not worried about AI",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildShareEvent infers the image type per extension, query strings included", () => {
|
||||
const typeFor = (thumbnail) => {
|
||||
const event = buildShareEvent({
|
||||
pubkey: "share-author",
|
||||
created_at: 1700000805,
|
||||
video: { id: "a".repeat(64), thumbnail },
|
||||
});
|
||||
return (imetaOf(event) || []).find((entry) => entry.startsWith("m ")) || "";
|
||||
};
|
||||
|
||||
assert.equal(typeFor("https://cdn.example/a.png"), "m image/png");
|
||||
assert.equal(typeFor("https://cdn.example/a.WEBP"), "m image/webp");
|
||||
assert.equal(typeFor("https://cdn.example/a.jpeg?sig=abc&exp=1"), "m image/jpeg");
|
||||
assert.equal(typeFor("https://cdn.example/a.avif#x"), "m image/avif");
|
||||
});
|
||||
|
||||
test("buildShareEvent omits the media type when the extension is unknown", () => {
|
||||
// A wrong `m` is worse than a missing one: clients use it to decide whether
|
||||
// to render inline at all. Blossom-style extensionless URLs hit this path.
|
||||
const event = buildShareEvent({
|
||||
pubkey: "share-author",
|
||||
created_at: 1700000806,
|
||||
video: {
|
||||
id: "a".repeat(64),
|
||||
title: "Untitled",
|
||||
thumbnail: "https://blossom.example/0101591966f5764e57abb79f8e0305d7",
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(imetaOf(event), [
|
||||
"imeta",
|
||||
"url https://blossom.example/0101591966f5764e57abb79f8e0305d7",
|
||||
"alt Untitled",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildShareEvent skips imeta for non-http thumbnails", () => {
|
||||
for (const thumbnail of [
|
||||
"",
|
||||
" ",
|
||||
"assets/jpg/fallback.jpg",
|
||||
"data:image/png;base64,iVBORw0KGgo=",
|
||||
"javascript:alert(1)",
|
||||
null,
|
||||
undefined,
|
||||
]) {
|
||||
const event = buildShareEvent({
|
||||
pubkey: "share-author",
|
||||
created_at: 1700000807,
|
||||
video: { id: "a".repeat(64), thumbnail },
|
||||
});
|
||||
assert.equal(
|
||||
imetaOf(event),
|
||||
null,
|
||||
`expected no imeta for thumbnail ${JSON.stringify(thumbnail)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildShareEvent collapses newlines in imeta alt text", () => {
|
||||
// imeta entries are space-delimited "key value" strings; a raw newline in the
|
||||
// title would corrupt the tag for parsers reading it back.
|
||||
const event = buildShareEvent({
|
||||
pubkey: "share-author",
|
||||
created_at: 1700000808,
|
||||
video: {
|
||||
id: "a".repeat(64),
|
||||
title: "Line one\nline two\t spaced",
|
||||
thumbnail: "https://cdn.example/t.png",
|
||||
},
|
||||
});
|
||||
|
||||
const alt = imetaOf(event).find((entry) => entry.startsWith("alt "));
|
||||
assert.equal(alt, "alt Line one line two spaced");
|
||||
assert.equal(/[\n\r\t]/.test(alt), false);
|
||||
});
|
||||
|
||||
// SCN-share-quote
|
||||
// When a NIP-71 mirror exists, the note body quotes it via `nostr:naddr1…`
|
||||
// instead of carrying a bare thumbnail URL. The imeta tag must then be dropped
|
||||
// — the quote card already renders the thumbnail, and emitting both showed the
|
||||
// same image twice — while an `a` tag links the note to the quoted coordinate.
|
||||
|
||||
const MIRROR_COORD = `34235:${"b".repeat(64)}:root-abc`;
|
||||
|
||||
test("buildShareEvent adds an `a` tag for the quoted mirror", () => {
|
||||
const event = buildShareEvent({
|
||||
pubkey: "share-author",
|
||||
created_at: 1700000809,
|
||||
video: {
|
||||
id: "a".repeat(64),
|
||||
pubkey: "b".repeat(64),
|
||||
title: "Quoted video",
|
||||
thumbnail: "https://cdn.example/thumb.jpg",
|
||||
mirrorNaddr: "naddr1qqxnzd3exuerjvfhx",
|
||||
mirrorCoordinate: MIRROR_COORD,
|
||||
},
|
||||
});
|
||||
|
||||
const aTag = event.tags.find((tag) => tag[0] === "a");
|
||||
assert.deepEqual(aTag, ["a", MIRROR_COORD, "", "mention"]);
|
||||
});
|
||||
|
||||
test("buildShareEvent drops the imeta when the note quotes a mirror", () => {
|
||||
const event = buildShareEvent({
|
||||
pubkey: "share-author",
|
||||
created_at: 1700000810,
|
||||
video: {
|
||||
id: "a".repeat(64),
|
||||
title: "Quoted video",
|
||||
thumbnail: "https://cdn.example/thumb.jpg",
|
||||
mirrorNaddr: "naddr1qqxnzd3exuerjvfhx",
|
||||
mirrorCoordinate: MIRROR_COORD,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
event.tags.some((tag) => tag[0] === "imeta"),
|
||||
false,
|
||||
"quote card renders the thumbnail; a second imeta duplicates it"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildShareEvent keeps the imeta when there is no mirror to quote", () => {
|
||||
const event = buildShareEvent({
|
||||
pubkey: "share-author",
|
||||
created_at: 1700000811,
|
||||
video: {
|
||||
id: "a".repeat(64),
|
||||
title: "Unmirrored video",
|
||||
thumbnail: "https://cdn.example/thumb.jpg",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(event.tags.some((tag) => tag[0] === "imeta"), true);
|
||||
assert.equal(event.tags.some((tag) => tag[0] === "a"), false);
|
||||
});
|
||||
|
||||
test("buildShareEvent ignores a malformed mirror coordinate", () => {
|
||||
for (const coordinate of [
|
||||
"not-a-coordinate",
|
||||
"34235:notahexpubkey:root",
|
||||
":::",
|
||||
"",
|
||||
]) {
|
||||
const event = buildShareEvent({
|
||||
pubkey: "share-author",
|
||||
created_at: 1700000812,
|
||||
video: {
|
||||
id: "a".repeat(64),
|
||||
title: "T",
|
||||
mirrorCoordinate: coordinate,
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
event.tags.some((tag) => tag[0] === "a"),
|
||||
false,
|
||||
`must not emit an a tag for ${JSON.stringify(coordinate)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
// SCN-share-nostr-gate
|
||||
//
|
||||
// The share menu's "Share on Nostr" item is gated on `isLoggedIn && hasSigner`
|
||||
// (js/ui/components/videoMenuRenderers.js). VideoModal initialized
|
||||
// `shareNostrAuthState` to {false, false} and NOTHING ever wrote to it, so the
|
||||
// item rendered permanently disabled — it looked like inert text, and the click
|
||||
// delegation in bindShareMenuActions() bails on `button.disabled`.
|
||||
//
|
||||
// Two halves have to hold for the button to work:
|
||||
// 1. VideoModal.setShareNostrAuthState() records the state.
|
||||
// 2. syncAuthUiState() pushes real auth state in, and re-runs on auth change
|
||||
// (it previously ran only once at bootstrap).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createUiCoordinator } from "../js/app/uiCoordinator.js";
|
||||
import { VideoModal } from "../js/ui/components/VideoModal.js";
|
||||
import {
|
||||
clearActiveSigner,
|
||||
registerSigner,
|
||||
setActiveSigner,
|
||||
} from "../js/nostrClientRegistry.js";
|
||||
|
||||
const PUBKEY = "b".repeat(64);
|
||||
|
||||
// --- VideoModal setter ----------------------------------------------------
|
||||
|
||||
function makeModal() {
|
||||
const modal = Object.create(VideoModal.prototype);
|
||||
modal.shareNostrAuthState = { isLoggedIn: false, hasSigner: false };
|
||||
modal.closedPopover = 0;
|
||||
modal.modalSharePopover = {
|
||||
close: () => {
|
||||
modal.closedPopover += 1;
|
||||
},
|
||||
};
|
||||
return modal;
|
||||
}
|
||||
|
||||
test("setShareNostrAuthState records the gate", () => {
|
||||
const modal = makeModal();
|
||||
assert.equal(modal.setShareNostrAuthState({ isLoggedIn: true, hasSigner: true }), true);
|
||||
assert.deepEqual(modal.shareNostrAuthState, { isLoggedIn: true, hasSigner: true });
|
||||
});
|
||||
|
||||
test("setShareNostrAuthState is idempotent and only closes a stale menu once", () => {
|
||||
const modal = makeModal();
|
||||
modal.setShareNostrAuthState({ isLoggedIn: true, hasSigner: true });
|
||||
assert.equal(modal.closedPopover, 1, "open menu must re-render against new state");
|
||||
|
||||
// No change -> no churn. Closing the popover on every no-op sync would slam
|
||||
// the menu shut under the user.
|
||||
assert.equal(modal.setShareNostrAuthState({ isLoggedIn: true, hasSigner: true }), false);
|
||||
assert.equal(modal.closedPopover, 1);
|
||||
});
|
||||
|
||||
test("setShareNostrAuthState coerces and defaults safely", () => {
|
||||
const modal = makeModal();
|
||||
modal.setShareNostrAuthState({ isLoggedIn: "yes", hasSigner: 1 });
|
||||
assert.deepEqual(modal.shareNostrAuthState, { isLoggedIn: true, hasSigner: true });
|
||||
|
||||
modal.setShareNostrAuthState();
|
||||
assert.deepEqual(modal.shareNostrAuthState, { isLoggedIn: false, hasSigner: false });
|
||||
});
|
||||
|
||||
test("setShareNostrAuthState tolerates no popover", () => {
|
||||
const modal = makeModal();
|
||||
modal.modalSharePopover = null;
|
||||
assert.doesNotThrow(() =>
|
||||
modal.setShareNostrAuthState({ isLoggedIn: true, hasSigner: true })
|
||||
);
|
||||
});
|
||||
|
||||
// --- syncAuthUiState wiring ----------------------------------------------
|
||||
|
||||
function makeApp({ loggedIn }) {
|
||||
const coordinator = createUiCoordinator({
|
||||
devLogger: { warn() {}, error() {}, log() {} },
|
||||
});
|
||||
const pushed = [];
|
||||
const app = {
|
||||
...coordinator,
|
||||
isUserLoggedIn: () => loggedIn,
|
||||
// Stub the DOM-touching branches; this test is about the share gate.
|
||||
applyAuthenticatedUiState() {},
|
||||
applyLoggedOutUiState() {},
|
||||
videoModal: {
|
||||
setShareNostrAuthState(state) {
|
||||
pushed.push({ ...state });
|
||||
},
|
||||
},
|
||||
};
|
||||
return { app, pushed };
|
||||
}
|
||||
|
||||
test("syncAuthUiState pushes the gate into the video modal", () => {
|
||||
const { app, pushed } = makeApp({ loggedIn: true });
|
||||
app.syncAuthUiState();
|
||||
assert.equal(pushed.length, 1);
|
||||
assert.equal(pushed[0].isLoggedIn, true);
|
||||
});
|
||||
|
||||
test("logged out propagates as a closed gate", () => {
|
||||
const { app, pushed } = makeApp({ loggedIn: false });
|
||||
app.syncAuthUiState();
|
||||
assert.deepEqual(pushed[0], { isLoggedIn: false, hasSigner: false });
|
||||
});
|
||||
|
||||
test("hasSigner reflects a registered, signing-capable signer", () => {
|
||||
clearActiveSigner();
|
||||
const { app, pushed } = makeApp({ loggedIn: true });
|
||||
|
||||
app.syncAuthUiState();
|
||||
assert.equal(pushed[0].hasSigner, false, "no signer registered yet");
|
||||
|
||||
registerSigner(PUBKEY, { pubkey: PUBKEY, signEvent: async (e) => e });
|
||||
setActiveSigner(PUBKEY);
|
||||
app.syncAuthUiState();
|
||||
assert.equal(pushed[1].hasSigner, true);
|
||||
|
||||
clearActiveSigner();
|
||||
app.syncAuthUiState();
|
||||
assert.equal(pushed[2].hasSigner, false, "logout must close the gate again");
|
||||
});
|
||||
|
||||
test("a signer that cannot sign does not open the gate", () => {
|
||||
// Mirrors ShareNostrController.handleShare(): enabling the item for a signer
|
||||
// without signEvent would only produce an error toast on click.
|
||||
clearActiveSigner();
|
||||
registerSigner(PUBKEY, { pubkey: PUBKEY });
|
||||
setActiveSigner(PUBKEY);
|
||||
|
||||
const { app, pushed } = makeApp({ loggedIn: true });
|
||||
app.syncAuthUiState();
|
||||
assert.equal(pushed[0].hasSigner, false);
|
||||
clearActiveSigner();
|
||||
});
|
||||
|
||||
test("syncAuthUiState re-runs on bitvid:auth-changed, and binds only once", () => {
|
||||
// The original bug's second half: the gate was computed once at bootstrap and
|
||||
// never again, so logging in mid-session left the button disabled.
|
||||
const listeners = [];
|
||||
const originalWindow = globalThis.window;
|
||||
globalThis.window = {
|
||||
addEventListener(type, handler) {
|
||||
listeners.push({ type, handler });
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const { app, pushed } = makeApp({ loggedIn: false });
|
||||
app.syncAuthUiState();
|
||||
assert.equal(pushed.length, 1);
|
||||
|
||||
const bound = listeners.filter((l) => l.type === "bitvid:auth-changed");
|
||||
assert.equal(bound.length, 1, "listener installed on first sync");
|
||||
|
||||
bound[0].handler();
|
||||
assert.equal(pushed.length, 2, "auth change re-syncs the gate");
|
||||
|
||||
// Re-entrancy: the resync must not stack a new listener every time.
|
||||
assert.equal(
|
||||
listeners.filter((l) => l.type === "bitvid:auth-changed").length,
|
||||
1
|
||||
);
|
||||
} finally {
|
||||
if (originalWindow === undefined) {
|
||||
delete globalThis.window;
|
||||
} else {
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// SCN-share-body: what the viewer actually reads in the composed note.
|
||||
//
|
||||
// Two shapes, chosen by whether a NIP-71 mirror was confirmed on relays:
|
||||
// mirror -> quote card via `nostr:naddr1…`, NO bare thumbnail URL
|
||||
// no mirror -> bare thumbnail URL (described by the note's NIP-92 imeta)
|
||||
//
|
||||
// The "no bare thumbnail while quoting" rule is the one that matters visually:
|
||||
// keeping both rendered the same image twice (once loose, once in the card).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ShareNostrModal } from "../js/ui/components/ShareNostrModal.js";
|
||||
|
||||
// buildShareContent is a pure string builder; construct without touching the
|
||||
// DOM-dependent parts of the modal.
|
||||
const modal = Object.create(ShareNostrModal.prototype);
|
||||
const build = (payload) => modal.buildShareContent(payload);
|
||||
|
||||
const BASE = {
|
||||
title: "Why I'm (sort of) not worried about AI",
|
||||
shareUrl: "https://bitvid.network/?v=nevent1abc",
|
||||
thumbnail: "https://cdn.example/thumb.jpg",
|
||||
};
|
||||
|
||||
test("without a mirror the body ends with the thumbnail URL", () => {
|
||||
const body = build(BASE);
|
||||
assert.equal(
|
||||
body,
|
||||
"Why I'm (sort of) not worried about AI — Check out this video on bitvid 👇\n\n" +
|
||||
"https://bitvid.network/?v=nevent1abc\n\n" +
|
||||
"https://cdn.example/thumb.jpg"
|
||||
);
|
||||
});
|
||||
|
||||
test("with a mirror the body quotes it and omits the thumbnail URL", () => {
|
||||
const body = build({ ...BASE, mirrorNaddr: "naddr1qqxnzd3e" });
|
||||
|
||||
assert.match(body, /nostr:naddr1qqxnzd3e$/);
|
||||
assert.equal(
|
||||
body.includes("https://cdn.example/thumb.jpg"),
|
||||
false,
|
||||
"quote card already shows the thumbnail; a loose URL duplicates it"
|
||||
);
|
||||
// The bitvid link stays — attribution and traffic still matter.
|
||||
assert.match(body, /https:\/\/bitvid\.network\/\?v=nevent1abc/);
|
||||
});
|
||||
|
||||
test("the quote reference uses the nostr: URI scheme clients resolve", () => {
|
||||
const body = build({ ...BASE, mirrorNaddr: "naddr1qqxnzd3e" });
|
||||
assert.equal(body.includes("nostr:naddr1qqxnzd3e"), true);
|
||||
// A bare naddr without the scheme is rendered as plain text by most clients.
|
||||
assert.equal(/(^|\s)naddr1qqxnzd3e/.test(body), false);
|
||||
});
|
||||
|
||||
test("an empty or whitespace mirror pointer falls back to the thumbnail", () => {
|
||||
for (const mirrorNaddr of ["", " ", null, undefined]) {
|
||||
const body = build({ ...BASE, mirrorNaddr });
|
||||
assert.match(body, /https:\/\/cdn\.example\/thumb\.jpg$/);
|
||||
assert.equal(body.includes("nostr:"), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("missing pieces never leave dangling blank sections", () => {
|
||||
const noThumb = build({ title: "T", shareUrl: "https://bitvid.network/?v=x" });
|
||||
assert.equal(noThumb.endsWith("\n"), false);
|
||||
assert.equal(/\n{3,}/.test(noThumb), false);
|
||||
|
||||
const titleOnly = build({ title: "T" });
|
||||
assert.equal(titleOnly, "T — Check out this video on bitvid 👇");
|
||||
|
||||
const empty = build({});
|
||||
assert.equal(empty, "Untitled video — Check out this video on bitvid 👇");
|
||||
});
|
||||
|
||||
test("a missing title degrades to a placeholder rather than 'undefined'", () => {
|
||||
const body = build({ ...BASE, title: " " });
|
||||
assert.match(body, /^Untitled video — /);
|
||||
assert.equal(body.includes("undefined"), false);
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// SCN-share-pointer-hints
|
||||
//
|
||||
// A shared bitvid link carries the video as an `nevent` in `?v=`. It used to be
|
||||
// encoded as a BARE id -- no author, no relay hints -- so a viewer whose relay
|
||||
// set didn't happen to carry that event could not resolve it and landed on the
|
||||
// home feed. That is the "the shared link just opens bitvid.network" report:
|
||||
// the reported video was on nos.lol but NOT on damus, snort or primal, three of
|
||||
// bitvid's four defaults.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createPlaybackCoordinator } from "../js/app/playbackCoordinator.js";
|
||||
|
||||
const EVENT_ID = "a759836ece8f5b16ee20fc9f5872ccb7a04208758a3c473f3b3ed219e3adcfd1";
|
||||
const AUTHOR = "da19b5d291e06ed09ba545bc5366408dc82ba8c9e9f97d32ea8ea60f1f6cbfaa";
|
||||
|
||||
// Capture what gets encoded rather than asserting on bech32 output.
|
||||
function makeHarness({ videos = [], relays = [], writeRelays } = {}) {
|
||||
const encoded = [];
|
||||
globalThis.window = {
|
||||
NostrTools: {
|
||||
nip19: {
|
||||
neventEncode(pointer) {
|
||||
encoded.push(pointer);
|
||||
return `nevent1${pointer.id.slice(0, 8)}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const nostrClient = { relays };
|
||||
if (writeRelays !== undefined) {
|
||||
nostrClient.writeRelays = writeRelays;
|
||||
}
|
||||
|
||||
const coordinator = createPlaybackCoordinator({
|
||||
devLogger: { warn() {}, error() {}, log() {} },
|
||||
userLogger: { warn() {}, error() {}, info() {} },
|
||||
nostrClient,
|
||||
BITVID_WEBSITE_URL: "https://bitvid.network/",
|
||||
});
|
||||
|
||||
const app = {
|
||||
...coordinator,
|
||||
videosMap: new Map(videos.map((v) => [v.id, v])),
|
||||
getShareUrlBase: () => "https://bitvid.network",
|
||||
};
|
||||
|
||||
return { app, encoded };
|
||||
}
|
||||
|
||||
test("the pointer carries the video author", () => {
|
||||
const { app, encoded } = makeHarness({
|
||||
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
|
||||
relays: ["wss://relay.damus.io"],
|
||||
});
|
||||
|
||||
app.buildShareUrlFromEventId(EVENT_ID);
|
||||
assert.equal(encoded[0].author, AUTHOR);
|
||||
});
|
||||
|
||||
test("the pointer carries relay hints", () => {
|
||||
const { app, encoded } = makeHarness({
|
||||
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
|
||||
writeRelays: ["wss://nos.lol", "wss://relay.damus.io"],
|
||||
});
|
||||
|
||||
app.buildShareUrlFromEventId(EVENT_ID);
|
||||
assert.deepEqual(encoded[0].relays, ["wss://nos.lol", "wss://relay.damus.io"]);
|
||||
});
|
||||
|
||||
test("write relays win over the general relay list", () => {
|
||||
const { app, encoded } = makeHarness({
|
||||
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
|
||||
relays: ["wss://read-only.example"],
|
||||
writeRelays: ["wss://nos.lol"],
|
||||
});
|
||||
|
||||
app.buildShareUrlFromEventId(EVENT_ID);
|
||||
assert.deepEqual(encoded[0].relays, ["wss://nos.lol"]);
|
||||
});
|
||||
|
||||
test("relay hints are capped, deduped and scheme-filtered", () => {
|
||||
// Hints inflate every shared URL, and a whole relay list in a note body is
|
||||
// both unwieldy and more disclosure than the link needs.
|
||||
const { app, encoded } = makeHarness({
|
||||
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
|
||||
writeRelays: [
|
||||
"https://not-a-relay.example",
|
||||
"wss://a.example",
|
||||
" wss://a.example ",
|
||||
"wss://b.example",
|
||||
"ws://c.example",
|
||||
"wss://d.example",
|
||||
"",
|
||||
null,
|
||||
],
|
||||
});
|
||||
|
||||
app.buildShareUrlFromEventId(EVENT_ID);
|
||||
assert.deepEqual(encoded[0].relays, [
|
||||
"wss://a.example",
|
||||
"wss://b.example",
|
||||
"ws://c.example",
|
||||
]);
|
||||
});
|
||||
|
||||
test("an unknown video still yields a usable pointer", () => {
|
||||
// Sharing from a surface that has not cached the video must not break; the
|
||||
// bare id is the old behavior and remains the floor.
|
||||
const { app, encoded } = makeHarness({ writeRelays: ["wss://nos.lol"] });
|
||||
|
||||
const url = app.buildShareUrlFromEventId(EVENT_ID);
|
||||
assert.equal(encoded[0].id, EVENT_ID);
|
||||
assert.equal("author" in encoded[0], false);
|
||||
assert.match(url, /^https:\/\/bitvid\.network\/\?v=/);
|
||||
});
|
||||
|
||||
test("a malformed author is omitted rather than embedded", () => {
|
||||
for (const pubkey of ["", "not-hex", "abc123", null, undefined, 42]) {
|
||||
const { app, encoded } = makeHarness({
|
||||
videos: [{ id: EVENT_ID, pubkey }],
|
||||
writeRelays: ["wss://nos.lol"],
|
||||
});
|
||||
app.buildShareUrlFromEventId(EVENT_ID);
|
||||
assert.equal(
|
||||
"author" in encoded[0],
|
||||
false,
|
||||
`must not embed author ${JSON.stringify(pubkey)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("no relays configured omits the hint rather than sending an empty array", () => {
|
||||
const { app, encoded } = makeHarness({
|
||||
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
|
||||
relays: [],
|
||||
});
|
||||
app.buildShareUrlFromEventId(EVENT_ID);
|
||||
assert.equal("relays" in encoded[0], false);
|
||||
});
|
||||
|
||||
test("the share URL has a path separator before the query", () => {
|
||||
// `https://bitvid.network?v=...` is legal but reads as a typo in a note and
|
||||
// trips some link parsers on where the URL ends.
|
||||
const { app } = makeHarness({
|
||||
videos: [{ id: EVENT_ID, pubkey: AUTHOR }],
|
||||
relays: ["wss://nos.lol"],
|
||||
});
|
||||
|
||||
const url = app.buildShareUrlFromEventId(EVENT_ID);
|
||||
assert.match(url, /^https:\/\/bitvid\.network\/\?v=nevent1/);
|
||||
assert.equal(url.includes("network?v="), false);
|
||||
});
|
||||
|
||||
test("an empty event id yields no URL", () => {
|
||||
const { app } = makeHarness({ relays: ["wss://nos.lol"] });
|
||||
assert.equal(app.buildShareUrlFromEventId(""), "");
|
||||
assert.equal(app.buildShareUrlFromEventId(null), "");
|
||||
});
|
||||
@@ -83,6 +83,70 @@ test('ShareNostrController', async (t) => {
|
||||
assert.equal(ui.showError.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
await t.test('handleShare forwards title and thumbnail for the imeta hint', async () => {
|
||||
// The controller used to pass only { id, pubkey } to buildShareEvent, so
|
||||
// the NIP-92 imeta entry could never be built and the thumbnail URL in the
|
||||
// note body rendered as a bare link instead of a preview image.
|
||||
const ui = {
|
||||
showError: t.mock.fn(),
|
||||
showSuccess: t.mock.fn(),
|
||||
getModal: () => ({})
|
||||
};
|
||||
const state = {
|
||||
getPubkey: () => 'signerpubkey',
|
||||
normalizeHexPubkey: (k) => k,
|
||||
getCurrentVideo: () => null
|
||||
};
|
||||
|
||||
mockBuildShareEvent.mock.resetCalls();
|
||||
const controller = new ShareNostrController({ ui, state, services });
|
||||
|
||||
await controller.handleShare({
|
||||
video: {
|
||||
id: 'video1',
|
||||
title: 'Video 1',
|
||||
pubkey: 'creator1',
|
||||
thumbnail: ' https://cdn.example/thumb.jpg '
|
||||
},
|
||||
content: 'Check this out',
|
||||
relays: ['wss://relay.example.com']
|
||||
});
|
||||
|
||||
const passedVideo = mockBuildShareEvent.mock.calls[0].arguments[0].video;
|
||||
assert.equal(passedVideo.title, 'Video 1');
|
||||
assert.equal(passedVideo.thumbnail, 'https://cdn.example/thumb.jpg');
|
||||
assert.equal(passedVideo.id, 'video1');
|
||||
assert.equal(passedVideo.pubkey, 'creator1');
|
||||
});
|
||||
|
||||
await t.test('handleShare tolerates a video with no thumbnail', async () => {
|
||||
const ui = {
|
||||
showError: t.mock.fn(),
|
||||
showSuccess: t.mock.fn(),
|
||||
getModal: () => ({})
|
||||
};
|
||||
const state = {
|
||||
getPubkey: () => 'signerpubkey',
|
||||
normalizeHexPubkey: (k) => k,
|
||||
getCurrentVideo: () => null
|
||||
};
|
||||
|
||||
mockBuildShareEvent.mock.resetCalls();
|
||||
const controller = new ShareNostrController({ ui, state, services });
|
||||
|
||||
const result = await controller.handleShare({
|
||||
video: { id: 'video1', title: 'Video 1', pubkey: 'creator1' },
|
||||
content: 'Check this out',
|
||||
relays: ['wss://relay.example.com']
|
||||
});
|
||||
|
||||
assert.ok(result.ok);
|
||||
assert.equal(
|
||||
mockBuildShareEvent.mock.calls[0].arguments[0].video.thumbnail,
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
await t.test('handleShare throws error if missing video details', async () => {
|
||||
const ui = {
|
||||
showError: t.mock.fn(),
|
||||
@@ -144,3 +208,130 @@ test('ShareNostrController', async (t) => {
|
||||
assert.equal(openArg.video.shareUrl, 'https://example.com/v1');
|
||||
});
|
||||
});
|
||||
|
||||
// SCN-share-mirror-lookup
|
||||
// The share modal embeds a quote pointer ONLY when a mirror is confirmed on
|
||||
// relays. A pointer to a missing event renders as a broken/empty quote box in
|
||||
// nostr clients — strictly worse than the plain-link fallback — so every
|
||||
// failure path here must degrade to "no pointer" rather than guess.
|
||||
|
||||
test('ShareNostrController mirror pointer', async (t) => {
|
||||
const NIP19 = { naddrEncode: (p) => `naddr1-${p.kind}-${p.identifier}` };
|
||||
|
||||
const baseUi = () => ({
|
||||
showError: t.mock.fn(),
|
||||
showSuccess: t.mock.fn(),
|
||||
getModal: () => ({ open: async () => {} }),
|
||||
});
|
||||
const baseState = () => ({
|
||||
getPubkey: () => 'signerpubkey',
|
||||
normalizeHexPubkey: (k) => k,
|
||||
getCurrentVideo: () => null,
|
||||
buildShareUrlFromEventId: () => 'https://bitvid.network/?v=nevent1abc',
|
||||
});
|
||||
|
||||
const VIDEO = {
|
||||
id: 'video1',
|
||||
title: 'Video 1',
|
||||
pubkey: 'b'.repeat(64),
|
||||
videoRootId: 'root-abc',
|
||||
thumbnail: 'https://cdn.example/t.jpg',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
};
|
||||
|
||||
const makeController = (mirrorService) =>
|
||||
new ShareNostrController({
|
||||
ui: baseUi(),
|
||||
state: baseState(),
|
||||
services: {
|
||||
mirrorService,
|
||||
nip19: NIP19,
|
||||
nostrClient: { pool: {}, writeRelays: ['wss://relay.one'] },
|
||||
userLogger: { warn: () => {}, error: () => {}, info: () => {} },
|
||||
devLogger: { warn: () => {}, error: () => {}, log: () => {} },
|
||||
},
|
||||
});
|
||||
|
||||
await t.test('confirmed mirror yields an naddr and coordinate', async () => {
|
||||
const controller = makeController({
|
||||
isAvailable: () => true,
|
||||
findMirror: async () => ({ mirrored: true, kinds: [34235] }),
|
||||
});
|
||||
const out = await controller.resolveMirrorPointer(VIDEO);
|
||||
assert.equal(out.naddr, 'naddr1-34235-root-abc');
|
||||
assert.equal(out.coordinate, `34235:${'b'.repeat(64)}:root-abc`);
|
||||
});
|
||||
|
||||
await t.test('the observed kind wins over the dimension heuristic', async () => {
|
||||
// Portrait dims would suggest 34236; relays say 34235, so 34235 it is.
|
||||
const controller = makeController({
|
||||
isAvailable: () => true,
|
||||
findMirror: async () => ({ mirrored: true, kinds: [34235] }),
|
||||
});
|
||||
const out = await controller.resolveMirrorPointer({
|
||||
...VIDEO,
|
||||
width: 720,
|
||||
height: 1280,
|
||||
});
|
||||
assert.equal(out.naddr, 'naddr1-34235-root-abc');
|
||||
});
|
||||
|
||||
await t.test('no mirror on relays yields no pointer', async () => {
|
||||
const controller = makeController({
|
||||
isAvailable: () => true,
|
||||
findMirror: async () => ({ mirrored: false, kinds: [] }),
|
||||
});
|
||||
assert.deepEqual(await controller.resolveMirrorPointer(VIDEO), {
|
||||
naddr: '',
|
||||
coordinate: '',
|
||||
});
|
||||
});
|
||||
|
||||
await t.test('a throwing lookup degrades instead of breaking the share', async () => {
|
||||
const controller = makeController({
|
||||
isAvailable: () => true,
|
||||
findMirror: async () => {
|
||||
throw new Error('relay exploded');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await controller.resolveMirrorPointer(VIDEO), {
|
||||
naddr: '',
|
||||
coordinate: '',
|
||||
});
|
||||
});
|
||||
|
||||
await t.test('a disabled mirror service is never consulted', async () => {
|
||||
let called = false;
|
||||
const controller = makeController({
|
||||
isAvailable: () => false,
|
||||
findMirror: async () => {
|
||||
called = true;
|
||||
return { mirrored: true, kinds: [34235] };
|
||||
},
|
||||
});
|
||||
const out = await controller.resolveMirrorPointer(VIDEO);
|
||||
assert.equal(out.naddr, '');
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
await t.test('a video without a videoRootId cannot be addressed', async () => {
|
||||
const controller = makeController({
|
||||
isAvailable: () => true,
|
||||
findMirror: async () => ({ mirrored: true, kinds: [34235] }),
|
||||
});
|
||||
const out = await controller.resolveMirrorPointer({
|
||||
...VIDEO,
|
||||
videoRootId: '',
|
||||
});
|
||||
assert.equal(out.naddr, '');
|
||||
});
|
||||
|
||||
await t.test('a missing mirror service is tolerated', async () => {
|
||||
const controller = makeController(null);
|
||||
assert.deepEqual(await controller.resolveMirrorPointer(VIDEO), {
|
||||
naddr: '',
|
||||
coordinate: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1324,6 +1324,7 @@ test("loadSubscriptions uses overlap window and deterministic latest-event tie-b
|
||||
subscribedPubkeys: [oldAuthor],
|
||||
createdAt: 1000,
|
||||
eventId: "cached-event",
|
||||
eventPubkey: pubkey,
|
||||
});
|
||||
|
||||
nostrClient.fetchListIncrementally = async (params = {}) => {
|
||||
@@ -1378,6 +1379,104 @@ test("loadSubscriptions uses overlap window and deterministic latest-event tie-b
|
||||
}
|
||||
});
|
||||
|
||||
test("loadSubscriptions never applies a different BitLogin session actor's list", async () => {
|
||||
const SubscriptionsManager = subscriptions.constructor;
|
||||
const manager = new SubscriptionsManager();
|
||||
const activePubkey = "a".repeat(64);
|
||||
const sessionPubkey = "b".repeat(64);
|
||||
const activeAuthor = "c".repeat(64);
|
||||
const sessionAuthor = "d".repeat(64);
|
||||
const originalFetchIncremental = nostrClient.fetchListIncrementally;
|
||||
const originalSessionActor = nostrClient.sessionActor;
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalDecryptSubscriptionEvent = manager.decryptSubscriptionEvent;
|
||||
|
||||
nostrClient.sessionActor = { pubkey: sessionPubkey };
|
||||
nostrClient.pool = { list: async () => [] };
|
||||
nostrClient.fetchListIncrementally = async ({ pubkey }) => {
|
||||
assert.equal(pubkey, activePubkey, "only the active account may be fetched");
|
||||
return [
|
||||
{
|
||||
id: "active-list",
|
||||
pubkey: activePubkey,
|
||||
created_at: 100,
|
||||
content: "active-ciphertext",
|
||||
tags: [["encrypted", "nip04"]],
|
||||
},
|
||||
{
|
||||
id: "foreign-list",
|
||||
pubkey: sessionPubkey,
|
||||
created_at: 200,
|
||||
content: "foreign-ciphertext",
|
||||
tags: [["encrypted", "nip04"]],
|
||||
},
|
||||
];
|
||||
};
|
||||
manager.decryptSubscriptionEvent = async (event) => ({
|
||||
ok: true,
|
||||
plaintext: JSON.stringify([["p", event.id === "active-list" ? activeAuthor : sessionAuthor]]),
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.updateFromRelays(activePubkey, { allowPermissionPrompt: false });
|
||||
assert.equal(manager.subsEventId, "active-list");
|
||||
assert.deepEqual(manager.getSubscribedAuthors(), [activeAuthor]);
|
||||
} finally {
|
||||
nostrClient.fetchListIncrementally = originalFetchIncremental;
|
||||
nostrClient.sessionActor = originalSessionActor;
|
||||
nostrClient.pool = originalPool;
|
||||
manager.decryptSubscriptionEvent = originalDecryptSubscriptionEvent;
|
||||
localStorage.clear();
|
||||
profileCache.memoryCache.clear();
|
||||
profileCache.activePubkey = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("loadSubscriptions repairs a legacy cached list with a full active-account fetch", async () => {
|
||||
const SubscriptionsManager = subscriptions.constructor;
|
||||
const manager = new SubscriptionsManager();
|
||||
const activePubkey = "e".repeat(64);
|
||||
const staleAuthor = "f".repeat(64);
|
||||
const correctAuthor = "1".repeat(64);
|
||||
const originalFetchIncremental = nostrClient.fetchListIncrementally;
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalDecryptSubscriptionEvent = manager.decryptSubscriptionEvent;
|
||||
|
||||
profileCache.setProfileData(activePubkey, "subscriptions", {
|
||||
subscribedPubkeys: [staleAuthor],
|
||||
eventId: "legacy-foreign-event",
|
||||
createdAt: 9999,
|
||||
});
|
||||
nostrClient.pool = { list: async () => [] };
|
||||
nostrClient.fetchListIncrementally = async (params) => {
|
||||
assert.equal(params.since, 0, "legacy source-less cache must not suppress a full fetch");
|
||||
return [{
|
||||
id: "correct-active-event",
|
||||
pubkey: activePubkey,
|
||||
created_at: 100,
|
||||
content: "correct-ciphertext",
|
||||
tags: [["encrypted", "nip04"]],
|
||||
}];
|
||||
};
|
||||
manager.decryptSubscriptionEvent = async () => ({
|
||||
ok: true,
|
||||
plaintext: JSON.stringify([["p", correctAuthor]]),
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.updateFromRelays(activePubkey, { allowPermissionPrompt: false });
|
||||
assert.deepEqual(manager.getSubscribedAuthors(), [correctAuthor]);
|
||||
assert.equal(manager.subsEventPubkey, activePubkey);
|
||||
} finally {
|
||||
nostrClient.fetchListIncrementally = originalFetchIncremental;
|
||||
nostrClient.pool = originalPool;
|
||||
manager.decryptSubscriptionEvent = originalDecryptSubscriptionEvent;
|
||||
localStorage.clear();
|
||||
profileCache.memoryCache.clear();
|
||||
profileCache.activePubkey = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleSubscriptionListEvent does not re-trigger on the live-sub echo (loop guard)", () => {
|
||||
const SubscriptionsManager = subscriptions.constructor;
|
||||
const manager = new SubscriptionsManager();
|
||||
|
||||
@@ -15,36 +15,36 @@ describe("ReactionController", () => {
|
||||
subscribe: (pointer, cb) => {
|
||||
return () => {};
|
||||
},
|
||||
publish: async () => ({ ok: true }),
|
||||
publish: async () => ({ ok: true })
|
||||
};
|
||||
|
||||
mockVideoModal = {
|
||||
updateReactionSummary: () => {},
|
||||
setUserReaction: () => {},
|
||||
setUserReaction: () => {}
|
||||
};
|
||||
|
||||
mockUi = {
|
||||
getVideoModal: () => mockVideoModal,
|
||||
showError: () => {},
|
||||
showError: () => {}
|
||||
};
|
||||
|
||||
mockState = {
|
||||
getCurrentVideo: () => ({ id: "v1", pubkey: "p1" }),
|
||||
getCurrentVideoPointer: () => ["e", "v1"],
|
||||
getCurrentVideoPointerKey: () => "e:v1",
|
||||
getCurrentVideoPointerKey: () => "e:v1"
|
||||
};
|
||||
|
||||
mockCallbacks = {
|
||||
isUserLoggedIn: () => true,
|
||||
normalizeHexPubkey: (val) => val,
|
||||
getPubkey: () => "user1",
|
||||
getPubkey: () => "user1"
|
||||
};
|
||||
|
||||
controller = new ReactionController({
|
||||
services: { reactionCounter: mockReactionCounter },
|
||||
ui: mockUi,
|
||||
state: mockState,
|
||||
callbacks: mockCallbacks,
|
||||
callbacks: mockCallbacks
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,26 @@ describe("ReactionController", () => {
|
||||
controller.subscribe(["e", "v1"], "e:v1");
|
||||
|
||||
assert.strictEqual(subscribeMock.mock.callCount(), 1);
|
||||
assert.deepStrictEqual(subscribeMock.mock.calls[0].arguments[0], ["e", "v1"]);
|
||||
assert.deepStrictEqual(subscribeMock.mock.calls[0].arguments[0], [
|
||||
"e",
|
||||
"v1"
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes the immutable event ID when subscribing to addressable videos", (t) => {
|
||||
const subscribeMock = t.mock.method(mockReactionCounter, "subscribe");
|
||||
|
||||
controller.subscribe(
|
||||
["a", "34235:p1:episode-42", "wss://relay.example"],
|
||||
"a:34235:p1:episode-42"
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(subscribeMock.mock.calls[0].arguments[0], {
|
||||
type: "a",
|
||||
value: "34235:p1:episode-42",
|
||||
relay: "wss://relay.example",
|
||||
eventId: "v1"
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle reaction update from subscription", (t) => {
|
||||
@@ -63,35 +82,41 @@ describe("ReactionController", () => {
|
||||
subCallback = cb;
|
||||
return () => {};
|
||||
};
|
||||
const updateSummaryMock = t.mock.method(mockVideoModal, "updateReactionSummary");
|
||||
const updateSummaryMock = t.mock.method(
|
||||
mockVideoModal,
|
||||
"updateReactionSummary"
|
||||
);
|
||||
|
||||
controller.subscribe(["e", "v1"], "e:v1");
|
||||
// subscribe calls unsubscribe which calls resetState which calls updateReactionSummary (1st call)
|
||||
|
||||
subCallback({
|
||||
total: 10,
|
||||
counts: { "+": 10 },
|
||||
reactions: { "user1": { content: "+" } }
|
||||
total: 10,
|
||||
counts: { "+": 10 },
|
||||
reactions: { user1: { content: "+" } }
|
||||
});
|
||||
// subCallback calls updateReactionSummary (2nd call)
|
||||
|
||||
assert.strictEqual(updateSummaryMock.mock.callCount(), 2);
|
||||
assert.deepStrictEqual(updateSummaryMock.mock.calls[1].arguments[0], {
|
||||
total: 10,
|
||||
counts: { "+": 10, "-": 0 },
|
||||
userReaction: "+"
|
||||
total: 10,
|
||||
counts: { "+": 10, "-": 0 },
|
||||
userReaction: "+"
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply optimistic update on handleReaction", async (t) => {
|
||||
const updateSummaryMock = t.mock.method(mockVideoModal, "updateReactionSummary");
|
||||
const updateSummaryMock = t.mock.method(
|
||||
mockVideoModal,
|
||||
"updateReactionSummary"
|
||||
);
|
||||
const publishMock = t.mock.method(mockReactionCounter, "publish");
|
||||
|
||||
// Pre-seed state
|
||||
controller.reactionState = {
|
||||
total: 5,
|
||||
counts: { "+": 5, "-": 0 },
|
||||
userReaction: ""
|
||||
total: 5,
|
||||
counts: { "+": 5, "-": 0 },
|
||||
userReaction: ""
|
||||
};
|
||||
|
||||
await controller.handleReaction({ reaction: "+" });
|
||||
@@ -110,16 +135,19 @@ describe("ReactionController", () => {
|
||||
|
||||
it("should rollback optimistic update on publish failure", async (t) => {
|
||||
mockReactionCounter.publish = async () => ({ ok: false });
|
||||
const updateSummaryMock = t.mock.method(mockVideoModal, "updateReactionSummary");
|
||||
const updateSummaryMock = t.mock.method(
|
||||
mockVideoModal,
|
||||
"updateReactionSummary"
|
||||
);
|
||||
|
||||
// We need to update controller.ui.showError because constructor copied the reference
|
||||
const showErrorMock = t.mock.method(mockUi, "showError");
|
||||
controller.ui.showError = showErrorMock;
|
||||
|
||||
controller.reactionState = {
|
||||
total: 5,
|
||||
counts: { "+": 5, "-": 0 },
|
||||
userReaction: ""
|
||||
total: 5,
|
||||
counts: { "+": 5, "-": 0 },
|
||||
userReaction: ""
|
||||
};
|
||||
|
||||
await controller.handleReaction({ reaction: "+" });
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { VideoListView } from "../../js/ui/views/VideoListView.js";
|
||||
|
||||
test("VideoListView disposes health observers before replacing its card grid", () => {
|
||||
const dom = new JSDOM("<div id=grid></div>");
|
||||
const grid = dom.window.document.getElementById("grid");
|
||||
let healthDetaches = 0;
|
||||
let urlDetaches = 0;
|
||||
const view = new VideoListView({
|
||||
document: dom.window.document,
|
||||
badgeHelpers: {
|
||||
attachHealthBadges: () => () => { healthDetaches += 1; },
|
||||
attachUrlHealthBadges: () => () => { urlDetaches += 1; },
|
||||
},
|
||||
});
|
||||
view.mount(grid);
|
||||
const videos = [{ id: "one", title: "One", pubkey: "a", created_at: 1 }];
|
||||
|
||||
view.render(videos);
|
||||
view.render([{ ...videos[0], title: "Updated", created_at: 2 }]);
|
||||
view.unmount();
|
||||
|
||||
assert.equal(healthDetaches, 2, "rerender and unmount each detach health observers");
|
||||
assert.equal(urlDetaches, 2, "rerender and unmount each detach URL observers");
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test("VideoListView restores cards when a navigation remount clears unchanged feed markup", () => {
|
||||
const dom = new JSDOM("<div id=grid></div>");
|
||||
const grid = dom.window.document.getElementById("grid");
|
||||
const view = new VideoListView({ document: dom.window.document });
|
||||
const videos = [{ id: "for-you-one", title: "For You", pubkey: "a", created_at: 1 }];
|
||||
|
||||
view.mount(grid);
|
||||
view.render(videos);
|
||||
assert.equal(grid.querySelectorAll('[data-component="video-card"]').length, 1);
|
||||
|
||||
// Loading a view replaces #videoList. The data can still be identical when
|
||||
// returning to For You, so a signature-only cache must not skip the repaint.
|
||||
grid.innerHTML = '<div data-for-you-empty-state="true"></div>';
|
||||
view.render(videos);
|
||||
|
||||
assert.equal(grid.querySelectorAll('[data-component="video-card"]').length, 1);
|
||||
assert.equal(grid.querySelector('[data-for-you-empty-state]'), null);
|
||||
dom.window.close();
|
||||
});
|
||||
@@ -141,6 +141,68 @@ test(".ogg url WITHOUT an audio imeta is kept (ambiguous → treated as video)",
|
||||
assert.equal(parsed.url, "https://cdn.example/clip.ogg");
|
||||
});
|
||||
|
||||
// HLS playlists are a CONTAINER format, not an assertion about content. The
|
||||
// `audio/mpegurl` / `audio/x-mpegurl` spellings are legacy aliases for an HLS
|
||||
// playlist of any kind, so they must not vote "audio-only" — otherwise an HLS
|
||||
// video ladder published with the legacy type is misfiled as a podcast and
|
||||
// silently dropped from every video feed.
|
||||
test("HLS playlist events are kept regardless of the playlist mime spelling", () => {
|
||||
const makeHlsEvent = (mime, id) => ({
|
||||
id,
|
||||
content: JSON.stringify({
|
||||
version: 3,
|
||||
title: "Why I'm (sort of) not worried about AI",
|
||||
url: "https://almond.apps2.slidestr.net/3751b84f.m3u8",
|
||||
videoRootId: `root-${id}`,
|
||||
}),
|
||||
tags: [
|
||||
[
|
||||
"imeta",
|
||||
"dim 1280x720",
|
||||
"url https://almond.apps2.slidestr.net/3751b84f.m3u8",
|
||||
`m ${mime}`,
|
||||
"duration 3512",
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
for (const mime of [
|
||||
"application/vnd.apple.mpegurl",
|
||||
"application/x-mpegurl",
|
||||
"audio/mpegurl",
|
||||
"audio/x-mpegurl",
|
||||
"APPLICATION/VND.APPLE.MPEGURL",
|
||||
]) {
|
||||
const parsed = convertEventToVideo(makeHlsEvent(mime, `evt-hls-${mime}`));
|
||||
assert.equal(
|
||||
parsed.invalid,
|
||||
false,
|
||||
`HLS note with "m ${mime}" must not be filtered as audio-only`
|
||||
);
|
||||
assert.equal(parsed.url, "https://almond.apps2.slidestr.net/3751b84f.m3u8");
|
||||
}
|
||||
});
|
||||
|
||||
test("a genuine audio/* variant still filters an HLS-tagged note", () => {
|
||||
// The playlist type abstains; it must not *rescue* a note that also carries a
|
||||
// real audio-only variant and no video variant.
|
||||
const audioHls = {
|
||||
id: "evt-hls-audio",
|
||||
content: JSON.stringify({
|
||||
version: 3,
|
||||
title: "Podcast, HLS packaged",
|
||||
url: "https://cdn.example/episode.m3u8",
|
||||
videoRootId: "root-hls-audio",
|
||||
}),
|
||||
tags: [
|
||||
["imeta", "url https://cdn.example/episode.m3u8", "m application/x-mpegurl"],
|
||||
["imeta", "url https://cdn.example/episode.mp3", "m audio/mpeg"],
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(convertEventToVideo(audioHls).invalid, true);
|
||||
});
|
||||
|
||||
test("a video imeta variant wins even when an audio variant is also present", () => {
|
||||
const mixed = {
|
||||
id: "evt-mixed",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
"contrast": "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 truncated
|
||||
"default": "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 truncated
|
||||
"light": "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 truncated
|
||||
"video-modal-mobile": "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 truncated
|
||||
"video-modal-mobile": "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 truncated
|
||||
}
|
||||
@@ -57,8 +57,17 @@ class MockTorrent extends EventEmitter {
|
||||
}
|
||||
|
||||
destroy(opts, cb) {
|
||||
this.destroyed = true;
|
||||
this.destroyedDuringWire = this.emittingWire === true;
|
||||
if (typeof cb === 'function') cb();
|
||||
}
|
||||
|
||||
emit(eventName, ...args) {
|
||||
if (eventName === 'wire') this.emittingWire = true;
|
||||
const result = super.emit(eventName, ...args);
|
||||
if (eventName === 'wire') this.emittingWire = false;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
describe("WebTorrent Regression Tests", () => {
|
||||
@@ -118,4 +127,18 @@ describe("WebTorrent Regression Tests", () => {
|
||||
assert.strictEqual(result.healthy, false, "Should be unhealthy");
|
||||
assert.strictEqual(result.reason, "timeout", "Reason should be 'timeout'");
|
||||
});
|
||||
|
||||
it("defers probe cleanup until after a successful wire callback returns", async () => {
|
||||
const client = new TorrentClient({ webTorrentClass: MockWebTorrent });
|
||||
const magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567";
|
||||
const result = await client.probePeers(magnet, {
|
||||
timeoutMs: 1000,
|
||||
urlList: ["http://localhost:8080/video.mp4"],
|
||||
});
|
||||
const torrent = client.probeClient.torrents[0];
|
||||
|
||||
assert.equal(result.healthy, true);
|
||||
assert.equal(torrent.destroyed, true, "temporary probe torrent is cleaned up before its queue slot releases");
|
||||
assert.equal(torrent.destroyedDuringWire, false, "probe cleanup is not synchronous with the wire event");
|
||||
});
|
||||
});
|
||||
@@ -373,3 +373,25 @@ test("most-zapped sorter: sats desc, recency tie-break, muted sinks", () => {
|
||||
"sats desc, newer wins ties, trusted-muted last regardless of sats",
|
||||
);
|
||||
});
|
||||
|
||||
test("most-zapped sorter keeps strict zap order when one creator has several top videos", () => {
|
||||
const item = (id, author, sats, createdAt) => ({
|
||||
video: { id, pubkey: author, created_at: createdAt },
|
||||
metadata: {},
|
||||
sats,
|
||||
});
|
||||
const items = [
|
||||
item("creator-a-top", "creator-a", 900, 100),
|
||||
item("creator-a-second", "creator-a", 800, 200),
|
||||
item("creator-b", "creator-b", 700, 300),
|
||||
];
|
||||
const sorted = createMostZappedSorter()(items, {
|
||||
runtime: { getZapTotal: (video) => items.find((entry) => entry.video === video)?.sats || 0 },
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
sorted.map((entry) => entry.video.id),
|
||||
["creator-a-top", "creator-a-second", "creator-b"],
|
||||
"a diversity rule must not move a lower-zapped video above a higher-zapped one",
|
||||
);
|
||||
});
|
||||
@@ -30,6 +30,37 @@ tests → `npm run build` + `npm run test:unit` green → commit + push.
|
||||
|
||||
## Open — high priority (launch-blocking candidates)
|
||||
|
||||
### 35. Channel subscriber/follower counts — scoped, deferred
|
||||
|
||||
- [ ] Approve the public/private product definition and implement only after
|
||||
protocol research, bounded profile-only reads, and privacy/performance
|
||||
tests. Do **not** infer a global count from BitVid's encrypted private
|
||||
subscription lists.
|
||||
- **Scope, recommendation, and acceptance criteria:**
|
||||
[`docs/channel-subscriber-counts-plan.md`](../docs/channel-subscriber-counts-plan.md).
|
||||
|
||||
### 34. Performance and efficiency — staged, measurement-first plan
|
||||
|
||||
- [x] Establish the local-only measurement harness (`js/performanceHarness.js`);
|
||||
it is enabled only on loopback with
|
||||
`localStorage.__bitvidPerformanceHarness__ = "1"`.
|
||||
- [ ] Capture and retain the complete Stage 0 baseline scenarios (cache size,
|
||||
worker lifetime, probe counts, browser CPU/RSS, and warm-feed behavior).
|
||||
- [x] Complete Stage 1: explicit localhost relay diagnostics.
|
||||
- [x] Complete Stage 2: lazy and coalesced Explore/For You indexes, including
|
||||
cache-first rendering when returning to For You.
|
||||
- [ ] **Stage 3 in progress:** observer teardown is shipped; cancellable URL and
|
||||
WebTorrent queue/probe work remains.
|
||||
- [ ] **Stage 7 in progress:** recommendation refresh coalescing is shipped;
|
||||
bounded event-cache growth and full feed-refresh coalescing remain.
|
||||
- [ ] Stage 5: service-worker/image-cache cadence.
|
||||
- [ ] Stage 4 and Stage 6: measured playback-visual and bundle/lazy-loading
|
||||
improvements.
|
||||
- **Plan and acceptance criteria:**
|
||||
[`docs/performance-efficiency-plan.md`](../docs/performance-efficiency-plan.md).
|
||||
Preserve live Nostr ingestion, feed freshness, source fallback, WebTorrent
|
||||
playback, moderation decisions, and recommendation inputs at every stage.
|
||||
|
||||
### 33. Login / profile / session persistence broken with NIP-46 (Amber) — AUDIT (BUG)
|
||||
Reported 2026-06-25. All symptoms observed with a **NIP-46 remote signer (Amber)**.
|
||||
Likely a shared root cause in session/identity persistence + multi-account handling.
|
||||
|
||||
+15
-7
@@ -2,17 +2,25 @@
|
||||
"package": "@bitlogin/widget",
|
||||
"version": "0.1.0",
|
||||
"files": {
|
||||
"bitlogin-shared-QIBe5Omw.js": {
|
||||
"sha256": "7ce4ed0a5c3c7f744e864f55745f7fa78482cbfa19cd30eb551f67a415f55191",
|
||||
"bytes": 288275
|
||||
"bitlogin-shared-B2Rc9khL.js": {
|
||||
"sha256": "6a9df1b27d73499d3bd488182b3134e0173c6b0bf1b5537cddc0e9ec4073d8e7",
|
||||
"bytes": 288486
|
||||
},
|
||||
"bitlogin-shared-DxcpMAo3.js": {
|
||||
"sha256": "b6cb73f3544a8c378416c50740a91eaea700c4af3e4f6b131bb3694e7c816758",
|
||||
"bytes": 483718
|
||||
},
|
||||
"bitlogin-shared-IsyHQNSG.js": {
|
||||
"sha256": "bd6d38087df246792cd61b82323f2997ecf478d3abcc3885235f45a925614131",
|
||||
"bytes": 545712
|
||||
},
|
||||
"bitlogin.js": {
|
||||
"sha256": "55312cef3462a2e41043b57bd3c87a80bad4e077dd6e529faf25fa55db4ba7a8",
|
||||
"bytes": 67232
|
||||
"sha256": "0d118d599efd30d4c0d0c197d463fd64900e803aa1a84c7880e1780bed9f3aba",
|
||||
"bytes": 85342
|
||||
},
|
||||
"cryptoWorker.js": {
|
||||
"sha256": "49a421e1026ad94243ceb0653f3761820d4e98e19b8fbe469307cd692f9a8489",
|
||||
"bytes": 79173
|
||||
"sha256": "4210e0d6e7c5fc33e4df2dd3eedbe9434439600d0e60fcb3cad90699c198d452",
|
||||
"bytes": 104078
|
||||
}
|
||||
}
|
||||
}
|
||||
+335
-323
File diff suppressed because it is too large.
Load diff
+8269
File diff suppressed because it is too large.
Load diff
+11772
File diff suppressed because it is too large.
Load diff
Vendored
+547
-135
@@ -1,13 +1,13 @@
|
||||
import { r as y, e as w, E as v, p as Q, R as k, i as h, a as d } from "./bitlogin-shared-QIBe5Omw.js";
|
||||
const x = 6, p = 64;
|
||||
function u(a = x) {
|
||||
const e = w(), t = [];
|
||||
for (let r = 0; r < a; r++)
|
||||
t.push(e[y(e.length)]);
|
||||
const s = a * v;
|
||||
if (s < p)
|
||||
throw new Error(`Passphrase of ${a} words provides only ~${s.toFixed(1)} bits; must exceed ${p}.`);
|
||||
return { kind: "passphrase", secret: t.join(" "), entropyBits: s };
|
||||
import { r as w, e as k, E as Q, p as x, R as C, i as p, a as d } from "./bitlogin-shared-B2Rc9khL.js";
|
||||
const S = 6, f = 64;
|
||||
function u(r = S) {
|
||||
const e = k(), t = [];
|
||||
for (let s = 0; s < r; s++)
|
||||
t.push(e[w(e.length)]);
|
||||
const a = r * Q;
|
||||
if (a < f)
|
||||
throw new Error(`Passphrase of ${r} words provides only ~${a.toFixed(1)} bits; must exceed ${f}.`);
|
||||
return { kind: "passphrase", secret: t.join(" "), entropyBits: a };
|
||||
}
|
||||
const b = 64, m = 12, P = /* @__PURE__ */ new Set([
|
||||
"password",
|
||||
@@ -41,56 +41,77 @@ const b = 64, m = 12, P = /* @__PURE__ */ new Set([
|
||||
"passw0rd",
|
||||
"correcthorsebatterystaple"
|
||||
]);
|
||||
function L(a) {
|
||||
function L(r) {
|
||||
let e = 0;
|
||||
return /[a-z]/u.test(a) && (e += 26), /[A-Z]/u.test(a) && (e += 26), /[0-9]/u.test(a) && (e += 10), /[^a-zA-Z0-9]/u.test(a) && (e += 33), e || 1;
|
||||
return /[a-z]/u.test(r) && (e += 26), /[A-Z]/u.test(r) && (e += 26), /[0-9]/u.test(r) && (e += 10), /[^a-zA-Z0-9]/u.test(r) && (e += 33), e || 1;
|
||||
}
|
||||
function S(a) {
|
||||
return a.length * Math.log2(L(a));
|
||||
function E(r) {
|
||||
return r.length * Math.log2(L(r));
|
||||
}
|
||||
function C(a) {
|
||||
if (/^(.)\1*$/u.test(a) || /(.)\1{2,}/u.test(a))
|
||||
function $(r) {
|
||||
if (/^(.)\1*$/u.test(r) || /(.)\1{2,}/u.test(r))
|
||||
return !0;
|
||||
const e = a.toLowerCase(), t = ["0123456789", "abcdefghijklmnopqrstuvwxyz"];
|
||||
for (const s of t)
|
||||
for (let r = 0; r + 4 <= s.length; r++) {
|
||||
const n = s.slice(r, r + 4), o = [...n].reverse().join("");
|
||||
const e = r.toLowerCase(), t = ["0123456789", "abcdefghijklmnopqrstuvwxyz"];
|
||||
for (const a of t)
|
||||
for (let s = 0; s + 4 <= a.length; s++) {
|
||||
const n = a.slice(s, s + 4), o = [...n].reverse().join("");
|
||||
if (e.includes(n) || e.includes(o))
|
||||
return !0;
|
||||
}
|
||||
return !1;
|
||||
}
|
||||
function g(a, e) {
|
||||
const t = S(a);
|
||||
return a.length < m ? { ok: !1, entropyBits: t, reason: `Must be at least ${m} characters.` } : P.has(a.toLowerCase()) ? { ok: !1, entropyBits: t, reason: "This is on a list of extremely common passwords." } : e.length >= 3 && a.toLowerCase().includes(e) ? { ok: !1, entropyBits: t, reason: "Must not contain your login name." } : C(a) ? { ok: !1, entropyBits: t, reason: "Too predictable (repeated characters or a simple sequence)." } : t < b ? {
|
||||
function g(r, e) {
|
||||
const t = E(r);
|
||||
return r.length < m ? { ok: !1, entropyBits: t, reason: `Must be at least ${m} characters.` } : P.has(r.toLowerCase()) ? { ok: !1, entropyBits: t, reason: "This is on a list of extremely common passwords." } : e.length >= 3 && r.toLowerCase().includes(e) ? { ok: !1, entropyBits: t, reason: "Must not contain your login name." } : $(r) ? { ok: !1, entropyBits: t, reason: "Too predictable (repeated characters or a simple sequence)." } : t < b ? {
|
||||
ok: !1,
|
||||
entropyBits: t,
|
||||
reason: `Estimated entropy (~${t.toFixed(0)} bits) is below the required ${b}. Use a longer password or mix character types.`
|
||||
} : { ok: !0, entropyBits: t };
|
||||
}
|
||||
class E {
|
||||
const R = 6e4;
|
||||
class N {
|
||||
worker;
|
||||
pending = /* @__PURE__ */ new Map();
|
||||
counter = 0;
|
||||
dead = !1;
|
||||
constructor() {
|
||||
const e = ["cryptoWorker", ".js"].join(""), t = new URL(e, import.meta.url);
|
||||
this.worker = new Worker(t, { type: "module" }), this.worker.addEventListener("message", (s) => {
|
||||
const r = s.data, n = this.pending.get(r.id);
|
||||
if (n)
|
||||
if (this.pending.delete(r.id), r.ok)
|
||||
n.resolve(r.result);
|
||||
const n = s.data;
|
||||
if (!n || typeof n != "object" || typeof n.id != "string") return;
|
||||
const o = this.pending.get(n.id);
|
||||
if (o)
|
||||
if (this.pending.delete(n.id), clearTimeout(o.timer), n.ok)
|
||||
o.resolve(n.result);
|
||||
else {
|
||||
const o = new Error(r.error);
|
||||
o.name = r.errorName ?? "Error", n.reject(o);
|
||||
const l = new Error(n.error);
|
||||
l.name = n.errorName ?? "Error", o.reject(l);
|
||||
}
|
||||
});
|
||||
const a = (s) => () => this.failAll(s);
|
||||
this.worker.addEventListener("error", a("The BitLogin crypto worker stopped unexpectedly.")), this.worker.addEventListener(
|
||||
"messageerror",
|
||||
a("The BitLogin crypto worker sent an unreadable message.")
|
||||
);
|
||||
}
|
||||
/** Settles every outstanding call with an error. Idempotent. */
|
||||
failAll(e) {
|
||||
const t = [...this.pending.values()];
|
||||
this.pending.clear();
|
||||
for (const a of t)
|
||||
clearTimeout(a.timer), a.reject(new Error(e));
|
||||
}
|
||||
call(e, t) {
|
||||
const s = `${Date.now().toString(36)}-${(this.counter++).toString(36)}`;
|
||||
return new Promise((r, n) => {
|
||||
this.pending.set(s, { resolve: r, reject: n });
|
||||
const o = { id: s, action: e, payload: t };
|
||||
this.worker.postMessage(o);
|
||||
if (this.dead)
|
||||
return Promise.reject(new Error("This BitLogin session was torn down; reload the page to sign in again."));
|
||||
const a = `${Date.now().toString(36)}-${(this.counter++).toString(36)}`;
|
||||
return new Promise((s, n) => {
|
||||
const o = setTimeout(() => {
|
||||
this.pending.delete(a), n(new Error(`BitLogin's ${e} call timed out.`));
|
||||
}, R);
|
||||
this.pending.set(a, { resolve: s, reject: n, timer: o });
|
||||
const l = { id: a, action: e, payload: t };
|
||||
this.worker.postMessage(l);
|
||||
});
|
||||
}
|
||||
configure(e) {
|
||||
@@ -150,64 +171,88 @@ class E {
|
||||
restoreSession() {
|
||||
return this.call("restoreSession", {});
|
||||
}
|
||||
vaultStatus() {
|
||||
return this.call("vaultStatus", {});
|
||||
}
|
||||
vaultList() {
|
||||
return this.call("vaultList", {});
|
||||
}
|
||||
vaultSaveNwc(e) {
|
||||
return this.call("vaultSaveNwc", e);
|
||||
}
|
||||
vaultFindForOrigin() {
|
||||
return this.call("vaultFindForOrigin", {});
|
||||
}
|
||||
vaultRevealNwc(e) {
|
||||
return this.call("vaultRevealNwc", e);
|
||||
}
|
||||
vaultSetBinding(e) {
|
||||
return this.call("vaultSetBinding", e);
|
||||
}
|
||||
vaultDelete(e) {
|
||||
return this.call("vaultDelete", e);
|
||||
}
|
||||
vaultOfferCheck(e) {
|
||||
return this.call("vaultOfferCheck", e);
|
||||
}
|
||||
logout() {
|
||||
return this.call("logout", {});
|
||||
}
|
||||
terminate() {
|
||||
this.worker.terminate();
|
||||
this.dead = !0, this.worker.terminate(), this.failAll("The BitLogin crypto worker was terminated.");
|
||||
}
|
||||
}
|
||||
const N = "BitLogin: no identity is unlocked yet. Add <bitlogin-auth> to the page and let the user sign in, or call it programmatically before invoking window.nostr.";
|
||||
function c(a) {
|
||||
return a.catch((e) => {
|
||||
throw e.message.includes("No identity is unlocked") ? new Error(N) : e;
|
||||
const M = "BitLogin: no identity is unlocked yet. Add <bitlogin-auth> to the page and let the user sign in, or call it programmatically before invoking window.nostr.";
|
||||
function c(r) {
|
||||
return r.catch((e) => {
|
||||
throw e.message.includes("No identity is unlocked") ? new Error(M) : e;
|
||||
});
|
||||
}
|
||||
function $(a, e) {
|
||||
function B(r, e) {
|
||||
return {
|
||||
async getPublicKey() {
|
||||
const { publicKey: t } = await c(a.getPublicKey());
|
||||
const { publicKey: t } = await c(r.getPublicKey());
|
||||
return t;
|
||||
},
|
||||
async signEvent(t) {
|
||||
return c(a.signEvent(t));
|
||||
return c(r.signEvent(t));
|
||||
},
|
||||
async getRelays() {
|
||||
const t = {};
|
||||
for (const s of e()) t[s] = { read: !0, write: !0 };
|
||||
for (const a of e()) t[a] = { read: !0, write: !0 };
|
||||
return t;
|
||||
},
|
||||
nip44: {
|
||||
async encrypt(t, s) {
|
||||
const { ciphertext: r } = await c(a.nip44Encrypt({ peerPublicKey: t, plaintext: s }));
|
||||
return r;
|
||||
async encrypt(t, a) {
|
||||
const { ciphertext: s } = await c(r.nip44Encrypt({ peerPublicKey: t, plaintext: a }));
|
||||
return s;
|
||||
},
|
||||
async decrypt(t, s) {
|
||||
const { plaintext: r } = await c(a.nip44Decrypt({ peerPublicKey: t, payload: s }));
|
||||
return r;
|
||||
async decrypt(t, a) {
|
||||
const { plaintext: s } = await c(r.nip44Decrypt({ peerPublicKey: t, payload: a }));
|
||||
return s;
|
||||
}
|
||||
},
|
||||
nip04: {
|
||||
async encrypt(t, s) {
|
||||
const { ciphertext: r } = await c(a.nip04Encrypt({ peerPublicKey: t, plaintext: s }));
|
||||
return r;
|
||||
async encrypt(t, a) {
|
||||
const { ciphertext: s } = await c(r.nip04Encrypt({ peerPublicKey: t, plaintext: a }));
|
||||
return s;
|
||||
},
|
||||
async decrypt(t, s) {
|
||||
const { plaintext: r } = await c(a.nip04Decrypt({ peerPublicKey: t, payload: s }));
|
||||
return r;
|
||||
async decrypt(t, a) {
|
||||
const { plaintext: s } = await c(r.nip04Decrypt({ peerPublicKey: t, payload: a }));
|
||||
return s;
|
||||
}
|
||||
},
|
||||
_bitlogin: !0
|
||||
};
|
||||
}
|
||||
function R(a) {
|
||||
const e = a.getAttribute("vault-relays"), t = a.getAttribute("discovery-relays");
|
||||
function T(r) {
|
||||
const e = r.getAttribute("vault-relays"), t = r.getAttribute("discovery-relays");
|
||||
return {
|
||||
vaultRelayUrls: e ? e.split(",").map((s) => s.trim()).filter(Boolean) : void 0,
|
||||
discoveryRelayUrls: t ? t.split(",").map((s) => s.trim()).filter(Boolean) : void 0
|
||||
vaultRelayUrls: e ? e.split(",").map((a) => a.trim()).filter(Boolean) : void 0,
|
||||
discoveryRelayUrls: t ? t.split(",").map((a) => a.trim()).filter(Boolean) : void 0
|
||||
};
|
||||
}
|
||||
const M = (
|
||||
const q = (
|
||||
/* css */
|
||||
`
|
||||
:host {
|
||||
@@ -448,8 +493,60 @@ button.link {
|
||||
}
|
||||
@keyframes bl-spin { to { transform: rotate(360deg); } }
|
||||
`
|
||||
);
|
||||
class F extends HTMLElement {
|
||||
), y = "bc:config", U = /^nostr\+walletconnect:\/\//iu;
|
||||
let h = null;
|
||||
function F(r) {
|
||||
if (!h) {
|
||||
try {
|
||||
localStorage.removeItem(y);
|
||||
} catch {
|
||||
}
|
||||
h = import("./bitlogin-shared-IsyHQNSG.js").then((e) => (e.init({
|
||||
appName: r,
|
||||
filters: ["nwc"],
|
||||
showBalance: !1,
|
||||
autoConnect: !1,
|
||||
persistConnection: !1,
|
||||
providerConfig: {
|
||||
nwc: {
|
||||
authorizationUrlOptions: {
|
||||
name: r,
|
||||
requestMethods: ["pay_invoice"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}), e)).catch((e) => {
|
||||
throw h = null, e;
|
||||
});
|
||||
}
|
||||
return h;
|
||||
}
|
||||
function I(r) {
|
||||
const e = r instanceof Error ? r.message : String(r);
|
||||
return /closed|cancell?ed|dismissed|aborted/iu.test(e);
|
||||
}
|
||||
async function O(r) {
|
||||
const e = await F(r);
|
||||
try {
|
||||
const t = await e.requestProvider(), a = e.getConnectorConfig()?.nwcUrl, s = t.client?.nostrWalletConnectUrl, n = typeof a == "string" ? a : typeof s == "string" ? s : "";
|
||||
if (!U.test(n.trim()))
|
||||
throw new Error("The wallet did not return an NWC connection. Try again or paste one manually.");
|
||||
return n.trim();
|
||||
} catch (t) {
|
||||
if (I(t)) return null;
|
||||
throw t;
|
||||
} finally {
|
||||
try {
|
||||
e.disconnect();
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(y);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
class D extends HTMLElement {
|
||||
root;
|
||||
worker;
|
||||
vaultRelayUrls = [];
|
||||
@@ -500,14 +597,30 @@ class F extends HTMLElement {
|
||||
// signer or dispatching bitlogin-login, and requires an explicit second step to proceed.
|
||||
pendingRollback = null;
|
||||
rollbackMessage = "";
|
||||
// ---- Connection Vault request flow (vault-ux.md §2-§4, reveal mode) ----
|
||||
// stage "auth": waiting for the user to sign in first; the goto("dashboard")
|
||||
// hook resumes the request exactly once. stage "active": the vault screens
|
||||
// own navigation until finishVaultRequest() settles the promise.
|
||||
vaultRequest = null;
|
||||
vaultCandidate = null;
|
||||
/** True when the account cannot store the connection (no vault root); the
|
||||
* flow still hands the URI to the app, labeled as unsaved. */
|
||||
vaultUnsaved = !1;
|
||||
vaultUnsavedReason;
|
||||
vaultConnections = null;
|
||||
/** offerNwcConnection state: the app already holds this URI; the only
|
||||
* question on screen is whether a copy enters the user's vault. */
|
||||
/** Claimed synchronously by offerNwcConnection before any await (see there). */
|
||||
offerInFlight = !1;
|
||||
vaultOffer = null;
|
||||
constructor() {
|
||||
super(), this.root = this.attachShadow({ mode: "open" });
|
||||
const e = new CSSStyleSheet();
|
||||
e.replaceSync(M), this.root.adoptedStyleSheets = [e], this.worker = new E();
|
||||
e.replaceSync(q), this.root.adoptedStyleSheets = [e], this.worker = new N();
|
||||
}
|
||||
connectedCallback() {
|
||||
const e = R(this);
|
||||
if (this.vaultRelayUrls = e.vaultRelayUrls ?? [], this.discoveryRelayUrls = e.discoveryRelayUrls ?? [], this.worker.configure({ vaultRelayUrls: this.vaultRelayUrls, discoveryRelayUrls: this.discoveryRelayUrls }).then(() => this.tryRestoreSession()), this.root.addEventListener("click", (t) => this.onClick(t)), this.root.addEventListener("submit", (t) => this.onSubmit(t)), this.root.addEventListener("input", (t) => this.onInput(t)), this.root.addEventListener("change", (t) => void this.onFileChange(t)), this.render(), this.installedProvider = $(this.worker, () => this.vaultRelayUrls), !window.nostr)
|
||||
const e = T(this);
|
||||
if (this.vaultRelayUrls = e.vaultRelayUrls ?? [], this.discoveryRelayUrls = e.discoveryRelayUrls ?? [], this.worker.configure({ vaultRelayUrls: this.vaultRelayUrls, discoveryRelayUrls: this.discoveryRelayUrls }).then(() => this.tryRestoreSession()), this.root.addEventListener("click", (t) => this.onClick(t)), this.root.addEventListener("submit", (t) => this.onSubmit(t)), this.root.addEventListener("input", (t) => this.onInput(t)), this.root.addEventListener("change", (t) => void this.onFileChange(t)), this.render(), this.installedProvider = B(this.worker, () => this.vaultRelayUrls), !window.nostr)
|
||||
try {
|
||||
window.nostr = this.installedProvider;
|
||||
} catch {
|
||||
@@ -551,6 +664,186 @@ class F extends HTMLElement {
|
||||
async logout() {
|
||||
await this.worker.logout(), this.session = null, this.releaseSigner(), this.dispatchEvent(new CustomEvent("bitlogin-logout")), this.goto("welcome");
|
||||
}
|
||||
/**
|
||||
* Connection Vault request API (connection-vault.md §12, vault-ux.md §2-§4).
|
||||
*
|
||||
* Asks the user to share an NWC wallet connection with THIS page's origin
|
||||
* and resolves the raw `nostr+walletconnect://` URI, or null if the user
|
||||
* declines or dismisses. REVEAL MODE, stated plainly: the caller receives
|
||||
* the full bearer credential and everything its wallet-side budget allows —
|
||||
* an embedded same-origin widget cannot broker (§CV12.3), so it does not
|
||||
* pretend to. The consent copy tells the user the same thing.
|
||||
*
|
||||
* If nobody is signed in, the widget shows its sign-in flow first and
|
||||
* resumes the request after. If the account already has a connection bound
|
||||
* to this origin, the user sees a one-tap approval; otherwise a guided
|
||||
* import (Bitcoin Connect chooser, or paste).
|
||||
*/
|
||||
async requestNwcConnection(e = {}) {
|
||||
if (this.vaultRequest) throw new Error("A wallet connection request is already in progress.");
|
||||
const t = window.location.origin, a = e.appName?.trim() || window.location.hostname || "This app";
|
||||
return new Promise((s) => {
|
||||
this.vaultRequest = { appName: a, reason: e.reason, origin: t, stage: "auth", resolve: s }, this.dispatchEvent(new CustomEvent("bitlogin-request-pending")), (async () => {
|
||||
try {
|
||||
if (!(await this.worker.getSessionStatus()).unlocked) {
|
||||
this.goto("login");
|
||||
return;
|
||||
}
|
||||
await this.continueVaultRequest();
|
||||
} catch (n) {
|
||||
this.fail(n);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Offer-to-save (the inverse of requestNwcConnection): the app OBTAINED an
|
||||
* NWC URI by its own means — its own wallet chooser, its own paste box —
|
||||
* and offers the user a portable copy. Consent-gated in this widget's own
|
||||
* UI, never silent: the write goes to the user's account (encrypted events
|
||||
* under their vault identity), and nothing enters or leaves the vault
|
||||
* without the user seeing it happen in BitLogin's chrome.
|
||||
*
|
||||
* Resolves "saved", "declined", "already-saved" (same wallet + secret
|
||||
* exists; its origin binding was refreshed, no UI shown), or "unavailable"
|
||||
* (no session or no vault root — the offer is quietly impossible, and an
|
||||
* app should treat that as a no-op rather than an error).
|
||||
*/
|
||||
async offerNwcConnection(e, t = {}) {
|
||||
if (this.vaultOffer || this.vaultRequest || this.offerInFlight) return "unavailable";
|
||||
this.offerInFlight = !0;
|
||||
try {
|
||||
return await this.runOfferNwcConnection(e, t);
|
||||
} finally {
|
||||
this.offerInFlight = !1;
|
||||
}
|
||||
}
|
||||
async runOfferNwcConnection(e, t) {
|
||||
if (!(await this.worker.getSessionStatus().catch(() => ({ unlocked: !1 }))).unlocked || !(await this.worker.vaultStatus()).enabled) return "unavailable";
|
||||
if ((await this.worker.vaultOfferCheck({ uri: e })).duplicate) return "already-saved";
|
||||
const o = t.appName?.trim() || window.location.hostname || "This app";
|
||||
return new Promise((l) => {
|
||||
this.vaultOffer = {
|
||||
uri: e,
|
||||
appName: o,
|
||||
label: t.label?.trim() || `${o} wallet`,
|
||||
resolve: l
|
||||
}, this.dispatchEvent(new CustomEvent("bitlogin-offer-pending")), this.goto("vault-offer");
|
||||
});
|
||||
}
|
||||
finishVaultOffer(e) {
|
||||
const t = this.vaultOffer;
|
||||
t && (this.vaultOffer = null, t.resolve(e), this.goto(this.session ? "dashboard" : "welcome"));
|
||||
}
|
||||
async acceptVaultOffer() {
|
||||
const e = this.vaultOffer;
|
||||
if (e) {
|
||||
this.setBusy(!0);
|
||||
try {
|
||||
const t = this.field("vaultOfferLabel").trim() || e.label;
|
||||
await this.worker.vaultSaveNwc({ uri: e.uri, label: t }), this.setBusy(!1), this.finishVaultOffer("saved"), this.flashSuccess(this.screen, "Wallet saved"), this.dispatchEvent(
|
||||
new CustomEvent("bitlogin-connection-granted", { detail: { origin: window.location.origin } })
|
||||
);
|
||||
} catch (t) {
|
||||
this.setBusy(!1), this.fail(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Settles the pending request exactly once and returns to a neutral screen. */
|
||||
finishVaultRequest(e) {
|
||||
const t = this.vaultRequest;
|
||||
t && (this.vaultRequest = null, this.vaultCandidate = null, this.vaultUnsaved = !1, this.vaultUnsavedReason = void 0, t.resolve(e), e !== null && this.dispatchEvent(
|
||||
new CustomEvent("bitlogin-connection-granted", { detail: { origin: t.origin } })
|
||||
), this.goto(this.session ? "dashboard" : "welcome"));
|
||||
}
|
||||
async continueVaultRequest() {
|
||||
const e = this.vaultRequest;
|
||||
if (!e) return;
|
||||
e.stage = "active";
|
||||
const t = await this.worker.vaultStatus();
|
||||
if (!t.enabled) {
|
||||
this.vaultUnsaved = !0, this.vaultUnsavedReason = t.reason, this.goto("vault-import");
|
||||
return;
|
||||
}
|
||||
this.vaultUnsaved = !1;
|
||||
const a = await this.worker.vaultFindForOrigin();
|
||||
a.connection ? (this.vaultCandidate = a.connection, this.goto("vault-consent")) : this.goto("vault-import");
|
||||
}
|
||||
async approveVaultCandidate() {
|
||||
const e = this.vaultCandidate;
|
||||
if (!(!e || !this.vaultRequest)) {
|
||||
this.setBusy(!0);
|
||||
try {
|
||||
const { uri: t } = await this.worker.vaultRevealNwc({ connectionId: e.connectionId });
|
||||
this.setBusy(!1), this.finishVaultRequest(t), this.flashSuccess(this.screen, "Wallet shared");
|
||||
} catch (t) {
|
||||
this.setBusy(!1), this.fail(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
async runVaultBcChooser() {
|
||||
const e = this.vaultRequest;
|
||||
if (e) {
|
||||
this.setBusy(!0);
|
||||
try {
|
||||
const t = await O(e.appName);
|
||||
if (t === null) {
|
||||
this.setBusy(!1);
|
||||
return;
|
||||
}
|
||||
await this.saveAndShareVaultUri(t, this.field("vaultLabel"));
|
||||
} catch (t) {
|
||||
this.setBusy(!1), this.fail(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
async handleVaultImportSubmit() {
|
||||
const e = this.field("vaultUri").trim();
|
||||
if (e) {
|
||||
this.setBusy(!0);
|
||||
try {
|
||||
await this.saveAndShareVaultUri(e, this.field("vaultLabel"));
|
||||
} catch (t) {
|
||||
this.setBusy(!1), this.fail(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
async saveAndShareVaultUri(e, t) {
|
||||
const a = this.vaultRequest;
|
||||
if (!a) return;
|
||||
if (this.vaultUnsaved) {
|
||||
this.setBusy(!1), this.finishVaultRequest(e);
|
||||
return;
|
||||
}
|
||||
const s = t.trim() || `${a.appName} wallet`;
|
||||
await this.worker.vaultSaveNwc({ uri: e, label: s }), this.setBusy(!1), this.finishVaultRequest(e), this.flashSuccess(this.screen, "Wallet connected");
|
||||
}
|
||||
async loadVaultManage() {
|
||||
this.vaultConnections = null, this.goto("vault-manage");
|
||||
try {
|
||||
const e = await this.worker.vaultList();
|
||||
this.vaultConnections = e.connections;
|
||||
} catch (e) {
|
||||
this.errorMessage = e instanceof Error ? e.message : String(e), this.vaultConnections = [];
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
async vaultUnbind(e) {
|
||||
this.setBusy(!0);
|
||||
try {
|
||||
await this.worker.vaultSetBinding({ connectionId: e, origin: null }), this.busy = !1, await this.loadVaultManage();
|
||||
} catch (t) {
|
||||
this.setBusy(!1), this.fail(t);
|
||||
}
|
||||
}
|
||||
async vaultDeleteConnection(e) {
|
||||
this.setBusy(!0);
|
||||
try {
|
||||
await this.worker.vaultDelete({ connectionId: e }), this.busy = !1, await this.loadVaultManage();
|
||||
} catch (t) {
|
||||
this.setBusy(!1), this.fail(t);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* (Re)installs this element's own provider as window.nostr, taking over from whatever is
|
||||
* currently there (an extension, a different BitLogin instance, or nothing). Called
|
||||
@@ -614,6 +907,18 @@ class F extends HTMLElement {
|
||||
return !1;
|
||||
}
|
||||
goto(e) {
|
||||
if (e === "dashboard" && this.vaultRequest?.stage === "auth") {
|
||||
this.continueVaultRequest().catch((t) => this.fail(t));
|
||||
return;
|
||||
}
|
||||
if (e === "welcome" && this.vaultRequest) {
|
||||
this.finishVaultRequest(null);
|
||||
return;
|
||||
}
|
||||
if (e === "welcome" && this.vaultOffer) {
|
||||
this.finishVaultOffer("declined");
|
||||
return;
|
||||
}
|
||||
this.screen = e, this.errorMessage = void 0, this.render();
|
||||
}
|
||||
setBusy(e) {
|
||||
@@ -646,10 +951,10 @@ class F extends HTMLElement {
|
||||
* validation-failure re-render can restore it (see renderManualPasswordFields).
|
||||
*/
|
||||
readManualPassword(e) {
|
||||
const t = this.field("manualPassword"), s = this.field("manualPasswordConfirm");
|
||||
this.manualPasswordDraft = t, this.manualPasswordConfirmDraft = s;
|
||||
const r = g(t, e);
|
||||
return r.ok ? t !== s ? { password: "", error: "Passwords do not match. Please re-enter both." } : { password: t } : { password: "", error: `Password not accepted: ${r.reason}` };
|
||||
const t = this.field("manualPassword"), a = this.field("manualPasswordConfirm");
|
||||
this.manualPasswordDraft = t, this.manualPasswordConfirmDraft = a;
|
||||
const s = g(t, e);
|
||||
return s.ok ? t !== a ? { password: "", error: "Passwords do not match. Please re-enter both." } : { password: t } : { password: "", error: `Password not accepted: ${s.reason}` };
|
||||
}
|
||||
/**
|
||||
* Explicitly asks the browser to offer saving this credential via the Credential
|
||||
@@ -665,17 +970,39 @@ class F extends HTMLElement {
|
||||
*/
|
||||
async offerToSaveCredential(e, t) {
|
||||
try {
|
||||
const s = window.PasswordCredential;
|
||||
if (!s || !navigator.credentials?.store) return;
|
||||
const r = new s({ id: e, password: t, name: e });
|
||||
await navigator.credentials.store(r);
|
||||
const a = window.PasswordCredential;
|
||||
if (!a || !navigator.credentials?.store) return;
|
||||
const s = new a({ id: e, password: t, name: e });
|
||||
await navigator.credentials.store(s);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
async onClick(e) {
|
||||
if (!e.isTrusted) return;
|
||||
const t = e.target.closest("[data-action]");
|
||||
if (!t) return;
|
||||
switch (t.dataset.action) {
|
||||
case "vault-offer-save":
|
||||
return this.acceptVaultOffer();
|
||||
case "vault-offer-decline":
|
||||
this.finishVaultOffer("declined");
|
||||
return;
|
||||
case "vault-approve":
|
||||
return this.approveVaultCandidate();
|
||||
case "vault-different":
|
||||
this.vaultCandidate = null, this.goto("vault-import");
|
||||
return;
|
||||
case "vault-bc":
|
||||
return this.runVaultBcChooser();
|
||||
case "vault-cancel":
|
||||
this.finishVaultRequest(null);
|
||||
return;
|
||||
case "goto-vault-manage":
|
||||
return this.loadVaultManage();
|
||||
case "vault-unbind":
|
||||
return this.vaultUnbind(t.dataset.id);
|
||||
case "vault-delete":
|
||||
return this.vaultDeleteConnection(t.dataset.id);
|
||||
case "goto-create":
|
||||
this.loginName = "", this.importKey = "", this.importPreviewNpub = "", this.goto("create-name");
|
||||
return;
|
||||
@@ -717,16 +1044,16 @@ class F extends HTMLElement {
|
||||
this.manualPasswordMode = !this.manualPasswordMode, this.manualPasswordFeedback = null, this.manualPasswordDraft = "", this.manualPasswordConfirmDraft = "", this.savedCheckbox = !1, this.render();
|
||||
return;
|
||||
case "copy-credential": {
|
||||
const r = this.root.querySelector("#credential-box"), n = t, o = n.textContent ?? "Copy", l = (f) => {
|
||||
n.textContent = f, setTimeout(() => {
|
||||
const s = this.root.querySelector("#credential-box"), n = t, o = n.textContent ?? "Copy", l = (v) => {
|
||||
n.textContent = v, setTimeout(() => {
|
||||
n.isConnected && (n.textContent = o);
|
||||
}, 2e3);
|
||||
};
|
||||
if (!r || !navigator.clipboard?.writeText) {
|
||||
if (!s || !navigator.clipboard?.writeText) {
|
||||
l("Copy not available — select the text manually");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(r.textContent ?? "").then(
|
||||
navigator.clipboard.writeText(s.textContent ?? "").then(
|
||||
() => l("Copied"),
|
||||
() => l("Copy failed — select the text manually")
|
||||
);
|
||||
@@ -741,8 +1068,8 @@ class F extends HTMLElement {
|
||||
case "logout":
|
||||
return this.logout();
|
||||
case "rollback-retry": {
|
||||
const r = this.pendingRollback?.kind;
|
||||
this.pendingRollback = null, this.goto(r === "change-password" ? "change-password" : "login");
|
||||
const s = this.pendingRollback?.kind;
|
||||
this.pendingRollback = null, this.goto(s === "change-password" ? "change-password" : "login");
|
||||
return;
|
||||
}
|
||||
case "rollback-continue":
|
||||
@@ -752,10 +1079,10 @@ class F extends HTMLElement {
|
||||
}
|
||||
}
|
||||
async onSubmit(e) {
|
||||
e.preventDefault();
|
||||
const s = e.target.dataset.form;
|
||||
if (e.preventDefault(), !e.isTrusted) return;
|
||||
const a = e.target.dataset.form;
|
||||
try {
|
||||
switch (s) {
|
||||
switch (a) {
|
||||
case "import-key":
|
||||
return await this.handlePreviewImport();
|
||||
case "create-name":
|
||||
@@ -772,11 +1099,13 @@ class F extends HTMLElement {
|
||||
return await this.handleRecoverNewCredentialsSubmit();
|
||||
case "change-password":
|
||||
return await this.handleChangePasswordSubmit();
|
||||
case "vault-import":
|
||||
return await this.handleVaultImportSubmit();
|
||||
default:
|
||||
return;
|
||||
}
|
||||
} catch (r) {
|
||||
this.fail(r);
|
||||
} catch (s) {
|
||||
this.fail(s);
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -787,8 +1116,8 @@ class F extends HTMLElement {
|
||||
onInput(e) {
|
||||
const t = e.target;
|
||||
if (!(t instanceof HTMLInputElement) || t.name !== "manualPassword" && t.name !== "manualPasswordConfirm") return;
|
||||
const s = this.root.querySelector('input[name="manualPassword"]')?.value ?? "", r = this.root.querySelector('input[name="manualPasswordConfirm"]')?.value ?? "";
|
||||
this.manualPasswordFeedback = s ? g(s, this.loginName) : null;
|
||||
const a = this.root.querySelector('input[name="manualPassword"]')?.value ?? "", s = this.root.querySelector('input[name="manualPasswordConfirm"]')?.value ?? "";
|
||||
this.manualPasswordFeedback = a ? g(a, this.loginName) : null;
|
||||
const n = this.root.querySelector("#manual-password-feedback");
|
||||
if (n)
|
||||
if (!this.manualPasswordFeedback)
|
||||
@@ -798,7 +1127,7 @@ class F extends HTMLElement {
|
||||
this.manualPasswordFeedback.ok ? (n.textContent = `Looks good (~${l} bits estimated).`, n.className = "notice info") : (n.textContent = `${this.manualPasswordFeedback.reason} (~${l} bits estimated)`, n.className = "notice warn");
|
||||
}
|
||||
const o = this.root.querySelector("#manual-password-match-feedback");
|
||||
o && (r ? r === s ? (o.textContent = "Passwords match.", o.className = "notice info") : (o.textContent = "Passwords do not match.", o.className = "notice warn") : (o.textContent = "", o.className = "notice info"));
|
||||
o && (s ? s === a ? (o.textContent = "Passwords match.", o.className = "notice info") : (o.textContent = "Passwords do not match.", o.className = "notice warn") : (o.textContent = "", o.className = "notice info"));
|
||||
}
|
||||
/**
|
||||
* Reads and validates an optional recovery-export file (§19.5) for the recover-phrase
|
||||
@@ -808,16 +1137,16 @@ class F extends HTMLElement {
|
||||
async onFileChange(e) {
|
||||
const t = e.target;
|
||||
if (!(t instanceof HTMLInputElement) || t.name !== "offlineExportFile" || t.type !== "file") return;
|
||||
const s = t.files?.[0];
|
||||
if (!s) {
|
||||
const a = t.files?.[0];
|
||||
if (!a) {
|
||||
this.offlineExportFile = null, this.offlineExportFileNotice = void 0, this.render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await s.text();
|
||||
this.offlineExportFile = Q(JSON.parse(r)), this.offlineExportFileNotice = `Loaded recovery export from ${new Date(this.offlineExportFile.created_at * 1e3).toLocaleString()}.`;
|
||||
} catch (r) {
|
||||
this.offlineExportFile = null, this.offlineExportFileNotice = r instanceof k || r instanceof SyntaxError ? `Couldn't read that file: ${r.message}` : `Couldn't read that file: ${String(r)}`;
|
||||
const s = await a.text();
|
||||
this.offlineExportFile = x(JSON.parse(s)), this.offlineExportFileNotice = `Loaded recovery export from ${new Date(this.offlineExportFile.created_at * 1e3).toLocaleString()}.`;
|
||||
} catch (s) {
|
||||
this.offlineExportFile = null, this.offlineExportFileNotice = s instanceof C || s instanceof SyntaxError ? `Couldn't read that file: ${s.message}` : `Couldn't read that file: ${String(s)}`;
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
@@ -844,7 +1173,7 @@ class F extends HTMLElement {
|
||||
}
|
||||
handleCreateNameSubmit() {
|
||||
const e = this.field("loginName").trim().toLowerCase();
|
||||
if (!h(e)) {
|
||||
if (!p(e)) {
|
||||
this.errorMessage = "Login name must be 3-32 characters: a-z, 0-9, '.', '_', '-', and not start/end with punctuation.", this.render();
|
||||
return;
|
||||
}
|
||||
@@ -872,8 +1201,8 @@ class F extends HTMLElement {
|
||||
importKey: this.importKey || void 0
|
||||
});
|
||||
this.importKey = "", this.importPreviewNpub = "", this.recoveryPhrase = t.recoveryPhrase;
|
||||
const s = this.recoveryPhrase.split(" "), r = D(s.length, 3);
|
||||
this.confirmSlots = r.map((n) => ({ index: n, value: "" })), this.session = { publicKey: t.everydayPublicKey, npub: d(t.everydayPublicKey), accountId: t.accountId }, this.busy = !1, this.goto("confirm-phrase"), this.worker.publishProfileAndRelayLists({
|
||||
const a = this.recoveryPhrase.split(" "), s = K(a.length, 3);
|
||||
this.confirmSlots = s.map((n) => ({ index: n, value: "" })), this.session = { publicKey: t.everydayPublicKey, npub: d(t.everydayPublicKey), accountId: t.accountId }, this.busy = !1, this.goto("confirm-phrase"), this.worker.publishProfileAndRelayLists({
|
||||
name: this.loginName,
|
||||
generalRelays: this.vaultRelayUrls,
|
||||
dmRelays: this.vaultRelayUrls
|
||||
@@ -919,17 +1248,17 @@ class F extends HTMLElement {
|
||||
* paths grant a session identically -- claimSigner() and the bitlogin-login event only ever
|
||||
* fire once a RollbackDetectedError (if any) has been resolved one way or the other.
|
||||
*/
|
||||
async attemptLogin(e, t, s = !1) {
|
||||
async attemptLogin(e, t, a = !1) {
|
||||
this.setBusy(!0);
|
||||
try {
|
||||
const r = await this.worker.login({ loginName: e, password: t, acknowledgeRollback: s });
|
||||
this.loginName = e, this.session = { publicKey: r.everydayPublicKey, npub: d(r.everydayPublicKey), accountId: r.accountId }, this.sessionWarnings = [r.rollbackWarning, r.relayDisagreementWarning].filter((n) => !!n), this.busy = !1, this.noteSignerClaim(this.claimSigner()), this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: r.everydayPublicKey } })), this.flashSuccess("dashboard", "Signed in"), this.offerToSaveCredential(e, t);
|
||||
} catch (r) {
|
||||
if (r instanceof Error && r.name === "RollbackDetectedError") {
|
||||
this.pendingRollback = { kind: "login", loginName: e, password: t }, this.rollbackMessage = r.message, this.busy = !1, this.goto("rollback-confirm");
|
||||
const s = await this.worker.login({ loginName: e, password: t, acknowledgeRollback: a });
|
||||
this.loginName = e, this.session = { publicKey: s.everydayPublicKey, npub: d(s.everydayPublicKey), accountId: s.accountId }, this.sessionWarnings = [s.rollbackWarning, s.relayDisagreementWarning].filter((n) => !!n), this.busy = !1, this.noteSignerClaim(this.claimSigner()), this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: s.everydayPublicKey } })), this.flashSuccess("dashboard", "Signed in"), this.offerToSaveCredential(e, t);
|
||||
} catch (s) {
|
||||
if (s instanceof Error && s.name === "RollbackDetectedError") {
|
||||
this.pendingRollback = { kind: "login", loginName: e, password: t }, this.rollbackMessage = s.message, this.busy = !1, this.goto("rollback-confirm");
|
||||
return;
|
||||
}
|
||||
this.fail(r);
|
||||
this.fail(s);
|
||||
}
|
||||
}
|
||||
async handleRollbackContinue() {
|
||||
@@ -952,18 +1281,18 @@ class F extends HTMLElement {
|
||||
}
|
||||
async handleRecoverNewCredentialsSubmit() {
|
||||
const e = this.field("newLoginName").trim().toLowerCase();
|
||||
if (!h(e)) {
|
||||
if (!p(e)) {
|
||||
this.errorMessage = "Login name must be 3-32 characters: a-z, 0-9, '.', '_', '-', and not start/end with punctuation.", this.render();
|
||||
return;
|
||||
}
|
||||
let t = this.newCredentialAfterRecovery;
|
||||
if (this.manualPasswordMode) {
|
||||
const { password: s, error: r } = this.readManualPassword(e);
|
||||
if (r) {
|
||||
this.errorMessage = r, this.render();
|
||||
const { password: a, error: s } = this.readManualPassword(e);
|
||||
if (s) {
|
||||
this.errorMessage = s, this.render();
|
||||
return;
|
||||
}
|
||||
t = s;
|
||||
t = a;
|
||||
}
|
||||
this.setBusy(!0), await this.worker.completeRecovery({ newLoginName: e, newPassword: t }), this.loginName = e, this.busy = !1, this.sessionWarnings = [], this.noteSignerClaim(this.claimSigner()), this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } })), this.flashSuccess("dashboard", "Account recovered"), this.offerToSaveCredential(e, t);
|
||||
}
|
||||
@@ -971,36 +1300,36 @@ class F extends HTMLElement {
|
||||
const e = this.field("oldPassword");
|
||||
let t = this.changePasswordNewCredential;
|
||||
if (this.manualPasswordMode) {
|
||||
const { password: s, error: r } = this.readManualPassword(this.loginName);
|
||||
if (r) {
|
||||
this.errorMessage = r, this.render();
|
||||
const { password: a, error: s } = this.readManualPassword(this.loginName);
|
||||
if (s) {
|
||||
this.errorMessage = s, this.render();
|
||||
return;
|
||||
}
|
||||
t = s;
|
||||
t = a;
|
||||
}
|
||||
await this.attemptChangePassword(e, t);
|
||||
}
|
||||
/** Shared by the rotation form and the "continue anyway" rollback-confirmation step; see attemptLogin. */
|
||||
async attemptChangePassword(e, t, s = !1) {
|
||||
async attemptChangePassword(e, t, a = !1) {
|
||||
this.setBusy(!0);
|
||||
try {
|
||||
await this.worker.changePassword({
|
||||
loginName: this.loginName,
|
||||
oldPassword: e,
|
||||
newPassword: t,
|
||||
acknowledgeRollback: s
|
||||
acknowledgeRollback: a
|
||||
}), this.busy = !1, this.sessionWarnings = [], this.noteSignerClaim(this.claimSigner()), this.flashSuccess("dashboard", "Password updated"), this.offerToSaveCredential(this.loginName, t);
|
||||
} catch (r) {
|
||||
if (r instanceof Error && r.name === "RollbackDetectedError") {
|
||||
this.pendingRollback = { kind: "change-password", oldPassword: e, newPassword: t }, this.rollbackMessage = r.message, this.busy = !1, this.goto("rollback-confirm");
|
||||
} catch (s) {
|
||||
if (s instanceof Error && s.name === "RollbackDetectedError") {
|
||||
this.pendingRollback = { kind: "change-password", oldPassword: e, newPassword: t }, this.rollbackMessage = s.message, this.busy = !1, this.goto("rollback-confirm");
|
||||
return;
|
||||
}
|
||||
this.fail(r);
|
||||
this.fail(s);
|
||||
}
|
||||
}
|
||||
async handleDownloadRecoveryExport() {
|
||||
const e = await this.worker.buildRecoveryExport(), t = new Blob([JSON.stringify(e, null, 2)], { type: "application/json" }), s = URL.createObjectURL(t), r = document.createElement("a");
|
||||
r.href = s, r.download = "bitlogin-recovery-export.json", r.click(), URL.revokeObjectURL(s);
|
||||
const e = await this.worker.buildRecoveryExport(), t = new Blob([JSON.stringify(e, null, 2)], { type: "application/json" }), a = URL.createObjectURL(t), s = document.createElement("a");
|
||||
s.href = a, s.download = "bitlogin-recovery-export.json", s.click(), URL.revokeObjectURL(a);
|
||||
}
|
||||
async handleSignTestEvent() {
|
||||
const e = await this.worker.signEvent({ kind: 1, content: `Hello from BitLogin at ${(/* @__PURE__ */ new Date()).toISOString()}` });
|
||||
@@ -1222,15 +1551,98 @@ class F extends HTMLElement {
|
||||
return `
|
||||
<h2>Signed in</h2>
|
||||
${this.renderWarnings()}
|
||||
<p class="pubkey">${this.session?.npub ?? ""}</p>
|
||||
<p class="pubkey">${i(this.session?.npub ?? "")}</p>
|
||||
${this.renderError()}
|
||||
<button class="secondary" type="button" data-action="sign-test-event">Sign a test event</button>
|
||||
${this.lastSignedEventJson ? `<div class="credential-box" style="white-space:pre-wrap">${i(this.lastSignedEventJson)}</div>` : ""}
|
||||
<div class="divider"></div>
|
||||
<button class="secondary" type="button" data-action="goto-vault-manage">Wallet connections</button>
|
||||
<button class="secondary" type="button" data-action="goto-change-password">Rotate password</button>
|
||||
<button class="secondary" type="button" data-action="goto-export">Export identity</button>
|
||||
<button class="secondary" type="button" data-action="logout">Log out</button>
|
||||
`;
|
||||
case "vault-consent": {
|
||||
const e = this.vaultRequest, t = this.vaultCandidate;
|
||||
if (!e || !t) return '<div class="notice error">No wallet request is in progress.</div>';
|
||||
const a = new Date(t.createdAt * 1e3).toLocaleDateString();
|
||||
return `
|
||||
<h2>Share a wallet with ${i(e.appName)}?</h2>
|
||||
${e.reason ? `<p class="sub">Reason given: ${i(e.reason)}</p>` : ""}
|
||||
${this.renderError()}
|
||||
<div class="credential-box">
|
||||
<strong>${i(t.label)}</strong><br />
|
||||
Connected ${i(a)} · wallet ${i((t.walletPubkey ?? "").slice(0, 10))}…
|
||||
</div>
|
||||
<div class="notice info">${i(e.appName)} will receive this connection and can spend within the budget your wallet enforces, until you revoke it from Wallet connections.</div>
|
||||
<button class="primary" type="button" data-action="vault-approve" ${this.busy ? "disabled" : ""}>
|
||||
${this.busy ? '<span class="spinner"></span>Sharing…' : "Use this wallet"}
|
||||
</button>
|
||||
<button class="secondary" type="button" data-action="vault-different">Use a different wallet</button>
|
||||
<button class="link" type="button" data-action="vault-cancel">Cancel</button>
|
||||
`;
|
||||
}
|
||||
case "vault-import": {
|
||||
const e = this.vaultRequest;
|
||||
if (!e) return '<div class="notice error">No wallet request is in progress.</div>';
|
||||
const t = this.vaultUnsaved ? `<div class="notice warn">${this.vaultUnsavedReason === "no-vault" ? "This account predates the Connection Vault, so the connection will be handed to the app but not saved to your account. Enable the vault from your account manager (recovery phrase required) to make wallets portable." : "Sign in again to save connections to your account — until then the connection will be handed to the app but not saved."}</div>` : "";
|
||||
return `
|
||||
<h2>Connect a wallet for ${i(e.appName)}</h2>
|
||||
${e.reason ? `<p class="sub">Reason given: ${i(e.reason)}</p>` : ""}
|
||||
${t}
|
||||
${this.renderError()}
|
||||
<button class="primary" type="button" data-action="vault-bc" ${this.busy ? "disabled" : ""}>
|
||||
${this.busy ? '<span class="spinner"></span>Waiting for your wallet…' : "Connect a wallet"}
|
||||
</button>
|
||||
<div class="divider"></div>
|
||||
<form data-form="vault-import">
|
||||
<label for="vaultUri">Or paste an NWC connection</label>
|
||||
<input type="password" name="vaultUri" id="vaultUri" autocomplete="off" placeholder="nostr+walletconnect://…" />
|
||||
<label for="vaultLabel">Name this connection</label>
|
||||
<input type="text" name="vaultLabel" id="vaultLabel" autocomplete="off" maxlength="120" value="${i(`${e.appName} wallet`)}" />
|
||||
<button class="secondary" type="submit" ${this.busy ? "disabled" : ""}>
|
||||
${this.vaultUnsaved ? "Share without saving" : "Save and share"}
|
||||
</button>
|
||||
</form>
|
||||
<div class="notice info">Set a spending budget on the wallet's own authorization page — the wallet is the only place a budget is actually enforced.</div>
|
||||
<button class="link" type="button" data-action="vault-cancel">Cancel</button>
|
||||
`;
|
||||
}
|
||||
case "vault-offer": {
|
||||
const e = this.vaultOffer;
|
||||
return e ? `
|
||||
<h2>Save this wallet to your BitLogin?</h2>
|
||||
<p class="sub">${i(e.appName)} just connected a wallet on this device. Saved to your BitLogin, the connection follows you — on a new device it's one tap instead of another paste.</p>
|
||||
${this.renderError()}
|
||||
<label for="vaultOfferLabel">Name this connection</label>
|
||||
<input type="text" name="vaultOfferLabel" id="vaultOfferLabel" autocomplete="off" maxlength="120" value="${i(e.label)}" />
|
||||
<button class="primary" type="button" data-action="vault-offer-save" ${this.busy ? "disabled" : ""}>
|
||||
${this.busy ? '<span class="spinner"></span>Saving…' : "Save to BitLogin"}
|
||||
</button>
|
||||
<button class="link" type="button" data-action="vault-offer-decline" ${this.busy ? "disabled" : ""}>No thanks — keep it on this device only</button>
|
||||
<div class="notice info">Stored encrypted on your account; ${i(e.appName)} keeps working either way. Remove it any time from Wallet connections.</div>
|
||||
` : '<div class="notice error">No wallet offer is in progress.</div>';
|
||||
}
|
||||
case "vault-manage": {
|
||||
const e = this.vaultConnections, t = e === null ? '<p class="sub">Loading…</p>' : e.length === 0 ? `<p class="sub">No connections stored yet. They're added when you connect a wallet inside an app.</p>` : e.map(
|
||||
(a) => `
|
||||
<div class="credential-box">
|
||||
<strong>${i(a.label)}</strong> <span class="sub">(${i(a.connectionType)})</span><br />
|
||||
${a.origin ? `Linked to ${i(a.origin)}` : "Not linked to an app"}
|
||||
<div>
|
||||
${a.origin ? `<button class="link" type="button" data-action="vault-unbind" data-id="${i(a.connectionId)}" ${this.busy ? "disabled" : ""}>Revoke app access</button>` : ""}
|
||||
<button class="link" type="button" data-action="vault-delete" data-id="${i(a.connectionId)}" ${this.busy ? "disabled" : ""}>Remove from BitLogin</button>
|
||||
</div>
|
||||
</div>`
|
||||
).join("");
|
||||
return `
|
||||
<h2>Wallet connections</h2>
|
||||
<p class="sub">Connections your apps use, stored encrypted on your account and restored on any device you sign in to.</p>
|
||||
${this.renderError()}
|
||||
${t}
|
||||
<div class="notice info">"Remove from BitLogin" deletes the stored copy only — the connection itself keeps working for any app that already has it. To revoke spending authority, delete the connection inside your wallet app.</div>
|
||||
<button class="link" data-action="goto-dashboard">Back</button>
|
||||
`;
|
||||
}
|
||||
case "rollback-confirm":
|
||||
return `
|
||||
<h2>This looks like a stale or revoked credential</h2>
|
||||
@@ -1278,30 +1690,30 @@ class F extends HTMLElement {
|
||||
}
|
||||
}
|
||||
}
|
||||
function D(a, e) {
|
||||
const t = Array.from({ length: a }, (r, n) => n), s = [];
|
||||
for (let r = 0; r < e && t.length > 0; r++) {
|
||||
function K(r, e) {
|
||||
const t = Array.from({ length: r }, (s, n) => n), a = [];
|
||||
for (let s = 0; s < e && t.length > 0; s++) {
|
||||
const n = Math.floor(Math.random() * t.length);
|
||||
s.push(t.splice(n, 1)[0]);
|
||||
a.push(t.splice(n, 1)[0]);
|
||||
}
|
||||
return s.sort((r, n) => r - n);
|
||||
return a.sort((s, n) => s - n);
|
||||
}
|
||||
function i(a) {
|
||||
return a.replace(/[&<>"']/gu, (e) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[e]);
|
||||
function i(r) {
|
||||
return r.replace(/[&<>"']/gu, (e) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[e]);
|
||||
}
|
||||
customElements.get("bitlogin-auth") || customElements.define("bitlogin-auth", F);
|
||||
customElements.get("bitlogin-auth") || customElements.define("bitlogin-auth", D);
|
||||
window.bitlogin = {
|
||||
version: "0.1.0",
|
||||
isActiveSigner() {
|
||||
return window.nostr?._bitlogin === !0;
|
||||
},
|
||||
releaseSigner() {
|
||||
const a = window;
|
||||
return a.nostr?._bitlogin === !0 ? (delete a.nostr, window.dispatchEvent(new CustomEvent("bitlogin-signer-released")), !0) : !1;
|
||||
const r = window;
|
||||
return r.nostr?._bitlogin === !0 ? (delete r.nostr, window.dispatchEvent(new CustomEvent("bitlogin-signer-released")), !0) : !1;
|
||||
}
|
||||
};
|
||||
export {
|
||||
F as BitLoginAuthElement,
|
||||
E as WorkerClient,
|
||||
$ as createNip07Provider
|
||||
D as BitLoginAuthElement,
|
||||
N as WorkerClient,
|
||||
B as createNip07Provider
|
||||
};
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user