Merge pull request #1610 from PR0M3TH3AN/unstable

Unstable
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-23 15:22:29 -05:00
commit 2275745533
141 files changed
+10567 -2536

No files matched your search

+9
View File
@@ -8,6 +8,10 @@ permissions:
contents: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
agent-review:
runs-on: ubuntu-latest
@@ -25,6 +29,11 @@ jobs:
node-version: 20
cache: 'npm'
- name: Add upstream remote
run: |
git remote add upstream https://github.com/PR0M3TH3AN/bitvid.git || true
git fetch upstream
- name: Run Agent Review
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+9
View File
@@ -6,9 +6,14 @@ on:
- main
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
matrix:
node-version: [20]
@@ -76,6 +81,7 @@ jobs:
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -119,6 +125,7 @@ jobs:
dm-unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
node-version: [20]
@@ -158,6 +165,7 @@ jobs:
dm-integration-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
matrix:
node-version: [20]
@@ -197,6 +205,7 @@ jobs:
e2e-headless:
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
matrix:
node-version: [20]
+72
View File
@@ -0,0 +1,72 @@
name: Cleanup stuck workflow runs
on:
schedule:
- cron: '*/30 * * * *'
workflow_dispatch:
permissions:
actions: write
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Cancel stuck workflow runs
uses: actions/github-script@v7
env:
STALE_THRESHOLD_HOURS: '2'
with:
script: |
const thresholdHours = Number(process.env.STALE_THRESHOLD_HOURS || 2);
const thresholdMs = thresholdHours * 60 * 60 * 1000;
const now = Date.now();
const statuses = ['queued', 'in_progress'];
const owner = context.repo.owner;
const repo = context.repo.repo;
const staleRuns = [];
for (const status of statuses) {
const runs = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
owner,
repo,
status,
per_page: 100,
});
for (const run of runs) {
const createdAt = Date.parse(run.created_at);
if (Number.isNaN(createdAt)) {
core.warning(`Skipping run ${run.id} (${run.name}) due to invalid created_at.`);
continue;
}
const ageMs = now - createdAt;
if (ageMs > thresholdMs) {
staleRuns.push({
id: run.id,
name: run.name,
status: run.status,
created_at: run.created_at,
html_url: run.html_url,
});
}
}
}
if (staleRuns.length === 0) {
core.info(`No runs older than ${thresholdHours} hours found in queued/in_progress.`);
return;
}
core.info(`Found ${staleRuns.length} stale runs older than ${thresholdHours} hours.`);
for (const run of staleRuns) {
core.info(`Canceling run ${run.id} (${run.name}) status=${run.status} created_at=${run.created_at} url=${run.html_url}`);
await github.request('POST /repos/{owner}/{repo}/actions/runs/{run_id}/force-cancel', {
owner,
repo,
run_id: run.id,
});
}
+50
View File
@@ -49,6 +49,41 @@ Automated agents contributing to this repository should follow these rules:
4. Push your branch and open a Pull Request against the `main` branch.
5. Provide a clear description of the problem and solution.
## CI Behavior and Operations
### Concurrency (superseded runs)
The `CI` workflow is configured with `concurrency.cancel-in-progress: true`, which means when you push new commits to the same PR or branch, any in-flight run for the same workflow group is canceled automatically. This keeps the latest changes running and stops superseded runs from burning capacity. If you need to cancel runs manually (for example, stuck or obviously outdated runs), operators can use the GitHub CLI:
```bash
gh run list --workflow=CI --limit 10
gh run list --workflow=CI --limit 10 | awk 'NR>1 {print $1}' | xargs -n1 gh run cancel
```
If a run refuses to cancel normally, GitHub provides a `force-cancel` endpoint. We only use this in the automated cleanup workflow (below) or when a normal `gh run cancel` does not take effect.
### Job timeouts
Each job in the `CI` workflow has an explicit timeout:
- `build`: 45 minutes
- `unit-tests` (sharded): 30 minutes
- `dm-unit-tests`: 20 minutes
- `dm-integration-tests`: 30 minutes
- `e2e-headless`: 45 minutes
If a job exceeds its timeout, the runner terminates it automatically, so keep an eye on the longest-running suites when adjusting tests.
### Cleanup workflow (manual invocation)
The `Cleanup stuck workflow runs` workflow cancels queued or in-progress runs older than the configured stale threshold (currently 2 hours). It runs on a schedule every 30 minutes, but you can also trigger it manually:
```bash
gh workflow run "Cleanup stuck workflow runs"
```
This workflow uses the `force-cancel` endpoint to ensure stalled runs are terminated reliably, so prefer it for true stuck runs instead of normal cancellation.
## Development Setup
To set up the project locally:
@@ -62,11 +97,19 @@ To set up the project locally:
```bash
npm run build
```
(This runs `npm run build:css` to generate Tailwind styles.)
3. **Run Tests**:
```bash
npm run test:unit
```
*Note: Unit tests may take a few minutes to complete.*
You can also run end-to-end and visual tests:
```bash
npm run test:e2e
npm run test:visual
```
4. **Format & Lint**:
```bash
@@ -82,6 +125,13 @@ To set up the project locally:
For a full guide, see the [Local Setup section in README.md](./README.md#local-setup).
### Troubleshooting
- **Browserslist Warning**: If you see `Browserslist: caniuse-lite is outdated` during the build, run:
```bash
npx update-browserslist-db@latest
```
## Dev Container
This project includes a `.devcontainer` configuration for VS Code. It provides a pre-configured environment with Node.js 22 and necessary extensions.
+8 -1
View File
@@ -103,8 +103,14 @@ To run **bitvid** locally:
2. Install dependencies:
If you cloned the repo but haven’t run `npm install`/`npm ci`, `npx` won’t find the local `tailwindcss`.
```bash
# reproducible install (preferred for CI / locks)
npm ci
# or if you want to install/upgrade packages
npm install
```
3. Build the project:
@@ -139,6 +145,7 @@ http://localhost:8000
- **Run DM unit tests**: `npm run test:dm:unit`
- **Run DM integration tests**: `npm run test:dm:integration`
- **Run headless E2E tests**: `npm run test:e2e`
- **Cancel CI runs**: See [`docs/cancelling-ci-runs.md`](docs/cancelling-ci-runs.md) for a script to clear pending workflows.
### Running Tests in Docker
@@ -172,7 +179,7 @@ Use these artifacts to inspect the UI state at the moment of failure and diagnos
### Send your first video post
Use the event builders in `js/nostrEventSchemas.js` to construct valid video notes.
Use the event builders in `js/nostrEventSchemas.js` (the source of truth for all event definitions) to construct valid video notes.
```javascript
import { buildVideoPostEvent } from "./js/nostrEventSchemas.js";
+45
View File
@@ -0,0 +1,45 @@
# Daily Design System Audit Report
Headline: ⚠️ 16 violations found
## CSS
Total count: 0
## Hex Colors
Total count: 16
| File | Line | Snippet |
|---|---|---|
| ./js/themeController.js | 7 | `dark: "#0f172a",` |
| ./js/themeController.js | 8 | `light: "#f8fafc",` |
| ./js/ui/ambientBackground.js | 34 | `return "#000000";` |
| ./js/ui/loginModalController.js | 1303 | `let qrLightColor = "#ffffff";` |
| ./js/ui/loginModalController.js | 1304 | `let qrDarkColor = "#000000";` |
| ./js/ui/components/VideoModal.js | 2168 | `return "#000000";` |
| ./config/validate-config.js | 97 | `THEME_ACCENT_OVERRIDES.${theme}.${token} must be ...` |
| ./config/instance-config.js | 291 | `* Operators can provide hex color strings (e.g., ...` |
| ./config/instance-config.js | 299 | `accent: "#540011",` |
| ./config/instance-config.js | 300 | `accentStrong: "#fe0032",` |
... and 6 more.
## Inline Styles
Total count: 0
## Raw Lengths
Total count: 0
## Bracket Utilities
Total count: 0
## Tailwind Palette
Total count: 0
## Next Steps
- [ ] Review violations
- [ ] Apply auto-fixes (if safe)
- [ ] Open remediation PR
+45
View File
@@ -0,0 +1,45 @@
# Onboarding Audit Report
Date: 2024-10-24
Environment: Node.js v22.22.0
## Execution Results
| Command | Status | Notes |
| :--- | :--- | :--- |
| `npm ci` | Success | Installed dependencies correctly. |
| `npm run build:css` | Success | Generated `css/tailwind.generated.css`. Warnings observed: `Browserslist: caniuse-lite is outdated` and Tailwind mixed units warning. |
| `npm run format` | Success | No files were modified (codebase is clean). |
| `npm run lint` | Success | All lint checks passed. |
| `npm run test:unit` | Success | Tests ran successfully (100+ tests passed), though execution time is significant (> 5 minutes). |
## Documentation Alignment
- **README.md**:
- Mentions `npm run build`. Correct (delegates to `build:css`).
- Mentions `npm run test:unit`. Correct.
- Mentions starting server `python -m http.server 8000`. Correct.
- **CONTRIBUTING.md**:
- Mentions `npm run build`. Correct.
- Mentions `npm run test:unit`. Correct.
- Mentions `npm run format` and `npm run lint`. Correct.
## Devcontainer
- `.devcontainer/devcontainer.json` exists and uses `mcr.microsoft.com/devcontainers/javascript-node:22`.
- This aligns with the local environment (Node v22) used for validation.
- `postCreateCommand` runs `npm ci && npm run build`. This is efficient.
## Recommendations
1. **Documentation**:
- `CONTRIBUTING.md`: The section "Development Setup" is accurate. However, we can add a note about `npm run build:css` explicitly if users want to just build CSS without the alias, but `npm run build` is better.
- We could add a note about the expected warnings during build to reassure new developers.
- The `caniuse-lite` warning suggests we should run `npx update-browserslist-db@latest` and commit the lockfile change, but that might be out of scope for "docs". However, I can try it.
2. **Devcontainer**:
- The setup seems solid. No changes required.
3. **General**:
- Run `npx update-browserslist-db@latest` to fix the build warning.
+215
View File
@@ -0,0 +1,215 @@
# Telemetry Report - 2026-01-23
**Total Unique Issues:** 16
**Generated:** 2026-01-23T06:26:53.283Z
## Top 10 Priority Issues
| Priority | Count | Issue | Owner | Sources |
| :--- | :---: | :--- | :--- | :--- |
| **High** | 2 | [batchFetchProfiles] Failed to fetch profiles from relay ... | QA Team | test_unit.log, test_unit_debug.log |
| **High** | 1 | [ChannelProfile] Failed to prepare channel videos for mod... | QA Team | test_unit.log |
| **High** | 1 | [ChannelProfile] Failed to decorate channel video moderat... | QA Team | test_unit.log |
| **High** | 1 | [ChannelProfile] Failed to decorate channel video moderat... | QA Team | test_unit.log |
| **High** | 1 | [ChannelProfile] Failed to decorate channel video moderat... | QA Team | test_unit.log |
| **High** | 1 | [ChannelProfile] Failed to decorate channel video moderat... | QA Team | test_unit.log |
| **High** | 1 | [ChannelProfile] Failed to decorate channel video moderat... | QA Team | test_unit.log |
| **High** | 1 | [ChannelProfile] Failed to decorate channel video moderat... | QA Team | test_unit.log |
| **High** | 1 | [ChannelProfile] Failed to decorate channel video moderat... | QA Team | test_unit.log |
| **High** | 1 | [ChannelProfile] Failed to decorate channel video moderat... | Unassigned | test_unit.log |
## Detailed Breakdown
### [High] [batchFetchProfiles] Failed to fetch profiles from relay wss://fail.example: Error: relay timed out
- **Occurrences:** 2
- **Sources:** test_unit.log, test_unit_debug.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `b6566eea`
**Stack Trace / Details:**
```
[batchFetchProfiles] Failed to fetch profiles from relay wss://fail.example: Error: relay timed out
at Object.list (file://$REPO/tests/app-batch-fetch-profiles.test.mjs:80:31)
at file://$REPO/js/utils/profileBatchFetcher.js:115:10
at Array.map (<anonymous>)
at batchFetchProfilesFromRelays (file://$REPO/js/utils/profileBatchFetcher.js:112:75)
at TestContext.<anonymous> (file://$REPO/tests/app-batch-fetch-profiles.test.mjs:87:11)
at Test.runInAsyncScope (node:async_hooks:214:14)
at Test.run (node:internal/test_runner/test:1047:25)
at Test.start (node:internal/test_runner/test:944:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17)
```
### [High] [ChannelProfile] Failed to prepare channel videos for moderation TypeError: Cannot read properties of undefined (reading 'normalizeModerationSettings')
- **Occurrences:** 1
- **Sources:** test_unit.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `c18193e3`
**Stack Trace / Details:**
```
[ChannelProfile] Failed to prepare channel videos for moderation TypeError: Cannot read properties of undefined (reading 'normalizeModerationSettings')
at Application.getActiveModerationThresholds (file://$REPO/js/app.js:6593:56)
at applyChannelModerationToVideos (file://$REPO/js/channelProfile.js:282:21)
at renderChannelVideosFromList (file://$REPO/js/channelProfile.js:4490:28)
at TestContext.<anonymous> (file://$REPO/tests$REPO/channel-profile-moderation.test.mjs:135:26)
at async Test.run (node:internal/test_runner/test:1054:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:296:3)
```
### [High] [ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
- **Occurrences:** 1
- **Sources:** test_unit.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `1e73620e`
**Stack Trace / Details:**
```
[ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
at Application.decorateVideoModeration (file://$REPO/js/app.js:6598:37)
at decorateChannelVideo (file://$REPO/js/channelProfile.js:210:29)
at applyChannelModerationToVideos (file://$REPO/js/channelProfile.js:363:26)
at async renderChannelVideosFromList (file://$REPO/js/channelProfile.js:4490:22)
at async TestContext.<anonymous> (file://$REPO/tests$REPO/channel-profile-moderation.test.mjs:216:20)
at async Test.run (node:internal/test_runner/test:1054:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
```
### [High] [ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
- **Occurrences:** 1
- **Sources:** test_unit.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `21dc1236`
**Stack Trace / Details:**
```
[ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
at Application.decorateVideoModeration (file://$REPO/js/app.js:6598:37)
at decorateChannelVideo (file://$REPO/js/channelProfile.js:210:29)
at resolveChannelModeration (file://$REPO/js/channelProfile.js:917:23)
at applyChannelVisualBlur (file://$REPO/js/channelProfile.js:962:22)
at updateChannelModerationVisuals (file://$REPO/js/channelProfile.js:1027:18)
at renderChannelVideosFromList (file://$REPO/js/channelProfile.js:4893:3)
at async TestContext.<anonymous> (file://$REPO/tests$REPO/channel-profile-moderation.test.mjs:216:20)
at async Test.run (node:internal/test_runner/test:1054:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
```
### [High] [ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
- **Occurrences:** 1
- **Sources:** test_unit.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `9cebf1f5`
**Stack Trace / Details:**
```
[ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
at Application.decorateVideoModeration (file://$REPO/js/app.js:6598:37)
at decorateChannelVideo (file://$REPO/js/channelProfile.js:210:29)
at applyChannelModerationToVideos (file://$REPO/js/channelProfile.js:363:26)
at async renderChannelVideosFromList (file://$REPO/js/channelProfile.js:4490:22)
at async TestContext.<anonymous> (file://$REPO/tests$REPO/channel-profile-moderation.test.mjs:309:3)
at async Test.run (node:internal/test_runner/test:1054:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
```
### [High] [ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
- **Occurrences:** 1
- **Sources:** test_unit.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `a8230c7d`
**Stack Trace / Details:**
```
[ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
at Application.decorateVideoModeration (file://$REPO/js/app.js:6598:37)
at decorateChannelVideo (file://$REPO/js/channelProfile.js:210:29)
at resolveChannelModeration (file://$REPO/js/channelProfile.js:917:23)
at applyChannelVisualBlur (file://$REPO/js/channelProfile.js:962:22)
at updateChannelModerationVisuals (file://$REPO/js/channelProfile.js:1027:18)
at renderChannelVideosFromList (file://$REPO/js/channelProfile.js:4893:3)
at async TestContext.<anonymous> (file://$REPO/tests$REPO/channel-profile-moderation.test.mjs:309:3)
at async Test.run (node:internal/test_runner/test:1054:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
```
### [High] [ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
- **Occurrences:** 1
- **Sources:** test_unit.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `07c6b0b2`
**Stack Trace / Details:**
```
[ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
at Application.decorateVideoModeration (file://$REPO/js/app.js:6598:37)
at decorateChannelVideo (file://$REPO/js/channelProfile.js:210:29)
at applyChannelModerationToVideos (file://$REPO/js/channelProfile.js:363:26)
at async renderChannelVideosFromList (file://$REPO/js/channelProfile.js:4490:22)
at async TestContext.<anonymous> (file://$REPO/tests$REPO/channel-profile-moderation.test.mjs:383:3)
at async Test.run (node:internal/test_runner/test:1054:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
```
### [High] [ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
- **Occurrences:** 1
- **Sources:** test_unit.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `abfcb1f2`
**Stack Trace / Details:**
```
[ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
at Application.decorateVideoModeration (file://$REPO/js/app.js:6598:37)
at decorateChannelVideo (file://$REPO/js/channelProfile.js:210:29)
at resolveChannelModeration (file://$REPO/js/channelProfile.js:917:23)
at applyChannelVisualBlur (file://$REPO/js/channelProfile.js:962:22)
at updateChannelModerationVisuals (file://$REPO/js/channelProfile.js:1027:18)
at renderChannelVideosFromList (file://$REPO/js/channelProfile.js:4893:3)
at async TestContext.<anonymous> (file://$REPO/tests$REPO/channel-profile-moderation.test.mjs:383:3)
at async Test.run (node:internal/test_runner/test:1054:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
```
### [High] [ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
- **Occurrences:** 1
- **Sources:** test_unit.log
- **Suggested Owner:** QA Team
- **Fingerprint:** `ff2d20dc`
**Stack Trace / Details:**
```
[ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
at Application.decorateVideoModeration (file://$REPO/js/app.js:6598:37)
at decorateChannelVideo (file://$REPO/js/channelProfile.js:210:29)
at resolveChannelModeration (file://$REPO/js/channelProfile.js:917:23)
at applyChannelVisualBlur (file://$REPO/js/channelProfile.js:962:22)
at TestContext.<anonymous> (file://$REPO/tests$REPO/channel-profile-moderation.test.mjs:390:3)
at async Test.run (node:internal/test_runner/test:1054:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
```
### [High] [ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
- **Occurrences:** 1
- **Sources:** test_unit.log
- **Suggested Owner:** Unassigned
- **Fingerprint:** `812d9b22`
**Stack Trace / Details:**
```
[ChannelProfile] Failed to decorate channel video moderation TypeError: Cannot read properties of undefined (reading 'decorateVideo')
at Application.decorateVideoModeration (file://$REPO/js/app.js:6598:37)
at decorateChannelVideo (file://$REPO/js/channelProfile.js:210:29)
at resolveChannelModeration (file://$REPO/js/channelProfile.js:917:23)
at applyChannelVisualBlur (file://$REPO/js/channelProfile.js:962:22)
at updateChannelModerationVisuals (file://$REPO/js/channelProfile.js:1027:18)
at handleChannelModerationOverride (file://$REPO/js/channelProfile.js:804:5)
at channelModerationBadgeState.boundOverride (file://$REPO/js/channelProfile.js:570:7)
at HTMLButtonElement.callTheUserObjectsOperation ($REPO/node_modules/jsdom/lib/jsdom/living/generated/EventListener.js:26:30)
at innerInvokeEventListeners ($REPO/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:350:25)
at invokeEventListeners ($REPO/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:286:3)
```
---
*Privacy Notice: PII (IPs, emails, keys) has been sanitized from this report.*
-70
View File
@@ -1,70 +0,0 @@
{
"summary": {
"date": "2026-01-22T19:39:07.362Z",
"duration_sec": 14.367,
"clients": 50,
"events_published": 380,
"events_confirmed": 380,
"events_failed": 0
},
"metrics": {
"throughput": {
"avg_events_per_sec": 26.44950233173244
},
"latency_ms": {
"build": {
"avg": 0.09473684210526316,
"p95": 1
},
"sign": {
"avg": 6.257894736842105,
"p95": 10
},
"rtt": {
"avg": 30.563157894736843,
"p95": 50
}
},
"resources": {
"cpu_samples": [
{
"timestamp": 9.227,
"user": 1834418,
"system": 114421
},
{
"timestamp": 14.266,
"user": 3170422,
"system": 155074
}
],
"memory_samples": [
{
"timestamp": 9.227,
"rss": 94273536,
"heapUsed": 11273392
},
{
"timestamp": 14.266,
"rss": 94453760,
"heapUsed": 11443640
}
]
},
"errors": []
},
"hot_functions": [
{
"name": "buildEvent",
"avg_ms": 0.09473684210526316
},
{
"name": "signEvent",
"avg_ms": 6.257894736842105
}
],
"recommendations": [
"If signEvent latency > 5ms, consider offloading signing to Web Workers or using Wasm optimization.",
"If RTT is high but build/sign is low, network or relay processing is the bottleneck."
]
}
+1 -1
View File
@@ -14,7 +14,7 @@
"prod": 5,
"dev": 316,
"optional": 29,
"peer": 8,
"peer": 0,
"peerOptional": 0,
"total": 320
}
+14
View File
@@ -20,6 +20,13 @@
"dependent": "app",
"location": "/app/node_modules/jsdom"
},
"nostr-tools": {
"current": "2.19.4",
"wanted": "2.20.0",
"latest": "2.20.0",
"dependent": "app",
"location": "/app/node_modules/nostr-tools"
},
"pixelmatch": {
"current": "5.3.0",
"wanted": "5.3.0",
@@ -27,6 +34,13 @@
"dependent": "app",
"location": "/app/node_modules/pixelmatch"
},
"prettier": {
"current": "3.8.0",
"wanted": "3.8.1",
"latest": "3.8.1",
"dependent": "app",
"location": "/app/node_modules/prettier"
},
"prettier-plugin-tailwindcss": {
"current": "0.6.14",
"wanted": "0.6.14",
+6
View File
@@ -0,0 +1,6 @@
tests/visual/moderation.spec.ts:89: // TODO: Investigate why visibility check fails in fixture environment (headless)
tests/visual/moderation.spec.ts:93: // TODO: Investigate why click times out in fixture environment (headless)
tests/visual/moderation.spec.ts:166: // TODO: Investigate visibility check failure
tests/visual/moderation.spec.ts:170: // TODO: Investigate click timeout failure
tests/visual/moderation.spec.ts:219: // TODO: Investigate visibility check failure
tests/visual/moderation.spec.ts:223: // TODO: Investigate click timeout failure
+1 -1
View File
@@ -348,7 +348,7 @@
<div data-nip46-handshake-panel class="space-y-6">
<div class="flex flex-col items-center gap-6">
<div
class="flex aspect-square w-full max-w-xs items-center justify-center rounded-2xl border border-border-strong bg-white p-4 shadow-sm"
class="flex aspect-square w-full max-w-xs items-center justify-center rounded-2xl border border-border-strong bg-white text-black p-6 shadow-sm"
data-nip46-qr
aria-hidden="true"
>
+1 -1
View File
@@ -1270,7 +1270,7 @@
</section>
<section
id="profilePaneMessages"
class="profile-pane hidden space-y-4"
class="profile-pane hidden"
role="tabpanel"
aria-labelledby="profileNavMessages"
>
+174
View File
@@ -0,0 +1,174 @@
<div
id="searchFilterModal"
class="bv-modal hidden p-0 sm:p-0 lg:p-8 lg:items-start lg:justify-center overflow-hidden"
role="dialog"
aria-modal="true"
aria-labelledby="searchFilterModalTitle"
data-ds="new"
>
<div class="bv-modal-backdrop" data-dismiss></div>
<div
class="modal-sheet w-full h-full max-w-none rounded-none border-0 lg:max-h-modal-shell lg:max-w-4xl lg:rounded-modal-xl lg:border lg:h-auto flex min-h-0 flex-col overflow-hidden"
tabindex="-1"
>
<!-- Header -->
<div class="modal-header">
<h2 id="searchFilterModalTitle" class="text-xl font-bold text-text">
Search Filters
</h2>
<button id="closeSearchFilterModal" class="modal-close">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Body -->
<div class="modal-body flex-1 overflow-y-auto">
<form id="searchFilterForm" class="space-y-6">
<!-- Basic Filters Grid -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Column 1: Date Range -->
<div class="space-y-3">
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">Date Range</h3>
<div class="flex flex-wrap gap-2" id="searchFilterDateChips">
<button type="button" class="search-filter-chip focus-ring" data-days="1">24h</button>
<button type="button" class="search-filter-chip focus-ring" data-days="7">7d</button>
<button type="button" class="search-filter-chip focus-ring" data-days="30">30d</button>
<button type="button" class="search-filter-chip focus-ring" data-days="90">90d</button>
<button type="button" class="search-filter-chip focus-ring" data-days="all">All time</button>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<label for="searchFilterDateStart" class="text-xs text-muted">Start</label>
<input type="date" id="searchFilterDateStart" class="input w-full text-xs" />
</div>
<div class="space-y-1">
<label for="searchFilterDateEnd" class="text-xs text-muted">End</label>
<input type="date" id="searchFilterDateEnd" class="input w-full text-xs" />
</div>
</div>
</div>
<!-- Column 2: Sort & Author -->
<div class="space-y-4">
<div class="space-y-2">
<label for="searchFilterSort" class="text-xs font-semibold uppercase tracking-wide text-muted">Sort By</label>
<select id="searchFilterSort" class="select w-full">
<option value="recent">Most recent</option>
<option value="views">Most viewed</option>
<option value="trending">Trending</option>
<option value="longest">Longest</option>
</select>
</div>
<div class="space-y-2">
<label for="searchFilterAuthor" class="text-xs font-semibold uppercase tracking-wide text-muted">Author (npub)</label>
<input type="text" id="searchFilterAuthor" class="input w-full" placeholder="npub1..." />
</div>
</div>
<!-- Column 3: Quick Toggles (was advanced) -->
<div class="space-y-4">
<div class="space-y-2">
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">Content</h3>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-sm text-text">NSFW Content</span>
<button type="button" role="switch" aria-checked="false" id="searchFilterNsfwToggle" class="switch">
</button>
</div>
<!-- Reserved for "Followed only" later -->
</div>
</div>
</div>
</div>
<!-- Advanced Toggle -->
<div>
<button
type="button"
id="searchFilterAdvancedToggle"
class="btn-ghost w-full justify-between text-sm font-medium"
aria-expanded="false"
aria-controls="searchFilterAdvancedPanel"
>
<span>Advanced filters</span>
<span class="text-muted transition-transform duration-200" aria-hidden="true">▼</span>
</button>
</div>
<!-- Advanced Panel (Hidden by default) -->
<div id="searchFilterAdvancedPanel" class="hidden space-y-6 pt-2 border-t border-border-subtle">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Advanced: Tags -->
<div class="md:col-span-2 space-y-3">
<label for="searchFilterTags" class="text-xs font-semibold uppercase tracking-wide text-muted">Tags</label>
<input type="text" id="searchFilterTags" class="input w-full" placeholder="Add tag or keyword (comma separated)" />
<div class="flex flex-wrap gap-2" id="searchFilterTagChips">
<button type="button" class="search-filter-chip focus-ring" data-tag="nostr">#nostr</button>
<button type="button" class="search-filter-chip focus-ring" data-tag="music">#music</button>
<button type="button" class="search-filter-chip focus-ring" data-tag="live">#live</button>
<button type="button" class="search-filter-chip focus-ring" data-tag="gaming">#gaming</button>
<button type="button" class="search-filter-chip focus-ring" data-tag="tech">#tech</button>
</div>
</div>
<!-- Advanced: Duration -->
<div class="space-y-3">
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">Duration</h3>
<div class="grid gap-2">
<label class="flex items-center gap-3 text-sm text-text cursor-pointer">
<input type="checkbox" class="checkbox" id="searchFilterDurationShort" />
<span>Short (under 5 min)</span>
</label>
<label class="flex items-center gap-3 text-sm text-text cursor-pointer">
<input type="checkbox" class="checkbox" id="searchFilterDurationMedium" />
<span>Medium (5-20 min)</span>
</label>
<label class="flex items-center gap-3 text-sm text-text cursor-pointer">
<input type="checkbox" class="checkbox" id="searchFilterDurationLong" />
<span>Long (20+ min)</span>
</label>
</div>
</div>
<!-- Advanced: Has -->
<div class="space-y-3">
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">Must Have</h3>
<div class="flex flex-wrap gap-2" id="searchFilterHasChips">
<button type="button" class="search-filter-chip focus-ring" data-has="magnet">Magnet Link</button>
<button type="button" class="search-filter-chip focus-ring" data-has="url">Web URL</button>
</div>
</div>
</div>
</div>
</form>
</div>
<!-- Footer -->
<div class="modal-footer flex-row items-center justify-between border-t border-border-subtle bg-surface-alt/50">
<button type="button" id="searchFilterResetBtn" class="btn-ghost text-sm">
Reset
</button>
<button type="button" id="searchFilterApplyBtn" class="btn text-sm">
Apply Filters
</button>
</div>
</div>
</div>
+127
View File
@@ -0,0 +1,127 @@
<!-- components/share-nostr-modal.html -->
<div
id="shareNostrModal"
class="bv-modal hidden items-start justify-center md:items-center"
role="dialog"
aria-modal="true"
aria-labelledby="shareNostrModalTitle"
aria-describedby="shareNostrModalDescription"
data-ds="new"
>
<div class="bv-modal-backdrop" data-dismiss></div>
<div class="modal-sheet max-h-modal-shell overflow-hidden flex flex-col w-full max-w-2xl" tabindex="-1">
<div class="modal-header">
<h2 id="shareNostrModalTitle" class="text-xl font-bold text-text">
Share on Nostr
</h2>
<button
id="closeShareNostrModal"
class="modal-close"
type="button"
aria-label="Close share modal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="modal-body space-y-6 overflow-y-auto flex-1 min-h-0 custom-scrollbar p-6">
<p id="shareNostrModalDescription" class="text-sm text-muted">
Publish a short note and thumbnail preview to your preferred Nostr relays.
</p>
<div class="space-y-2">
<label for="shareNostrContent" class="block text-sm font-medium text-text">
Post content
</label>
<textarea
id="shareNostrContent"
rows="6"
class="textarea"
placeholder="Write your message"
></textarea>
</div>
<div class="space-y-3 rounded-lg border border-border bg-panel/50 p-4">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wide text-muted">
Preview
</span>
<a
id="shareNostrPreviewUrl"
class="text-xs text-accent hover:underline break-all"
href="#"
target="_blank"
rel="noopener noreferrer"
>
--
</a>
</div>
<div class="flex items-start gap-4">
<div class="h-16 w-24 rounded-md border border-border bg-surface-alt overflow-hidden flex items-center justify-center">
<img
id="shareNostrPreviewThumbnail"
class="h-full w-full object-cover hidden"
alt="Video thumbnail preview"
/>
<span
id="shareNostrPreviewFallback"
class="text-xs text-muted text-center px-2"
>
No thumbnail
</span>
</div>
<div class="flex-1 space-y-1 min-w-0">
<p id="shareNostrPreviewTitle" class="text-sm font-semibold text-text truncate">
--
</p>
<p id="shareNostrPreviewMeta" class="text-xs text-muted truncate">
--
</p>
</div>
</div>
</div>
<div class="space-y-2">
<label for="shareNostrRelays" class="block text-sm font-medium text-text">
Relays to post to
</label>
<input
id="shareNostrRelays"
type="text"
class="input"
placeholder="wss://relay.example, wss://relay.example2"
/>
<p class="text-xs text-muted">
Separate relay URLs with commas. The list defaults to your write relays.
</p>
<div id="shareNostrRelayPills" class="flex flex-wrap gap-2"></div>
</div>
</div>
<div class="modal-footer flex flex-col gap-3 border-t border-border bg-panel/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<p id="shareNostrStatus" class="text-xs text-muted" role="status" aria-live="polite"></p>
<div class="flex items-center gap-3">
<button id="cancelShareNostr" type="button" class="btn-ghost">
Cancel
</button>
<button id="postShareNostr" type="button" class="btn-primary">
Post
</button>
</div>
</div>
</div>
</div>
+16
View File
@@ -63,6 +63,22 @@
/>
<span class="sidebar-text">Recently Added</span>
</a>
<a href="#view=kids" class="sidebar-nav-link" title="For Kids">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
class="sidebar-icon"
aria-hidden="true"
fill="currentColor"
>
<path
d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm6 14h-12c.331 1.465 2.827 4 6.001 4 3.134 0 5.666-2.521 5.999-4zm0-3.998l-.755.506s-.503-.948-1.746-.948c-1.207 0-1.745.948-1.745.948l-.754-.506c.281-.748 1.205-2.002 2.499-2.002 1.295 0 2.218 1.254 2.501 2.002zm-7 0l-.755.506s-.503-.948-1.746-.948c-1.207 0-1.745.948-1.745.948l-.754-.506c.281-.748 1.205-2.002 2.499-2.002 1.295 0 2.218 1.254 2.501 2.002z"
/>
</svg>
<span class="sidebar-text">For Kids</span>
</a>
<a
id="exploreLink"
href="#view=explore"
+145 -7
View File
@@ -482,6 +482,20 @@
@apply pointer-events-none scale-95 opacity-0;
}
.search-filter-chip {
@apply inline-flex items-center gap-2 rounded-full border border-border-subtle bg-surface-alt px-3 py-1 text-xs font-medium text-text transition-colors duration-base ease-out;
}
.search-filter-chip[data-state="on"] {
@apply border-accent-strong bg-accent-strong text-white;
}
@media (min-width: theme("screens.md")) {
.search-filters-panel.is-advanced-open {
width: 40rem;
}
}
[data-popover-arrow="true"] {
left: var(--popover-arrow-left, auto);
right: var(--popover-arrow-right, auto);
@@ -2419,6 +2433,21 @@
overflow: hidden;
}
.video-tag-strip--scroll {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
padding-bottom: var(--space-xs);
scrollbar-width: none;
-ms-overflow-style: none;
}
.video-tag-strip--scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.video-tag-pill {
@apply inline-flex items-center gap-2 rounded-full border border-status-neutral-border bg-status-neutral-surface px-3 py-1 text-2xs font-medium text-status-neutral-on;
@apply relative justify-center whitespace-nowrap transition;
@@ -3048,6 +3077,20 @@
overflow-x: hidden;
}
/* Specific override for messages pane to prevent parent scrolling on desktop */
@media (min-width: theme("screens.md")) {
#profilePaneMessages {
height: 100%;
min-height: 0;
overflow: hidden;
}
#dmAppShellMount {
height: 100%;
min-height: 0;
}
}
.profile-pane.hidden {
display: none !important;
}
@@ -3712,6 +3755,18 @@
z-index: var(--z-modal-top-content);
}
#shareNostrModal {
z-index: var(--z-modal-peak-root) !important;
}
#shareNostrModal > .bv-modal-backdrop {
z-index: var(--z-modal-peak-overlay) !important;
}
#shareNostrModal .bv-modal__panel {
z-index: var(--z-modal-peak-content) !important;
}
.bv-modal[data-nested-modal-active="true"] {
pointer-events: none;
visibility: hidden;
@@ -5033,7 +5088,8 @@
.profile-modal .dm-app-shell {
min-height: 0;
height: auto;
padding: var(--space-md);
padding: 0;
gap: var(--space-md);
}
.dm-app-shell__sidebar,
@@ -5048,6 +5104,10 @@
.profile-modal .dm-app-shell__main {
height: auto;
min-height: 0;
border: none;
box-shadow: none;
background: transparent;
padding: 0;
}
.dm-app-shell__sidebar {
@@ -5285,8 +5345,33 @@
border-radius: var(--radius-md);
}
.dm-message-row {
display: flex;
align-items: flex-end;
gap: var(--space-xs);
width: 100%;
}
.dm-message-row--incoming {
justify-content: flex-start;
}
.dm-message-row--outgoing {
justify-content: flex-end;
}
.dm-message-avatar {
width: var(--space-xl);
height: var(--space-xl);
flex-shrink: 0;
border-radius: var(--radius-full);
object-fit: cover;
background-color: var(--surface-sunken);
border: var(--border-width-hairline) solid var(--border-subtle);
}
.dm-message-bubble {
max-width: 85%;
max-width: 75%;
display: flex;
flex-direction: column;
gap: var(--space-2xs);
@@ -5298,14 +5383,14 @@
}
.dm-message-bubble--incoming {
align-self: flex-start;
border-bottom-left-radius: var(--radius-sm);
}
.dm-message-bubble--outgoing {
align-self: flex-end;
background: var(--color-accent);
color: var(--color-white);
border-color: var(--color-accent-strong);
border-bottom-right-radius: var(--radius-sm);
}
.dm-message-bubble--outgoing .dm-message-bubble__meta,
@@ -5758,7 +5843,7 @@
min-height: var(--space-md);
}
@media (max-width: 767.98px) {
@media (max-width: calc(theme("screens.md") - 0.02px)) {
.dm-app-shell[data-dm-view="list"] .dm-app-shell__main {
display: none !important;
}
@@ -5800,21 +5885,65 @@
}
}
/* Ensure the profile pane for messages takes full height */
#profilePaneMessages {
height: 100%;
min-height: 0;
}
@media (min-width: theme("screens.md")) {
.profile-modal .dm-app-shell {
flex-direction: row;
align-items: stretch;
height: 100%;
min-height: 0;
}
.profile-modal .dm-app-shell__sidebar {
flex: 0 0 var(--size-18rem);
max-width: var(--size-20rem);
height: auto;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.profile-modal .dm-app-shell__main {
flex: 1 1 auto;
height: auto;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.profile-modal .dm-conversation-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.profile-modal .dm-conversation-list__items {
flex: 1;
overflow-y: auto;
min-height: 0;
overscroll-behavior-y: contain;
@apply no-scrollbar;
}
.profile-modal .dm-message-thread {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.profile-modal .dm-message-thread__timeline {
flex: 1;
overflow-y: auto;
min-height: 0;
overscroll-behavior-y: contain;
@apply no-scrollbar;
}
}
@@ -5957,3 +6086,12 @@
}
}
}
.no-scrollbar {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
.no-scrollbar::-webkit-scrollbar {
display: none !important;
}
+53
View File
@@ -0,0 +1,53 @@
# Cancelling CI Runs
This document explains how to use the `scripts/cancel-all-ci-runs.sh` script to cancel pending or in-progress CI workflows. This is useful when you have a backlog of runs that are no longer needed.
## Prerequisites
- **GitHub CLI (`gh`)**: You must have the GitHub CLI installed and authenticated.
- Install: [GitHub CLI Installation Guide](https://github.com/cli/cli#installation)
- Authenticate: `gh auth login`
## Usage
### Basic Usage (Repo Root)
From the root of the repository, you can simply run:
```bash
./scripts/cancel-all-ci-runs.sh
```
The script will automatically detect the repository from your git configuration.
### Running from Anywhere
You can run the script from any directory by setting the `REPO` environment variable:
```bash
REPO=PR0M3TH3AN/bitvid ./scripts/cancel-all-ci-runs.sh
```
### Previewing Runs
Before cancelling, you might want to see which runs are currently active. You can use the following commands:
```bash
# List in-progress runs
gh run list --repo PR0M3TH3AN/bitvid --status in_progress --limit 1000 --json databaseId,headBranch,name,createdAt | jq .
# List queued runs
gh run list --repo PR0M3TH3AN/bitvid --status queued --limit 1000 --json databaseId,headBranch,name,createdAt | jq .
```
## How it Works
The script performs the following steps:
1. Checks if `gh` is installed.
2. Determines the repository (either from `REPO` env var or `gh repo view`).
3. Fetches IDs of all runs with status `in_progress` and `queued`.
4. Cancels them one by one using `gh run cancel`.
### Why use this script?
Trying to cancel runs in bulk using `xargs gh run cancel` directly can fail because some versions of `gh` only accept a single run ID per invocation. This script uses `xargs -n1` to ensure `gh run cancel` is called individually for each run ID, preventing "accepts at most 1 arg(s)" errors.
+10
View File
@@ -128,6 +128,16 @@ Hooks can be provided globally when registering the feed or per-execution via
uses this mechanism so Phase 1 feeds can plug in actor-specific suppression
logic later without changing the core pipeline.
## Feed configuration overview
The feed registry in `js/app.js` registers each feed with a `defaultConfig` and
optional `configSchema`. The Kids feed (`kids`) is the first feed to ship with a
custom schema:
| Feed | Config schema summary |
| --- | --- |
| `kids` | `ageGroup` (`toddler`/`preschool`/`early`/`older`), `educationalTags` (string array), `disallowedWarnings` (string array) — see [`docs/feeds/kids-feed.md`](feeds/kids-feed.md) for the full behavior and defaults. |
## Why-Metadata
`context.addWhy()` records structured audit entries. All built-in stages use it
+84
View File
@@ -0,0 +1,84 @@
# Kids feed
The Kids feed (`kids`) is a curated feed-engine pipeline that only surfaces
videos explicitly marked for kids and then re-ranks them using age-appropriate
scoring. It lives in the feed registry in `js/app.js` and runs through the
feed engine stages documented below.
## Hard include/exclude rules
The Kids feed hard-gates content before scoring:
- **Must be marked for kids**: `video.isForKids` must be `true` or the item is
dropped.
- **Never allow NSFW**: `video.isNsfw === true` is always excluded.
- **Drop invalid or blocked items**: Videos marked `invalid`, or rejected by the
standard `shouldIncludeVideo` checks (privacy, access control, blacklists,
author blocks) are excluded.
- **Content warnings**: Any `video.contentWarning` entry that matches the
`disallowedWarnings` list removes the item from the feed.
The default `disallowedWarnings` list is:
`nudity`, `sexual`, `graphic-violence`, `self-harm`, and `drugs`. Feed config or
runtime overrides can replace or extend this list.
## Age-group definitions + duration bounds
Age groups tune the scoring defaults (preferred tags, educational tags, and
maximum duration). The feed defaults to the **preschool** profile unless
`config.ageGroup` or a runtime override is supplied.
| Age group | Max duration | Preferred tags (default) | Educational tags (default) |
| --- | --- | --- | --- |
| `toddler` | 5 minutes | toddler, baby, nursery, colors, shapes, lullaby | abc, numbers, counting, learning, alphabet |
| `preschool` | 10 minutes | preschool, kindergarten, storytime, letters, phonics | counting, alphabet, reading, learning, math |
| `early` | 15 minutes | early, kids, reading, science, animals, art | science, math, reading, history, geography |
| `older` | 20 minutes | tween, teens, tutorial, stem, coding, music | stem, coding, history, geography, tutorial |
Duration affects the **age-appropriateness** score. Videos longer than the
age-group maximum are down-ranked proportionally.
## Moderation thresholds + multi-category report policy
The Kids feed applies stricter moderation thresholds than the general feeds:
- **Blur**: trusted report count ≥ **1**.
- **Hide**: trusted report count ≥ **1**.
- **Hide (trusted mutes)**: trusted mute count ≥ **1**.
Moderation runs **three separate stages** for report categories: `nudity`,
`violence`, and `self-harm`. Each stage pulls the trusted report count for its
own category, so **counts do not accumulate across categories**. This means a
report spike in one category only affects that category’s moderation stage.
Use the why-trail (below) to inspect which category triggered an action.
## Explainability (`addWhy`)
The Kids feed relies on `context.addWhy()` so future UI surfaces can explain
filtering and scoring decisions:
- **Audience filter** reasons: `not-for-kids`, `nsfw`, `invalid`, `blacklist`,
and `content-warning` (includes the matched warning).
- **Moderation** reasons: `blur` / `autoplay-block` with the report category,
plus `trusted-mute` / `viewer-mute` when applicable.
- **Scoring** reasons: the dominant positive scoring component (`age-appropriateness`,
`educational-boost`, `author-trust`, `popularity`, or `freshness`) is recorded
alongside the computed score.
These entries are returned in `metadata.why` whenever the feed engine executes.
## Feed configuration schema
The Kids feed exposes a config schema via the feed engine registry:
```json
{
"ageGroup": "preschool",
"educationalTags": [],
"disallowedWarnings": ["nudity", "sexual", "graphic-violence", "self-harm", "drugs"]
}
```
Use `ageGroup` to select the default scoring profile, `educationalTags` to
override the default educational tag list for that age group, and
`disallowedWarnings` to adjust the hard content-warning exclusions.
+63
View File
@@ -63,3 +63,66 @@ The client tracks `tombstones` (map of ID -> deletedAt timestamp). Any incoming
* `subscribeVideos(callback)`: Main feed subscription.
* `publishVideo(payload)`: Complex flow to upload -> sign -> publish -> mirror.
* `connectRemoteSigner(uri)`: Handshake protocol for NIP-46.
## Why it works this way
### 1. Immutability & "Active Map"
Nostr events are immutable signed messages. To support "edits", we must allow multiple events to represent the same "entity" (Video).
- **The Trade-off**: We accept higher memory usage (storing multiple versions in `allEvents`) to gain strict cryptographic verifiability.
- **The Solution**: `activeMap` acts as a "Latest-State Projection" (Materialized View) that the UI consumes. This decouples the network reality (append-only log) from the user expectation (mutable state).
### 2. Buffering vs. Streaming
Directly piping relay events to React State (`setState`) triggers a Re-Render Cycle for every message.
- **The Bottleneck**: Initial loads often fetch 500-2000 events in <2 seconds.
- **The Solution**: A 75ms debounce buffer collects bursts and processes them in one microtask. This turns 1000 re-renders into ~10-15, keeping the UI thread responsive (60fps).
### 3. Dual-Layer Caching (IDB + Memory)
Relays are slow and sometimes fail.
- **Offline-First**: We restore from IndexedDB immediately on boot (`init`).
- **Fingerprinting**: We hash events before writing to IDB. If `JSON.stringify(video)` hasn't changed, we skip the write syscall. This reduces I/O freezing on mobile devices.
## When to change
- **Update `EVENTS_CACHE_VERSION`**: If you change the internal `Video` object structure (e.g., rename fields in `convertEventToVideo`), bump the DB version in `EventsCacheStore` to force a wipe/rebuild on client devices.
- **Modify `subscribeVideos`**: If adding new filters (e.g., "Kids Only Mode"), update the subscription filter here. Ensure the buffer logic remains to protect performance.
- **Refactor Signers**: If NIP-46 changes or a new signer standard (NIP-XX) emerges, modify `signerHelpers.js` and `client.js` methods (`signAndPublishEvent`).
## Sequence Example: Publish Video
Publishing is not atomic; it's a multi-step orchestration to ensure compatibility across different Nostr clients (WebSeed vs. File Metadata vs. Categorization).
```mermaid
sequenceDiagram
participant UI as VideoUploadModal
participant Client as NostrClient
participant Signer as NIP-07/46
participant Relay
UI->>Client: publishVideo(payload)
activate Client
Note right of Client: 1. Construct Main Video Note
Client->>Client: buildVideoPostEvent (Kind 30078)
Client->>Signer: signEvent(Kind 30078)
Signer-->>Client: signedEvent
Client->>Relay: Publish Kind 30078
Note right of Client: 2. NIP-94 Mirror (Optional)
opt has hosted URL
Client->>Client: buildMirrorEvent (Kind 1063)
Client->>Signer: signEvent(Kind 1063)
Signer-->>Client: signedMirror
Client->>Relay: Publish Kind 1063
end
Note right of Client: 3. NIP-71 Metadata (Optional)
opt has tags/categories
Client->>Client: buildNip71Event (Kind 22)
Client->>Signer: signEvent(Kind 22)
Signer-->>Client: signedWrapper
Client->>Relay: Publish Kind 22
end
Client-->>UI: Success (returns Main Event)
deactivate Client
```
+44
View File
@@ -16,6 +16,7 @@ import {
setNostrEventSchemaOverrides,
buildVideoPostEvent,
buildRepostEvent,
buildShareEvent,
buildProfileMetadataEvent,
} from "./nostrEventSchemas.js";
@@ -147,6 +148,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `isNsfw`, `isForKids`, `enableComments`, `ws`, `xs`) |
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | JSON-serialized event being reposted |
| Share note (`NOTE_TYPES.SHARE`) | `1` | `['e', <video id>, '', 'mention']` plus `['p', <video pubkey>, '', 'mention']` when available; repeating `['r', <relay url>, <read/write?>]` tags for relay hints; inherits schema append tags | Plain text share content from the compose modal |
| Video reaction (`NOTE_TYPES.VIDEO_REACTION`) | `7` | `['e', <event id>, <relay?>]` or `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <author pubkey>]` | Reaction content (e.g. `+`, `-`, or emoji) |
| Video comment (`NOTE_TYPES.VIDEO_COMMENT`) | `1111` | NIP-22 root scope tags `['A'\|`E`\|`I`, <pointer>, <relay?>?], `['K', <root kind>]`, and `['P', <root author pubkey>, <relay?>?]` plus parent metadata `['E'\|`I`, <parent pointer>, <relay?>?, <author?>], `['K', <parent kind>]`, `['P', <parent author>, <relay?>?]`; inherits schema append tags | Plain text body sanitized to valid UTF-8 |
| DM attachment (`NOTE_TYPES.DM_ATTACHMENT`) | `15` | `['p', <recipient pubkey>]`, `['x', <sha256 of uploaded bytes>]`, `['url', <download url>]`, optional `['name', <filename>]`, `['type', <mime>]`, `['size', <bytes>]`, optional `['k', <base64 key>]`; inherits schema append tags | Empty content; attachment metadata is represented as tags |
@@ -169,9 +171,27 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', <pubkey>]` entries | Empty content |
| Profile metadata (`NOTE_TYPES.PROFILE_METADATA`) | `0` | `['d', ...]` is not used | JSON payload with NIP-01 fields (`name`, `about`, `picture`, `nip05`, etc.) |
| Mute list (`NOTE_TYPES.MUTE_LIST`) | `10000` | Repeated `['p', <pubkey>]` tags for blocked/muted users | Optional content (often encrypted) |
| Deletion (`NOTE_TYPES.DELETION`) | `5` | `['e', <event id>]` or `['a', <coordinate>]` | Reason for deletion |
| Legacy Direct Message (`NOTE_TYPES.LEGACY_DM`) | `4` | `['p', <recipient pubkey>]` | NIP-04 encrypted ciphertext |
| Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', <recipient pubkey>]` plus optional `['e', <event id>]`, `['a', <coordinate>]`, `['amount', <msats>]`, `['lnurl', <bech32>]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note |
| Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', <invoice>]`, `['description', <zap request JSON>]`, `['p', <recipient pubkey>]` plus optional `['e', <event id>]` and `['a', <coordinate>]` | Empty content; receipts are published by the recipient's LNURL server |
### Share notes (kind `1`)
Share notes are plain text `kind:1` events generated by the Share modal. The
`content` field is preserved verbatim (after UTF-8 sanitization) and can contain
any short message or commentary accompanying the share.
The builder attaches the following tags:
* `['e', <event id>, '', 'mention']` — the shared video’s event id, normalized to
lowercase hex.
* `['p', <pubkey>, '', 'mention']` — the shared video’s author pubkey,
normalized to lowercase hex.
* `['r', <relay url>, <read/write?>]` — optional relay hints. Relay URLs are
trimmed, duplicates are removed, and the optional marker is normalized to
`read` or `write` when provided.
Subscription lists therefore match the
[NIP-51 follow-set specification](./nips/51.md#sets) by emitting kind `30000`
events with the shared `['d','subscriptions']` identifier. Builders continue to
@@ -293,3 +313,27 @@ device, etc.) should remain on-device via the
Refer to the [`WatchHistoryService`](../js/watchHistoryService.js) for queue
management hooks and metadata toggle controls that complement these schema
definitions.【F:js/watchHistoryService.js†L695-L776】【F:js/watchHistoryService.js†L1040-L1093】
## Validation
bitvid now enforces schema validation at runtime during development. Every event
builder automatically runs the generated event against its definition in
`validateEventAgainstSchema` when `isDevMode` is true. Violations (missing
required tags, invalid JSON content, etc.) are logged to the console via
`devLogger`.
### Continuous Integration
A standalone validation suite runs in CI to ensure that all builders produce
valid events:
```bash
# Run the validation script
node scripts/agent/validate-events.mjs
# Run the unit test suite
npm run test:unit
```
When modifying schemas or builders, use `scripts/agent/validate-events.mjs` to
verify your changes locally.
@@ -0,0 +1,11 @@
# Issue 001: VideoCard Logic Failure
This reproducer demonstrates the bug where `VideoCard` block action fails to restore the trusted mute hide state after an override.
## Run
```bash
node --import ./tests/test-helpers/setup-localstorage.mjs examples/reproducers/issue-001-video-card-logic/repro.mjs
```
## Expected Result
The test should FAIL with an `AssertionError: false !== true`.
@@ -0,0 +1,199 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import "../../../tests/test-helpers/setup-localstorage.mjs";
import { VideoCard } from "../../../js/ui/components/VideoCard.js";
import { clearModerationOverride, setModerationOverride } from "../../../js/state/cache.js";
import {
withMockedNostrTools,
createModerationAppHarness,
} from "../../../tests/helpers/moderation-test-helpers.mjs";
import {
applyModerationContextDatasets,
normalizeVideoModerationContext,
} from "../../../js/ui/moderationUiHelpers.js";
import { buildModerationBadgeText } from "../../../js/ui/moderationCopy.js";
import { userBlocks } from "../../../js/userBlocks.js";
function setupDom(t) {
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
url: "https://example.com",
pretendToBeVisual: true,
});
const { window } = dom;
const previous = new Map();
const keys = [
"window",
"document",
"HTMLElement",
"Element",
"Node",
"MouseEvent",
"CustomEvent",
"ResizeObserver",
"requestAnimationFrame",
"cancelAnimationFrame",
];
keys.forEach((key) => {
const hadValue = Object.prototype.hasOwnProperty.call(globalThis, key);
previous.set(key, hadValue ? globalThis[key] : Symbol.for("__undefined__"));
if (key === "requestAnimationFrame" && typeof window[key] !== "function") {
window.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16);
} else if (key === "cancelAnimationFrame" && typeof window[key] !== "function") {
window.cancelAnimationFrame = (id) => clearTimeout(id);
} else if (key === "ResizeObserver" && typeof window[key] !== "function") {
window.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
globalThis[key] = window[key];
});
// Mock navigator safely
const originalNavigator = globalThis.navigator;
Object.defineProperty(globalThis, "navigator", {
value: window.navigator,
writable: true,
configurable: true,
});
if (!window.requestAnimationFrame) {
window.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16);
globalThis.requestAnimationFrame = window.requestAnimationFrame;
}
if (!window.cancelAnimationFrame) {
window.cancelAnimationFrame = (id) => clearTimeout(id);
globalThis.cancelAnimationFrame = window.cancelAnimationFrame;
}
if (!window.ResizeObserver) {
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserverStub;
globalThis.ResizeObserver = ResizeObserverStub;
}
t.after(() => {
dom.window.close();
keys.forEach((key) => {
const previousValue = previous.get(key);
if (previousValue === Symbol.for("__undefined__")) {
delete globalThis[key];
} else {
globalThis[key] = previousValue;
}
});
// Restore navigator
Object.defineProperty(globalThis, "navigator", {
value: originalNavigator,
writable: true,
configurable: true,
});
});
return { window, document: window.document };
}
// Reproducer for skipped test: "VideoCard block action restores trusted mute hide state after override"
test("VideoCard block action restores trusted mute hide state after override", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
const app = await createModerationAppHarness();
app.getActiveModerationThresholds = () => ({
autoplayBlockThreshold: Number.POSITIVE_INFINITY,
blurThreshold: Number.POSITIVE_INFINITY,
trustedMuteHideThreshold: 1,
trustedSpamHideThreshold: Number.POSITIVE_INFINITY,
});
app.pubkey = "f".repeat(64);
app.isUserLoggedIn = () => true;
app.showStatus = () => {};
app.showError = () => {};
app.onVideosShouldRefresh = async () => {};
const originalEnsureLoaded = userBlocks.ensureLoaded;
const originalAddBlock = userBlocks.addBlock;
const originalIsBlocked = userBlocks.isBlocked;
userBlocks.ensureLoaded = async () => {};
userBlocks.addBlock = async () => ({ ok: true });
userBlocks.isBlocked = () => false;
t.after(() => {
userBlocks.ensureLoaded = originalEnsureLoaded;
userBlocks.addBlock = originalAddBlock;
userBlocks.isBlocked = originalIsBlocked;
});
const videoId = "d".repeat(64);
const video = {
id: videoId,
title: "Muted Hide Clip",
pubkey: "e".repeat(64),
moderation: {
trustedMuted: true,
trustedMuteCount: 1,
trustedMuters: ["f".repeat(64)],
trustedCount: 0,
reportType: "nudity",
},
};
app.videosMap.set(video.id, video);
app.currentVideo = video;
app.decorateVideoModeration(video);
const card = new VideoCard({
document,
video,
formatters: {
formatTimeAgo: () => "moments ago",
},
helpers: {
isMagnetSupported: () => false,
},
});
document.body.appendChild(card.getRoot());
assert.equal(
normalizeVideoModerationContext(card.video?.moderation).activeHidden,
true,
);
assert.equal(card.getRoot().dataset.moderationHidden, "true");
card.onModerationOverride = ({ video: overrideVideo, card: overrideCard }) =>
app.handleModerationOverride({ video: overrideVideo, card: overrideCard });
card.moderationActionButton.click();
await new Promise((resolve) => setTimeout(resolve, 0));
const contextAfterOverride = normalizeVideoModerationContext(
card.video?.moderation,
);
assert.equal(contextAfterOverride.overrideActive, true);
assert.equal(contextAfterOverride.activeHidden, false);
assert.ok(card.moderationBlockButton);
card.onModerationBlock = ({ video: targetVideo, card: targetCard }) =>
app.handleModerationBlock({ video: targetVideo, card: targetCard });
card.moderationBlockButton.click();
await new Promise((resolve) => setTimeout(resolve, 0));
const contextAfterHide = normalizeVideoModerationContext(card.video?.moderation);
assert.equal(contextAfterHide.overrideActive, false);
assert.equal(contextAfterHide.activeHidden, true);
assert.equal(card.getRoot().dataset.moderationHidden, "true");
assert.equal(card.moderationBlockButton, null);
});
@@ -0,0 +1,12 @@
# Issue 002: Login Modal NSEC Error
This reproducer attempts to reproduce the issue where `login-modal` fails to find the `[data-nsec-error]` element.
## Run
```bash
npx playwright test examples/reproducers/issue-002-login-modal-nsec/repro.spec.ts
```
## Expected Result
If the bug is present, the test will FAIL waiting for `[data-nsec-error]` to be visible.
Note: In some environments, this test might pass if the bug is flaky or fixed.
@@ -0,0 +1,131 @@
import { expect, test } from "@playwright/test";
import {
applyReducedMotion,
failOnConsoleErrors,
} from "../../../tests/e2e/helpers/uiTestUtils";
test.describe("login modal flows (reproducer)", () => {
test.beforeEach(async ({ page }) => {
await applyReducedMotion(page);
failOnConsoleErrors(page);
await page.goto("/components/login-modal.html", { waitUntil: "networkidle" });
await page.addScriptTag({
type: "module",
content: `
import LoginModalController from "/js/ui/loginModalController.js";
import {
prepareStaticModal,
openStaticModal,
closeStaticModal,
} from "/js/ui/components/staticModalAccessibility.js";
const modalElement = document.getElementById("loginModal");
window.__testAuth = {
lastRequest: null,
};
const authService = {
requestLogin: async (options) => {
window.__testAuth.lastRequest = options;
if (options.providerId === "nip46") {
if (typeof options.onHandshakePrepared === "function") {
options.onHandshakePrepared({ uri: "nostr:test-uri" });
}
await new Promise(() => {});
}
return { pubkey: "npub-test" };
},
hydrateFromStorage: async () => null,
on: () => () => {},
};
const providers = [
{
id: "nsec",
label: "Secret key",
description: "Use a raw key",
login: async () => {},
},
{
id: "nip46",
label: "Remote signer",
description: "Pair a signer",
login: async () => {},
},
{
id: "nip07",
label: "Extension",
description: "Browser extension",
login: async () => {},
},
];
const prepareModal = (modal) =>
prepareStaticModal({ root: modal }) || modal;
const openModal = ({ modal, triggerElement } = {}) =>
openStaticModal(modal, { triggerElement });
const closeModal = () => {
if (modalElement) {
closeStaticModal(modalElement);
}
};
window.__loginController = new LoginModalController({
modalElement,
providers,
services: {
authService,
nostrClient: {
getRemoteSignerStatus: () => null,
onRemoteSignerChange: () => () => {},
getStoredNip46Metadata: () => null,
},
},
helpers: {
prepareModal,
openModal,
closeModal,
setModalState: (name, isOpen) => {
window.__testModalState = { name, isOpen };
},
},
});
const closeButton = document.getElementById("closeLoginModal");
if (closeButton) {
closeButton.addEventListener("click", () => {
closeModal();
});
}
window.__loginReady = true;
`,
});
await page.waitForFunction(() => window.__loginReady === true);
});
test("handles nsec login flow (reproduces missing error element)", async ({ page }) => {
const modal = page.locator("#loginModal");
await page.evaluate(() => window.__loginController.openModal());
await expect(modal).toHaveAttribute("data-open", "true");
await page
.locator('[data-provider-button][data-provider-id="nsec"]')
.click();
await expect(page.locator("[data-nsec-secret]")).toBeVisible();
await page.locator("[data-nsec-submit]").click();
const errorMessage = page.locator("[data-nsec-error]");
// This is expected to fail if the bug exists
await expect(errorMessage).toBeVisible();
await expect(errorMessage).toHaveText(
"Paste an nsec, hex key, or mnemonic to continue.",
);
});
});
@@ -0,0 +1,12 @@
# Issue 003: Popover ReferenceError
This reproducer attempts to reproduce the `ReferenceError` in popover placement tests.
## Run
```bash
npx playwright test examples/reproducers/issue-003-popover-ref-error/repro.spec.ts
```
## Expected Result
If the bug is present, the test will FAIL with `ReferenceError`.
Note: In some environments, this test might pass if the bug is flaky or fixed.
@@ -0,0 +1,95 @@
import { expect, test } from "@playwright/test";
import type { Locator } from "@playwright/test";
import {
applyReducedMotion,
failOnConsoleErrors,
} from "../../../tests/e2e/helpers/uiTestUtils";
// Function that might be causing reference error
async function getPanelWithTriggerMetrics(panel: Locator, trigger: Locator) {
const triggerHandle = await trigger.elementHandle();
if (!triggerHandle) {
throw new Error("Trigger element is not attached");
}
try {
return await panel.evaluate((node, triggerElement) => {
const rect = node.getBoundingClientRect();
const triggerRect = triggerElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const tokenValue = window
.getComputedStyle(document.documentElement)
.getPropertyValue("--popover-inline-safe-max")
.trim();
const popoverMaxWidth = window
.getComputedStyle(node)
.getPropertyValue("--popover-max-width")
.trim();
return {
rect: {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
},
triggerRect: {
top: triggerRect.top,
left: triggerRect.left,
right: triggerRect.right,
bottom: triggerRect.bottom,
width: triggerRect.width,
height: triggerRect.height,
},
viewport: { width: viewportWidth, height: viewportHeight },
placement: node.dataset.popoverPlacement || "",
state: node.dataset.popoverState || "",
popoverMaxWidth,
tokenMaxWidth: tokenValue,
};
}, triggerHandle);
} finally {
await triggerHandle.dispose();
}
}
function assertWithinViewport(metrics: {
rect: { top: number; left: number; right: number; bottom: number };
viewport: { width: number; height: number };
}) {
const tolerance = 0.5;
expect(metrics.rect.left).toBeGreaterThanOrEqual(-tolerance);
expect(metrics.rect.top).toBeGreaterThanOrEqual(-tolerance);
expect(metrics.rect.right).toBeLessThanOrEqual(metrics.viewport.width + tolerance);
expect(metrics.rect.bottom).toBeLessThanOrEqual(metrics.viewport.height + tolerance);
}
test.describe("popover demo alignments (reproducer)", () => {
test.beforeEach(async ({ page }) => {
await applyReducedMotion(page);
failOnConsoleErrors(page);
await page.goto("/views/dev/popover-demo.html", { waitUntil: "networkidle" });
});
test("aligns the video card menu with the trigger's right edge", async ({ page }) => {
const trigger = page.locator("[data-demo-card-more]");
await trigger.click();
const panel = page.locator('[data-component="overlay-root"] [data-popover-state="open"]');
await expect(panel).toBeVisible();
const metrics = await getPanelWithTriggerMetrics(panel, trigger);
assertWithinViewport(metrics);
expect(metrics.state).toBe("open");
expect(metrics.placement).toBe("bottom-end");
expect(metrics.popoverMaxWidth).toBe(metrics.tokenMaxWidth);
expect(metrics).toHaveProperty("popoverMaxWidth");
const tolerance = 1.5;
expect(Math.abs(metrics.rect.right - metrics.triggerRect.right)).toBeLessThanOrEqual(tolerance);
});
});
@@ -0,0 +1,13 @@
To run:
```bash
node --import ../../../tests/test-helpers/setup-localstorage.mjs repro.mjs
```
Error: Explosion!
Stack: Error: Explosion!
at Object.toString (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:103:29)
at String (<anonymous>)
at buildVideoMirrorEvent (file:///app/js/nostrEventSchemas.js:878:54)
at fuzzFunction (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:210:14)
at file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:244:3
@@ -0,0 +1,17 @@
import { buildVideoMirrorEvent } from '../../../js/nostrEventSchemas.js';
const args = [
{
"content": {}
}
];
console.log('Running reproduction for buildVideoMirrorEvent...');
try {
buildVideoMirrorEvent(...args);
console.log('No crash reproduced.');
} catch (error) {
console.log('Crash reproduced:');
console.error(error);
}
@@ -0,0 +1,12 @@
To run:
```bash
node --import ../../../tests/test-helpers/setup-localstorage.mjs repro.mjs
```
Error: Cannot convert object to primitive value
Stack: TypeError: Cannot convert object to primitive value
at String (<anonymous>)
at buildVideoMirrorEvent (file:///app/js/nostrEventSchemas.js:878:54)
at fuzzFunction (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:210:14)
at file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:244:3
@@ -0,0 +1,17 @@
import { buildVideoMirrorEvent } from '../../../js/nostrEventSchemas.js';
const args = [
{
"content": {}
}
];
console.log('Running reproduction for buildVideoMirrorEvent...');
try {
buildVideoMirrorEvent(...args);
console.log('No crash reproduced.');
} catch (error) {
console.log('Crash reproduced:');
console.error(error);
}
@@ -0,0 +1,13 @@
To run:
```bash
node --import ../../../tests/test-helpers/setup-localstorage.mjs repro.mjs
```
Error: Explosion!
Stack: Error: Explosion!
at Object.toString (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:103:29)
at String (<anonymous>)
at buildWatchHistoryEvent (file:///app/js/nostrEventSchemas.js:2147:54)
at fuzzFunction (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:210:14)
at file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:244:3
@@ -0,0 +1,17 @@
import { buildWatchHistoryEvent } from '../../../js/nostrEventSchemas.js';
const args = [
{
"content": {}
}
];
console.log('Running reproduction for buildWatchHistoryEvent...');
try {
buildWatchHistoryEvent(...args);
console.log('No crash reproduced.');
} catch (error) {
console.log('Crash reproduced:');
console.error(error);
}
@@ -0,0 +1,12 @@
To run:
```bash
node --import ../../../tests/test-helpers/setup-localstorage.mjs repro.mjs
```
Error: Cannot convert object to primitive value
Stack: TypeError: Cannot convert object to primitive value
at String (<anonymous>)
at buildWatchHistoryEvent (file:///app/js/nostrEventSchemas.js:2147:54)
at fuzzFunction (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:210:14)
at file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:244:3
@@ -0,0 +1,17 @@
import { buildWatchHistoryEvent } from '../../../js/nostrEventSchemas.js';
const args = [
{
"content": {}
}
];
console.log('Running reproduction for buildWatchHistoryEvent...');
try {
buildWatchHistoryEvent(...args);
console.log('No crash reproduced.');
} catch (error) {
console.log('Crash reproduced:');
console.error(error);
}
@@ -0,0 +1,13 @@
To run:
```bash
node --import ../../../tests/test-helpers/setup-localstorage.mjs repro.mjs
```
Error: Explosion!
Stack: Error: Explosion!
at Object.toString (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:103:29)
at String (<anonymous>)
at buildSubscriptionListEvent (file:///app/js/nostrEventSchemas.js:2183:54)
at fuzzFunction (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:210:14)
at file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:244:3
@@ -0,0 +1,17 @@
import { buildSubscriptionListEvent } from '../../../js/nostrEventSchemas.js';
const args = [
{
"content": {}
}
];
console.log('Running reproduction for buildSubscriptionListEvent...');
try {
buildSubscriptionListEvent(...args);
console.log('No crash reproduced.');
} catch (error) {
console.log('Crash reproduced:');
console.error(error);
}
@@ -0,0 +1,12 @@
To run:
```bash
node --import ../../../tests/test-helpers/setup-localstorage.mjs repro.mjs
```
Error: Cannot convert object to primitive value
Stack: TypeError: Cannot convert object to primitive value
at String (<anonymous>)
at buildSubscriptionListEvent (file:///app/js/nostrEventSchemas.js:2183:54)
at fuzzFunction (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:210:14)
at file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:244:3
@@ -0,0 +1,17 @@
import { buildSubscriptionListEvent } from '../../../js/nostrEventSchemas.js';
const args = [
{
"content": {}
}
];
console.log('Running reproduction for buildSubscriptionListEvent...');
try {
buildSubscriptionListEvent(...args);
console.log('No crash reproduced.');
} catch (error) {
console.log('Crash reproduced:');
console.error(error);
}
@@ -0,0 +1,13 @@
To run:
```bash
node --import ../../../tests/test-helpers/setup-localstorage.mjs repro.mjs
```
Error: Explosion!
Stack: Error: Explosion!
at Object.toString (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:103:29)
at String (<anonymous>)
at buildBlockListEvent (file:///app/js/nostrEventSchemas.js:2219:54)
at fuzzFunction (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:210:14)
at file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:244:3
@@ -0,0 +1,17 @@
import { buildBlockListEvent } from '../../../js/nostrEventSchemas.js';
const args = [
{
"content": {}
}
];
console.log('Running reproduction for buildBlockListEvent...');
try {
buildBlockListEvent(...args);
console.log('No crash reproduced.');
} catch (error) {
console.log('Crash reproduced:');
console.error(error);
}
@@ -0,0 +1,12 @@
To run:
```bash
node --import ../../../tests/test-helpers/setup-localstorage.mjs repro.mjs
```
Error: Cannot convert object to primitive value
Stack: TypeError: Cannot convert object to primitive value
at String (<anonymous>)
at buildBlockListEvent (file:///app/js/nostrEventSchemas.js:2219:54)
at fuzzFunction (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:210:14)
at file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:244:3
@@ -0,0 +1,17 @@
import { buildBlockListEvent } from '../../../js/nostrEventSchemas.js';
const args = [
{
"content": {}
}
];
console.log('Running reproduction for buildBlockListEvent...');
try {
buildBlockListEvent(...args);
console.log('No crash reproduced.');
} catch (error) {
console.log('Crash reproduced:');
console.error(error);
}
@@ -0,0 +1,13 @@
To run:
```bash
node --import ../../../tests/test-helpers/setup-localstorage.mjs repro.mjs
```
Error: Explosion!
Stack: Error: Explosion!
at Object.toString (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:103:29)
at String (<anonymous>)
at buildHashtagPreferenceEvent (file:///app/js/nostrEventSchemas.js:2249:54)
at fuzzFunction (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:210:14)
at file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:244:3
@@ -0,0 +1,17 @@
import { buildHashtagPreferenceEvent } from '../../../js/nostrEventSchemas.js';
const args = [
{
"content": {}
}
];
console.log('Running reproduction for buildHashtagPreferenceEvent...');
try {
buildHashtagPreferenceEvent(...args);
console.log('No crash reproduced.');
} catch (error) {
console.log('Crash reproduced:');
console.error(error);
}
@@ -0,0 +1,12 @@
To run:
```bash
node --import ../../../tests/test-helpers/setup-localstorage.mjs repro.mjs
```
Error: Cannot convert object to primitive value
Stack: TypeError: Cannot convert object to primitive value
at String (<anonymous>)
at buildHashtagPreferenceEvent (file:///app/js/nostrEventSchemas.js:2249:54)
at fuzzFunction (file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:210:14)
at file:///app/scripts/agent/fuzz-nostrEventSchemas.mjs:244:3
@@ -0,0 +1,17 @@
import { buildHashtagPreferenceEvent } from '../../../js/nostrEventSchemas.js';
const args = [
{
"content": {}
}
];
console.log('Running reproduction for buildHashtagPreferenceEvent...');
try {
buildHashtagPreferenceEvent(...args);
console.log('No crash reproduced.');
} catch (error) {
console.log('Crash reproduced:');
console.error(error);
}
@@ -0,0 +1,14 @@
To run:
```bash
node --import ../../../tests/test-helpers/setup-localstorage.mjs repro.mjs
```
Error: Cannot destructure property 'webSeed' of '(intermediate value)(intermediate value)(intermediate value)' as it is null.
Stack: TypeError: Cannot destructure property 'webSeed' of '(intermediate value)(intermediate value)(intermediate value)' as it is null.
at normalizeAndAugmentMagnet (file:///app/js/magnetUtils.js:20:5)
at fuzzNormalize (file:///app/scripts/agent/fuzz-magnetUtils.mjs:175:13)
at file:///app/scripts/agent/fuzz-magnetUtils.mjs:205:1
at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:665:26)
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)
@@ -0,0 +1,16 @@
import { normalizeAndAugmentMagnet } from '../../../js/magnetUtils.js';
const args = [
"magnet:?xt=urn:btih:0000000000000000000000000000000000000000",
null
];
console.log('Running reproduction for normalizeAndAugmentMagnet...');
try {
normalizeAndAugmentMagnet(...args);
console.log('No crash reproduced.');
} catch (error) {
console.log('Crash reproduced:');
console.error(error);
}
+2 -2
View File
@@ -54,8 +54,8 @@
const root = document.documentElement;
const themeMeta = document.querySelector('meta[name="theme-color"]');
const fallbackColors = {
dark: "#0f172a",
light: "#f8fafc",
dark: "rgb(15, 23, 42)",
light: "rgb(248, 250, 252)",
};
const prefersDark =
window.matchMedia &&
+7
View File
@@ -449,6 +449,13 @@ class AccessControl {
return Promise.resolve();
}
async waitForReady() {
if (this.hasLoaded) {
return;
}
return this.refresh();
}
whitelistMode() {
return this.whitelistEnabled;
}
+16 -1
View File
@@ -590,7 +590,22 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") {
async function loadNostrList(identifier) {
const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`;
const event = await fetchLatestListEvent({ "#d": [dTagValue] }, identifier);
let superAdminHex = "";
try {
superAdminHex = decodeNpubToHex(ADMIN_SUPER_NPUB);
} catch (error) {
devLogger.warn(
`[adminListStore] Failed to decode super admin npub for ${identifier} lookup:`,
error,
);
}
const filter = { "#d": [dTagValue] };
if (superAdminHex) {
filter.authors = [superAdminHex];
}
const event = await fetchLatestListEvent(filter, identifier);
return event ? extractNpubsFromEvent(event) : [];
}
+875 -470
View File
File diff suppressed because it is too large. Load diff
+29 -1
View File
@@ -4,6 +4,7 @@ import {
DEFAULT_TRUST_SEED_NPUBS as CONFIG_DEFAULT_TRUST_SEED_NPUBS,
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD as CONFIG_DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD as CONFIG_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
IS_DEV_MODE,
} from "./config.js";
function coerceNonNegativeInteger(value, fallback) {
@@ -147,6 +148,7 @@ const DEFAULT_FLAGS = Object.freeze({
FEATURE_WATCH_HISTORY_V2: true,
FEATURE_PUBLISH_NIP71: false,
FEATURE_HASHTAG_PREFERENCES: false,
FEATURE_SEARCH_FILTERS: Boolean(IS_DEV_MODE),
FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes.
FEATURE_TRUSTED_HIDE_CONTROLS: true,
FEATURE_IMPROVED_COMMENT_FETCHING: true, // Default true for comment persistence fixes
@@ -166,6 +168,7 @@ const runtimeFlags = (() => {
FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2,
FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71,
FEATURE_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES,
FEATURE_SEARCH_FILTERS: DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS,
FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS,
FEATURE_TRUSTED_HIDE_CONTROLS: DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS,
FEATURE_IMPROVED_COMMENT_FETCHING:
@@ -200,6 +203,11 @@ export let FEATURE_HASHTAG_PREFERENCES = coerceBoolean(
DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES
);
export let FEATURE_SEARCH_FILTERS = coerceBoolean(
runtimeFlags.FEATURE_SEARCH_FILTERS,
DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS
);
export let FEATURE_TRUST_SEEDS = coerceBoolean(
runtimeFlags.FEATURE_TRUST_SEEDS,
DEFAULT_FLAGS.FEATURE_TRUST_SEEDS
@@ -282,6 +290,20 @@ Object.defineProperty(runtimeFlags, "FEATURE_HASHTAG_PREFERENCES", {
},
});
Object.defineProperty(runtimeFlags, "FEATURE_SEARCH_FILTERS", {
configurable: true,
enumerable: true,
get() {
return FEATURE_SEARCH_FILTERS;
},
set(next) {
FEATURE_SEARCH_FILTERS = coerceBoolean(
next,
DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS
);
},
});
Object.defineProperty(runtimeFlags, "FEATURE_TRUST_SEEDS", {
configurable: true,
enumerable: true,
@@ -370,6 +392,7 @@ runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED;
runtimeFlags.FEATURE_WATCH_HISTORY_V2 = FEATURE_WATCH_HISTORY_V2;
runtimeFlags.FEATURE_PUBLISH_NIP71 = FEATURE_PUBLISH_NIP71;
runtimeFlags.FEATURE_HASHTAG_PREFERENCES = FEATURE_HASHTAG_PREFERENCES;
runtimeFlags.FEATURE_SEARCH_FILTERS = FEATURE_SEARCH_FILTERS;
runtimeFlags.FEATURE_TRUST_SEEDS = FEATURE_TRUST_SEEDS;
runtimeFlags.FEATURE_TRUSTED_HIDE_CONTROLS = FEATURE_TRUSTED_HIDE_CONTROLS;
runtimeFlags.FEATURE_IMPROVED_COMMENT_FETCHING =
@@ -412,10 +435,16 @@ export function setHashtagPreferencesEnabled(next) {
return FEATURE_HASHTAG_PREFERENCES;
}
export function setSearchFiltersEnabled(next) {
runtimeFlags.FEATURE_SEARCH_FILTERS = next;
return FEATURE_SEARCH_FILTERS;
}
export function resetRuntimeFlags() {
setUrlFirstEnabled(DEFAULT_FLAGS.URL_FIRST_ENABLED);
setWatchHistoryV2Enabled(DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2);
setHashtagPreferencesEnabled(DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES);
setSearchFiltersEnabled(DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS);
setTrustSeedsEnabled(DEFAULT_FLAGS.FEATURE_TRUST_SEEDS);
setTrustedHideControlsEnabled(DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS);
setImprovedCommentFetchingEnabled(
@@ -450,4 +479,3 @@ export function getTrustedMuteHideThreshold() {
export function getTrustedSpamHideThreshold() {
return TRUSTED_SPAM_HIDE_THRESHOLD;
}
+2
View File
@@ -6,6 +6,8 @@ const FALLBACK_CONTEXT = Object.freeze({
isNew: () => true,
});
export const BREAKPOINT_LG = 1024;
export function getDesignSystemMode() {
return NEW_MODE;
}
+3
View File
@@ -11,9 +11,12 @@ export {
createResolvePostedAtStage,
} from "./stages.js";
export { createExploreScorerStage } from "./exploreScoring.js";
export { createKidsScorerStage } from "./kidsScoring.js";
export { createKidsAudienceFilterStage } from "./kidsAudienceFilterStage.js";
export {
createChronologicalSorter,
createExploreDiversitySorter,
createKidsScoreSorter,
} from "./sorters.js";
export {
createActiveNostrSource,
+161
View File
@@ -0,0 +1,161 @@
// js/feedEngine/kidsAudienceFilterStage.js
import nostrService from "../services/nostrService.js";
import { toSet } from "./utils.js";
const DEFAULT_DISALLOWED_WARNINGS = [
"nudity",
"sexual",
"graphic-violence",
"self-harm",
"drugs",
];
function normalizeWarningValue(value) {
if (typeof value !== "string") {
return "";
}
return value.trim().toLowerCase().replace(/[\s_]+/g, "-");
}
function normalizeWarningList(values) {
const normalized = new Set();
if (values instanceof Set || Array.isArray(values)) {
for (const value of values) {
const entry = normalizeWarningValue(value);
if (entry) {
normalized.add(entry);
}
}
} else if (typeof values === "string") {
const entries = values.split(/[,;|]+/);
for (const entry of entries) {
const normalizedEntry = normalizeWarningValue(entry);
if (normalizedEntry) {
normalized.add(normalizedEntry);
}
}
}
return normalized;
}
function resolveDisallowedWarnings(context, fallback) {
const runtimeOverride = context?.runtime?.disallowedWarnings;
const configOverride = context?.config?.disallowedWarnings;
const resolved = runtimeOverride ?? configOverride ?? fallback;
return normalizeWarningList(resolved);
}
function parseContentWarnings(contentWarning) {
if (!contentWarning) {
return [];
}
if (Array.isArray(contentWarning)) {
return contentWarning
.map((value) => normalizeWarningValue(value))
.filter(Boolean);
}
if (typeof contentWarning === "string") {
return contentWarning
.split(/[,;|]+/)
.map((value) => normalizeWarningValue(value))
.filter(Boolean);
}
return [];
}
export function createKidsAudienceFilterStage({
stageName = "kids-audience-filter",
disallowedWarnings = DEFAULT_DISALLOWED_WARNINGS,
} = {}) {
return async function kidsAudienceFilterStage(items = [], context = {}) {
const results = [];
const disallowedSet = resolveDisallowedWarnings(context, disallowedWarnings);
const blacklist = toSet(context?.runtime?.blacklistedEventIds);
const isAuthorBlocked =
typeof context?.runtime?.isAuthorBlocked === "function"
? context.runtime.isAuthorBlocked
: () => false;
const options = { blacklistedEventIds: blacklist, isAuthorBlocked };
const includeFn =
typeof context?.runtime?.shouldIncludeVideo === "function"
? context.runtime.shouldIncludeVideo
: (video, includeOptions) => nostrService.shouldIncludeVideo(video, includeOptions);
for (const item of items) {
const video = item?.video;
if (!video || typeof video !== "object") {
results.push(item);
continue;
}
const videoId = typeof video.id === "string" ? video.id : null;
const addWhy = (reason, extra = {}) => {
context?.addWhy?.({
stage: stageName,
type: "filter",
reason,
videoId,
...extra,
});
};
if (video.isForKids !== true) {
addWhy("not-for-kids");
continue;
}
if (video.isNsfw === true) {
addWhy("nsfw");
continue;
}
if (video.invalid === true) {
addWhy("invalid");
continue;
}
let include = true;
try {
include = includeFn(video, options) !== false;
} catch (error) {
context?.log?.(`[${stageName}] shouldIncludeVideo threw`, error);
include = false;
}
if (!include) {
addWhy("blacklist");
continue;
}
if (disallowedSet.size > 0) {
const warnings = parseContentWarnings(video.contentWarning);
for (const warning of warnings) {
if (disallowedSet.has(warning)) {
addWhy("content-warning", { warning });
include = false;
break;
}
}
}
if (!include) {
continue;
}
results.push(item);
}
return results;
};
}
+424
View File
@@ -0,0 +1,424 @@
// js/feedEngine/kidsScoring.js
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
import { isPlainObject, toSet } from "./utils.js";
const DEFAULT_WEIGHTS = Object.freeze({
w_age: 0.35,
w_edu: 0.25,
w_author: 0.15,
w_pop: 0.1,
w_fresh: 0.1,
w_risk: 0.6,
});
const DEFAULT_FRESHNESS_HALF_LIFE_DAYS = 14;
const AGE_GROUP_DEFAULTS = Object.freeze({
toddler: {
maxDurationSeconds: 5 * 60,
preferredTags: ["toddler", "baby", "nursery", "colors", "shapes", "lullaby"],
educationalTags: ["abc", "numbers", "counting", "learning", "alphabet"],
},
preschool: {
maxDurationSeconds: 10 * 60,
preferredTags: ["preschool", "kindergarten", "storytime", "letters", "phonics"],
educationalTags: ["counting", "alphabet", "reading", "learning", "math"],
},
early: {
maxDurationSeconds: 15 * 60,
preferredTags: ["early", "kids", "reading", "science", "animals", "art"],
educationalTags: ["science", "math", "reading", "history", "geography"],
},
older: {
maxDurationSeconds: 20 * 60,
preferredTags: ["tween", "teens", "tutorial", "stem", "coding", "music"],
educationalTags: ["stem", "coding", "history", "geography", "tutorial"],
},
});
function clamp01(value) {
if (!Number.isFinite(value)) {
return 0;
}
if (value <= 0) {
return 0;
}
if (value >= 1) {
return 1;
}
return value;
}
function normalizeTagSetFromVideo(video) {
const normalized = new Set();
if (!video || typeof video !== "object") {
return normalized;
}
const addTag = (rawTag) => {
if (typeof rawTag !== "string") {
return;
}
const tag = normalizeHashtag(rawTag);
if (tag) {
normalized.add(tag);
}
};
if (Array.isArray(video.tags)) {
for (const tag of video.tags) {
if (Array.isArray(tag) && tag[0] === "t") {
addTag(tag[1]);
}
}
}
if (Array.isArray(video.nip71?.hashtags)) {
for (const tag of video.nip71.hashtags) {
addTag(tag);
}
}
return normalized;
}
function normalizeTagSet(values) {
const normalized = new Set();
if (!values) {
return normalized;
}
for (const value of toSet(values)) {
if (typeof value !== "string") {
continue;
}
const tag = normalizeHashtag(value);
if (tag) {
normalized.add(tag);
}
}
return normalized;
}
function resolveAgeProfile(ageGroup) {
const key = typeof ageGroup === "string" ? ageGroup.trim().toLowerCase() : "";
return AGE_GROUP_DEFAULTS[key] || AGE_GROUP_DEFAULTS.preschool;
}
function resolveDuration(video) {
const candidates = [video?.duration, video?.metadata?.duration];
for (const candidate of candidates) {
const value = Number(candidate);
if (Number.isFinite(value) && value > 0) {
return value;
}
}
return null;
}
function resolveWeights(options, context) {
const weights = { ...DEFAULT_WEIGHTS };
const overrides = [options?.weights, context?.runtime?.kidsWeights];
for (const candidate of overrides) {
if (!isPlainObject(candidate)) {
continue;
}
for (const key of Object.keys(weights)) {
if (Number.isFinite(candidate[key])) {
weights[key] = candidate[key];
}
}
}
return weights;
}
function resolveFreshnessHalfLife(options, context) {
const candidates = [options?.freshnessHalfLifeDays, context?.runtime?.kidsFreshnessHalfLifeDays];
for (const candidate of candidates) {
const value = Number(candidate);
if (Number.isFinite(value) && value > 0) {
return value;
}
}
return DEFAULT_FRESHNESS_HALF_LIFE_DAYS;
}
function resolvePopularityMax(options, context) {
const candidates = [
options?.popularityMax,
context?.runtime?.kidsPopularityMax,
context?.runtime?.explorePopularityMax,
];
for (const candidate of candidates) {
const value = Number(candidate);
if (Number.isFinite(value) && value > 0) {
return value;
}
}
return 0;
}
function resolveNow(context) {
const candidate = Number(context?.runtime?.now);
if (Number.isFinite(candidate) && candidate > 0) {
return Math.floor(candidate);
}
return Math.floor(Date.now() / 1000);
}
function resolveKidsPopularityValue(video, item) {
const candidates = [
video?.kidsViews,
video?.stats?.kidsViews,
video?.metrics?.kidsViews,
item?.metadata?.kidsViews,
];
for (const candidate of candidates) {
const value = Number(candidate);
if (Number.isFinite(value) && value >= 0) {
return value;
}
}
return null;
}
function resolveGlobalPopularityValue(video, item) {
const candidates = [
video?.viewCount,
video?.views,
video?.stats?.views,
video?.stats?.viewCount,
video?.metrics?.views,
item?.metadata?.viewCount,
];
for (const candidate of candidates) {
const value = Number(candidate);
if (Number.isFinite(value) && value >= 0) {
return value;
}
}
return 0;
}
function resolveSafetyScore(item) {
const moderation = item?.metadata?.moderation || item?.video?.moderation || {};
if (moderation.hidden === true) {
return 0;
}
const trustedMuted = moderation.trustedMuted === true;
const trustedCount = Number(moderation.trustedCount ?? moderation.trustedReportCount);
let penalty = 0;
if (Number.isFinite(trustedCount) && trustedCount > 0) {
penalty = Math.min(1, trustedCount / 3) * 0.7;
}
if (trustedMuted) {
penalty = Math.max(penalty, 0.9);
}
return clamp01(1 - penalty);
}
function computeTagMatchScore(tags, preferredTags) {
if (!tags || tags.size === 0 || preferredTags.size === 0) {
return 0.4;
}
let matches = 0;
for (const tag of preferredTags) {
if (tags.has(tag)) {
matches += 1;
}
}
const divisor = Math.min(3, preferredTags.size);
return clamp01(matches / divisor);
}
export function createKidsScorerStage({
stageName = "kids-scorer",
ageGroup = "preschool",
educationalTags,
weights,
...options
} = {}) {
const resolveAgeGroupFromContext = (context) => {
const candidates = [context?.runtime?.ageGroup, context?.config?.ageGroup, ageGroup];
for (const candidate of candidates) {
if (typeof candidate !== "string") {
continue;
}
const trimmed = candidate.trim();
if (trimmed) {
return trimmed;
}
}
return ageGroup;
};
const resolveEducationalTagsFromContext = (context) => {
const candidates = [
context?.runtime?.educationalTags,
context?.config?.educationalTags,
educationalTags,
];
for (const candidate of candidates) {
if (candidate !== undefined && candidate !== null) {
return candidate;
}
}
return null;
};
return async function kidsScorerStage(items = [], context = {}) {
if (!Array.isArray(items) || items.length === 0) {
return items;
}
const resolvedAgeGroup = resolveAgeGroupFromContext(context);
const ageProfile = resolveAgeProfile(resolvedAgeGroup);
const preferredTags = normalizeTagSet(ageProfile.preferredTags);
const defaultEducationalTags = normalizeTagSet(ageProfile.educationalTags);
const resolvedEducationalTags = resolveEducationalTagsFromContext(context);
const educationalTagSet =
resolvedEducationalTags !== null
? normalizeTagSet(resolvedEducationalTags)
: defaultEducationalTags;
const resolvedWeights = resolveWeights({ weights, ...options }, context);
const freshnessHalfLifeDays = resolveFreshnessHalfLife(options, context);
const popularityMax = resolvePopularityMax(options, context);
const now = resolveNow(context);
const trustedAuthors = toSet(context?.runtime?.trustedAuthors);
const trustedAuthorSet = new Set();
for (const author of trustedAuthors) {
if (typeof author === "string") {
trustedAuthorSet.add(author.toLowerCase());
}
}
for (const item of items) {
if (!item || typeof item !== "object") {
continue;
}
const video = item.video;
if (!video || typeof video !== "object") {
continue;
}
const tags = normalizeTagSetFromVideo(video);
const duration = resolveDuration(video);
let durationScore = 0.5;
if (Number.isFinite(duration) && duration > 0) {
const ratio = duration / ageProfile.maxDurationSeconds;
durationScore = ratio <= 1 ? 1 : clamp01(1 / ratio);
}
const tagScore = computeTagMatchScore(tags, preferredTags);
const ageAppropriateness = clamp01(0.6 * durationScore + 0.4 * tagScore);
let educationalBoost = 0;
if (educationalTagSet.size > 0 && tags.size > 0) {
let matches = 0;
for (const tag of educationalTagSet) {
if (tags.has(tag)) {
matches += 1;
}
}
const divisor = Math.min(3, educationalTagSet.size);
educationalBoost = clamp01(matches / divisor);
}
let authorTrust = 0;
if (trustedAuthorSet.size > 0 && typeof video.pubkey === "string") {
authorTrust = trustedAuthorSet.has(video.pubkey.toLowerCase()) ? 1 : 0;
}
let popularityWithinKids = 0;
const kidsViews = resolveKidsPopularityValue(video, item);
const popularityValue = kidsViews === null ? resolveGlobalPopularityValue(video, item) : kidsViews;
if (popularityMax > 0) {
const normalized = Math.log1p(popularityValue) / Math.log1p(popularityMax);
popularityWithinKids = clamp01(normalized);
} else if (popularityValue > 0) {
popularityWithinKids = clamp01(Math.log1p(popularityValue) / Math.log1p(popularityValue + 100));
}
let freshness = 0;
const createdAt = Number(video?.rootCreatedAt ?? video?.nip71Source?.created_at ?? video?.created_at);
if (Number.isFinite(createdAt) && createdAt > 0 && freshnessHalfLifeDays > 0) {
const ageSeconds = Math.max(0, now - Math.floor(createdAt));
const ageDays = ageSeconds / 86400;
const decay = Math.exp(-ageDays / freshnessHalfLifeDays);
freshness = clamp01(decay);
}
const safetyScore = resolveSafetyScore(item);
const baseScore =
resolvedWeights.w_age * ageAppropriateness +
resolvedWeights.w_edu * educationalBoost +
resolvedWeights.w_author * authorTrust +
resolvedWeights.w_pop * popularityWithinKids +
resolvedWeights.w_fresh * freshness;
const riskMultiplier = clamp01(1 - resolvedWeights.w_risk * (1 - safetyScore));
const kidsScore = clamp01(baseScore * riskMultiplier);
if (!isPlainObject(item.metadata)) {
item.metadata = {};
}
item.metadata.kidsScore = kidsScore;
item.metadata.kidsScoreComponents = {
ageAppropriateness,
educationalBoost,
authorTrust,
popularityWithinKids,
freshness,
safetyScore,
};
const videoId = typeof video.id === "string" ? video.id : null;
const pubkey = typeof video.pubkey === "string" ? video.pubkey : null;
const positiveComponents = [
{ key: "age-appropriateness", value: ageAppropriateness },
{ key: "educational-boost", value: educationalBoost },
{ key: "author-trust", value: authorTrust },
{ key: "popularity", value: popularityWithinKids },
{ key: "freshness", value: freshness },
];
let dominantPositive = positiveComponents[0];
for (const component of positiveComponents) {
if (component.value > dominantPositive.value) {
dominantPositive = component;
}
}
if (dominantPositive.value > 0) {
context?.addWhy?.({
stage: stageName,
type: "score",
reason: dominantPositive.key,
value: dominantPositive.value,
score: kidsScore,
videoId,
pubkey,
});
}
}
return items;
};
}
+47
View File
@@ -411,3 +411,50 @@ export function createExploreDiversitySorter({
return selected.concat(orderedRest);
};
}
export function createKidsScoreSorter({
stageName = "kids-score-sorter",
} = {}) {
return function kidsScoreSorter(items = [], context = {}) {
if (!Array.isArray(items)) {
return [];
}
const copy = [...items];
copy.sort((a, b) => {
const aScore = Number(a?.metadata?.kidsScore);
const bScore = Number(b?.metadata?.kidsScore);
const aHasScore = Number.isFinite(aScore);
const bHasScore = Number.isFinite(bScore);
const normalizedAScore = aHasScore ? aScore : Number.NEGATIVE_INFINITY;
const normalizedBScore = bHasScore ? bScore : Number.NEGATIVE_INFINITY;
if (normalizedAScore !== normalizedBScore) {
return normalizedBScore - normalizedAScore;
}
const aRootCreatedAt = Number(a?.video?.rootCreatedAt);
const bRootCreatedAt = Number(b?.video?.rootCreatedAt);
const normalizedARoot =
Number.isFinite(aRootCreatedAt) ? aRootCreatedAt : Number.NEGATIVE_INFINITY;
const normalizedBRoot =
Number.isFinite(bRootCreatedAt) ? bRootCreatedAt : Number.NEGATIVE_INFINITY;
if (normalizedARoot !== normalizedBRoot) {
return normalizedBRoot - normalizedARoot;
}
return stableVideoId(a).localeCompare(stableVideoId(b));
});
if (context?.addWhy && typeof context.addWhy === "function") {
context.addWhy({
stage: stageName,
type: "sort",
reason: "kids-score",
});
}
return copy;
};
}
+7 -2
View File
@@ -158,7 +158,7 @@ function normalizeResult(result) {
};
}
function queueProbe(magnet, cacheKey, priority = 0) {
function queueProbe(magnet, cacheKey, priority = 0, webSeeds = []) {
if (!magnet) {
return Promise.resolve(null);
}
@@ -180,6 +180,7 @@ function queueProbe(magnet, cacheKey, priority = 0) {
timeoutMs: PROBE_TIMEOUT_MS,
maxWebConns: 2,
polls: PROBE_POLL_COUNT,
urlList: webSeeds,
})
.catch((err) => ({
healthy: false,
@@ -584,7 +585,11 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) {
setBadge(card, "checking");
const probePromise = queueProbe(magnet, infoHash, priority);
const encodedUrl = card.dataset.urlHealthUrl || "";
const webSeed = encodedUrl ? decodeURIComponent(encodedUrl) : "";
const webSeeds = webSeed ? [webSeed] : [];
const probePromise = queueProbe(magnet, infoHash, priority, webSeeds);
pendingByCard.set(card, probePromise);
+4
View File
@@ -71,6 +71,10 @@ export function createHashChangeHandler({
typeof window !== "undefined" && window.location && window.location.hash
? window.location.hash
: "";
if (hash === "#kids") {
window.location.hash = "#view=kids";
return;
}
const match = hash.match(/^#view=([^&]+)/);
try {
+4 -2
View File
@@ -14,14 +14,16 @@ export function getHashViewName() {
return viewName.trim();
}
export function setHashView(viewName) {
export function setHashView(viewName, options = {}) {
if (typeof window === "undefined") {
return;
}
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("v");
if (!options?.preserveVideoParam) {
url.searchParams.delete("v");
}
const newUrl = url.pathname + url.search + `#view=${viewName}`;
window.history.replaceState({}, "", newUrl);
+165 -144
View File
@@ -232,7 +232,9 @@ function normalizeHistoryItems(rawItems) {
? candidate.watchedAt
: Number.isFinite(candidate?.timestamp)
? candidate.timestamp
: null;
: Number.isFinite(pointer?.watchedAt)
? pointer.watchedAt
: null;
const watchedAt =
watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : 0;
normalized.push({
@@ -1117,17 +1119,34 @@ function refreshRegisteredHistoryCards(video, context, pointerKeys = null) {
});
}
export function buildHistoryCard({ item, video, profile }) {
export function buildHistoryCard({ item, video, profile, variant }) {
const isCompact = variant === "compact" || variant === "modal";
const article = document.createElement("article");
article.className = "watch-history-card";
article.dataset.pointerKey = item.pointerKey;
article.dataset.historyCard = "true";
if (video?.isPrivate) {
article.classList.add("watch-history-card--private");
article.className =
"watch-history-card group relative flex flex-row items-start gap-4 p-3 sm:p-4 rounded-lg transition-colors";
if (!isCompact) {
article.classList.add("hover:bg-surface-panel-hover", "hover:shadow-sm");
} else {
article.classList.add("hover:bg-surface-highlight");
}
const primary = document.createElement("div");
primary.className = "watch-history-card__primary";
if (video?.isPrivate) {
article.classList.add(
"border",
"border-status-info",
"border-opacity-40",
"bg-surface-alt"
);
}
article.dataset.pointerKey = item.pointerKey;
article.dataset.historyCard = "true";
const pointerVideoId = getPointerVideoId(video, item.pointer);
if (pointerVideoId) {
article.dataset.videoId = pointerVideoId;
}
const playbackData = (() => {
if (!video) {
@@ -1143,10 +1162,10 @@ export function buildHistoryCard({ item, video, profile }) {
return { url, magnet: magnetRaw };
})();
const pointerVideoId = getPointerVideoId(video, item.pointer);
// --- Thumbnail Column ---
const thumbnailLink = document.createElement("a");
thumbnailLink.className = "watch-history-card__thumbnail";
const thumbnailSizeClass = isCompact ? "w-28 sm:w-32" : "w-32 sm:w-48";
thumbnailLink.className = `relative block flex-shrink-0 aspect-video rounded-md overflow-hidden bg-surface-alt hover:opacity-90 transition-opacity ${thumbnailSizeClass}`;
thumbnailLink.href = "#";
thumbnailLink.dataset.historyAction = "play";
thumbnailLink.dataset.pointerKey = item.pointerKey;
@@ -1160,9 +1179,8 @@ export function buildHistoryCard({ item, video, profile }) {
thumbnailLink.dataset.playMagnet = playbackData.magnet;
}
const thumbnailInner = document.createElement("div");
thumbnailInner.className = "watch-history-card__thumbnailInner";
const thumbnailImg = document.createElement("img");
thumbnailImg.className = "w-full h-full object-cover";
thumbnailImg.alt = video?.title || "Video thumbnail";
const thumbnailSrc =
video && typeof video.thumbnail === "string" && video.thumbnail.trim()
@@ -1171,14 +1189,19 @@ export function buildHistoryCard({ item, video, profile }) {
thumbnailImg.src = thumbnailSrc;
thumbnailImg.loading = "lazy";
thumbnailImg.decoding = "async";
thumbnailInner.appendChild(thumbnailImg);
thumbnailLink.appendChild(thumbnailInner);
thumbnailLink.appendChild(thumbnailImg);
const details = document.createElement("div");
details.className = "watch-history-card__details";
// --- Content Column ---
const content = document.createElement("div");
content.className = "flex flex-col flex-1 min-w-0 gap-1.5";
// Title Row
const header = document.createElement("div");
header.className = "flex justify-between items-start gap-2";
const titleLink = document.createElement("a");
titleLink.className = "watch-history-card__title";
titleLink.className =
"text-sm font-semibold text-text line-clamp-2 leading-snug hover:text-accent transition-colors";
titleLink.href = "#";
titleLink.dataset.historyAction = "play";
titleLink.dataset.pointerKey = item.pointerKey;
@@ -1193,164 +1216,160 @@ export function buildHistoryCard({ item, video, profile }) {
}
titleLink.textContent = video?.title || "Untitled video";
const created = document.createElement("p");
created.className = "watch-history-card__created";
const createdAt = Number.isFinite(video?.created_at)
? video.created_at
: null;
created.textContent = createdAt
? `Published ${formatRelativeTime(createdAt)}`
: "Published date unavailable";
header.appendChild(titleLink);
content.appendChild(header);
details.appendChild(titleLink);
details.appendChild(created);
const moderationMount = document.createElement("div");
moderationMount.className = "watch-history-card__moderation flex flex-wrap items-center gap-sm";
details.appendChild(moderationMount);
// Author Row
const authorRow = document.createElement("div");
authorRow.className = "flex items-center gap-2";
primary.appendChild(thumbnailLink);
primary.appendChild(details);
const meta = document.createElement("div");
meta.className = "watch-history-card__meta";
const watched = document.createElement("p");
watched.className = "watch-history-card__watched";
watched.textContent = item.watchedAt
? `Watched ${formatRelativeTime(item.watchedAt)}`
: "Watched time unavailable";
meta.appendChild(watched);
const creator = document.createElement("div");
creator.className = "watch-history-card__creator";
const creatorAvatarButton = document.createElement("button");
creatorAvatarButton.type = "button";
creatorAvatarButton.className = "watch-history-card__creatorAvatar";
creatorAvatarButton.dataset.historyAction = "channel";
creatorAvatarButton.dataset.pointerKey = item.pointerKey;
const avatarBtn = document.createElement("button");
avatarBtn.type = "button";
avatarBtn.className =
"flex-shrink-0 w-5 h-5 rounded-full overflow-hidden bg-surface-alt hover:opacity-80 transition-opacity";
avatarBtn.dataset.historyAction = "channel";
avatarBtn.dataset.pointerKey = item.pointerKey;
if (video?.pubkey) {
creatorAvatarButton.dataset.author = video.pubkey;
avatarBtn.dataset.author = video.pubkey;
}
const avatarImg = document.createElement("img");
avatarImg.className = "w-full h-full object-cover";
const avatarSrc =
profile && typeof profile.picture === "string" && profile.picture.trim()
? profile.picture.trim()
: FALLBACK_AVATAR;
avatarImg.src = avatarSrc;
avatarImg.alt = profile?.name || profile?.display_name || "Creator avatar";
creatorAvatarButton.appendChild(avatarImg);
avatarImg.alt = profile?.name || "Avatar";
avatarBtn.appendChild(avatarImg);
const creatorNameButton = document.createElement("button");
creatorNameButton.type = "button";
creatorNameButton.className = "watch-history-card__creatorName";
creatorNameButton.dataset.historyAction = "channel";
creatorNameButton.dataset.pointerKey = item.pointerKey;
const authorNameBtn = document.createElement("button");
authorNameBtn.type = "button";
authorNameBtn.className =
"text-xs font-medium text-muted hover:text-text transition-colors truncate";
authorNameBtn.dataset.historyAction = "channel";
authorNameBtn.dataset.pointerKey = item.pointerKey;
if (video?.pubkey) {
creatorNameButton.dataset.author = video.pubkey;
authorNameBtn.dataset.author = video.pubkey;
}
const app = getAppInstance();
let creatorLabel =
profile?.display_name || profile?.name || profile?.username || "Unknown";
if ((!creatorLabel || creatorLabel === "Unknown") && video?.pubkey) {
const encoded = app?.safeEncodeNpub?.(video.pubkey) || "";
creatorLabel = formatShortNpub(encoded) || encoded || video.pubkey.slice(0, 8).concat("…");
creatorLabel =
formatShortNpub(encoded) || encoded || video.pubkey.slice(0, 8).concat("…");
}
creatorNameButton.textContent = creatorLabel;
authorNameBtn.textContent = creatorLabel;
creator.appendChild(creatorAvatarButton);
creator.appendChild(creatorNameButton);
meta.appendChild(creator);
authorRow.appendChild(avatarBtn);
authorRow.appendChild(authorNameBtn);
content.appendChild(authorRow);
const actions = document.createElement("div");
actions.className = "watch-history-card__actions";
// Moderation Badge Mount
const moderationMount = document.createElement("div");
moderationMount.className = "flex flex-wrap items-center gap-2 mt-0.5";
content.appendChild(moderationMount);
const playButton = document.createElement("button");
playButton.type = "button";
playButton.className =
"watch-history-card__action watch-history-card__action--primary";
playButton.dataset.historyAction = "play";
playButton.dataset.pointerKey = item.pointerKey;
if (pointerVideoId) {
playButton.dataset.videoId = pointerVideoId;
}
if (playbackData.url) {
playButton.dataset.playUrl = encodeURIComponent(playbackData.url);
}
if (playbackData.magnet) {
playButton.dataset.playMagnet = playbackData.magnet;
}
playButton.textContent = "Play";
// Meta Row (Timestamp)
const metaRow = document.createElement("div");
metaRow.className =
"flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-strong mt-auto pt-1";
const channelButton = document.createElement("button");
channelButton.type = "button";
channelButton.className = "watch-history-card__action";
channelButton.dataset.historyAction = "channel";
channelButton.dataset.pointerKey = item.pointerKey;
if (video?.pubkey) {
channelButton.dataset.author = video.pubkey;
if (item.watchedAt) {
const watchedSpan = document.createElement("span");
watchedSpan.textContent = formatRelativeTime(item.watchedAt);
watchedSpan.title = new Date(item.watchedAt * 1000).toLocaleString();
metaRow.appendChild(watchedSpan);
} else {
const unknownSpan = document.createElement("span");
unknownSpan.textContent = "Time unavailable";
metaRow.appendChild(unknownSpan);
}
channelButton.textContent = "Open channel";
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className =
"watch-history-card__action watch-history-card__action--danger";
removeButton.dataset.variant = "danger";
removeButton.dataset.historyAction = "remove";
removeButton.dataset.pointerKey = item.pointerKey;
removeButton.textContent = "Remove from history";
if (item.pointer?.type) {
removeButton.dataset.pointerType = item.pointer.type;
}
if (item.pointer?.value) {
removeButton.dataset.pointerValue = item.pointer.value;
}
if (item.pointer?.relay) {
removeButton.dataset.pointerRelay = item.pointer.relay;
}
if (Number.isFinite(item.watchedAt)) {
removeButton.dataset.pointerWatchedAt = String(item.watchedAt);
}
if (item.pointer?.session === true) {
removeButton.dataset.pointerSession = "true";
}
removeButton.setAttribute(
"aria-label",
"Remove this history entry (sync may take a moment)."
);
removeButton.title =
"Removes this entry from history. Relay sync may take a moment.";
if (video?.created_at) {
const sep = document.createElement("span");
sep.className = "text-muted/40";
sep.textContent = "•";
metaRow.appendChild(sep);
actions.appendChild(playButton);
actions.appendChild(channelButton);
actions.appendChild(removeButton);
meta.appendChild(actions);
article.appendChild(primary);
article.appendChild(meta);
if (pointerVideoId) {
article.dataset.videoId = pointerVideoId;
const pubSpan = document.createElement("span");
pubSpan.textContent = `Published ${formatRelativeTime(video.created_at)}`;
metaRow.appendChild(pubSpan);
}
content.appendChild(metaRow);
// Actions Row
const actionsRow = document.createElement("div");
actionsRow.className =
"flex flex-wrap items-center gap-4 mt-2 pt-2 border-t border-border/40";
const createActionBtn = (label, action, btnVariant = "default") => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `text-xs font-semibold ${
btnVariant === "danger"
? "text-status-danger hover:text-status-danger-strong"
: "text-muted hover:text-text"
} transition-colors`;
btn.textContent = label;
btn.dataset.historyAction = action;
btn.dataset.pointerKey = item.pointerKey;
if (action === "play") {
if (pointerVideoId) btn.dataset.videoId = pointerVideoId;
if (playbackData.url)
btn.dataset.playUrl = encodeURIComponent(playbackData.url);
if (playbackData.magnet)
btn.dataset.playMagnet = playbackData.magnet;
} else if (action === "channel" && video?.pubkey) {
btn.dataset.author = video.pubkey;
} else if (action === "remove") {
if (item.pointer?.type) btn.dataset.pointerType = item.pointer.type;
if (item.pointer?.value) btn.dataset.pointerValue = item.pointer.value;
if (item.pointer?.relay) btn.dataset.pointerRelay = item.pointer.relay;
if (Number.isFinite(item.watchedAt))
btn.dataset.pointerWatchedAt = String(item.watchedAt);
if (item.pointer?.session === true)
btn.dataset.pointerSession = "true";
btn.title = "Remove from history";
}
return btn;
};
const playBtn = createActionBtn("Play now", "play", "default");
playBtn.classList.add("text-accent", "hover:text-accent-strong");
const channelBtn = createActionBtn("Channel", "channel");
const removeBtn = createActionBtn("Remove", "remove", "danger");
actionsRow.appendChild(playBtn);
actionsRow.appendChild(channelBtn);
actionsRow.appendChild(removeBtn);
content.appendChild(actionsRow);
article.appendChild(thumbnailLink);
article.appendChild(content);
const moderationContext = normalizeVideoModerationContext(video?.moderation);
const cardRef = {
pointerKey: item.pointerKey,
article,
primary,
meta,
details,
primary: thumbnailLink,
meta: content,
details: content,
badgeMount: moderationMount,
thumbnailInner,
thumbnailInner: null,
thumbnailLink,
titleLink,
playButton,
channelButton,
removeButton,
creatorNameButton,
avatarButton: creatorAvatarButton,
playButton: playBtn,
channelButton: channelBtn,
removeButton: removeBtn,
creatorNameButton: authorNameBtn,
avatarButton: avatarBtn,
video: video || null,
hiddenContainer: null,
badgeEl: null,
@@ -1420,7 +1439,8 @@ export function createWatchHistoryRenderer(config = {}) {
return controller.handleWatchHistoryRemoval(payload);
}
return defaultRemoveHandler(payload);
}
},
variant = "default"
} = config;
const syncEnabled =
@@ -1979,7 +1999,8 @@ export function createWatchHistoryRenderer(config = {}) {
const card = buildHistoryCard({
item: entry,
video: entry.metadata?.video || null,
profile: entry.metadata?.profile || null
profile: entry.metadata?.profile || null,
variant
});
container.list.appendChild(card);
});
+233 -195
View File
@@ -18,6 +18,13 @@ import {
} from "./themeController.js";
import { setHashView } from "./hashView.js";
import { devLogger, userLogger } from "./utils/logger.js";
import { parseFilterQuery } from "./search/searchFilters.js";
import {
buildSearchHashFromState,
getSearchFilterState,
resetSearchFilters,
setSearchFilterState,
} from "./search/searchFilterState.js";
import {
prepareStaticModal,
openStaticModal,
@@ -45,6 +52,7 @@ import { nostrClient } from "./nostrClientFacade.js";
import { userBlocks } from "./userBlocks.js";
import { relayManager } from "./relayManager.js";
import createApplication from "./bootstrap.js";
import SearchFilterModal from "./ui/components/SearchFilterModal.js";
validateInstanceConfig();
@@ -607,6 +615,7 @@ async function bootstrapInterface() {
loadModal("components/general-feedback-form.html"),
loadModal("components/feature-request-form.html"),
loadModal("components/bug-fix-form.html"),
loadModal("components/search-filter-modal.html"),
]);
devLogger.log("Modals loaded.");
@@ -639,201 +648,6 @@ async function bootstrapInterface() {
await loadSidebar("components/sidebar.html", "sidebarContainer");
devLogger.log("Sidebar loaded.");
try {
await applicationReadyPromise;
} catch (error) {
// fall through
}
if (application && typeof application.hydrateSidebarNavigation === "function") {
try {
application.hydrateSidebarNavigation();
} catch (error) {
devLogger.warn("[Interface] Failed to hydrate sidebar navigation:", error);
}
}
bindOptionalExternalLink({
selector: "[data-blog-link]",
url: BLOG_URL,
label: "Sidebar blog link",
});
bindOptionalExternalLink({
selector: "[data-nostr-link]",
url: NOSTR_URL,
label: "Sidebar Nostr link",
});
bindOptionalExternalLink({
selector: "[data-community-link]",
url: COMMUNITY_URL,
label: "Sidebar community link",
});
bindOptionalExternalLink({
selector: "[data-github-link]",
url: GITHUB_URL,
label: "Sidebar GitHub link",
});
bindOptionalExternalLink({
selector: "[data-beta-link]",
url: BETA_URL,
label: "Sidebar Beta link",
});
bindOptionalExternalLink({
selector: "[data-dns-link]",
url: DNS_URL,
label: "Sidebar DNS link",
});
bindOptionalExternalLink({
selector: "[data-tip-jar-link]",
url: TIP_JAR_URL,
label: "Sidebar Tip Jar link",
});
const headerSearchForm = document.getElementById("headerSearchForm");
if (headerSearchForm) {
headerSearchForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await applicationReadyPromise;
} catch (error) {
// Continue even if app fails full initialization, if possible.
}
const input = document.getElementById("headerSearchInput");
const query = input && input.value ? input.value.trim() : "";
if (!query) {
return;
}
// Check if it's an npub
try {
if (
window.NostrTools &&
window.NostrTools.nip19 &&
query.startsWith("npub1")
) {
const decoded = window.NostrTools.nip19.decode(query);
if (decoded && decoded.type === "npub") {
setHashView(`channel-profile&npub=${encodeURIComponent(query)}`);
return;
}
}
} catch (error) {
// Not a valid npub, proceed to search
}
setHashView(`search&q=${encodeURIComponent(query)}`);
});
}
// Mobile Search Logic
const mobileSearchFab = document.getElementById("mobileSearchFab");
const mobileSearchContainer = document.getElementById(
"mobileSearchContainer",
);
const mobileSearchForm = document.getElementById("mobileSearchForm");
const mobileSearchInput = document.getElementById("mobileSearchInput");
if (mobileSearchFab && mobileSearchContainer) {
const openMobileSearch = () => {
mobileSearchFab.classList.add("hidden");
mobileSearchContainer.classList.remove("hidden");
if (mobileSearchInput) {
mobileSearchInput.focus();
}
};
const closeMobileSearch = () => {
mobileSearchContainer.classList.add("hidden");
mobileSearchFab.classList.remove("hidden");
};
mobileSearchFab.addEventListener("click", (e) => {
e.stopPropagation(); // Prevent document click from immediately closing it
openMobileSearch();
});
// Close when clicking outside
document.addEventListener("click", (event) => {
// If mobile search is not visible, do nothing
if (mobileSearchContainer.classList.contains("hidden")) {
return;
}
// If click is on the FAB, do nothing (handled by FAB listener)
if (mobileSearchFab.contains(event.target)) {
return;
}
// If click is inside the search container, do nothing
if (mobileSearchContainer.contains(event.target)) {
return;
}
// Otherwise, close it
closeMobileSearch();
});
// Prevent clicks inside the container from closing it (redundant with above check but safe)
mobileSearchContainer.addEventListener("click", (e) => {
e.stopPropagation();
});
}
if (mobileSearchForm) {
mobileSearchForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await applicationReadyPromise;
} catch (error) {
// Continue even if app fails full initialization, if possible.
}
const query =
mobileSearchInput && mobileSearchInput.value
? mobileSearchInput.value.trim()
: "";
if (!query) {
return;
}
// Check if it's an npub (Copy logic from headerSearchForm)
try {
if (
window.NostrTools &&
window.NostrTools.nip19 &&
query.startsWith("npub1")
) {
const decoded = window.NostrTools.nip19.decode(query);
if (decoded && decoded.type === "npub") {
setHashView(`channel-profile&npub=${encodeURIComponent(query)}`);
// Close search on submit
mobileSearchContainer.classList.add("hidden");
mobileSearchFab.classList.remove("hidden");
return;
}
}
} catch (error) {
// Not a valid npub, proceed to search
}
setHashView(`search&q=${encodeURIComponent(query)}`);
// Close search on submit
mobileSearchContainer.classList.add("hidden");
mobileSearchFab.classList.remove("hidden");
});
}
const sidebar = document.getElementById("sidebar");
const collapseToggle = document.getElementById("sidebarCollapseToggle");
if (sidebar && !sidebar.hasAttribute("data-footer-state")) {
@@ -1171,6 +985,230 @@ async function bootstrapInterface() {
updateSidebarDropupContentWidth();
}
try {
await applicationReadyPromise;
} catch (error) {
// fall through
}
if (application && typeof application.hydrateSidebarNavigation === "function") {
try {
application.hydrateSidebarNavigation();
} catch (error) {
devLogger.warn("[Interface] Failed to hydrate sidebar navigation:", error);
}
}
bindOptionalExternalLink({
selector: "[data-blog-link]",
url: BLOG_URL,
label: "Sidebar blog link",
});
bindOptionalExternalLink({
selector: "[data-nostr-link]",
url: NOSTR_URL,
label: "Sidebar Nostr link",
});
bindOptionalExternalLink({
selector: "[data-community-link]",
url: COMMUNITY_URL,
label: "Sidebar community link",
});
bindOptionalExternalLink({
selector: "[data-github-link]",
url: GITHUB_URL,
label: "Sidebar GitHub link",
});
bindOptionalExternalLink({
selector: "[data-beta-link]",
url: BETA_URL,
label: "Sidebar Beta link",
});
bindOptionalExternalLink({
selector: "[data-dns-link]",
url: DNS_URL,
label: "Sidebar DNS link",
});
bindOptionalExternalLink({
selector: "[data-tip-jar-link]",
url: TIP_JAR_URL,
label: "Sidebar Tip Jar link",
});
const headerSearchForm = document.getElementById("headerSearchForm");
if (headerSearchForm) {
headerSearchForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await applicationReadyPromise;
} catch (error) {
// Continue even if app fails full initialization, if possible.
}
const input = document.getElementById("headerSearchInput");
const query = input && input.value ? input.value.trim() : "";
if (!query) {
return;
}
// Check if it's an npub
try {
if (
window.NostrTools &&
window.NostrTools.nip19 &&
query.startsWith("npub1")
) {
const decoded = window.NostrTools.nip19.decode(query);
if (decoded && decoded.type === "npub") {
setHashView(`channel-profile&npub=${encodeURIComponent(query)}`);
return;
}
}
} catch (error) {
// Not a valid npub, proceed to search
}
const parsedFilters = parseFilterQuery(query);
if (parsedFilters.errors.length > 0) {
devLogger.warn("[Search] Filter parsing errors", parsedFilters.errors);
}
const nextState = {
text: parsedFilters.text || "",
filters: parsedFilters.filters,
};
setSearchFilterState(nextState);
setHashView(buildSearchHashFromState(nextState));
});
}
// Mobile Search Logic
const mobileSearchFab = document.getElementById("mobileSearchFab");
const mobileSearchContainer = document.getElementById(
"mobileSearchContainer",
);
const mobileSearchForm = document.getElementById("mobileSearchForm");
const mobileSearchInput = document.getElementById("mobileSearchInput");
if (mobileSearchFab && mobileSearchContainer) {
const openMobileSearch = () => {
mobileSearchFab.classList.add("hidden");
mobileSearchContainer.classList.remove("hidden");
if (mobileSearchInput) {
mobileSearchInput.focus();
}
};
const closeMobileSearch = () => {
mobileSearchContainer.classList.add("hidden");
mobileSearchFab.classList.remove("hidden");
};
mobileSearchFab.addEventListener("click", (e) => {
e.stopPropagation(); // Prevent document click from immediately closing it
openMobileSearch();
});
// Close when clicking outside
document.addEventListener("click", (event) => {
// If mobile search is not visible, do nothing
if (mobileSearchContainer.classList.contains("hidden")) {
return;
}
// If click is on the FAB, do nothing (handled by FAB listener)
if (mobileSearchFab.contains(event.target)) {
return;
}
// If click is inside the search container, do nothing
if (mobileSearchContainer.contains(event.target)) {
return;
}
// Otherwise, close it
closeMobileSearch();
});
// Prevent clicks inside the container from closing it (redundant with above check but safe)
mobileSearchContainer.addEventListener("click", (e) => {
e.stopPropagation();
});
}
if (mobileSearchForm) {
mobileSearchForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await applicationReadyPromise;
} catch (error) {
// Continue even if app fails full initialization, if possible.
}
const query =
mobileSearchInput && mobileSearchInput.value
? mobileSearchInput.value.trim()
: "";
if (!query) {
return;
}
// Check if it's an npub (Copy logic from headerSearchForm)
try {
if (
window.NostrTools &&
window.NostrTools.nip19 &&
query.startsWith("npub1")
) {
const decoded = window.NostrTools.nip19.decode(query);
if (decoded && decoded.type === "npub") {
setHashView(`channel-profile&npub=${encodeURIComponent(query)}`);
// Close search on submit
mobileSearchContainer.classList.add("hidden");
mobileSearchFab.classList.remove("hidden");
return;
}
}
} catch (error) {
// Not a valid npub, proceed to search
}
const parsedFilters = parseFilterQuery(query);
if (parsedFilters.errors.length > 0) {
devLogger.warn("[Search] Filter parsing errors", parsedFilters.errors);
}
const nextState = {
text: parsedFilters.text || "",
filters: parsedFilters.filters,
};
setSearchFilterState(nextState);
setHashView(buildSearchHashFromState(nextState));
// Close search on submit
mobileSearchContainer.classList.add("hidden");
mobileSearchFab.classList.remove("hidden");
});
}
SearchFilterModal.init();
const searchFilterButtons = document.querySelectorAll(".header-search__filter");
searchFilterButtons.forEach((button) => {
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
SearchFilterModal.open();
});
});
try {
const sidebarModule = await import("./sidebar.js");
if (typeof sidebarModule.setupSidebarNavigation === "function") {
+119
View File
@@ -0,0 +1,119 @@
import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
const VIDEO_CARD_SELECTOR = "[data-component=\"video-card\"]";
const EMPTY_STATE_SELECTOR = "[data-kids-empty-state]";
const LOADING_SELECTOR = ".sidebar-loading-wrapper";
class KidsView {
constructor({ document: doc = document } = {}) {
this.document = doc;
this.container = null;
this.observer = null;
this.handleMutation = this.handleMutation.bind(this);
}
init() {
this.container = this.document?.getElementById("videoList") || null;
if (!this.container) {
return;
}
this.observeContainer();
this.updateEmptyState();
const infoTrigger = this.document.getElementById("kidsInfoTrigger");
if (infoTrigger) {
attachFeedInfoPopover(
infoTrigger,
"Kid-friendly videos filtered for younger audiences."
);
}
}
observeContainer() {
if (!this.container || typeof MutationObserver === "undefined") {
return;
}
if (this.observer) {
this.observer.disconnect();
}
this.observer = new MutationObserver(this.handleMutation);
this.observer.observe(this.container, {
childList: true,
subtree: true,
});
}
handleMutation() {
this.updateEmptyState();
}
updateEmptyState() {
if (!this.container) {
return;
}
const hasVideos = Boolean(this.container.querySelector(VIDEO_CARD_SELECTOR));
const isLoading = Boolean(this.container.querySelector(LOADING_SELECTOR));
const existingEmptyState = this.container.querySelector(EMPTY_STATE_SELECTOR);
if (isLoading) {
if (existingEmptyState) {
existingEmptyState.remove();
}
return;
}
if (hasVideos) {
if (existingEmptyState) {
existingEmptyState.remove();
}
return;
}
if (existingEmptyState) {
return;
}
const emptyState = this.buildEmptyState();
if (emptyState) {
this.container.innerHTML = "";
this.container.appendChild(emptyState);
}
}
buildEmptyState() {
if (!this.document) {
return null;
}
const wrapper = this.document.createElement("div");
wrapper.className = "col-span-full";
wrapper.dataset.kidsEmptyState = "true";
const card = this.document.createElement("div");
card.className = "card p-lg text-center";
const title = this.document.createElement("h3");
title.className = "text-lg font-semibold text-text";
title.textContent = "No kids videos available yet.";
const copy = this.document.createElement("p");
copy.className = "mt-2 text-sm text-muted";
copy.textContent = "Check back soon for new kid-friendly videos.";
card.appendChild(title);
card.appendChild(copy);
wrapper.appendChild(card);
return wrapper;
}
}
export function initKidsView() {
const view = new KidsView();
view.init();
return view;
}
+5 -3
View File
@@ -16,15 +16,17 @@ export const safeDecodeMagnet = sharedSafeDecodeMagnet;
export function normalizeAndAugmentMagnet(
rawValue,
{
options = {}
) {
const {
webSeed,
torrentUrl,
xs,
extraTrackers = [],
logger,
appProtocol,
} = {}
) {
} = options || {};
const safeExtraTrackers = Array.isArray(extraTrackers) ? extraTrackers : [];
const {
+66 -53
View File
@@ -423,15 +423,16 @@ function waitForTransaction(tx) {
/**
* Persists video events and tombstones to IndexedDB.
*
* Schema:
* **Schema:**
* - `events`: Store for video objects. Key: `id`.
* - `tombstones`: Store for deletion timestamps. Key: `key`.
* - `meta`: Store for versioning and timestamps. Key: `key`.
*
* Strategy:
* **Strategy: Incremental Persistence**
* - Snapshots are saved periodically (debounced).
* - "Fingerprints" (hashes/stringified) are tracked to minimize writes.
* - If the TTL expires, the cache is cleared on restore.
* - Only changed items are written to IDB; unchanged items are skipped.
* - If the TTL expires, the cache is cleared on restore to force a fresh fetch.
*/
class EventsCacheStore {
/**
@@ -3695,14 +3696,14 @@ export class NostrClient {
}
/**
* Initializes the client.
* Initializes the client and bootstraps the network layer.
*
* Flow:
* 1. Restores local data (IndexedDB/localStorage) to render the UI immediately ("stale-while-revalidate").
* 2. Schedules the restoration of any stored NIP-46 remote signer session.
* 3. Initializes the NostrTools `SimplePool` and connects to the configured relays.
* **Boot Sequence:**
* 1. **Offline Restore**: Loads cached events from IndexedDB/localStorage to render the UI immediately (Stale-While-Revalidate).
* 2. **Session Restore**: Attempts to reconnect to a stored NIP-46 remote signer (if any).
* 3. **Network Connect**: Initializes `SimplePool` and establishes WebSocket connections to relays.
*
* @returns {Promise<void>} Resolves when relays are connected (or at least attempted).
* @returns {Promise<void>} Resolves when the relay pool is initialized and connections are attempted.
*/
async init() {
devLogger.log("Connecting to relays...");
@@ -5159,21 +5160,21 @@ export class NostrClient {
}
/**
* Publishes a video event (Kind 30078).
* Publishes a new video event (Kind 30078) to the network.
*
* This constructs a V3 video note which includes:
* - Core metadata (title, description, thumbnail).
* - `magnet`: The Magnet URI (XT=InfoHash, WS=WebSeed, XS=TorrentMetadata).
* - `url`: The direct HTTP URL (WebSeed).
* **Payload Construction:**
* - Creates a V3 video note with `magnet`, `url` (WebSeed), and core metadata.
* - Generates a unique `d` tag and `videoRootId` for this new series.
*
* Side Effects:
* - NIP-94 Mirror: Optionally publishes a Kind 1063 file header event if a hosted URL is provided.
* - NIP-71: Optionally publishes a Kind 22 (Video Wrapper) for categorization if metadata is provided.
* **Side Effects (in order):**
* 1. **Primary Event**: Signs and publishes the Kind 30078 Video Note.
* 2. **NIP-94 Mirror**: If a hosted `url` is provided, publishes a Kind 1063 File Header event (for clients that only support NIP-94).
* 3. **NIP-71 Metadata**: If categories/tags are present, publishes a Kind 22 Video Wrapper event linked to the primary event.
*
* @param {object} videoPayload - The video metadata and form data.
* @param {object} videoPayload - The video metadata, form data, and NIP-71 attributes.
* @param {string} pubkey - The public key of the publisher.
* @returns {Promise<import("nostr-tools").Event>} The signed and published event.
* @throws {Error} If not logged in or publishing fails.
* @returns {Promise<import("nostr-tools").Event>} The signed and published Kind 30078 event.
* @throws {Error} If not logged in or if the primary publish fails.
*/
async publishVideo(videoPayload, pubkey) {
if (!pubkey) throw new Error("Not logged in to publish video.");
@@ -5528,15 +5529,15 @@ export class NostrClient {
}
/**
* Edits a video by creating a new event that reuses the existing d tag
* so subsequent publishes overwrite the same NIP-33 addressable record
* while keeping the original videoRootId.
* Edits an existing video by publishing a new version (Kind 30078).
*
* This version forces version=2 for the original note and uses
* lowercase comparison for public keys.
* **Mechanism:**
* - Reuses the original `d` tag to ensure NIP-33 addressability (clients see the edit as the "latest" version).
* - Preserves the `videoRootId` to maintain the history chain.
* - Enforces ownership (pubkey match).
*
* @param {object} originalEventStub - The original video event (must have `id`).
* @param {object} updatedData - The new metadata to apply.
* @param {object} updatedData - The new metadata to apply (title, magnet, NIP-71, etc.).
* @param {string} userPubkey - The public key of the editor (must match owner).
* @returns {Promise<import("nostr-tools").Event>} The signed and published edit event.
* @throws {Error} If permission denied, ownership mismatch, or publish failure.
@@ -6435,22 +6436,25 @@ export class NostrClient {
}
/**
* Subscribes to Kind 30078 video events from relays.
* Subscribes to Kind 30078 video events from relays using a buffered stream.
*
* Strategy: "Buffering & Debouncing"
* **Why Buffer?**
* Relays often send bursts of events (e.g., historical dumps or new floods).
* Processing every single event immediately would cause layout thrashing and UI lag.
*
* Instead, we:
* 1. Push incoming events into `eventBuffer`.
* 2. Schedule a flush operation (`flushEventBuffer`) with a short delay (75ms).
* 3. When the flush runs, we process the entire batch:
* - Convert/Validate events.
* - Update the `activeMap` (latest version logic).
* - Persist to local cache.
* - Notify the UI (`onVideo`).
* **The Algorithm:**
* 1. **Buffer**: Push all incoming events into `eventBuffer`.
* 2. **Debounce**: Schedule `flushEventBuffer` to run after 75ms of silence or inactivity.
* 3. **Batch Process**:
* - Convert events to internal Video objects.
* - Filter out invalids or duplicates.
* - Apply "Last-Write-Wins" logic against `activeMap`.
* - Notify the UI (`onVideo`) once per batch.
* 4. **Persist**: Save the batch to IndexedDB to warm up the cache for next reload.
*
* This ensures O(1) renders per batch instead of O(N) renders per event.
* **Invariants:**
* - `activeMap` always holds the latest valid version of a video.
* - Deleted events (Tombstones) are never surfaced to `onVideo`.
*
* @param {function(object): void} onVideo - Callback fired when new valid videos are processed. Receives the `Video` object.
* @param {{ since?: number, until?: number, limit?: number }} [options] - Filter options.
@@ -6487,7 +6491,9 @@ export class NostrClient {
const sub = this.pool.sub(this.relays, [filter]);
const invalidDuringSub = [];
// We'll collect events here instead of processing them instantly
// BUFFERING STATE
// We collect events here instead of processing them instantly to avoid
// 1000s of React re-renders during the initial relay dump.
let eventBuffer = [];
const EVENT_FLUSH_DEBOUNCE_MS = 75;
let flushTimerId = null;
@@ -6554,7 +6560,9 @@ export class NostrClient {
continue;
}
// Latest-wins logic: only update activeMap if this version is newer
// LATEST-WINS LOGIC
// We only update the UI if the incoming video is newer than what we have.
// This handles the "Edit" case where multiple versions exist on relays.
const prevActive = this.activeMap.get(activeKey);
if (!prevActive || video.created_at > prevActive.created_at) {
this.activeMap.set(activeKey, video);
@@ -7386,21 +7394,25 @@ export class NostrClient {
}
/**
* Fetches the complete edit history for a video.
* Fetches and reconstructs the full edit history of a video.
*
* Why this is complex:
* 1. **Video Root ID**: Modern videos use a `videoRootId` inside the JSON content to link versions.
* 2. **NIP-33 `d` Tags**: Relays index by the `d` tag, not the JSON content.
* 3. **Legacy Data**: Older videos might not have a `videoRootId` and rely solely on the `d` tag or even the original Event ID.
* **The Problem:**
* Nostr events are immutable. "Editing" a video creates a NEW event.
* We need to find all previous versions to show a history log or allow reverting.
*
* Strategy:
* - First, check local memory (`allEvents`) for matches on `videoRootId` OR `d` tag.
* - If the root event is missing locally, fetch it by ID.
* - If we suspect missing history (e.g., only 1 version found), perform a relay query for the specific `d` tag.
* - Merge and sort all findings to present a linear history.
* **The Linking Logic:**
* 1. **Modern**: Versions share a `videoRootId` field in their content.
* 2. **NIP-33**: Versions share the same `d` tag.
* 3. **Legacy**: Older versions might only be linked by the `d` tag or lack a root pointer entirely.
*
* @param {object} video - The video object to find history for.
* @returns {Promise<object[]>} Sorted array of video objects (newest first).
* **Algorithm:**
* 1. **Local Scan**: Search `allEvents` for any event matching the target's `videoRootId` OR `d` tag.
* 2. **Root Recovery**: If the `videoRootId` refers to an event we don't have, fetch it specifically (to establish the timeline start).
* 3. **Relay Query**: If local history is sparse (<= 1 version), query relays for all events with the same `d` tag.
* 4. **Merge & Sort**: Combine all findings, filter out unrelated events, and sort by `created_at` descending.
*
* @param {object} video - The target video to find history for.
* @returns {Promise<object[]>} A promise resolving to an array of Video objects (Newest -> Oldest).
*/
async hydrateVideoHistory(video) {
if (!video || typeof video !== "object") {
@@ -7434,9 +7446,10 @@ export class NostrClient {
typeof candidate.videoRootId === "string" ? candidate.videoRootId : "";
const candidateDTag = this.resolveEventDTag(candidate, video);
// A video is "the same" if:
// HISTORY MATCHING LOGIC
// A video is part of the same history if:
// 1. It shares the same V3 `videoRootId`.
// 2. It shares the same NIP-33 `d` tag (for addressable updates).
// 2. It shares the same NIP-33 `d` tag (addressable events).
const sameRoot = targetRoot && candidateRoot === targetRoot;
const sameD = targetDTag && candidateDTag === targetDTag;
+6 -2
View File
@@ -1110,6 +1110,7 @@ export async function publishComment(
{
shouldRequestExtensionPermissions,
DEFAULT_NIP07_PERMISSION_METHODS,
resolveActiveSigner,
} = {},
) {
if (!client?.pool) {
@@ -1121,7 +1122,7 @@ export async function publishComment(
"Publishing comments is not allowed for session actors."
);
error.code = "session-actor-publish-blocked";
return { ok: false, error };
return { ok: false, error: "session-actor-publish-blocked", details: error };
}
const descriptor = normalizeCommentTarget(targetInput, options);
@@ -1189,7 +1190,9 @@ export async function publishComment(
let signedEvent = null;
const signer = getActiveSigner();
const signer = resolveActiveSigner
? resolveActiveSigner()
: getActiveSigner();
if (!signer || typeof signer.signEvent !== "function") {
const error = new Error(
@@ -1560,4 +1563,5 @@ export const __testExports = {
normalizeCommentTarget,
createVideoCommentFilters,
isVideoCommentEvent,
commentCache,
};
+293 -25
View File
@@ -16,6 +16,7 @@ export const NOTE_TYPES = Object.freeze({
NIP71_VIDEO: "nip71Video",
NIP71_SHORT_VIDEO: "nip71ShortVideo",
REPOST: "repost",
SHARE: "share",
RELAY_LIST: "relayList",
VIEW_EVENT: "viewEvent",
VIDEO_REACTION: "videoReaction",
@@ -59,6 +60,20 @@ let cachedUtf8Encoder = null;
const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]);
const HEX_32_BYTE_REGEX = /^[0-9a-f]{64}$/i;
function safeStringify(value) {
if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
return "";
}
try {
return String(value);
} catch (err) {
return "";
}
}
function normalizePointerIdentifier(value) {
if (typeof value !== "string") {
return "";
@@ -283,6 +298,19 @@ const BASE_SCHEMAS = {
description: "Content field contains the JSON-serialized event being reposted.",
},
},
[NOTE_TYPES.SHARE]: {
type: NOTE_TYPES.SHARE,
label: "Share note",
kind: 1,
relayTagName: "r",
readMarker: "read",
writeMarker: "write",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Plaintext share note referencing a video.",
},
},
[NOTE_TYPES.NIP71_VIDEO]: {
type: NOTE_TYPES.NIP71_VIDEO,
label: "NIP-71 video (normal)",
@@ -856,13 +884,19 @@ export function buildVideoMirrorEvent(params) {
}
});
}
return {
const event = {
kind: schema?.kind ?? 1063,
pubkey,
created_at,
tags: combinedTags,
content: typeof content === "string" ? content : String(content ?? ""),
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.VIDEO_MIRROR, event);
}
return event;
}
export function buildRepostEvent(params) {
@@ -972,13 +1006,132 @@ export function buildRepostEvent(params) {
}
}
return {
const event = {
kind: resolvedKind,
pubkey,
created_at,
tags,
content,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.REPOST, event);
}
return event;
}
export function buildShareEvent(params) {
const {
pubkey,
created_at,
content = "",
video = null,
relays = [],
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.SHARE);
const tags = [];
const relayTagName = schema?.relayTagName || "r";
const readMarker = schema?.readMarker || "read";
const writeMarker = schema?.writeMarker || "write";
const normalizedVideoId = normalizePointerIdentifier(video?.id);
if (normalizedVideoId) {
tags.push(["e", normalizedVideoId, "", "mention"]);
}
const normalizedVideoPubkey = normalizePointerIdentifier(video?.pubkey);
if (normalizedVideoPubkey) {
tags.push(["p", normalizedVideoPubkey, "", "mention"]);
}
const seenRelayTags = new Set();
const pushRelayTag = (url, marker) => {
const normalizedUrl = typeof url === "string" ? url.trim() : "";
if (!normalizedUrl) {
return;
}
const normalizedMarker =
typeof marker === "string" ? marker.trim().toLowerCase() : "";
const resolvedMarker =
normalizedMarker === "read" || normalizedMarker === "write"
? normalizedMarker
: "";
const dedupeKey = `${normalizedUrl}|${resolvedMarker}`;
if (seenRelayTags.has(dedupeKey)) {
return;
}
seenRelayTags.add(dedupeKey);
if (resolvedMarker) {
const markerValue = resolvedMarker === "read" ? readMarker : writeMarker;
tags.push([relayTagName, normalizedUrl, markerValue]);
} else {
tags.push([relayTagName, normalizedUrl]);
}
};
if (Array.isArray(relays)) {
relays.forEach((entry) => {
if (typeof entry === "string") {
pushRelayTag(entry, "");
return;
}
if (Array.isArray(entry) && entry.length) {
if (entry[0] === relayTagName) {
pushRelayTag(entry[1], entry[2]);
return;
}
pushRelayTag(entry[0], entry[1]);
return;
}
if (entry && typeof entry === "object") {
const url =
typeof entry.url === "string"
? entry.url
: typeof entry.relay === "string"
? entry.relay
: "";
let marker = "";
if (typeof entry.mode === "string") {
marker = entry.mode;
} else if (typeof entry.marker === "string") {
marker = entry.marker;
} else if (entry.read === true && entry.write === false) {
marker = "read";
} else if (entry.write === true && entry.read === false) {
marker = "write";
}
pushRelayTag(url, marker);
}
});
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const resolvedContent = ensureValidUtf8Content(content);
const event = {
kind: schema?.kind ?? 1,
pubkey,
created_at,
tags,
content: resolvedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.SHARE, event);
}
return event;
}
export function buildRelayListEvent(params) {
@@ -1043,13 +1196,19 @@ export function buildRelayListEvent(params) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
return {
const event = {
kind: schema?.kind ?? 10002,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.RELAY_LIST, event);
}
return event;
}
export function buildDmRelayListEvent(params) {
@@ -1079,13 +1238,19 @@ export function buildDmRelayListEvent(params) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
return {
const event = {
kind: schema?.kind ?? 10050,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DM_RELAY_LIST, event);
}
return event;
}
export function buildProfileMetadataEvent(params) {
@@ -1115,13 +1280,19 @@ export function buildProfileMetadataEvent(params) {
}
}
return {
const event = {
kind: schema?.kind ?? 0,
pubkey,
created_at,
tags,
content: serializedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.PROFILE_METADATA, event);
}
return event;
}
export function buildMuteListEvent(params) {
@@ -1156,13 +1327,19 @@ export function buildMuteListEvent(params) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
return {
const event = {
kind: schema?.kind ?? 10000,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.MUTE_LIST, event);
}
return event;
}
export function buildDeletionEvent(params) {
@@ -1201,13 +1378,19 @@ export function buildDeletionEvent(params) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
return {
const event = {
kind: schema?.kind ?? 5,
pubkey,
created_at,
tags,
content: typeof reason === "string" ? reason : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DELETION, event);
}
return event;
}
export function buildLegacyDirectMessageEvent(params) {
@@ -1232,13 +1415,19 @@ export function buildLegacyDirectMessageEvent(params) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
return {
const event = {
kind: schema?.kind ?? 4,
pubkey,
created_at,
tags,
content: typeof ciphertext === "string" ? ciphertext : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.LEGACY_DM, event);
}
return event;
}
export function buildDmAttachmentEvent(params) {
@@ -1282,13 +1471,19 @@ export function buildDmAttachmentEvent(params) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
return {
const event = {
kind: schema?.kind ?? 15,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DM_ATTACHMENT, event);
}
return event;
}
export function buildDmReadReceiptEvent(params) {
@@ -1323,13 +1518,19 @@ export function buildDmReadReceiptEvent(params) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
return {
const event = {
kind: schema?.kind ?? 20001,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DM_READ_RECEIPT, event);
}
return event;
}
export function buildDmTypingIndicatorEvent(params) {
@@ -1368,13 +1569,19 @@ export function buildDmTypingIndicatorEvent(params) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
return {
const event = {
kind: schema?.kind ?? 20002,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DM_TYPING, event);
}
return event;
}
export function buildViewEvent(params) {
@@ -1429,13 +1636,19 @@ export function buildViewEvent(params) {
const resolvedContent = ensureValidUtf8Content(content);
return {
const event = {
kind: schema?.kind ?? WATCH_HISTORY_KIND,
pubkey,
created_at,
tags,
content: resolvedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.VIEW_EVENT, event);
}
return event;
}
export function buildZapRequestEvent(params) {
@@ -1503,13 +1716,19 @@ export function buildZapRequestEvent(params) {
const resolvedContent = ensureValidUtf8Content(content);
return {
const event = {
kind: schema?.kind ?? 9734,
pubkey,
created_at,
tags,
content: resolvedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.ZAP_REQUEST, event);
}
return event;
}
export function buildReactionEvent(params) {
@@ -1635,13 +1854,19 @@ export function buildReactionEvent(params) {
const resolvedContent = ensureValidUtf8Content(content);
return {
const event = {
kind: schema?.kind ?? 7,
pubkey,
created_at,
tags,
content: resolvedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.VIDEO_REACTION, event);
}
return event;
}
export function buildCommentEvent(params) {
@@ -1880,13 +2105,19 @@ export function buildCommentEvent(params) {
const resolvedContent = ensureValidUtf8Content(content);
return {
const event = {
kind: schema?.kind ?? 1111,
pubkey,
created_at,
tags,
content: resolvedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.VIDEO_COMMENT, event);
}
return event;
}
export function buildWatchHistoryEvent(params) {
@@ -1922,13 +2153,19 @@ export function buildWatchHistoryEvent(params) {
appendSchemaTags(tags, schema);
return {
const event = {
kind: schema?.kind ?? WATCH_HISTORY_KIND,
pubkey,
created_at: Number.isFinite(created_at) ? created_at : Math.floor(Date.now() / 1000),
tags,
content: typeof content === "string" ? content : String(content ?? ""),
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.WATCH_HISTORY, event);
}
return event;
}
export function buildSubscriptionListEvent(params) {
@@ -1951,13 +2188,20 @@ export function buildSubscriptionListEvent(params) {
tags.push(["encrypted", normalizedEncryption]);
}
appendSchemaTags(tags, schema);
return {
const event = {
kind: schema?.kind ?? 30000,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : String(content ?? ""),
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.SUBSCRIPTION_LIST, event);
}
return event;
}
export function buildBlockListEvent(params) {
@@ -1980,13 +2224,20 @@ export function buildBlockListEvent(params) {
tags.push(["encrypted", normalizedEncryption]);
}
appendSchemaTags(tags, schema);
return {
const event = {
kind: schema?.kind ?? 10000,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : String(content ?? ""),
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.USER_BLOCK_LIST, event);
}
return event;
}
export function buildHashtagPreferenceEvent(params) {
@@ -2003,13 +2254,20 @@ export function buildHashtagPreferenceEvent(params) {
tags.push([identifierName, identifierValue]);
}
appendSchemaTags(tags, schema);
return {
const event = {
kind: schema?.kind ?? 30015,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : String(content ?? ""),
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.HASHTAG_PREFERENCES, event);
}
return event;
}
function resolveAdminNoteType(listKey) {
@@ -2057,13 +2315,23 @@ export function buildAdminListEvent(listKey, params) {
}
});
appendSchemaTags(tags, schema);
return {
const event = {
kind: schema?.kind ?? 30000,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
const resolvedType = resolveAdminNoteType(listKey);
if (resolvedType) {
validateEventAgainstSchema(resolvedType, event);
}
}
return event;
}
function hasTag(tags, tagName, tagValue = null) {
+40
View File
@@ -0,0 +1,40 @@
const listeners = new Set();
const cloneFacetList = (list) =>
Array.isArray(list) ? list.map((entry) => ({ ...entry })) : [];
let state = {
tags: [],
authors: [],
relays: [],
};
function notify() {
const snapshot = getSearchFacetCounts();
listeners.forEach((listener) => listener(snapshot));
}
export function getSearchFacetCounts() {
return {
tags: cloneFacetList(state.tags),
authors: cloneFacetList(state.authors),
relays: cloneFacetList(state.relays),
};
}
export function setSearchFacetCounts(nextState = {}) {
state = {
tags: cloneFacetList(nextState.tags),
authors: cloneFacetList(nextState.authors),
relays: cloneFacetList(nextState.relays),
};
notify();
}
export function subscribeSearchFacetCounts(listener) {
if (typeof listener !== "function") {
return () => {};
}
listeners.add(listener);
return () => listeners.delete(listener);
}
+80
View File
@@ -0,0 +1,80 @@
import { ALLOW_NSFW_CONTENT } from "../config.js";
export function buildVideoSearchFilterMatcher(filters = {}, options = {}) {
const allowNsfw = options.allowNsfw ?? ALLOW_NSFW_CONTENT;
const authorSet = new Set(
Array.isArray(filters?.authorPubkeys)
? filters.authorPubkeys.map((author) => author.toLowerCase())
: [],
);
const tagSet = new Set(
Array.isArray(filters?.tags) ? filters.tags.map((tag) => tag.toLowerCase()) : [],
);
const afterDate = Number.isFinite(filters?.dateRange?.after)
? filters.dateRange.after
: null;
const beforeDate = Number.isFinite(filters?.dateRange?.before)
? filters.dateRange.before
: null;
const minDuration = Number.isFinite(filters?.duration?.minSeconds)
? filters.duration.minSeconds
: null;
const maxDuration = Number.isFinite(filters?.duration?.maxSeconds)
? filters.duration.maxSeconds
: null;
const hasMagnet = filters?.hasMagnet === true;
const hasUrl = filters?.hasUrl === true;
const nsfwFilter = typeof filters?.nsfw === "string" ? filters.nsfw : "any";
return (video) => {
if (!video) return false;
if (authorSet.size > 0) {
const pubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : "";
if (!pubkey || !authorSet.has(pubkey)) return false;
}
if (tagSet.size > 0) {
const tags = Array.isArray(video.tags) ? video.tags : [];
const hasTag = tags.some((tag) => {
if (Array.isArray(tag)) {
return tag[0] === "t" && tagSet.has(String(tag[1] || "").toLowerCase());
}
return tagSet.has(String(tag || "").toLowerCase());
});
if (!hasTag) return false;
}
if (Number.isFinite(afterDate) && (!Number.isFinite(video.created_at) || video.created_at < afterDate)) {
return false;
}
if (Number.isFinite(beforeDate) && (!Number.isFinite(video.created_at) || video.created_at > beforeDate)) {
return false;
}
if (hasMagnet && !video.magnet) return false;
if (hasUrl && !video.url) return false;
if (Number.isFinite(minDuration) || Number.isFinite(maxDuration)) {
const duration = Number(video.nip71?.duration);
if (!Number.isFinite(duration)) return false;
if (Number.isFinite(minDuration) && duration < minDuration) return false;
if (Number.isFinite(maxDuration) && duration > maxDuration) return false;
}
if (allowNsfw !== true && video.isNsfw === true) {
return false;
}
if (allowNsfw === true) {
if ((nsfwFilter === "true" || nsfwFilter === "only") && video.isNsfw !== true) {
return false;
}
if (nsfwFilter === "false" && video.isNsfw === true) {
return false;
}
}
return true;
};
}
+112
View File
@@ -0,0 +1,112 @@
import {
DEFAULT_FILTERS,
parseFilterQuery,
serializeFiltersToQuery,
} from "./searchFilters.js";
const listeners = new Set();
const cloneFilters = (filters = DEFAULT_FILTERS) => ({
dateRange: {
after: filters?.dateRange?.after ?? null,
before: filters?.dateRange?.before ?? null,
},
sort: filters?.sort ?? DEFAULT_FILTERS.sort,
authorPubkeys: Array.isArray(filters?.authorPubkeys)
? [...filters.authorPubkeys]
: [],
tags: Array.isArray(filters?.tags) ? [...filters.tags] : [],
textScope: filters?.textScope ?? DEFAULT_FILTERS.textScope,
duration: {
minSeconds: filters?.duration?.minSeconds ?? null,
maxSeconds: filters?.duration?.maxSeconds ?? null,
},
hasMagnet:
typeof filters?.hasMagnet === "boolean" ? filters.hasMagnet : null,
hasUrl: typeof filters?.hasUrl === "boolean" ? filters.hasUrl : null,
nsfw: filters?.nsfw ?? DEFAULT_FILTERS.nsfw,
relay: filters?.relay ?? DEFAULT_FILTERS.relay,
kind:
Number.isFinite(filters?.kind) && filters.kind !== null
? filters.kind
: null,
});
let state = {
text: "",
filters: cloneFilters(DEFAULT_FILTERS),
};
function notify() {
const snapshot = getSearchFilterState();
listeners.forEach((listener) => listener(snapshot));
}
export function getSearchFilterState() {
return {
text: state.text,
filters: cloneFilters(state.filters),
};
}
export function setSearchFilterState(nextState = {}, options = {}) {
state = {
text: typeof nextState.text === "string" ? nextState.text : "",
filters: cloneFilters(nextState.filters),
};
if (options.notify !== false) {
notify();
}
}
export function resetSearchFilters(options = {}) {
state = {
text: state.text,
filters: cloneFilters(DEFAULT_FILTERS),
};
if (options.notify !== false) {
notify();
}
}
export function subscribeSearchFilterState(listener) {
if (typeof listener !== "function") {
return () => {};
}
listeners.add(listener);
return () => listeners.delete(listener);
}
export function syncSearchFilterStateFromHash(hash = "") {
const hashString =
typeof hash === "string" && hash ? hash : window?.location?.hash || "";
const hashParams = new URLSearchParams(
hashString.split("?")[1] || hashString.slice(1),
);
const rawQuery = hashParams.get("q") || "";
const rawFilters = hashParams.get("filters") || "";
const parsed = parseFilterQuery(
[rawQuery, rawFilters].filter(Boolean).join(" "),
);
setSearchFilterState(
{
text: parsed.text || "",
filters: parsed.filters,
},
{ notify: true },
);
return parsed;
}
export function buildSearchHashFromState(nextState = state) {
const params = new URLSearchParams();
if (nextState.text) {
params.set("q", nextState.text);
}
const serialized = serializeFiltersToQuery(nextState.filters);
if (serialized) {
params.set("filters", serialized);
}
const queryString = params.toString();
return queryString ? `search&${queryString}` : "search";
}
+340
View File
@@ -0,0 +1,340 @@
const DATE_FORMAT_OPTIONS = {
year: "numeric",
month: "2-digit",
day: "2-digit",
};
export const DEFAULT_FILTERS = {
dateRange: {
after: null,
before: null,
},
sort: "relevance",
authorPubkeys: [],
tags: [],
textScope: "all",
duration: {
minSeconds: null,
maxSeconds: null,
},
hasMagnet: null,
hasUrl: null,
nsfw: "any",
relay: null,
kind: null,
};
const BOOLEAN_OPERATORS = new Set(["AND", "OR", "NOT"]);
const cloneDefaultFilters = () => ({
dateRange: { ...DEFAULT_FILTERS.dateRange },
sort: DEFAULT_FILTERS.sort,
authorPubkeys: [],
tags: [],
textScope: DEFAULT_FILTERS.textScope,
duration: { ...DEFAULT_FILTERS.duration },
hasMagnet: DEFAULT_FILTERS.hasMagnet,
hasUrl: DEFAULT_FILTERS.hasUrl,
nsfw: DEFAULT_FILTERS.nsfw,
relay: DEFAULT_FILTERS.relay,
kind: DEFAULT_FILTERS.kind,
});
const normalizeWhitespace = (input) =>
input.replace(/\s+/g, " ").trim();
const tokenizeQuery = (input) => {
const tokens = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < input.length; i += 1) {
const char = input[i];
if (char === '"' && input[i - 1] !== "\\") {
inQuotes = !inQuotes;
continue;
}
if (!inQuotes && /\s/.test(char)) {
if (current) {
tokens.push(current);
current = "";
}
continue;
}
current += char;
}
if (current) {
tokens.push(current);
}
return tokens;
};
const parseDateValue = (value) => {
if (!value) return { error: "Date value is missing." };
if (/^\d+$/.test(value)) {
const timestamp = Number.parseInt(value, 10);
if (!Number.isFinite(timestamp)) {
return { error: "Date timestamp is invalid." };
}
return { value: timestamp };
}
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) {
return { error: "Date value is invalid." };
}
return { value: Math.floor(parsed / 1000) };
};
const parseDurationValue = (value) => {
const match = value.match(/^(\d+(?:\.\d+)?)(s|m|h)?$/i);
if (!match) {
return { error: "Duration format is invalid." };
}
const amount = Number.parseFloat(match[1]);
if (!Number.isFinite(amount)) {
return { error: "Duration value is invalid." };
}
const unit = (match[2] || "s").toLowerCase();
const multiplier = unit === "h" ? 3600 : unit === "m" ? 60 : 1;
return { value: Math.round(amount * multiplier) };
};
const applyDurationFilter = (filters, operator, seconds) => {
if (operator === "<" || operator === "<=") {
filters.duration.maxSeconds = seconds;
} else if (operator === ">" || operator === ">=") {
filters.duration.minSeconds = seconds;
}
};
const parseKeyValueToken = (token) => {
const match = token.match(/^([a-zA-Z]+):(.*)$/);
if (!match) return null;
return {
key: match[1].toLowerCase(),
value: match[2] || "",
};
};
const formatDateForFilter = (timestampSeconds) => {
if (!Number.isFinite(timestampSeconds)) return "";
const date = new Date(timestampSeconds * 1000);
const parts = new Intl.DateTimeFormat("en-CA", DATE_FORMAT_OPTIONS).formatToParts(
date,
);
const map = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${map.year}-${map.month}-${map.day}`;
};
const formatDurationValue = (seconds) => {
if (!Number.isFinite(seconds)) return "";
if (seconds % 3600 === 0) return `${seconds / 3600}h`;
if (seconds % 60 === 0) return `${seconds / 60}m`;
return `${seconds}s`;
};
export function parseFilterQuery(inputString = "") {
const normalized = normalizeWhitespace(String(inputString || ""));
const tokens = tokenizeQuery(normalized);
const filters = cloneDefaultFilters();
const errors = [];
const textTokens = [];
for (const token of tokens) {
if (!token) continue;
const upperToken = token.toUpperCase();
if (BOOLEAN_OPERATORS.has(upperToken)) {
textTokens.push({ type: "operator", value: upperToken });
continue;
}
const keyValue = parseKeyValueToken(token);
if (!keyValue) {
textTokens.push({ type: "term", value: token, isPhrase: token.includes(" ") });
continue;
}
const { key, value } = keyValue;
if (!value) {
errors.push({ token, message: "Filter value is missing." });
continue;
}
switch (key) {
case "author": {
const values = value.split(",").map((entry) => entry.trim()).filter(Boolean);
if (values.length === 0) {
errors.push({ token, message: "Author value is empty." });
break;
}
filters.authorPubkeys.push(...values);
break;
}
case "tag": {
const values = value
.split(",")
.map((entry) => entry.trim().replace(/^#/, ""))
.filter(Boolean);
if (values.length === 0) {
errors.push({ token, message: "Tag value is empty." });
break;
}
filters.tags.push(...values);
break;
}
case "kind": {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
errors.push({ token, message: "Kind must be an integer." });
break;
}
filters.kind = parsed;
break;
}
case "relay": {
filters.relay = value;
break;
}
case "after": {
const parsed = parseDateValue(value);
if (parsed.error) {
errors.push({ token, message: parsed.error });
break;
}
filters.dateRange.after = parsed.value;
break;
}
case "before": {
const parsed = parseDateValue(value);
if (parsed.error) {
errors.push({ token, message: parsed.error });
break;
}
filters.dateRange.before = parsed.value;
break;
}
case "duration": {
const match = value.match(/^(<=|>=|<|>)(.+)$/);
if (!match) {
errors.push({ token, message: "Duration must use < or > operators." });
break;
}
const operator = match[1];
const durationValue = match[2].trim();
const parsed = parseDurationValue(durationValue);
if (parsed.error) {
errors.push({ token, message: parsed.error });
break;
}
applyDurationFilter(filters, operator, parsed.value);
break;
}
case "has": {
const normalizedValue = value.toLowerCase();
if (normalizedValue === "magnet") {
filters.hasMagnet = true;
} else if (normalizedValue === "url") {
filters.hasUrl = true;
} else {
errors.push({ token, message: "Has filter supports magnet or url." });
}
break;
}
case "nsfw": {
const normalizedValue = value.toLowerCase();
if (["any", "true", "false", "only", "safe"].includes(normalizedValue)) {
filters.nsfw = normalizedValue === "safe" ? "false" : normalizedValue;
} else {
errors.push({ token, message: "NSFW filter supports any/true/false/only." });
}
break;
}
default:
errors.push({ token, message: `Unknown filter "${key}".` });
}
}
const text = textTokens
.filter((token) => token.type === "term")
.map((token) => token.value)
.join(" ");
return {
filters,
text,
tokens: textTokens,
errors,
};
}
export function serializeFiltersToQuery(filters = DEFAULT_FILTERS) {
const tokens = [];
if (filters.authorPubkeys?.length) {
for (const author of filters.authorPubkeys) {
tokens.push(`author:${author}`);
}
}
if (filters.tags?.length) {
for (const tag of filters.tags) {
tokens.push(`tag:${tag}`);
}
}
if (Number.isFinite(filters.kind)) {
tokens.push(`kind:${filters.kind}`);
}
if (filters.relay) {
tokens.push(`relay:${filters.relay}`);
}
if (Number.isFinite(filters.dateRange?.after)) {
tokens.push(`after:${formatDateForFilter(filters.dateRange.after)}`);
}
if (Number.isFinite(filters.dateRange?.before)) {
tokens.push(`before:${formatDateForFilter(filters.dateRange.before)}`);
}
if (Number.isFinite(filters.duration?.minSeconds)) {
tokens.push(`duration:>=${formatDurationValue(filters.duration.minSeconds)}`);
}
if (Number.isFinite(filters.duration?.maxSeconds)) {
tokens.push(`duration:<=${formatDurationValue(filters.duration.maxSeconds)}`);
}
if (filters.hasMagnet === true) {
tokens.push("has:magnet");
}
if (filters.hasUrl === true) {
tokens.push("has:url");
}
if (filters.nsfw && filters.nsfw !== "any") {
tokens.push(`nsfw:${filters.nsfw}`);
}
return tokens.join(" ");
}
export function filtersToHashParams(filters = DEFAULT_FILTERS) {
const params = new URLSearchParams();
const serialized = serializeFiltersToQuery(filters);
if (serialized) {
params.set("filters", serialized);
}
return params;
}
+427 -10
View File
@@ -4,16 +4,196 @@ import { nostrClient } from "./nostrClientFacade.js";
import { getApplication } from "./applicationContext.js";
import { renderChannelVideosFromList } from "./channelProfile.js";
import { escapeHTML } from "./utils/domUtils.js";
import { formatShortNpub } from "./utils/formatters.js";
import { formatShortNpub, truncateMiddle } from "./utils/formatters.js";
import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js";
import { devLogger } from "./utils/logger.js";
import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
import { setHashView } from "./hashView.js";
import {
buildSearchHashFromState,
getSearchFilterState,
resetSearchFilters,
setSearchFilterState,
syncSearchFilterStateFromHash,
} from "./search/searchFilterState.js";
import { buildVideoSearchFilterMatcher } from "./search/searchFilterMatchers.js";
function getApp() {
return getApplication();
}
const FALLBACK_AVATAR = "assets/svg/default-profile.svg";
const EMPTY_FACET_COUNTS = { tags: [], authors: [], relays: [] };
const MAX_FACET_RESULTS = {
tags: 8,
authors: 6,
relays: 6,
};
const normalizeFacetText = (value) => {
if (typeof value !== "string") {
return "";
}
return value.trim();
};
const resolveShortPubkeyLabel = (pubkey) => {
const trimmed = normalizeFacetText(pubkey);
if (!trimmed) {
return "";
}
let npub = "";
try {
if (window.NostrTools && window.NostrTools.nip19) {
npub = window.NostrTools.nip19.npubEncode(trimmed);
}
} catch (error) {
// ignore
}
const formattedNpub = formatShortNpub(npub);
if (formattedNpub) {
return formattedNpub;
}
return truncateMiddle(trimmed, 18);
};
const resolveAuthorFacetLabel = (video) => {
const name = normalizeFacetText(video?.authorName);
if (name) {
return name;
}
const npub = normalizeFacetText(video?.authorNpub);
if (npub) {
return formatShortNpub(npub) || npub;
}
return resolveShortPubkeyLabel(video?.pubkey);
};
const extractRelayHints = (event) => {
if (!event || typeof event !== "object") {
return [];
}
const relayCandidates = [];
const relayCollections = [event.relays, event.seenOn, event.seen_on];
relayCollections.forEach((collection) => {
if (Array.isArray(collection)) {
relayCandidates.push(...collection);
}
});
if (typeof event.relay === "string") {
relayCandidates.push(event.relay);
}
return Array.from(
new Set(
relayCandidates
.map((value) => normalizeFacetText(String(value)))
.filter(Boolean),
),
);
};
const normalizeTagFacetValue = (value) =>
normalizeFacetText(value)
.replace(/^#/, "")
.toLowerCase();
const buildFacetEntries = (map, limit) => {
if (!map.size) {
return [];
}
return Array.from(map.values())
.sort((a, b) => (b.count - a.count) || a.label.localeCompare(b.label))
.slice(0, limit);
};
const buildSearchFacetCounts = (videos = []) => {
const tagCounts = new Map();
const authorCounts = new Map();
const relayCounts = new Map();
videos.forEach((video) => {
if (!video) {
return;
}
const authorKey = normalizeFacetText(video.pubkey || "");
if (authorKey) {
const label = resolveAuthorFacetLabel(video) || authorKey;
const entry = authorCounts.get(authorKey) || {
value: authorKey,
label,
count: 0,
};
entry.count += 1;
if (!entry.label && label) {
entry.label = label;
}
authorCounts.set(authorKey, entry);
}
const tagSet = new Set();
if (Array.isArray(video.tags)) {
video.tags.forEach((tag) => {
if (Array.isArray(tag)) {
if (tag[0] === "t") {
const normalized = normalizeTagFacetValue(tag[1] || "");
if (normalized) {
tagSet.add(normalized);
}
}
return;
}
const normalized = normalizeTagFacetValue(tag);
if (normalized) {
tagSet.add(normalized);
}
});
}
tagSet.forEach((tag) => {
const label = `#${tag}`;
const entry = tagCounts.get(tag) || { value: tag, label, count: 0 };
entry.count += 1;
tagCounts.set(tag, entry);
});
const relaySet = new Set();
if (Array.isArray(video.relays)) {
video.relays.forEach((relay) => {
const normalized = normalizeFacetText(relay);
if (normalized) {
relaySet.add(normalized);
}
});
}
if (Array.isArray(video.relayHints)) {
video.relayHints.forEach((relay) => {
const normalized = normalizeFacetText(relay);
if (normalized) {
relaySet.add(normalized);
}
});
}
const relayField = normalizeFacetText(video.relay || video.eventRelay || "");
if (relayField) {
relaySet.add(relayField);
}
relaySet.forEach((relay) => {
const entry = relayCounts.get(relay) || {
value: relay,
label: relay,
count: 0,
};
entry.count += 1;
relayCounts.set(relay, entry);
});
});
return {
tags: buildFacetEntries(tagCounts, MAX_FACET_RESULTS.tags),
authors: buildFacetEntries(authorCounts, MAX_FACET_RESULTS.authors),
relays: buildFacetEntries(relayCounts, MAX_FACET_RESULTS.relays),
};
};
function renderProfileCards(profiles, container) {
if (!container) return;
@@ -100,10 +280,13 @@ function renderProfileCards(profiles, container) {
let currentSearchToken = 0;
export async function initSearchView() {
const hashParams = new URLSearchParams(window.location.hash.split("?")[1] || window.location.hash.slice(1));
// The hash format is #view=search&q=...
// URLSearchParams handles 'view=search&q=...' correctly if we pass the string after #
const query = hashParams.get("q") || "";
const parsedQuery = syncSearchFilterStateFromHash(window.location.hash);
if (parsedQuery.errors.length > 0) {
devLogger.warn("[Search] Filter parsing errors", parsedQuery.errors);
}
const query = parsedQuery.text || "";
renderActiveFilters(parsedQuery.filters);
const titleEl = document.getElementById("searchTitle");
if (titleEl) {
@@ -114,7 +297,7 @@ export async function initSearchView() {
if (infoTrigger) {
attachFeedInfoPopover(
infoTrigger,
"Results matching your search query."
"Results matching your search query. Use tokens like author:, tag:, kind:, relay:, after:, before:, duration:<, and has:magnet/url."
);
}
@@ -131,6 +314,7 @@ export async function initSearchView() {
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>`;
setSearchFacetCounts(EMPTY_FACET_COUNTS);
return;
}
@@ -148,18 +332,231 @@ export async function initSearchView() {
});
// 2. Search Videos
performVideoSearch(query, searchToken).then(videos => {
performVideoSearch(query, searchToken, parsedQuery.filters).then(videos => {
if (searchToken !== currentSearchToken) return;
const app = getApp();
setSearchFacetCounts(buildSearchFacetCounts(videos));
renderSearchVideos(videos, videoList, app);
}).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>`;
}
if (searchToken === currentSearchToken) {
setSearchFacetCounts(EMPTY_FACET_COUNTS);
}
});
}
const formatDateLabel = (timestampSeconds) => {
if (!Number.isFinite(timestampSeconds)) return "";
const formatter = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
return formatter.format(new Date(timestampSeconds * 1000));
};
const formatDurationLabel = (seconds) => {
if (!Number.isFinite(seconds)) return "";
if (seconds % 3600 === 0) {
return `${seconds / 3600}h`;
}
if (seconds % 60 === 0) {
return `${seconds / 60}m`;
}
return `${seconds}s`;
};
function buildNextFilters() {
const current = getSearchFilterState();
const filters = current.filters || {};
return {
...filters,
dateRange: { ...filters.dateRange },
duration: { ...filters.duration },
authorPubkeys: Array.isArray(filters.authorPubkeys)
? [...filters.authorPubkeys]
: [],
tags: Array.isArray(filters.tags) ? [...filters.tags] : [],
};
}
function applyFiltersAndRefresh(nextFilters) {
const current = getSearchFilterState();
const nextState = {
text: current.text || "",
filters: nextFilters,
};
setSearchFilterState(nextState);
setHashView(buildSearchHashFromState(nextState));
}
function renderActiveFilters(filters) {
const wrapper = document.getElementById("searchActiveFilters");
const list = document.getElementById("searchActiveFiltersList");
const clearBtn = document.getElementById("searchActiveFiltersClear");
if (!wrapper || !list || !clearBtn) {
return;
}
list.innerHTML = "";
const pills = [];
filters.authorPubkeys?.forEach((author) => {
pills.push({
label: `Author: ${author}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.authorPubkeys = nextFilters.authorPubkeys.filter(
(value) => value !== author,
);
applyFiltersAndRefresh(nextFilters);
},
});
});
filters.tags?.forEach((tag) => {
pills.push({
label: `#${tag}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.tags = nextFilters.tags.filter((value) => value !== tag);
applyFiltersAndRefresh(nextFilters);
},
});
});
if (Number.isFinite(filters.kind)) {
pills.push({
label: `Kind: ${filters.kind}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.kind = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (filters.relay) {
pills.push({
label: `Relay: ${filters.relay}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.relay = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (Number.isFinite(filters.dateRange?.after)) {
pills.push({
label: `After: ${formatDateLabel(filters.dateRange.after)}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.dateRange.after = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (Number.isFinite(filters.dateRange?.before)) {
pills.push({
label: `Before: ${formatDateLabel(filters.dateRange.before)}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.dateRange.before = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (Number.isFinite(filters.duration?.minSeconds)) {
pills.push({
label: `Min duration: ${formatDurationLabel(filters.duration.minSeconds)}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.duration.minSeconds = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (Number.isFinite(filters.duration?.maxSeconds)) {
pills.push({
label: `Max duration: ${formatDurationLabel(filters.duration.maxSeconds)}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.duration.maxSeconds = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (filters.hasMagnet === true) {
pills.push({
label: "Has magnet",
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.hasMagnet = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (filters.hasUrl === true) {
pills.push({
label: "Has URL",
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.hasUrl = null;
applyFiltersAndRefresh(nextFilters);
},
});
}
if (filters.nsfw && filters.nsfw !== "any") {
pills.push({
label: `NSFW: ${filters.nsfw}`,
onRemove: () => {
const nextFilters = buildNextFilters();
nextFilters.nsfw = "any";
applyFiltersAndRefresh(nextFilters);
},
});
}
if (!pills.length) {
wrapper.classList.add("hidden");
return;
}
const fragment = document.createDocumentFragment();
pills.forEach((pill) => {
const button = document.createElement("button");
button.type = "button";
button.className =
"search-filter-chip inline-flex items-center gap-2 text-xs";
const label = document.createElement("span");
label.textContent = pill.label;
const remove = document.createElement("span");
remove.setAttribute("aria-hidden", "true");
remove.textContent = "✕";
button.append(label, remove);
button.addEventListener("click", pill.onRemove);
fragment.appendChild(button);
});
list.appendChild(fragment);
wrapper.classList.remove("hidden");
clearBtn.onclick = () => {
resetSearchFilters();
const nextState = getSearchFilterState();
setHashView(buildSearchHashFromState(nextState));
};
}
async function renderSearchVideos(videos, container, app) {
if (!container) return;
container.innerHTML = "";
@@ -357,12 +754,24 @@ async function performProfileSearch(query, token) {
return Array.from(uniqueProfiles.values());
}
async function performVideoSearch(query, token) {
async function performVideoSearch(query, token, filters = {}) {
if (!nostrClient) return [];
const isHashtag = query.startsWith("#");
const term = isHashtag ? query.slice(1) : query;
const lowerTerm = term.toLowerCase();
const app = getApp();
const nostrService = app?.nostrService;
const filterOptions = {
blacklistedEventIds:
app?.blacklistedEventIds instanceof Set ? new Set(app.blacklistedEventIds) : new Set(),
isAuthorBlocked:
typeof app?.isAuthorBlocked === "function" ? (pubkey) => app.isAuthorBlocked(pubkey) : () => false
};
const applyAccessFilters = (videos) =>
nostrService?.filterVideos ? nostrService.filterVideos(videos, filterOptions) : videos;
const matchesCustomFilters = buildVideoSearchFilterMatcher(filters);
// 1. Local Search (Cache)
const localMatches = [];
@@ -393,6 +802,8 @@ async function performVideoSearch(query, token) {
if (currentSearchToken !== token) return [];
const filteredLocalMatches = applyAccessFilters(localMatches).filter(matchesCustomFilters);
// 2. Relay Search
let relayVideos = [];
const relays = nostrClient.relays || [];
@@ -417,6 +828,10 @@ async function performVideoSearch(query, token) {
relayVideos = events.map(evt => {
const vid = convertEventToVideo(evt);
if (vid.invalid) return null;
const relayHints = extractRelayHints(evt);
if (relayHints.length) {
return { ...vid, relayHints };
}
return vid;
}).filter(Boolean);
}
@@ -428,16 +843,18 @@ async function performVideoSearch(query, token) {
if (currentSearchToken !== token) return [];
const filteredRelayVideos = applyAccessFilters(relayVideos).filter(matchesCustomFilters);
// Deduplicate by ID
const unique = new Map();
// Add local matches first
for (const v of localMatches) {
for (const v of filteredLocalMatches) {
if (!unique.has(v.id)) unique.set(v.id, v);
}
// Add relay matches (overwriting local if needed, or ignoring duplicates)
for (const v of relayVideos) {
for (const v of filteredRelayVideos) {
if (!unique.has(v.id)) unique.set(v.id, v);
}
+2 -2
View File
@@ -1402,12 +1402,12 @@ export class NostrService {
}
async ensureAccessControlReady() {
if (!this.accessControl || typeof this.accessControl.ensureReady !== "function") {
if (!this.accessControl || typeof this.accessControl.waitForReady !== "function") {
return;
}
try {
await this.accessControl.ensureReady();
await this.accessControl.waitForReady();
} catch (error) {
userLogger.warn(
"[nostrService] Failed to ensure access control lists are ready:",
+3 -3
View File
@@ -64,7 +64,7 @@ async function waitForRelaysReady({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS } =
}
async function waitForAccessControl({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS } = {}) {
if (!accessControl || typeof accessControl.ensureReady !== "function") {
if (!accessControl || typeof accessControl.waitForReady !== "function") {
return { ok: true, timedOut: false };
}
@@ -72,12 +72,12 @@ async function waitForAccessControl({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS }
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
timedOut = true;
reject(new Error("accessControl ensureReady timed out"));
reject(new Error("accessControl ready check timed out"));
}, timeoutMs);
});
try {
await Promise.race([accessControl.ensureReady(), timeoutPromise]);
await Promise.race([accessControl.waitForReady(), timeoutPromise]);
return { ok: true, timedOut: false };
} catch (error) {
if (timedOut) {
+2 -2
View File
@@ -4,8 +4,8 @@ const STORAGE_KEY = "bitvid:theme";
const FALLBACK_THEME = "dark";
const VALID_THEMES = new Set(["light", "dark"]);
const FALLBACK_META_COLORS = {
dark: "#0f172a",
light: "#f8fafc",
dark: "rgb(15, 23, 42)",
light: "rgb(248, 250, 252)",
};
const TOGGLE_SELECTORS = [
"[data-theme-toggle]",
+23
View File
@@ -5,6 +5,7 @@ import { truncateMiddle, formatShortNpub } from "../utils/formatters.js";
import { showLoginRequiredToZapNotification } from "../payments/zapNotifications.js";
import { VideoModal } from "./components/VideoModal.js";
import { RevertModal } from "./components/RevertModal.js";
import { ShareNostrModal } from "./components/ShareNostrModal.js";
import initUploadModal from "./initUploadModal.js";
import initEditModal from "./initEditModal.js";
import initDeleteModal from "./initDeleteModal.js";
@@ -49,6 +50,7 @@ export default class ModalManager {
this.videoModalHandlers = {};
this.eventDetailsModal = null;
this.shareNostrModal = null;
this.zapController = null;
}
@@ -170,6 +172,15 @@ export default class ModalManager {
app.deleteModal = this.deleteModal;
app.deleteModalEvents = this.deleteModalEvents;
this.shareNostrModal = new ShareNostrModal({
removeTrackingScripts,
setGlobalModalState,
container: modalContainer,
fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc,
onPost: (payload) => app.handleShareNostrPost(payload),
});
app.shareNostrModal = this.shareNostrModal;
this.videoModal =
(typeof this.ui.videoModal === "function"
? this.ui.videoModal({ app })
@@ -698,6 +709,18 @@ export default class ModalManager {
}
}
if (this.shareNostrModal && typeof this.shareNostrModal.close === "function") {
try {
this.shareNostrModal.close();
} catch (error) {
devLogger.warn("[ModalManager] Failed to close share Nostr modal:", error);
}
}
this.shareNostrModal = null;
if (app.shareNostrModal) {
app.shareNostrModal = null;
}
if (this.revertModal && this.revertConfirmHandler) {
try {
this.revertModal.removeEventListener(
+1 -1
View File
@@ -31,7 +31,7 @@ function getFallbackFillColor(canvas) {
}
}
return "#000000";
return "rgb(0, 0, 0)";
}
function resizeCanvas(canvas, video) {
+2 -1
View File
@@ -266,6 +266,7 @@ export default class ApplicationBootstrap {
}
app.registerRecentFeed();
app.registerForYouFeed();
app.registerKidsFeed();
app.registerExploreFeed();
app.registerSubscriptionsFeed();
app.registerWatchHistoryFeed();
@@ -944,7 +945,7 @@ export default class ApplicationBootstrap {
trigger,
}) => {
if (videoId) {
app.recordForYouClick(videoId);
app.recordFeedClick(videoId);
}
if (videoId) {
Promise.resolve(
+430
View File
@@ -0,0 +1,430 @@
import {
prepareStaticModal,
openStaticModal,
closeStaticModal,
} from "./staticModalAccessibility.js";
import {
getSearchFilterState,
setSearchFilterState,
buildSearchHashFromState,
resetSearchFilters,
} from "../../search/searchFilterState.js";
import { DEFAULT_FILTERS } from "../../search/searchFilters.js";
import { setHashView } from "../../hashView.js";
import { ALLOW_NSFW_CONTENT } from "../../config.js";
const MODAL_ID = "searchFilterModal";
let isInitialized = false;
// DOM Elements
let modal;
let form;
let dateStartInput;
let dateEndInput;
let sortSelect;
let authorInput;
let nsfwToggle;
let advancedToggleBtn;
let advancedPanel;
let advancedChevron;
let tagsInput;
let durationShortCheckbox;
let durationMediumCheckbox;
let durationLongCheckbox;
let dateChipsContainer;
let tagChipsContainer;
let hasChipsContainer;
function getElement(id) {
return document.getElementById(id);
}
function init() {
if (isInitialized) return;
modal = getElement(MODAL_ID);
if (!modal) return; // Modal HTML not loaded yet
form = getElement("searchFilterForm");
dateStartInput = getElement("searchFilterDateStart");
dateEndInput = getElement("searchFilterDateEnd");
sortSelect = getElement("searchFilterSort");
authorInput = getElement("searchFilterAuthor");
nsfwToggle = getElement("searchFilterNsfwToggle");
advancedToggleBtn = getElement("searchFilterAdvancedToggle");
advancedPanel = getElement("searchFilterAdvancedPanel");
advancedChevron = advancedToggleBtn.querySelector("span:last-child"); // The chevron
tagsInput = getElement("searchFilterTags");
durationShortCheckbox = getElement("searchFilterDurationShort");
durationMediumCheckbox = getElement("searchFilterDurationMedium");
durationLongCheckbox = getElement("searchFilterDurationLong");
dateChipsContainer = getElement("searchFilterDateChips");
tagChipsContainer = getElement("searchFilterTagChips");
hasChipsContainer = getElement("searchFilterHasChips");
// Event Listeners
// Close
const closeBtn = getElement("closeSearchFilterModal");
if (closeBtn) {
closeBtn.addEventListener("click", close);
}
// Apply
const applyBtn = getElement("searchFilterApplyBtn");
if (applyBtn) {
applyBtn.addEventListener("click", () => {
applyFilters();
close();
});
}
// Reset
const resetBtn = getElement("searchFilterResetBtn");
if (resetBtn) {
resetBtn.addEventListener("click", () => {
syncStateToControls(DEFAULT_FILTERS);
});
}
// Advanced Toggle
if (advancedToggleBtn) {
advancedToggleBtn.addEventListener("click", () => toggleAdvanced());
}
// Date Chips
if (dateChipsContainer) {
dateChipsContainer.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const days = btn.dataset.days;
handleDateChip(days);
updateChipVisuals(dateChipsContainer, btn);
});
}
// Tag Chips
if (tagChipsContainer) {
tagChipsContainer.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn) return;
toggleChipState(btn);
});
}
// Has Chips
if (hasChipsContainer) {
hasChipsContainer.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn) return;
toggleChipState(btn);
});
}
// NSFW Toggle
if (nsfwToggle) {
if (!ALLOW_NSFW_CONTENT) {
nsfwToggle.disabled = true;
nsfwToggle.setAttribute("aria-disabled", "true");
nsfwToggle.title = "NSFW content is disabled on this instance.";
// Visual disabled state is usually handled by CSS for [disabled], but we can enforce:
nsfwToggle.classList.add("opacity-50", "cursor-not-allowed");
} else {
nsfwToggle.addEventListener("click", () => {
const isChecked = nsfwToggle.getAttribute("aria-checked") === "true";
nsfwToggle.setAttribute("aria-checked", !isChecked);
nsfwToggle.classList.toggle("is-on", !isChecked);
});
}
}
prepareStaticModal({ id: MODAL_ID });
isInitialized = true;
}
function open() {
if (!isInitialized) init();
const currentState = getSearchFilterState();
syncStateToControls(currentState.filters || DEFAULT_FILTERS);
openStaticModal(modal);
}
function close() {
closeStaticModal(modal);
}
function toggleAdvanced(forceState = null) {
const isCurrentlyExpanded = advancedToggleBtn.getAttribute("aria-expanded") === "true";
const newState = forceState !== null ? forceState : !isCurrentlyExpanded;
advancedToggleBtn.setAttribute("aria-expanded", newState);
if (newState) {
advancedPanel.classList.remove("hidden");
advancedChevron.style.transform = "rotate(180deg)";
} else {
advancedPanel.classList.add("hidden");
advancedChevron.style.transform = "rotate(0deg)";
}
}
// Helpers for Date
const parseDateInputValue = (value) => {
if (!value) return null;
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) return null;
return Math.floor(parsed / 1000);
};
const formatDateInputValue = (timestampSeconds) => {
if (!Number.isFinite(timestampSeconds)) return "";
const date = new Date(timestampSeconds * 1000);
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, "0");
const day = `${date.getDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
};
function handleDateChip(days) {
if (days === "all") {
dateStartInput.value = "";
dateEndInput.value = "";
return;
}
const numDays = parseInt(days, 10);
if (isNaN(numDays)) return;
const now = new Date();
const start = new Date();
start.setDate(now.getDate() - numDays);
dateStartInput.value = formatDateInputValue(Math.floor(start.getTime() / 1000));
dateEndInput.value = formatDateInputValue(Math.floor(now.getTime() / 1000));
}
// Helpers for Chips
function toggleChipState(btn) {
const isPressed = btn.getAttribute("aria-pressed") === "true";
btn.setAttribute("aria-pressed", !isPressed);
btn.dataset.state = !isPressed ? "on" : "off";
}
function updateChipVisuals(container, activeBtn) {
// For exclusive selection (like date ranges typically are treated in this UI)
const buttons = container.querySelectorAll("button");
buttons.forEach(btn => {
const isActive = btn === activeBtn;
btn.setAttribute("aria-pressed", isActive);
btn.dataset.state = isActive ? "on" : "off";
});
}
function syncStateToControls(filters) {
const safeFilters = filters || DEFAULT_FILTERS;
// Date
dateStartInput.value = formatDateInputValue(safeFilters.dateRange?.after);
dateEndInput.value = formatDateInputValue(safeFilters.dateRange?.before);
// Logic to highlight correct date chip if it matches
// (Simplified: just highlight 'All Time' if no dates, otherwise clear chips)
const hasDateRange = Number.isFinite(safeFilters.dateRange?.after) || Number.isFinite(safeFilters.dateRange?.before);
const dateBtns = dateChipsContainer.querySelectorAll("button");
dateBtns.forEach(btn => {
if (btn.dataset.days === "all") {
const isActive = !hasDateRange;
btn.setAttribute("aria-pressed", isActive);
btn.dataset.state = isActive ? "on" : "off";
} else {
// We could try to calculate if range matches 24h/7d etc, but for now just clear them if custom dates are set
btn.setAttribute("aria-pressed", "false");
btn.dataset.state = "off";
}
});
// Sort
sortSelect.value = safeFilters.sort || "relevance";
// Author
authorInput.value = safeFilters.authorPubkeys?.join(", ") || "";
// Tags
tagsInput.value = "";
// We separate tags that are in chips vs typed
const activeTags = new Set(safeFilters.tags || []);
const tagBtns = tagChipsContainer.querySelectorAll("button");
const chippedTags = new Set();
tagBtns.forEach(btn => {
const tag = btn.dataset.tag;
chippedTags.add(tag);
const isActive = activeTags.has(tag);
btn.setAttribute("aria-pressed", isActive);
btn.dataset.state = isActive ? "on" : "off";
});
// Remaining tags go to input
const otherTags = (safeFilters.tags || []).filter(t => !chippedTags.has(t));
if (otherTags.length) {
tagsInput.value = otherTags.join(", ");
}
// Duration
const minSec = safeFilters.duration?.minSeconds;
const maxSec = safeFilters.duration?.maxSeconds;
durationShortCheckbox.checked = false;
durationMediumCheckbox.checked = false;
durationLongCheckbox.checked = false;
if (minSec === null && maxSec === 300) durationShortCheckbox.checked = true;
if (minSec === 300 && maxSec === 1200) durationMediumCheckbox.checked = true;
if (minSec === 1200 && maxSec === null) durationLongCheckbox.checked = true;
// Has
const hasBtns = hasChipsContainer.querySelectorAll("button");
hasBtns.forEach(btn => {
const type = btn.dataset.has;
let isActive = false;
if (type === "magnet") isActive = safeFilters.hasMagnet === true;
if (type === "url") isActive = safeFilters.hasUrl === true;
btn.setAttribute("aria-pressed", isActive);
btn.dataset.state = isActive ? "on" : "off";
});
// NSFW
if (ALLOW_NSFW_CONTENT) {
const nsfwIsOn = safeFilters.nsfw === "true" || safeFilters.nsfw === "only";
nsfwToggle.setAttribute("aria-checked", nsfwIsOn);
nsfwToggle.classList.toggle("is-on", nsfwIsOn);
} else {
nsfwToggle.setAttribute("aria-checked", "false");
nsfwToggle.classList.remove("is-on");
}
// Auto-expand Advanced if needed
const hasAdvancedFilters =
(safeFilters.tags && safeFilters.tags.length > 0) ||
Number.isFinite(minSec) ||
Number.isFinite(maxSec) ||
safeFilters.hasMagnet === true ||
safeFilters.hasUrl === true;
// Note: NSFW is moved to main view in our design, so it doesn't trigger advanced
toggleAdvanced(hasAdvancedFilters);
}
function applyFilters() {
const filters = {
...DEFAULT_FILTERS,
dateRange: { ...DEFAULT_FILTERS.dateRange },
duration: { ...DEFAULT_FILTERS.duration },
};
// Date
filters.dateRange.after = parseDateInputValue(dateStartInput.value);
filters.dateRange.before = parseDateInputValue(dateEndInput.value);
// Sort
filters.sort = sortSelect.value;
// Author
const authors = authorInput.value.split(/[, ]+/).map(s => s.trim()).filter(Boolean);
filters.authorPubkeys = authors;
// Tags
const selectedTags = new Set();
const tagBtns = tagChipsContainer.querySelectorAll("button[aria-pressed='true']");
tagBtns.forEach(btn => selectedTags.add(btn.dataset.tag));
const inputTags = tagsInput.value.split(/[, ]+/).map(s => s.trim().replace(/^#/, "")).filter(Boolean);
inputTags.forEach(t => selectedTags.add(t));
filters.tags = Array.from(selectedTags);
// Duration
// (Simplified logic: taking the last checked one or union? The original popover logic allowed multiple?
// Let's look at the original logic in SearchFiltersPopover.js:
// It collected ranges into an array and took min of mins and max of maxes.
// Effectively treating them as OR if multiple selected (e.g. Short OR Medium -> 0 to 1200).
const ranges = [];
if (durationShortCheckbox.checked) ranges.push({ min: null, max: 300 });
if (durationMediumCheckbox.checked) ranges.push({ min: 300, max: 1200 });
if (durationLongCheckbox.checked) ranges.push({ min: 1200, max: null });
if (ranges.length) {
const mins = ranges.map(r => r.min).filter(v => v !== null);
const maxes = ranges.map(r => r.max).filter(v => v !== null);
// If any range has min=null (Short), the overall min is null.
// If any range has max=null (Long), the overall max is null.
// Actually, logic:
// Short (0-300) + Medium (300-1200) = 0-1200.
// Short + Long = 0-300 AND 1200+. Currently filter schema supports min/max seconds as a single range.
// It doesn't support disjoint ranges.
// The original logic:
// filters.duration.minSeconds = mins.length ? Math.min(...mins) : null;
// filters.duration.maxSeconds = maxes.length ? Math.max(...maxes) : null;
// This implies union of ranges IF they are contiguous. If disjoint (Short + Long), it creates 0 to infinity (min=0, max=null is wrong, max of maxes? Long has max=null).
// Let's stick to original logic.
// min: Math.min of all explicit mins. If Short is there, min is null.
// max: Math.max of all explicit maxes. If Long is there, max is null.
const hasNullMin = ranges.some(r => r.min === null);
const hasNullMax = ranges.some(r => r.max === null);
filters.duration.minSeconds = hasNullMin ? null : Math.min(...mins);
filters.duration.maxSeconds = hasNullMax ? null : Math.max(...maxes);
}
// Has
const hasBtns = hasChipsContainer.querySelectorAll("button[aria-pressed='true']");
hasBtns.forEach(btn => {
if (btn.dataset.has === "magnet") filters.hasMagnet = true;
if (btn.dataset.has === "url") filters.hasUrl = true;
});
// NSFW
const nsfwChecked = nsfwToggle.getAttribute("aria-checked") === "true";
if (nsfwChecked) {
filters.nsfw = "true";
} else {
filters.nsfw = "any"; // or "false"? DEFAULT is "any".
// Wait, original popover logic:
// if (nsfwSwitch?.getAttribute("aria-checked") === "true") { filters.nsfw = "true"; }
// else { ... it defaults to cloneDefaultFilters which is 'any' }
// Wait, DEFAULT_FILTERS.nsfw is 'any'.
// If switch is off, it remains 'any' (showing sensitive content if user allows?).
// Usually NSFW toggle means "Show me NSFW".
// If off, it might mean "Filter out NSFW" or "Show Any".
// Let's assume 'any' means standard behavior (hide usually unless safe mode off?).
// Actually searchFilters.js says:
// filters.nsfw = "any" (default).
// So if I don't touch it, it's 'any'.
// If I toggle it ON, it becomes 'true'.
}
// Update State
const currentState = getSearchFilterState();
const nextState = {
text: currentState.text || "",
filters,
};
setSearchFilterState(nextState);
setHashView(buildSearchHashFromState(nextState));
}
export default {
init,
open,
close
};
+486
View File
@@ -0,0 +1,486 @@
import { createModalAccessibility } from "./modalAccessibility.js";
import { DEFAULT_RELAY_URLS } from "../../nostr/toolkit.js";
import logger from "../../utils/logger.js";
import { relayManager } from "../../relayManager.js";
function normalizeRelayList(relays) {
if (!Array.isArray(relays)) {
return [];
}
const seen = new Set();
return relays
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
.filter((relay) => relay)
.filter((relay) => {
if (seen.has(relay)) {
return false;
}
seen.add(relay);
return true;
});
}
export class ShareNostrModal {
constructor({
removeTrackingScripts,
setGlobalModalState,
eventTarget,
container,
fallbackThumbnailSrc,
onPost,
onCancel,
} = {}) {
this.removeTrackingScripts =
typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {};
this.setGlobalModalState =
typeof setGlobalModalState === "function" ? setGlobalModalState : () => {};
this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
this.container = container || document.getElementById("modalContainer") || null;
this.fallbackThumbnailSrc =
typeof fallbackThumbnailSrc === "string" ? fallbackThumbnailSrc : "";
this.onPost = typeof onPost === "function" ? onPost : null;
this.onCancel = typeof onCancel === "function" ? onCancel : null;
this.modal = null;
this.overlay = null;
this.modalPanel = null;
this.textarea = null;
this.previewUrl = null;
this.previewThumbnail = null;
this.previewFallback = null;
this.previewTitle = null;
this.previewMeta = null;
this.relayInput = null;
this.relayPills = null;
this.status = null;
this.postButton = null;
this.cancelButton = null;
this.closeButton = null;
this.modalAccessibility = null;
this.activeVideo = null;
this.currentRelays = [];
this.busy = false;
this.bound = false;
this.boundHandlers = {
post: (event) => this.handlePostClick(event),
cancel: (event) => this.handleCancelInteraction(event),
overlay: (event) => this.handleCancelInteraction(event),
relayInput: (event) => this.handleRelayInput(event),
relayPillClick: (event) => this.handleRelayPillClick(event),
};
}
addEventListener(type, listener, options) {
this.eventTarget.addEventListener(type, listener, options);
}
removeEventListener(type, listener, options) {
this.eventTarget.removeEventListener(type, listener, options);
}
dispatch(type, detail) {
this.eventTarget.dispatchEvent(
new CustomEvent(type, {
detail,
})
);
}
async load() {
if (this.modal) {
return this.modal;
}
const targetContainer = this.container || document.getElementById("modalContainer");
if (!targetContainer) {
throw new Error("Modal container element not found!");
}
let modal = targetContainer.querySelector("#shareNostrModal");
if (!modal) {
const response = await fetch("components/share-nostr-modal.html");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const html = await response.text();
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
this.removeTrackingScripts(wrapper);
targetContainer.appendChild(wrapper);
modal = wrapper.querySelector("#shareNostrModal");
}
if (!modal) {
throw new Error("Share Nostr modal markup missing after load.");
}
this.cacheElements(modal);
this.setupModalAccessibility();
this.bindEvents();
this.reset();
return this.modal;
}
cacheElements(modal) {
this.modal = modal;
this.overlay = modal.querySelector(".bv-modal-backdrop") || null;
this.modalPanel = modal.querySelector(".modal-sheet") || modal;
this.textarea = modal.querySelector("#shareNostrContent") || null;
this.previewUrl = modal.querySelector("#shareNostrPreviewUrl") || null;
this.previewThumbnail = modal.querySelector("#shareNostrPreviewThumbnail") || null;
this.previewFallback = modal.querySelector("#shareNostrPreviewFallback") || null;
this.previewTitle = modal.querySelector("#shareNostrPreviewTitle") || null;
this.previewMeta = modal.querySelector("#shareNostrPreviewMeta") || null;
this.relayInput = modal.querySelector("#shareNostrRelays") || null;
this.relayPills = modal.querySelector("#shareNostrRelayPills") || null;
this.status = modal.querySelector("#shareNostrStatus") || null;
this.postButton = modal.querySelector("#postShareNostr") || null;
this.cancelButton = modal.querySelector("#cancelShareNostr") || null;
this.closeButton = modal.querySelector("#closeShareNostrModal") || null;
}
setupModalAccessibility() {
if (!this.modal) {
return;
}
this.modalAccessibility = createModalAccessibility({
root: this.modal,
panel: this.modalPanel || this.modal,
backdrop: this.overlay,
onRequestClose: () => this.handleCancelInteraction(),
});
}
bindEvents() {
if (this.bound || !this.modal) {
return;
}
if (this.postButton) {
this.postButton.addEventListener("click", this.boundHandlers.post);
}
if (this.cancelButton) {
this.cancelButton.addEventListener("click", this.boundHandlers.cancel);
}
if (this.closeButton) {
this.closeButton.addEventListener("click", this.boundHandlers.cancel);
}
if (this.overlay) {
this.overlay.addEventListener("click", this.boundHandlers.overlay);
}
if (this.relayInput) {
this.relayInput.addEventListener("input", this.boundHandlers.relayInput);
}
if (this.relayPills) {
this.relayPills.addEventListener("click", this.boundHandlers.relayPillClick);
}
this.bound = true;
}
reset() {
this.activeVideo = null;
this.currentRelays = [];
this.busy = false;
this.setStatus("");
this.setBusy(false);
if (this.textarea) {
this.textarea.value = "";
}
if (this.previewTitle) {
this.previewTitle.textContent = "--";
}
if (this.previewMeta) {
this.previewMeta.textContent = "--";
}
if (this.previewUrl) {
this.previewUrl.textContent = "--";
this.previewUrl.href = "#";
}
this.setThumbnail("");
this.setRelayList([]);
}
setBusy(isBusy) {
this.busy = isBusy;
const actionButtons = [this.postButton, this.cancelButton, this.closeButton];
actionButtons.forEach((button) => {
if (!(button instanceof HTMLButtonElement)) {
return;
}
button.disabled = isBusy;
});
if (this.postButton) {
this.postButton.setAttribute("aria-busy", isBusy ? "true" : "false");
const label = isBusy ? "Posting..." : "Post";
if (this.postButton.textContent !== label) {
this.postButton.textContent = label;
}
}
}
setStatus(message = "", tone = "muted") {
if (!this.status) {
return;
}
this.status.textContent = message;
const toneClassMap = {
muted: "text-muted",
success: "text-success",
warning: "text-warning",
danger: "text-danger",
};
Object.values(toneClassMap).forEach((cls) => this.status.classList.remove(cls));
const nextClass = toneClassMap[tone] || toneClassMap.muted;
this.status.classList.add(nextClass);
}
resolveDefaultRelays() {
const candidate =
relayManager && typeof relayManager.getWriteRelayUrls === "function"
? relayManager.getWriteRelayUrls()
: [];
const normalized = normalizeRelayList(candidate);
if (normalized.length) {
return normalized;
}
return normalizeRelayList(DEFAULT_RELAY_URLS);
}
parseRelayInput(value) {
if (typeof value !== "string") {
return [];
}
return normalizeRelayList(value.split(","));
}
setRelayList(relays) {
const normalized = normalizeRelayList(relays);
this.currentRelays = normalized;
if (this.relayInput) {
this.relayInput.value = normalized.join(", ");
}
this.renderRelayPills();
}
renderRelayPills() {
if (!this.relayPills) {
return;
}
this.relayPills.innerHTML = "";
if (!this.currentRelays.length) {
const placeholder = document.createElement("span");
placeholder.className = "text-xs text-muted";
placeholder.textContent = "No relays selected.";
this.relayPills.appendChild(placeholder);
return;
}
this.currentRelays.forEach((relay) => {
const pill = document.createElement("span");
pill.className =
"inline-flex items-center gap-2 rounded-full border border-border/70 bg-surface-alt px-3 py-1 text-xs text-text";
const label = document.createElement("span");
label.textContent = relay;
label.className = "truncate max-w-[200px]";
pill.appendChild(label);
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className =
"btn-ghost text-muted hover:text-text px-1 py-0.5 rounded-full";
removeButton.setAttribute("aria-label", `Remove ${relay}`);
removeButton.dataset.action = "remove-relay";
removeButton.dataset.relay = relay;
removeButton.innerHTML =
'<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/></svg>';
pill.appendChild(removeButton);
this.relayPills.appendChild(pill);
});
}
setThumbnail(url) {
const sanitized = typeof url === "string" ? url.trim() : "";
const resolved = sanitized || this.fallbackThumbnailSrc || "";
if (this.previewThumbnail) {
if (resolved) {
this.previewThumbnail.src = resolved;
this.previewThumbnail.classList.remove("hidden");
} else {
this.previewThumbnail.removeAttribute("src");
this.previewThumbnail.classList.add("hidden");
}
}
if (this.previewFallback) {
this.previewFallback.classList.toggle(
"hidden",
Boolean(resolved)
);
}
}
buildShareContent(payload) {
const title =
typeof payload?.title === "string" && payload.title.trim()
? payload.title.trim()
: "Untitled video";
const shareUrl =
typeof payload?.shareUrl === "string" ? payload.shareUrl.trim() : "";
const thumbnail =
typeof payload?.thumbnail === "string" ? payload.thumbnail.trim() : "";
return `${title} — Check out this video on bitvid 👇\n\n${shareUrl}\n\n${thumbnail}`;
}
setVideo(payload) {
this.activeVideo = payload || null;
const title =
typeof payload?.title === "string" && payload.title.trim()
? payload.title.trim()
: "Untitled video";
const shareUrl =
typeof payload?.shareUrl === "string" ? payload.shareUrl.trim() : "";
const thumbnail =
typeof payload?.thumbnail === "string" ? payload.thumbnail.trim() : "";
const pubkey =
typeof payload?.pubkey === "string" && payload.pubkey.trim()
? payload.pubkey.trim()
: "";
const authorName =
typeof payload?.authorName === "string" && payload.authorName.trim()
? payload.authorName.trim()
: "";
if (this.textarea) {
this.textarea.value = this.buildShareContent({
title,
shareUrl,
thumbnail,
});
}
if (this.previewTitle) {
this.previewTitle.textContent = title;
}
if (this.previewMeta) {
const displayAuthor = authorName || pubkey;
this.previewMeta.textContent = displayAuthor
? `Author: ${displayAuthor}`
: "Author info unavailable";
}
if (this.previewUrl) {
const displayUrl = shareUrl || "--";
this.previewUrl.textContent = displayUrl;
this.previewUrl.href = shareUrl || "#";
this.previewUrl.classList.toggle("pointer-events-none", !shareUrl);
}
this.setThumbnail(thumbnail);
}
async open({ video, triggerElement } = {}) {
await this.load();
this.setVideo(video || {});
this.setRelayList(this.resolveDefaultRelays());
this.setStatus("");
this.modal?.classList.remove("hidden");
this.modalAccessibility?.activate({ triggerElement });
this.setGlobalModalState("shareNostr", true);
this.dispatch("share-nostr:open", { video: this.activeVideo });
}
close() {
if (!this.modal) {
return;
}
this.modal.classList.add("hidden");
this.modalAccessibility?.deactivate();
this.setGlobalModalState("shareNostr", false);
this.dispatch("share-nostr:close", { video: this.activeVideo });
this.reset();
}
handleRelayInput(event) {
if (this.busy) {
return;
}
const value = event?.target?.value || "";
this.currentRelays = this.parseRelayInput(value);
this.renderRelayPills();
}
handleRelayPillClick(event) {
const trigger = event?.target?.closest?.("[data-action='remove-relay']");
if (!trigger) {
return;
}
const relay = trigger.dataset.relay;
if (!relay) {
return;
}
this.setRelayList(this.currentRelays.filter((entry) => entry !== relay));
}
async handlePostClick(event) {
if (event?.preventDefault) {
event.preventDefault();
}
if (this.busy) {
return;
}
const content = this.textarea?.value || "";
const relays = this.currentRelays.slice();
const payload = {
video: this.activeVideo,
content,
relays,
};
this.setBusy(true);
this.setStatus("Posting to relays...", "muted");
this.dispatch("share-nostr:post", payload);
if (!this.onPost) {
logger.user.warn("Share on Nostr is not configured yet.");
this.setStatus("Posting is not configured yet.", "warning");
this.setBusy(false);
return;
}
try {
await this.onPost(payload);
this.setStatus("Posted to relays.", "success");
this.close();
} catch (error) {
logger.user.error("Failed to post to relays.");
logger.dev.warn("[ShareNostrModal] Post request failed:", error);
this.setStatus("Failed to post. Please try again.", "danger");
this.setBusy(false);
}
}
handleCancelInteraction(event) {
if (event?.preventDefault) {
event.preventDefault();
}
if (this.busy) {
return;
}
if (this.onCancel) {
this.onCancel({ video: this.activeVideo });
}
this.close();
}
}
+5 -2
View File
@@ -1,4 +1,7 @@
import { normalizeDesignSystemContext } from "../../designSystem.js";
import {
normalizeDesignSystemContext,
BREAKPOINT_LG,
} from "../../designSystem.js";
import { updateVideoCardSourceVisibility } from "../../utils/cardSourceVisibility.js";
import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js";
import { userLogger } from "../../utils/logger.js";
@@ -3046,7 +3049,7 @@ export class VideoCard {
// Prevent active state on desktop to avoid conflicting with hover
const isDesktop =
typeof window !== "undefined" &&
window.matchMedia("(min-width: 1024px)").matches;
window.matchMedia(`(min-width: ${BREAKPOINT_LG}px)`).matches;
for (const entry of entries) {
if (isDesktop) {
+35 -8
View File
@@ -244,6 +244,10 @@ export class VideoModal {
reason: "",
parentCommentId: null,
};
this.shareNostrAuthState = {
isLoggedIn: false,
hasSigner: false,
};
this.commentThreadContext = {
videoEventId: "",
parentCommentId: null,
@@ -2161,7 +2165,7 @@ export class VideoModal {
}
}
return "#000000";
return "rgb(0, 0, 0)";
}
clearAmbientCanvas() {
@@ -2578,9 +2582,8 @@ export class VideoModal {
}
buildModalShareMenuPanel({ document: doc, close }) {
const isLoggedIn =
this.commentComposerState.reason !== "login-required" &&
!this.commentComposerState.disabled;
const isLoggedIn = this.shareNostrAuthState.isLoggedIn;
const hasSigner = this.shareNostrAuthState.hasSigner;
const hasMagnet = Boolean(
(typeof this.activeVideo?.magnet === "string" &&
@@ -2596,6 +2599,7 @@ export class VideoModal {
document: doc,
video: this.activeVideo,
isLoggedIn,
hasSigner,
hasMagnet,
hasCdn,
context: "modal",
@@ -2671,6 +2675,27 @@ export class VideoModal {
}
}
setShareNostrAuthState({ isLoggedIn, hasSigner } = {}) {
const nextIsLoggedIn = Boolean(isLoggedIn);
const nextHasSigner = Boolean(hasSigner);
const previous = this.shareNostrAuthState;
if (
previous &&
previous.isLoggedIn === nextIsLoggedIn &&
previous.hasSigner === nextHasSigner
) {
return;
}
this.shareNostrAuthState = {
isLoggedIn: nextIsLoggedIn,
hasSigner: nextHasSigner,
};
this.refreshModalShareMenuPanel();
}
buildModalMoreMenuPanel({ document: doc, close }) {
const panel = createVideoMoreMenuPanel({
document: doc,
@@ -2772,10 +2797,6 @@ export class VideoModal {
}
updateSourceAvailability(video) {
if (!this.sourceToggleContainer) {
return;
}
const hasUrl = Boolean(
typeof video?.url === "string" && video.url.trim()
);
@@ -2784,6 +2805,12 @@ export class VideoModal {
video?.torrentSupported
);
this.setShareEnabled(hasUrl || hasMagnet);
if (!this.sourceToggleContainer) {
return;
}
if (hasUrl || hasMagnet) {
this.sourceToggleContainer.removeAttribute("hidden");
this.sourceToggleContainer.classList.remove("hidden");
+395
View File
@@ -0,0 +1,395 @@
import { userLogger } from "../../utils/logger.js";
import { collectVideoTags } from "../../utils/videoTags.js";
import {
applyTagPreferenceState,
renderTagPillStrip,
trimTagPillStripToFit,
} from "./tagPillList.js";
export class HashtagStripHelper {
constructor({
document: doc = null,
window: win = null,
logger,
context = "",
scrollable = false,
} = {}) {
this.document = doc || (typeof document !== "undefined" ? document : null);
this.window = win || this.document?.defaultView ||
(typeof window !== "undefined" ? window : null);
this.logger = logger || userLogger;
this.context = typeof context === "string" ? context : "";
this.scrollable = scrollable === true;
this.container = null;
this._tagStrip = null;
this._sortedTags = [];
this._resizeObserver = null;
this._resizeHandler = null;
this._resizeCancel = null;
this._resizeScheduled = false;
this._isApplyingTrim = false;
this.tagStateResolver = null;
this.activateHandler = null;
this._handleResize = () => {
if (this._isApplyingTrim) {
return;
}
this.render();
};
}
mount(container) {
if (this.container === container) {
return this.container;
}
this._teardownResizeObserver();
if (this.container && this.container !== container) {
this.container.textContent = "";
this.container.hidden = true;
}
this.container = container || null;
this._tagStrip = null;
if (this.container) {
this.container.textContent = "";
this.container.hidden = true;
if (this._sortedTags.length) {
this.render();
this._ensureResizeObserver();
}
}
return this.container;
}
update(videos) {
const root = this.container;
if (!root) {
return;
}
if (!Array.isArray(videos) || videos.length === 0) {
this._sortedTags = [];
this.render();
this._teardownResizeObserver();
return;
}
const counts = new Map();
const displayNames = new Map();
videos.forEach((video) => {
const tags = collectVideoTags(video);
tags.forEach((tag) => {
if (typeof tag !== "string" || !tag) {
return;
}
const lower = tag.toLowerCase();
counts.set(lower, (counts.get(lower) || 0) + 1);
if (!displayNames.has(lower)) {
displayNames.set(lower, tag);
}
});
});
if (!counts.size) {
this._sortedTags = [];
this.render();
this._teardownResizeObserver();
return;
}
const tagEntries = Array.from(counts.entries()).map(([lower, count]) => ({
count,
tag: displayNames.get(lower) || lower,
}));
tagEntries.sort((a, b) => {
if (b.count !== a.count) {
return b.count - a.count;
}
const lowerA = a.tag.toLowerCase();
const lowerB = b.tag.toLowerCase();
if (lowerA === lowerB) {
return a.tag.localeCompare(b.tag);
}
return lowerA.localeCompare(lowerB);
});
this._sortedTags = tagEntries.map((entry) => entry.tag);
this.render();
if (this._sortedTags.length) {
this._ensureResizeObserver();
} else {
this._teardownResizeObserver();
}
}
setTagStateResolver(resolver) {
this.tagStateResolver = typeof resolver === "function" ? resolver : null;
this.refreshTagStates();
}
setActivateHandler(handler) {
this.activateHandler = typeof handler === "function" ? handler : null;
}
setContext(context) {
this.context = typeof context === "string" ? context : "";
}
refreshTagPreferenceStates() {
this.refreshTagStates();
}
refreshTagStates() {
if (!this._tagStrip) {
return;
}
const buttons = this._tagStrip.querySelectorAll("button[data-tag]");
buttons.forEach((button) => {
const tag = button.dataset.tag || "";
const state = this._resolveTagState(tag);
applyTagPreferenceState(button, state);
});
}
render() {
const root = this.container;
if (!root) {
return;
}
const tags = Array.isArray(this._sortedTags) ? [...this._sortedTags] : [];
this._isApplyingTrim = true;
try {
root.textContent = "";
this._tagStrip = null;
if (!tags.length) {
root.hidden = true;
return;
}
const doc = this.document || root.ownerDocument || null;
if (!doc) {
root.hidden = true;
this._teardownResizeObserver();
return;
}
const { root: strip } = renderTagPillStrip({
document: doc,
tags,
onTagActivate: (tag, detail = {}) =>
this._handleTagActivate(tag, detail),
getTagState: (tag) => this._resolveTagState(tag),
scrollable: this.scrollable,
});
root.appendChild(strip);
this._tagStrip = strip;
if (!this.scrollable) {
trimTagPillStripToFit({ strip, container: root });
} else {
this._triggerScrollHint();
}
root.hidden = strip.childElementCount === 0;
} finally {
this._isApplyingTrim = false;
}
}
destroy() {
this._teardownResizeObserver();
if (this.container) {
this.container.textContent = "";
this.container.hidden = true;
}
this.container = null;
this._tagStrip = null;
this._sortedTags = [];
}
_triggerScrollHint() {
const strip = this._tagStrip;
if (!strip) {
return;
}
if (
this.window &&
typeof this.window.requestAnimationFrame === "function" &&
typeof strip.scrollTo === "function"
) {
this.window.requestAnimationFrame(() => {
if (!strip.isConnected) {
return;
}
if (strip.scrollWidth > strip.clientWidth) {
if (this.window && typeof this.window.setTimeout === "function") {
this.window.setTimeout(() => {
if (!strip.isConnected || strip.scrollLeft !== 0) {
return;
}
strip.scrollTo({ left: 40, behavior: "smooth" });
this.window.setTimeout(() => {
if (!strip.isConnected) {
return;
}
strip.scrollTo({ left: 0, behavior: "smooth" });
}, 800);
}, 600);
}
}
});
}
}
_resolveTagState(tag) {
const resolver = this.tagStateResolver;
if (typeof resolver !== "function") {
return "neutral";
}
try {
return resolver(tag);
} catch (error) {
this.logger?.warn?.(
"[HashtagStripHelper] Failed to resolve tag preference state:",
error,
);
return "neutral";
}
}
_handleTagActivate(tag, detail) {
if (typeof this.activateHandler !== "function") {
return;
}
try {
const trigger =
detail?.button ||
detail?.event?.currentTarget ||
detail?.event?.target?.closest?.("button[data-tag]") ||
null;
this.activateHandler({
tag,
event: detail?.event || null,
trigger,
button: trigger,
context: this.context,
});
} catch (error) {
this.logger?.warn?.(
"[HashtagStripHelper] Tag activation handler threw an error:",
error,
);
}
}
_ensureResizeObserver() {
const root = this.container;
if (!root || this._resizeObserver || this._resizeHandler) {
return;
}
const resizeObserverCtor =
(this.window && this.window.ResizeObserver) ||
(typeof globalThis !== "undefined" ? globalThis.ResizeObserver : null);
if (typeof resizeObserverCtor === "function") {
this._resizeObserver = new resizeObserverCtor(() => {
this._handleResize();
});
this._resizeObserver.observe(root);
return;
}
if (!this.window) {
return;
}
const schedule = (callback) => {
if (this._resizeScheduled) {
return;
}
this._resizeScheduled = true;
if (typeof this.window.requestAnimationFrame === "function") {
const frameId = this.window.requestAnimationFrame(() => {
this._resizeScheduled = false;
this._resizeCancel = null;
callback();
});
this._resizeCancel = () => {
this.window.cancelAnimationFrame(frameId);
this._resizeCancel = null;
this._resizeScheduled = false;
};
} else {
const timeoutId = this.window.setTimeout(() => {
this._resizeScheduled = false;
this._resizeCancel = null;
callback();
}, 50);
this._resizeCancel = () => {
this.window.clearTimeout(timeoutId);
this._resizeCancel = null;
this._resizeScheduled = false;
};
}
};
const handler = () => {
schedule(() => {
if (!this.container) {
return;
}
this._handleResize();
});
};
this.window.addEventListener("resize", handler);
this._resizeHandler = handler;
}
_teardownResizeObserver() {
if (this._resizeObserver) {
try {
this._resizeObserver.disconnect();
} catch (error) {
// Ignore disconnect errors.
}
}
this._resizeObserver = null;
if (this._resizeHandler && this.window) {
this.window.removeEventListener("resize", this._resizeHandler);
}
this._resizeHandler = null;
if (typeof this._resizeCancel === "function") {
try {
this._resizeCancel();
} catch (error) {
// Ignore cancellation errors.
}
}
this._resizeCancel = null;
this._resizeScheduled = false;
}
}
+4
View File
@@ -213,12 +213,16 @@ export function renderTagPillStrip({
tags = [],
onTagActivate,
getTagState,
scrollable = false,
} = {}) {
const resolvedDocument = resolveDocument(doc);
assertDocument(resolvedDocument);
const root = resolvedDocument.createElement("div");
root.classList.add("video-tag-strip");
if (scrollable) {
root.classList.add("video-tag-strip--scroll");
}
const buttons = tags.map((tag) =>
createTagButton({
+6 -3
View File
@@ -313,6 +313,7 @@ export function createVideoShareMenuPanel({
document: documentRef = null,
video = null,
isLoggedIn = false,
hasSigner = false,
hasMagnet = false,
hasCdn = false,
designSystem = null,
@@ -338,10 +339,13 @@ export function createVideoShareMenuPanel({
action: "share-nostr",
dataset: { eventId: metadata.id },
});
if (!isLoggedIn) {
const canShareOnNostr = Boolean(isLoggedIn) && Boolean(hasSigner);
if (!canShareOnNostr) {
nostrBtn.disabled = true;
nostrBtn.classList.add("opacity-50", "cursor-not-allowed");
nostrBtn.title = "Log in to share on Nostr";
nostrBtn.title = isLoggedIn
? "Connect a Nostr signer to share"
: "Log in to share on Nostr";
}
const magnetBtn = appendMenuAction(doc, list, {
@@ -488,4 +492,3 @@ export function createVideoSettingsMenuPanel({
return panel;
}
+8 -18
View File
@@ -1,7 +1,6 @@
import { Composer } from "./Composer.js";
import { ConversationList } from "./ConversationList.js";
import { MessageThread } from "./MessageThread.js";
import { NotificationCenter } from "./NotificationCenter.js";
import { DMPrivacySettings } from "./DMPrivacySettings.js";
import { aggregateZapTotals } from "./zapHelpers.js";
import { createZapInterface } from "./ZapInterface.js";
@@ -114,6 +113,7 @@ const DEFAULT_ZAP_RECEIPTS = [
export class AppShell {
constructor({
document: doc,
currentUserAvatarUrl = "",
conversations = DEFAULT_CONVERSATIONS,
activeConversationId = DEFAULT_CONVERSATIONS[0]?.id || "",
conversationState = "idle",
@@ -124,7 +124,6 @@ export class AppShell {
composerState = "idle",
privacyMode = "nip04",
dmPrivacySettings = { readReceiptsEnabled: false, typingIndicatorsEnabled: false },
notifications = [],
signingAdapter = null,
zapConfig = null,
mobileView = "list",
@@ -135,6 +134,7 @@ export class AppShell {
onMarkAllRead,
onToggleReadReceipts,
onToggleTypingIndicators,
onRefreshConversations,
onBack,
} = {}) {
if (!doc) {
@@ -162,18 +162,18 @@ export class AppShell {
(receipt) => receipt.conversationId === activeConversation.id,
);
const sidebar = createElement(doc, "aside", "dm-app-shell__sidebar");
const sidebar = createElement(doc, "aside", "dm-app-shell__sidebar bg-transparent");
const sidebarHeader = createElement(doc, "div", "dm-app-shell__sidebar-header flex items-center justify-between p-4 relative");
const sidebarTitle = createElement(
doc,
"h1",
"dm-app-shell__title text-lg font-bold",
"Direct Messages",
"Conversations",
);
sidebarHeader.appendChild(sidebarTitle);
const settingsBtn = createElement(doc, "button", "btn-ghost btn-icon");
settingsBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`;
const settingsBtn = createElement(doc, "button", "icon-button accent-action-button");
settingsBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0" stroke-linecap="round" stroke-linejoin="round" class="icon-image w-5 h-5"><path d="M24,13.616L24,10.384C22.349,9.797 21.306,9.632 20.781,8.365L20.781,8.364C20.254,7.093 20.881,6.23 21.628,4.657L19.343,2.372C17.782,3.114 16.91,3.747 15.636,3.219L15.635,3.219C14.366,2.693 14.2,1.643 13.616,0L10.384,0C9.802,1.635 9.635,2.692 8.365,3.219L8.364,3.219C7.093,3.747 6.232,3.121 4.657,2.372L2.372,4.657C3.117,6.225 3.747,7.091 3.219,8.364C2.692,9.635 1.635,9.802 0,10.384L0,13.616C1.632,14.196 2.692,14.365 3.219,15.635C3.749,16.917 3.105,17.801 2.372,19.342L4.657,21.628C6.219,20.885 7.091,20.253 8.364,20.781L8.365,20.781C9.635,21.307 9.801,22.36 10.384,24L13.616,24C14.198,22.364 14.366,21.31 15.643,20.778L15.644,20.778C16.906,20.254 17.764,20.879 19.342,21.629L21.627,19.343C20.883,17.78 20.252,16.91 20.779,15.637C21.306,14.366 22.367,14.197 24,13.616ZM12,16C9.791,16 8,14.209 8,12C8,9.791 9.791,8 12,8C14.209,8 16,9.791 16,12C16,14.209 14.209,16 12,16Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path></svg>`;
settingsBtn.ariaLabel = "Direct message settings";
settingsBtn.addEventListener("click", (e) => {
e.stopPropagation();
@@ -211,6 +211,7 @@ export class AppShell {
state: conversationState,
onSelect: onSelectConversation,
onMarkAllRead,
onRefresh: onRefreshConversations,
}),
);
@@ -219,6 +220,7 @@ export class AppShell {
main.appendChild(
MessageThread({
document: doc,
currentUserAvatarUrl,
contact: activeConversation,
messages,
state: threadState,
@@ -248,12 +250,6 @@ export class AppShell {
zapConfig
});
// Position it relative to the composer or just absolute in main
// The extracted createZapInterface uses 'absolute bottom-16 right-4' which positions it nicely
// relative to the container if the container is relative.
// main usually has relative positioning or we can append to root.
// AppShell root has absolute positioning context maybe?
// If main is the container, we append there.
main.appendChild(zapInterface);
};
@@ -276,12 +272,6 @@ export class AppShell {
}
}),
);
main.appendChild(
NotificationCenter({
document: doc,
notices: notifications,
}),
);
this.root.appendChild(sidebar);
this.root.appendChild(main);
+51 -4
View File
@@ -22,6 +22,7 @@ export function ConversationList({
state = "idle",
onSelect,
onMarkAllRead,
onRefresh,
} = {}) {
if (!doc) {
throw new Error("ConversationList requires a document reference.");
@@ -29,12 +30,24 @@ export function ConversationList({
const root = createElement(doc, "section", "dm-conversation-list");
const header = createElement(doc, "div", "dm-conversation-list__header");
header.appendChild(createElement(doc, "h2", "dm-conversation-list__title", "Messages"));
// We removed the redundant "Messages" title here.
// If we need spacing/alignment for the button, justify-end in CSS handles it.
if (typeof onRefresh === "function") {
const refreshBtn = createElement(doc, "button", "btn-ghost btn-icon");
refreshBtn.type = "button";
refreshBtn.dataset.size = "sm";
refreshBtn.ariaLabel = "Refresh conversations";
refreshBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>`;
refreshBtn.addEventListener("click", onRefresh);
header.appendChild(refreshBtn);
}
if (typeof onMarkAllRead === "function") {
const markAllButton = createElement(
doc,
"button",
"btn-ghost",
"btn-ghost ml-auto",
"Mark all as read",
);
markAllButton.type = "button";
@@ -45,9 +58,12 @@ export function ConversationList({
});
header.appendChild(markAllButton);
}
root.appendChild(header);
const list = createElement(doc, "div", "dm-conversation-list__items");
if (header.hasChildNodes()) {
root.appendChild(header);
}
const list = createElement(doc, "div", "dm-conversation-list__items no-scrollbar");
list.setAttribute("role", "listbox");
list.setAttribute("aria-label", "Conversation list");
@@ -75,6 +91,37 @@ export function ConversationList({
});
list.appendChild(row);
});
// Peek animation
if (conversations.length > 0) {
if (typeof IntersectionObserver !== "undefined") {
const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
observer.disconnect();
setTimeout(() => {
if (list.scrollHeight > list.clientHeight) {
list.scrollTo({ top: 40, behavior: "smooth" });
setTimeout(() => {
list.scrollTo({ top: 0, behavior: "smooth" });
}, 800);
}
}, 600);
}
}, { threshold: 0.1 });
observer.observe(list);
} else {
// Fallback for no IntersectionObserver (e.g. some tests or very old browsers)
setTimeout(() => {
if (list.scrollHeight > list.clientHeight) {
list.scrollTo({ top: 40, behavior: "smooth" });
setTimeout(() => {
list.scrollTo({ top: 0, behavior: "smooth" });
}, 800);
}
}, 600);
}
}
}
list.addEventListener("keydown", (event) => {
+23 -6
View File
@@ -13,6 +13,7 @@ export function MessageBubble({
document: doc,
message = {},
variant = "incoming",
avatarSrc = "",
} = {}) {
if (!doc) {
throw new Error("MessageBubble requires a document reference.");
@@ -24,12 +25,19 @@ export function MessageBubble({
status = "sent",
} = message;
const wrapper = createElement(doc, "div", "dm-message-bubble");
wrapper.classList.add(`dm-message-bubble--${variant}`);
wrapper.dataset.status = status;
const row = createElement(doc, "div", "dm-message-row");
row.classList.add(`dm-message-row--${variant}`);
const avatar = createElement(doc, "img", "dm-message-avatar");
avatar.src = avatarSrc || "assets/svg/default-profile.svg";
avatar.alt = variant === "outgoing" ? "Me" : "Sender";
const bubble = createElement(doc, "div", "dm-message-bubble");
bubble.classList.add(`dm-message-bubble--${variant}`);
bubble.dataset.status = status;
const content = createElement(doc, "div", "dm-message-bubble__content", body);
wrapper.appendChild(content);
bubble.appendChild(content);
const meta = createElement(doc, "div", "dm-message-bubble__meta");
if (timestamp) {
@@ -55,6 +63,15 @@ export function MessageBubble({
meta.appendChild(statusLabel);
}
wrapper.appendChild(meta);
return wrapper;
bubble.appendChild(meta);
if (variant === "outgoing") {
row.appendChild(bubble);
row.appendChild(avatar);
} else {
row.appendChild(avatar);
row.appendChild(bubble);
}
return row;
}
+38 -3
View File
@@ -16,6 +16,7 @@ export function MessageThread({
document: doc,
contact = {},
messages = [],
currentUserAvatarUrl = "",
state = "idle",
errorType = "",
privacyMode = "nip04",
@@ -93,7 +94,7 @@ export function MessageThread({
}
root.appendChild(header);
const timeline = createElement(doc, "div", "dm-message-thread__timeline");
const timeline = createElement(doc, "div", "dm-message-thread__timeline no-scrollbar");
timeline.setAttribute("role", "log");
timeline.setAttribute("aria-live", "polite");
@@ -128,20 +129,54 @@ export function MessageThread({
timeline.appendChild(DayDivider({ document: doc, label: message.label }));
return;
}
const isOutgoing = message.direction === "outgoing";
const bubble = MessageBubble({
document: doc,
message,
variant: message.direction === "outgoing" ? "outgoing" : "incoming",
variant: isOutgoing ? "outgoing" : "incoming",
avatarSrc: isOutgoing ? currentUserAvatarUrl : (contact.avatarSrc || ""),
});
timeline.appendChild(bubble);
});
}
// Scroll to bottom
// Scroll to bottom and peek
if (messages.length > 0) {
const animate = () => {
timeline.scrollTop = timeline.scrollHeight;
if (timeline.scrollHeight > timeline.clientHeight) {
setTimeout(() => {
const targetScroll = Math.max(0, timeline.scrollTop - 40);
timeline.scrollTo({ top: targetScroll, behavior: "smooth" });
setTimeout(() => {
timeline.scrollTo({
top: timeline.scrollHeight,
behavior: "smooth",
});
}, 800);
}, 600);
}
};
if (typeof IntersectionObserver !== "undefined") {
const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
observer.disconnect();
// First ensure we are at bottom
timeline.scrollTop = timeline.scrollHeight;
setTimeout(animate, 300);
}
}, { threshold: 0.1 });
observer.observe(timeline);
} else {
setTimeout(() => {
timeline.scrollTop = timeline.scrollHeight;
animate();
}, 0);
}
}
root.appendChild(timeline);
+34 -23
View File
@@ -293,6 +293,7 @@ export default class LoginModalController {
this.modalCloseObserver = null;
this.modalCloseIntervalId = null;
this.isSelectionInProgress = false;
this.nip46AutoStartTimer = null;
this.initializeRemoteSignerStatus();
this.initialized = false;
@@ -1296,28 +1297,24 @@ export default class LoginModalController {
if (qrContainer instanceof HTMLElement) {
qrContainer.innerHTML = "";
const resolveTokenColor = (tokenName, fallback) => {
if (!tokenName) {
return fallback;
// Force high-contrast black-on-white for the QR code to ensure
// it remains readable and scannable regardless of the application theme.
// We resolve these from the unified token system to ensure consistency.
let qrLightColor = "rgb(255, 255, 255)";
let qrDarkColor = "rgb(0, 0, 0)";
if (this.window && typeof this.window.getComputedStyle === "function") {
try {
const style = this.window.getComputedStyle(this.document.documentElement);
const white = style.getPropertyValue("--color-white").trim();
const black = style.getPropertyValue("--color-black").trim();
if (white) qrLightColor = white;
if (black) qrDarkColor = black;
} catch (error) {
// Fallback to hardcoded values if style resolution fails
}
const doc = this.document;
const win = this.window;
if (!doc || !win || typeof win.getComputedStyle !== "function") {
return fallback;
}
const root = doc.documentElement;
if (!root) {
return fallback;
}
const computed = win.getComputedStyle(root);
if (!computed) {
return fallback;
}
const value = computed.getPropertyValue(tokenName);
return value && value.trim() ? value.trim() : fallback;
};
const qrLightColor = resolveTokenColor("--color-white", "#ffffff");
const qrDarkColor = resolveTokenColor("--color-black", "#000000");
}
if (uri) {
try {
qrInstance = renderQrCode(qrContainer, uri, {
@@ -1486,7 +1483,14 @@ export default class LoginModalController {
if (cancelButton instanceof HTMLButtonElement) {
cancelHandler = (event) => {
event.preventDefault();
finish(null, { keepMounted: false });
if (settled) {
this.rejectPendingTask(this.createCancellationError(), {
type: "add-profile",
});
cleanup();
} else {
finish(null, { keepMounted: false });
}
};
cancelButton.addEventListener("click", cancelHandler);
}
@@ -1566,7 +1570,7 @@ export default class LoginModalController {
}
// Kick off the handshake generation
setTimeout(autoStart, 0);
this.nip46AutoStartTimer = setTimeout(autoStart, 0);
});
}
@@ -2472,6 +2476,13 @@ export default class LoginModalController {
}
this.modalCloseIntervalId = null;
if (this.nip46AutoStartTimer) {
if (this.window && typeof this.window.clearTimeout === "function") {
this.window.clearTimeout(this.nip46AutoStartTimer);
}
this.nip46AutoStartTimer = null;
}
if (this.pendingTask && typeof this.pendingTask.reject === "function") {
try {
this.pendingTask.reject(this.createCancellationError());
+11
View File
@@ -1892,6 +1892,7 @@ export class ProfileModalController {
featureBannerSelector: "#profileHistoryFeatureBanner",
sessionWarningSelector: "#profileHistorySessionWarning",
emptyCopy: "You haven’t watched any videos yet.",
variant: "modal",
remove: (payload) =>
this.callbacks.onHistoryReady({
...(typeof payload === "object" && payload ? payload : {}),
@@ -4412,6 +4413,11 @@ export class ProfileModalController {
activeThread?.latestMessage,
);
const currentUserSummary = this.resolveProfileSummaryForPubkey(
this.resolveActiveDmActor(),
);
const currentUserAvatarUrl = currentUserSummary?.avatarSrc || "";
container.innerHTML = "";
try {
@@ -4419,6 +4425,7 @@ export class ProfileModalController {
this.dmAppShell = new AppShell({
document,
currentUserAvatarUrl,
conversations,
activeConversationId,
conversationState,
@@ -4435,6 +4442,9 @@ export class ProfileModalController {
onSelectConversation: (conversation) => {
void this.handleDmConversationSelect(conversation);
},
onRefreshConversations: () => {
this.populateProfileMessages({ force: true });
},
onBack: () => {
this.dmMobileView = "list";
void this.renderDmAppShell(this.directMessagesCache, {
@@ -4473,6 +4483,7 @@ export class ProfileModalController {
return;
}
root.classList.add("bg-transparent");
container.appendChild(root);
if (actor && activeConversationId) {
+477
View File
@@ -0,0 +1,477 @@
export default class UrlHealthController {
constructor({
state = {},
utils = {},
logger = {},
constants = {},
callbacks = {},
} = {}) {
this.state = state;
this.utils = utils;
this.logger = logger;
this.constants = constants;
this.callbacks = callbacks;
}
getUrlHealthPlaceholderMarkup(options = {}) {
const includeMargin = options?.includeMargin !== false;
const classes = ["badge", "url-health-badge", "text-muted"];
if (includeMargin) {
classes.push("mt-sm");
}
return `
<span
class="${classes.join(" ")}"
data-url-health-state="checking"
data-variant="neutral"
aria-live="polite"
role="status"
>
⏳ CDN
</span>
`;
}
updateUrlHealthBadge(badgeEl, state, videoId) {
if (!badgeEl) {
return;
}
if (
videoId &&
badgeEl.dataset.urlHealthFor &&
badgeEl.dataset.urlHealthFor !== videoId
) {
return;
}
if (!badgeEl.isConnected) {
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
if (badgeEl.isConnected) {
this.updateUrlHealthBadge(badgeEl, state, videoId);
}
});
}
return;
}
const status = state?.status || "checking";
const fallbackMessages = {
healthy: "✅ CDN",
offline: "❌ CDN",
unknown: "⚠️ CDN",
timeout: "⚠️ CDN timed out",
checking: "⏳ CDN",
};
const message =
state?.message ||
fallbackMessages[status] ||
fallbackMessages.checking;
const hadCompactMargin =
badgeEl.classList.contains("mt-sm") || badgeEl.classList.contains("mt-3");
badgeEl.dataset.urlHealthState = status;
const cardEl = badgeEl.closest(".card[data-video-id]");
if (cardEl && typeof this.utils.updateVideoCardSourceVisibility === "function") {
cardEl.dataset.urlHealthState = status;
this.utils.updateVideoCardSourceVisibility(cardEl);
}
badgeEl.setAttribute("aria-live", "polite");
badgeEl.setAttribute("role", status === "offline" ? "alert" : "status");
badgeEl.textContent = message;
const classes = ["badge", "url-health-badge"];
if (hadCompactMargin) {
classes.push("mt-sm");
}
badgeEl.className = classes.join(" ");
const variantMap = {
healthy: "success",
offline: "critical",
unknown: "neutral",
timeout: "neutral",
checking: "neutral",
};
const variant = variantMap[status];
if (variant) {
badgeEl.dataset.variant = variant;
} else if (badgeEl.dataset.variant) {
delete badgeEl.dataset.variant;
}
const videoListView =
this.callbacks.getVideoListView &&
typeof this.callbacks.getVideoListView === "function"
? this.callbacks.getVideoListView()
: null;
if (
videoId &&
videoListView &&
typeof videoListView.cacheUrlHealth === "function"
) {
videoListView.cacheUrlHealth(videoId, {
status,
message,
lastCheckedAt: Number.isFinite(state?.lastCheckedAt)
? state.lastCheckedAt
: undefined,
});
}
}
handleUrlHealthBadge({ video, url, badgeEl }) {
if (!video?.id || !badgeEl || !url) {
return;
}
const eventId = video.id;
const trimmedUrl = typeof url === "string" ? url.trim() : "";
if (!trimmedUrl) {
return;
}
badgeEl.dataset.urlHealthFor = eventId;
if (typeof this.state.getCachedUrlHealth === "function") {
const cached = this.state.getCachedUrlHealth(eventId, trimmedUrl);
if (cached) {
this.updateUrlHealthBadge(badgeEl, cached, eventId);
return;
}
}
this.updateUrlHealthBadge(badgeEl, { status: "checking" }, eventId);
const probeOptions = { confirmPlayable: true };
const getInFlight = this.state.getInFlightUrlProbe;
const existingProbe =
typeof getInFlight === "function"
? getInFlight(eventId, trimmedUrl, probeOptions)
: null;
if (existingProbe) {
existingProbe
.then((entry) => {
if (entry) {
this.updateUrlHealthBadge(badgeEl, entry, eventId);
}
})
.catch((err) => {
this.logger.warn(
`[urlHealth] cached probe promise rejected for ${trimmedUrl}:`,
err
);
});
return;
}
const probePromise = this.probeUrl(trimmedUrl, probeOptions)
.then((result) => {
const outcome = result?.outcome || "error";
let entry;
if (outcome === "ok") {
entry = { status: "healthy", message: "✅ CDN" };
} else if (outcome === "opaque" || outcome === "unknown") {
entry = {
status: "unknown",
message: "⚠️ CDN",
};
} else if (outcome === "timeout") {
entry = {
status: "timeout",
message: "⚠️ CDN timed out",
};
} else {
entry = {
status: "offline",
message: "❌ CDN",
};
}
const urlHealthConstants = this.constants.urlHealthConstants || {};
const ttlOverride =
entry.status === "timeout" || entry.status === "unknown"
? urlHealthConstants.URL_HEALTH_TIMEOUT_RETRY_MS
: undefined;
if (typeof this.state.storeUrlHealth === "function") {
return this.state.storeUrlHealth(
eventId,
trimmedUrl,
entry,
ttlOverride
);
}
return entry;
})
.catch((err) => {
this.logger.warn(`[urlHealth] probe failed for ${trimmedUrl}:`, err);
const entry = {
status: "offline",
message: "❌ CDN",
};
if (typeof this.state.storeUrlHealth === "function") {
return this.state.storeUrlHealth(eventId, trimmedUrl, entry);
}
return entry;
});
if (typeof this.state.setInFlightUrlProbe === "function") {
this.state.setInFlightUrlProbe(
eventId,
trimmedUrl,
probePromise,
probeOptions
);
}
probePromise
.then((entry) => {
if (entry) {
this.updateUrlHealthBadge(badgeEl, entry, eventId);
}
})
.catch((err) => {
this.logger.warn(
`[urlHealth] probe promise rejected post-cache for ${trimmedUrl}:`,
err
);
});
}
async probeUrlWithVideoElement(url, timeoutMs) {
const defaultTimeout = this.constants.URL_PROBE_TIMEOUT_MS || 5000;
const effectiveTimeout =
Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : defaultTimeout;
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed || typeof document === "undefined") {
return { outcome: "error" };
}
return new Promise((resolve) => {
const video = document.createElement("video");
let settled = false;
let timeoutId = null;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
video.removeEventListener("loadeddata", handleSuccess);
video.removeEventListener("canplay", handleSuccess);
video.removeEventListener("error", handleError);
try {
video.pause();
} catch (err) {
// ignore pause failures
}
try {
video.removeAttribute("src");
video.load();
} catch (err) {
// ignore cleanup failures
}
};
const settle = (result) => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(result);
};
const handleSuccess = () => {
settle({ outcome: "ok" });
};
const handleError = () => {
settle({ outcome: "error" });
};
if (Number.isFinite(effectiveTimeout) && effectiveTimeout > 0) {
timeoutId = setTimeout(() => {
settle({ outcome: "timeout" });
}, effectiveTimeout);
}
try {
video.preload = "metadata";
video.muted = true;
video.playsInline = true;
video.addEventListener("loadeddata", handleSuccess, { once: true });
video.addEventListener("canplay", handleSuccess, { once: true });
video.addEventListener("error", handleError, { once: true });
video.src = trimmed;
video.load();
} catch (err) {
settle({ outcome: "error", error: err });
}
});
}
async probeUrl(url, options = {}) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
return { outcome: "invalid" };
}
const confirmPlayable = options?.confirmPlayable === true;
const defaultTimeout = this.constants.URL_PROBE_TIMEOUT_MS || 5000;
const urlHealthConstants = this.constants.urlHealthConstants || {};
const confirmWithVideoElement = async () => {
if (!confirmPlayable) {
return null;
}
const initialTimeout =
Number.isFinite(options?.videoProbeTimeoutMs) &&
options.videoProbeTimeoutMs > 0
? options.videoProbeTimeoutMs
: defaultTimeout;
const attemptWithTimeout = async (timeoutMs) => {
try {
const result = await this.probeUrlWithVideoElement(
trimmed,
timeoutMs
);
if (result && result.outcome) {
return result;
}
} catch (err) {
this.logger.warn(
`[probeUrl] Video element probe threw for ${trimmed}:`,
err
);
}
return null;
};
let result = await attemptWithTimeout(initialTimeout);
if (
result &&
result.outcome === "timeout" &&
Number.isFinite(urlHealthConstants.URL_PROBE_TIMEOUT_RETRY_MS) &&
urlHealthConstants.URL_PROBE_TIMEOUT_RETRY_MS > initialTimeout
) {
const retryResult = await attemptWithTimeout(
urlHealthConstants.URL_PROBE_TIMEOUT_RETRY_MS
);
if (retryResult) {
result = { ...retryResult, retriedAfterTimeout: true };
}
}
return result;
};
const supportsAbort = typeof AbortController !== "undefined";
const controller = supportsAbort ? new AbortController() : null;
let timeoutId = null;
const racers = [
fetch(trimmed, {
method: "HEAD",
mode: "no-cors",
cache: "no-store",
signal: controller ? controller.signal : undefined,
}),
];
if (Number.isFinite(defaultTimeout) && defaultTimeout > 0) {
racers.push(
new Promise((resolve) => {
timeoutId = setTimeout(() => {
if (controller) {
try {
controller.abort();
} catch (err) {
// ignore abort errors
}
}
resolve({ outcome: "timeout" });
}, defaultTimeout);
})
);
}
let responseOrTimeout;
try {
responseOrTimeout = await Promise.race(racers);
} catch (err) {
if (timeoutId) {
clearTimeout(timeoutId);
}
this.logger.warn(`[probeUrl] HEAD request failed for ${trimmed}:`, err);
const fallback = await confirmWithVideoElement();
if (fallback) {
return fallback;
}
return confirmPlayable
? { outcome: "error", error: err }
: { outcome: "unknown", error: err };
}
if (timeoutId) {
clearTimeout(timeoutId);
}
if (responseOrTimeout && responseOrTimeout.outcome === "timeout") {
const fallback = await confirmWithVideoElement();
if (fallback) {
return fallback;
}
return confirmPlayable ? { outcome: "timeout" } : { outcome: "unknown" };
}
const response = responseOrTimeout;
if (!response) {
return { outcome: "error" };
}
if (response.type === "opaque") {
const fallback = await confirmWithVideoElement();
if (fallback) {
return fallback;
}
return { outcome: confirmPlayable ? "opaque" : "unknown" };
}
if (!response.ok) {
const fallback = await confirmWithVideoElement();
if (fallback) {
return fallback;
}
return {
outcome: "bad",
status: response.status,
};
}
const playbackCheck = await confirmWithVideoElement();
if (playbackCheck) {
if (playbackCheck.outcome === "ok") {
return {
...playbackCheck,
status: response.status,
};
}
return playbackCheck;
}
return {
outcome: "ok",
status: response.status,
};
}
}
+18 -4
View File
@@ -17,15 +17,23 @@ export default class VideoListViewController {
);
}
mount({ container = null, view, currentVideoList = null, includeTags = true } = {}) {
mount({
container = null,
popularTagsContainer = null,
view,
currentVideoList = null,
includeTags = true,
} = {}) {
if (!view) {
return { videoList: currentVideoList || null, popularTags: null };
}
const target = container || this.getElementById("videoList");
let popularTags = null;
if (includeTags) {
popularTags = this.getElementById("recentVideoTags");
if (this.isElement(popularTagsContainer)) {
popularTags = popularTagsContainer;
} else if (includeTags) {
popularTags = this.getElementById("videoListTags");
}
if (typeof view.setPopularTagsContainer === "function") {
@@ -45,6 +53,7 @@ export default class VideoListViewController {
reason,
postLoginResult,
currentVideoList = null,
popularTagsContainer = null,
} = {}) {
if (!view) {
return {
@@ -74,7 +83,12 @@ export default class VideoListViewController {
? "Applying your filters…"
: "Refreshing videos…";
const tagsRoot = this.getElementById("recentVideoTags");
let tagsRoot = null;
if (this.isElement(popularTagsContainer)) {
tagsRoot = popularTagsContainer;
} else {
tagsRoot = this.getElementById("videoListTags");
}
if (typeof view.setPopularTagsContainer === "function") {
view.setPopularTagsContainer(this.isElement(tagsRoot) ? tagsRoot : null);
}
+44 -275
View File
@@ -1,16 +1,11 @@
import { VideoCard } from "../components/VideoCard.js";
import {
renderTagPillStrip,
applyTagPreferenceState,
trimTagPillStripToFit,
} from "../components/tagPillList.js";
import { HashtagStripHelper } from "../components/hashtagStripHelper.js";
import {
subscribeToVideoViewCount,
unsubscribeFromVideoViewCount,
} from "../../viewCounter.js";
import { normalizeDesignSystemContext } from "../../designSystem.js";
import { userLogger } from "../../utils/logger.js";
import { collectVideoTags } from "../../utils/videoTags.js";
const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__";
@@ -180,20 +175,17 @@ export class VideoListView {
this.currentVideos = [];
this.lastRenderedVideoSignature = null;
this._lastRenderedVideoListElement = null;
this.popularTagsRoot = null;
this._popularTagStrip = null;
this._popularTagsSortedList = [];
this._popularTagResizeObserver = null;
this._popularTagResizeHandler = null;
this._popularTagResizeCancel = null;
this._popularTagResizeScheduled = false;
this._isApplyingPopularTagTrim = false;
this._handlePopularTagResize = () => {
if (this._isApplyingPopularTagTrim) {
return;
}
this.renderPopularTagStrip();
};
this.popularTagHelper = new HashtagStripHelper({
document: this.document,
window: this.window,
logger: userLogger,
scrollable: true,
});
this.popularTagHelper.setContext("popular-tags");
this.popularTagHelper.setActivateHandler((detail) =>
this.handlePopularTagActivate(detail),
);
this.handlers = {
playback: null,
@@ -207,8 +199,6 @@ export class VideoListView {
tagActivate: null,
};
this.tagPreferenceStateResolver = null;
this.allowNsfw = allowNsfw !== false;
this.designSystem = normalizeDesignSystemContext(designSystem);
@@ -281,14 +271,9 @@ export class VideoListView {
this.currentVideos = [];
this.lastRenderedVideoSignature = null;
this._lastRenderedVideoListElement = null;
this._teardownPopularTagResizeObserver();
if (this.popularTagsRoot) {
this.popularTagsRoot.textContent = "";
this.popularTagsRoot.hidden = true;
if (this.popularTagHelper) {
this.popularTagHelper.destroy();
}
this.popularTagsRoot = null;
this._popularTagStrip = null;
this._popularTagsSortedList = [];
}
setPlaybackHandler(handler) {
@@ -330,228 +315,21 @@ export class VideoListView {
}
setTagPreferenceStateResolver(resolver) {
this.tagPreferenceStateResolver =
typeof resolver === "function" ? resolver : null;
if (this.popularTagHelper) {
this.popularTagHelper.setTagStateResolver(resolver);
}
}
setPopularTagsContainer(container) {
this._teardownPopularTagResizeObserver();
if (this.popularTagsRoot && this.popularTagsRoot !== container) {
this.popularTagsRoot.textContent = "";
this.popularTagsRoot.hidden = true;
}
this.popularTagsRoot = container || null;
this._popularTagStrip = null;
if (this.popularTagsRoot) {
this.popularTagsRoot.textContent = "";
this.popularTagsRoot.hidden = true;
if (this._popularTagsSortedList.length) {
this.renderPopularTagStrip();
this._ensurePopularTagResizeObserver();
}
if (this.popularTagHelper) {
this.popularTagHelper.mount(container || null);
}
}
updatePopularTags(videos) {
const root = this.popularTagsRoot;
if (!root) {
return;
if (this.popularTagHelper) {
this.popularTagHelper.update(videos);
}
if (!Array.isArray(videos) || videos.length === 0) {
this._popularTagsSortedList = [];
this.renderPopularTagStrip();
this._teardownPopularTagResizeObserver();
return;
}
const counts = new Map();
const displayNames = new Map();
videos.forEach((video) => {
const tags = collectVideoTags(video);
tags.forEach((tag) => {
if (typeof tag !== "string" || !tag) {
return;
}
const lower = tag.toLowerCase();
counts.set(lower, (counts.get(lower) || 0) + 1);
if (!displayNames.has(lower)) {
displayNames.set(lower, tag);
}
});
});
if (!counts.size) {
this._popularTagsSortedList = [];
this.renderPopularTagStrip();
this._teardownPopularTagResizeObserver();
return;
}
const tagEntries = Array.from(counts.entries()).map(([lower, count]) => ({
count,
tag: displayNames.get(lower) || lower,
}));
tagEntries.sort((a, b) => {
if (b.count !== a.count) {
return b.count - a.count;
}
const lowerA = a.tag.toLowerCase();
const lowerB = b.tag.toLowerCase();
if (lowerA === lowerB) {
return a.tag.localeCompare(b.tag);
}
return lowerA.localeCompare(lowerB);
});
this._popularTagsSortedList = tagEntries.map((entry) => entry.tag);
this.renderPopularTagStrip();
if (this._popularTagsSortedList.length) {
this._ensurePopularTagResizeObserver();
} else {
this._teardownPopularTagResizeObserver();
}
}
renderPopularTagStrip() {
const root = this.popularTagsRoot;
if (!root) {
return;
}
const tags = Array.isArray(this._popularTagsSortedList)
? [...this._popularTagsSortedList]
: [];
this._isApplyingPopularTagTrim = true;
try {
root.textContent = "";
this._popularTagStrip = null;
if (!tags.length) {
root.hidden = true;
return;
}
const doc = this.document || root.ownerDocument || null;
if (!doc) {
root.hidden = true;
this._teardownPopularTagResizeObserver();
return;
}
const { root: strip } = renderTagPillStrip({
document: doc,
tags,
onTagActivate: (tag, detail = {}) =>
this.handlePopularTagActivate(tag, detail),
getTagState: (tag) => this.resolveTagPreferenceState(tag),
});
root.appendChild(strip);
this._popularTagStrip = strip;
trimTagPillStripToFit({ strip, container: root });
root.hidden = strip.childElementCount === 0;
} finally {
this._isApplyingPopularTagTrim = false;
}
}
_ensurePopularTagResizeObserver() {
const root = this.popularTagsRoot;
if (!root || this._popularTagResizeObserver || this._popularTagResizeHandler) {
return;
}
const resizeObserverCtor =
(this.window && this.window.ResizeObserver) ||
(typeof globalThis !== "undefined" ? globalThis.ResizeObserver : null);
if (typeof resizeObserverCtor === "function") {
this._popularTagResizeObserver = new resizeObserverCtor(() => {
this._handlePopularTagResize();
});
this._popularTagResizeObserver.observe(root);
return;
}
if (!this.window) {
return;
}
const schedule = (callback) => {
if (this._popularTagResizeScheduled) {
return;
}
this._popularTagResizeScheduled = true;
if (typeof this.window.requestAnimationFrame === "function") {
const frameId = this.window.requestAnimationFrame(() => {
this._popularTagResizeScheduled = false;
this._popularTagResizeCancel = null;
callback();
});
this._popularTagResizeCancel = () => {
this.window.cancelAnimationFrame(frameId);
this._popularTagResizeCancel = null;
this._popularTagResizeScheduled = false;
};
} else {
const timeoutId = this.window.setTimeout(() => {
this._popularTagResizeScheduled = false;
this._popularTagResizeCancel = null;
callback();
}, 50);
this._popularTagResizeCancel = () => {
this.window.clearTimeout(timeoutId);
this._popularTagResizeCancel = null;
this._popularTagResizeScheduled = false;
};
}
};
const handler = () => {
schedule(() => {
if (!this.popularTagsRoot) {
return;
}
this._handlePopularTagResize();
});
};
this.window.addEventListener("resize", handler);
this._popularTagResizeHandler = handler;
}
_teardownPopularTagResizeObserver() {
if (this._popularTagResizeObserver) {
try {
this._popularTagResizeObserver.disconnect();
} catch (error) {
// Ignore disconnect errors.
}
}
this._popularTagResizeObserver = null;
if (this._popularTagResizeHandler && this.window) {
this.window.removeEventListener("resize", this._popularTagResizeHandler);
}
this._popularTagResizeHandler = null;
if (typeof this._popularTagResizeCancel === "function") {
try {
this._popularTagResizeCancel();
} catch (error) {
// Ignore cancellation errors.
}
}
this._popularTagResizeCancel = null;
this._popularTagResizeScheduled = false;
}
render(videos, metadata = null) {
@@ -1500,53 +1278,44 @@ export class VideoListView {
this.emitSelected(detail);
}
resolveTagPreferenceState(tag) {
const resolver = this.tagPreferenceStateResolver;
if (typeof resolver !== "function") {
return "neutral";
}
try {
return resolver(tag);
} catch (error) {
userLogger.warn(
"[VideoListView] Failed to resolve tag preference state:",
error,
);
return "neutral";
}
}
refreshTagPreferenceStates() {
if (!this._popularTagStrip) {
return;
if (this.popularTagHelper) {
this.popularTagHelper.refreshTagPreferenceStates();
}
const buttons = this._popularTagStrip.querySelectorAll("button[data-tag]");
buttons.forEach((button) => {
const tag = button.dataset.tag || "";
const state = this.resolveTagPreferenceState(tag);
applyTagPreferenceState(button, state);
});
}
handlePopularTagActivate(tag, { event = null, button = null } = {}) {
handlePopularTagActivate(detail = {}, options = {}) {
const candidate =
detail && typeof detail === "object" ? detail : { tag: detail };
const tag = candidate.tag;
const event = candidate.event || options.event || null;
let trigger = candidate.trigger || candidate.button || options.button || null;
if (!trigger && event && event.target) {
trigger =
event.target.closest?.("button[data-tag]") ||
event.currentTarget ||
null;
}
const context =
typeof candidate.context === "string" && candidate.context
? candidate.context
: "popular-tags";
const normalizedTag =
typeof tag === "string" && tag.trim() ? tag.trim() : String(tag ?? "");
if (!normalizedTag) {
return;
}
const detail = {
const payload = {
tag: normalizedTag,
event,
trigger: button,
context: "popular-tags",
trigger,
context,
};
if (typeof this.handlers.tagActivate === "function") {
try {
this.handlers.tagActivate(detail);
this.handlers.tagActivate(payload);
} catch (error) {
userLogger.warn(
"[VideoListView] Tag activation handler threw an error:",
@@ -1555,6 +1324,6 @@ export class VideoListView {
}
}
this.emit("tag:activate", detail);
this.emit("tag:activate", payload);
}
}
+15
View File
@@ -1,6 +1,7 @@
// js/viewManager.js
import { initChannelProfileView } from "./channelProfile.js";
import { initForYouView } from "./forYouView.js";
import { initKidsView } from "./kidsView.js";
import { initExploreView } from "./exploreView.js";
import { subscriptions } from "./subscriptions.js";
import { getApplication } from "./applicationContext.js";
@@ -118,6 +119,20 @@ export const viewInitRegistry = {
refreshApp.forceRefreshAllProfiles();
}
},
kids: () => {
initKidsView();
const app = getApplication();
if (app && typeof app.loadKidsVideos === "function") {
if (typeof app.mountVideoListView === "function") {
app.mountVideoListView();
}
app.loadKidsVideos();
}
const refreshApp = getApplication();
if (refreshApp && typeof refreshApp.forceRefreshAllProfiles === "function") {
refreshApp.forceRefreshAllProfiles();
}
},
explore: () => {
initExploreView();
const app = getApplication();
+10 -4
View File
@@ -175,7 +175,7 @@ export class TorrentClient {
*/
async probePeers(
magnetURI,
{ timeoutMs = 8000, maxWebConns = 2, polls = 3 } = {}
{ timeoutMs = 8000, maxWebConns = 2, polls = 3, urlList = [] } = {}
) {
const magnet = typeof magnetURI === "string" ? magnetURI.trim() : "";
if (!magnet) {
@@ -194,7 +194,9 @@ export class TorrentClient {
const { magnet: augmentedMagnet, appended, hasProbeTrackers } =
appendProbeTrackers(magnet, trackers);
const hasWebSeed = magnet.includes("ws=") || magnet.includes("webSeed=");
const hasMagnetWebSeed = magnet.includes("ws=") || magnet.includes("webSeed=");
const hasExplicitWebSeed = Array.isArray(urlList) && urlList.length > 0;
const hasWebSeed = hasMagnetWebSeed || hasExplicitWebSeed;
if (!hasProbeTrackers && !hasWebSeed) {
return {
@@ -270,10 +272,14 @@ export class TorrentClient {
};
try {
torrent = client.add(augmentedMagnet, {
const addOptions = {
announce: trackers,
maxWebConns: safeMaxWebConns,
});
};
if (hasExplicitWebSeed) {
addOptions.urlList = urlList;
}
torrent = client.add(augmentedMagnet, addOptions);
} catch (err) {
finalize({
reason: "error",
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more