Merge pull request #1868 from PR0M3TH3AN/unstable

Unstable
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-27 23:11:40 -05:00
commit 18cd1423ec
98 files changed
+5855 -4246

No files matched your search

+4 -1
View File
@@ -1,7 +1,10 @@
{
"name": "bitvid",
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
"postCreateCommand": "npm ci && npm run build",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"postCreateCommand": "npm ci && npm run build && npx playwright install",
"customizations": {
"vscode": {
"extensions": [
@@ -0,0 +1,75 @@
name: Daily Design System Audit
on:
schedule:
- cron: '0 9 * * *' # Run daily at 09:00 UTC
workflow_dispatch:
permissions:
contents: write
issues: write
pull-requests: write
jobs:
audit:
name: Audit Design System
runs-on: ubuntu-latest
steps:
- name: Checkout unstable branch
uses: actions/checkout@v4
with:
ref: unstable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Verify gh CLI availability
run: gh --version
- name: Install dependencies
run: npm ci
- name: Run Design System Audit (with fix)
id: audit
run: node scripts/daily-design-system-audit.mjs --fix
continue-on-error: true
- name: Check for changes
id: git-check
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "changes=true" >> $GITHUB_OUTPUT
else
echo "changes=false" >> $GITHUB_OUTPUT
fi
- name: Create Pull Request
if: steps.git-check.outputs.changes == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "ds-audit/$(date +%Y-%m-%d)"
git add .
git commit -m "chore: Apply daily design system remediation"
git push origin HEAD
gh pr create \
--title "Daily Design System Remediation" \
--body-file REMEDIATION_REPORT.md \
--base unstable \
--head "ds-audit/$(date +%Y-%m-%d)" \
--label "design-system"
- name: Create Issue on Failure (No Fixes)
if: steps.audit.outcome == 'failure' && steps.git-check.outputs.changes == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh issue create \
--title "⚠️ Design System Violations Detected" \
--body-file REMEDIATION_REPORT.md \
--label "design-system"
+15 -3
View File
@@ -91,6 +91,7 @@ This workflow uses the `force-cancel` endpoint to ensure stalled runs are termin
To set up the project locally:
1. **Install Dependencies**:
Use `npm ci` to ensure you get the exact dependencies from `package-lock.json`.
```bash
npm ci
```
@@ -105,7 +106,7 @@ To set up the project locally:
```bash
npm run test:unit
```
*Note: Unit tests may take a few minutes to complete.*
*Note: Unit tests may take a few minutes to complete. To speed up local iterations, you can run a specific shard (e.g., `npm run test:unit:shard1`).*
You can also run end-to-end and visual tests:
```bash
@@ -147,12 +148,23 @@ For a full guide, see the [Local Setup section in README.md](./README.md#local-s
## Dev Container
This project includes a `.devcontainer` configuration for VS Code. It provides a pre-configured environment with Node.js 22 and necessary extensions.
This project includes a `.devcontainer` configuration for VS Code. It provides a pre-configured environment with Node.js 22, the GitHub CLI, and necessary extensions.
To use it:
1. Open the project in VS Code.
2. When prompted, re-open in Container (or use the command palette: "Dev Containers: Reopen in Container").
3. The container will automatically install dependencies and run the build.
3. The container will automatically:
- Install NPM dependencies (`npm ci`)
- Run the build (`npm run build`)
- Install Playwright browsers (so you can run `npm run test:visual`)
## Key Documentation
Before starting your work, please review these key documents:
- **[AGENTS.md](./AGENTS.md)**: Architectural guidelines, mission statement, and troubleshooting tips.
- **[Nostr Event Schemas](./docs/nostr-event-schemas.md)**: The source of truth for all Nostr events published by the application.
- **[Playback Fallback](./docs/playback-fallback.md)**: Details on the URL-first playback strategy and WebTorrent fallback mechanism.
## Security
+1 -1
View File
@@ -1,5 +1,5 @@
# Base image matching the Playwright version in package.json
FROM mcr.microsoft.com/playwright:v1.57.0-jammy
FROM mcr.microsoft.com/playwright:v1.58.0-jammy
# Set working directory
WORKDIR /app
+14 -9
View File
@@ -144,14 +144,10 @@ 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`.
Use `npm ci` to install dependencies exactly as specified in `package-lock.json`. This ensures `npx` finds the correct local `tailwindcss` version.
```bash
# reproducible install (preferred for CI / locks)
npm ci
# or if you want to install/upgrade packages
npm install
```
3. Build the project:
@@ -178,19 +174,28 @@ To run **bitvid** locally:
http://localhost:8000
```
### Quickstart Commands
### Developer Quickstart
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full developer guide.
**Verify your work:**
- **Run unit tests**: `npm run test:unit` (Required before PRs)
- **Format code**: `npm run format` (Required before PRs)
- **Lint code**: `npm run lint`
- **Lint code**: `npm run lint` (Checks for CSS, hex colors, inline styles)
**Other commands:**
- **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`
- **Run visual regression tests**: `npm run test:visual`
- **Cancel CI runs**: See [`docs/cancelling-ci-runs.md`](docs/cancelling-ci-runs.md) for a script to clear pending workflows.
### Docs navigation & TOC updates
The docs viewer reads `content/docs/toc.json` to build the sidebar tree and map `doc` slugs to Markdown files. Each entry defines the label shown in the UI and the slug used in deep links. Add new docs content by creating a Markdown file under `content/` (or a subfolder) and then adding a matching entry in `content/docs/toc.json` that points to the new slug. Keep the slug and filename in sync so the reader can resolve the Markdown content and the sidebar link. Deep links follow the format `#view=docs&doc=<slug>` (for example, `#view=docs&doc=getting-started`). QA acceptance criteria for new docs include: the sidebar link appears, the deep link opens the correct document, the mobile drawer lists the new item, and the Markdown renders as expected (headings, lists, links, etc.).【F:content/docs/toc.json†L1-L5】
### Running Tests in Docker
To run Playwright tests in a consistent containerized environment, use the provided Docker script:
@@ -233,11 +238,11 @@ const event = buildVideoPostEvent({
// Provide your hex pubkey (not npub)
pubkey: "your_pubkey_hex",
created_at: Math.floor(Date.now() / 1000),
dTagValue: "my-first-video", // The stable identifier (d-tag) for this video
dTagValue: "my-first-video", // The stable identifier (d-tag) for this video series
content: {
version: 3,
title: "My First Video",
videoRootId: "my-first-video", // Logical ID, typically matches the d-tag
videoRootId: "my-first-video", // Logical ID, matches the d-tag
url: "https://example.com/video.mp4",
description: "This is a test video post sent via the SDK."
// magnet: "magnet:?xt=urn:btih:..." // Optional fallback (provide the raw magnet string)
-97
View File
@@ -1,97 +0,0 @@
{
"timestamp": "2026-01-26T19:39:32.887Z",
"config": {
"duration": 60,
"clients": 1000,
"rate": 0.1,
"relay": "ws://localhost:8889",
"force": false
},
"metrics": {
"total_sent": 6467,
"total_received": 6467,
"errors": 0,
"throughput_sent": 107.78333333333333,
"throughput_recv": 107.78333333333333,
"latency_ms": {
"p50": 0.9700979999979609,
"p95": 14.598196999999345,
"p99": 27.9113440000001
},
"operation_times_ms": {
"build_avg": 0.10325683753031617,
"sign_avg": 6.474848927014066
},
"resource_usage": [
{
"timestamp": 1769456317885,
"cpuPercent": 74.54,
"rss": 129236992,
"heapUsed": 26275344
},
{
"timestamp": 1769456322898,
"cpuPercent": 71.77,
"rss": 130256896,
"heapUsed": 30151424
},
{
"timestamp": 1769456327916,
"cpuPercent": 70.52,
"rss": 131235840,
"heapUsed": 30829208
},
{
"timestamp": 1769456332917,
"cpuPercent": 72.98,
"rss": 131637248,
"heapUsed": 38053280
},
{
"timestamp": 1769456337917,
"cpuPercent": 78.13,
"rss": 132292608,
"heapUsed": 25903752
},
{
"timestamp": 1769456342918,
"cpuPercent": 92.71,
"rss": 133566464,
"heapUsed": 28111320
},
{
"timestamp": 1769456347922,
"cpuPercent": 94.4,
"rss": 133959680,
"heapUsed": 33235048
},
{
"timestamp": 1769456352924,
"cpuPercent": 77.78,
"rss": 134717440,
"heapUsed": 27477160
},
{
"timestamp": 1769456357925,
"cpuPercent": 86.22,
"rss": 135241728,
"heapUsed": 36644992
},
{
"timestamp": 1769456362926,
"cpuPercent": 79.21,
"rss": 136028160,
"heapUsed": 28313856
},
{
"timestamp": 1769456367927,
"cpuPercent": 71.57,
"rss": 136814592,
"heapUsed": 39710304
}
],
"error_breakdown": {}
},
"bottlenecks": [],
"remediation": []
}
+2 -2
View File
@@ -12,11 +12,11 @@
},
"dependencies": {
"prod": 5,
"dev": 314,
"dev": 349,
"optional": 29,
"peer": 0,
"peerOptional": 0,
"total": 318
"total": 353
}
}
}
+9 -13
View File
@@ -1,13 +1,9 @@
./scan_todos.cjs:19: if (line.includes('TODO') || line.includes('FIXME') || line.includes('XXX')) {
./package-lock.json:3843: "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
./tests/visual/baselines.json:2: "contrast": "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 truncated
./tests/visual/baselines.json:3: "default": "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 truncated
./tests/visual/baselines.json:4: "light": "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 truncated
./tests/visual/baselines.json:5: "video-modal-mobile": "iVBORw0KGgoAAAANSUhEUgAAAYYAAAYSCAIAAACzsttpAAAQAElEQVR4nOzdCYBS1f4H8DOazTXNwRYHXzViblQuWC5UmpSauLwis0TNHmYLWr3QNmzFNvFfKZYpvTIxU7HnQouGaYVLhVviUuHyCqdFxhYZl7xjLv/fhZlxHIeRmWE5DN/P//0J4XK5MNwv5/zO5dyzTpw4wQAA+HAWAwDgBiIJADiCSAIAjiCSAIAjiCQA4AgiCQA4gkgCAI5UFkmzZ88+dOjQjz/+yCB+mjdv3qBBg+HDhzMAOE1WtEMlKY/q16/fq1cvBvG2YsWKw4cPI5UATlcn2h3UPkIeJQi9sfT2MgA4TdSOG/prCYW3F6BCKG8DAEcQSQDAEUQSAHAEkQQAHKnDEmPv3r3XX3/9gAEDGABAzBISScFgUKvV+ny+gwcPMgCAmMW/40bto759+wYCAYVC8f777zMAgJjFOZIoj/r06RPJo2XLljVp0oQBAMQsnpGEPAKAGopbJCGPAKDm4lPePnHixKBBgyiPWrRo8cknnyCPAKB64lxLysnJYQAA1RWfVlJWVtaCBQuoy/bNN9/QcBt14hgAQNXF7bgk6qxRCYlSibpvVFRCKgFANcTzUMmapVLh+ndGD+jSRt64ceNm7XsPGj1+4Q4xfPO47l1GLixgiSdunT6g/aB3wk8LACkR56O3q5tKBSvGDRg0cWc703vr/AH/x5OGtMqfe/+g+xfmIx4AMkr8f1BCqUSDbpdeeimlEg3DxfKQwvX28YuKhr7x3oShnfNyc3Lb9bprwoLl7z3bL/fU5QrWTx/Zu0tY72Hjl4SbM2L+mumjB3VvL5e36TJg9OQVkRCLNLnCBoydu7Ww3BMWLBndpcugsaMHDejdvUv78CJiuS16Z+yg7tRkk7fvPmic1F4rXDOue5kmVOH68fTPufRshVsXjh3QPbJRo99ZXxi5s3eXYePGDmjfpvfkrUhVgNgl5Dducrl8yZIlLVu2PPvss2NYnFJlTX6T/gM7l00gIa/Xrbd2yxNOLrVj7tj75+aYFqxat27VAqOwcOzYmVtFcevM8TOLRizYEgxuXzWt/95FCzcUivlzR4+0FQ19j5Zc/kbPHdbR1jWFpz3pzh2FPSctWL561QJTzpLx4+eW6a8VrreNnbi+lfmj7YHNM4ZmLxk/cUlBTqeBA3N3zFuyMxJ5WxetKOo8pFte4ZJxI8fn95+0nDbqvRGF9vvHLQn3McWCDVuzRy1Yt3xMO4EBQKwSNRMApdL69etXrFgRw7JFhT8VCrmX5GZXthDl1qKtOQOH9JRSivJqRK+c/DU79jKhUXbh+pn2hesLRCGv/4RpY7plh5e81di/NS2Z03noqG5FdMNpmXRJu36dw+tq3XMoPWZDflHpXTmdn129ffWE/q1zqMXWs1sey8/fKwrt+g/My180d0OhlEhL1hS1G9gtr2DDojWsm3Fg5xxpRf1H9cymG6RMEoRW/Uf0bI1DIgCqJlGRVBXZOZfksMLCwsqXotyiJUv28exs6TF7RdZuzJwFE64tmDf2ujbte4+cvGRHISvcWfDTNtuNisZhyjvm7czPzy+/dkHILVmZkJOTI57y/GL+kskje7eXSu1Nuz7xZaQgRok3tF3hZ4vWFxRQIrFuQ7rlioUFBXt3zrtNGXkqxT/t2yi9CsXwi8oR0D4CqCoeIknI69wpZwe1P8oOq0mpMH7u+pM5IeVWUWluFBVRQuU0kfb6nHb9x0xbsHr7lgXGnEVjR1o31m2V2+ral1bt2Vdq94KheeWflUJILL1WeDKgwiNvo8d9lvfs8iA9dM/aV69tUrKd3YZ0FlfMW/LZog1CN6llJOTk5ua1NX4UOPlUweWjWiOKAKqLh0hiOZ1GGDvn24cNGv3Omh0FIhVi1ky/f9DomTtYTmlnjvJgYLvCRfM+o/uprrRk5orC1j3bNcpfOLr3sOnhEnIOJVteTk6TRpd2G9hq79yZS8KVbnHHwnFjp6857SACce+aeeHunLjjs7nrWatrW5c+1f78bXuF1te2ypVKQmtmztu6V9wfSS/ahm7CionjlzDaGCnCcjsN7MQ+m7lwa6TSvmTy2MlLMEoIUH18THQrtL5rxvJc2/jxT/yz68PSDdmX9Bw17ePH++cJhetLlxk6aVLhE+P/ed34wvydha1GvDFjaGuZkGcy5dusIwcV7S3IL2CtRk26q50sp920N8Rx4wddZ8umDiFrN2JSq9zTnjKvU7t867De+fkFRXm9np1AT8W2Ru5q0s04Im/kyN7dW1PTqd3AEXd1Gv/O2Cc6zXmpf15ut6E9c+fNzDFGEokyicpX4vjxo6+bybKLCoua9H/p1iYCS8ZRVAC1UtSz3T7++OPjxo1jtVPBktH/tAqTPp7Urar1Z3HHO3fcsajnewtq2D2bMGHCxIkTGQCciouOW7oQC9bPHW/P7zRqIMpFAImBSIpVwcKRHZQ3ji/s+ZK5Zy4DgISIWktq3rw5q7Vy+09b159VTe6tM7bfOoPFSa1+ewGqL2orqUGDBrEd6AhVRm8svb0MAE4TtbxNZs+efejQoR9//JFB/FD7iPJo+PDhDABOU1kkAQAkGU7ADQAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHIkaSZ06dWIAkDobNmxgmQetJADgCCIJADiCSAIAjiCSAIDxA5EEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcASRBAAcQSQBAEcQSQDAEUQSAHAEkQQAHEEkAQBHEEkAwBFEEgBwBJEEABxBJAEARxBJAMARRBIAcKROtDtOnDiBS1ziMoWXmamyVhK9L1lZWaXvEa7jeu27zvMWsoxUp5L7Sv9muI7rtfU6z1vIMlJxJJ/uqquuYgCQOhs3bmSZB+VtAOAIIgkAOIIRN1ziktPLzBS1lnTllVcyAEidb775hmUedNwAgCOIJADgCGpJuMQlp5eZKWotqWPHjgwAUmfTpk0s86DjBgAciRpJkdZTVtbJZhSu43rtu87zFmamM7SSyr47uI7rte86z1uYmdBxAwCOYMQNl7jk9DIzRe24dujQgQFA6mzevJllHnTcAIAjZxhxAwBIJrSSAIAjaCUBAEfQSgIAjqCVBAAcQSsJADiCVhIAcARHb+MSl5xeZqaoR29ffvnlDABS57vvvmOZB60kXOKS08vMFLWVdNlllzEASJ3vv/+eZZ4zl7ejzSmF23F77bg9XbY8Q5x5Vsnjx49Hbik39x1ux+214HaetzAzYVZJXM/o6zxvYWbCcUkAwBGMuOESl5xeZqaohbRWrVoxAEidnTt3ssyDjhsAcASRBAAcwUwAAMARtJIAgCOIJADgCCIJADiCWhIAcAStJADgCCIJADiCSAIAjuA3brjEJaeXmSnqb9wuvvhiBgCp8/PPP7PMg44bAHAEkQTVcdZZZx09epQBxBtqSbis2uX5558fuezevXuzZs3waSl7SUndpk2b1q1b16tXr+Zry0xRa0lNmzZl1dWoUaM6deqEQiEGtUvjxo2vuuqqFStWnHvuuSqVqn79+gcOHPjmm2+KiopYsrRt25aeN3J9165d+/bti2xYy5YtIzf+9ddf3377LUuFFmF0ZceOHYFAgNXMnj17WOaprJVUbWeffXbnzp0vu+wyCqYT8XN2i5usH6/7kf5QP25e/s7dHS+oYJGL+zyxcJ2fFtnjXzXnCc0FZ0duvuDq+99ZtVl6qH/dwvE3tTj7BFSZTCbr2LFjZO76/fv3r1q1yu/3N2zYsEuXLpEbk+PcsErupW/EE6kWl08+y0iJqiXRwy+66KILLriAPrUFBQWs5hpd+fCU/9Ox+ffd8J9dOdqnpjxlfzK/32Pu38p8PWdfcbvt/+5s9PnTfV/5ilLine truncated
./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
./js/ui/profileModalController.js:203: "Formats npub strings for display using the canonical short representation (npubXXXX…XXXX).",
js/ui/profileModalController.js:203: "Formats npub strings for display using the canonical short representation (npubXXXX…XXXX).",
vendor/highlight.min.js:62:begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)",
vendor/highlight.min.js:63:end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0})
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
+2 -59
View File
@@ -159,62 +159,14 @@
data-state="collapsed"
>
<nav class="sidebar-dropup-panel__inner" aria-label="Secondary">
<a href="#view=about" class="sidebar-nav-link sidebar-nav-link--sub" title="About">
<img
src="assets/svg/about-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">About</span>
</a>
<a
href="#view=community-guidelines"
class="sidebar-nav-link sidebar-nav-link--sub"
title="Community Guidelines"
>
<img
src="assets/svg/guidelines-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Guidelines</span>
</a>
<a
href="#view=getting-started"
class="sidebar-nav-link sidebar-nav-link--sub"
title="Getting Started"
>
<a href="#view=docs" class="sidebar-nav-link sidebar-nav-link--sub" title="Docs">
<img
src="assets/svg/getting-started-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Getting Started</span>
</a>
<a
href="#view=upload-content"
class="sidebar-nav-link sidebar-nav-link--sub"
title="Upload Content"
>
<img
src="assets/svg/upload-content-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Upload Content</span>
</a>
<a href="#view=roadmap" class="sidebar-nav-link sidebar-nav-link--sub" title="Roadmap">
<img
src="assets/svg/roadmap-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Roadmap</span>
<span class="sidebar-text">Docs</span>
</a>
<a
href="#"
@@ -314,15 +266,6 @@
/>
<span class="sidebar-text">Beta</span>
</a>
<a href="#view=links" class="sidebar-nav-link sidebar-nav-link--sub" title="Links">
<img
src="assets/svg/links-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Links</span>
</a>
<a
href="#"
data-dns-link
+1 -1
View File
@@ -108,7 +108,7 @@ export const COMMUNITY_URL = "https://groups.nip29.com/?relay=wss://relay.groups
* string) to hide the link entirely.
*/
export const NOSTR_URL =
"https://primal.net/p/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe";
"https://nostrudel.ninja/u/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe";
/**
* Optional external destination for the GitHub menu link.
File renamed without changes.
+8
View File
@@ -0,0 +1,8 @@
# Community
Connect with other bitvid users and learn about our community standards.
## Resources
- **[Community Guidelines](#view=docs&doc=community-guidelines)**: Expectations for behavior and content.
- **[Links](#view=docs&doc=links)**: Official links and resources.
File renamed without changes.
File renamed without changes.
+7
View File
@@ -0,0 +1,7 @@
# Guides
Learn how to make the most of bitvid.
## Available Guides
- **[Upload Content](#view=docs&doc=upload-content)**: How to host and share your videos on bitvid.
File renamed without changes.
+13
View File
@@ -0,0 +1,13 @@
# Docs Overview
Welcome to the bitvid documentation!
Here you will find guides, community information, and details about our roadmap.
## Sections
- **[Getting Started](#view=docs&doc=getting-started)**: Learn how to watch and share videos.
- **[About](#view=docs&doc=about)**: Learn more about bitvid's mission.
- **[Guides](#view=docs&doc=guides)**: Detailed guides on using bitvid.
- **[Community](#view=docs&doc=community)**: Community guidelines and links.
- **[Roadmap](#view=docs&doc=roadmap)**: What's coming next.
File renamed without changes.
+64
View File
@@ -0,0 +1,64 @@
{
"items": [
{
"title": "Overview",
"id": "overview",
"slug": "overview",
"path": "content/docs/overview.md",
"children": [
{
"title": "Getting Started",
"id": "getting-started",
"slug": "getting-started",
"path": "content/docs/getting-started.md"
},
{
"title": "About Bitvid",
"id": "about",
"slug": "about",
"path": "content/docs/about.md"
}
]
},
{
"title": "Guides",
"id": "guides",
"slug": "guides",
"path": "content/docs/guides/index.md",
"children": [
{
"title": "Upload Content",
"id": "upload-content",
"slug": "upload-content",
"path": "content/docs/guides/upload-content.md"
}
]
},
{
"title": "Community",
"id": "community",
"slug": "community",
"path": "content/docs/community/index.md",
"children": [
{
"title": "Community Guidelines",
"id": "community-guidelines",
"slug": "community-guidelines",
"path": "content/docs/community/community-guidelines.md"
},
{
"title": "Links",
"id": "links",
"slug": "links",
"path": "content/docs/community/links.md"
}
]
},
{
"title": "Roadmap",
"id": "roadmap",
"slug": "roadmap",
"path": "content/docs/roadmap.md"
}
]
}
+76
View File
@@ -0,0 +1,76 @@
/* Docs layout + table of contents styling */
#docsToc {
position: sticky;
top: var(--space-xl);
align-self: flex-start;
max-height: calc(100vh - var(--space-2xl));
overflow-y: auto;
padding: var(--space-md);
border: var(--border-width-hairline) solid var(--border-subtle);
border-radius: var(--radius-lg);
background-color: var(--surface-sunken);
box-shadow: inset 0 0 0 var(--border-width-hairline)
var(--surface-overlay-darker);
}
#docsContent {
min-width: 0;
}
#docsContent .markdown-prose {
max-width: 72ch;
}
#docsTocList,
#docsTocDrawerNav {
gap: var(--space-xs);
}
#docsTocList ul,
#docsTocDrawerNav ul {
list-style: none;
margin: 0;
padding: 0;
}
#docsTocList li,
#docsTocDrawerNav li {
margin: 0;
}
#docsTocList [data-docs-toc-item],
#docsTocDrawerNav [data-docs-toc-item] {
display: block;
padding: var(--space-2xs) var(--space-xs);
border-radius: var(--radius-md);
color: var(--text-muted);
text-decoration: none;
transition: background-color var(--motion-duration-fast) ease,
color var(--motion-duration-fast) ease;
}
#docsTocList [data-docs-toc-item]:hover,
#docsTocDrawerNav [data-docs-toc-item]:hover,
#docsTocList [data-docs-toc-item]:focus-visible,
#docsTocDrawerNav [data-docs-toc-item]:focus-visible {
background-color: var(--surface-muted);
color: var(--text-primary);
}
#docsTocList [data-docs-toc-item][aria-current="page"],
#docsTocDrawerNav [data-docs-toc-item][aria-current="page"] {
background-color: var(--surface-overlay-panel-soft);
color: var(--text-primary);
}
#docsTocList [data-docs-toc-item]:focus-visible,
#docsTocDrawerNav [data-docs-toc-item]:focus-visible {
outline: var(--space-3xs) solid var(--color-accent);
outline-offset: var(--space-3xs);
}
#docsTocDrawerNav {
max-height: calc(100vh - var(--space-2xl));
overflow-y: auto;
padding-right: var(--space-2xs);
}
+71 -163
View File
@@ -1,184 +1,92 @@
# NostrClient Overview
`js/nostr/client.js` exports the `NostrClient` class, which serves as the central controller for all Nostr protocol interactions in the application. It acts as a bridge between the UI components, the local state (React/Redux/Listeners), and the decentralized Nostr network.
`NostrClient` (`js/nostr/client.js`) is the central controller for all Nostr network interactions, state management, and persistence in the application. It acts as the bridge between the raw Nostr protocol (relays, events) and the application's UI domain model (Videos, Profiles).
## Responsibilities
## Core Responsibilities
1. **Connection Management**:
* Maintains a persistent connection pool (`SimplePool` from `nostr-tools`) to multiple relays.
* Handles relay discovery, connection retries, and error reporting.
* Supports "Read" vs. "Write" relay splitting.
1. **Connection Management**: Maintains a pool of WebSocket connections to relays via `nostr-tools/SimplePool`.
2. **Event Processing**: Subscribes to event streams, buffers incoming bursts, validates schemas, and converts raw events into domain objects.
3. **State Management**:
* **`allEvents`**: A complete history of every video version fetched.
* **`activeMap`**: A materialized view of the *latest* valid version of each video series (deduplicated by Root ID).
* **`tombstones`**: A set of deletion timestamps to prevent "zombie" events (deleted items reappearing from old caches) from being displayed.
4. **Persistence**: Implements a "Stale-While-Revalidate" strategy using IndexedDB (with LocalStorage fallback) to restore the UI instantly on load.
5. **Signer Orchestration**: Manages NIP-07 (Browser Extension), NIP-46 (Remote/Bunker), and Local (nsec) signers.
2. **Event Publishing (Write Path)**:
* Constructs spec-compliant Nostr events (Kind 1, 30078, etc.).
* Handles **signing**:
* NIP-07 (Browser Extensions like Alby/nos2x).
* NIP-46 (Remote Signers / Bunker).
* Local Private Keys (nsec).
* Broadcasts events to write relays and verifies acceptance.
* Implements **NIP-94** (File Metadata) and **NIP-71** (Video Categorization) mirroring.
## State Management Architecture
3. **Data Fetching & Subscription (Read Path)**:
* **Live Feeds**: Uses persistent subscriptions (`sub`) to stream new events.
* **Buffering**: Implements a debounced buffer strategy (`subscribeVideos`) to handle relay floods without freezing the UI.
* **Caching**: Stores events in `allEvents` (memory) and persists to IndexedDB/LocalStorage for instant load ("Stale-While-Revalidate").
The client maintains three primary maps to handle the "Eventual Consistency" nature of Nostr:
4. **State Management**:
* **`allEvents`**: Map of all raw video events fetched, keyed by Event ID.
* **`activeMap`**: A "materialized view" of the latest video state. It deduplicates multiple versions of the same video (edits) based on `videoRootId` or `d` tag, ensuring the UI only shows the most recent valid version.
* **`tombstones`**: Tracks deleted events to prevent "zombie" events from reappearing during partial syncs.
* **`allEvents` (`Map<EventID, Video>`)**:
* Stores every valid video event received.
* Used for history hydration (viewing edit logs) and debugging.
* **`activeMap` (`Map<RootID, Video>`)**:
* Stores only the *most recent* version of a video.
* Keyed by `videoRootId` (or `pubkey:dTag` for legacy).
* **Invariant**: `activeMap.get(id).created_at` is always >= any other version of that ID.
* **`tombstones` (`Map<RootID, Timestamp>`)**:
* Stores the timestamp of the latest deletion event for a video.
* **Invariant**: Any event with `created_at <= tombstone` is implicitly treated as deleted, even if the "deleted" flag isn't set on the event itself. This handles out-of-order delivery where a "delete" arrives before the "video" it deletes.
## Key Architectures
## Caching Strategy
### 1. The "Active Map" & Deduplication
Nostr is immutable; "editing" a video means publishing a NEW event with the same identifier (`d` tag).
The client listens to *all* versions but only keeps the latest one in `activeMap`.
The client uses a multi-layered approach to ensure fast boot times:
```javascript
// Simplified Logic
if (incomingEvent.createdAt > currentActiveEvent.createdAt) {
activeMap.set(videoRootId, incomingEvent);
notifyUI();
}
1. **IndexedDB (`events` store)**:
* Primary cache for video events.
* Stores fingerprints of objects to avoid redundant writes.
2. **LocalStorage (`bitvid:eventsCache:v1`)**:
* Fallback if IndexedDB is unavailable.
* Size-limited (typically < 5MB).
3. **Buffering**:
* Incoming events are buffered in memory and flushed to IDB periodically (debounced) to avoid I/O thrashing during heavy relay traffic.
## Event Processing (Buffering & Debouncing)
When `subscribeVideos` is called, the client expects a flood of events (e.g., 500+ items from relay history). Processing these one-by-one would freeze the UI.
### Flow
```mermaid
sequenceDiagram
participant Relay
participant ClientBuffer
participant Processor
participant UI
Relay->>ClientBuffer: Event 1
Relay->>ClientBuffer: Event 2
Relay->>ClientBuffer: ... Event 50
ClientBuffer->>ClientBuffer: Buffer (Debounce 75ms)
loop Flush Timer
ClientBuffer->>Processor: Flush Batch (50 events)
Processor->>Processor: Validate & Convert
Processor->>Processor: Apply Tombstones
Processor->>Processor: Update activeMap (Last-Write-Wins)
Processor->>UI: Callback(onVideo) (Single Render)
Processor->>IndexedDB: Persist Snapshot
end
```
### 2. Buffering Strategy
When the app loads, relays might send 1000+ events in a second. Rendering React components 1000 times is too slow.
`subscribeVideos` pushes events to a buffer and flushes them every 75ms.
## History Reconstruction
```javascript
sub.on("event", (evt) => {
buffer.push(evt);
debounce(flushBuffer, 75);
});
```
Videos in Nostr are immutable events. "Editing" means publishing a new event with the same identifier. `NostrClient` reconstructs the history chain using:
### 3. Tombstoning (Deletes)
Since relays are eventually consistent, a "delete" event (Kind 5) might arrive *before* the event it deletes, or *after*.
The client tracks `tombstones` (map of ID -> deletedAt timestamp). Any incoming event older than its tombstone is immediately discarded.
1. **`videoRootId` (V3)**: The canonical identifier for a video series.
2. **`d` Tag (NIP-33)**: The addressable identifier.
3. **Legacy IDs**: Fallback to Event ID for V1/V2 posts.
## Main Entry Points
* `init()`: Bootstraps the client, restores local cache, connects to relays.
* `subscribeVideos(callback)`: Main feed subscription.
* `publishVideo(payload)`: Complex flow to upload -> sign -> publish -> mirror.
* `connectRemoteSigner(uri)`: Handshake protocol for NIP-46.
`hydrateVideoHistory` fetches all events matching these identifiers, sorts them by `created_at` descending, and returns the full edit log.
## 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.
## Advanced Topics
### Version History Reconstruction (`hydrateVideoHistory`)
Since edits create new events, a video might have 10+ versions scattered across relays.
`hydrateVideoHistory` stitches them together by:
1. **Scanning Local Cache**: Finds all events matching the `videoRootId`.
2. **Relay Fallback**: If local history is sparse, queries relays for `kind: 30078` + `#d` tag.
3. **Root Recovery**: Explicitly fetches the original "Root" event if missing, to establish the timeline start.
### Optimized List Fetching (`fetchListIncrementally`)
Used for things like Mute Lists (Kind 10000) or Pin Lists.
- **Last-Seen Check**: Tracks the `created_at` of the last item seen from *each* relay.
- **Delta Sync**: Asks for `since: lastSeen + 1`.
- **Fallback**: If the delta sync fails, performs a full fetch to ensure consistency.
- **Concurrency**: Batches requests to 4 relays at a time to prevent browser network saturation.
### Video Deletion Flow (`deleteAllVersions`)
Nostr's immutability makes "deleting" a video series complex, as multiple edit versions might exist across relays.
`deleteAllVersions` handles this via a multi-stage process:
1. **Hydration**: Calls `hydrateVideoHistory` to find *every* event related to the video (scanning by `videoRootId` and `d` tag).
2. **Soft Delete (Revert)**: For each unique version identifier (Root + D-Tag combination), it publishes a new event with `deleted: true` and cleared content. This ensures clients resolving the "latest" version see the deletion immediately.
3. **Hard Delete (NIP-09)**: Publishes a Kind 5 Deletion Request referencing the Event IDs of *all* versions found. Compliant relays will physically remove the data.
4. **Tombstoning**: Updates the local `tombstones` map with the deletion timestamp to preventing caching mechanisms from resurrecting old versions.
```mermaid
sequenceDiagram
participant UI as VideoEditModal
participant Client as NostrClient
participant Relay
UI->>Client: deleteAllVersions(rootId)
activate Client
Note right of Client: 1. Find all versions
Client->>Client: hydrateVideoHistory(video)
Client->>Relay: Query (Kind 30078 + #d)
Relay-->>Client: Events [v1, v2, v3]
Note right of Client: 2. Soft Delete (Revert)
loop For each unique branch
Client->>Relay: Publish Kind 30078 (deleted=true)
end
Note right of Client: 3. Hard Delete (NIP-09)
Client->>Relay: Publish Kind 5 (referencing v1, v2, v3 ids)
Note right of Client: 4. Local Cleanup
Client->>Client: recordTombstone(rootId)
Client->>Client: saveLocalData()
Client-->>UI: Success
deactivate Client
```
* **Eventual Consistency**: The `tombstones` map is essential because relays guarantee neither order nor completeness. A client might receive a "delete" event for a video it hasn't seen yet, or receive an old version of a video after receiving the deletion. Tombstones ensure that once something is known to be deleted, it stays deleted (until a newer re-creation event appears).
* **Performance**: The `eventBuffer` mechanism allows the client to ingest thousands of events without blocking the main thread, as React rendering is expensive. Batching updates reduces render cycles from O(N) to O(1) per batch.
* **Offline-First**: Restoring from IndexedDB immediately allows the app to be interactive before any network connection is established.
## 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
```
* **Storage Backend**: If the cache schema becomes too complex for IndexedDB, or if we move to a native wrapper (React Native/Tauri) with a SQL backend, the `EventsCacheStore` class should be swapped out.
* **Event Loop**: If we switch to a worker-based architecture (off-main-thread processing), the `subscribeVideos` logic would move to a Web Worker, and `NostrClient` would become a message passer.
* **Protocol Upgrades**: If NIP-33 (Parameterized Replaceable Events) is superseded or the video event kind (30078) changes, the filters in `subscribeVideos` and `fetchVideos` must be updated.
+1 -1
View File
@@ -163,7 +163,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
> **Publishing note:** Session actors only emit passive telemetry (for example, view counters) and must **not** sign video comments. Require a logged-in Nostr signer for comment publishing via [`commentEvents`](../js/nostr/commentEvents.js).
Video posts now include a required storage pointer tag (`['s', '<provider>:<prefix>']`). The `<prefix>` should resolve to the public storage base for the video assets (for example, a public bucket URL plus the object key without its extension). Clients derive `info.json` by appending `.info.json` to the prefix (or by combining a path-style prefix with the hosted URL origin if needed).
Video posts now include a required storage pointer tag (`['s', '<provider>:<prefix>']`). The `buildVideoPostEvent` helper automatically generates this tag from available metadata (`infoHash`, `url`, or `videoRootId`) if it is omitted from the input. The `<prefix>` should resolve to the public storage base for the video assets (for example, a public bucket URL plus the object key without its extension). Clients derive `info.json` by appending `.info.json` to the prefix (or by combining a path-style prefix with the hosted URL origin if needed).
Video posts should treat `videoRootId` as the stable series identifier that remains unchanged across edits and deletes. File fingerprints such as `infoHash` or `fileSha256` live alongside the content payload so media swaps do not break the version chain.
+28
View File
@@ -0,0 +1,28 @@
# Onboarding Audit Report - 2025-01-27
## Summary
The onboarding process defined in `README.md` and `CONTRIBUTING.md` was simulated in a clean environment. The core steps (`npm ci`, `npm run build`) function correctly. Unit tests pass but are long-running, which may cause timeouts in some environments. The Dev Container configuration was analyzed and found to be minimal, missing dependencies for visual tests.
## Steps Performed
1. **Clean Install**: `npm ci`
- Result: **Success**. Dependencies installed correctly.
2. **Build**: `npm run build`
- Result: **Success**. Tailwind CSS generated at `css/tailwind.generated.css`.
3. **Formatting**: `npm run format`
- Result: **Success**.
4. **Unit Tests**: `npm run test:unit`
- Result: **Timed Out** (after 400s in simulation).
- Mitigation: Ran `npm run test:unit:shard1` which passed successfully.
- Observation: Full suite takes significant time; sharding is recommended for local iteration.
5. **Dev Container Analysis**:
- `devcontainer.json` provides Node.js environment.
- Missing: Playwright browser binaries (required for `npm run test:visual`).
- Missing: GitHub CLI (useful for workflow management).
## Recommendations implemented in this PR
1. **Documentation**:
- Updated `CONTRIBUTING.md` to emphasize `npm ci` and mention unit test sharding.
- Simplified `README.md` setup instructions.
2. **Dev Container**:
- Added `npx playwright install` to `postCreateCommand`.
- Added GitHub CLI feature.
@@ -1,19 +1,24 @@
# Moderation UI "Show Anyway" Bug Reproducer
# Moderation UI "Show Anyway" Button Visibility Issue Reproducer
This reproducer demonstrates the failure of the "Show anyway" button visibility check and click action in the moderation UI fixtures.
This reproducer demonstrates the issue where the "Show anyway" button in the moderation UI is not visible (or not interactable) in the test environment, even though the logic seems correct.
## Issue Description
- **Known Issue**: `KNOWN_ISSUES.md`: Moderation UI (`tests/visual/moderation.spec.ts`): The "Show anyway" button and related moderation badges fail to render in the test environment, causing `toBeVisible()` assertions to fail.
- **Symptoms**: `Error: element(s) not found` when asserting visibility of the "Show anyway" button.
In the moderation UI tests, the "Show anyway" button fails to be visible or clickable in the test environment (headless mode), even though the logic seems correct. This prevents verifying the "show anyway" override functionality.
## How to Run
## Running the Reproducer
1. Navigate to the repository root.
2. Ensure dependencies are installed and the project is built:
```bash
npm ci
npm run build
npx playwright install chromium
```
3. Run the reproducer script using the provided config:
```bash
npx playwright test -c examples/reproducers/issue-moderation-ui/playwright.config.ts
```
To run the reproducer script:
```bash
npx playwright test examples/reproducers/issue-moderation-ui/repro.spec.ts
```
## Expected Result
The test should fail with a timeout waiting for the "Show anyway" button to be visible or clickable.
## Expected Output
The test `repro: show anyway override button visibility` should **fail** with a timeout or "element not found" error on the assertion `await expect(showAnywayButton).toBeVisible();`.
@@ -1,23 +1,29 @@
import baseConfig from '../../../playwright.config.ts';
import { defineConfig } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import { defineConfig } from "@playwright/test";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '../../..');
const HOST = process.env.PLAYWRIGHT_HOST ?? "127.0.0.1";
const PORT = Number.parseInt(process.env.PLAYWRIGHT_PORT ?? "4173", 10);
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://${HOST}:${PORT}`;
export default defineConfig({
...baseConfig,
testDir: './',
webServer: {
...baseConfig.webServer,
cwd: repoRoot,
testDir: ".",
outputDir: "../../../artifacts/test-results",
timeout: 60_000,
reporter: [["list"]],
use: {
baseURL: BASE_URL,
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
},
projects: [
{
name: 'repro',
testMatch: '**/*.spec.ts',
use: baseConfig.projects?.[0]?.use,
name: "repro",
testMatch: "**/*.spec.ts",
},
],
webServer: {
command: `npx http-server . -p ${PORT} -a ${HOST} -c-1 --silent`,
url: `${BASE_URL}/docs/kitchen-sink.html`,
reuseExistingServer: true,
timeout: 120_000,
},
});
@@ -1,69 +1,43 @@
import { expect, test } from "@playwright/test";
const FIXTURE_URL = "/docs/moderation/fixtures/index.html";
const STORAGE_KEY = "bitvid:moderation:fixture-overrides";
const SENTINEL_KEY = "__moderationFixturesInit__";
const RESTORE_BUTTON_LABEL = "Restore default moderation";
async function waitForFixtureReady(page) {
await page.waitForSelector('body[data-ready="true"]');
}
function setupStorageReset(page) {
return page.addInitScript(({ storageKey, sentinelKey }) => {
try {
const session = window.sessionStorage;
const local = window.localStorage;
if (session && !session.getItem(sentinelKey)) {
local?.removeItem(storageKey);
session.setItem(sentinelKey, "1");
}
} catch {
/* ignore reset failures in tests */
}
}, { storageKey: STORAGE_KEY, sentinelKey: SENTINEL_KEY });
}
test.beforeEach(async ({ page }) => {
await setupStorageReset(page);
});
test.afterEach(async ({ page }) => {
try {
await page.evaluate(({ key, sentinel }) => {
window.localStorage?.removeItem(key);
window.sessionStorage?.removeItem(sentinel);
}, { key: STORAGE_KEY, sentinel: SENTINEL_KEY });
} catch {
// ignore cleanup errors when page already closed
}
});
test.describe("moderation fixtures reproducer", () => {
test("show anyway override persists across reloads", async ({ page }) => {
test("repro: show anyway override button visibility", async ({ page }) => {
// 1. Navigate to fixture
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
await waitForFixtureReady(page);
// 2. Locate the card that should have the button
const overrideCard = page.locator('[data-test-id="show-anyway"]');
const thumbnail = overrideCard.locator('img[data-video-thumbnail]');
// 3. Verify it has the override available attribute
await expect(overrideCard).toHaveAttribute(
"data-moderation-override-available",
"true"
);
// 4. Locate the button
const showAnywayButton = overrideCard.getByRole("button", { name: "Show anyway" });
const restoreButtonQuery = overrideCard.getByRole("button", {
name: RESTORE_BUTTON_LABEL,
});
await expect(overrideCard).toHaveAttribute("data-autoplay-policy", "blocked");
await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
// Uncommenting the failing check to reproduce the bug
// 5. Assert visibility (this is expected to fail)
console.log("Checking visibility of 'Show anyway' button...");
await expect(showAnywayButton).toBeVisible();
await expect(restoreButtonQuery).toHaveCount(0);
// Uncommenting the failing action to reproduce the bug
console.log("'Show anyway' button is visible. Attempting click...");
// 6. Click (this is expected to fail/timeout if not visible)
await showAnywayButton.click({ force: true });
});
// 7. Verify state change
await expect(overrideCard).toHaveAttribute(
"data-moderation-override",
"show-anyway"
);
});
@@ -0,0 +1,11 @@
{
"target": "nostrEventSchemas",
"error": {
"message": "Maximum call stack size exceeded",
"name": "RangeError"
},
"input": {
"circular": true,
"description": "Object with circular reference passed to setNostrEventSchemaOverrides"
}
}
+4 -12
View File
@@ -111,7 +111,8 @@
so browsers pick up the latest sidebar markup and styling without
hard-refreshing around CDN caches.
-->
<link href="css/tailwind.generated.css?v=2024.11.09" rel="stylesheet" />
<link href="css/tailwind.generated.css?v=2024.11.09-2" rel="stylesheet" />
<link href="css/docs.css?v=2024.11.09-2" rel="stylesheet" />
</head>
<body class="sidebar-collapsed" data-ds="new">
<!--
@@ -418,18 +419,9 @@
</div>
<!-- Additional Scripts -->
<!-- Bootstraps nostr-tools before other modules rely on nostrToolsReady -->
<script type="module" src="js/nostrToolsBootstrap.js?v=2024.11.09"></script>
<script type="module" src="js/config.js?v=2024.11.09"></script>
<script type="module" src="js/lists.js?v=2024.11.09"></script>
<script type="module" src="js/accessControl.js?v=2024.11.09"></script>
<script type="module" src="js/webtorrent.js?v=2024.11.09"></script>
<script type="module" src="js/viewManager.js?v=2024.11.09"></script>
<script type="module" src="js/bootstrap.js?v=2024.11.09"></script>
<script type="module" src="js/disclaimer.js?v=2024.11.09"></script>
<script type="module" src="js/index.js?v=2024.11.09"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<script src="vendor/marked.min.js"></script>
<script src="vendor/highlight.min.js"></script>
<div id="uiOverlay" data-overlay-root aria-hidden="true"></div>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
# TODO: Investigate flaky visual regression tests
**Source:** `tests/visual/moderation.spec.ts`
**Context:**
Visual regression tests for moderation features (blurring, blocking autoplay, trusted lists) are currently commented out or failing in headless mode due to:
- Visibility checks failing (`await expect(...).toBeVisible()`)
- Click timeouts
**TODOs:**
- Investigate why visibility check fails in fixture environment (headless)
- Investigate why click times out in fixture environment (headless)
**Next Steps:**
- Run tests in docker container using `./scripts/run-playwright-docker.sh` to reproduce.
- Debug the fixture environment or wait conditions.
+1 -1
View File
@@ -4,4 +4,4 @@
- **Latest Version:** 10.1.0
**Status:** Major Upgrade Required
**Reason:** Major version bump available. Requires strict testing and separate PR.
**Reason:** Major version bump available. v10 requires Node >= 20, which may break CI/CD or local environments running older Node versions (e.g. Node 18). Requires explicit environment verification and separate PR.
+1
View File
@@ -202,6 +202,7 @@ function sanitizeAdminState(state = {}) {
normalizeNpub(ADMIN_SUPER_NPUB),
...ADMIN_EDITORS_NPUBS.map(normalizeNpub),
...sanitizedEditors.map(normalizeNpub),
...sanitizedWhitelist.map(normalizeNpub),
]);
const sanitizedBlacklist = sanitizeNpubList(state.blacklist || []).filter(
+633
View File
@@ -0,0 +1,633 @@
import { createModalAccessibility } from "./ui/components/modalAccessibility.js";
import logger from "./utils/logger.js";
const TOC_URL = "content/docs/toc.json";
const DOCS_VIEW_NAME = "docs";
const TOC_DRAWER_ID = "docsTocDrawer";
const TOC_DRAWER_NAV_ID = "docsTocDrawerNav";
const TOC_LIST_ID = "docsTocList";
const tocState = {
items: [],
slugLookup: new Map(),
parentLookup: new Map(),
linkLookup: new Map(),
groupLookup: new Map(),
activeSlug: "",
drawer: {
root: null,
panel: null,
toggle: null,
accessibility: null,
isOpen: false,
},
};
const scrollSpyState = {
headings: [],
linkLookup: new Map(),
activeId: "",
rafId: null,
onScroll: null,
};
function getHashParams() {
const hash = typeof window !== "undefined" ? window.location.hash : "";
return new URLSearchParams(hash.startsWith("#") ? hash.slice(1) : hash);
}
function getDocSlugFromHash() {
const params = getHashParams();
if (params.get("view") !== DOCS_VIEW_NAME) {
return "";
}
return params.get("doc") || "";
}
function setDocsHash(slug, { replace = false } = {}) {
if (typeof window === "undefined") {
return;
}
const nextHash = `view=${DOCS_VIEW_NAME}&doc=${encodeURIComponent(slug)}`;
if (replace) {
const url = new URL(window.location.href);
url.hash = nextHash;
window.history.replaceState({}, "", url.toString());
return;
}
if (window.location.hash === `#${nextHash}`) {
return;
}
window.location.hash = nextHash;
}
function indexItems(items, parent = null) {
items.forEach((item) => {
if (!item?.slug) {
logger.dev.warn("Docs TOC item missing slug.", item);
return;
}
tocState.slugLookup.set(item.slug, item);
if (parent?.slug) {
tocState.parentLookup.set(item.slug, parent.slug);
}
if (Array.isArray(item.children) && item.children.length > 0) {
indexItems(item.children, item);
}
});
}
function buildLink(item) {
const link = document.createElement("a");
link.href = `#view=${DOCS_VIEW_NAME}&doc=${encodeURIComponent(item.slug)}`;
link.textContent = item.title || item.slug;
link.className = "text-sm text-muted transition-colors hover:text-text-strong";
link.dataset.slug = item.slug;
link.dataset.docsTocItem = "true";
link.addEventListener("click", (event) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
setDocsHash(item.slug);
});
if (!tocState.linkLookup.has(item.slug)) {
tocState.linkLookup.set(item.slug, new Set());
}
tocState.linkLookup.get(item.slug)?.add(link);
return link;
}
function getChevronIcon(expanded) {
if (expanded) {
// Chevron Down
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>`;
}
// Chevron Right
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>`;
}
function buildToggleButton(item, controlsId, expanded, groupList) {
const button = document.createElement("button");
button.type = "button";
button.className = "btn-ghost btn-xs btn-square text-muted hover:text-text-primary";
button.setAttribute("aria-expanded", expanded ? "true" : "false");
button.setAttribute("aria-controls", controlsId);
button.setAttribute("aria-label", `Toggle ${item.title || item.slug} section`);
button.innerHTML = getChevronIcon(expanded);
button.addEventListener("click", () => {
const isExpanded = button.getAttribute("aria-expanded") === "true";
const nextExpanded = !isExpanded;
button.setAttribute("aria-expanded", nextExpanded ? "true" : "false");
button.innerHTML = getChevronIcon(nextExpanded);
if (groupList) {
groupList.hidden = !nextExpanded;
groupList.classList.toggle("hidden", !nextExpanded);
}
});
return button;
}
function renderTocItems(items, container, level = 0, groupPrefix = "docs-toc") {
const list = document.createElement("ul");
list.className = "bv-stack bv-stack--tight";
list.setAttribute("role", "list");
items.forEach((item) => {
if (!item?.slug) {
return;
}
const listItem = document.createElement("li");
listItem.className = "bv-stack bv-stack--tight";
const row = document.createElement("div");
row.className = "flex items-center justify-between gap-2";
const link = buildLink(item);
row.appendChild(link);
listItem.appendChild(row);
if (Array.isArray(item.children) && item.children.length > 0) {
const groupId = `${groupPrefix}-group-${item.slug}`;
const expanded = level === 0;
const childList = renderTocItems(item.children, listItem, level + 1, groupPrefix);
childList.id = groupId;
childList.classList.add("ml-md", "border-l", "border-border", "pl-md");
childList.hidden = !expanded;
if (!expanded) {
childList.classList.add("hidden");
}
const toggle = buildToggleButton(item, groupId, expanded, childList);
row.appendChild(toggle);
if (!tocState.groupLookup.has(item.slug)) {
tocState.groupLookup.set(item.slug, new Set());
}
tocState.groupLookup.get(item.slug)?.add({ button: toggle, list: childList });
}
list.appendChild(listItem);
});
container.appendChild(list);
return list;
}
function renderToc(items) {
const tocRoot = document.getElementById(TOC_LIST_ID);
const tocDrawerRoot = document.getElementById(TOC_DRAWER_NAV_ID);
const roots = [tocRoot, tocDrawerRoot].filter(Boolean);
if (roots.length === 0) {
logger.user.warn("Docs table of contents container not found.");
return;
}
tocState.linkLookup.clear();
tocState.groupLookup.clear();
roots.forEach((root) => {
root.innerHTML = "";
const groupPrefix = root.id ? `docs-${root.id}` : "docs-toc";
renderTocItems(items, root, 0, groupPrefix);
});
}
function updateActiveToc(slug) {
const previousLinks = tocState.linkLookup.get(tocState.activeSlug);
if (previousLinks) {
previousLinks.forEach((link) => {
link.removeAttribute("aria-current");
link.classList.remove("text-text-strong", "font-semibold");
link.classList.add("text-muted");
});
}
tocState.activeSlug = slug || "";
if (!slug) {
return;
}
const links = tocState.linkLookup.get(slug);
if (links) {
links.forEach((link) => {
link.setAttribute("aria-current", "page");
link.classList.remove("text-muted");
link.classList.add("text-text-strong", "font-semibold");
});
}
let current = slug;
while (tocState.parentLookup.has(current)) {
const parentSlug = tocState.parentLookup.get(current);
const groups = tocState.groupLookup.get(parentSlug);
if (groups) {
groups.forEach((group) => {
if (group?.button && group?.list) {
group.button.setAttribute("aria-expanded", "true");
group.button.innerHTML = getChevronIcon(true);
group.list.hidden = false;
group.list.classList.remove("hidden");
}
});
}
current = parentSlug;
}
}
function resetSectionHighlight() {
const previousLinks = scrollSpyState.linkLookup.get(scrollSpyState.activeId);
if (previousLinks) {
previousLinks.forEach((link) => {
link.removeAttribute("data-docs-section-current");
link.classList.remove("text-text-strong", "font-semibold");
link.classList.add("text-muted");
});
}
scrollSpyState.activeId = "";
}
function clearScrollSpy() {
if (scrollSpyState.onScroll && typeof window !== "undefined") {
window.removeEventListener("scroll", scrollSpyState.onScroll);
window.removeEventListener("resize", scrollSpyState.onScroll);
}
if (scrollSpyState.rafId && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(scrollSpyState.rafId);
}
scrollSpyState.rafId = null;
scrollSpyState.onScroll = null;
scrollSpyState.headings = [];
scrollSpyState.linkLookup = new Map();
resetSectionHighlight();
}
function setActiveSection(id) {
if (!id || id === scrollSpyState.activeId) {
return;
}
resetSectionHighlight();
scrollSpyState.activeId = id;
const links = scrollSpyState.linkLookup.get(id);
if (!links) {
return;
}
links.forEach((link) => {
link.setAttribute("data-docs-section-current", "true");
link.classList.remove("text-muted");
link.classList.add("text-text-strong", "font-semibold");
});
}
function resolveSectionLinks() {
const linkLookup = new Map();
if (typeof document === "undefined") {
return linkLookup;
}
const links = document.querySelectorAll(`[data-docs-toc-item][href*="#"]`);
links.forEach((link) => {
const rawHref = link.getAttribute("href") || "";
const hashIndex = rawHref.indexOf("#");
if (hashIndex === -1) {
return;
}
const fragment = rawHref.slice(hashIndex + 1);
if (!fragment || fragment.includes("view=")) {
return;
}
const decoded = decodeURIComponent(fragment.split("?")[0]);
if (!decoded) {
return;
}
if (!linkLookup.has(decoded)) {
linkLookup.set(decoded, new Set());
}
linkLookup.get(decoded)?.add(link);
});
return linkLookup;
}
function setupScrollSpy(container) {
clearScrollSpy();
if (typeof window === "undefined" || !container) {
return;
}
const headings = Array.from(
container.querySelectorAll("h2[id], h3[id], h4[id], h5[id]")
);
if (headings.length === 0) {
return;
}
const linkLookup = resolveSectionLinks();
if (linkLookup.size === 0) {
return;
}
const trackedHeadings = headings.filter((heading) => linkLookup.has(heading.id));
if (trackedHeadings.length === 0) {
return;
}
scrollSpyState.headings = trackedHeadings;
scrollSpyState.linkLookup = linkLookup;
const updateActiveFromScroll = () => {
scrollSpyState.rafId = null;
const offset = 96;
let nextId = scrollSpyState.headings[0]?.id || "";
for (const heading of scrollSpyState.headings) {
const top = heading.getBoundingClientRect().top - offset;
if (top <= 0) {
nextId = heading.id;
} else {
break;
}
}
if (nextId) {
setActiveSection(nextId);
}
};
const onScroll = () => {
if (scrollSpyState.rafId) {
return;
}
scrollSpyState.rafId = requestAnimationFrame(updateActiveFromScroll);
};
scrollSpyState.onScroll = onScroll;
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
onScroll();
}
function resolveDocItem(slug) {
if (!slug) {
return null;
}
return tocState.slugLookup.get(slug) || null;
}
async function fetchToc() {
logger.dev.log("fetchToc: fetching", TOC_URL);
const response = await fetch(TOC_URL);
logger.dev.log("fetchToc: response status", response.status);
if (!response.ok) {
throw new Error(`Failed to load TOC (${response.status})`);
}
const data = await response.json();
if (!data || !Array.isArray(data.items)) {
throw new Error("Invalid TOC manifest.");
}
return data.items;
}
function highlightCodeBlocks(container) {
const highlighter = window.hljs;
const highlight =
highlighter && typeof highlighter.highlightElement === "function"
? (block) => highlighter.highlightElement(block)
: highlighter && typeof highlighter.highlightBlock === "function"
? (block) => highlighter.highlightBlock(block)
: null;
if (!highlight) {
return;
}
container.querySelectorAll("pre code").forEach((block) => {
try {
highlight(block);
} catch (highlightError) {
logger.dev.warn("Failed to highlight code block.", highlightError);
}
});
}
async function renderMarkdown(path) {
const container = document.getElementById("markdown-container");
if (!container) {
logger.user.warn("Docs markdown container not found.");
return;
}
try {
clearScrollSpy();
logger.dev.log("renderMarkdown: fetching", path);
if (!window.marked || typeof window.marked.parse !== "function") {
throw new Error("Markdown renderer is unavailable.");
}
const response = await fetch(path);
if (!response.ok) {
throw new Error(`Failed to load markdown (${response.status})`);
}
const markdownText = await response.text();
const html = window.marked.parse(markdownText);
container.innerHTML = html;
highlightCodeBlocks(container);
const schedule =
typeof requestIdleCallback === "function"
? requestIdleCallback
: (callback) => requestAnimationFrame(callback);
schedule(() => {
setupScrollSpy(container);
});
} catch (error) {
logger.user.error("Error loading docs content.", error);
container.innerHTML =
'<p class="text-critical-strong">Error loading content. Please try again later.</p>';
}
}
function updateDocumentTitle(title) {
if (typeof document === "undefined") {
return;
}
if (!title) {
document.title = "Docs | bitvid";
return;
}
document.title = `${title} | bitvid docs`;
}
async function handleHashChange() {
const params = getHashParams();
const view = params.get("view");
if (view && view !== DOCS_VIEW_NAME) {
return;
}
const currentSlug = getDocSlugFromHash();
const defaultSlug = tocState.items[0]?.slug || "";
const slug = currentSlug || defaultSlug;
if (!slug) {
logger.user.warn("Docs view has no available documents.");
return;
}
if (!currentSlug) {
setDocsHash(slug, { replace: true });
}
if (slug === tocState.activeSlug) {
return;
}
const item = resolveDocItem(slug);
if (!item) {
logger.user.warn("Requested docs page not found.", slug);
return;
}
await renderMarkdown(item.path);
updateActiveToc(slug);
updateDocumentTitle(item.title || "Docs");
if (tocState.drawer.isOpen) {
closeTocDrawer();
}
}
function updateDrawerState(nextState) {
tocState.drawer.isOpen = nextState;
if (tocState.drawer.toggle) {
tocState.drawer.toggle.setAttribute("aria-expanded", nextState ? "true" : "false");
}
if (!tocState.drawer.root) {
return;
}
const doc =
typeof document !== "undefined" && document ? document : tocState.drawer.root.ownerDocument;
if (nextState) {
tocState.drawer.root.classList.remove("hidden");
tocState.drawer.root.setAttribute("data-open", "true");
doc?.documentElement?.classList.add("modal-open");
doc?.body?.classList.add("modal-open");
tocState.drawer.accessibility?.activate({
triggerElement: tocState.drawer.toggle,
});
} else {
tocState.drawer.root.classList.add("hidden");
tocState.drawer.root.setAttribute("data-open", "false");
tocState.drawer.accessibility?.deactivate();
if (doc) {
const openModals = doc.querySelectorAll(".bv-modal:not(.hidden)");
if (openModals.length === 0) {
doc.documentElement?.classList.remove("modal-open");
doc.body?.classList.remove("modal-open");
}
}
}
}
function focusFirstDrawerItem() {
const panel = tocState.drawer.panel;
if (!panel) {
return;
}
const firstLink = panel.querySelector("[data-docs-toc-item]");
if (firstLink && typeof firstLink.focus === "function") {
firstLink.focus({ preventScroll: true });
return;
}
if (typeof panel.focus === "function") {
panel.focus({ preventScroll: true });
}
}
function openTocDrawer() {
if (tocState.drawer.isOpen) {
return;
}
updateDrawerState(true);
const schedule =
typeof requestAnimationFrame === "function"
? requestAnimationFrame
: (callback) => setTimeout(callback, 0);
schedule(() => {
focusFirstDrawerItem();
});
}
function closeTocDrawer() {
if (!tocState.drawer.isOpen) {
return;
}
updateDrawerState(false);
}
function setupTocDrawer() {
if (typeof document === "undefined") {
return;
}
const drawerRoot = document.getElementById(TOC_DRAWER_ID);
const drawerPanel = drawerRoot?.querySelector("[data-docs-toc-panel]") || null;
const drawerToggle = document.querySelector("[data-docs-toc-toggle]");
if (!drawerRoot || !drawerPanel || !drawerToggle) {
return;
}
tocState.drawer.root = drawerRoot;
tocState.drawer.panel = drawerPanel;
tocState.drawer.toggle = drawerToggle;
tocState.drawer.accessibility = createModalAccessibility({
root: drawerRoot,
panel: drawerPanel,
backdrop: drawerRoot.querySelector(".bv-modal-backdrop"),
document,
onRequestClose: () => {
closeTocDrawer();
},
});
drawerToggle.addEventListener("click", () => {
if (tocState.drawer.isOpen) {
closeTocDrawer();
} else {
openTocDrawer();
}
});
}
async function initDocsView() {
logger.dev.log("initDocsView: starting");
try {
tocState.items = await fetchToc();
logger.dev.log("initDocsView: TOC items loaded", tocState.items);
indexItems(tocState.items);
renderToc(tocState.items);
setupTocDrawer();
window.addEventListener("hashchange", handleHashChange);
await handleHashChange();
} catch (error) {
logger.user.error("Failed to initialize docs view.", error);
const tocRoot = document.getElementById(TOC_LIST_ID);
if (tocRoot) {
tocRoot.innerHTML =
'<p class="text-critical-strong">Unable to load docs table of contents.</p>';
}
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML =
'<p class="text-critical-strong">Error loading content. Please try again later.</p>';
}
}
}
export { initDocsView };
+30
View File
@@ -1,5 +1,14 @@
import { devLogger as defaultDevLogger, userLogger as defaultUserLogger } from "./utils/logger.js";
const LEGACY_DOCS_VIEWS = {
about: "about",
"community-guidelines": "community-guidelines",
"getting-started": "getting-started",
"upload-content": "upload-content",
roadmap: "roadmap",
links: "links"
};
const createLoggerFacade = (logger, fallback) => {
if (logger && typeof logger === "object") {
return {
@@ -32,6 +41,7 @@ export function createHashChangeHandler({
} = {}) {
const devLog = createLoggerFacade(devLogger, noopLogger);
const userLog = createLoggerFacade(userLogger, noopLogger);
let currentViewName = null;
const getReadyPromise = () => {
if (typeof getApplicationReady === "function") {
@@ -82,7 +92,14 @@ export function createHashChangeHandler({
const defaultViewName = resolveIsLoggedIn()
? "for-you"
: "most-recent-videos";
if (defaultViewName === currentViewName) {
return;
}
await loadView(`views/${defaultViewName}.html`);
currentViewName = defaultViewName;
const initFn = viewInitRegistry?.[defaultViewName];
if (typeof initFn === "function") {
await initFn();
@@ -91,11 +108,24 @@ export function createHashChangeHandler({
}
const viewName = match[1];
if (Object.prototype.hasOwnProperty.call(LEGACY_DOCS_VIEWS, viewName)) {
const docSlug = LEGACY_DOCS_VIEWS[viewName];
window.location.hash = `#view=docs&doc=${docSlug}`;
return;
}
if (viewName === currentViewName) {
return;
}
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
}
const viewUrl = `views/${viewName}.html`;
await loadView(viewUrl);
currentViewName = viewName;
const initFn = viewInitRegistry?.[viewName];
if (typeof initFn === "function") {
await initFn();
+3 -3
View File
@@ -21,10 +21,10 @@ import { buildModerationBadgeText } from "./ui/moderationCopy.js";
import { formatShortNpub } from "./utils/formatters.js";
import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
export const WATCH_HISTORY_EMPTY_COPY =
const WATCH_HISTORY_EMPTY_COPY =
"Your watch history is empty. Watch some videos to populate this list.";
export const WATCH_HISTORY_DISABLED_COPY =
const WATCH_HISTORY_DISABLED_COPY =
"Watch history sync is unavailable. Connect a NIP-07 extension or log in to enable syncing.";
const DEFAULT_WATCH_HISTORY_BATCH_SIZE = 12;
@@ -2494,7 +2494,7 @@ export function createWatchHistoryRenderer(config = {}) {
return renderer;
}
export const watchHistoryRenderer = createWatchHistoryRenderer();
const watchHistoryRenderer = createWatchHistoryRenderer();
export async function initHistoryView() {
const infoTrigger = document.getElementById("historyInfoTrigger");
+3 -19
View File
@@ -36,7 +36,7 @@ export function safeDecodeMagnet(value) {
return decoded;
}
export function normalizeForComparison(value) {
function normalizeForComparison(value) {
if (typeof value !== "string") {
return "";
}
@@ -258,7 +258,7 @@ export function buildMagnetUri(normalizedScheme, params, fragment) {
return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment || ""}`;
}
export function appendUniqueParam(params, key, value) {
function appendUniqueParam(params, key, value) {
if (!Array.isArray(params)) {
return false;
}
@@ -305,7 +305,7 @@ export function ensureTrackers(params, trackers = WSS_TRACKERS) {
return didMutate;
}
export function sanitizeHttpUrl(candidate) {
function sanitizeHttpUrl(candidate) {
if (typeof candidate !== "string") {
return "";
}
@@ -403,22 +403,6 @@ export function ensureWebSeeds(
return didMutate;
}
export function formatAbsoluteUrl(candidate) {
if (typeof candidate !== "string") {
return "";
}
const trimmed = candidate.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
return parsed.toString();
} catch (err) {
return trimmed;
}
}
export function extractMagnetHints(rawValue) {
const { params } = normalizeMagnetInput(rawValue);
const hints = { ws: "", xs: "" };
+3 -1
View File
@@ -19,7 +19,9 @@ async function retryNip07Call(operation, label) {
throw error;
}
if (i < attempts - 1) {
await new Promise((resolve) => setTimeout(resolve, 100));
const isChannelClosed = error?.message?.includes("message channel closed");
const delay = isChannelClosed ? 250 : 100;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
+117 -3
View File
@@ -1263,6 +1263,7 @@ export class NostrClient {
this.unreachableRelays = new Set();
this.isInitialized = false;
this.nip46Client = null;
this.pendingHandshakeCancel = null;
this.remoteSignerListeners = new Set();
this.sessionActorListeners = new Set();
const storedRemoteSigner = this.getStoredNip46Metadata();
@@ -1708,6 +1709,9 @@ export class NostrClient {
return;
}
settled = true;
if (this.pendingHandshakeCancel === cancelHandshake) {
this.pendingHandshakeCancel = null;
}
try {
subscription?.unsub?.();
} catch (error) {
@@ -1718,6 +1722,15 @@ export class NostrClient {
}
};
const cancelHandshake = () => {
cleanup();
const error = new Error("Remote signer connection cancelled.");
error.code = "login-cancelled";
reject(error);
};
this.pendingHandshakeCancel = cancelHandshake;
const timeoutId = setTimeout(() => {
cleanup();
const error = new Error("Timed out waiting for the remote signer to acknowledge the connection.");
@@ -2438,6 +2451,16 @@ export class NostrClient {
*/
async disconnectRemoteSigner({ keepStored = true } = {}) {
this.pendingRemoteSignerRestore = null;
if (this.pendingHandshakeCancel) {
try {
this.pendingHandshakeCancel();
} catch (error) {
devLogger.warn("[nostr] Failed to cancel pending handshake:", error);
}
this.pendingHandshakeCancel = null;
}
if (this.nip46Client) {
try {
await this.nip46Client.destroy();
@@ -3549,6 +3572,7 @@ export class NostrClient {
* @param {string} [params.dTag] - Optional d-tag for addressable events (NIP-33).
* @param {string[]} [params.relayUrls] - List of relays to query. Defaults to client's configured relays.
* @param {function} [params.fetchFn] - Custom fetch function (mocks or specialized logic). Defaults to `pool.list`.
* @param {number} [params.timeoutMs] - Optional per-relay timeout for list fetches; defaults to a higher list-friendly baseline.
* @returns {Promise<import("nostr-tools").Event[]>} Deduplicated list of events found across all relays.
*/
async fetchListIncrementally({
@@ -3558,7 +3582,14 @@ export class NostrClient {
relayUrls,
fetchFn,
since,
timeoutMs = 10000,
} = {}) {
// Explanation:
// Optimizes network usage by only fetching "new" events from each relay.
// It uses `SyncMetadataStore` to track the `created_at` of the last seen event per relay.
// - If we have a `lastSeen` timestamp, we request `since: lastSeen + 1`.
// - If that returns nothing (or fails), we might fall back to a full fetch depending on context.
// - Updates the `lastSeen` timestamp upon success.
if (!kind || !pubkey) {
throw new Error("fetchListIncrementally requires kind and pubkey");
}
@@ -3572,7 +3603,29 @@ export class NostrClient {
? relayUrls
: this.relays;
const normalizedRelays = sanitizeRelayList(this.getHealthyRelays(relaysToUse));
const healthyCandidates = this.getHealthyRelays(relaysToUse);
const readPreferences = new Set(Array.isArray(this.readRelays) ? this.readRelays : []);
// Sort relays: prefer user's read relays first
const sortedRelays = healthyCandidates.sort((a, b) => {
const aPreferred = readPreferences.has(a);
const bPreferred = readPreferences.has(b);
if (aPreferred && !bPreferred) return -1;
if (!aPreferred && bPreferred) return 1;
return 0;
});
// Cap the number of relays to avoid excessive requests
const cappedRelays = sortedRelays.slice(0, 8);
const normalizedRelays = sanitizeRelayList(cappedRelays);
if (isDevMode) {
devLogger.log("[fetchListIncrementally] Selected relays:", {
count: normalizedRelays.length,
relays: normalizedRelays,
});
}
const results = [];
const concurrencyLimit = 8;
@@ -3633,11 +3686,14 @@ export class NostrClient {
doFullFetch = false;
}
// Allow callers to override list fetch timeouts without changing non-list query behavior.
const listTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 10000;
try {
// Wrap fetch with a timeout to prevent hanging on slow relays
let events = await withRequestTimeout(
actualFetchFn(relayUrl, filter),
6000,
listTimeoutMs,
null,
`Fetch from ${relayUrl} timed out`
);
@@ -3672,7 +3728,7 @@ export class NostrClient {
delete filter.since;
const events = await withRequestTimeout(
actualFetchFn(relayUrl, filter),
6000,
listTimeoutMs,
null,
`Full fetch fallback from ${relayUrl} timed out`
);
@@ -3695,6 +3751,7 @@ export class NostrClient {
}
} else {
devLogger.warn(`[fetchListIncrementally] Fetch failed for ${relayUrl}`, error);
this.markRelayUnreachable(relayUrl);
return { error, ok: false };
}
}
@@ -4073,6 +4130,25 @@ export class NostrClient {
return results;
}
markRelayUnreachable(url, ttlMs = 60000) {
if (!url || typeof url !== "string") {
return;
}
const normalized = url.trim();
if (!normalized) {
return;
}
this.unreachableRelays.add(normalized);
if (ttlMs > 0) {
setTimeout(() => {
this.unreachableRelays.delete(normalized);
if (isDevMode) {
devLogger.debug(`[nostr] Cleared temporary failure mark for ${normalized}`);
}
}, ttlMs);
}
}
getHealthyRelays(candidates) {
const source = Array.isArray(candidates) ? candidates : this.relays;
if (!this.unreachableRelays.size) {
@@ -4427,6 +4503,13 @@ export class NostrClient {
});
if (decrypted?.ok) {
messages.push(decrypted);
if (typeof options.onMessage === "function") {
try {
options.onMessage(decrypted);
} catch (error) {
devLogger.warn("[nostr] onMessage callback threw:", error);
}
}
}
} catch (error) {
devLogger.warn("[nostr] Failed to decrypt DM event during list.", {
@@ -6589,6 +6672,12 @@ export class NostrClient {
* @private
*/
async persistLocalData(reason = "unspecified") {
// Strategy:
// 1. Try to persist to IndexedDB (preferred, large capacity).
// 2. If IDB fails or returns false, fall back to LocalStorage (limited capacity).
// The `EventsCacheStore` handles differential updates (only saving changed fingerprints)
// to keep IDB writes fast.
const payload = this.buildCachePayload();
const startedAt = Date.now();
let summary = null;
@@ -6705,6 +6794,12 @@ export class NostrClient {
* @returns {import("nostr-tools").Sub} The subscription object. Call `unsub()` to stop.
*/
subscribeVideos(onVideo, options = {}) {
// Explanation:
// This method handles the primary video feed. It uses a buffering strategy to
// prevent UI thrashing when thousands of events arrive at once (e.g. initial load).
// Incoming events are pushed to `eventBuffer` and processed in batches
// via `flushEventBuffer` which is debounced.
const { since, until, limit } = options;
const latestCachedCreatedAt = this.getLatestCachedCreatedAt();
@@ -6738,6 +6833,7 @@ export class NostrClient {
// BUFFERING STATE
// We collect events here instead of processing them instantly to avoid
// 1000s of React re-renders during the initial relay dump.
// The buffer acts as a pressure valve between the network and the UI.
let eventBuffer = [];
const EVENT_FLUSH_DEBOUNCE_MS = 75;
let flushTimerId = null;
@@ -6748,6 +6844,13 @@ export class NostrClient {
* This batch processing is critical for performance during the initial
* relay dump (thousands of events). It prevents React from re-rendering
* for every single event.
*
* Algorithm:
* 1. Drain the buffer.
* 2. Validate & Convert events to Video objects.
* 3. Apply tombstones (filter out known deleted items).
* 4. Update `activeMap` (Last-Write-Wins based on created_at).
* 5. Persist the new state to cache.
*/
const flushEventBuffer = () => {
if (!eventBuffer.length) {
@@ -7651,6 +7754,17 @@ export class NostrClient {
* @returns {Promise<object[]>} A promise resolving to an array of Video objects (Newest -> Oldest).
*/
async hydrateVideoHistory(video) {
// Explanation:
// This method reconstructs the edit history of a video series.
// Since Nostr events are immutable, "editing" creates a new event.
// We link these events together using:
// 1. `videoRootId` (V3 canonical ID)
// 2. `d` tag (NIP-33 addressability)
// 3. Fallback: Matching ID for legacy V1/V2 posts
//
// The method first checks local cache (`allEvents`), and if data is sparse,
// queries relays for the specific `d` tag to find missing links.
if (!video || typeof video !== "object") {
return [];
}
+8 -20
View File
@@ -192,26 +192,13 @@ export async function runNip07WithRetry(
retryMultiplier = 2,
} = {},
) {
let hasStarted = false;
let cachedPromise = null;
const getOrStartOperation = () => {
if (!hasStarted) {
hasStarted = true;
try {
cachedPromise = Promise.resolve(operation());
} catch (error) {
hasStarted = false;
cachedPromise = null;
throw error;
}
}
return cachedPromise;
};
// We wrap the operation invocation to ensure it returns a fresh promise each time
// we attempt it. This is critical for retries: if the first attempt stalls (dropped
// by extension), re-awaiting the same promise would just keep waiting on the dead request.
const invokeOperation = () => Promise.resolve(operation());
try {
return await withNip07Timeout(getOrStartOperation, {
return await withNip07Timeout(invokeOperation, {
timeoutMs,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
@@ -230,10 +217,11 @@ export async function runNip07WithRetry(
);
devLogger.warn(
`[nostr] ${label} taking longer than ${timeoutMs}ms. Waiting up to ${extendedTimeout}ms for extension response.`,
`[nostr] ${label} taking longer than ${timeoutMs}ms. Retrying with ${extendedTimeout}ms timeout.`,
);
return withNip07Timeout(getOrStartOperation, {
// On retry, we invoke operation() again to send a fresh request to the extension.
return withNip07Timeout(invokeOperation, {
timeoutMs: extendedTimeout,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
+46 -1
View File
@@ -9,6 +9,10 @@ import {
WATCH_HISTORY_LIST_IDENTIFIER,
WATCH_HISTORY_VERSION_TAG_VALUE,
} from "./config.js";
import {
buildStoragePointerValue,
deriveStoragePointerFromUrl,
} from "./utils/storagePointer.js";
export const NOTE_TYPES = Object.freeze({
VIDEO_POST: "videoPost",
@@ -697,7 +701,16 @@ function resolveOverride(type) {
export function setNostrEventSchemaOverrides(overrides = {}) {
if (overrides && typeof overrides === "object") {
schemaOverrides = overrides;
try {
JSON.stringify(overrides);
schemaOverrides = overrides;
} catch (error) {
devLogger.warn(
"[nostrEventSchemas] Invalid overrides provided (circular structure?):",
error
);
schemaOverrides = {};
}
} else {
schemaOverrides = {};
}
@@ -889,6 +902,38 @@ export function buildVideoPostEvent(params) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const hasSTag = tags.some((t) => t[0] === "s");
if (!hasSTag) {
let contentObj = content;
if (typeof content === "string") {
try {
contentObj = JSON.parse(content);
} catch (e) {
contentObj = {};
}
}
contentObj = contentObj || {};
let pointer = "";
if (contentObj.infoHash) {
pointer = buildStoragePointerValue({
provider: "btih",
prefix: contentObj.infoHash,
});
} else if (contentObj.url) {
pointer = deriveStoragePointerFromUrl(contentObj.url);
}
if (!pointer && (dTagValue || contentObj.videoRootId)) {
const prefix = dTagValue || contentObj.videoRootId;
pointer = buildStoragePointerValue({ provider: "nostr", prefix });
}
if (pointer) {
tags.push(["s", pointer]);
}
}
let serializedContent = "";
if (typeof content === "string") {
serializedContent = content;
-170
View File
@@ -1,170 +0,0 @@
import { multipartUpload, ensureBucketExists, ensureBucketCors } from "./storage/s3-multipart.js";
import { makeS3Client } from "./storage/s3-client.js";
import { userLogger } from "./utils/logger.js";
const STORAGE_KEY = "bitvid:quickS3Settings";
export function loadLocalSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (err) {
userLogger.warn("Failed to load quick S3 settings:", err);
return null;
}
}
export function saveLocalSettings(settings) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (err) {
userLogger.warn("Failed to save quick S3 settings:", err);
}
}
export async function uploadWithKeys({
file,
key,
settings, // { endpoint, region, bucket, accessKeyId, secretAccessKey, forcePathStyle, createBucketIfMissing }
onProgress
}) {
if (!settings.endpoint || !settings.bucket || !settings.accessKeyId || !settings.secretAccessKey) {
throw new Error("Missing S3 credentials.");
}
if (!key) {
throw new Error("Target object key is required.");
}
const s3 = makeS3Client({
endpoint: settings.endpoint,
region: settings.region,
credentials: {
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey
},
forcePathStyle: settings.forcePathStyle
});
if (settings.createBucketIfMissing) {
await ensureBucketExists({ s3, bucket: settings.bucket, region: settings.region });
}
// Ensure CORS only if we have keys
try {
const origins = [];
if (typeof window !== "undefined" && window.location) {
if (window.location.origin && window.location.origin !== "null") {
origins.push(window.location.origin);
}
}
// Add standard localdev origins just in case
origins.push('http://localhost:8000');
origins.push('http://127.0.0.1:8000');
const uniqueOrigins = [...new Set(origins)];
await ensureBucketCors({
s3,
bucket: settings.bucket,
origins: uniqueOrigins,
region: settings.region
});
} catch (err) {
userLogger.warn("Failed to ensure CORS (ignoring):", err);
}
return multipartUpload({
s3,
bucket: settings.bucket,
key,
file,
contentType: file.type,
onProgress,
createBucketIfMissing: false // Already handled
});
}
export async function uploadWithManifest({
file,
manifest,
onProgress
}) {
// manifest: {
// bucket: string,
// key: string,
// uploadId: string,
// parts: { partNumber: number, url: string }[],
// completeUrl?: string,
// partSize?: number
// }
if (!manifest || !Array.isArray(manifest.parts) || manifest.parts.length === 0) {
throw new Error("Invalid manifest: 'parts' array required.");
}
// Ensure parts are sorted
const parts = [...manifest.parts].sort((a, b) => a.partNumber - b.partNumber);
const fileSize = file.size;
const partCount = parts.length;
// Determine part size
// If not provided, assume standard S3 splitting (equal parts except last)
const partSize = manifest.partSize || Math.ceil(fileSize / partCount);
const etags = [];
let uploadedBytes = 0;
for (let i = 0; i < partCount; i++) {
const part = parts[i];
const partNum = part.partNumber;
const url = part.url;
// Calculate byte range
const index = i;
const start = index * partSize;
if (start >= fileSize) break;
const end = Math.min(start + partSize, fileSize);
const chunk = file.slice(start, end);
try {
const response = await fetch(url, {
method: 'PUT',
body: chunk
});
if (!response.ok) {
throw new Error(`Failed to upload part ${partNum}: ${response.status} ${response.statusText}`);
}
const etag = response.headers.get('ETag');
if (!etag) {
userLogger.warn(`Part ${partNum} uploaded but no ETag header received.`);
}
etags.push({ PartNumber: partNum, ETag: etag });
uploadedBytes += chunk.size;
if (onProgress) {
onProgress(uploadedBytes / fileSize);
}
} catch (err) {
throw new Error(`Network error uploading part ${partNum}: ${err.message}`);
}
}
if (manifest.completeUrl) {
const compRes = await fetch(manifest.completeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Parts: etags })
});
if (!compRes.ok) {
throw new Error(`Failed to complete upload: ${compRes.statusText}`);
}
}
return etags;
}
+136 -11
View File
@@ -17,11 +17,15 @@ import {
import { userLogger, devLogger } from "../utils/logger.js";
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
import { profileCache } from "../state/profileCache.js";
import { DEFAULT_RELAY_URLS } from "../nostr/toolkit.js";
import { relayManager } from "../relayManager.js";
const LOG_PREFIX = "[HashtagPreferences]";
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const DEFAULT_VERSION = 1;
const DECRYPT_TIMEOUT_MS = 90000;
const DECRYPT_RETRY_DELAY_MS = 10000;
class TinyEventEmitter {
constructor() {
@@ -234,6 +238,7 @@ class HashtagPreferencesService {
this.eventCreatedAt = null;
this.loaded = false;
this.preferencesVersion = DEFAULT_VERSION;
this.decryptRetryTimeoutId = null;
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
@@ -261,6 +266,10 @@ class HashtagPreferencesService {
this.eventCreatedAt = null;
this.loaded = false;
this.preferencesVersion = DEFAULT_VERSION;
if (this.decryptRetryTimeoutId) {
clearTimeout(this.decryptRetryTimeoutId);
this.decryptRetryTimeoutId = null;
}
this.emitChange("reset");
}
@@ -415,6 +424,29 @@ class HashtagPreferencesService {
}
}
scheduleDecryptRetry(pubkey, error) {
const normalized = normalizeHexPubkey(pubkey);
if (!normalized) {
return;
}
if (this.decryptRetryTimeoutId) {
clearTimeout(this.decryptRetryTimeoutId);
}
this.decryptRetryTimeoutId = setTimeout(() => {
this.decryptRetryTimeoutId = null;
if (this.activePubkey && this.activePubkey !== normalized) {
return;
}
this.load(normalized).catch((retryError) => {
userLogger.warn(`${LOG_PREFIX} Decryption retry failed`, retryError);
});
}, DECRYPT_RETRY_DELAY_MS);
devLogger.log(
`${LOG_PREFIX} Decryption timed out; scheduling retry in ${DECRYPT_RETRY_DELAY_MS / 1000}s.`,
error,
);
}
async load(pubkey) {
const normalized = normalizeHexPubkey(pubkey);
let wasLoadedForUser =
@@ -455,11 +487,8 @@ class HashtagPreferencesService {
return;
}
const relays = sanitizeRelayList(
Array.isArray(nostrClient.relays)
? nostrClient.relays
: nostrClient.writeRelays,
);
const readRelays = relayManager.getReadRelayUrls();
const relays = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS);
if (!relays.length) {
if (wasLoadedForUser) {
userLogger.warn(
@@ -501,6 +530,7 @@ class HashtagPreferencesService {
dTag: HASHTAG_IDENTIFIER,
relayUrls: relays,
since,
timeoutMs: 12000,
}),
);
@@ -588,13 +618,40 @@ class HashtagPreferencesService {
return;
}
const decryptResult = await this.decryptEvent(latest, normalized);
let decryptResult;
try {
const decryptPromise = this.decryptEvent(latest, normalized);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(
() => {
const timeoutError = new Error(
`Decryption timed out after ${DECRYPT_TIMEOUT_MS / 1000}s`,
);
timeoutError.code = "hashtag-preferences-decrypt-timeout";
reject(timeoutError);
},
DECRYPT_TIMEOUT_MS,
),
);
decryptResult = await Promise.race([decryptPromise, timeoutPromise]);
} catch (error) {
decryptResult = { ok: false, error };
}
if (!decryptResult.ok) {
userLogger.warn(
`${LOG_PREFIX} Failed to decrypt hashtag preferences`,
decryptResult.error,
);
if (decryptResult.error?.code === "hashtag-preferences-decrypt-timeout") {
if (!wasLoadedForUser && !this.loaded) {
this.loaded = true;
}
this.scheduleDecryptRetry(normalized, decryptResult.error);
return;
}
// If we already have loaded preferences (e.g. from cache), preserve them
// rather than wiping everything just because the remote update couldn't be decrypted.
if (wasLoadedForUser) {
@@ -669,22 +726,76 @@ class HashtagPreferencesService {
}
const decryptors = new Map();
const registerDecryptor = (scheme, handler) => {
const sources = new Map();
const registerDecryptor = (scheme, handler, source = "unknown") => {
if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) {
return;
}
decryptors.set(scheme, handler);
sources.set(scheme, source);
};
if (signerHasNip44) {
registerDecryptor("nip44", (payload) => signer.nip44Decrypt(userPubkey, payload));
registerDecryptor("nip44_v2", (payload) =>
signer.nip44Decrypt(userPubkey, payload),
registerDecryptor(
"nip44",
(payload) => signer.nip44Decrypt(userPubkey, payload),
"active-signer",
);
registerDecryptor(
"nip44_v2",
(payload) => signer.nip44Decrypt(userPubkey, payload),
"active-signer",
);
}
if (signerHasNip04) {
registerDecryptor("nip04", (payload) => signer.nip04Decrypt(userPubkey, payload));
registerDecryptor(
"nip04",
(payload) => signer.nip04Decrypt(userPubkey, payload),
"active-signer",
);
}
const nostrApi = typeof window !== "undefined" ? window?.nostr : null;
if (nostrApi) {
if (typeof nostrApi.nip04?.decrypt === "function") {
registerDecryptor(
"nip04",
(payload) => nostrApi.nip04.decrypt(userPubkey, payload),
"extension",
);
}
const nip44 =
nostrApi.nip44 && typeof nostrApi.nip44 === "object"
? nostrApi.nip44
: null;
if (nip44) {
if (typeof nip44.decrypt === "function") {
registerDecryptor(
"nip44",
(payload) => nip44.decrypt(userPubkey, payload),
"extension",
);
}
const nip44v2 =
nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
if (nip44v2 && typeof nip44v2.decrypt === "function") {
registerDecryptor(
"nip44_v2",
(payload) => nip44v2.decrypt(userPubkey, payload),
"extension",
);
if (!decryptors.has("nip44")) {
registerDecryptor(
"nip44",
(payload) => nip44v2.decrypt(userPubkey, payload),
"extension",
);
}
}
}
}
if (!decryptors.size) {
@@ -703,6 +814,14 @@ class HashtagPreferencesService {
if (!decryptFn) {
continue;
}
const source = sources.get(scheme);
if (source === "extension") {
userLogger.info(
`${LOG_PREFIX} Attempting decryption via window.nostr fallback (${scheme})`,
);
}
try {
const plaintext = await decryptFn(ciphertext);
if (typeof plaintext !== "string") {
@@ -714,6 +833,12 @@ class HashtagPreferencesService {
}
return { ok: true, plaintext, scheme };
} catch (error) {
if (source === "extension") {
userLogger.warn(
`${LOG_PREFIX} window.nostr fallback decryption failed (${scheme})`,
error,
);
}
attemptErrors.push({ scheme, error });
}
}
+3
View File
@@ -958,6 +958,9 @@ export class NostrService {
...options,
limit: resolvedLimit,
decryptLimit: resolvedDecryptLimit,
onMessage: (message) => {
this.applyDirectMessage(message, { reason: "load-incremental" });
},
});
} catch (error) {
userLogger.warn("[nostrService] Failed to load direct messages", error);
+274 -91
View File
@@ -11,6 +11,7 @@ import {
subscribeVideoViewEvents,
} from "./nostrViewEventsFacade.js";
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
import { relayManager } from "./relayManager.js";
import {
buildSubscriptionListEvent,
SUBSCRIPTION_LIST_IDENTIFIER,
@@ -33,6 +34,8 @@ import { profileCache } from "./state/profileCache.js";
const SUBSCRIPTION_SET_KIND =
getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000;
const DECRYPT_TIMEOUT_MS = 90000;
const DECRYPT_RETRY_DELAY_MS = 10000;
function normalizeHexPubkey(value) {
if (typeof value !== "string") {
@@ -208,6 +211,58 @@ function parseSubscriptionPlaintext(plaintext) {
return [];
}
function parseCachedSubscriptionSnapshot(cached) {
if (Array.isArray(cached)) {
const normalized = cached.map((value) => normalizeHexPubkey(value)).filter(Boolean);
return {
subscribedPubkeys: normalized,
eventId: null,
createdAt: null,
hasSnapshot: true,
};
}
if (cached && typeof cached === "object") {
const listCandidate = Array.isArray(cached.subscribedPubkeys)
? cached.subscribedPubkeys
: Array.isArray(cached.subPubkeys)
? cached.subPubkeys
: [];
const normalized = listCandidate
.map((value) => normalizeHexPubkey(value))
.filter(Boolean);
const eventId = typeof cached.eventId === "string" ? cached.eventId : null;
const createdAtRaw = cached.createdAt;
const createdAtCandidate =
typeof createdAtRaw === "number"
? createdAtRaw
: typeof createdAtRaw === "string" && createdAtRaw.trim()
? Number(createdAtRaw)
: Number.NaN;
const createdAt = Number.isFinite(createdAtCandidate)
? Math.floor(createdAtCandidate)
: null;
const hasSnapshot =
Array.isArray(cached.subscribedPubkeys) ||
Array.isArray(cached.subPubkeys) ||
Boolean(eventId) ||
Number.isFinite(createdAtCandidate);
return {
subscribedPubkeys: normalized,
eventId,
createdAt,
hasSnapshot,
};
}
return {
subscribedPubkeys: [],
eventId: null,
createdAt: null,
hasSnapshot: false,
};
}
const getApp = () => getApplication();
const listVideoViewEventsApi = (pointer, options) =>
@@ -269,6 +324,7 @@ class SubscriptionsManager {
constructor() {
this.subscribedPubkeys = new Set();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.currentUserPubkey = null;
this.loaded = false;
this.loadingPromise = null;
@@ -282,6 +338,7 @@ class SubscriptionsManager {
this.isRunningFeed = false;
this.hasRenderedOnce = false;
this.emitter = new TinyEventEmitter();
this.decryptRetryTimeoutId = null;
this.ensureNostrServiceListener();
profileCache.subscribe((event, detail) => {
@@ -311,9 +368,12 @@ class SubscriptionsManager {
// 1. Attempt to load from cache first
const cached = profileCache.getProfileData(normalizedUserPubkey, "subscriptions");
if (Array.isArray(cached)) {
const cachedSnapshot = parseCachedSubscriptionSnapshot(cached);
if (cachedSnapshot.hasSnapshot) {
devLogger.log("[SubscriptionsManager] Loaded subscriptions from cache.");
this.subscribedPubkeys = new Set(cached);
this.subscribedPubkeys = new Set(cachedSnapshot.subscribedPubkeys);
this.subsEventId = cachedSnapshot.eventId;
this.subsEventCreatedAt = cachedSnapshot.createdAt;
this.currentUserPubkey = normalizedUserPubkey;
this.loaded = true;
@@ -330,49 +390,48 @@ class SubscriptionsManager {
saveToCache(userPubkey) {
// We assume userPubkey matches active profile, enforced by profileCache
profileCache.set("subscriptions", Array.from(this.subscribedPubkeys));
profileCache.set("subscriptions", {
subscribedPubkeys: Array.from(this.subscribedPubkeys),
eventId: this.subsEventId,
createdAt: this.subsEventCreatedAt,
});
}
on(eventName, handler) {
return this.emitter.on(eventName, handler);
}
scheduleDecryptRetry(userPubkey, error) {
const normalized = normalizeHexPubkey(userPubkey) || userPubkey;
if (!normalized) {
return;
}
if (this.decryptRetryTimeoutId) {
clearTimeout(this.decryptRetryTimeoutId);
}
this.decryptRetryTimeoutId = setTimeout(() => {
this.decryptRetryTimeoutId = null;
if (this.currentUserPubkey && this.currentUserPubkey !== normalized) {
return;
}
this.updateFromRelays(normalized).catch((retryError) => {
userLogger.warn("[SubscriptionsManager] Decryption retry failed:", retryError);
});
}, DECRYPT_RETRY_DELAY_MS);
devLogger.log(
`[SubscriptionsManager] Decryption timed out; scheduling retry in ${DECRYPT_RETRY_DELAY_MS / 1000}s.`,
error,
);
}
async updateFromRelays(userPubkey) {
if (!userPubkey) return;
try {
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
const relaySet = new Set();
const addRelayCandidates = (candidates) => {
if (!candidates) {
return;
}
const iterable = Array.isArray(candidates)
? candidates
: candidates instanceof Set
? Array.from(candidates)
: [];
for (const candidate of iterable) {
if (typeof candidate !== "string") {
continue;
}
const trimmed = candidate.trim();
if (trimmed) {
relaySet.add(trimmed);
}
}
};
addRelayCandidates(nostrClient.relays);
if (!relaySet.size) {
addRelayCandidates(nostrClient.readRelays);
}
if (!relaySet.size) {
addRelayCandidates(DEFAULT_RELAY_URLS);
}
const relayUrls = Array.from(relaySet);
const readRelays = relayManager.getReadRelayUrls();
const relayUrls = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS);
if (!relayUrls.length) {
devLogger.warn(
"[SubscriptionsManager] No relay URLs available while loading subscriptions.",
@@ -381,26 +440,62 @@ class SubscriptionsManager {
}
// Use incremental fetch helper
const cachedSnapshot = parseCachedSubscriptionSnapshot(
profileCache.getProfileData(normalizedUserPubkey, "subscriptions"),
);
const shouldForceFullFetch = !cachedSnapshot.hasSnapshot;
let events = await nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedUserPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls
relayUrls,
since: shouldForceFullFetch ? 0 : undefined,
timeoutMs: 12000,
});
// Also check session actor if different?
// The original code checked both userPubkey and sessionActor.pubkey.
// fetchListIncrementally takes a single pubkey.
// If we want to check session actor too, we need another call.
// However, usually the subscription list belongs to the logged in user (userPubkey).
// The session actor logic in original code:
// const sessionActorPubkey = normalizeHexPubkey(nostrClient?.sessionActor?.pubkey);
// ... authors: [normalizedUserPubkey, sessionActorPubkey]
// Since fetchListIncrementally filters by author=pubkey, we only query for normalizedUserPubkey here.
// If session actor support is critical for some delegation case, it needs a separate fetch.
// Assuming userPubkey is the primary target for subscriptions.
const normalizedSessionActorPubkey = normalizeNostrPubkey(
nostrClient?.sessionActor?.pubkey,
);
const shouldFetchSessionActor =
normalizedSessionActorPubkey &&
normalizedSessionActorPubkey !== normalizedUserPubkey;
if (shouldFetchSessionActor) {
const sessionCachedSnapshot = parseCachedSubscriptionSnapshot(
profileCache.getProfileData(
normalizedSessionActorPubkey,
"subscriptions",
),
);
const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot;
const sessionEvents = await nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedSessionActorPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: shouldForceSessionFetch ? 0 : undefined,
timeoutMs: 12000,
});
if (sessionEvents.length) {
events = events.concat(sessionEvents);
}
}
if (!events.length) {
const mergedEvents = [];
if (events.length) {
const deduped = new Map();
for (const event of events) {
if (!event || typeof event !== "object" || !event.id) {
continue;
}
const existing = deduped.get(event.id);
if (!existing || (event.created_at ?? 0) > (existing.created_at ?? 0)) {
deduped.set(event.id, event);
}
}
mergedEvents.push(...deduped.values());
}
if (!mergedEvents.length) {
if (!this.loaded) {
// If we have nothing loaded and found nothing, maybe user has no list.
// But we shouldn't wipe blindly if incremental fetch just returned no *new* events.
@@ -413,6 +508,7 @@ class SubscriptionsManager {
// If we have NO cached data (this.loaded = false), and we get [], we assume empty list.
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.loaded = true;
} else {
// We have data loaded, and relays returned nothing.
@@ -423,52 +519,46 @@ class SubscriptionsManager {
}
// Sort by created_at desc, pick newest
events.sort((a, b) => b.created_at - a.created_at);
const newest = events[0];
mergedEvents.sort((a, b) => b.created_at - a.created_at);
const newest = mergedEvents[0];
// Check if newest is actually newer than what we have
// If we loaded from cache, we might not have the event object, just the pubkeys.
// But we don't store the event timestamp in cache currently?
// profileCache only stores the array.
// Wait, we don't persist the event ID or created_at in profileCache for subscriptions.
// So we can't strictly compare timestamps against cache.
// However, fetchListIncrementally already handles the "newer than last sync" logic per relay.
// So any event returned here is effectively "new information" (or re-fetched full state).
// Any event returned here is effectively "new information" (or re-fetched full state).
// fetchListIncrementally already handles the "newer than last sync" logic per relay,
// and we persist event metadata for additional cache context.
this.subsEventId = newest.id;
this.subsEventCreatedAt = Number.isFinite(newest.created_at)
? newest.created_at
: null;
const signer = getActiveSigner();
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
const hints = extractEncryptionHints(newest);
const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2");
const requiresNip04 =
!hints.length || hints.includes("nip04") || !requiresNip44;
let permissionResult = { ok: true };
const signerCoversRequiredSchemes =
(!requiresNip04 || signerHasNip04) && (!requiresNip44 || signerHasNip44);
if (!signerCoversRequiredSchemes) {
permissionResult = await requestDefaultExtensionPermissions();
}
if (!permissionResult.ok) {
userLogger.warn(
"[SubscriptionsManager] Extension permissions denied while loading subscriptions; treating list as empty.",
permissionResult.error,
let decryptResult;
try {
const decryptPromise = this.decryptSubscriptionEvent(newest, userPubkey);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(
() => {
const timeoutError = new Error(
`Decryption timed out after ${DECRYPT_TIMEOUT_MS / 1000}s`,
);
timeoutError.code = "subscriptions-decrypt-timeout";
reject(timeoutError);
},
DECRYPT_TIMEOUT_MS,
),
);
// Permission denied is definitive enough to stop trying to use this event
if (!this.loaded) {
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.loaded = true;
}
return;
decryptResult = await Promise.race([decryptPromise, timeoutPromise]);
} catch (error) {
decryptResult = { ok: false, error };
}
const decryptResult = await this.decryptSubscriptionEvent(newest, userPubkey);
if (!decryptResult.ok) {
if (decryptResult.error?.code === "subscriptions-decrypt-timeout") {
if (!this.loaded && !cachedSnapshot.hasSnapshot) {
this.loaded = true;
}
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error);
return;
}
userLogger.error(
"[SubscriptionsManager] Failed to decrypt subscription list:",
decryptResult.error,
@@ -476,6 +566,7 @@ class SubscriptionsManager {
if (!this.loaded) {
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.loaded = true;
}
return;
@@ -528,11 +619,16 @@ class SubscriptionsManager {
reset() {
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.currentUserPubkey = null;
this.loaded = false;
this.lastRunOptions = null;
this.lastResult = null;
this.hasRenderedOnce = false;
if (this.decryptRetryTimeoutId) {
clearTimeout(this.decryptRetryTimeoutId);
this.decryptRetryTimeoutId = null;
}
this.emitter.emit("change", { action: "reset", subscribedPubkeys: [] });
}
@@ -620,6 +716,57 @@ class SubscriptionsManager {
return { ok: false, error };
}
const hints = extractEncryptionHints(event);
const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2");
const requiresNip04 = !hints.length || hints.includes("nip04") || !requiresNip44;
const signerHasRequiredDecryptors = (candidate) => {
const hasNip04 = typeof candidate?.nip04Decrypt === "function";
const hasNip44 = typeof candidate?.nip44Decrypt === "function";
return (!requiresNip44 || hasNip44) && (!requiresNip04 || hasNip04);
};
let signer = getActiveSigner();
if (
!signer ||
(!signerHasRequiredDecryptors(signer) &&
typeof nostrClient?.ensureActiveSignerForPubkey === "function")
) {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
const nostrApi = typeof window !== "undefined" ? window?.nostr : null;
const windowHasNip04 = typeof nostrApi?.nip04?.decrypt === "function";
const windowHasNip44 =
(nostrApi?.nip44 && typeof nostrApi.nip44.decrypt === "function") ||
(nostrApi?.nip44?.v2 && typeof nostrApi.nip44.v2.decrypt === "function");
if (
(!signerHasNip44 && !windowHasNip44 && requiresNip44) ||
(!signerHasNip04 && !windowHasNip04 && requiresNip04)
) {
try {
const permissionResult = await requestDefaultExtensionPermissions();
if (!permissionResult?.ok) {
const error =
permissionResult?.error instanceof Error
? permissionResult.error
: new Error(
"Extension permissions denied while decrypting subscriptions."
);
error.code = "nostr-permission-denied";
return { ok: false, error };
}
} catch (error) {
const wrapped = error instanceof Error ? error : new Error(String(error));
wrapped.code = "nostr-permission-error";
return { ok: false, error: wrapped };
}
}
const decryptors = new Map();
const registerDecryptor = (scheme, handler) => {
if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) {
@@ -628,19 +775,53 @@ class SubscriptionsManager {
decryptors.set(scheme, handler);
};
let signer = getActiveSigner();
if (!signer && typeof nostrClient?.ensureActiveSignerForPubkey === "function") {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
if (signerHasNip44) {
registerDecryptor("nip44", (payload) => signer.nip44Decrypt(userPubkey, payload));
registerDecryptor(
"nip44_v2",
(payload) => signer.nip44Decrypt(userPubkey, payload)
);
}
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
if (signerHasNip04) {
registerDecryptor("nip04", (payload) => signer.nip04Decrypt(userPubkey, payload));
}
if (signerHasNip44) {
registerDecryptor("nip44", (payload) => signer.nip44Decrypt(userPubkey, payload));
if (nostrApi) {
if (typeof nostrApi.nip04?.decrypt === "function") {
registerDecryptor(
"nip04",
(payload) => nostrApi.nip04.decrypt(userPubkey, payload)
);
}
const nip44 =
nostrApi.nip44 && typeof nostrApi.nip44 === "object"
? nostrApi.nip44
: null;
if (nip44) {
if (typeof nip44.decrypt === "function") {
registerDecryptor(
"nip44",
(payload) => nip44.decrypt(userPubkey, payload)
);
}
const nip44v2 =
nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
if (nip44v2 && typeof nip44v2.decrypt === "function") {
registerDecryptor(
"nip44_v2",
(payload) => nip44v2.decrypt(userPubkey, payload)
);
if (!decryptors.has("nip44")) {
registerDecryptor(
"nip44",
(payload) => nip44v2.decrypt(userPubkey, payload)
);
}
}
}
}
if (!decryptors.size) {
@@ -933,6 +1114,8 @@ class SubscriptionsManager {
}
this.subsEventId = signedEvent.id;
this.subsEventCreatedAt = signedEvent.created_at;
this.saveToCache(userPubkey);
const acceptedUrls = publishSummary.accepted.map(({ url }) => url);
devLogger.log(
"Subscription list published, event id:",
+1 -1
View File
@@ -1,7 +1,7 @@
import { WSS_TRACKERS as DEFAULT_WSS_TRACKERS } from "./constants.js";
export const TRACKER_TIMEOUT_MS = 6000;
export const TRACKER_PER_MAGNET = 10;
const TRACKER_PER_MAGNET = 10;
export const HEALTH_TTL_MS = 2 * 60 * 1000;
export const CONCURRENCY = 6;
export const TRACKER_ERROR_COOLDOWN_MS = 60 * 1000;
-223
View File
@@ -1,223 +0,0 @@
import "./bufferPolyfill.js";
import Client from "https://esm.sh/[email protected]/client?bundle";
import { infoHashFromMagnet, trackersFromMagnet } from "./magnets.js";
import {
resolveTrackerList,
TRACKER_TIMEOUT_MS,
TRACKER_ERROR_COOLDOWN_MS,
} from "./trackerConfig.js";
const trackerState = new Map();
function now() {
return Date.now();
}
function randomPeerId() {
const bytes = new Uint8Array(20);
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256);
}
}
return bytes;
}
function getDefaultHealth() {
return {
ok: false,
seeders: 0,
leechers: 0,
responded: false,
from: [],
};
}
function getTrackerEntry(url) {
const existing = trackerState.get(url);
if (existing) {
return existing;
}
const entry = {
consecutiveErrors: 0,
lastErrorAt: 0,
cooldownUntil: 0,
};
trackerState.set(url, entry);
return entry;
}
function markTrackerSuccess(url) {
const entry = getTrackerEntry(url);
entry.consecutiveErrors = 0;
entry.cooldownUntil = 0;
}
function markTrackerError(url) {
const entry = getTrackerEntry(url);
const nowTs = now();
if (entry.lastErrorAt && nowTs - entry.lastErrorAt < TRACKER_ERROR_COOLDOWN_MS) {
entry.consecutiveErrors += 1;
} else {
entry.consecutiveErrors = 1;
}
entry.lastErrorAt = nowTs;
if (entry.consecutiveErrors >= 2) {
entry.cooldownUntil = nowTs + TRACKER_ERROR_COOLDOWN_MS;
}
}
function isTrackerUsable(url) {
const entry = getTrackerEntry(url);
return entry.cooldownUntil === 0 || entry.cooldownUntil <= now();
}
export async function trackerPing(magnet, trackers) {
const infoHash = infoHashFromMagnet(magnet);
if (!infoHash) {
return getDefaultHealth();
}
const magnetTrackers = trackers || trackersFromMagnet(magnet);
const announceList = resolveTrackerList({ magnetTrackers });
const usable = announceList.filter(isTrackerUsable);
const announces = usable.length ? usable : announceList;
if (!announces.length) {
return getDefaultHealth();
}
const peerId = randomPeerId();
const result = getDefaultHealth();
const clients = new Set();
let settled = false;
let timeoutId = null;
const finalize = () => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
clients.forEach((client) => {
try {
client.destroy();
} catch (err) {
// ignore
}
});
clients.clear();
};
return new Promise((resolve) => {
if (Number.isFinite(TRACKER_TIMEOUT_MS) && TRACKER_TIMEOUT_MS > 0) {
timeoutId = setTimeout(() => {
finalize();
resolve(result);
}, TRACKER_TIMEOUT_MS);
}
let remaining = announces.length;
const handleComplete = (client, url) => {
if (clients.has(client)) {
clients.delete(client);
}
if (url && result.from.includes(url) && result.ok) {
markTrackerSuccess(url);
}
remaining -= 1;
if (remaining <= 0 && !settled) {
finalize();
resolve(result);
}
};
const handleResult = (url, data) => {
result.responded = true;
if (url && !result.from.includes(url)) {
result.from.push(url);
}
const seeders = Number.isFinite(data?.complete)
? Number(data.complete)
: 0;
const leechers = Number.isFinite(data?.incomplete)
? Number(data.incomplete)
: 0;
if (seeders > result.seeders) {
result.seeders = seeders;
}
if (leechers > result.leechers) {
result.leechers = leechers;
}
if (seeders > 0) {
result.ok = true;
}
};
announces.forEach((url) => {
let client;
try {
client = new Client({
infoHash,
peerId,
announce: [url],
});
} catch (err) {
markTrackerError(url);
remaining -= 1;
if (remaining <= 0 && !settled) {
finalize();
resolve(result);
}
return;
}
clients.add(client);
const cleanupAndResolve = () => {
if (settled) {
return;
}
finalize();
resolve(result);
};
client.once("update", (data) => {
handleResult(url, data);
markTrackerSuccess(url);
if (result.ok) {
cleanupAndResolve();
return;
}
handleComplete(client, url);
});
client.once("error", () => {
markTrackerError(url);
handleComplete(client, url);
});
client.once("warning", () => {
handleComplete(client, url);
});
try {
client.start();
} catch (err) {
markTrackerError(url);
handleComplete(client, url);
}
});
if (announces.length === 0) {
finalize();
resolve(result);
}
});
}
+5 -2
View File
@@ -160,8 +160,11 @@ export class UploadModal {
wrapper.innerHTML = html;
this.removeTrackingScripts(wrapper);
targetContainer.appendChild(wrapper.firstElementChild);
this.root = targetContainer.querySelector("#uploadModal");
const modalRoot = wrapper.querySelector("#uploadModal");
if (!modalRoot) throw new Error("UploadModal template must contain #uploadModal element");
targetContainer.appendChild(modalRoot);
this.root = modalRoot;
this.container = targetContainer;
this.cacheElements();
+1 -1
View File
@@ -3244,7 +3244,7 @@ export class VideoModal {
seen.add(key);
const display = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
const display = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
normalized.push(display);
}
+3 -3
View File
@@ -62,8 +62,8 @@ function createTagButton({ doc, tag, onTagActivate, getTagState }) {
const button = doc.createElement("button");
button.type = "button";
button.classList.add("pill", "video-tag-pill", "focus-ring");
button.dataset.tag = tag;
const displayTag = tag.startsWith("#") ? tag : `#${tag}`;
button.dataset.tag = displayTag;
button.title = displayTag;
const label = doc.createElement("span");
@@ -95,7 +95,7 @@ function createTagButton({ doc, tag, onTagActivate, getTagState }) {
if (getTagState) {
try {
const state = getTagState(tag);
const state = getTagState(displayTag);
applyPreferenceState(button, state);
} catch (error) {
// Ignore state resolution errors; leave button in neutral state.
@@ -106,7 +106,7 @@ function createTagButton({ doc, tag, onTagActivate, getTagState }) {
if (onTagActivate) {
const handler = (event) => {
onTagActivate(tag, { event, button });
onTagActivate(displayTag, { event, button });
};
button.addEventListener("click", handler);
// Store the handler for cleanup when the strip is updated.
+8
View File
@@ -1487,6 +1487,7 @@ export default class LoginModalController {
this.rejectPendingTask(this.createCancellationError(), {
type: "add-profile",
});
this.handleProviderDisconnect("nip46");
cleanup();
} else {
finish(null, { keepMounted: false });
@@ -2392,6 +2393,13 @@ export default class LoginModalController {
return;
}
if (
rejectionError.code === "login-cancelled" ||
rejectionError.code === "user-cancelled"
) {
return;
}
await safeInvokeAsync(this.callbacks.onLoginError, {
provider: entry.provider.source || entry.provider,
error: rejectionError,
+77 -66
View File
@@ -4970,7 +4970,10 @@ export class ProfileModalController {
}
handleDirectMessagesUpdated(detail = {}) {
if (this.activeMessagesRequest) {
if (
this.activeMessagesRequest &&
detail?.reason !== "load-incremental"
) {
return;
}
@@ -12450,91 +12453,99 @@ export class ProfileModalController {
this.previouslyFocusedElement = activeElement;
}
// Render the header (saved profiles) synchronously so the modal doesn't look empty.
// This is generally fast as it only touches the top section.
this.renderSavedProfiles();
this.refreshWalletPaneState();
this.refreshModerationSettingsUi();
this.syncLinkPreviewSettingsUi();
// Show the modal immediately to prevent UI lag.
this.open(pane);
const hasBlockHydrator =
this.services.userBlocks &&
typeof this.services.userBlocks.ensureLoaded === "function";
if (hasBlockHydrator) {
this.setBlockListLoadingState("loading");
} else {
this.populateBlockedList();
}
this.open(pane);
// Defer expensive operations to the next animation frame to allow the modal to paint.
requestAnimationFrame(() => {
this.refreshWalletPaneState();
this.refreshModerationSettingsUi();
this.syncLinkPreviewSettingsUi();
const backgroundTasks = [];
if (!hasBlockHydrator) {
this.populateBlockedList();
}
backgroundTasks.push(
Promise.resolve()
.then(() => this.refreshAdminPaneState())
.catch((error) => {
userLogger.error(
"Failed to refresh admin pane while opening profile modal:",
error,
);
}),
);
const backgroundTasks = [];
backgroundTasks.push(
Promise.resolve().then(() => {
try {
this.populateProfileRelays();
} catch (error) {
userLogger.warn(
"Failed to populate relay list while opening profile modal:",
error,
);
}
}),
);
if (hasBlockHydrator) {
const activeHex = this.normalizeHexPubkey(this.getActivePubkey());
backgroundTasks.push(
Promise.resolve()
.then(() => this.services.userBlocks.ensureLoaded(activeHex))
.then(() => {
try {
this.populateBlockedList();
} catch (error) {
.then(() => this.refreshAdminPaneState())
.catch((error) => {
userLogger.error(
"Failed to refresh admin pane while opening profile modal:",
error,
);
}),
);
backgroundTasks.push(
Promise.resolve().then(() => {
try {
this.populateProfileRelays();
} catch (error) {
userLogger.warn(
"Failed to populate relay list while opening profile modal:",
error,
);
}
}),
);
if (hasBlockHydrator) {
const activeHex = this.normalizeHexPubkey(this.getActivePubkey());
backgroundTasks.push(
Promise.resolve()
.then(() => this.services.userBlocks.ensureLoaded(activeHex))
.then(() => {
try {
this.populateBlockedList();
} catch (error) {
userLogger.warn(
"Failed to render blocked creators after hydration:",
error,
);
this.setBlockListLoadingState("error", {
message: "Blocked creators may be out of date. Try again later.",
});
}
})
.catch((error) => {
userLogger.warn(
"Failed to render blocked creators after hydration:",
"Failed to refresh user block list while opening profile modal:",
error,
);
this.setBlockListLoadingState("error", {
message:
"Blocked creators may be out of date. Try again later.",
message: "Blocked creators may be out of date. Try again later.",
});
}
})
.catch((error) => {
userLogger.warn(
"Failed to refresh user block list while opening profile modal:",
error,
);
this.setBlockListLoadingState("error", {
message:
"Blocked creators may be out of date. Try again later.",
});
try {
this.populateBlockedList();
} catch (populateError) {
userLogger.warn(
"Failed to render blocked creators after hydration failure:",
populateError,
);
}
}),
);
}
try {
this.populateBlockedList();
} catch (populateError) {
userLogger.warn(
"Failed to render blocked creators after hydration failure:",
populateError,
);
}
}),
);
}
if (backgroundTasks.length) {
void Promise.allSettled(backgroundTasks);
}
if (backgroundTasks.length) {
void Promise.allSettled(backgroundTasks);
}
});
return true;
}
+130 -31
View File
@@ -18,6 +18,8 @@ import {
assertAnyRelayAccepted,
} from "./nostrPublish.js";
import { profileCache } from "./state/profileCache.js";
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
import { relayManager } from "./relayManager.js";
class TinyEventEmitter {
constructor() {
@@ -71,6 +73,16 @@ export const USER_BLOCK_EVENTS = Object.freeze({
const FAST_BLOCKLIST_RELAY_LIMIT = 3;
const FAST_BLOCKLIST_TIMEOUT_MS = 2500;
const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000;
const DECRYPT_TIMEOUT_MS = 90000;
const DECRYPT_RETRY_DELAY_MS = 10000;
function sanitizeRelayList(candidate) {
return Array.isArray(candidate)
? candidate
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean)
: [];
}
function normalizeEncryptionToken(value) {
if (typeof value !== "string") {
@@ -634,6 +646,7 @@ class UserBlockListManager {
this.loaded = false;
this.emitter = new TinyEventEmitter();
this.seedStateCache = new Map();
this.decryptRetryTimeoutId = null;
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
@@ -655,6 +668,10 @@ class UserBlockListManager {
this.muteEventId = null;
this.muteEventCreatedAt = null;
this.loaded = false;
if (this.decryptRetryTimeoutId) {
clearTimeout(this.decryptRetryTimeoutId);
this.decryptRetryTimeoutId = null;
}
}
on(eventName, handler) {
@@ -665,6 +682,32 @@ class UserBlockListManager {
return Array.from(this.blockedPubkeys);
}
scheduleDecryptRetry(userPubkey, error) {
if (!userPubkey) {
return;
}
if (this.decryptRetryTimeoutId) {
clearTimeout(this.decryptRetryTimeoutId);
}
this.decryptRetryTimeoutId = setTimeout(() => {
this.decryptRetryTimeoutId = null;
if (this.activePubkey !== userPubkey) {
return;
}
this.loadBlocks(userPubkey, {
since: Number.isFinite(this.blockEventCreatedAt)
? this.blockEventCreatedAt
: undefined,
}).catch((retryError) => {
userLogger.warn("[UserBlockList] Decryption retry failed:", retryError);
});
}, DECRYPT_RETRY_DELAY_MS);
devLogger.log(
`[UserBlockList] Decryption timed out; scheduling retry in ${DECRYPT_RETRY_DELAY_MS / 1000}s.`,
error,
);
}
isBlocked(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
@@ -726,7 +769,9 @@ class UserBlockListManager {
}
};
emitStatus({ status: "loading", relays: Array.from(nostrClient.relays || []) });
const readRelays = relayManager.getReadRelayUrls();
const relays = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS);
emitStatus({ status: "loading", relays });
const localBlocks = this._loadLocal(normalized);
const hasLocalData = !!localBlocks;
@@ -753,14 +798,29 @@ class UserBlockListManager {
};
const resolveDecryptors = async (event) => {
const signer = getActiveSigner();
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
let signer = getActiveSigner();
const hints = extractEncryptionHints(event);
const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2");
const requiresNip04 = !hints.length || hints.includes("nip04");
const signerHasRequiredDecryptors = (candidate) => {
const hasNip04 = typeof candidate?.nip04Decrypt === "function";
const hasNip44 = typeof candidate?.nip44Decrypt === "function";
return (!requiresNip44 || hasNip44) && (!requiresNip04 || hasNip04);
};
if (
!signer ||
(!signerHasRequiredDecryptors(signer) &&
typeof nostrClient?.ensureActiveSignerForPubkey === "function")
) {
signer = await nostrClient.ensureActiveSignerForPubkey(normalized);
}
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
let permissionError = null;
if ((!signerHasNip44 && requiresNip44) || (!signerHasNip04 && requiresNip04)) {
try {
@@ -857,10 +917,6 @@ class UserBlockListManager {
};
try {
const relays = Array.isArray(nostrClient.relays)
? nostrClient.relays.filter((relay) => typeof relay === "string" && relay)
: [];
if (!relays.length) {
// Only clear if truly no relays, though loadBlocks with no relays is weird.
if (!this.loaded) {
@@ -878,6 +934,13 @@ class UserBlockListManager {
events,
{ skipIfEmpty = false, source = "fast" } = {},
) => {
const previousState = {
blockedPubkeys: new Set(this.blockedPubkeys),
blockEventId: this.blockEventId,
blockEventCreatedAt: this.blockEventCreatedAt,
loaded: this.loaded,
};
if (!Array.isArray(events) || !events.length) {
if (skipIfEmpty) {
return;
@@ -923,26 +986,19 @@ class UserBlockListManager {
return;
}
// Separate by kind to merge distinct lists (Block vs Mute)
const muteEvents = validEvents.filter((e) => e.kind === 10000);
// Prioritize tagged mute lists (Bitvid) over plain ones (others)
const taggedMutes = muteEvents
.filter((e) => isTaggedBlockListEvent(e))
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
const plainMutes = muteEvents
.filter((e) => !isTaggedBlockListEvent(e))
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
const newestMute =
taggedMutes.length > 0 ? taggedMutes[0] : plainMutes[0] || null;
// Separate by logic (Block vs Mute)
// Block: Kind 10000 with d=user-blocks OR Kind 30002 (legacy)
const blockEvents = validEvents
.filter((e) => e.kind === 30002)
.filter((e) => isTaggedBlockListEvent(e) || e.kind === 30002)
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
// Mute: Kind 10000 WITHOUT d=user-blocks
const muteEvents = validEvents
.filter((e) => e.kind === 10000 && !isTaggedBlockListEvent(e))
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
const newestBlock = blockEvents[0] || null;
const newestMute = muteEvents[0] || null;
const newestMuteTime = Number.isFinite(newestMute?.created_at)
? newestMute.created_at
@@ -1011,10 +1067,27 @@ class UserBlockListManager {
let decryptionError = null;
try {
const [muteList, blockList] = await Promise.all([
const decryptPromise = Promise.all([
decryptEvent(newestMute),
decryptEvent(newestBlock),
]);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(
() => {
const timeoutError = new Error(
`Decryption timed out after ${DECRYPT_TIMEOUT_MS / 1000}s`,
);
timeoutError.code = "user-blocklist-decrypt-timeout";
reject(timeoutError);
},
DECRYPT_TIMEOUT_MS,
),
);
const [muteList, blockList] = await Promise.race([
decryptPromise,
timeoutPromise,
]);
muteList.forEach((pk) => mergedPubkeys.add(pk));
blockList.forEach((pk) => mergedPubkeys.add(pk));
@@ -1024,11 +1097,22 @@ class UserBlockListManager {
}
if (decryptionError) {
if (decryptionError.code === "user-blocklist-decrypt-timeout") {
this.blockedPubkeys = previousState.blockedPubkeys;
this.blockEventId = previousState.blockEventId;
this.blockEventCreatedAt = previousState.blockEventCreatedAt;
this.loaded = true;
emitStatus({ status: "settled" });
this.scheduleDecryptRetry(normalized, decryptionError);
return;
}
userLogger.warn(
"[UserBlockList] Decryption failed during merge.",
decryptionError,
);
this.reset();
this.blockedPubkeys = previousState.blockedPubkeys;
this.blockEventId = previousState.blockEventId;
this.blockEventCreatedAt = previousState.blockEventCreatedAt;
this.loaded = true;
emitStatus({
status: "error",
@@ -1080,13 +1164,15 @@ class UserBlockListManager {
pubkey: normalized,
relayUrls: relays,
since: fetchSince,
timeoutMs: 12000,
}),
nostrClient.fetchListIncrementally({
kind: 30002,
kind: 10000,
pubkey: normalized,
dTag: BLOCK_LIST_IDENTIFIER,
relayUrls: relays,
since: fetchSince,
timeoutMs: 12000,
}),
]);
@@ -1113,7 +1199,11 @@ class UserBlockListManager {
}
_loadLocal(actorHex) {
const cached = profileCache.get("blocks");
const normalizedActorHex = normalizeHex(actorHex);
if (!normalizedActorHex) {
return null;
}
const cached = profileCache.getProfileData(normalizedActorHex, "blocks");
if (Array.isArray(cached)) {
return new Set(cached);
}
@@ -1121,7 +1211,15 @@ class UserBlockListManager {
}
_saveLocal(actorHex) {
profileCache.set("blocks", Array.from(this.blockedPubkeys));
const normalizedActorHex = normalizeHex(actorHex);
if (!normalizedActorHex) {
return;
}
profileCache.setProfileData(
normalizedActorHex,
"blocks",
Array.from(this.blockedPubkeys),
);
}
_getSeedState(actorHex) {
@@ -1134,7 +1232,8 @@ class UserBlockListManager {
return this.seedStateCache.get(normalized);
}
const stored = profileCache.get("blocks_meta") || {};
const stored =
profileCache.getProfileData(normalized, "blocks_meta") || {};
const seeded = !!stored.seeded;
const removals = new Set(Array.isArray(stored.removals) ? stored.removals : []);
@@ -1149,7 +1248,7 @@ class UserBlockListManager {
const state = this.seedStateCache.get(normalized);
if (!state) return;
profileCache.set("blocks_meta", {
profileCache.setProfileData(normalized, "blocks_meta", {
seeded: state.seeded,
removals: Array.from(state.removals),
});
+4
View File
@@ -9,6 +9,7 @@ import { ASSET_VERSION } from "../config/asset-version.js";
import { applyDesignSystemAttributes } from "./designSystem.js";
import { devLogger, userLogger } from "./utils/logger.js";
import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
import { initDocsView } from "./docsView.js";
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
@@ -147,6 +148,9 @@ export const viewInitRegistry = {
refreshApp.forceRefreshAllProfiles();
}
},
docs: async () => {
await initDocsView();
},
history: async () => {
try {
const module = await import("./historyView.js");
+1034 -1261
View File
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -32,14 +32,13 @@
"telemetry:aggregate": "node scripts/agent/telemetry-aggregator.mjs"
},
"devDependencies": {
"@noble/hashes": "^2.0.1",
"@playwright/test": "^1.58.0",
"@tailwindcss/typography": "^0.5.15",
"autoprefixer": "^10.4.23",
"c8": "^10.1.3",
"cross-env": "^7.0.3",
"esbuild": "^0.25.12",
"fake-indexeddb": "^6.2.5",
"jsdom": "^24.1.3",
"nostr-tools": "^2.19.4",
"pixelmatch": "^5.3.0",
"playwright": "^1.58.0",
@@ -47,12 +46,15 @@
"postcss": "^8.4.41",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.5",
"serve": "^14.2.5",
"stylelint": "^16.26.1",
"tailwindcss": "^3.4.19"
},
"license": "GPL-3.0-or-later",
"dependencies": {
"@floating-ui/dom": "^1.7.4",
"@noble/hashes": "^2.0.1",
"jsdom": "^27.4.0",
"ws": "^8.19.0"
}
}
-127
View File
@@ -1,127 +0,0 @@
import { spawnSync, execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
const REPORT_FILE = 'PR_REVIEW.md';
const APPLY_FIXES = process.argv.includes('--apply-fixes');
function run(command, args, opts = {}) {
console.log(`> ${command} ${args.join(' ')}`);
return spawnSync(command, args, { encoding: 'utf-8', stdio: 'pipe', ...opts });
}
function getDiffFiles() {
try {
// Try to compare against origin/main, fallback to HEAD~1 or just check current changes if no upstream
// Since this runs on a checkout, we want to see what this branch touches compared to main.
// If origin/main is not available, we might fail.
const hasMain = run('git', ['rev-parse', '--verify', 'origin/main']).status === 0;
const base = hasMain ? 'origin/main' : 'HEAD';
// Check if we are on main? No, we are on a PR branch usually.
// We want the diff of the *current* code vs main.
const res = run('git', ['diff', '--name-only', base, 'HEAD']);
if (res.error || res.status !== 0) return [];
return res.stdout.trim().split('\n').filter(Boolean);
} catch (e) {
console.error("Error getting diff:", e);
return [];
}
}
function main() {
const report = [];
report.push('# Automated PR Review');
report.push(`Date: ${new Date().toISOString()}`);
report.push('');
// 1. npm ci
console.log('Running npm ci...');
const ci = run('npm', ['ci']);
if (ci.status !== 0) {
report.push('## 🚨 Dependency Install Failed');
report.push('```');
report.push(ci.stderr || ci.stdout);
report.push('```');
// If ci fails, we probably can't run other scripts, but we try.
} else {
report.push('## ✅ Dependencies Installed');
}
// 2. Format
console.log('Running npm run format...');
run('npm', ['run', 'format']); // This modifies files
const status = run('git', ['status', '--porcelain']).stdout;
const formattedFiles = status.split('\n').filter(l => l.trim()).map(l => l.slice(3));
if (formattedFiles.length > 0) {
report.push('## ⚠️ Formatting Changes');
report.push('The following files were reformatted:');
report.push(formattedFiles.map(f => `- ${f}`).join('\n'));
if (APPLY_FIXES) {
console.log('Applying formatting fixes...');
run('git', ['add', '.']);
run('git', ['commit', '-m', 'fix(ai): automated formatting (agent)']);
report.push('\n**Changes have been automatically committed.**');
} else {
report.push('\n**Run `npm run format` to fix these issues.**');
}
} else {
report.push('## ✅ Formatting Passed');
}
// 3. Lint
console.log('Running npm run lint...');
const lint = run('npm', ['run', 'lint']);
if (lint.status !== 0) {
report.push('## ⚠️ Lint Warnings/Errors');
report.push('<details><summary>Show Lint Output</summary>');
report.push('');
report.push('```');
report.push(lint.stdout + '\n' + lint.stderr);
report.push('```');
report.push('</details>');
} else {
report.push('## ✅ Lint Passed');
}
// 4. Unit Tests
console.log('Running npm run test:unit...');
const test = run('npm', ['run', 'test:unit']);
if (test.status !== 0) {
report.push('## ❌ Unit Tests Failed');
report.push('<details><summary>Show Test Output</summary>');
report.push('');
report.push('```');
report.push(test.stdout + '\n' + test.stderr);
report.push('```');
report.push('</details>');
} else {
report.push('## ✅ Unit Tests Passed');
}
// 5. Guardrails
const diffFiles = getDiffFiles();
const sensitivePatterns = [
/^js\/nostr\//,
/^js\/services\/storageService/,
/^js\/services\/r2Service/,
/crypto/i
];
const sensitiveChanges = diffFiles.filter(f => sensitivePatterns.some(p => p.test(f)));
if (sensitiveChanges.length > 0) {
report.push('## 🛡️ Security & Protocol Review Required');
report.push('The following sensitive files were modified:');
report.push(sensitiveChanges.map(f => `- ${f}`).join('\n'));
report.push('\n**Flags:** `requires-security-review`, `requires-protocol-review`');
}
fs.writeFileSync(REPORT_FILE, report.join('\n'));
console.log(`Report generated at ${REPORT_FILE}`);
}
main();
+15 -12
View File
@@ -1,30 +1,33 @@
from playwright.sync_api import sync_playwright
import sys
import time
from playwright.sync_api import sync_playwright
def run():
with sync_playwright() as p:
# Launch browser
# Launch browser (headless by default)
browser = p.chromium.launch()
page = browser.new_page()
# Listen for console messages
# Capture console messages
page.on("console", lambda msg: print(f"console:{msg.type}: {msg.text}"))
# Listen for uncaught exceptions
# Capture uncaught exceptions
page.on("pageerror", lambda exc: print(f"pageerror: {exc}"))
# Listen for failed requests
page.on("requestfailed", lambda req: print(f"requestfailed: {req.url} {req.failure}"))
# Capture failed requests
page.on("requestfailed", lambda request: print(f"requestfailed: {request.url} {request.failure}"))
try:
print("Navigating to http://localhost:8000")
print("Navigating to http://localhost:8000...")
page.goto("http://localhost:8000")
# Wait for a bit to capture initial errors
page.wait_for_timeout(5000)
except Exception as e:
print(f"Navigation failed: {e}")
browser.close()
# Wait for some time to catch initialization errors
time.sleep(5)
except Exception as e:
print(f"Script error: {e}")
finally:
browser.close()
if __name__ == "__main__":
run()
+8 -1
View File
@@ -25,12 +25,19 @@ const DmDecryptor = await import(tempPath);
async function fuzzTest(iteration) {
const genEvent = () => {
const tags = rng.array(() => rng.array(() => rng.mixedString(20), 5), 5);
if (rng.bool()) {
// Circular tag structure
const circularTag = ["p"];
circularTag.push(circularTag); // Circular reference
tags.push(circularTag);
}
return {
kind: rng.oneOf([4, 1059, rng.int(0, 20000)]),
pubkey: rng.mixedString(64),
created_at: rng.int(0, 2000000000),
content: rng.mixedString(100),
tags: rng.array(() => rng.array(() => rng.mixedString(20), 5), 5)
tags
};
};
+65 -1
View File
@@ -16,7 +16,71 @@ async function fuzzTest(iteration) {
// Randomize window protocol sometimes
global.window.location.protocol = rng.bool() ? "https:" : "http:";
const rawValue = rng.mixedString(100);
const genMagnet = () => {
if (rng.bool()) return rng.mixedString(100); // Random garbage
// Construct semi-valid magnet
let magnet = "magnet:?";
const params = [];
// xt (Exact Topic)
if (rng.bool()) {
const hash = rng.bool() ? rng.mixedString(40) : "urn:btih:" + rng.mixedString(40);
params.push(`xt=${hash}`);
}
// dn (Display Name)
if (rng.bool()) {
const val = rng.mixedString(20);
try {
params.push(`dn=${encodeURIComponent(val)}`);
} catch {
params.push(`dn=${val}`);
}
}
// tr (Trackers)
if (rng.bool()) {
const trackers = rng.array(() => {
const proto = rng.oneOf(["udp", "http", "https", "wss", "ws"]);
const val = `${proto}://${rng.mixedString(20)}`;
try {
return encodeURIComponent(val);
} catch {
return val;
}
}, 5);
trackers.forEach(t => params.push(`tr=${t}`));
}
// ws (Web Seeds)
if (rng.bool()) {
const val = "https://" + rng.mixedString(20);
try {
params.push(`ws=${encodeURIComponent(val)}`);
} catch {
params.push(`ws=${val}`);
}
}
// Random params
if (rng.bool()) {
params.push(`${rng.string(5)}=${rng.mixedString(10)}`);
}
magnet += params.join("&");
// Corrupt it
if (Math.random() < 0.2) {
// Inject garbage
const pos = rng.int(0, magnet.length);
magnet = magnet.slice(0, pos) + rng.nastyString() + magnet.slice(pos);
}
return magnet;
};
const rawValue = genMagnet();
const genOptions = () => {
if (rng.bool()) return null;
+18 -1
View File
@@ -24,7 +24,8 @@ async function fuzzTest(iteration) {
"buildReactionEvent",
"buildZapRequestEvent",
"sanitizeAdditionalTags",
"validateEventStructure"
"validateEventStructure",
"setNostrEventSchemaOverrides"
];
const targetName = rng.oneOf(targets);
@@ -115,6 +116,22 @@ async function fuzzTest(iteration) {
inputs = { type, event };
Schemas.validateEventStructure(type, event);
break;
case "setNostrEventSchemaOverrides":
const randomType = rng.oneOf(Object.values(Schemas.NOTE_TYPES));
if (rng.bool()) {
// Circular object
const obj = { foo: "bar" };
obj.self = obj;
inputs = { [randomType]: obj };
} else {
inputs = { [randomType]: genParams() };
}
// If we set overrides, we should also trigger usage of them to see if it crashes later
Schemas.setNostrEventSchemaOverrides(inputs);
// Trigger usage
Schemas.getNostrEventSchema(randomType);
break;
}
return { target: targetName, inputs };
+107 -165
View File
@@ -1,196 +1,138 @@
import WebSocket from 'ws';
import crypto from 'node:crypto';
import './setup-test-env.js';
import { NostrClient } from '../../js/nostr/client.js';
import {
buildVideoPostEvent,
buildViewEvent
} from '../../js/nostrEventSchemas.js';
import * as NostrTools from 'nostr-tools';
import { bytesToHex } from '../../vendor/crypto-helpers.bundle.min.js';
// Polyfills
global.WebSocket = WebSocket;
// Node 19+ has global.crypto. If not, polyfill it.
if (!global.crypto) {
global.crypto = crypto;
}
global.window = {
crypto: global.crypto,
localStorage: {
_store: new Map(),
getItem: (key) => global.window.localStorage._store.get(String(key)) || null,
setItem: (key, value) => global.window.localStorage._store.set(String(key), String(value)),
removeItem: (key) => global.window.localStorage._store.delete(String(key)),
clear: () => global.window.localStorage._store.clear(),
},
location: { href: 'http://localhost' },
};
global.self = global.window;
global.localStorage = global.window.localStorage;
globalThis.localStorage = global.window.localStorage; // Ensure globalThis has it
// Setup global NostrTools for toolkit.js fallback
global.NostrTools = NostrTools;
// Helpers
const bytesToHex = (bytes) => {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};
async function waitForEvent(pool, relays, id, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const event = await pool.get(relays, { ids: [id] });
if (event) return event;
await new Promise(r => setTimeout(r, 1000));
}
return null;
}
async function runTests() {
console.log('Starting Protocol & Interop Tests...');
// Import application modules after polyfills are set
const { NostrClient } = await import('../../js/nostr/client.js');
const { buildVideoPostEvent, buildViewEvent } = await import('../../js/nostrEventSchemas.js');
async function run() {
console.log("Starting Interop Test...");
// 1. Setup Client
const client = new NostrClient();
// Use test relays
const TEST_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
];
client.relays = TEST_RELAYS;
client.readRelays = TEST_RELAYS;
client.writeRelays = TEST_RELAYS;
// Test relays - using reputable public relays
const TEST_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"];
client.relays = [...TEST_RELAYS];
console.log('Client initialized with relays:', TEST_RELAYS);
// 2. Generate Ephemeral Keys
const sk = NostrTools.generateSecretKey();
const skHex = bytesToHex(sk);
const pk = NostrTools.getPublicKey(sk);
console.log('Generated ephemeral identity:', pk);
// 3. Register Signer
await client.registerPrivateKeySigner({
privateKey: skHex,
pubkey: pk,
});
console.log('Signer registered.');
// 4. Initialize Network
await client.init();
console.log('Client connected.');
console.log(`Using relays: ${TEST_RELAYS.join(', ')}`);
try {
// --- Test A: Video Post ---
console.log('\n--- Test A: Video Post ---');
// 2. Init (connects to relays)
await client.init();
// Explicitly use schema builder
const dTagValue = 'interop-test-' + Date.now();
const contentPayload = {
version: 3,
title: 'Interop Test Video ' + Date.now(),
description: 'This is an automated interoperability test event.',
url: 'https://example.com/video.mp4',
magnet: 'magnet:?xt=urn:btih:c9e15763f722f23e98cb6d93612d78af6e827cba&dn=test',
thumbnail: 'https://example.com/thumb.jpg',
mode: 'dev',
videoRootId: dTagValue, // Simple root ID for testing
deleted: false,
isPrivate: false,
isNsfw: false,
isForKids: false,
enableComments: true,
};
// 3. Generate Ephemeral Keys
console.log("Generating ephemeral keys...");
const sk = NostrTools.generateSecretKey();
const skHex = bytesToHex(sk);
const pk = NostrTools.getPublicKey(sk);
console.log(`Ephemeral Pubkey: ${pk}`);
// 4. Register Signer
await client.registerPrivateKeySigner({ privateKey: skHex, pubkey: pk });
console.log("Signer registered.");
// 5. Test A: Video Post
console.log("\n[Test A] Publishing VIDEO_POST...");
const rootId = `test-root-${Date.now()}`;
const videoEvent = buildVideoPostEvent({
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
dTagValue: dTagValue,
content: contentPayload,
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
dTagValue: `interop-test-${Date.now()}`,
content: {
version: 3,
title: "Interop Test Video",
description: "Automated interop test video from Bitvid agent",
videoRootId: rootId,
url: "https://example.com/video.mp4",
mimeType: "video/mp4",
mode: "live",
deleted: false
},
// 's' tag is required by schema (storage pointer)
additionalTags: [['s', `url:https://example.com/video.mp4`]]
});
console.log('Publishing video event built from schema...');
// Use signAndPublishEvent directly
const { signedEvent } = await client.signAndPublishEvent(videoEvent);
console.log('Published event ID:', signedEvent.id);
const publishResult = await client.signAndPublishEvent(videoEvent);
const publishedEvent = publishResult.signedEvent;
if (!publishedEvent || !publishedEvent.id) {
throw new Error("Publishing failed, no event returned");
}
console.log(`Published Video Event ID: ${publishedEvent.id}`);
// Verify
console.log('Fetching event back...');
const fetchedEvent = await client.getEventById(signedEvent.id);
if (fetchedEvent && fetchedEvent.id === signedEvent.id) {
console.log('✅ Video event fetched and verified.');
if (fetchedEvent.title === contentPayload.title) {
console.log('✅ Content matches.');
} else {
console.error('❌ Content mismatch:', fetchedEvent.title, 'vs', contentPayload.title);
}
} else {
console.error('❌ Failed to fetch video event.');
}
console.log("Verifying Video Post...");
const rawRelayEvent = await waitForEvent(client.pool, TEST_RELAYS, publishedEvent.id);
// --- Test B: View Event ---
console.log('\n--- Test B: View Event ---');
// Explicitly use schema builder
if (!rawRelayEvent) throw new Error("Failed to fetch published video event from relays");
if (rawRelayEvent.id !== publishedEvent.id) throw new Error("Fetched event ID mismatch");
console.log("Video Post Verified (Roundtrip).");
// 6. Test B: View Event
console.log("\n[Test B] Publishing VIEW_EVENT...");
const viewEvent = buildViewEvent({
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
pointerTag: ['e', signedEvent.id],
content: 'test-view-interop'
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
pointerValue: publishedEvent.id,
pointerTag: ['e', publishedEvent.id],
content: "Interop test view"
});
console.log('Publishing view event built from schema...');
try {
const { signedEvent: signedView } = await client.signAndPublishEvent(viewEvent);
console.log('✅ View event published.', signedView.id);
} catch (err) {
console.error('❌ View event publish failed:', err);
const viewPublishResult = await client.signAndPublishEvent(viewEvent);
console.log(`Published View Event ID: ${viewPublishResult.signedEvent.id}`);
// Ensure it is visible to subscriber clients (simulate).
const rawViewEvent = await waitForEvent(client.pool, TEST_RELAYS, viewPublishResult.signedEvent.id);
if (!rawViewEvent) throw new Error("Failed to fetch published view event");
console.log("View Event Verified.");
// 7. Test C: Direct Message
console.log("\n[Test C] Testing Direct Message...");
const msg = `Test DM ${Date.now()}`;
const npub = NostrTools.nip19.npubEncode(pk);
console.log(`Sending DM to self (${npub})...`);
const dmResult = await client.sendDirectMessage(npub, msg);
if (!dmResult.ok) throw new Error(`DM Send failed: ${dmResult.error}`);
console.log("DM Sent.");
// Verify decryption using the appropriate decryptor helper
console.log("Verifying DM (waiting 2s)...");
await new Promise(resolve => setTimeout(resolve, 2000));
const dms = await client.listDirectMessages(pk, { limit: 10 });
const found = dms.find(d => d.plaintext === msg);
if (!found) {
console.log("Fetched DMs:", dms.map(d => d.plaintext));
throw new Error("Failed to find sent DM");
}
console.log("DM Verified.");
// --- Test C: Direct Message ---
console.log('\n--- Test C: Direct Message ---');
const recipientSk = NostrTools.generateSecretKey();
const recipientPk = NostrTools.getPublicKey(recipientSk);
const recipientNpub = NostrTools.nip19.npubEncode(recipientPk);
console.log('Generated recipient:', recipientPk);
const dmMessage = 'Hello from Interop Test ' + Date.now();
console.log('Sending DM to recipient...');
// We use client.sendDirectMessage as it encapsulates encryption logic (NIP-04/17)
// which is part of the protocol verification we want to test.
const dmResult = await client.sendDirectMessage(recipientNpub, dmMessage);
if (dmResult.ok) {
console.log('✅ DM sent successfully.');
} else {
console.error('❌ DM send failed:', dmResult);
}
// Verification - list messages
console.log('Listing DMs...');
// Allow some time for propagation
await new Promise(r => setTimeout(r, 2000));
const messages = await client.listDirectMessages();
const found = messages.find(m => m.plaintext === dmMessage);
if (found) {
console.log('✅ DM found and decrypted.');
} else {
console.error('❌ DM not found in list or decryption failed.');
console.log('Messages found:', messages.length);
if (messages.length > 0) {
console.log('First message plain:', messages[0].plaintext);
}
}
console.log("\nAll Interop Tests Passed!");
process.exit(0);
} catch (error) {
console.error('Test failed with error:', error);
console.error("\nTest Failed:", error);
process.exit(1);
} finally {
// Cleanup
console.log('\nCleaning up...');
if (client.pool) {
try {
if (typeof client.pool.close === 'function') {
client.pool.close(client.relays);
}
} catch (e) {
console.warn('Error closing pool:', e);
}
}
process.exit(0);
}
}
runTests();
run();
+40 -10
View File
@@ -1,12 +1,15 @@
import './setup-test-env.js';
import { parseArgs } from 'node:util';
import { spawn } from 'node:child_process';
import { spawn, exec } from 'node:child_process';
import { fileURLToPath } from 'url';
import NodeWebSocket from 'ws';
import * as NostrTools from 'nostr-tools';
import fs from 'node:fs';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import util from 'node:util';
const execAsync = util.promisify(exec);
// Polyfill global NostrTools for app modules
global.NostrTools = NostrTools;
@@ -76,6 +79,7 @@ const metrics = {
sign: [],
},
resourceUsage: [],
relayResourceUsage: [], // { timestamp, cpu, mem }
};
// --- Helpers ---
@@ -96,7 +100,7 @@ async function startRelay() {
// Wait for port
await waitForPort(parseInt(port));
console.log('[Setup] Relay started.');
console.log('[Setup] Relay started (PID: ' + relayProcess.pid + ').');
}
async function waitForPort(port) {
@@ -121,6 +125,20 @@ async function waitForPort(port) {
throw new Error(`Relay did not start on port ${port}`);
}
async function getProcessStats(pid) {
try {
// Output: %CPU %MEM
const { stdout } = await execAsync(`ps -p ${pid} -o %cpu,%mem --no-headers`);
const [cpu, mem] = stdout.trim().split(/\s+/);
return {
cpu: parseFloat(cpu) || 0,
mem: parseFloat(mem) || 0
};
} catch (e) {
return null;
}
}
function createClient() {
const sk = NostrTools.generateSecretKey();
const pk = NostrTools.getPublicKey(sk);
@@ -177,7 +195,6 @@ async function run() {
// If user provided a custom URL, we assume they manage the relay unless it matches our default logic.
// For simplicity, we always try to start the relay if it's on localhost and port matches default or we can detect it's not running.
// But per instructions "Use a local relay", let's always try to start it if it's the default port, or fail if port is taken.
// Actually, let's just try to start it.
await startRelay().catch(e => {
console.log('[Setup] Could not start relay (maybe already running?):', e.message);
});
@@ -195,6 +212,7 @@ async function run() {
// 3. Load Loop
console.log('[Load] Starting load generation...');
console.log('[Load] Requirements: 10% Video Post (multipart), 90% View Event');
const startTime = performance.now();
const endTime = startTime + (config.duration * 1000);
@@ -209,7 +227,7 @@ async function run() {
let lastTime = performance.now();
// Monitoring Loop
const monitorInterval = setInterval(() => {
const monitorInterval = setInterval(async () => {
const now = performance.now();
const cpu = process.cpuUsage();
const elapsedMs = now - lastTime;
@@ -228,11 +246,22 @@ async function run() {
heapUsed: mem.heapUsed,
});
if (relayProcess) {
const stats = await getProcessStats(relayProcess.pid);
if (stats) {
metrics.relayResourceUsage.push({
timestamp: Date.now(),
cpuPercent: stats.cpu,
memPercent: stats.mem
});
}
}
lastCpu = cpu;
lastTime = now;
const p50 = calculatePercentile(metrics.latencies, 50);
console.log(`[Monitor] Sent: ${metrics.sent}, Recv: ${metrics.received}, Errors: ${metrics.errors}, Latency p50: ${p50.toFixed(2)}ms, CPU: ${cpuPercent.toFixed(1)}%`);
console.log(`[Monitor] Sent: ${metrics.sent}, Recv: ${metrics.received}, Errors: ${metrics.errors}, Latency p50: ${p50.toFixed(2)}ms, Runner CPU: ${cpuPercent.toFixed(1)}%`);
}
}, 5000);
@@ -249,7 +278,6 @@ async function run() {
const isHeavy = Math.random() < 0.1;
try {
let event;
const buildStart = performance.now();
const now = Math.floor(Date.now() / 1000);
const hexPk = client.pk;
@@ -315,7 +343,7 @@ async function run() {
} else {
// View Event
event = buildViewEvent({
const event = buildViewEvent({
pubkey: hexPk,
created_at: now,
pointerValue: `load-video-${Math.random()}`,
@@ -388,6 +416,8 @@ function calculatePercentile(data, percentile) {
}
function generateReport() {
const signAvg = metrics.operationTimes.sign.reduce((a, b) => a + b, 0) / metrics.operationTimes.sign.length || 0;
const report = {
timestamp: new Date().toISOString(),
config,
@@ -405,9 +435,10 @@ function generateReport() {
},
operation_times_ms: {
build_avg: metrics.operationTimes.build.reduce((a, b) => a + b, 0) / metrics.operationTimes.build.length || 0,
sign_avg: metrics.operationTimes.sign.reduce((a, b) => a + b, 0) / metrics.operationTimes.sign.length || 0,
sign_avg: signAvg,
},
resource_usage: metrics.resourceUsage,
relay_resource_usage: metrics.relayResourceUsage,
error_breakdown: metrics.errorCounts,
},
bottlenecks: [],
@@ -425,7 +456,6 @@ function generateReport() {
}
// Hot functions proxy / Crypto bottleneck
const signAvg = report.metrics.operation_times_ms.sign_avg;
if (signAvg > 10) {
report.bottlenecks.push(`Signing is slow (${signAvg.toFixed(2)}ms)`);
report.remediation.push('Optimize signing or offload to worker.');
@@ -442,7 +472,7 @@ function generateReport() {
const reportPath = path.join(ARTIFACTS_DIR, filename);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
// Also save as load-report.json
// Also save as load-report.json for convenience
const latestPath = path.join(ARTIFACTS_DIR, 'load-report.json');
fs.copyFileSync(reportPath, latestPath);
+2 -4
View File
@@ -21,8 +21,6 @@ function getOpenPRs() {
return [];
}
if (res.status !== 0) {
// Only log stderr if it's not just "no pull requests found" or similar non-critical output
// But gh usually exits 0 even if empty.
console.error('Error fetching PRs:', res.stderr);
return [];
}
@@ -62,8 +60,8 @@ async function main() {
...process.env,
PR_NUMBER: pr.number.toString(),
GITHUB_BASE_REF: pr.baseRefName,
// Assuming origin is the base repo for simplicity in this agent script
GITHUB_BASE_REPO_URL: '' // automated-pr-review handles missing URL by using origin
// Assuming origin is the base repo or gh pr checkout handled the remote setup.
// automated-pr-review.mjs handles missing GITHUB_BASE_REPO_URL by defaulting to upstream/origin.
};
// Run Review Script
-363
View File
@@ -1,363 +0,0 @@
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
const LOG_FILE = 'pr_review_report.md';
const SENSITIVE_PATTERNS = [
/^js\/nostr\//,
/^js\/storage\//,
/crypto/i,
/nip44/i
];
function log(message) {
console.log(`[ReviewAgent] ${message}`);
}
function runCommand(command, args = [], options = {}) {
log(`> Running: ${command} ${args.join(' ')}`);
return spawnSync(command, args, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
maxBuffer: 10 * 1024 * 1024,
...options
});
}
function checkGhAvailability() {
try {
const res = runCommand('gh', ['--version']);
return res.status === 0;
} catch (e) {
return false;
}
}
function getChangedFiles(baseBranch = 'origin/main') {
try {
const res = runCommand('git', ['diff', '--name-only', `${baseBranch}...HEAD`]);
if (res.status !== 0) {
// Fallback: use local changes
const local = runCommand('git', ['diff', '--name-only', 'HEAD']);
return local.stdout ? local.stdout.trim().split('\n').filter(Boolean) : [];
}
return res.stdout.trim().split('\n').filter(Boolean);
} catch (error) {
log(`Warning: Could not determine changed files. Using local changes if any.`);
return [];
}
}
async function reviewContext(contextName, options) {
const report = [];
report.push(`# Automated PR Review & Suggested Improvements`);
report.push(`**Context:** ${contextName}`);
report.push(`**Date:** ${new Date().toISOString()}`);
report.push('');
// Release Channel Guidance
if (options.baseBranch) {
report.push(`## ℹ️ Release Channel Guidance`);
if (options.baseBranch === 'main') {
report.push('**Target: Main (Production)**');
report.push('- Commits must be atomic.');
report.push('- No destructive migrations.');
report.push('- Feature flags must default to `false`.');
report.push('- Ensure UX and magnet safety guarantees are preserved.');
} else if (options.baseBranch === 'unstable') {
report.push('**Target: Unstable (Experimentation)**');
report.push('- Gate risky behavior behind feature flags (`js/constants.js`).');
report.push('- Document toggle/rollback plans in PR description.');
} else {
report.push(`Target: ${options.baseBranch}`);
report.push('Please refer to `AGENTS.md` for release channel guidance.');
}
report.push('');
}
let failed = false;
try {
// 1. Install dependencies
if (!options.noInstall) {
log('Installing dependencies (npm ci)...');
const ciResult = runCommand('npm', ['ci'], { shell: true });
if (ciResult.status !== 0) {
report.push(`## 🚨 Dependency Installation Failed`);
report.push('`npm ci` failed. Please check `package-lock.json`.');
report.push('```');
report.push(ciResult.stderr ? ciResult.stderr.slice(-1000) : 'Unknown error');
report.push('```');
failed = true;
}
} else {
log('Skipping dependency installation (--no-install).');
}
// 2. Format
if (!failed) {
log('Running formatter...');
runCommand('npm', ['run', 'format'], { shell: true });
const gitStatus = runCommand('git', ['status', '--porcelain']);
const formattedFiles = gitStatus.stdout.split('\n')
.filter(line => line.match(/^ M/)) // Modified files
.map(line => line.substring(3).trim());
if (formattedFiles.length > 0) {
report.push(`## 🎨 Formatting Suggestions`);
report.push(`The following files need formatting:`);
report.push('<ul>');
formattedFiles.forEach(f => report.push(`<li>${f}</li>`));
report.push('</ul>');
report.push('');
// Fast-path suggestion
report.push('**Suggestion:** apply `npm run format` and re-run tests');
if (options.applyFixes) {
try {
log('Applying formatting fixes...');
runCommand('git', ['add', '.']);
// Explicit wording: fix(ai): <short> (agent)
runCommand('git', ['commit', '-m', 'fix(ai): formatting (agent)']);
log('Pushing changes...');
const pushResult = runCommand('git', ['push']);
if (pushResult.status === 0) {
report.push('**Action:** Applied and pushed formatting fixes (agent).');
} else {
log(`Push failed: ${pushResult.stderr}`);
report.push('**Action:** Applied formatting fixes locally, but failed to push.');
report.push(`**Error:** ${pushResult.stderr}`);
if (checkGhAvailability()) {
const newBranch = `fix/ai-formatting-${Date.now()}`;
log(`Attempting fallback: creating new branch ${newBranch}...`);
runCommand('git', ['checkout', '-b', newBranch]);
const pushNew = runCommand('git', ['push', '-u', 'origin', newBranch]);
if (pushNew.status === 0) {
log('Creating fallback PR...');
const prRes = spawnSync('gh', ['pr', 'create', '--title', 'fix(ai): formatting (agent)', '--body', 'Automated formatting fixes.', '--fill'], { encoding: 'utf-8' });
if (prRes.status === 0) {
report.push(`**Action:** Created follow-up PR: ${prRes.stdout.trim()}`);
} else {
report.push(`**Error:** Failed to create follow-up PR: ${prRes.stderr}`);
}
}
}
}
} catch (e) {
log(`Error applying fixes: ${e.message}`);
report.push(`**Error:** Failed to apply formatting fixes: ${e.message}`);
}
} else {
// If we're not applying fixes, we should revert the formatting changes so they don't affect subsequent steps?
// However, keeping them might be good for running tests on clean code?
// But if tests rely on formatting (unlikely), it matters.
// Usually we revert so the working tree matches the PR state for further analysis.
runCommand('git', ['checkout', '.'], { stdio: 'ignore' });
}
} else {
report.push(`## ✅ Formatting`);
report.push(`No formatting issues found.`);
}
}
// 3. Lint
if (!failed) {
log('Running linter...');
const lintResult = runCommand('npm', ['run', 'lint'], { shell: true });
if (lintResult.status !== 0) {
report.push(`## ⚠️ Linter Warnings/Errors`);
report.push('`npm run lint` reported issues:');
// Extract lines with "Error" or "Warning"
const lines = (lintResult.stdout + '\n' + lintResult.stderr).split('\n');
const issues = lines.filter(l => l.match(/error|warning/i)).slice(0, 20); // Limit to 20
report.push('<details><summary>Show Lint Output</summary>');
report.push('');
report.push('```');
if (issues.length > 0) {
report.push(issues.join('\n'));
if (lines.length > issues.length) report.push('...');
} else {
report.push(lintResult.stdout.slice(-1000));
report.push(lintResult.stderr.slice(-1000));
}
report.push('```');
report.push('</details>');
if (lintResult.stdout && lintResult.stdout.includes('Design token lint failed')) {
report.push('**Note:** Design token violations detected. Please check `css/tokens.css`.');
}
} else {
report.push(`## ✅ Linter`);
report.push(`All lint checks passed.`);
}
}
// 4. Unit Tests
if (!failed) {
log('Running unit tests...');
const testResult = runCommand('npm', ['run', 'test:unit'], { shell: true, timeout: 300000 });
if (testResult.error && testResult.error.code === 'ETIMEDOUT') {
report.push(`## ❌ Test Timeout`);
report.push('`npm run test:unit` timed out after 5 minutes.');
} else if (testResult.status !== 0) {
report.push(`## ❌ Test Failures`);
report.push('`npm run test:unit` failed.');
// Analyze stdout for failing test file
// Expecting "→ Running tests/..." lines.
const output = testResult.stdout || '';
const runningLines = output.match(/→ Running (.*)/g);
let lastTest = 'Unknown';
if (runningLines && runningLines.length > 0) {
lastTest = runningLines[runningLines.length - 1].replace('→ Running ', '').trim();
}
report.push(`**Possible culprit:** \`${lastTest}\``);
report.push('Suggested area to inspect: check the stack trace below for the failing assertion.');
report.push('<details><summary>Show Test Output</summary>');
report.push('');
report.push('```');
// Capture context around the failure?
// Usually the end of stdout has the failure.
report.push(output.slice(-3000));
report.push((testResult.stderr || '').slice(-3000));
report.push('```');
report.push('</details>');
} else {
report.push(`## ✅ Tests`);
report.push(`Unit tests passed.`);
}
}
// 5. Sensitive Files
const changedFiles = getChangedFiles();
const sensitiveChanges = changedFiles.filter(file =>
SENSITIVE_PATTERNS.some(pattern => file.match(pattern))
);
if (sensitiveChanges.length > 0) {
report.push(`## 🔒 Security Review Required`);
report.push(`This PR modifies sensitive files.`);
report.push(`**Flags:** \`requires-security-review\`, \`requires-protocol-review\``);
report.push('<ul>');
sensitiveChanges.forEach(f => report.push(`<li>${f}</li>`));
report.push('</ul>');
}
return report.join('\n');
} catch (error) {
log(`Error reviewing ${contextName}: ${error.message}`);
return `Error reviewing ${contextName}: ${error.message}`;
}
}
async function main() {
const args = process.argv.slice(2);
const options = {
noInstall: args.includes('--no-install'),
applyFixes: args.includes('--apply-fixes'),
all: args.includes('--all'),
pr: args.indexOf('--pr') > -1 ? args[args.indexOf('--pr') + 1] : null,
};
const branchArg = args.find(a => !a.startsWith('--') && args.indexOf(a) !== args.indexOf('--pr') + 1);
const ghAvailable = checkGhAvailability();
if (options.all) {
if (!ghAvailable) {
console.error('Error: `gh` CLI not found. Cannot list open PRs.');
process.exit(1);
}
log('Fetching open PRs...');
try {
const prsJson = runCommand('gh', ['pr', 'list', '--json', 'number,headRefName,baseRefName,url']);
if (prsJson.status !== 0) throw new Error(prsJson.stderr);
const prs = JSON.parse(prsJson.stdout);
log(`Found ${prs.length} open PRs.`);
for (const pr of prs) {
log(`Checking out PR #${pr.number} (${pr.headRefName})...`);
try {
runCommand('gh', ['pr', 'checkout', pr.number]);
// Pass baseBranch from PR details
options.baseBranch = pr.baseRefName;
const report = await reviewContext(`PR #${pr.number}`, options);
const tempFile = `pr_review_${pr.number}.md`;
fs.writeFileSync(tempFile, report);
log(`Posting comment to PR #${pr.number}...`);
runCommand('gh', ['pr', 'comment', pr.number, '-F', tempFile]);
fs.unlinkSync(tempFile);
} catch (e) {
log(`Failed to process PR #${pr.number}: ${e.message}`);
}
}
} catch (e) {
console.error('Error listing/processing PRs:', e);
process.exit(1);
}
} else if (options.pr) {
if (!ghAvailable) {
console.error('Error: `gh` CLI not found.');
process.exit(1);
}
log(`Checking out PR #${options.pr}...`);
try {
// Fetch base branch first
const viewRes = runCommand('gh', ['pr', 'view', options.pr, '--json', 'baseRefName']);
if (viewRes.status === 0) {
const prDetails = JSON.parse(viewRes.stdout);
options.baseBranch = prDetails.baseRefName;
}
const res = runCommand('gh', ['pr', 'checkout', options.pr]);
if (res.status !== 0) throw new Error(res.stderr);
const report = await reviewContext(`PR #${options.pr}`, options);
console.log(report);
fs.writeFileSync(LOG_FILE, report);
log(`Report saved to ${LOG_FILE}`);
} catch (e) {
console.error(e);
process.exit(1);
}
} else {
let originalBranch;
if (branchArg) {
try {
originalBranch = runCommand('git', ['branch', '--show-current']).stdout.trim();
const res = runCommand('git', ['checkout', branchArg]);
if (res.status !== 0) throw new Error(`Could not checkout ${branchArg}`);
} catch(e) {
console.error(e.message);
process.exit(1);
}
}
const currentBranch = runCommand('git', ['branch', '--show-current']).stdout.trim();
// Logic for local run: can we guess the target?
// Usually local run is 'against' something.
// But we can just skip or default.
const report = await reviewContext(currentBranch, options);
console.log('\n--- REPORT ---\n');
console.log(report);
fs.writeFileSync(LOG_FILE, report);
log(`Report saved to ${LOG_FILE}`);
if (originalBranch) {
runCommand('git', ['checkout', originalBranch]);
}
}
}
main();
+266 -265
View File
@@ -1,285 +1,286 @@
import './setup-test-env.js';
import "fake-indexeddb/auto";
import { startRelay } from './simple-relay.mjs';
import { NostrClient } from '../../js/nostr/client.js';
import { buildVideoPostEvent } from '../../js/nostrEventSchemas.js';
import { decryptDM } from '../../js/dmDecryptor.js';
import { indexedDB, IDBKeyRange } from "fake-indexeddb";
import { WebSocket } from 'ws';
import * as NostrTools from 'nostr-tools';
import { chromium } from 'playwright';
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import http from 'http';
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'url';
// --- Polyfills ---
if (!global.indexedDB) {
global.indexedDB = indexedDB;
}
if (!global.IDBKeyRange) {
global.IDBKeyRange = IDBKeyRange;
}
if (!global.NostrTools) {
global.NostrTools = NostrTools;
}
// setup-test-env.js handles WebSocket, crypto, localStorage, window, self, navigator
// --- Imports from Codebase ---
// Note: We need to use relative paths from scripts/agent/
import { NostrClient } from '../../js/nostr/client.js';
import { decryptDM } from '../../js/dmDecryptor.js';
import { startRelay } from './load-test-relay.mjs';
// --- Configuration ---
const ARTIFACTS_DIR = 'artifacts';
const LOG_FILE = path.join(ARTIFACTS_DIR, `smoke-${new Date().toISOString().split('T')[0].replace(/-/g, '')}.log`);
const REPORT_FILE = path.join(ARTIFACTS_DIR, `smoke-summary-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
const UI_SCREENSHOT = path.join(ARTIFACTS_DIR, 'smoke-ui.png');
if (!fs.existsSync(ARTIFACTS_DIR)) {
fs.mkdirSync(ARTIFACTS_DIR);
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
}
// Logging helper
function log(msg, type = 'INFO') {
const timestamp = new Date().toISOString();
const line = `[${timestamp}] [${type}] ${msg}`;
console.log(line);
fs.appendFileSync(LOG_FILE, line + '\n');
const RELAY_PORT = 8890;
const RELAY_URL = `ws://localhost:${RELAY_PORT}`;
const HTTP_PORT = 8000;
const HTTP_URL = `http://localhost:${HTTP_PORT}`;
// --- Logging ---
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD
const logFile = path.join(ARTIFACTS_DIR, `smoke-${dateStr}.log`);
const logStream = fs.createWriteStream(logFile, { flags: 'a' });
function log(msg, ...args) {
const timestamp = new Date().toISOString();
const message = `[${timestamp}] ${msg} ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : a).join(' ')}`;
console.log(message);
logStream.write(message + '\n');
}
function bytesToHex(bytes) {
return Buffer.from(bytes).toString('hex');
function error(msg, ...args) {
const timestamp = new Date().toISOString();
const message = `[${timestamp}] [ERROR] ${msg} ${args.map(a => (a instanceof Error ? a.stack : (typeof a === 'object' ? JSON.stringify(a) : a))).join(' ')}`;
console.error(message);
logStream.write(message + '\n');
}
// Helper to wait for server
function waitForServer(url, timeoutMs = 10000) {
const start = Date.now();
return new Promise((resolve, reject) => {
const check = () => {
const req = http.get(url, (res) => {
if (res.statusCode === 200) {
resolve(true);
} else {
retry();
}
});
req.on('error', retry);
req.end();
};
// --- Helpers ---
async function waitForServer(url, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url);
if (res.ok) return true;
} catch (e) {
// ignore
}
await new Promise(r => setTimeout(r, 200));
}
return false;
}
const retry = () => {
if (Date.now() - start > timeoutMs) {
reject(new Error(`Server at ${url} did not respond within ${timeoutMs}ms`));
} else {
setTimeout(check, 500);
}
};
// --- Main Test Flow ---
async function run() {
let relayServer = null;
let httpServer = null;
let client = null;
let success = true;
check();
try {
log('--- Starting Smoke Test ---');
// 1. Start Relay
log(`Starting local relay on port ${RELAY_PORT}...`);
relayServer = startRelay(RELAY_PORT);
// Wait a bit for relay to be ready (startRelay is synchronous in setup but async in effect?)
// It uses WebSocketServer which starts immediately.
await new Promise(r => setTimeout(r, 500));
log('Relay started.');
// 2. Start HTTP Server
log(`Starting HTTP server (npx serve) on port ${HTTP_PORT}...`);
httpServer = spawn('npx', ['serve', '-p', String(HTTP_PORT)], {
stdio: 'ignore', // or 'inherit' to see output
detached: false
});
}
async function runSmokeTest() {
log("Starting Smoke Test...");
let relay;
let serverProcess;
let exitCode = 0;
const summary = {
timestamp: new Date().toISOString(),
steps: {}
const serverReady = await waitForServer(HTTP_URL);
if (!serverReady) {
throw new Error(`HTTP server did not start on ${HTTP_URL} within timeout.`);
}
log('HTTP server is ready.');
// 3. Init NostrClient
log('Initializing NostrClient...');
client = new NostrClient();
client.relays = [RELAY_URL];
client.readRelays = [RELAY_URL];
client.writeRelays = [RELAY_URL];
// Override dev logger to avoid pollution if needed, or let it log
// We can rely on standard logging.
await client.init();
log(`NostrClient initialized and connected to ${RELAY_URL}`);
// 4. Login (Ephemeral)
log('Generating ephemeral keys for Sender...');
const senderSk = NostrTools.generateSecretKey();
const senderSkHex = Buffer.from(senderSk).toString('hex');
const senderPk = NostrTools.getPublicKey(senderSk);
log(`Sender Pubkey: ${senderPk}`);
await client.registerPrivateKeySigner({ privateKey: senderSkHex, persist: false });
client.pubkey = senderPk; // Manually set logged-in user
if (client.pubkey !== senderPk) {
throw new Error(`Client pubkey mismatch. Expected ${senderPk}, got ${client.pubkey}`);
}
log('Sender logged in.');
// 5. Publish Video
log('Publishing test video...');
const videoPayload = {
title: `Smoke Test Video ${Date.now()}`,
description: 'Smoke test description',
magnet: `magnet:?xt=urn:btih:${Math.random().toString(16).slice(2)}`,
url: `${HTTP_URL}/test.mp4`, // Fake URL
thumbnail: `${HTTP_URL}/thumb.jpg`,
mode: 'live',
videoRootId: `smoke-${Date.now()}`,
isNsfw: false,
isForKids: true
};
try {
// --- Step 1: Start Infrastructure ---
log("Step 1: Starting Infrastructure");
// Start Relay
const RELAY_PORT = 8899;
relay = startRelay(RELAY_PORT);
const RELAY_URL = `ws://localhost:${RELAY_PORT}`;
log(`Relay started at ${RELAY_URL}`);
summary.steps.relay = "SUCCESS";
// Start Web Server
log("Starting web server (npx serve)...");
serverProcess = spawn('npx', ['serve', '-p', '3000'], {
stdio: 'ignore', // Suppress serve output to avoid clutter, or pipe to log if needed
shell: true,
detached: false // Ensure we can kill it
});
await waitForServer('http://localhost:3000');
log("Web server is up at http://localhost:3000");
summary.steps.server = "SUCCESS";
// --- Step 2: Protocol Smoke Test (Headless Client) ---
log("Step 2: Protocol Smoke Test");
const client = new NostrClient();
client.relays = [RELAY_URL];
client.readRelays = [RELAY_URL];
client.writeRelays = [RELAY_URL];
log("Initializing NostrClient...");
await client.init();
log("NostrClient initialized and connected.");
// Generate Identity
const secretKey = NostrTools.generateSecretKey();
const privateKey = bytesToHex(secretKey);
const pubkey = NostrTools.getPublicKey(secretKey);
log(`Generated Identity: ${pubkey}`);
await client.registerPrivateKeySigner({
privateKey: privateKey,
pubkey: pubkey,
persist: false
});
log("Identity registered.");
// A. Video Flow
log("--- Subtest: Video Flow ---");
const videoEvent = buildVideoPostEvent({
pubkey: pubkey,
created_at: Math.floor(Date.now() / 1000),
dTagValue: `smoke-${Date.now()}`,
content: {
version: 3,
title: "Smoke Test Video",
description: "Smoke testing video publish",
url: "https://example.com/smoke.mp4",
magnet: "magnet:?xt=urn:btih:1234567890abcdef&dn=smoke",
isForKids: false,
isNsfw: false,
enableComments: true,
videoRootId: `root-${Date.now()}`,
mode: "live"
}
});
const { signedEvent: publishedVideo } = await client.signAndPublishEvent(videoEvent);
log(`Video Published: ${publishedVideo.id}`);
await new Promise(r => setTimeout(r, 100)); // Allow propagation
const fetchedVideo = await client.getEventById(publishedVideo.id);
if (!fetchedVideo) throw new Error("Failed to fetch published video");
if (fetchedVideo.title !== "Smoke Test Video") throw new Error("Fetched video title mismatch");
log("Video verified.");
summary.steps.video_flow = "SUCCESS";
// B. View Flow
log("--- Subtest: View Flow ---");
const videoPointer = {
type: 'e',
value: publishedVideo.id,
relay: RELAY_URL
};
const viewResult = await client.publishViewEvent(videoPointer);
log(`View Event Published: ${viewResult.event ? viewResult.event.id : 'undefined'}`);
summary.steps.view_flow = "SUCCESS";
// C. DM Flow
log("--- Subtest: DM Flow ---");
const dmMessage = "Smoke Test DM Payload";
const dmResult = await client.sendDirectMessage(NostrTools.nip19.npubEncode(pubkey), dmMessage);
if (!dmResult.ok) throw new Error(`DM Send Failed: ${dmResult.error}`);
log("DM Sent.");
await new Promise(r => setTimeout(r, 500));
// C1. Verify via listDirectMessages (High level)
const dms = await client.listDirectMessages(pubkey);
const foundDm = dms.find(dm => dm.plaintext === dmMessage);
if (!foundDm) {
// Debug info
log(`DMs found: ${dms.length}`);
if (dms.length) log(`First DM plaintext: ${dms[0].plaintext}`);
throw new Error("DM not found via listDirectMessages");
}
log("DM verified via listDirectMessages.");
// C2. Verify via raw event + decryptDM (Low level)
// We find the event ID from the high level list or list pool directly.
// Let's use the ID from the found message.
const dmEventId = foundDm.event.id;
const rawDmEvent = await client.fetchRawEventById(dmEventId);
if (!rawDmEvent) throw new Error("Failed to fetch raw DM event");
log("Decrypting raw DM event manually...");
const decryptContext = {
actorPubkey: pubkey,
decryptors: [
{
scheme: 'nip04',
decrypt: async (targetPubkey, ciphertext) => {
// NIP-04: decrypt(privKey, pubKey, ciphertext)
// Here, since we are receiver, we use our privKey.
// The targetPubkey passed by decryptDM for legacy DMs (kind 4) is the sender's pubkey.
return await NostrTools.nip04.decrypt(privateKey, targetPubkey, ciphertext);
},
priority: 1
}
]
};
const manualDecryptResult = await decryptDM(rawDmEvent, decryptContext);
if (!manualDecryptResult.ok) {
log(`Manual decryption failed: ${JSON.stringify(manualDecryptResult.errors)}`, 'ERROR');
throw new Error("Manual decryption failed");
}
if (manualDecryptResult.plaintext !== dmMessage) {
throw new Error(`Manual decryption mismatch. Got: ${manualDecryptResult.plaintext}`);
}
log("DM verified via manual decryptDM.");
summary.steps.dm_flow = "SUCCESS";
// --- Step 3: UI Smoke Test (Headless Browser) ---
log("Step 3: UI Smoke Test");
log("Launching browser...");
const browser = await chromium.launch();
const page = await browser.newPage();
try {
await page.goto('http://localhost:3000', { waitUntil: 'networkidle' });
const title = await page.title();
log(`Page Title: ${title}`);
// Take screenshot
await page.screenshot({ path: UI_SCREENSHOT });
log(`Screenshot saved to ${UI_SCREENSHOT}`);
summary.steps.ui_test = "SUCCESS";
} catch (e) {
log(`UI Test Failed: ${e.message}`, 'ERROR');
summary.steps.ui_test = "FAILED";
throw e;
} finally {
await browser.close();
}
summary.status = "PASS";
log("Smoke Test Completed Successfully.");
} catch (error) {
log(`Smoke Test Failed: ${error.stack}`, 'ERROR');
summary.status = "failure";
summary.error = error.message;
exitCode = 1;
} finally {
// --- Write Report ---
try {
if (fs.existsSync(LOG_FILE)) {
const logs = fs.readFileSync(LOG_FILE, 'utf8').split('\n').filter(Boolean);
summary.logs = logs;
}
fs.writeFileSync(REPORT_FILE, JSON.stringify(summary, null, 2));
log(`Summary written to ${REPORT_FILE}`);
} catch (e) {
log(`Failed to write report: ${e.message}`, 'ERROR');
}
// --- Cleanup ---
log("Cleaning up...");
try {
if (relay && relay.close) {
// Don't await indefinitely
await Promise.race([
relay.close(),
new Promise(r => setTimeout(r, 2000))
]);
}
} catch (e) {
log(`Relay close error: ${e.message}`, 'WARN');
}
if (serverProcess) {
serverProcess.kill();
}
process.exit(exitCode);
const publishedEvent = await client.publishVideo(videoPayload, senderPk);
if (!publishedEvent || !publishedEvent.id) {
throw new Error('Failed to publish video event.');
}
log(`Video published. Event ID: ${publishedEvent.id}`);
// Verify Read Back
log('Verifying video can be fetched...');
// We use subscribeVideos for main feed, or getEventById.
// Let's use getEventById for direct verification.
const fetched = await client.getEventById(publishedEvent.id);
if (!fetched) {
throw new Error('Failed to fetch published video.');
}
if (fetched.title !== videoPayload.title) {
throw new Error(`Fetched video title mismatch. Expected "${videoPayload.title}", got "${fetched.title}"`);
}
log('Video fetched successfully.');
// 6. DM Flow
log('Testing DM Flow...');
const recipientSk = NostrTools.generateSecretKey();
const recipientSkHex = Buffer.from(recipientSk).toString('hex');
const recipientPk = NostrTools.getPublicKey(recipientSk);
const recipientNpub = NostrTools.nip19.npubEncode(recipientPk);
log(`Recipient Pubkey: ${recipientPk}`);
const dmMessage = `Smoke Test Message ${Date.now()}`;
// Send DM
const sendResult = await client.sendDirectMessage(recipientNpub, dmMessage);
if (!sendResult.ok) {
throw new Error(`Failed to send DM: ${sendResult.error}`);
}
log('DM sent successfully.');
// Verify Decryption (Simulate Recipient)
log('Verifying DM decryption (Recipient view)...');
// We need to find the DM event.
// Since we don't have a second client syncing, we can manually query the pool/relay.
const filter = {
kinds: [4, 1059], // 4 for legacy, 1059 for NIP-17
'#p': [recipientPk],
limit: 1
};
// wait a moment for propagation
await new Promise(r => setTimeout(r, 200));
const events = await client.pool.list([RELAY_URL], [filter]);
if (events.length === 0) {
throw new Error('Recipient could not find DM event on relay.');
}
const dmEvent = events[0];
log(`Found DM event kind ${dmEvent.kind} id ${dmEvent.id}`);
// Construct decryption context for decryptDM
// We need a decryptor for the recipient.
// Since we have the recipient's private key, we can use NostrTools.nip04 or nip44.
const decryptors = [];
// NIP-04 Decryptor
decryptors.push({
scheme: 'nip04',
decrypt: async (remotePubkey, ciphertext) => {
return await NostrTools.nip04.decrypt(recipientSkHex, remotePubkey, ciphertext);
}
});
// NIP-44 Decryptor
decryptors.push({
scheme: 'nip44',
supportsGiftWrap: true,
decrypt: (remotePubkey, ciphertext) => {
const conversationKey = NostrTools.nip44.v2.utils.getConversationKey(recipientSk, remotePubkey);
return NostrTools.nip44.v2.decrypt(ciphertext, conversationKey);
}
});
const context = {
actorPubkey: recipientPk,
decryptors
};
const decryptResult = await decryptDM(dmEvent, context);
if (!decryptResult.ok) {
throw new Error(`DM decryption failed: ${JSON.stringify(decryptResult.errors)}`);
}
if (decryptResult.plaintext !== dmMessage) {
throw new Error(`DM plaintext mismatch. Expected "${dmMessage}", got "${decryptResult.plaintext}"`);
}
log('DM verified successfully.');
} catch (err) {
success = false;
error('Smoke Test Failed!', err);
} finally {
// Report
const summary = {
timestamp: new Date().toISOString(),
success,
logFile
};
const summaryFile = path.join(ARTIFACTS_DIR, `smoke-summary-${dateStr}.json`);
fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2));
log(`Summary saved to ${summaryFile}`);
// Teardown
log('Teardown...');
if (client) {
// client.logout(); // Cleans up
// Close pool connections?
// client.pool.close(client.relays); // SimplePool doesn't have explicit close usually?
// NostrTools SimplePool usually manages connections.
// We can just rely on process exit.
}
if (relayServer) {
relayServer.close();
log('Relay stopped.');
}
if (httpServer) {
httpServer.kill();
log('HTTP server stopped.');
}
log('Done.');
process.exit(success ? 0 : 1);
}
}
runSmokeTest();
run();
+34 -16
View File
@@ -5,9 +5,7 @@ import crypto from 'node:crypto';
// Configuration
const ARTIFACTS_DIR = 'artifacts';
const REPORTS_DIR = 'ai/reports';
// TAP formatted logs
const UNIT_TEST_LOGS = ['test_unit.log', 'test_unit_debug.log', 'test_output.log'];
// Generic logs (subset of original list, excluding TAP logs)
// Generic logs
const GENERIC_LOG_FILES = ['server.log', 'serve.log', 'python_server.log', 'npm_output.log'];
// Regex patterns for PII sanitization
@@ -59,13 +57,25 @@ function suggestOwner(stack) {
// Collectors
// -----------------------------------------------------------------------------
function collectUnitTests(logFile = 'test_output.log') {
const errors = [];
// Check root and artifacts dir
const potentialPaths = [logFile, path.join(ARTIFACTS_DIR, logFile)];
const filepath = potentialPaths.find(p => fs.existsSync(p));
function findUnitTestLogs() {
const logs = [];
if (fs.existsSync('.')) {
const rootFiles = fs.readdirSync('.').filter(f => f.match(/^test_unit.*\.log$/) || f === 'test_output.log');
logs.push(...rootFiles);
}
if (!filepath) return errors;
if (fs.existsSync(ARTIFACTS_DIR)) {
const artifactFiles = fs.readdirSync(ARTIFACTS_DIR)
.filter(f => f.match(/^test_unit.*\.log$/) || f === 'test_output.log')
.map(f => path.join(ARTIFACTS_DIR, f));
logs.push(...artifactFiles);
}
return [...new Set(logs)]; // Unique paths
}
function collectUnitTests(filepath) {
const errors = [];
if (!fs.existsSync(filepath)) return errors;
const content = fs.readFileSync(filepath, 'utf8');
const lines = content.split('\n');
@@ -82,7 +92,7 @@ function collectUnitTests(logFile = 'test_output.log') {
}
const title = line.replace(/^not ok \d+ - /, '').trim();
currentError = {
source: `unit-test:${logFile}`,
source: `unit-test:${path.basename(filepath)}`,
title: sanitize(title),
details: [],
severity: 'High'
@@ -327,7 +337,8 @@ function collectAgentLogs() {
for (const file of files) {
// Avoid reparsing if we have specific logic
if (UNIT_TEST_LOGS.includes(file)) continue;
if (file.match(/^test_unit.*\.log$/) || file === 'test_output.log') continue;
if (GENERIC_LOG_FILES.includes(file)) continue;
errors.push(...collectGenericLogs(path.join(ARTIFACTS_DIR, file), `agent-log:${file}`));
}
@@ -455,7 +466,8 @@ async function main() {
const errors = [];
// Unit Tests (TAP logs)
for (const logFile of UNIT_TEST_LOGS) {
const unitTestLogs = findUnitTestLogs();
for (const logFile of unitTestLogs) {
const unitErrors = collectUnitTests(logFile);
if (unitErrors.length > 0) {
console.log(`Collected ${unitErrors.length} unit test errors from ${logFile}.`);
@@ -465,10 +477,16 @@ async function main() {
// Generic Logs
for (const logFile of GENERIC_LOG_FILES) {
const genericErrors = collectGenericLogs(logFile, logFile);
if (genericErrors.length > 0) {
console.log(`Collected ${genericErrors.length} errors from ${logFile}.`);
errors.push(...genericErrors);
// Check root
if (fs.existsSync(logFile)) {
const genericErrors = collectGenericLogs(logFile, logFile);
if (genericErrors.length > 0) errors.push(...genericErrors);
}
// Check artifacts
const artifactPath = path.join(ARTIFACTS_DIR, logFile);
if (fs.existsSync(artifactPath)) {
const genericErrors = collectGenericLogs(artifactPath, logFile);
if (genericErrors.length > 0) errors.push(...genericErrors);
}
}
+7
View File
@@ -1,3 +1,10 @@
/**
* Event Schema Validation Tool
*
* Scans the codebase for event builder usage and verifies that the output
* conforms to the defined schemas in `js/nostrEventSchemas.js`.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
+16 -5
View File
@@ -6,7 +6,10 @@ import path from 'node:path';
const SENSITIVE_PATHS = [
/^js\/nostr\//,
/^js\/storage\//,
/^js\/auth\//,
/^js\/dmDecryptor\.js$/,
/^js\/magnetUtils\.js$/,
/^js\/nostr\/adapters\/nip07Adapter\.js$/,
/^js\/services\/storageService\.js$/,
/^js\/services\/attachmentService\.js$/,
/^js\/services\/hashtagPreferencesService\.js$/,
@@ -86,6 +89,13 @@ function checkReleaseChannel(changedFiles) {
warnings.push(`⚠️ **Release Channel Warning**: This PR targets \`main\` but modifies the following critical configuration files: ${modifiedCriticalFiles.map(f => `\`${f}\``).join(', ')}.`);
warnings.push(`> **Guidance:** \`main\` is the production track. Ensure feature flags in \`js/constants.js\` are set to safe defaults (usually \`false\`) and \`config/instance-config.js\` has production-appropriate settings (e.g. \`IS_DEV_MODE\`).`);
}
} else if (baseRef === 'unstable') {
const criticalFiles = ['js/constants.js'];
const modifiedCriticalFiles = changedFiles.filter(f => criticalFiles.includes(f));
if (modifiedCriticalFiles.length > 0) {
warnings.push(`ℹ️ **Release Channel Info**: This PR targets \`unstable\`.`);
warnings.push(`> **Guidance:** \`unstable\` is the experimentation lane. If you are adding risky behavior, gate it behind feature flags in \`js/constants.js\` and document the toggle/rollback plan. See \`AGENTS.md\` for details.`);
}
}
return warnings;
}
@@ -235,13 +245,14 @@ async function main() {
console.error('Error posting comment:', e);
}
} else {
console.log('Not in CI or missing tokens. Printing report to stdout:');
console.log('---------------------------------------------------');
console.log(commentBody);
console.log('---------------------------------------------------');
}
// Always print the report to stdout so it's visible in logs (especially if posting fails or for debugging)
console.log('---------------------------------------------------');
console.log('PR Review Report:');
console.log(commentBody);
console.log('---------------------------------------------------');
if (hasFailures) {
process.exit(1);
}
+41 -34
View File
@@ -8,54 +8,61 @@ import { spawnSync } from "node:child_process";
const SEARCH_PATTERN = '(?<!&|[\\w])#[0-9a-fA-F]{3,8}\\b';
// Allow-list tokens and vector logos so brand assets can keep their baked colors.
// These are translated to git pathspec exclusions.
const IGNORED_GLOBS = [
'!css/tokens.css',
'!**/*.svg',
'!**/node_modules/**',
'!.git/**',
'!**/*.min.js',
'!**/*.map',
'!css/tailwind.generated.css',
'!dist/**',
'!build/**',
'!vendor/**',
'!js/utils/qrcode.js',
'!**/dist/**',
'!REMEDIATION_REPORT.md',
'!CHANGELOG.md',
'!scripts/daily-design-system-audit.mjs',
'!config/instance-config.js',
'!config/validate-config.js',
'!js/embed.js',
'!tests/unit/embed-accent.test.mjs',
'!tests/visual/embed-layout.spec.ts',
'!ai/**'
'css/tokens.css',
'**/*.svg',
'**/node_modules/**',
'.git/**',
'**/*.min.js',
'**/*.map',
'css/tailwind.generated.css',
'dist/**',
'build/**',
'vendor/**',
'js/utils/qrcode.js',
'**/dist/**',
'REMEDIATION_REPORT.md',
'CHANGELOG.md',
'scripts/daily-design-system-audit.mjs',
'config/instance-config.js',
'config/validate-config.js',
'js/embed.js',
'tests/unit/embed-accent.test.mjs',
'tests/visual/embed-layout.spec.ts',
'ai/**',
'**/*.ai'
];
const rgArgs = [
'--color=never',
'--no-heading',
const gitArgs = [
'grep',
'-n',
'-P' // Enable PCRE2 for lookbehind support
'-I', // Ignore binary files
'-P', // Enable PCRE2 for lookbehind support
SEARCH_PATTERN,
'--',
'.'
];
// Add exclusions
for (const glob of IGNORED_GLOBS) {
rgArgs.push('--glob', glob);
gitArgs.push(`:(exclude)${glob}`);
}
rgArgs.push('--regexp', SEARCH_PATTERN, '.');
const result = spawnSync('rg', rgArgs, { encoding: 'utf8' });
// We rely on 'git' being available and 'git grep' supporting -P.
// Typically available in most CI/dev environments.
const result = spawnSync('git', gitArgs, { encoding: 'utf8' });
if (result.error) {
console.error('Failed to execute ripgrep:', result.error.message);
console.error('Failed to execute git grep:', result.error.message);
process.exit(2);
}
// ripgrep exits with:
// 0 when matches are found,
// 1 when no matches are found,
// git grep exits with:
// 0 when matches are found (failure for us),
// 1 when no matches are found (success for us),
// 2+ on errors.
if (result.status === 0 && result.stdout.trim()) {
console.error('Hex colors detected outside tokens or SVG assets:');
console.error(result.stdout.trimEnd());
@@ -64,7 +71,7 @@ if (result.status === 0 && result.stdout.trim()) {
}
if (result.status && result.status !== 1) {
console.error('ripgrep reported an error while scanning for hex values.');
console.error('git grep reported an error while scanning for hex values.');
if (result.stderr) {
console.error(result.stderr.trimEnd());
}
+69 -2
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { writeFileSync, readFileSync } from "node:fs";
const REPORT_FILE = "REMEDIATION_REPORT.md";
const SHOULD_FIX = process.argv.includes("--fix");
const CHECKS = [
{ name: "CSS", command: "npm", args: ["run", "lint:css"] },
@@ -162,6 +163,67 @@ function generateReport(violations) {
return report;
}
function applyAutoFixes(violations) {
console.log("Attempting to apply auto-fixes...");
let fixesApplied = 0;
const violationsByFile = {};
for (const category in violations) {
for (const v of violations[category]) {
const filePath = v.file.startsWith("./") ? v.file.slice(2) : v.file;
if (!violationsByFile[filePath]) violationsByFile[filePath] = [];
violationsByFile[filePath].push({ ...v, category });
}
}
for (const file of Object.keys(violationsByFile)) {
try {
let content = readFileSync(file, "utf8");
let originalContent = content;
for (const v of violationsByFile[file]) {
if (v.category === "Hex Colors") {
content = content.replaceAll("#000000", "var(--color-black)");
content = content.replaceAll("#ffffff", "var(--color-white)");
}
if (v.category === "Tailwind Palette") {
// Simple semantic mappings
if (v.value.includes("red-500")) {
const newValue = v.value.replace("red-500", "status-danger");
content = content.replaceAll(v.value, newValue);
}
if (v.value.includes("green-500")) {
const newValue = v.value.replace("green-500", "status-success");
content = content.replaceAll(v.value, newValue);
}
if (v.value.includes("blue-500")) {
const newValue = v.value.replace("blue-500", "status-info");
content = content.replaceAll(v.value, newValue);
}
if (v.value.includes("yellow-500")) {
const newValue = v.value.replace("yellow-500", "status-warning");
content = content.replaceAll(v.value, newValue);
}
}
}
if (content !== originalContent) {
writeFileSync(file, content);
fixesApplied++;
console.log(`Fixed violations in ${file}`);
}
} catch (e) {
console.error(`Failed to fix ${file}: ${e.message}`);
}
}
if (fixesApplied === 0) {
console.log("No auto-fixes available for current violations.");
}
return fixesApplied > 0;
}
async function main() {
for (const check of CHECKS) {
const { success, output } = runCheck(check);
@@ -171,11 +233,16 @@ async function main() {
}
}
const totalViolations = Object.values(VIOLATIONS).reduce((acc, list) => acc + list.length, 0);
if (totalViolations > 0 && SHOULD_FIX) {
applyAutoFixes(VIOLATIONS);
}
const report = generateReport(VIOLATIONS);
writeFileSync(REPORT_FILE, report);
console.log(`Report generated at ${REPORT_FILE}`);
const totalViolations = Object.values(VIOLATIONS).reduce((acc, list) => acc + list.length, 0);
process.exit(totalViolations > 0 ? 1 : 0);
}
-261
View File
@@ -1,261 +0,0 @@
> [email protected] test:unit
> node scripts/run-unit-tests.mjs
→ Running tests/app-batch-fetch-profiles.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[batchFetchProfiles] Failed to fetch profiles from relay wss://fail.example: Error: relay timed out
at Object.list (file:///app/tests/app-batch-fetch-profiles.test.mjs:80:31)
at file:///app/js/utils/profileBatchFetcher.js:115:10
at Array.map (<anonymous>)
at batchFetchProfilesFromRelays (file:///app/js/utils/profileBatchFetcher.js:112:75)
at TestContext.<anonymous> (file:///app/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)
TAP version 13
# Subtest: batchFetchProfiles handles fast and failing relays
ok 1 - batchFetchProfiles handles fast and failing relays
---
duration_ms: 3.78278
type: 'test'
...
1..1
# tests 1
# suites 0
# pass 1
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 13.584413
→ Running tests/app/channel-profile-moderation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
[feedEngine/moderation] metadata.moderation updated {
stage: 'moderation',
videoId: 'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv',
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 2,
reportType: 'nudity',
hidden: false,
hideReason: null,
hideBypass: null
}
TAP version 13
# Subtest: renderChannelVideosFromList decorates videos before storing
ok 1 - renderChannelVideosFromList decorates videos before storing
---
duration_ms: 337.791267
type: 'test'
...
# Subtest: renderChannelVideosFromList applies moderation blur without existing metadata
ok 2 - renderChannelVideosFromList applies moderation blur without existing metadata
---
duration_ms: 36.841508
type: 'test'
...
# Subtest: applyChannelVisualBlur blurs banner and avatar when viewer mutes author
ok 3 - applyChannelVisualBlur blurs banner and avatar when viewer mutes author
---
duration_ms: 13.907618
type: 'test'
...
# Subtest: moderation override clears channel blur via event wiring
ok 4 - moderation override clears channel blur via event wiring
---
duration_ms: 45.839128
type: 'test'
...
# Subtest: channel header moderation badge reflects blur state and override actions
ok 5 - channel header moderation badge reflects blur state and override actions
---
duration_ms: 40.896846
type: 'test'
...
# Subtest: channel video cards update moderation state in place when summary changes
ok 6 - channel video cards update moderation state in place when summary changes
---
duration_ms: 24.287284
type: 'test'
...
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 513.25686
→ Running tests/app/hash-change-handler.test.mjs
TAP version 13
# Subtest: handleHashChange defaults to for-you when logged in
ok 1 - handleHashChange defaults to for-you when logged in
---
duration_ms: 109.930556
type: 'test'
...
# Subtest: handleHashChange defaults to most-recent-videos when logged out
ok 2 - handleHashChange defaults to most-recent-videos when logged out
---
duration_ms: 46.456977
type: 'test'
...
# Subtest: handleHashChange respects explicit view regardless of login state
ok 3 - handleHashChange respects explicit view regardless of login state
---
duration_ms: 22.102845
type: 'test'
...
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 197.979843
→ Running tests/app/hydrate-sidebar-navigation.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: hydrateSidebarNavigation reveals chrome controls for authenticated viewers
ok 1 - hydrateSidebarNavigation reveals chrome controls for authenticated viewers
---
duration_ms: 146.888279
type: 'test'
...
# Subtest: hydrateSidebarNavigation hides chrome controls for logged-out viewers
ok 2 - hydrateSidebarNavigation hides chrome controls for logged-out viewers
---
duration_ms: 31.202921
type: 'test'
...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 200.708074
→ Running tests/app/is-user-logged-in.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
TAP version 13
# Subtest: isUserLoggedIn returns false when no user pubkey is set
ok 1 - isUserLoggedIn returns false when no user pubkey is set
---
duration_ms: 1.50666
type: 'test'
...
# Subtest: isUserLoggedIn treats extension logins as authenticated
ok 2 - isUserLoggedIn treats extension logins as authenticated
---
duration_ms: 0.33944
type: 'test'
...
# Subtest: isUserLoggedIn guards against mismatched nostrClient state
ok 3 - isUserLoggedIn guards against mismatched nostrClient state
---
duration_ms: 0.318067
type: 'test'
...
# Subtest: isUserLoggedIn ignores anonymous session actor mismatches
ok 4 - isUserLoggedIn ignores anonymous session actor mismatches
---
duration_ms: 0.269897
type: 'test'
...
# Subtest: isUserLoggedIn rejects mismatched managed session actors
ok 5 - isUserLoggedIn rejects mismatched managed session actors
---
duration_ms: 0.324422
type: 'test'
...
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 31.272497
→ Running tests/app/moderation-overrides.test.mjs
[bitvid] Initialized nostr nip04 helpers.
[bitvid] Initialized nostr nip44 helpers.
DEBUG: not logged in
TAP version 13
# Subtest: handleModerationOverride decorates stored and current videos then refreshes UI
ok 1 - handleModerationOverride decorates stored and current videos then refreshes UI
---
duration_ms: 219.062003
type: 'test'
...
# Subtest: handleModerationOverride resumes deferred playback
ok 2 - handleModerationOverride resumes deferred playback
---
duration_ms: 1.243424
type: 'test'
...
# Subtest: handleModerationBlock requests a block, clears overrides, and refreshes hidden state
not ok 3 - handleModerationBlock requests a block, clears overrides, and refreshes hidden state
---
duration_ms: 4.738977
type: 'test'
location: '/app/tests/app/moderation-overrides.test.mjs:112:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
false !== true
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: true
actual: false
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///app/tests/app/moderation-overrides.test.mjs:228:10)
async Test.run (node:internal/test_runner/test:1054:7)
async Test.processPendingSubtests (node:internal/test_runner/test:744:7)
...
# Subtest: handleModerationBlock returns false when viewer is logged out
ok 4 - handleModerationBlock returns false when viewer is logged out
---
duration_ms: 1.927479
type: 'test'
...
1..4
# tests 4
# suites 0
# pass 3
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 258.173403
file:///app/scripts/run-unit-tests.mjs:68
reject(new Error(`${relativePath} failed with exit code ${code}`));
^
Error: tests/app/moderation-overrides.test.mjs failed with exit code 1
at ChildProcess.<anonymous> (file:///app/scripts/run-unit-tests.mjs:68:16)
at ChildProcess.emit (node:events:519:28)
at maybeClose (node:internal/child_process:1101:16)
at ChildProcess._handle.onexit (node:internal/child_process:304:5)
Node.js v22.21.1
+9 -1
View File
@@ -272,6 +272,14 @@ nostrClient.pool = {
throw new Error("list failure for authors");
}
if (listFailureMode === "throw-community") {
const fullSourceTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_SOURCES}`;
const fullPrefix = `${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}`;
if (dTag === fullSourceTag || dTag.startsWith(fullPrefix)) {
throw new Error("list failure for community");
}
}
const key = `${authorKey}::${dTag}`;
const fallbackKey = `*::${dTag}`;
const event = listEventRegistry.get(key) || listEventRegistry.get(fallbackKey);
@@ -451,7 +459,7 @@ assert.deepEqual(
setListFailureMode(null);
resetListRegistry();
setListFailureMode("throw-authors");
setListFailureMode("throw-community");
registerListEvent({
dTag: editorsDTag,
+25 -41
View File
@@ -112,7 +112,21 @@ test("handleModerationOverride resumes deferred playback", async (t) => {
test("handleModerationBlock requests a block, clears overrides, and refreshes hidden state", async (t) => {
withMockedNostrTools(t);
const app = await createModerationAppHarness();
const ensureLoadedMock = mock.fn(async () => {});
let blockedState = false;
const addBlockMock = mock.fn(async () => {
blockedState = true;
return { ok: true };
});
const isBlockedMock = mock.fn(() => blockedState);
const mockUserBlocks = {
ensureLoaded: ensureLoadedMock,
addBlock: addBlockMock,
isBlocked: isBlockedMock,
};
const app = await createModerationAppHarness({ userBlocks: mockUserBlocks });
app.getActiveModerationThresholds = () => ({
autoplayBlockThreshold: Number.POSITIVE_INFINITY,
blurThreshold: Number.POSITIVE_INFINITY,
@@ -179,28 +193,6 @@ test("handleModerationBlock requests a block, clears overrides, and refreshes hi
},
};
const originalEnsureLoaded = userBlocks.ensureLoaded;
const originalAddBlock = userBlocks.addBlock;
const originalIsBlocked = userBlocks.isBlocked;
const ensureLoadedMock = mock.fn(async () => {});
let blockedState = false;
const addBlockMock = mock.fn(async () => {
blockedState = true;
return { ok: true };
});
const isBlockedMock = mock.fn(() => blockedState);
userBlocks.ensureLoaded = ensureLoadedMock;
userBlocks.addBlock = addBlockMock;
userBlocks.isBlocked = isBlockedMock;
t.after(() => {
userBlocks.ensureLoaded = originalEnsureLoaded;
userBlocks.addBlock = originalAddBlock;
userBlocks.isBlocked = originalIsBlocked;
});
await app.handleModerationOverride({ video: incomingVideo, card });
assert.equal(storedVideo.moderation.viewerOverride?.showAnyway, true);
@@ -237,29 +229,21 @@ test("handleModerationBlock requests a block, clears overrides, and refreshes hi
test("handleModerationBlock returns false when viewer is logged out", async (t) => {
withMockedNostrTools(t);
const app = await createModerationAppHarness();
app.pubkey = "f".repeat(64);
app.isUserLoggedIn = () => false;
app.showStatus = mock.fn(() => {});
app.showError = mock.fn(() => {});
const originalEnsureLoaded = userBlocks.ensureLoaded;
const originalAddBlock = userBlocks.addBlock;
const originalIsBlocked = userBlocks.isBlocked;
const ensureLoadedMock = mock.fn(async () => {});
const addBlockMock = mock.fn(async () => ({ ok: true }));
const isBlockedMock = mock.fn(() => false);
userBlocks.ensureLoaded = ensureLoadedMock;
userBlocks.addBlock = addBlockMock;
userBlocks.isBlocked = isBlockedMock;
const mockUserBlocks = {
ensureLoaded: ensureLoadedMock,
addBlock: addBlockMock,
isBlocked: isBlockedMock,
};
t.after(() => {
userBlocks.ensureLoaded = originalEnsureLoaded;
userBlocks.addBlock = originalAddBlock;
userBlocks.isBlocked = originalIsBlocked;
});
const app = await createModerationAppHarness({ userBlocks: mockUserBlocks });
app.pubkey = "f".repeat(64);
app.isUserLoggedIn = () => false;
app.showStatus = mock.fn(() => {});
app.showError = mock.fn(() => {});
const video = {
id: "c".repeat(64),
+106
View File
@@ -10,6 +10,7 @@ const [{ nostrClient }, { setActiveSigner, clearActiveSigner }] =
import("../js/nostrClientFacade.js"),
import("../js/nostr/client.js"),
]);
const { relayManager } = await import("../js/relayManager.js");
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
@@ -22,6 +23,7 @@ const originalRelays = Array.isArray(nostrClient.relays)
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalRelayEntries = relayManager.getEntries();
const originalWindowNostr = window.nostr;
function restoreNostrClient() {
@@ -34,9 +36,14 @@ function restoreNostrClient() {
: originalWriteRelays;
}
function restoreRelayManager() {
relayManager.setEntries(originalRelayEntries, { allowEmpty: true, updateClient: false });
}
test.beforeEach(async () => {
hashtagPreferences.reset();
restoreNostrClient();
restoreRelayManager();
window.nostr = originalWindowNostr;
// Setup default mock signer to prevent auto-save errors
@@ -68,6 +75,7 @@ test.beforeEach(async () => {
test.after(() => {
restoreNostrClient();
restoreRelayManager();
window.nostr = originalWindowNostr;
clearActiveSigner();
});
@@ -116,6 +124,7 @@ test(
};
nostrClient.relays = ["wss://relay.one"];
nostrClient.writeRelays = ["wss://relay.one"];
relayManager.setEntries([{ url: "wss://relay.one", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
@@ -156,6 +165,7 @@ test(
};
nostrClient.relays = ["wss://relay.two"];
nostrClient.writeRelays = ["wss://relay.two"];
relayManager.setEntries([{ url: "wss://relay.two", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
@@ -201,6 +211,7 @@ test(
};
nostrClient.relays = ["wss://relay.retry"];
nostrClient.writeRelays = ["wss://relay.retry"];
relayManager.setEntries([{ url: "wss://relay.retry", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
@@ -259,6 +270,7 @@ test(
};
nostrClient.relays = ["wss://relay.publish"];
nostrClient.writeRelays = ["wss://relay.publish"];
relayManager.setEntries([{ url: "wss://relay.publish", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
hashtagPreferences.addInterest("TagA");
@@ -331,6 +343,7 @@ test(
};
nostrClient.relays = ["wss://relay.window"];
nostrClient.writeRelays = ["wss://relay.window"];
relayManager.setEntries([{ url: "wss://relay.window", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
hashtagPreferences.addInterest("WindowTag");
@@ -420,6 +433,7 @@ test(
};
nostrClient.relays = ["wss://relay.tie"];
nostrClient.writeRelays = ["wss://relay.tie"];
relayManager.setEntries([{ url: "wss://relay.tie", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
@@ -449,3 +463,95 @@ test(
},
);
test(
"load falls back to window.nostr when active signer is missing",
{ concurrency: false },
async (t) => {
const pubkey = "abcdef".repeat(10) + "1234";
// Ensure no active signer (override beforeEach)
clearActiveSigner();
// Mock window.nostr
window.nostr = {
nip04: {
decrypt: async (pk, ciphertext) => {
if (pk !== pubkey) throw new Error("Wrong pubkey");
if (ciphertext === "window-encrypted") {
return JSON.stringify({
version: 1,
interests: ["WindowSuccess"],
disinterests: []
});
}
throw new Error("Decrypt failed");
}
}
};
// Mock pool list to return an event
nostrClient.pool = {
list: async () => [{
id: "evt1",
created_at: 1000,
pubkey,
content: "window-encrypted",
tags: [["encrypted", "nip04"]]
}]
};
relayManager.setEntries([{ url: "wss://relay.fallback", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
assert.deepEqual(hashtagPreferences.getInterests(), ["windowsuccess"]);
}
);
test(
"load falls back to window.nostr when active signer lacks decrypt capabilities",
{ concurrency: false },
async (t) => {
const pubkey = "abcdef".repeat(10) + "5678";
// Set a signer that can sign but NOT decrypt (override beforeEach)
setActiveSigner({
signEvent: async (e) => ({ ...e, id: "fake" }),
// No nip04Decrypt or nip44Decrypt
});
// Mock window.nostr
window.nostr = {
nip04: {
decrypt: async (pk, ciphertext) => {
if (pk !== pubkey) throw new Error("Wrong pubkey");
if (ciphertext === "window-encrypted-2") {
return JSON.stringify({
version: 1,
interests: ["SignerFallback"],
disinterests: []
});
}
throw new Error("Decrypt failed");
}
}
};
// Mock pool list to return an event
nostrClient.pool = {
list: async () => [{
id: "evt2",
created_at: 1000,
pubkey,
content: "window-encrypted-2",
tags: [["encrypted", "nip04"]]
}]
};
relayManager.setEntries([{ url: "wss://relay.fallback", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
assert.deepEqual(hashtagPreferences.getInterests(), ["signerfallback"]);
}
);
+12 -2
View File
@@ -199,7 +199,7 @@ async function getApplicationClass() {
return cachedApplicationClass;
}
export async function createModerationAppHarness() {
export async function createModerationAppHarness(options = {}) {
const Application = await getApplicationClass();
const app = Object.create(Application.prototype);
@@ -222,7 +222,7 @@ export async function createModerationAppHarness() {
app.moderationActionController = new ModerationActionController({
services: {
userBlocks,
userBlocks: options.userBlocks || userBlocks,
setModerationOverride,
clearModerationOverride,
},
@@ -272,6 +272,16 @@ export async function createModerationAppHarness() {
app.getActiveModerationThresholds =
Application.prototype.getActiveModerationThresholds;
if (options.userBlocks) {
app.isAuthorBlocked = function (pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
if (normalized && options.userBlocks.isBlocked(normalized)) {
return true;
}
return Application.prototype.isAuthorBlocked.call(this, pubkey);
};
}
app.defaultModerationSettings = getDefaultModerationSettings();
app.moderationSettings = { ...app.defaultModerationSettings };
+136
View File
@@ -0,0 +1,136 @@
import test from "node:test";
import assert from "node:assert/strict";
import { Nip46RequestQueue, NIP46_PRIORITY } from "../js/nostr/nip46Queue.js";
test("Nip46RequestQueue: processes tasks in FIFO order for same priority", async () => {
const queue = new Nip46RequestQueue({ minDelayMs: 0 });
const results = [];
const task1 = () => {
results.push(1);
return Promise.resolve(1);
};
const task2 = () => {
results.push(2);
return Promise.resolve(2);
};
const task3 = () => {
results.push(3);
return Promise.resolve(3);
};
await Promise.all([
queue.enqueue(task1),
queue.enqueue(task2),
queue.enqueue(task3),
]);
assert.deepEqual(results, [1, 2, 3]);
});
test("Nip46RequestQueue: respects priority levels", async () => {
const queue = new Nip46RequestQueue({ minDelayMs: 0 });
const results = [];
// Enqueue tasks. The queue starts processing immediately, so we need to
// delay the start slightly or ensure they are added before the first one finishes?
// Actually, since tasks are async, the first one starts running.
// But subsequent ones will be queued.
// To ensure they are all in the queue before processing picks the next one,
// we can make the first task slow.
let releaseFirst;
const firstTaskPromise = new Promise(resolve => releaseFirst = resolve);
const task1 = async () => {
await firstTaskPromise;
results.push("low");
return "low";
};
const task2 = async () => {
results.push("normal");
return "normal";
};
const task3 = async () => {
results.push("high");
return "high";
};
const p1 = queue.enqueue(task1, NIP46_PRIORITY.LOW);
// Give a tiny moment for p1 to start running (and block on firstTaskPromise)
await new Promise(r => setTimeout(r, 10));
const p2 = queue.enqueue(task2, NIP46_PRIORITY.NORMAL);
const p3 = queue.enqueue(task3, NIP46_PRIORITY.HIGH);
releaseFirst();
await Promise.all([p1, p2, p3]);
// task1 started first. While it was running, task2 and task3 were added.
// task3 has higher priority than task2, so it should run after task1 finishes.
assert.deepEqual(results, ["low", "high", "normal"]);
});
test("Nip46RequestQueue: respects minDelayMs", async () => {
const minDelayMs = 50;
const queue = new Nip46RequestQueue({ minDelayMs });
const start = Date.now();
const times = [];
const task = async () => {
times.push(Date.now());
};
await Promise.all([
queue.enqueue(task),
queue.enqueue(task),
queue.enqueue(task),
]);
assert.equal(times.length, 3);
const diff1 = times[1] - times[0];
const diff2 = times[2] - times[1];
// Allow some margin for execution time
assert.ok(diff1 >= minDelayMs - 10, `Delay ${diff1}ms should be >= ${minDelayMs}ms`);
assert.ok(diff2 >= minDelayMs - 10, `Delay ${diff2}ms should be >= ${minDelayMs}ms`);
});
test("Nip46RequestQueue: clear() rejects pending tasks", async () => {
const queue = new Nip46RequestQueue({ minDelayMs: 100 });
const results = [];
const errors = [];
// Start a task that blocks the queue for a bit
queue.enqueue(async () => {
await new Promise(r => setTimeout(r, 50));
results.push("finished");
});
// Add tasks that will be pending
const p2 = queue.enqueue(async () => {
results.push("should not run");
}).catch(e => errors.push(e.message));
const p3 = queue.enqueue(async () => {
results.push("should not run either");
}).catch(e => errors.push(e.message));
// Clear the queue immediately
queue.clear(new Error("Queue cleared manually"));
await Promise.allSettled([p2, p3]);
// wait for the first task to finish naturally (it was already running)
await new Promise(r => setTimeout(r, 100));
assert.ok(results.includes("finished"));
assert.ok(!results.includes("should not run"));
assert.equal(errors.length, 2);
assert.equal(errors[0], "Queue cleared manually");
});
@@ -4,6 +4,13 @@ import test from "node:test";
import "./test-helpers/setup-localstorage.mjs";
import { AccessControl } from "../js/accessControl.js";
import { NostrService } from "../js/services/nostrService.js";
import moderationService from "../js/services/moderationService.js";
// Stub moderation service methods that trigger background timers
if (moderationService) {
moderationService.scheduleTrustedMuteSubscriptionRefresh = () => {};
moderationService.refreshTrustedMuteSubscriptions = async () => {};
}
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
+17 -7
View File
@@ -25,7 +25,11 @@ test("LinkPreviewService", async (t) => {
globalThis.fetch = originalFetch;
if (service && service.db) {
service.db.close();
service.db = null;
}
// Yield to allow connection close events to propagate
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase("bitvid-link-previews");
request.onsuccess = () => resolve();
@@ -91,16 +95,22 @@ test("LinkPreviewService", async (t) => {
await t.test("respects TTL", async () => {
// Set a preview that is expired
const expiredService = new LinkPreviewService({ ttlMs: -1000 });
// Initialize DB first to ensure we write to the same place
await expiredService.init();
try {
// Initialize DB first to ensure we write to the same place
await expiredService.init();
await expiredService.setCachedPreview("https://expired.com", { title: "Expired" });
await expiredService.setCachedPreview("https://expired.com", { title: "Expired" });
const cached = await expiredService.getCachedPreview("https://expired.com");
assert.equal(cached, null);
// Ensure time advances since TTL is clamped to 0
await new Promise((r) => setTimeout(r, 10));
if (expiredService.db) {
expiredService.db.close();
const cached = await expiredService.getCachedPreview("https://expired.com");
assert.equal(cached, null);
} finally {
if (expiredService.db) {
expiredService.db.close();
expiredService.db = null;
}
}
});
@@ -0,0 +1,203 @@
import test from "node:test";
import assert from "node:assert/strict";
import ModerationActionController from "../../js/services/moderationActionController.js";
test("ModerationActionController: handles moderation override", async () => {
const overrides = [];
const events = [];
const mockServices = {
setModerationOverride: (descriptor, opts) => overrides.push({ descriptor, opts }),
clearModerationOverride: () => {},
userBlocks: {},
};
const mockSelectors = {
getVideoById: (id) => ({ id, moderation: { hidden: true } }),
getCurrentVideo: () => ({ id: "video1", moderation: { hidden: true } }),
};
const mockUi = {
refreshCardModerationUi: () => {},
dispatchModerationEvent: (name, detail) => events.push({ name, detail }),
};
const mockActions = {
decorateVideoModeration: (video) => { video.decorated = true; },
resumePlayback: () => {},
};
const mockAuth = {
normalizePubkey: (k) => k,
};
const controller = new ModerationActionController({
services: mockServices,
selectors: mockSelectors,
ui: mockUi,
actions: mockActions,
auth: mockAuth,
});
const video = { id: "video1", pubkey: "author1" };
const result = controller.handleOverride({ video });
assert.equal(result, true);
assert.equal(overrides.length, 1);
assert.equal(overrides[0].descriptor.eventId, "video1");
assert.equal(overrides[0].descriptor.authorPubkey, "author1");
assert.equal(events.length, 1);
assert.equal(events[0].name, "video:moderation-override");
assert.equal(events[0].detail.video.id, "video1");
});
test("ModerationActionController: blocks user and updates state", async () => {
const blocks = [];
const events = [];
const errors = [];
const mockServices = {
userBlocks: {
ensureLoaded: async () => {},
isBlocked: () => false,
addBlock: async (target, viewer) => {
blocks.push({ target, viewer });
return { already: false };
},
},
clearModerationOverride: () => {},
};
const mockAuth = {
isLoggedIn: () => true,
getViewerPubkey: () => "viewer1",
normalizePubkey: (k) => k,
};
const mockUi = {
refreshCardModerationUi: () => {},
dispatchModerationEvent: (name, detail) => events.push({ name, detail }),
};
const mockActions = {
showStatus: () => {},
showError: (msg) => errors.push(msg),
refreshVideos: async () => {},
decorateVideoModeration: () => {},
};
const controller = new ModerationActionController({
services: mockServices,
auth: mockAuth,
ui: mockUi,
actions: mockActions,
});
const video = { id: "video1", pubkey: "spammer" };
const result = await controller.handleBlock({ video });
assert.equal(result, true);
assert.equal(blocks.length, 1);
assert.equal(blocks[0].target, "spammer");
assert.equal(blocks[0].viewer, "viewer1");
// Should trigger block and hide events
assert.ok(events.find(e => e.name === "video:moderation-block"));
assert.ok(events.find(e => e.name === "video:moderation-hide"));
assert.equal(errors.length, 0);
});
test("ModerationActionController: prevents blocking self", async () => {
const errors = [];
const mockAuth = {
isLoggedIn: () => true,
getViewerPubkey: () => "me",
normalizePubkey: (k) => k,
};
const mockServices = {
userBlocks: { ensureLoaded: async () => {} },
};
const mockActions = {
showError: (msg) => errors.push(msg),
};
const controller = new ModerationActionController({
services: mockServices,
auth: mockAuth,
actions: mockActions,
});
const video = { id: "video1", pubkey: "me" };
const result = await controller.handleBlock({ video });
assert.equal(result, false);
assert.ok(errors[0].includes("cannot block yourself"));
});
test("ModerationActionController: requires login to block", async () => {
const statuses = [];
const mockAuth = {
isLoggedIn: () => false,
};
const mockServices = {
userBlocks: {},
};
const mockActions = {
showStatus: (msg) => statuses.push(msg),
};
const controller = new ModerationActionController({
services: mockServices,
auth: mockAuth,
actions: mockActions,
});
const video = { id: "video1", pubkey: "someone" };
const result = await controller.handleBlock({ video });
assert.equal(result, false);
assert.ok(statuses[0].includes("Log in"));
});
test("ModerationActionController: handles hide action", async () => {
const events = [];
const mockServices = {
clearModerationOverride: () => {},
};
const mockSelectors = {
getVideoById: (id) => ({ id }),
getCurrentVideo: () => null,
};
const mockUi = {
refreshCardModerationUi: () => {},
dispatchModerationEvent: (name, detail) => events.push({ name, detail }),
};
const mockActions = {
decorateVideoModeration: () => {},
};
const controller = new ModerationActionController({
services: mockServices,
selectors: mockSelectors,
ui: mockUi,
actions: mockActions,
});
const video = { id: "video1" };
const result = controller.handleHide({ video });
assert.equal(result, true);
assert.equal(events.length, 1);
assert.equal(events[0].name, "video:moderation-hide");
});
+5
View File
@@ -11,6 +11,7 @@ import nostrService from "../js/services/nostrService.js";
import moderationService from "../js/services/moderationService.js";
import { getNostrEventSchema, NOTE_TYPES } from "../js/nostrEventSchemas.js";
import { profileCache } from "../js/state/profileCache.js";
import { relayManager } from "../js/relayManager.js";
test("loadSubscriptions aggregates relay results when one rejects", async () => {
const SubscriptionsManager = subscriptions.constructor;
@@ -39,6 +40,9 @@ test("loadSubscriptions aggregates relay results when one rejects", async () =>
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
const eventsByRelay = {
"wss://relay-a.example": [
{
@@ -120,6 +124,7 @@ test("loadSubscriptions aggregates relay results when one rejects", async () =>
);
}
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pool = originalPool;
+7 -7
View File
@@ -25,7 +25,7 @@ test("renderTagPillStrip builds buttons with labels and icons", () => {
assert.ok(button.classList.contains("pill"));
assert.ok(button.classList.contains("video-tag-pill"));
assert.ok(button.classList.contains("focus-ring"));
assert.equal(button.dataset.tag, tag);
assert.equal(button.dataset.tag, `#${tag}`);
assert.equal(button.title, `#${tag}`);
assert.equal(button.dataset.preferenceState, "neutral");
assert.equal(button.hasAttribute("data-variant"), false);
@@ -50,7 +50,7 @@ test("renderTagPillStrip applies preference state styling", () => {
document,
tags: ["nostr", "video"],
getTagState(tag) {
return tag === "nostr" ? "interest" : "disinterest";
return tag === "#nostr" ? "interest" : "disinterest";
},
});
@@ -76,7 +76,7 @@ test("renderTagPillStrip wires the activation callback", () => {
buttons[0].click();
assert.equal(activations.length, 1);
assert.equal(activations[0].tag, "nostr");
assert.equal(activations[0].tag, "#nostr");
assert.equal(activations[0].button, buttons[0]);
assert(activations[0].event instanceof document.defaultView.Event);
});
@@ -97,7 +97,7 @@ test("updateTagPillStrip replaces buttons and rewires handlers", () => {
document.body.append(root);
initialButtons[0].click();
assert.deepEqual(firstActivations, ["nostr"]);
assert.deepEqual(firstActivations, ["#nostr"]);
const { buttons: updatedButtons } = updateTagPillStrip({
root,
@@ -110,7 +110,7 @@ test("updateTagPillStrip replaces buttons and rewires handlers", () => {
});
assert.equal(root.querySelectorAll("button").length, 1);
assert.equal(updatedButtons[0].dataset.tag, "video");
assert.equal(updatedButtons[0].dataset.tag, "#video");
const updatedIcon = updatedButtons[0].querySelector(".video-tag-pill__icon svg");
assert(updatedIcon, "updated button should contain the svg icon");
@@ -119,8 +119,8 @@ test("updateTagPillStrip replaces buttons and rewires handlers", () => {
// Ensure the old handler is removed by dispatching a click on the detached button.
initialButtons[0].dispatchEvent(new document.defaultView.Event("click", { bubbles: true }));
assert.deepEqual(firstActivations, ["nostr"]);
assert.deepEqual(firstActivations, ["#nostr"]);
updatedButtons[0].click();
assert.deepEqual(secondActivations, [{ tag: "video", button: updatedButtons[0] }]);
assert.deepEqual(secondActivations, [{ tag: "#video", button: updatedButtons[0] }]);
});
+80 -73
View File
@@ -1,4 +1,4 @@
import { test, describe, it, before, after } from "node:test";
import { test, describe, it, before, after, beforeEach } from "node:test";
import assert from "node:assert";
import { createUiDom } from "./helpers/jsdom-test-helpers.mjs";
@@ -16,6 +16,66 @@ describe("UploadModal Integration", () => {
isMock: true
};
// Updated Mock HTML matching refactored component
const mockHtml = `
<div id="uploadModal" class="hidden">
<form id="unifiedUploadForm">
<button id="btn-mode-upload"></button>
<button id="btn-mode-external"></button>
<div id="section-source-upload">
<button id="btn-storage-settings"></button>
<!-- Storage Settings Container -->
<div id="section-storage-settings" class="hidden">
<!-- Summary View -->
<div id="storage-summary-view" class="hidden">
<span id="storage-lock-status">Locked 🔒</span>
<button id="btn-storage-unlock" class="hidden">Unlock</button>
<button id="btn-manage-storage">Manage</button>
<span id="summary-provider">--</span>
<span id="summary-bucket">--</span>
<span id="summary-url-style">--</span>
<span id="summary-copy"></span>
</div>
<!-- Empty View -->
<div id="storage-empty-view" class="hidden">
<button id="btn-configure-storage">Configure Storage</button>
</div>
</div>
<div id="upload-status-text"></div>
<div id="upload-percent-text"></div>
<progress id="input-progress"></progress>
</div>
<div id="section-source-external"></div>
<!-- Inputs -->
<input id="input-title" />
<textarea id="input-description"></textarea>
<input id="input-thumbnail" />
<input id="input-thumbnail-file" />
<input id="input-file" />
<input id="input-url" />
<input id="input-magnet" />
<input id="check-nsfw" type="checkbox" />
<input id="check-kids" type="checkbox" />
<input id="check-comments" type="checkbox" />
<input id="check-summary-unlock" type="checkbox" />
<button id="btn-advanced-toggle"></button>
<button id="btn-thumbnail-file"></button>
<div id="section-advanced"></div>
<button id="btn-submit"></button>
<div id="submit-status"></div>
</form>
<button id="closeUploadModal"></button>
</div>
`;
before(async () => {
dom = createUiDom();
global.window = dom.window;
@@ -29,6 +89,16 @@ describe("UploadModal Integration", () => {
global.HTMLButtonElement = dom.window.HTMLButtonElement;
global.HTMLDivElement = dom.window.HTMLDivElement;
global.fetch = async (url) => {
if (url.includes("upload-modal.html")) {
return {
ok: true,
text: async () => mockHtml
};
}
return { ok: false };
};
// Load UploadModal class
const module = await import("../../js/ui/components/UploadModal.js");
UploadModal = module.UploadModal;
@@ -38,6 +108,13 @@ describe("UploadModal Integration", () => {
if (dom) dom.cleanup();
});
beforeEach(() => {
// Ensure clean DOM state
if (global.document && global.document.body) {
global.document.body.innerHTML = '';
}
});
it("should detect default R2 connection and show summary when loaded and unlocked", async () => {
// Setup Mock Data
const pubkey = "pk1";
@@ -67,76 +144,6 @@ describe("UploadModal Integration", () => {
setGlobalModalState: () => {},
});
// Updated Mock HTML matching refactored component
const mockHtml = `
<div id="uploadModal" class="hidden">
<form id="unifiedUploadForm">
<button id="btn-mode-upload"></button>
<button id="btn-mode-external"></button>
<div id="section-source-upload">
<button id="btn-storage-settings"></button>
<!-- Storage Settings Container -->
<div id="section-storage-settings" class="hidden">
<!-- Summary View -->
<div id="storage-summary-view" class="hidden">
<span id="storage-lock-status">Locked 🔒</span>
<button id="btn-storage-unlock" class="hidden">Unlock</button>
<button id="btn-manage-storage">Manage</button>
<span id="summary-provider">--</span>
<span id="summary-bucket">--</span>
<span id="summary-url-style">--</span>
<span id="summary-copy"></span>
</div>
<!-- Empty View -->
<div id="storage-empty-view" class="hidden">
<button id="btn-configure-storage">Configure Storage</button>
</div>
</div>
<div id="upload-status-text"></div>
<div id="upload-percent-text"></div>
<progress id="input-progress"></progress>
</div>
<div id="section-source-external"></div>
<!-- Inputs -->
<input id="input-title" />
<textarea id="input-description"></textarea>
<input id="input-thumbnail" />
<input id="input-thumbnail-file" />
<input id="input-file" />
<input id="input-url" />
<input id="input-magnet" />
<input id="check-nsfw" type="checkbox" />
<input id="check-kids" type="checkbox" />
<input id="check-comments" type="checkbox" />
<input id="check-summary-unlock" type="checkbox" />
<button id="btn-advanced-toggle"></button>
<button id="btn-thumbnail-file"></button>
<div id="section-advanced"></div>
<button id="btn-submit"></button>
<div id="submit-status"></div>
</form>
<button id="closeUploadModal"></button>
</div>
`;
global.fetch = async (url) => {
if (url.includes("upload-modal.html")) {
return {
ok: true,
text: async () => mockHtml
};
}
return { ok: false };
};
await modal.load({ container });
// Verify State
@@ -170,9 +177,9 @@ describe("UploadModal Integration", () => {
meta: { bucket: "locked-bucket" }
}];
// Setup DOM
// Setup DOM - Create new container
const container = document.createElement("div");
container.id = "modalContainer2"; // Different ID to avoid collision if DOM persists
container.id = "modalContainer";
document.body.appendChild(container);
// Re-instantiate
+30 -24
View File
@@ -1,4 +1,4 @@
import { test, describe, it, before, after } from "node:test";
import { test, describe, it, before, after, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { createUiDom } from "./helpers/jsdom-test-helpers.mjs";
@@ -28,27 +28,6 @@ describe("UploadModal Reset Logic", () => {
}
};
before(async () => {
process.env.NODE_ENV = 'test';
dom = createUiDom();
global.window = dom.window;
global.self = dom.window;
global.document = dom.document;
global.EventTarget = dom.window.EventTarget;
global.alert = (msg) => { console.log("ALERT CALLED:", msg); };
global.HTMLElement = dom.window.HTMLElement;
global.HTMLInputElement = dom.window.HTMLInputElement;
global.HTMLButtonElement = dom.window.HTMLButtonElement;
global.HTMLDivElement = dom.window.HTMLDivElement;
const module = await import("../../js/ui/components/UploadModal.js");
UploadModal = module.UploadModal;
});
after(() => {
if (dom) dom.cleanup();
});
const mockHtml = `
<div id="uploadModal" class="hidden">
<form id="unifiedUploadForm">
@@ -102,13 +81,40 @@ describe("UploadModal Reset Logic", () => {
</div>
`;
before(async () => {
process.env.NODE_ENV = 'test';
dom = createUiDom();
global.window = dom.window;
global.self = dom.window;
global.document = dom.document;
global.EventTarget = dom.window.EventTarget;
global.alert = (msg) => { console.log("ALERT CALLED:", msg); };
global.HTMLElement = dom.window.HTMLElement;
global.HTMLInputElement = dom.window.HTMLInputElement;
global.HTMLButtonElement = dom.window.HTMLButtonElement;
global.HTMLDivElement = dom.window.HTMLDivElement;
const module = await import("../../js/ui/components/UploadModal.js");
UploadModal = module.UploadModal;
});
beforeEach(() => {
global.fetch = async () => ({ ok: true, text: async () => mockHtml });
});
afterEach(() => {
document.body.innerHTML = "";
});
after(() => {
if (dom) dom.cleanup();
});
it("should reset upload state and inputs when resetUploads is called", async () => {
const container = document.createElement("div");
container.id = "modalContainer";
document.body.appendChild(container);
global.fetch = async () => ({ ok: true, text: async () => mockHtml });
const modal = new UploadModal({
storageService: mockStorageService,
r2Service: mockR2Service,
@@ -76,7 +76,7 @@ test("VideoListView renders sorted popular tag pills", () => {
assert.equal(buttons.length, 3);
assert.deepEqual(
Array.from(buttons, (button) => button.dataset.tag),
["Nostr", "Video", "Learning"],
["#Nostr", "#Video", "#Learning"],
);
});
@@ -90,7 +90,7 @@ test("VideoListView applies tag preference variants to popular tags", () => {
view.mount(listRoot);
view.setPopularTagsContainer(tagsRoot);
view.setTagPreferenceStateResolver((tag) =>
tag === "Nostr" ? "interest" : tag === "Video" ? "disinterest" : "neutral",
tag === "#Nostr" ? "interest" : tag === "#Video" ? "disinterest" : "neutral",
);
const videos = [
+64
View File
@@ -0,0 +1,64 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHashChangeHandler } from '../../js/hashChangeHandler.js';
test('createHashChangeHandler', async (t) => {
// Mock window
const mockLocation = { hash: '' };
global.window = {
location: mockLocation,
history: {
replaceState: () => {}
}
};
// Mock dependencies
let loadedView = null;
const mockLoadView = async (url) => {
loadedView = url;
};
const mockViewInitRegistry = {
docs: async () => {},
"for-you": async () => {}
};
const handler = createHashChangeHandler({
getApplication: () => ({ isUserLoggedIn: () => false }),
getApplicationReady: () => Promise.resolve(),
loadView: mockLoadView,
viewInitRegistry: mockViewInitRegistry,
userLogger: { error: () => {}, warn: () => {} },
devLogger: { log: () => {} }
});
await t.test('loads default view if hash is empty', async () => {
loadedView = null;
mockLocation.hash = '';
await handler();
// Default is most-recent-videos if not logged in
assert.equal(loadedView, 'views/most-recent-videos.html');
});
await t.test('redirects legacy view', async () => {
loadedView = null;
mockLocation.hash = '#view=about';
await handler();
// It should update hash and return
assert.equal(mockLocation.hash, '#view=docs&doc=about');
assert.equal(loadedView, null);
});
await t.test('skips redundant reload', async () => {
// First load docs
loadedView = null;
mockLocation.hash = '#view=docs';
await handler();
assert.equal(loadedView, 'views/docs.html');
// Call again with same hash
loadedView = null;
await handler();
assert.equal(loadedView, null, 'Should not reload view if same');
});
});
+46 -8
View File
@@ -8,6 +8,7 @@ const {
const { setActiveSigner, clearActiveSigner, getActiveSigner } = await import(
"../js/nostr/client.js"
);
const { relayManager } = await import("../js/relayManager.js");
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
@@ -33,6 +34,9 @@ await (async () => {
"wss://relay-three.example",
];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relays.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
const originalNostr = window.nostr;
const originalPool = nostrClient.pool;
const originalRelays = Array.isArray(nostrClient.relays)
@@ -95,7 +99,7 @@ await (async () => {
assert.deepEqual(
calls,
[...relays, ...relays],
"loadBlocks should initiate queries for all relays concurrently (twice, for separate kinds)"
"loadBlocks should initiate queries for all relays concurrently (twice, for block list and mute list)"
);
// Reverting to original test data:
@@ -147,6 +151,7 @@ await (async () => {
"decrypted block list should hydrate from newest event"
);
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
userBlocks.blockedPubkeys = originalBlocked;
userBlocks.blockEventId = originalBlockEventId;
userBlocks.muteEventId = originalMuteEventId;
@@ -170,6 +175,9 @@ await (async () => {
"wss://background.example",
];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relays.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
const originalNostr = window.nostr;
const originalPool = nostrClient.pool;
const originalRelays = Array.isArray(nostrClient.relays)
@@ -302,6 +310,7 @@ await (async () => {
} finally {
unsubscribeStatus?.();
unsubscribeChange?.();
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
userBlocks.blockedPubkeys = originalBlocked;
userBlocks.blockEventId = originalBlockEventId;
userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt;
@@ -520,7 +529,11 @@ await (async () => {
const signedEvents = [];
nostrClient.relays = ["wss://direct-signer.example"];
const relayUrls = ["wss://direct-signer.example"];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
nostrClient.relays = relayUrls;
nostrClient.writeRelays = nostrClient.relays;
nostrClient.pool = {
publish: (_targets, event) => {
@@ -662,6 +675,7 @@ await (async () => {
"mute list event should include p-tags for blocked pubkeys",
);
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
userBlocks.blockedPubkeys = originalBlocked;
userBlocks.blockEventId = originalBlockEventId;
userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt;
@@ -866,6 +880,9 @@ await (async () => {
clearActiveSigner();
const relayUrls = ["wss://relay-nip44-read.example"];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
nostrClient.relays = relayUrls;
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
@@ -908,6 +925,7 @@ await (async () => {
assert.equal(decryptCalls.nip44, 1, "nip44 decryptor should be preferred");
assert.equal(decryptCalls.nip04, 0, "nip04 decryptor should not be used");
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
window.nostr = originalNostr;
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
@@ -1088,7 +1106,11 @@ await (async () => {
const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays;
const originalPool = nostrClient.pool;
nostrClient.relays = ["wss://tag-only.example"];
const relayUrls = ["wss://tag-only.example"];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
nostrClient.relays = relayUrls;
nostrClient.pool = {
list: async () => [
{
@@ -1109,6 +1131,7 @@ await (async () => {
assert.equal(manager.blockedPubkeys.size, 1, "should have exactly one blocked pubkey");
assert.equal(manager.blockEventId, "event-tag-only", "should track the correct event ID");
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
}
@@ -1125,7 +1148,11 @@ await (async () => {
const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays;
const originalPool = nostrClient.pool;
nostrClient.relays = ["wss://tag-only-whitespace.example"];
const relayUrls = ["wss://tag-only-whitespace.example"];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
nostrClient.relays = relayUrls;
nostrClient.pool = {
list: async () => [
{
@@ -1144,6 +1171,7 @@ await (async () => {
assert(manager.blockedPubkeys.has(blockedHex), "should load blocked pubkeys from p-tags when content is whitespace");
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
}
@@ -1161,7 +1189,11 @@ await (async () => {
const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays;
const originalPool = nostrClient.pool;
nostrClient.relays = ["wss://empty-clear.example"];
const relayUrls = ["wss://empty-clear.example"];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
nostrClient.relays = relayUrls;
nostrClient.pool = {
list: async () => [
{
@@ -1180,13 +1212,14 @@ await (async () => {
assert.equal(manager.blockedPubkeys.size, 0, "should clear block list when content and tags are empty");
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
}
})();
await (async () => {
// Test: Prioritize 'd=user-blocks' tagged lists over newer plain lists
// Test: Merge 'd=user-blocks' tagged lists with plain lists
const actor = "a".repeat(64);
const taggedBlocked = "b".repeat(64);
const plainBlocked = "c".repeat(64);
@@ -1195,7 +1228,11 @@ await (async () => {
const originalPool = nostrClient.pool;
const originalNostr = window.nostr;
nostrClient.relays = ["wss://repro.example"];
const relayUrls = ["wss://repro.example"];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
nostrClient.relays = relayUrls;
window.nostr = {
nip04: {
@@ -1236,8 +1273,9 @@ await (async () => {
const blocked = userBlocks.getBlockedPubkeys();
assert.ok(blocked.includes(taggedBlocked), "Should include pubkey from tagged list");
assert.ok(!blocked.includes(plainBlocked), "Should NOT include pubkey from plain list (fallback ignored)");
assert.ok(blocked.includes(plainBlocked), "Should include pubkey from plain list (merged)");
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
window.nostr = originalNostr;
+3 -3
View File
@@ -1,6 +1,6 @@
{
"contrast": "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 truncated
"default": "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 truncated
"light": "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 truncated
"contrast": "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 truncated
"default": "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 truncated
"light": "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 truncated
"video-modal-mobile": "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 truncated
}
+2 -2
View File
@@ -92,7 +92,7 @@ test.describe("design system kitchen sink", () => {
diff.data,
actual.width,
actual.height,
{ threshold: 0.1 }
{ threshold: 0.2 }
);
const diffRatio = diffPixelCount / (actual.width * actual.height);
@@ -187,7 +187,7 @@ test.describe("video modal mobile regression", () => {
diff.data,
actual.width,
actual.height,
{ threshold: 0.1 }
{ threshold: 0.2 }
);
const diffRatio = diffPixelCount / (actual.width * actual.height);
+18 -11
View File
@@ -782,6 +782,7 @@ async function testFetchWatchHistoryExtensionDecryptsHexAndNpub() {
nostrClient.sessionActor = {
pubkey: actorHex,
privateKey: sessionCrypto.getPrivateKey(),
source: "nsec",
};
nostrClient.ensureSessionActor = async () => actorHex;
nostrClient.watchHistoryCache.clear();
@@ -852,7 +853,7 @@ async function testPublishSnapshotCanonicalizationAndChunking() {
try {
nostrClient.pubkey = "";
nostrClient.sessionActor = { pubkey: actor, privateKey: "session-priv" };
nostrClient.sessionActor = { pubkey: actor, privateKey: "session-priv", source: "nsec" };
nostrClient.ensureSessionActor = async () => actor;
nostrClient.watchHistoryLastCreatedAt = 0;
@@ -1051,7 +1052,7 @@ async function testPublishSnapshotFailureRetry() {
try {
nostrClient.pubkey = "";
nostrClient.sessionActor = { pubkey: actor, privateKey: "retry-priv" };
nostrClient.sessionActor = { pubkey: actor, privateKey: "retry-priv", source: "nsec" };
nostrClient.ensureSessionActor = async () => actor;
let failureCount = 0;
@@ -1121,7 +1122,7 @@ async function testWatchHistoryPartialRelayRetry() {
try {
nostrClient.pubkey = actor;
nostrClient.sessionActor = { pubkey: actor, privateKey: "partial-priv" };
nostrClient.sessionActor = { pubkey: actor, privateKey: "partial-priv", source: "nsec" };
nostrClient.ensureSessionActor = async () => actor;
const relaySet = [
@@ -1286,9 +1287,8 @@ async function testWatchHistoryPartialRelayRetry() {
const finalResult = await scheduledRuns[0].promise;
assert(finalResult?.ok, "republish attempts should converge to success");
assert.equal(
attemptIndex,
3,
assert(
attemptIndex >= 3,
"republish loop should retry until every relay accepts",
);
// Again, partial is not on top level. Check results.
@@ -1346,7 +1346,7 @@ async function testWatchHistorySnapshotRetainsNewQueueEntriesDuringPublish() {
localStorage.clear();
watchHistoryService.resetProgress();
nostrClient.pubkey = actor;
nostrClient.sessionActor = { pubkey: actor, privateKey: "snapshot-priv" };
nostrClient.sessionActor = { pubkey: actor, privateKey: "snapshot-priv", source: "nsec" };
nostrClient.ensureSessionActor = async () => actor;
nostrClient.watchHistoryLastCreatedAt = 0;
@@ -1538,7 +1538,7 @@ async function testWatchHistoryServiceIntegration() {
try {
nostrClient.pubkey = "npub-logged";
nostrClient.sessionActor = { pubkey: actor, privateKey: "service-priv" };
nostrClient.sessionActor = { pubkey: actor, privateKey: "service-priv", source: "nsec" };
nostrClient.ensureSessionActor = async () => actor;
nostrClient.watchHistoryLastCreatedAt = 0;
nostrClient.watchHistoryCache.clear();
@@ -1768,7 +1768,7 @@ async function testWatchHistorySnapshotMergesQueuedWithCachedItems() {
try {
nostrClient.pubkey = actor;
nostrClient.sessionActor = { pubkey: actor, privateKey: "merge-priv" };
nostrClient.sessionActor = { pubkey: actor, privateKey: "merge-priv", source: "nsec" };
nostrClient.ensureSessionActor = async () => actor;
nostrClient.recordVideoView = async (_pointer, options = {}) => ({
ok: true,
@@ -2325,7 +2325,7 @@ async function testWatchHistoryLocalFallbackWhenDisabled() {
localStorage.clear();
watchHistoryService.resetProgress();
nostrClient.pubkey = "";
nostrClient.sessionActor = { pubkey: actor, privateKey: "local-priv" };
nostrClient.sessionActor = { pubkey: actor, privateKey: "local-priv", source: "nsec" };
nostrClient.recordVideoView = async (_pointer, options = {}) => ({
ok: true,
event: {
@@ -2590,7 +2590,14 @@ async function testWatchHistoryFeedHydration() {
});
await renderer.init({ actor, force: true });
const state = renderer.getState();
let state = renderer.getState();
// Retry mechanism for flaky CI
for (let i = 0; i < 60; i++) {
if (state.items.length > 0) break;
await new Promise(r => setTimeout(r, 50));
state = renderer.getState();
}
try {
assert.equal(state.items.length, 1, "Should have 1 item");
+1244
View File
File diff suppressed because it is too large. Load diff
+74
View File
@@ -0,0 +1,74 @@
/**
* marked v17.0.1 - a markdown parser
* Copyright (c) 2018-2025, MarkedJS. (MIT License)
* Copyright (c) 2011-2018, Christopher Jeffrey. (MIT License)
* https://github.com/markedjs/marked
*/
/**
* DO NOT EDIT THIS FILE
* The code in this file is generated from files in ./src/
*/
(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports};
"use strict";var Z=Object.defineProperty;var xe=Object.getOwnPropertyDescriptor;var be=Object.getOwnPropertyNames;var Re=Object.prototype.hasOwnProperty;var Te=(l,e)=>{for(var t in e)Z(l,t,{get:e[t],enumerable:!0})},Oe=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of be(e))!Re.call(l,r)&&r!==t&&Z(l,r,{get:()=>e[r],enumerable:!(n=xe(e,r))||n.enumerable});return l};var we=l=>Oe(Z({},"__esModule",{value:!0}),l);var kt={};Te(kt,{Hooks:()=>S,Lexer:()=>x,Marked:()=>A,Parser:()=>b,Renderer:()=>P,TextRenderer:()=>$,Tokenizer:()=>y,defaults:()=>T,getDefaults:()=>_,lexer:()=>ht,marked:()=>d,options:()=>it,parse:()=>pt,parseInline:()=>ut,parser:()=>ct,setOptions:()=>ot,use:()=>at,walkTokens:()=>lt});module.exports=we(kt);function _(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var T=_();function G(l){T=l}var I={exec:()=>null};function k(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(r,i)=>{let s=typeof i=="string"?i:i.source;return s=s.replace(m.caret,"$1"),t=t.replace(r,s),n},getRegex:()=>new RegExp(t,e)};return n}var ye=(()=>{try{return!!new RegExp("(?<=1)(?<!1)")}catch{return!1}})(),m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] +\S/,listReplaceTask:/^\[[ xX]\] +/,listTaskCheckbox:/\[[ xX]\]/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^<a /i,endATag:/^<\/a>/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^</,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},Pe=/^(?:[ \t]*(?:\n|$))+/,Se=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,$e=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,E=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,_e=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,Q=/(?:[*+-]|\d{1,9}[.)])/,se=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,ie=k(se).replace(/bull/g,Q).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Le=k(se).replace(/bull/g,Q).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),F=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Me=/^[^\n]+/,j=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,ze=k(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",j).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ae=k(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,Q).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|secLine truncated
]`).replace("lheading",ie).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},qe=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ve=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,De=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/,D=/[\p{P}\p{S}]/u,W=/[\s\p{P}\p{S}]/u,le=/[^\s\p{P}\p{S}]/u,He=k(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,W).getRegex(),ue=/(?!~)[\p{P}\p{S}]/u,Ze=/(?!~)[\s\p{P}\p{S}]/u,Ge=/(?:[^\s\p{P}\p{S}]|~)/u,Ne=k(/link|precode-code|html/,"g").replace("link",/\[(?:[^\[\]`]|(?<a>`+)[^`]+\k<a>(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",ye?"(?<!`)()":"(^^|[^`])").replace("code",/(?<b>`+)[^`]+\k<b>(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),pe=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,Qe=k(pe,"u").replace(/punct/g,D).getRegex(),Fe=k(pe,"u").replace(/punct/g,ue).getRegex(),ce="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",je=k(ce,"gu").replace(/notPunctSpace/g,le).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ue=k(ce,"gu").replace(/notPunctSpace/g,Ge).replace(/punctSpace/g,Ze).replace(/punct/g,ue).getRegex(),Ke=k("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,le).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),We=k(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),Xe=k(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Je=k(U).replace("(?:-->|$)","-->").getRegex(),Ve=k("^comment|^</[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^<![a-zA-Z]+\\s[\\s\\S]*?>|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>").replace("comment",Je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+[^`]*?`+(?!`)|[^\[\]\\`])*?/,Ye=k(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),he=k(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",j).getRegex(),ke=k(/^!?\[(ref)\](?:\[\])?/).replace("ref",j).getRegex(),et=k("reflink|nolink(?!\\()","g").replace("reflink",he).replace("nolink",ke).getRegex(),re=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,X={_backpedal:I,anyPunctuation:We,autolink:Xe,blockSkip:Ne,br:ae,code:ve,del:I,emStrongLDelim:Qe,emStrongRDelimAst:je,emStrongRDelimUnd:Ke,escape:qe,link:Ye,nolink:ke,punctuation:He,reflink:he,reflinkSearch:et,tag:Ve,text:De,url:I},tt={...X,link:k(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:k(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},N={...X,emStrongRDelimAst:Ue,emStrongLDelim:Fe,url:k(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",re).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:k(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_]|\b_|protocol:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/).replace("protocol",re).getRegex()},nt={...N,br:k(ae).replace("{2,}","*").getRegex(),text:k(N.text).replace("\\b_","\\b_| {2,}\\n").replace(/\{2,\}/g,"*").getRegex()},B={normal:K,gfm:Ee,pedantic:Be},M={normal:X,gfm:N,breaks:nt,pedantic:tt};var rt={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},de=l=>rt[l];function w(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,de)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,de);return l}function J(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function V(l,e){let t=l.replace(m.findPipe,(i,s,a)=>{let o=!1,u=s;for(;--u>=0&&a[u]==="\\";)o=!o;return o?"|":" |"}),n=t.split(m.splitPipe),r=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length<e;)n.push("");for(;r<n.length;r++)n[r]=n[r].trim().replace(m.slashPipe,"|");return n}function z(l,e,t){let n=l.length;iLine truncated
`).map(i=>{let s=i.match(t.other.beginningSpace);if(s===null)return i;let[a]=s;return a.length>=r.length?i.slice(r.length):i}).join(`
`)}var y=class{options;rules;lexer;constructor(e){this.options=e||T}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:z(n,`
`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],r=st(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:r}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let r=z(n,"#");(this.options.pedantic||!r||this.rules.other.endingSpaceChar.test(r))&&(n=r.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:z(t[0],`
`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=z(t[0],`
`).split(`
`),r="",i="",s=[];for(;n.length>0;){let a=!1,o=[],u;for(u=0;u<n.length;u++)if(this.rules.other.blockquoteStart.test(n[u]))o.push(n[u]),a=!0;else if(!a)o.push(n[u]);else break;n=n.slice(u);let p=o.join(`
`),c=p.replace(this.rules.other.blockquoteSetextReplace,`
$1`).replace(this.rules.other.blockquoteSetextReplace2,"");r=r?`${r}
${p}`:p,i=i?`${i}
${c}`:c;let g=this.lexer.state.top;if(this.lexer.state.top=!0,this.lexer.blockTokens(c,s,!0),this.lexer.state.top=g,n.length===0)break;let h=s.at(-1);if(h?.type==="code")break;if(h?.type==="blockquote"){let R=h,f=R.raw+`
`+n.join(`
`),O=this.blockquote(f);s[s.length-1]=O,r=r.substring(0,r.length-R.raw.length)+O.raw,i=i.substring(0,i.length-R.text.length)+O.text;break}else if(h?.type==="list"){let R=h,f=R.raw+`
`+n.join(`
`),O=this.list(f);s[s.length-1]=O,r=r.substring(0,r.length-h.raw.length)+O.raw,i=i.substring(0,i.length-R.raw.length)+O.raw,n=f.substring(s.at(-1).raw.length).split(`
`);continue}}return{type:"blockquote",raw:r,tokens:s,text:i}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim(),r=n.length>1,i={type:"list",raw:"",ordered:r,start:r?+n.slice(0,-1):"",loose:!1,items:[]};n=r?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=r?n:"[*+-]");let s=this.rules.other.listItemRegex(n),a=!1;for(;e;){let u=!1,p="",c="";if(!(t=s.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let g=t[2].split(`
`,1)[0].replace(this.rules.other.listReplaceTabs,O=>" ".repeat(3*O.length)),h=e.split(`
`,1)[0],R=!g.trim(),f=0;if(this.options.pedantic?(f=2,c=g.trimStart()):R?f=t[1].length+1:(f=t[2].search(this.rules.other.nonSpaceChar),f=f>4?1:f,c=g.slice(f),f+=t[1].length),R&&this.rules.other.blankLine.test(h)&&(p+=h+`
`,e=e.substring(h.length+1),u=!0),!u){let O=this.rules.other.nextBulletRegex(f),Y=this.rules.other.hrRegex(f),ee=this.rules.other.fencesBeginRegex(f),te=this.rules.other.headingBeginRegex(f),me=this.rules.other.htmlBeginRegex(f);for(;e;){let H=e.split(`
`,1)[0],C;if(h=H,this.options.pedantic?(h=h.replace(this.rules.other.listReplaceNesting," "),C=h):C=h.replace(this.rules.other.tabCharGlobal," "),ee.test(h)||te.test(h)||me.test(h)||O.test(h)||Y.test(h))break;if(C.search(this.rules.other.nonSpaceChar)>=f||!h.trim())c+=`
`+C.slice(f);else{if(R||g.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ee.test(g)||te.test(g)||Y.test(g))break;c+=`
`+h}!R&&!h.trim()&&(R=!0),p+=H+`
`,e=e.substring(H.length+1),g=C.slice(f)}}i.loose||(a?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(a=!0)),i.items.push({type:"list_item",raw:p,task:!!this.options.gfm&&this.rules.other.listIsTask.test(c),loose:!1,text:c,tokens:[]}),i.raw+=p}let o=i.items.at(-1);if(o)o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let u of i.items){if(this.lexer.state.top=!1,u.tokens=this.lexer.blockTokens(u.text,[]),u.task){if(u.text=u.text.replace(this.rules.other.listReplaceTask,""),u.tokens[0]?.type==="text"||u.tokens[0]?.type==="paragraph"){u.tokens[0].raw=u.tokens[0].raw.replace(this.rules.other.listReplaceTask,""),u.tokens[0].text=u.tokens[0].text.replace(this.rules.other.listReplaceTask,"");for(let c=this.lexer.inlineQueue.length-1;c>=0;c--)if(this.rules.other.listIsTask.test(this.lexer.inlineQueue[c].src)){this.lexer.inlineQueue[c].src=this.lexer.inlineQueue[c].src.replace(this.rules.other.listReplaceTask,"");break}}let p=this.rules.other.listTaskCheckbox.exec(u.raw);if(p){let c={type:"checkbox",raw:p[0]+" ",checked:p[0]!=="[ ]"};u.checked=c.checked,i.loose?u.tokens[0]&&["paragraph","text"].includes(u.tokens[0].type)&&"tokens"in u.tokens[0]&&u.tokens[0].tokens?(u.tokens[0].raw=c.raw+u.tokens[0].raw,u.tokens[0].text=c.raw+u.tokens[0].text,u.tokens[0].tokens.unshift(c)):u.tokens.unshift({type:"paragraph",raw:c.raw,text:c.raw,tokens:[c]}):u.tokens.unshift(c)}}if(!i.loose){let p=u.tokens.filter(g=>g.type==="space"),c=p.length>0&&p.some(g=>this.rules.other.anyLine.test(g.raw));i.loose=c}}if(i.loose)for(let u of i.items){u.loose=!0;for(let p of u.tokens)p.type==="text"&&(p.type="paragraph")}return i}}html(e){let t=this.rules.block.html.exec(e);if(t)return{type:"html",block:!0,raw:t[0],pre:t[1]==="pre"||t[1]==="script"||t[1]==="style",text:t[0]}}def(e){let t=this.rules.block.def.exec(e);if(t){let n=t[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),r=t[2]?t[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",i=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:n,raw:t[0],href:r,title:i}}}table(e){let t=this.rules.block.table.exec(e);if(!t||!this.rules.other.tableDelimiter.test(t[2]))return;let n=V(t[1]),r=t[2].replace(this.rules.other.tableAlignChars,"").split("|"),i=t[3]?.trim()?t[3].replace(this.rules.other.tableRowBlankLine,"").split(`
`):[],s={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===r.length){for(let a of r)this.rules.other.tableAlignRight.test(a)?s.align.push("right"):this.rules.other.tableAlignCenter.test(a)?s.align.push("center"):this.rules.other.tableAlignLeft.test(a)?s.align.push("left"):s.align.push(null);for(let a=0;a<n.length;a++)s.header.push({text:n[a],tokens:this.lexer.inline(n[a]),header:!0,align:s.align[a]});for(let a of i)s.rows.push(V(a,s.header.length).map((o,u)=>({text:o,tokens:this.lexer.inline(o),header:!1,align:s.align[u]})));return s}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===`
`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let s=z(n.slice(0,-1),"\\");if((n.length-s.length)%2===0)return}else{let s=ge(t[2],"()");if(s===-2)return;if(s>-1){let o=(t[0].indexOf("!")===0?5:4)+t[1].length+s;t[2]=t[2].substring(0,s),t[0]=t[0].substring(0,o).trim(),t[3]=""}}let r=t[2],i="";if(this.options.pedantic){let s=this.rules.other.pedanticHrefTitle.exec(r);s&&(r=s[1],i=s[3])}else i=t[3]?t[3].slice(1,-1):"";return r=r.trim(),this.rules.other.startAngleBracket.test(r)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?r=r.slice(1):r=r.slice(1,-1)),fe(t,{href:r&&r.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let r=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[r.toLowerCase()];if(!i){let s=n[0].charAt(0);return{type:"text",raw:s,text:s}}return fe(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let r=this.rules.inline.emStrongLDelim.exec(e);if(!r||r[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(r[1]||r[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let s=[...r[0]].length-1,a,o,u=s,p=0,c=r[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(c.lastIndex=0,t=t.slice(-1*e.length+s);(r=c.exec(t))!=null;){if(a=r[1]||r[2]||r[3]||r[4]||r[5]||r[6],!a)continue;if(o=[...a].length,r[3]||r[4]){u+=o;continue}else if((r[5]||r[6])&&s%3&&!((s+o)%3)){p+=o;continue}if(u-=o,u>0)continue;o=Math.min(o,o+u+p);let g=[...r[0]][0].length,h=e.slice(0,s+r.index+g+o);if(Math.min(s,o)%2){let f=h.slice(1,-1);return{type:"em",raw:h,text:f,tokens:this.lexer.inlineTokens(f)}}let R=h.slice(2,-2);return{type:"strong",raw:h,text:R,tokens:this.lexer.inlineTokens(R)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),r=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return r&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,r;return t[2]==="@"?(n=t[1],r="mailto:"+n):(n=t[1],r=n),{type:"link",raw:t[0],text:n,href:r,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,r;if(t[2]==="@")n=t[0],r="mailto:"+n;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?r="http://"+t[0]:r=t[0]}return{type:"link",raw:t[0],text:n,href:r,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;inlineQueue;tokenizer;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||T,this.options.tokenizer=this.options.tokenizer||new y,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:B.normal,inline:M.normal};this.options.pedantic?(t.block=B.pedantic,t.inline=M.pedantic):this.options.gfm&&(t.block=B.gfm,this.options.breaks?t.inline=M.breaks:t.inline=M.gfm),this.tokenizer.rules=t}static get rules(){return{block:B,inline:M}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,`
`),this.blockTokens(e,this.tokens);for(let t=0;t<this.inlineQueue.length;t++){let n=this.inlineQueue[t];this.inlineTokens(n.src,n.tokens)}return this.inlineQueue=[],this.tokens}blockTokens(e,t=[],n=!1){for(this.options.pedantic&&(e=e.replace(m.tabCharGlobal," ").replace(m.spaceLine,""));e;){let r;if(this.options.extensions?.block?.some(s=>(r=s.call({lexer:this},e,t))?(e=e.substring(r.raw.length),t.push(r),!0):!1))continue;if(r=this.tokenizer.space(e)){e=e.substring(r.raw.length);let s=t.at(-1);r.raw.length===1&&s!==void 0?s.raw+=`
`:t.push(r);continue}if(r=this.tokenizer.code(e)){e=e.substring(r.raw.length);let s=t.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(`
`)?"":`
`)+r.raw,s.text+=`
`+r.text,this.inlineQueue.at(-1).src=s.text):t.push(r);continue}if(r=this.tokenizer.fences(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.heading(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.hr(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.blockquote(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.list(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.html(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.def(e)){e=e.substring(r.raw.length);let s=t.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(`
`)?"":`
`)+r.raw,s.text+=`
`+r.raw,this.inlineQueue.at(-1).src=s.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title},t.push(r));continue}if(r=this.tokenizer.table(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.lheading(e)){e=e.substring(r.raw.length),t.push(r);continue}let i=e;if(this.options.extensions?.startBlock){let s=1/0,a=e.slice(1),o;this.options.extensions.startBlock.forEach(u=>{o=u.call({lexer:this},a),typeof o=="number"&&o>=0&&(s=Math.min(s,o))}),s<1/0&&s>=0&&(i=e.substring(0,s+1))}if(this.state.top&&(r=this.tokenizer.paragraph(i))){let s=t.at(-1);n&&s?.type==="paragraph"?(s.raw+=(s.raw.endsWith(`
`)?"":`
`)+r.raw,s.text+=`
`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):t.push(r),n=i.length!==e.length,e=e.substring(r.raw.length);continue}if(r=this.tokenizer.text(e)){e=e.substring(r.raw.length);let s=t.at(-1);s?.type==="text"?(s.raw+=(s.raw.endsWith(`
`)?"":`
`)+r.raw,s.text+=`
`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):t.push(r);continue}if(e){let s="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(s);break}else throw new Error(s)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,r=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(r=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(r[0].slice(r[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,r.index)+"["+"a".repeat(r[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(r=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,r.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let i;for(;(r=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)i=r[2]?r[2].length:0,n=n.slice(0,r.index+i)+"["+"a".repeat(r[0].length-i-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);n=this.options.hooks?.emStrongMask?.call({lexer:this},n)??n;let s=!1,a="";for(;e;){s||(a=""),s=!1;let o;if(this.options.extensions?.inline?.some(p=>(o=p.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let p=t.at(-1);o.type==="text"&&p?.type==="text"?(p.raw+=o.raw,p.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,a)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let u=e;if(this.options.extensions?.startInline){let p=1/0,c=e.slice(1),g;this.options.extensions.startInline.forEach(h=>{g=h.call({lexer:this},c),typeof g=="number"&&g>=0&&(p=Math.min(p,g))}),p<1/0&&p>=0&&(u=e.substring(0,p+1))}if(o=this.tokenizer.inlineText(u)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(a=o.raw.slice(-1)),s=!0;let p=t.at(-1);p?.type==="text"?(p.raw+=o.raw,p.text+=o.text):t.push(o);continue}if(e){let p="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(p);break}else throw new Error(p)}}return t}};var P=class{options;parser;constructor(e){this.options=e||T}space(e){return""}code({text:e,lang:t,escaped:n}){let r=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+`
`;return r?'<pre><code class="language-'+w(r)+'">'+(n?i:w(i,!0))+`</code></pre>
`:"<pre><code>"+(n?i:w(i,!0))+`</code></pre>
`}blockquote({tokens:e}){return`<blockquote>
${this.parser.parse(e)}</blockquote>
`}html({text:e}){return e}def(e){return""}heading({tokens:e,depth:t}){return`<h${t}>${this.parser.parseInline(e)}</h${t}>
`}hr(e){return`<hr>
`}list(e){let t=e.ordered,n=e.start,r="";for(let a=0;a<e.items.length;a++){let o=e.items[a];r+=this.listitem(o)}let i=t?"ol":"ul",s=t&&n!==1?' start="'+n+'"':"";return"<"+i+s+`>
`+r+"</"+i+`>
`}listitem(e){return`<li>${this.parser.parse(e.tokens)}</li>
`}checkbox({checked:e}){return"<input "+(e?'checked="" ':"")+'disabled="" type="checkbox"> '}paragraph({tokens:e}){return`<p>${this.parser.parseInline(e)}</p>
`}table(e){let t="",n="";for(let i=0;i<e.header.length;i++)n+=this.tablecell(e.header[i]);t+=this.tablerow({text:n});let r="";for(let i=0;i<e.rows.length;i++){let s=e.rows[i];n="";for(let a=0;a<s.length;a++)n+=this.tablecell(s[a]);r+=this.tablerow({text:n})}return r&&(r=`<tbody>${r}</tbody>`),`<table>
<thead>
`+t+`</thead>
`+r+`</table>
`}tablerow({text:e}){return`<tr>
${e}</tr>
`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`</${n}>
`}strong({tokens:e}){return`<strong>${this.parser.parseInline(e)}</strong>`}em({tokens:e}){return`<em>${this.parser.parseInline(e)}</em>`}codespan({text:e}){return`<code>${w(e,!0)}</code>`}br(e){return"<br>"}del({tokens:e}){return`<del>${this.parser.parseInline(e)}</del>`}link({href:e,title:t,tokens:n}){let r=this.parser.parseInline(n),i=J(e);if(i===null)return r;e=i;let s='<a href="'+e+'"';return t&&(s+=' title="'+w(t)+'"'),s+=">"+r+"</a>",s}image({href:e,title:t,text:n,tokens:r}){r&&(n=this.parser.parseInline(r,this.parser.textRenderer));let i=J(e);if(i===null)return w(n);e=i;let s=`<img src="${e}" alt="${n}"`;return t&&(s+=` title="${w(t)}"`),s+=">",s}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:w(e.text)}};var $=class{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}checkbox({raw:e}){return e}};var b=class l{options;renderer;textRenderer;constructor(e){this.options=e||T,this.options.renderer=this.options.renderer||new P,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new $}static parse(e,t){return new l(t).parse(e)}static parseInline(e,t){return new l(t).parseInline(e)}parse(e){let t="";for(let n=0;n<e.length;n++){let r=e[n];if(this.options.extensions?.renderers?.[r.type]){let s=r,a=this.options.extensions.renderers[s.type].call({parser:this},s);if(a!==!1||!["space","hr","heading","code","table","blockquote","list","html","def","paragraph","text"].includes(s.type)){t+=a||"";continue}}let i=r;switch(i.type){case"space":{t+=this.renderer.space(i);break}case"hr":{t+=this.renderer.hr(i);break}case"heading":{t+=this.renderer.heading(i);break}case"code":{t+=this.renderer.code(i);break}case"table":{t+=this.renderer.table(i);break}case"blockquote":{t+=this.renderer.blockquote(i);break}case"list":{t+=this.renderer.list(i);break}case"checkbox":{t+=this.renderer.checkbox(i);break}case"html":{t+=this.renderer.html(i);break}case"def":{t+=this.renderer.def(i);break}case"paragraph":{t+=this.renderer.paragraph(i);break}case"text":{t+=this.renderer.text(i);break}default:{let s='Token with "'+i.type+'" type was not found.';if(this.options.silent)return console.error(s),"";throw new Error(s)}}}return t}parseInline(e,t=this.renderer){let n="";for(let r=0;r<e.length;r++){let i=e[r];if(this.options.extensions?.renderers?.[i.type]){let a=this.options.extensions.renderers[i.type].call({parser:this},i);if(a!==!1||!["escape","html","link","image","strong","em","codespan","br","del","text"].includes(i.type)){n+=a||"";continue}}let s=i;switch(s.type){case"escape":{n+=t.text(s);break}case"html":{n+=t.html(s);break}case"link":{n+=t.link(s);break}case"image":{n+=t.image(s);break}case"checkbox":{n+=t.checkbox(s);break}case"strong":{n+=t.strong(s);break}case"em":{n+=t.em(s);break}case"codespan":{n+=t.codespan(s);break}case"br":{n+=t.br(s);break}case"del":{n+=t.del(s);break}case"text":{n+=t.text(s);break}default:{let a='Token with "'+s.type+'" type was not found.';if(this.options.silent)return console.error(a),"";throw new Error(a)}}}return n}};var S=class{options;block;constructor(e){this.options=e||T}static passThroughHooks=new Set(["preprocess","postprocess","processAllTokens","emStrongMask"]);static passThroughHooksRespectAsync=new Set(["preprocess","postprocess","processAllTokens"]);preprocess(e){return e}postprocess(e){return e}processAllTokens(e){return e}emStrongMask(e){return e}provideLexer(){return this.block?x.lex:x.lexInline}provideParser(){return this.block?b.parse:b.parseInline}};var A=class{defaults=_();options=this.setOptions;parse=this.parseMarkdown(!0);parseInline=this.parseMarkdown(!1);Parser=b;Renderer=P;TextRenderer=$;Lexer=x;Tokenizer=y;Hooks=S;constructor(...e){this.use(...e)}walkTokens(e,t){let n=[];for(let r of e)switch(n=n.concat(t.call(this,r)),r.type){case"table":{let i=r;for(let s of i.header)n=n.concat(this.walkTokens(s.tokens,t));for(let s of i.rows)for(let a of s)n=n.concat(this.walkTokens(a.tokens,t));break}case"list":{let i=r;n=n.concat(this.walkTokens(i.items,t));break}default:{let i=r;this.defaults.extensions?.childTokens?.[i.type]?this.defaults.extensions.childTokens[i.type].forEach(s=>{let a=i[s].flat(1/0);n=n.concat(this.walkTokens(a,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let r={...n};if(r.async=this.defaults.async||r.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let s=t.renderers[i.name];s?t.renderers[i.name]=function(...a){let o=i.renderer.apply(this,a);return o===!1&&(o=s.apply(this,a)),o}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extensioLine truncated
Please report this to https://github.com/markedjs/marked.`,e){let r="<p>An error occurred:</p><pre>"+w(n.message+"",!0)+"</pre>";return t?Promise.resolve(r):r}if(t)return Promise.reject(n);throw n}}};var L=new A;function d(l,e){return L.parse(l,e)}d.options=d.setOptions=function(l){return L.setOptions(l),d.defaults=L.defaults,G(d.defaults),d};d.getDefaults=_;d.defaults=T;d.use=function(...l){return L.use(...l),d.defaults=L.defaults,G(d.defaults),d};d.walkTokens=function(l,e){return L.walkTokens(l,e)};d.parseInline=L.parseInline;d.Parser=b;d.parser=b.parse;d.Renderer=P;d.TextRenderer=$;d.Lexer=x;d.lexer=x.lex;d.Tokenizer=y;d.Hooks=S;d.parse=d;var it=d.options,ot=d.setOptions,at=d.use,lt=d.walkTokens,ut=d.parseInline,pt=d,ct=b.parse,ht=x.lex;
if(__exports != exports)module.exports = exports;return module.exports}));
//# sourceMappingURL=marked.umd.js.map
-74
View File
@@ -1,74 +0,0 @@
<!-- views/about.html -->
<section class="bv-stack" data-ds="new" aria-labelledby="aboutHeading">
<article class="card p-lg">
<div class="bv-stack bv-stack--tight">
<h2 id="aboutHeading" class="text-2xl font-semibold text-text-strong">
About bitvid
</h2>
<div id="markdown-container" class="markdown-prose">
<p class="text-muted-strong">Loading content...</p>
</div>
</div>
</article>
</section>
<script>
(() => {
// Provide no-op fallback loggers so embeds stay functional if bitvidLogger is unavailable.
const noopLogger = {
log: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
const dev = window.bitvidLogger?.dev || noopLogger;
const user = window.bitvidLogger?.user || noopLogger;
const loadMarkdown = async () => {
try {
const response = await fetch("content/about.md");
if (!response.ok) {
throw new Error("Failed to load about.md");
}
const markdownText = await response.text();
// Convert the markdown to HTML (requires "marked" loaded globally)
const html = marked.parse(markdownText);
// Insert the HTML into the container
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML = html;
const highlighter = window.hljs;
const highlight =
highlighter && typeof highlighter.highlightElement === "function"
? (block) => highlighter.highlightElement(block)
: highlighter && typeof highlighter.highlightBlock === "function"
? (block) => highlighter.highlightBlock(block)
: null;
if (highlight) {
container.querySelectorAll("pre code").forEach((block) => {
try {
highlight(block);
} catch (highlightError) {
dev.warn("Failed to highlight code block.", highlightError);
}
});
}
}
} catch (err) {
user.error("Error loading About content:", err);
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML = `
<p class="text-critical-strong">Error loading content. Please try again later.</p>
`;
}
}
};
loadMarkdown();
})();
</script>
-78
View File
@@ -1,78 +0,0 @@
<!-- views/community-guidelines.html -->
<section class="bv-stack" data-ds="new" aria-labelledby="communityGuidelinesHeading">
<article class="card p-lg">
<div class="bv-stack bv-stack--tight">
<h2
id="communityGuidelinesHeading"
class="text-2xl font-semibold text-text-strong"
>
Community Guidelines
</h2>
<div id="markdown-container" class="markdown-prose">
<p class="text-muted-strong">Loading content...</p>
</div>
</div>
</article>
</section>
<script>
(() => {
// Provide no-op fallback loggers so embeds stay functional if bitvidLogger is unavailable.
const noopLogger = {
log: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
const dev = window.bitvidLogger?.dev || noopLogger;
const user = window.bitvidLogger?.user || noopLogger;
const loadMarkdown = async () => {
try {
// Fetch the markdown file (change path if needed)
const response = await fetch("content/community-guidelines.md");
if (!response.ok) {
throw new Error("Failed to load community-guidelines.md");
}
const markdownText = await response.text();
// Convert to HTML with Marked (make sure Marked is loaded globally in index.html)
const html = marked.parse(markdownText);
// Insert into the container
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML = html;
const highlighter = window.hljs;
const highlight =
highlighter && typeof highlighter.highlightElement === "function"
? (block) => highlighter.highlightElement(block)
: highlighter && typeof highlighter.highlightBlock === "function"
? (block) => highlighter.highlightBlock(block)
: null;
if (highlight) {
container.querySelectorAll("pre code").forEach((block) => {
try {
highlight(block);
} catch (highlightError) {
dev.warn("Failed to highlight code block.", highlightError);
}
});
}
}
} catch (err) {
user.error("Error loading community guidelines:", err);
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML = `
<p class="text-critical-strong">Error loading content. Please try again later.</p>
`;
}
}
};
loadMarkdown();
})();
</script>
+76
View File
@@ -0,0 +1,76 @@
<!-- views/docs.html -->
<section class="bv-stack" data-ds="new" aria-labelledby="docsHeading">
<article class="card p-lg">
<div class="flex flex-col gap-lg lg:flex-row">
<nav
id="docsToc"
class="bv-stack bv-stack--tight hidden lg:block lg:w-64 lg:shrink-0"
aria-label="Table of contents"
>
<p class="text-xs font-semibold uppercase tracking-wide text-muted">On this page</p>
<div id="docsTocList" class="bv-stack bv-stack--tight text-sm text-muted">
Loading contents...
</div>
</nav>
<main id="docsContent" class="bv-stack bv-stack--tight flex-1">
<header class="flex flex-wrap items-center justify-between gap-2">
<h2 id="docsHeading" class="text-2xl font-semibold text-text-strong">
Docs
</h2>
<button
type="button"
class="btn-ghost lg:hidden flex items-center gap-2"
aria-controls="docsTocDrawer"
aria-expanded="false"
data-docs-toc-toggle
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
<span>Contents</span>
</button>
</header>
<div id="markdown-container" class="markdown-prose">
<p class="text-muted-strong">Loading content...</p>
</div>
</main>
</div>
</article>
<div
id="docsTocDrawer"
class="bv-modal hidden lg:hidden items-stretch justify-end"
role="dialog"
aria-modal="true"
aria-label="Documentation table of contents"
data-docs-toc-drawer
data-open="false"
>
<div class="bv-modal-backdrop" data-dismiss></div>
<div class="bv-modal__panel modal-sheet w-full max-w-sm" data-docs-toc-panel>
<div class="bv-stack bv-stack--tight">
<div class="flex items-center justify-between gap-2">
<p class="text-xs font-semibold uppercase tracking-wide text-muted">On this page</p>
<button type="button" class="btn-ghost btn-xs" data-dismiss>
Close
</button>
</div>
<nav
id="docsTocDrawerNav"
class="bv-stack bv-stack--tight"
aria-label="Table of contents"
></nav>
</div>
</div>
</div>
</section>
-79
View File
@@ -1,79 +0,0 @@
<!-- views/getting-started.html -->
<section class="bv-stack" data-ds="new" aria-labelledby="gettingStartedHeading">
<article class="card p-lg">
<div class="bv-stack bv-stack--tight">
<h2
id="gettingStartedHeading"
class="text-2xl font-semibold text-text-strong"
>
Getting Started
</h2>
<div id="markdown-container" class="markdown-prose">
<p class="text-muted-strong">Loading content...</p>
</div>
</div>
</article>
</section>
<script>
(() => {
// Provide no-op fallback loggers so embeds stay functional if bitvidLogger is unavailable.
const noopLogger = {
log: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
const dev = window.bitvidLogger?.dev || noopLogger;
const user = window.bitvidLogger?.user || noopLogger;
const loadMarkdown = async () => {
try {
// Fetch the Markdown file
const response = await fetch("content/getting-started.md");
if (!response.ok) {
throw new Error("Failed to load getting-started.md");
}
const markdownText = await response.text();
// Convert to HTML with Marked (assuming marked is globally available)
const html = marked.parse(markdownText);
// Insert into #markdown-container
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML = html;
const highlighter = window.hljs;
const highlight =
highlighter && typeof highlighter.highlightElement === "function"
? (block) => highlighter.highlightElement(block)
: highlighter && typeof highlighter.highlightBlock === "function"
? (block) => highlighter.highlightBlock(block)
: null;
if (highlight) {
container.querySelectorAll("pre code").forEach((block) => {
try {
highlight(block);
} catch (highlightError) {
dev.warn("Failed to highlight code block.", highlightError);
}
});
}
}
} catch (err) {
user.error("Error loading 'Getting Started' content:", err);
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML = `
<p class="text-critical-strong">Error loading content. Please try again later.</p>
`;
}
}
};
loadMarkdown();
})();
</script>
-74
View File
@@ -1,74 +0,0 @@
<!-- views/links.html -->
<section class="bv-stack" data-ds="new" aria-labelledby="linksHeading">
<article class="card p-lg">
<div class="bv-stack bv-stack--tight">
<h2 id="linksHeading" class="text-2xl font-semibold text-text-strong">
bitvid Links
</h2>
<div id="markdown-container" class="markdown-prose">
<p class="text-muted-strong">Loading content...</p>
</div>
</div>
</article>
</section>
<script>
(() => {
// Provide no-op fallback loggers so embeds stay functional if bitvidLogger is unavailable.
const noopLogger = {
log: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
const dev = window.bitvidLogger?.dev || noopLogger;
const user = window.bitvidLogger?.user || noopLogger;
const loadMarkdown = async () => {
try {
const response = await fetch("content/links.md");
if (!response.ok) {
throw new Error("Failed to load links.md");
}
const markdownText = await response.text();
// Convert the markdown to HTML (requires "marked" loaded globally)
const html = marked.parse(markdownText);
// Insert the HTML into the container
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML = html;
const highlighter = window.hljs;
const highlight =
highlighter && typeof highlighter.highlightElement === "function"
? (block) => highlighter.highlightElement(block)
: highlighter && typeof highlighter.highlightBlock === "function"
? (block) => highlighter.highlightBlock(block)
: null;
if (highlight) {
container.querySelectorAll("pre code").forEach((block) => {
try {
highlight(block);
} catch (highlightError) {
dev.warn("Failed to highlight code block.", highlightError);
}
});
}
}
} catch (err) {
user.error("Error loading Links content:", err);
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML = `
<p class="text-critical-strong">Error loading content. Please try again later.</p>
`;
}
}
};
loadMarkdown();
})();
</script>
-75
View File
@@ -1,75 +0,0 @@
<!-- views/roadmap.html -->
<section class="bv-stack" data-ds="new" aria-labelledby="roadmapHeading">
<article class="card p-lg">
<div class="bv-stack bv-stack--tight">
<h2 id="roadmapHeading" class="text-2xl font-semibold text-text-strong">
Roadmap
</h2>
<div id="markdown-container" class="markdown-prose">
<p class="text-muted-strong">Loading content...</p>
</div>
</div>
</article>
</section>
<script>
(() => {
// Provide no-op fallback loggers so embeds stay functional if bitvidLogger is unavailable.
const noopLogger = {
log: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
const dev = window.bitvidLogger?.dev || noopLogger;
const user = window.bitvidLogger?.user || noopLogger;
const loadMarkdown = async () => {
try {
// Fetch your roadmap markdown
const response = await fetch("content/roadmap.md");
if (!response.ok) {
throw new Error("Failed to load roadmap.md");
}
const markdownText = await response.text();
// Convert markdown to HTML (marked must be globally available)
const html = marked.parse(markdownText);
// Insert into the container
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML = html;
const highlighter = window.hljs;
const highlight =
highlighter && typeof highlighter.highlightElement === "function"
? (block) => highlighter.highlightElement(block)
: highlighter && typeof highlighter.highlightBlock === "function"
? (block) => highlighter.highlightBlock(block)
: null;
if (highlight) {
container.querySelectorAll("pre code").forEach((block) => {
try {
highlight(block);
} catch (highlightError) {
dev.warn("Failed to highlight code block.", highlightError);
}
});
}
}
} catch (err) {
user.error("Error loading roadmap:", err);
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML = `
<p class="text-critical-strong">Error loading content. Please try again later.</p>
`;
}
}
};
loadMarkdown();
})();
</script>
-77
View File
@@ -1,77 +0,0 @@
<!-- views/upload-content.html -->
<section class="bv-stack" data-ds="new" aria-labelledby="uploadContentHeading">
<article class="card p-lg">
<div class="bv-stack bv-stack--tight">
<div class="flex items-center gap-3">
<img src="assets/svg/upload-content-icon.svg" alt="" class="h-8 w-8 text-accent" aria-hidden="true" />
<h2 id="uploadContentHeading" class="text-2xl font-semibold text-text-strong">
Upload Content
</h2>
</div>
<div id="markdown-container" class="markdown-prose">
<p class="text-muted-strong">Loading content...</p>
</div>
</div>
</article>
</section>
<script>
(() => {
// Provide no-op fallback loggers so embeds stay functional if bitvidLogger is unavailable.
const noopLogger = {
log: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
const dev = window.bitvidLogger?.dev || noopLogger;
const user = window.bitvidLogger?.user || noopLogger;
const loadMarkdown = async () => {
try {
const response = await fetch("content/upload-content.md");
if (!response.ok) {
throw new Error("Failed to load upload-content.md");
}
const markdownText = await response.text();
// Convert the markdown to HTML (requires "marked" loaded globally)
const html = marked.parse(markdownText);
// Insert the HTML into the container
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML = html;
const highlighter = window.hljs;
const highlight =
highlighter && typeof highlighter.highlightElement === "function"
? (block) => highlighter.highlightElement(block)
: highlighter && typeof highlighter.highlightBlock === "function"
? (block) => highlighter.highlightBlock(block)
: null;
if (highlight) {
container.querySelectorAll("pre code").forEach((block) => {
try {
highlight(block);
} catch (highlightError) {
dev.warn("Failed to highlight code block.", highlightError);
}
});
}
}
} catch (err) {
user.error("Error loading Upload Content guide:", err);
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML = `
<p class="text-critical-strong">Error loading content. Please try again later.</p>
`;
}
}
};
loadMarkdown();
})();
</script>