Merge branch 'unstable' into perf/bound-comment-concurrency-6703856395977422565

This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-12 09:13:51 -05:00
commit 45ab69ef78
27 files changed
+300 -92

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@
"forwardPorts": [
3000
],
"postCreateCommand": "npm ci && npm run build && npx playwright install",
"postCreateCommand": "npm ci && npm run build && npx playwright install --with-deps",
"customizations": {
"vscode": {
"extensions": [
+26
View File
@@ -0,0 +1,26 @@
# Weekly Audit Report
**Branch:** `unstable`
**Date:** 2024-02-12
## Metrics
### 1. File Size Audit
- **Grandfathered oversized files:** 44
- **Sum of excess lines:** 50,025 (lines exceeding the 1000-line threshold)
- **Status:** **PASS** (No new violations beyond grandfathered list)
### 2. InnerHTML Audit
- **Total innerHTML assignments:** 99
- **Status:** **PASS** (No new assignments found)
### 3. Lint Status
- **Result:** **PASS**
- **Notes:** All checks passed. `lint:assets` and `lint:sw-compat` were skipped due to the environment (missing build artifacts/git history), which is expected.
## Comparison
- **Previous Week:** No previous report was found in the PR comments or issues for comparison.
## Recommendations
- Continue decomposition efforts for the largest grandfathered files (e.g., `js/ui/profileModalController.js`, `js/ui/components/VideoModal.js`).
- Maintain strict checks on innerHTML usage to prevent new security risks.
+1
View File
@@ -170,6 +170,7 @@ To set up the project locally:
- **Format**: Targets CSS, HTML, Markdown, and config files. (Note: JavaScript files are not currently auto-formatted by this command).
- **Lint**: Checks for CSS errors, hex color usage, inline styles, design tokens, and Tailwind guards. (Note: There is no ESLint configuration for JavaScript logic; this step focuses on style and design system guards).
- **Note**: `npm run lint` includes an asset verification step (`lint:assets`) which checks the `dist/` directory. For full coverage, run `npm run build` before linting.
6. **Git Hooks (Optional)**:
We provide a script to set up a git pre-commit hook that runs linting and CSS builds automatically before you commit.
+2 -1
View File
@@ -4,7 +4,7 @@
|------------|-------------|----------------|--------|-------|
| **NIP-01** | Basic Protocol Flow | `js/nostr/client.js`, `docs/nips/01.md` | Compliant | Base implementation verified via `client.js`. |
| **NIP-04** | Encrypted Direct Message | `js/nostr/dmDecryptWorker.js`, `js/nostr/client.js` | Compliant | Fallback verified in `nip04_44_compliance.test.mjs`. |
| **NIP-07** | `window.nostr` Capability | `js/nostr/nip07Permissions.js`, `js/nostr/adapters/nip07Adapter.js` | Compliant | Reviewed `nip07Permissions.js` and `nip07Adapter.js`. Handles async loading and timeouts correctly. |
| **NIP-07** | `window.nostr` Capability | `js/nostr/nip07Permissions.js`, `js/nostr/adapters/nip07Adapter.js` | Compliant | Retry logic and timeouts verified in `nip07_compliance.test.mjs`. |
| **NIP-09** | Event Deletion | `js/nostr/client.js`, `docs/nips/09.md` | Partial | `deleteAllVersions` implements Kind 5 logic. |
| **NIP-10** | Text Notes & Threads | `js/nostr/commentEvents.js`, `docs/nips/10.md` | Compliant | `buildCommentEvent` implements NIP-10 threading markers (E, P, K tags). |
| **NIP-17** | Private Direct Messages | `js/nostr/client.js`, `js/ui/profileModal/ProfileDirectMessageRenderer.js` | Compliant | Explicit support for Gift Wraps (Kind 1059) and Seals (Kind 13). |
@@ -29,3 +29,4 @@
| **Kind 30000** | Subscription List | `js/subscriptions.js` | Compliant | Implements NIP-51 follow sets. |
| **Kind 30015** | Hashtag Preferences | `js/services/hashtagPreferencesService.js` | Compliant | Implemented. |
| **Kind 30078** | App Data | `js/nostr/client.js`, `js/nostr/videoPayloadBuilder.js` | Compliant | Core video note kind. Verified structure. |
| **Kind 30079** | Watch History | `js/nostr/watchHistory.js`, `config/instance-config.js` | Compliant | Parameterized replaceable list (custom kind). Implements bucketing and encryption. |
+20 -17
View File
@@ -1,29 +1,32 @@
# Developer Onboarding Audit - 2025-02-18
# Developer Onboarding Audit
This document summarizes the findings from the developer onboarding audit.
**Date:** 2026-02-12
**Environment:** Linux (x64) | Node.js v22
## Steps Executed
1. `npm ci`: Successful.
2. `npm run build:css`: Successful.
3. `npm run format`: Successful.
## Environment
- **Node.js Version:** v22.22.0
- **Dependencies:** Installed successfully via `npm ci`.
- **CSS Build:** Tailwind CSS bundle generated successfully.
4. `npm run build`: Successful (required for full lint coverage).
5. `npm run lint`: Successful.
- `lint:assets` skipped initially when `dist/` was missing, then passed after `npm run build`.
- `lint:sw-compat` skipped due to missing git history (expected in shallow clones).
6. `npm run test:unit`: Successful (13 tests passed).
## Findings
- **Setup Reliability:** The onboarding steps are robust and reliable. No errors were encountered.
- **Documentation:** `CONTRIBUTING.md` and `README.md` provide clear instructions.
- **Dev Container:** The project includes a valid `.devcontainer/devcontainer.json` which uses `mcr.microsoft.com/devcontainers/javascript-node:22`. This is recommended for a consistent development environment.
- **Node Version:** While `package.json` does not explicitly enforce a Node.js version, the `.devcontainer` configuration implies a dependency on Node 22. Adding an `engines` field to `package.json` would improve environment consistency.
- **Build Commands:** The README suggests `npm run build:css` for CSS verification, which works as expected. For full builds, `npm run build` or `npm start` should be used.
- **Warnings:** A warning about `browserslist` (`caniuse-lite is outdated`) was observed during `npm run build:css`. This is documented in `CONTRIBUTING.md` under Troubleshooting.
- **Reliability:** The onboarding steps are robust. Dependencies installed correctly, and build/test scripts executed without error.
- **Documentation:**
- `README.md` correctly lists `npm ci`, `npm start` (which builds), and `npm run test:unit`. It was missing `npm run lint` in the "Verify setup" checklist.
- `CONTRIBUTING.md` correctly lists `npm run format` and `npm run lint`. It did not explicitly mention that `npm run build` is a prerequisite for `lint:assets` (though the lint script handles the missing build gracefully).
- **Dev Container:** The `.devcontainer/devcontainer.json` uses `mcr.microsoft.com/devcontainers/javascript-node:22` which aligns with the project's requirement. However, `postCreateCommand` uses `npx playwright install` without `--with-deps`, which might fail if system dependencies are missing in the base image.
- **Node Version:** `package.json` correctly enforces `"engines": { "node": ">=22" }`.
## Recommendations implemented
## Recommendations Implemented
- Added `"engines": { "node": ">=22" }` to `package.json` to align with `.devcontainer` and ensure environment consistency.
- Updated `CONTRIBUTING.md` to explicitly mention the Node.js version requirement and clarify build commands.
1. **Documentation Updates:**
- Added `npm run lint` to `README.md` verification steps.
- Clarified build prerequisites for linting in `CONTRIBUTING.md`.
2. **Dev Container Robustness:**
- Updated `.devcontainer/devcontainer.json` to use `npx playwright install --with-deps`.
+1
View File
@@ -178,6 +178,7 @@ To run **bitvid** locally:
```bash
npm run test:unit
npm run format
npm run lint
```
#### Quick Reference
+1 -6
View File
@@ -1,7 +1,2 @@
js/ui/profileModalContract.js:146: "Formats npub strings for display using the canonical short representation (npubXXXX…XXXX).",
tests/hashtag-preferences.test.mjs:303: // TODO: This part of the test is flaky in CI environments.
tests/visual/baselines.json:2: "contrast": "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 truncated
tests/visual/baselines.json:3: "default": "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 truncated
tests/visual/baselines.json:4: "light": "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 truncated
tests/visual/baselines.json:5: "video-modal-mobile": "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 truncated
tests/visual/moderation.spec.ts:214: // TODO: Visibility check fails in headless environment due to layout issues (0x0 size).
tests/hashtag-preferences.test.mjs:304: // TODO: This part of the test is flaky in CI environments.
+6 -5
View File
@@ -7,7 +7,7 @@ bitvid is a decentralized platform that supports multiple ways to share video co
bitvid offers three ways to publish content:
1. **Direct Upload:** Upload a video file from your device to your configured storage bucket (R2 or S3). The client handles the upload, generates a magnet link for WebTorrent, and publishes the metadata to Nostr.
2. **External Link:** Provide a direct URL to a video file hosted elsewhere (e.g., on a personal server or another CDN). This URL serves as the primary playback source. **Note:** External URLs must use **HTTPS**.
2. **External Link:** Provide a direct URL to a video file hosted elsewhere (e.g., on a personal server or another CDN). This URL serves as a playback source (and usually the primary one, depending on instance settings). **Note:** External URLs must use **HTTPS**.
3. **Magnet Link:** Assist the network by providing a magnet link for an existing torrent. Note that magnet-only uploads require at least one active seeder to be playable.
## Supported Media & Limits
@@ -24,7 +24,7 @@ Before you start, ensure your content meets the following requirements:
### File Size
- **Recommended:** Up to **2GB** per file.
- **Why?** Browser-based uploads rely on your device's memory for hashing (to generate the WebTorrent info hash). Files larger than 2GB may cause browser instability or crashes depending on your available RAM.
- **Why?** Browser-based uploads rely on your device's memory for hashing (to generate the WebTorrent info hash). Files larger than 2GB may cause browser instability or crashes during the hashing process, which happens entirely in your browser's memory. This is a client-side limitation, not a server limit.
## Metadata & Options
@@ -50,7 +50,7 @@ For power users and technical configurations, toggle the "Advanced Options" sect
- **Content Warning:** A text label for sensitive content (automatically set to "NSFW" if the NSFW toggle is on).
- **Duration:** Manually specify the video duration in seconds.
- **Summary:** A short summary separate from the full description.
- **IMETA (Video Variants):** Define alternative video sources, resolutions, or MIME types.
- **IMETA (Video Variants):** Define alternative video sources, resolutions, or MIME types. You can add multiple variants if you have different resolutions or formats available.
- **MIME:** (e.g., `video/mp4`)
- **Dimensions:** (e.g., `1920x1080`)
- **URL:** Direct link to the video file
@@ -148,8 +148,9 @@ bitvid will verify your credentials by attempting to list or upload a test file.
### How Uploads Work
1. **Direct Upload:** Your browser uploads the file directly to your storage bucket. No video data passes through a bitvid server.
2. **Client-Side Hashing:** Your browser calculates a cryptographic hash (info hash) of the file locally to enable WebTorrent support. This happens in memory, so large files require sufficient RAM.
3. **Publication:** The video metadata (title, URL, hash, tags) is signed by your Nostr key and published to relays.
2. **External Link:** Your browser streams directly from the provided URL (or via WebTorrent if a magnet is also provided). No video data passes through the bitvid server.
3. **Client-Side Hashing:** Your browser calculates a cryptographic hash (info hash) of the file locally to enable WebTorrent support. This happens in memory, so large files require sufficient RAM.
4. **Publication:** The video metadata (title, URL, hash, tags) is signed by your Nostr key and published to relays.
### Moderation & Visibility
+2 -2
View File
@@ -9,7 +9,7 @@
* Methods use `this` which is bound to the Application instance.
*/
import { FEED_TYPES, STANDARD_TIMEOUT_MS, MEDIUM_TIMEOUT_MS } from "../constants.js";
import { FEED_TYPES, STANDARD_TIMEOUT_MS, MEDIUM_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js";
/**
* @param {object} deps - Injected dependencies.
@@ -1056,7 +1056,7 @@ export function createFeedCoordinator(deps) {
}
const now = Date.now();
if (this.lastLoadVideosTime && (now - this.lastLoadVideosTime < 2000) && !forceFetch) {
if (this.lastLoadVideosTime && (now - this.lastLoadVideosTime < DEBOUNCE_DELAY_MS) && !forceFetch) {
devLogger.log("Skipping redundant loadVideos request (cooldown).");
return Promise.resolve();
}
+4 -2
View File
@@ -8,6 +8,8 @@
*
* Methods use `this` which is bound to the Application instance.
*/
import { SHORT_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js";
export function createUiCoordinator(deps) {
const {
devLogger,
@@ -286,7 +288,7 @@ export function createUiCoordinator(deps) {
if (
this.lastExperimentalWarningKey === normalizedProvider &&
typeof this.lastExperimentalWarningAt === "number" &&
now - this.lastExperimentalWarningAt < 2000
now - this.lastExperimentalWarningAt < DEBOUNCE_DELAY_MS
) {
return;
}
@@ -301,7 +303,7 @@ export function createUiCoordinator(deps) {
this.showStatus(
`${providerLabel} logins are still in development and may not work well yet. We recommend using a NIP-07 browser extension for the most reliable experience.`,
{ showSpinner: false, autoHideMs: 5000 },
{ showSpinner: false, autoHideMs: SHORT_TIMEOUT_MS },
);
}
};
+4
View File
@@ -24,6 +24,10 @@ export const STANDARD_TIMEOUT_MS = 10000;
export const MEDIUM_TIMEOUT_MS = 30000;
export const LONG_TIMEOUT_MS = 60000;
export const UI_FEEDBACK_DELAY_MS = 2000;
export const DEBOUNCE_DELAY_MS = 2000;
export const NETWORK_RETRY_DELAY_MS = 1500;
export const DEFAULT_CACHE_TTL_MS = FIVE_MINUTES_MS;
export const PROFILE_CACHE_TTL_MS = TEN_MINUTES_MS;
export const URL_HEALTH_TTL_MS = FORTY_FIVE_MINUTES_MS;
+51 -31
View File
@@ -54,9 +54,17 @@ const extractRelayHints = (event) => {
);
};
// Helper to create message elements for empty/loading/error states
function createMessageElement(text, className) {
const p = document.createElement('p');
p.className = className || "text-sm text-muted col-span-full";
p.textContent = text;
return p;
}
function renderProfileCards(profiles, container) {
if (!container) return;
container.innerHTML = "";
container.replaceChildren();
// Reset the "Show More" button and container limit state
const showMoreContainer = document.getElementById("searchChannelShowMore");
@@ -66,7 +74,7 @@ function renderProfileCards(profiles, container) {
container.classList.remove("channel-grid-limit");
if (!profiles || profiles.length === 0) {
container.innerHTML = `<p class="text-sm text-muted col-span-full">No matching channels found.</p>`;
container.appendChild(createMessageElement("No matching channels found."));
return;
}
@@ -93,7 +101,7 @@ function renderProfileCards(profiles, container) {
const metadata = profile.metadata || {};
const name = metadata.name || metadata.display_name || "Unknown User";
const picture = sanitizeProfileMediaUrl(metadata.picture) || FALLBACK_AVATAR;
const about = metadata.about || "";
// const about = metadata.about || ""; // Unused in original code
let npub = "";
try {
@@ -110,25 +118,37 @@ function renderProfileCards(profiles, container) {
card.href = `#view=channel-profile&npub=${npub || pubkey}`;
card.className = "flex items-center gap-3 p-3 rounded-lg bg-surface hover:bg-surface-elevated transition-colors border border-border group";
card.innerHTML = `
<div class="relative w-12 h-12 flex-shrink-0 rounded-full overflow-hidden bg-background">
<img
src="${escapeHTML(picture)}"
alt="${escapeHTML(name)}"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='${FALLBACK_AVATAR}'"
/>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-text-strong truncate group-hover:text-primary transition-colors">
${escapeHTML(name)}
</h3>
<p class="text-xs text-muted truncate">
${escapeHTML(shortNpub)}
</p>
</div>
`;
// Create Image Container
const imgContainer = document.createElement("div");
imgContainer.className = "relative w-12 h-12 flex-shrink-0 rounded-full overflow-hidden bg-background";
const img = document.createElement("img");
img.src = picture;
img.alt = name;
img.className = "w-full h-full object-cover";
img.loading = "lazy";
img.addEventListener("error", (e) => {
e.target.src = FALLBACK_AVATAR;
});
imgContainer.appendChild(img);
card.appendChild(imgContainer);
// Create Text Container
const textContainer = document.createElement("div");
textContainer.className = "flex-1 min-w-0";
const nameEl = document.createElement("h3");
nameEl.className = "text-sm font-semibold text-text-strong truncate group-hover:text-primary transition-colors";
nameEl.textContent = name;
const npubEl = document.createElement("p");
npubEl.className = "text-xs text-muted truncate";
npubEl.textContent = shortNpub;
textContainer.appendChild(nameEl);
textContainer.appendChild(npubEl);
card.appendChild(textContainer);
fragment.appendChild(card);
}
@@ -164,15 +184,15 @@ export async function initSearchView() {
const videoList = document.getElementById("searchVideoList");
if (channelList) {
channelList.innerHTML = `<p class="text-sm text-muted animate-pulse col-span-full">Searching channels...</p>`;
channelList.replaceChildren(createMessageElement("Searching channels...", "text-sm text-muted animate-pulse col-span-full"));
}
if (videoList) {
videoList.innerHTML = `<p class="text-sm text-muted animate-pulse col-span-full">Searching videos...</p>`;
videoList.replaceChildren(createMessageElement("Searching videos...", "text-sm text-muted animate-pulse col-span-full"));
}
if (!query) {
if (channelList) channelList.innerHTML = `<p class="text-sm text-muted col-span-full">Please enter a search term.</p>`;
if (videoList) videoList.innerHTML = `<p class="text-sm text-muted col-span-full">Please enter a search term.</p>`;
if (channelList) channelList.replaceChildren(createMessageElement("Please enter a search term."));
if (videoList) videoList.replaceChildren(createMessageElement("Please enter a search term."));
return;
}
@@ -185,7 +205,7 @@ export async function initSearchView() {
}).catch(err => {
devLogger.warn("[Search] Profile search failed", err);
if (channelList && searchToken === currentSearchToken) {
channelList.innerHTML = `<p class="text-sm text-critical col-span-full">Failed to load channels.</p>`;
channelList.replaceChildren(createMessageElement("Failed to load channels.", "text-sm text-critical col-span-full"));
}
});
@@ -197,7 +217,7 @@ export async function initSearchView() {
}).catch(err => {
devLogger.warn("[Search] Video search failed", err);
if (videoList && searchToken === currentSearchToken) {
videoList.innerHTML = `<p class="text-sm text-critical col-span-full">Failed to load videos.</p>`;
videoList.replaceChildren(createMessageElement("Failed to load videos.", "text-sm text-critical col-span-full"));
}
});
}
@@ -255,7 +275,7 @@ function renderActiveFilters(filters) {
return;
}
list.innerHTML = "";
list.replaceChildren();
const pills = [];
filters.authorPubkeys?.forEach((author) => {
@@ -413,10 +433,10 @@ function renderActiveFilters(filters) {
async function renderSearchVideos(videos, container, app) {
if (!container) return;
container.innerHTML = "";
container.replaceChildren();
if (!videos || videos.length === 0) {
container.innerHTML = `<p class="text-sm text-muted col-span-full">No matching videos found.</p>`;
container.appendChild(createMessageElement("No matching videos found."));
return;
}
+2 -1
View File
@@ -7,6 +7,7 @@ import {
import { sanitizeRelayList } from "../nostr/nip46Client.js";
import { publishEventToRelay } from "../nostrPublish.js";
import logger from "../utils/logger.js";
import { SHORT_TIMEOUT_MS } from "../constants.js";
const DEFAULT_BACKOFF = {
baseMs: 1000,
@@ -15,7 +16,7 @@ const DEFAULT_BACKOFF = {
};
const MAX_SEEN_EVENT_IDS = 5000;
const DM_RELAY_LIST_TIMEOUT_MS = 5000;
const DM_RELAY_LIST_TIMEOUT_MS = SHORT_TIMEOUT_MS;
export const DM_RELAY_WARNING_FALLBACK = "dm-relays-fallback";
function normalizeRelayList(relays) {
+3 -2
View File
@@ -31,6 +31,7 @@ import {
setLastSuccessfulScheme,
} from "../nostr/decryptionSchemeCache.js";
import { HEX64_REGEX } from "../utils/hex.js";
import { SHORT_TIMEOUT_MS, NETWORK_RETRY_DELAY_MS } from "../constants.js";
const LOG_PREFIX = "[HashtagPreferences]";
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
@@ -43,7 +44,7 @@ const DEFAULT_VERSION = 1;
const DECRYPT_TIMEOUT_MS = 6000;
// PERF: Reduced from 3s to 1.5s — extensions that already granted permission
// should recover near-instantly. Shorter delay speeds up the login path.
const DECRYPT_RETRY_DELAY_MS = 1500;
const DECRYPT_RETRY_DELAY_MS = NETWORK_RETRY_DELAY_MS;
class TinyEventEmitter {
constructor() {
@@ -1052,7 +1053,7 @@ class HashtagPreferencesService {
// gate guarantees the extension is ready before decryption starts, so
// decrypt calls should complete within 1-2s. 5s accommodates slow
// extensions while still failing fast for scheme fallback.
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 12000 : 5000;
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 12000 : SHORT_TIMEOUT_MS;
const signerDecryptOptions = {
priority: NIP07_PRIORITY.NORMAL,
timeoutMs: nip07DecryptTimeoutMs,
+2 -1
View File
@@ -8,6 +8,7 @@ import { accessControl } from "../accessControl.js";
import { userBlocks, USER_BLOCK_EVENTS } from "../userBlocks.js";
import { buildReportEvent, KIND_MUTE_LIST } from "../nostrEventSchemas.js";
import logger from "../utils/logger.js";
import { DEBOUNCE_DELAY_MS } from "../constants.js";
/**
* Trust threshold for disabling autoplay on reported videos.
@@ -1135,7 +1136,7 @@ export class ModerationService {
this.refreshTrustedMuteSubscriptions().catch((error) => {
this.log("[moderationService] refreshTrustedMuteSubscriptions failed", error);
});
}, 2000);
}, DEBOUNCE_DELAY_MS);
}
async refreshTrustedMuteSubscriptions() {
+2 -1
View File
@@ -23,6 +23,7 @@ import {
getActiveSigner,
requestDefaultExtensionPermissions,
} from "../../nostrClientFacade.js";
import { UI_FEEDBACK_DELAY_MS } from "../../constants.js";
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
@@ -790,7 +791,7 @@ export class UploadModal {
if (this.thumbnailUploadId === currentUploadId) {
this.sourceSections.thumbnailProgress.classList.add("hidden");
}
}, 2000);
}, UI_FEEDBACK_DELAY_MS);
} catch (err) {
if (this.thumbnailUploadId !== currentUploadId) return;
+2 -1
View File
@@ -47,6 +47,7 @@ import {
subscribeToLinkPreviewSettings,
} from "../../utils/linkPreviewSettings.js";
import { HEX64_REGEX } from "../../utils/hex.js";
import { UI_FEEDBACK_DELAY_MS } from "../../constants.js";
const SIMILAR_CONTENT_LIMIT = 10;
@@ -1534,7 +1535,7 @@ export class VideoModal {
if (button.dataset.feedbackTimeoutId) {
delete button.dataset.feedbackTimeoutId;
}
}, 2000);
}, UI_FEEDBACK_DELAY_MS);
button.dataset.feedbackTimeoutId = String(nextId);
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { userLogger as _fallbackUserLogger, devLogger as _fallbackDevLogger } from "../utils/logger.js";
import { SHORT_TIMEOUT_MS } from "../constants.js";
export default class NotificationController {
constructor({
@@ -98,7 +99,7 @@ export default class NotificationController {
container.textContent = "";
container.classList.add("hidden");
this.updateNotificationPortalVisibility();
}, 5000);
}, SHORT_TIMEOUT_MS);
}
}
@@ -236,7 +237,7 @@ export default class NotificationController {
container.textContent = "";
container.classList.add("hidden");
this.updateNotificationPortalVisibility();
}, 5000);
}, SHORT_TIMEOUT_MS);
}
}
@@ -3,6 +3,7 @@ import { extractAttachmentsFromMessage, formatAttachmentSize, describeAttachment
import { downloadAttachment, clearAttachmentCache, getAttachmentCacheStats } from "../../services/attachmentService.js";
import { getLinkPreviewSettings, setLinkPreviewAutoFetch } from "../../utils/linkPreviewSettings.js";
import { sanitizeRelayList } from "../../nostr/nip46Client.js";
import { SHORT_TIMEOUT_MS } from "../../constants.js";
const TYPING_INDICATOR_TTL_SECONDS = 15;
const TYPING_INDICATOR_COOLDOWN_MS = 4000;
@@ -118,7 +119,7 @@ export class ProfileDirectMessageActions {
if (this.controller.enableNip17RelayWarning && enabled && !relayHints.length) {
this.mainController.showStatus(
"Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.",
{ autoHideMs: 5000 },
{ autoHideMs: SHORT_TIMEOUT_MS },
);
}
@@ -207,7 +208,7 @@ export class ProfileDirectMessageActions {
if (this.controller.enableNip17RelayWarning && useNip17 && !recipientRelayHints.length) {
this.mainController.showStatus(
"Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.",
{ autoHideMs: 5000 },
{ autoHideMs: SHORT_TIMEOUT_MS },
);
}
@@ -263,7 +264,7 @@ export class ProfileDirectMessageActions {
if (this.controller.enableNip17RelayWarning && result?.warning === "dm-relays-fallback") {
this.mainController.showStatus(
"Privacy warning: this message used default relays because no NIP-17 relay list was found.",
{ autoHideMs: 5000 },
{ autoHideMs: SHORT_TIMEOUT_MS },
);
}
void this.populateProfileMessages({ force: true, reason: "send-message" });
@@ -494,7 +495,7 @@ export class ProfileDirectMessageActions {
if (useNip17 && !recipientRelayHints.length) {
this.mainController.showStatus(
"Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.",
{ autoHideMs: 5000 },
{ autoHideMs: SHORT_TIMEOUT_MS },
);
}
@@ -523,7 +524,7 @@ export class ProfileDirectMessageActions {
if (result?.warning === "dm-relays-fallback") {
this.mainController.showStatus(
"Privacy warning: this message used default relays because no NIP-17 relay list was found.",
{ autoHideMs: 5000 },
{ autoHideMs: SHORT_TIMEOUT_MS },
);
}
if (
@@ -804,7 +805,7 @@ export class ProfileDirectMessageActions {
this.mainController.showStatus(
"Privacy warning: direct messages are using your default relays because no NIP-17 relay list is available.",
{ autoHideMs: 5000 },
{ autoHideMs: SHORT_TIMEOUT_MS },
);
}
+4 -2
View File
@@ -1,3 +1,5 @@
import { SHORT_TIMEOUT_MS } from "../constants.js";
export default class UrlHealthController {
constructor({
state = {},
@@ -249,7 +251,7 @@ export default class UrlHealthController {
}
async probeUrlWithVideoElement(url, timeoutMs) {
const defaultTimeout = this.constants.URL_PROBE_TIMEOUT_MS || 5000;
const defaultTimeout = this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS;
const effectiveTimeout =
Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : defaultTimeout;
@@ -346,7 +348,7 @@ export default class UrlHealthController {
}
const confirmPlayable = options?.confirmPlayable === true;
const defaultTimeout = this.constants.URL_PROBE_TIMEOUT_MS || 5000;
const defaultTimeout = this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS;
const urlHealthConstants = this.constants.urlHealthConstants || {};
const confirmWithVideoElement = async () => {
+27
View File
@@ -0,0 +1,27 @@
# NIP Compliance Report - 2026-02-12
## Summary
Completed a rigorous audit of NIP compliance for `bitvid` (unstable). Verified P0 items (Auth, Encryption, Relay Lists, Video Notes, Watch History) via automated unit tests.
## Findings
### P0 Items (Critical)
| Item | NIPs | Status | Verification |
|------|------|--------|--------------|
| **Auth** | NIP-07 | **Compliant** | **New Test:** `tests/compliance/nip07_compliance.test.mjs` verifies retry logic and timeout handling in `runNip07WithRetry`. |
| **Encryption** | NIP-04, NIP-44 | **Compliant** | Verified `createNip46Cipher` prefers NIP-44 v2 and falls back to NIP-04. Verified decryption handles both formats. |
| **Relay Lists** | NIP-65 (Kind 10002) | **Compliant** | Verified `relayManager.loadRelayList` requests Kind 10002 and parses `read`/`write` markers correctly. |
| **Video Notes** | Kind 30078, NIP-71 | **Compliant** | Verified `prepareVideoPublishPayload` constructs Kind 30078 with proper `d` tag and embeds NIP-71 tags (`title`, `t`ags, etc). |
| **Watch History**| Kind 30079 | **Compliant** | Uses custom Kind 30079 (parameterized replaceable). Implements robust bucketing and NIP-44/04 encryption fallback. |
### Other Findings
- **NIP-51/56**: Block lists (Kind 10000/30002) and Reporting (Kind 1984) logic is implemented correctly in `userBlocks.js` and `moderationService.js`.
- **NIP-10**: Threading markers are correctly handled in `commentEvents.js`.
## Remediation
- Created `tests/compliance/nip07_compliance.test.mjs` to close the testing gap for authentication resilience.
- All targeted NIPs are currently **Compliant**.
## Next Steps
- Maintain `tests/compliance/` suite as part of CI if possible.
- Monitor NIP-21 (`nostr:` URI) usage if it becomes critical.
-1
View File
@@ -31,7 +31,6 @@ const BASELINE = {
"js/historyView.js": 4,
"js/index.js": 1,
"js/kidsView.js": 1,
"js/searchView.js": 12,
"js/subscriptions.js": 7,
"js/ui/components/DeleteModal.js": 3,
"js/ui/components/EditModal.js": 1,
+48
View File
@@ -0,0 +1,48 @@
from playwright.sync_api import sync_playwright
import os
def run(playwright):
browser = playwright.chromium.launch(headless=True)
context = browser.new_context()
# Inject localStorage to bypass disclaimer
page = context.new_page()
page.add_init_script("""
localStorage.setItem("hasSeenDisclaimer", "true");
""")
# Capture console logs
page.on("console", lambda msg: print(f"Browser console: {msg.text}"))
try:
# Navigate to the correct search URL format
page.goto("http://localhost:3000/#view=search&q=test", wait_until="networkidle")
print("Navigated to page")
# Wait for #searchTitle
print("Waiting for #searchTitle")
page.wait_for_selector("#searchTitle", timeout=10000)
print("Found #searchTitle")
# Wait for results or "no results" message
try:
# My refactor uses <p> with text content
page.wait_for_selector("#searchVideoList p", timeout=10000)
print("Found search results message")
except:
print("Timeout waiting for search results message")
# Take a screenshot
os.makedirs("verification", exist_ok=True)
page.screenshot(path="verification/search-results.png")
print("Screenshot saved")
except Exception as e:
print(f"Error: {e}")
page.screenshot(path="verification/error.png")
finally:
browser.close()
with sync_playwright() as playwright:
run(playwright)
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runNip07WithRetry, NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, __testExports } from '../../js/nostr/nip07Permissions.js';
// Polyfill global window/logger if needed
if (typeof globalThis.window === 'undefined') {
globalThis.window = {};
}
// Mock logger to suppress console noise during tests
const mockLogger = {
warn: () => {},
log: () => {},
info: () => {},
error: () => {},
};
// We need to inject the mock logger into the module if possible, or just accept that it logs to console.
// The module imports devLogger/userLogger. We can't easily mock them without a module loader hook or dependency injection.
// However, the test should still pass even if it logs.
test('NIP-07 Compliance: Retry Logic', async (t) => {
await t.test('runNip07WithRetry succeeds on first try', async () => {
const operation = t.mock.fn(async () => 'success');
const result = await runNip07WithRetry(operation, { timeoutMs: 100 });
assert.equal(result, 'success');
assert.equal(operation.mock.callCount(), 1);
});
await t.test('runNip07WithRetry retries on timeout error', async () => {
let attempts = 0;
const operation = t.mock.fn(async () => {
attempts++;
if (attempts === 1) {
// First attempt simulates a timeout/hang
// We can't easily simulate the *timeout* itself inside the operation because the wrapper controls it.
// But runNip07WithRetry catches specifically NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE.
// The wrapper throws this error if the operation takes too long.
// So we need the operation to actually take longer than the timeout.
await new Promise(resolve => setTimeout(resolve, 50));
return 'slow';
}
return 'success';
});
// We set a very short timeout for the test
const result = await runNip07WithRetry(operation, {
timeoutMs: 20, // Should timeout the first attempt (50ms)
retryMultiplier: 2
});
assert.equal(result, 'success');
// It should have called it twice: once timed out, once succeeded
// Note: The first call might technically still be running in background/promise land, but the retry logic starts a new one.
assert.equal(operation.mock.callCount(), 2);
});
await t.test('runNip07WithRetry fails after max retries or if error is not timeout', async () => {
const error = new Error('Random Error');
const operation = t.mock.fn(async () => {
throw error;
});
await assert.rejects(async () => {
await runNip07WithRetry(operation, { timeoutMs: 100 });
}, /Random Error/);
assert.equal(operation.mock.callCount(), 1, 'Should not retry on non-timeout errors');
});
});
+9 -10
View File
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { waitFor } from "./test-helpers/wait-for.mjs";
const hashtagPreferencesModule = await import(
"../js/services/hashtagPreferencesService.js"
@@ -301,19 +302,17 @@ test(
);
assert.deepEqual(hashtagPreferences.getInterests(), []);
// TODO: This part of the test is flaky in CI environments.
// Logs confirm the code attempts decryption ("Attempting decryption via window.nostr fallback"),
// but the mock spy is not consistently called, likely due to runNip07WithRetry/microtask timing.
permissionState.enabled = true;
await hashtagPreferences.load(pubkey, { allowPermissionPrompt: true });
assert.equal(
decryptCalls.length,
1,
"explicit permission prompts should retry decryption",
);
assert.deepEqual(hashtagPreferences.getInterests(), ["late"]);
await waitFor(() => {
assert.equal(
decryptCalls.length,
1,
"explicit permission prompts should retry decryption",
);
assert.deepEqual(hashtagPreferences.getInterests(), ["late"]);
});
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: true, updateClient: false });
nostrClient.fetchListIncrementally = originalFetchIncremental;
Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB