mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-06 04:42:57 +00:00
184 files changed
+21961
-7624
No files matched your search
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "bitvid",
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
|
||||
"postCreateCommand": "npm ci",
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
|
||||
"postCreateCommand": "npm ci && npm run build",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"esbenp.prettier-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"stylelint.vscode-stylelint"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,3 +76,123 @@ jobs:
|
||||
- name: Fail if visual regressions detected
|
||||
if: steps.visual.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
dm-unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Cache npm directory
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ matrix.node-version }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ matrix.node-version }}-npm-
|
||||
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-${{ matrix.node-version }}-modules-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ matrix.node-version }}-modules-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Run DM unit tests
|
||||
run: npm run test:dm:unit
|
||||
|
||||
dm-integration-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Cache npm directory
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ matrix.node-version }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ matrix.node-version }}-npm-
|
||||
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-${{ matrix.node-version }}-modules-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ matrix.node-version }}-modules-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Run DM integration tests
|
||||
run: npm run test:dm:integration
|
||||
|
||||
e2e-headless:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Cache npm directory
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ matrix.node-version }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ matrix.node-version }}-npm-
|
||||
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-${{ matrix.node-version }}-modules-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ matrix.node-version }}-modules-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run headless E2E tests
|
||||
run: npm run test:e2e
|
||||
+17
-2
@@ -36,6 +36,7 @@ If we later introduce **dual-licensing** options (e.g., commercial licenses for
|
||||
Automated agents contributing to this repository should follow these rules:
|
||||
- **Atomic Commits**: Keep changes small and self-contained.
|
||||
- **Descriptive Titles**: Use clear, semantic titles (e.g., `fix(ui): resolve upload modal overflow`).
|
||||
- **Commit Messages**: Use the convention `type(scope): description (agent)` (e.g., `fix(ai): formatting (agent)` or `docs(ai): update quickstart (agent)`).
|
||||
- **Reference Issues**: Link to relevant issues in the PR description.
|
||||
- **Review AGENTS.md**: Always consult `AGENTS.md` for specific architectural guidelines and constraints before starting work.
|
||||
|
||||
@@ -56,12 +57,17 @@ To set up the project locally:
|
||||
npm ci
|
||||
```
|
||||
|
||||
2. **Run Tests**:
|
||||
2. **Build**:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. **Run Tests**:
|
||||
```bash
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
3. **Format & Lint**:
|
||||
4. **Format & Lint**:
|
||||
```bash
|
||||
npm run format
|
||||
npm run lint
|
||||
@@ -69,6 +75,15 @@ To set up the project locally:
|
||||
|
||||
For a full guide, see the [Local Setup section in README.md](./README.md#local-setup).
|
||||
|
||||
## Dev Container
|
||||
|
||||
This project includes a `.devcontainer` configuration for VS Code. It provides a pre-configured environment with Node.js 22 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.
|
||||
|
||||
## Security
|
||||
|
||||
If you find a vulnerability, please email **[[email protected]](mailto:[email protected])**. We’ll coordinate a responsible disclosure window before public release.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Lint Issues Report
|
||||
|
||||
## Summary
|
||||
Automated linting and formatting checks were run. No auto-fixable formatting issues were found. However, one linting error persists which requires manual intervention.
|
||||
|
||||
## Issues
|
||||
|
||||
### css/tailwind.source.css
|
||||
|
||||
The `npm run lint:tokens` check failed with the following error:
|
||||
|
||||
```
|
||||
css/tailwind.source.css:5757 → 767.98px
|
||||
@media (max-width: 767.98px) {
|
||||
```
|
||||
|
||||
**Recommendation:**
|
||||
This raw measurement (`767.98px`) should be moved into `css/tokens.css` as a design token, or the linter configuration (`scripts/check-design-tokens.mjs`) should be updated to whitelist this specific value if it represents a standard responsive breakpoint for mobile layout fixes.
|
||||
|
||||
Note: This error currently prevents `npm run lint` from completing successfully, meaning subsequent checks (`lint:tailwind-brackets`, `lint:tailwind-colors`) are skipped in the standard run script. These subsequent checks were run manually and passed.
|
||||
@@ -107,7 +107,13 @@ To run **bitvid** locally:
|
||||
npm ci
|
||||
```
|
||||
|
||||
3. Start a local server:
|
||||
3. Build the project:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
4. Start a local server:
|
||||
- Using Python:
|
||||
```bash
|
||||
python -m http.server 8000
|
||||
@@ -117,17 +123,20 @@ To run **bitvid** locally:
|
||||
npx serve
|
||||
```
|
||||
|
||||
4. Open the site in your browser:
|
||||
5. Open the site in your browser:
|
||||
|
||||
```
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
### Quickstart Commands
|
||||
|
||||
- **Run unit tests**: `npm run test:unit`
|
||||
- **Format code**: `npm run format`
|
||||
- **Lint code**: `npm run lint`
|
||||
- **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`
|
||||
|
||||
### Send your first video post
|
||||
|
||||
@@ -137,13 +146,14 @@ Use the event builders in `js/nostrEventSchemas.js` to construct valid video not
|
||||
import { buildVideoPostEvent } from "./js/nostrEventSchemas.js";
|
||||
|
||||
const event = buildVideoPostEvent({
|
||||
// Provide your hex pubkey (not npub)
|
||||
pubkey: "your_pubkey_hex",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
dTagValue: "my-first-video",
|
||||
dTagValue: "my-first-video", // The stable identifier (d-tag) for this video
|
||||
content: {
|
||||
version: 3,
|
||||
title: "My First Video",
|
||||
videoRootId: "my-first-video", // Logical ID, often matches d-tag
|
||||
videoRootId: "my-first-video", // Logical ID, typically 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
|
||||
@@ -393,10 +403,13 @@ placeholder at “—” and development builds log a warning—so mixed deploym
|
||||
|
||||
## Testing
|
||||
|
||||
Continuous integration runs CSS linting/builds, the Playwright kitchen-sink snapshots, and the Node-based unit tests on every push.
|
||||
Continuous integration runs CSS linting/builds, the DM unit/integration suites, the Playwright kitchen-sink snapshots, headless E2E flows, and the Node-based unit tests on every push.
|
||||
Before pushing, run `npm run build` locally so the Tailwind bundle regenerates.
|
||||
Pair that with `npm run test:unit` for application logic changes and
|
||||
`npm run test:visual` for presentation updates to mirror the CI surface area.
|
||||
For DM-specific changes, also run `npm run test:dm:unit` and
|
||||
`npm run test:dm:integration` to cover the direct message flows.
|
||||
Use `npm run test:e2e` to execute the headless Playwright journeys in `tests/e2e`.
|
||||
|
||||
### Manual QA checklist
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"auditReportVersion": 2,
|
||||
"vulnerabilities": {},
|
||||
"metadata": {
|
||||
"vulnerabilities": {
|
||||
"info": 0,
|
||||
"low": 0,
|
||||
"moderate": 0,
|
||||
"high": 0,
|
||||
"critical": 0,
|
||||
"total": 0
|
||||
},
|
||||
"dependencies": {
|
||||
"prod": 5,
|
||||
"dev": 316,
|
||||
"optional": 29,
|
||||
"peer": 8,
|
||||
"peerOptional": 0,
|
||||
"total": 320
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"cross-env": {
|
||||
"current": "7.0.3",
|
||||
"wanted": "7.0.3",
|
||||
"latest": "10.1.0",
|
||||
"dependent": "app",
|
||||
"location": "/app/node_modules/cross-env"
|
||||
},
|
||||
"esbuild": {
|
||||
"current": "0.25.12",
|
||||
"wanted": "0.25.12",
|
||||
"latest": "0.27.2",
|
||||
"dependent": "app",
|
||||
"location": "/app/node_modules/esbuild"
|
||||
},
|
||||
"jsdom": {
|
||||
"current": "24.1.3",
|
||||
"wanted": "24.1.3",
|
||||
"latest": "27.4.0",
|
||||
"dependent": "app",
|
||||
"location": "/app/node_modules/jsdom"
|
||||
},
|
||||
"pixelmatch": {
|
||||
"current": "5.3.0",
|
||||
"wanted": "5.3.0",
|
||||
"latest": "7.1.0",
|
||||
"dependent": "app",
|
||||
"location": "/app/node_modules/pixelmatch"
|
||||
},
|
||||
"prettier-plugin-tailwindcss": {
|
||||
"current": "0.6.14",
|
||||
"wanted": "0.6.14",
|
||||
"latest": "0.7.2",
|
||||
"dependent": "app",
|
||||
"location": "/app/node_modules/prettier-plugin-tailwindcss"
|
||||
},
|
||||
"stylelint": {
|
||||
"current": "16.26.1",
|
||||
"wanted": "16.26.1",
|
||||
"latest": "17.0.0",
|
||||
"dependent": "app",
|
||||
"location": "/app/node_modules/stylelint"
|
||||
},
|
||||
"stylelint-config-standard": {
|
||||
"current": "36.0.1",
|
||||
"wanted": "36.0.1",
|
||||
"latest": "40.0.0",
|
||||
"dependent": "app",
|
||||
"location": "/app/node_modules/stylelint-config-standard"
|
||||
},
|
||||
"tailwindcss": {
|
||||
"current": "3.4.19",
|
||||
"wanted": "3.4.19",
|
||||
"latest": "4.1.18",
|
||||
"dependent": "app",
|
||||
"location": "/app/node_modules/tailwindcss"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- Disclaimer Modal (wide, matching other modals) -->
|
||||
<div
|
||||
id="disclaimerModal"
|
||||
class="bv-modal hidden items-start justify-center"
|
||||
class="bv-modal hidden p-0 sm:p-0 lg:p-8 lg:items-start lg:justify-center overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="disclaimerModalTitle"
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="bv-modal-backdrop" data-dismiss></div>
|
||||
|
||||
<!-- Outer container with wide layout, just like content-appeals-form -->
|
||||
<div class="bv-modal__panel modal-sheet max-h-modal-shell flex flex-col overflow-hidden" tabindex="-1">
|
||||
<div class="modal-sheet w-full h-full max-w-none rounded-none border-0 lg:max-h-modal-shell lg:max-w-5xl lg:rounded-modal-xl lg:border lg:h-auto flex min-h-0 flex-col overflow-hidden" tabindex="-1">
|
||||
<!-- Main Content -->
|
||||
<div class="modal-body flex-1 overflow-y-auto">
|
||||
<div class="mx-auto max-w-3xl space-y-8 text-base text-text">
|
||||
|
||||
@@ -41,10 +41,10 @@
|
||||
<h2 id="lockdownModalTitle" class="text-2xl font-semibold tracking-tight text-text">
|
||||
bitvid is in lockdown mode
|
||||
</h2>
|
||||
<p class="text-base leading-relaxed text-text-frosted">
|
||||
<p id="lockdownModalMessage" class="text-base leading-relaxed text-text-frosted">
|
||||
Only approved administrators and moderators can access the app while lockdown is active. Sign in with an authorized Nostr profile to continue.
|
||||
</p>
|
||||
<p class="text-sm text-text-muted">
|
||||
<p id="lockdownModalHint" class="text-sm text-text-muted">
|
||||
You will be prompted by your NIP-07 browser extension. Make sure it is installed, unlocked, and ready to approve the request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div
|
||||
id="loginModal"
|
||||
class="bv-modal hidden items-start justify-center"
|
||||
class="bv-modal hidden p-0 sm:p-0 lg:p-8 lg:items-start lg:justify-center overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="loginModalTitle"
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="bv-modal-backdrop" data-dismiss></div>
|
||||
|
||||
<div
|
||||
class="modal-sheet max-h-modal-shell flex min-h-0 flex-col overflow-hidden"
|
||||
class="modal-sheet w-full h-full max-w-none rounded-none border-0 lg:max-h-modal-shell lg:max-w-5xl lg:rounded-modal-xl lg:border lg:h-auto flex min-h-0 flex-col overflow-hidden"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Header with the special exit button (unchanged) -->
|
||||
|
||||
+251
-248
@@ -1,7 +1,7 @@
|
||||
<!-- components/profile-modal.html -->
|
||||
<div
|
||||
id="profileModal"
|
||||
class="bv-modal hidden items-start"
|
||||
class="bv-modal hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="profileModalTitle"
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="bv-modal-backdrop"></div>
|
||||
|
||||
<div
|
||||
class="bv-modal__panel modal-sheet profile-modal"
|
||||
class="bv-modal__panel profile-modal w-full h-full max-w-none rounded-none border-none p-0"
|
||||
tabindex="-1"
|
||||
data-mobile-view="menu"
|
||||
>
|
||||
@@ -136,6 +136,15 @@
|
||||
>
|
||||
Friends
|
||||
</button>
|
||||
<button
|
||||
id="profileNavSafety"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneSafety"
|
||||
aria-selected="false"
|
||||
>
|
||||
Safety
|
||||
</button>
|
||||
<button
|
||||
id="profileNavBlocked"
|
||||
type="button"
|
||||
@@ -148,11 +157,16 @@
|
||||
<button
|
||||
id="profileNavMessages"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
class="btn-ghost focus-ring relative w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneMessages"
|
||||
aria-selected="false"
|
||||
>
|
||||
Messages
|
||||
<span
|
||||
id="profileMessagesUnreadDot"
|
||||
class="profile-nav__unread-dot"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
<button
|
||||
id="profileNavHistory"
|
||||
@@ -172,6 +186,14 @@
|
||||
>
|
||||
Admin (Moderation)
|
||||
</button>
|
||||
<button
|
||||
id="profileMobileLogoutBtn"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-6 lg:hidden"
|
||||
data-variant="critical"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -212,7 +234,7 @@
|
||||
data-state="active"
|
||||
>
|
||||
<span
|
||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-info/50 bg-surface"
|
||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-accent/50 bg-surface"
|
||||
>
|
||||
<img
|
||||
id="profileModalAvatar"
|
||||
@@ -223,7 +245,7 @@
|
||||
</span>
|
||||
<div class="profile-modal__active-details">
|
||||
<div class="profile-modal__active-meta">
|
||||
<span class="text-3xs font-semibold uppercase tracking-extra-wide text-info">
|
||||
<span class="text-3xs font-semibold uppercase tracking-extra-wide text-accent">
|
||||
Active profile
|
||||
</span>
|
||||
</div>
|
||||
@@ -287,162 +309,6 @@
|
||||
Profiles you add here will stay on this device so you can quickly switch before posting or moderating.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="profileModerationSettings" class="card space-y-5 p-5">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-base font-semibold text-text">Safety & Moderation</h3>
|
||||
<p class="text-sm text-muted">
|
||||
Choose how many trusted reports or mutes it takes to blur thumbnails, block autoplay previews, or hide videos entirely.
|
||||
Lower numbers are stricter.
|
||||
</p>
|
||||
<div class="rounded-lg border border-border bg-bg-muted p-3 text-xs text-muted">
|
||||
<p>
|
||||
Trusted counts include your first-degree follows (F1) plus seeded moderators.
|
||||
</p>
|
||||
<dl class="mt-2 grid gap-1">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt>Trusted contacts</dt>
|
||||
<dd id="profileModerationTrustedContactsCount" class="font-semibold text-text">0</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt>Active trusted mute contributors</dt>
|
||||
<dd id="profileModerationTrustedMuteCount" class="font-semibold text-text">0</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt>Active trusted report contributors</dt>
|
||||
<dd id="profileModerationTrustedReportCount" class="font-semibold text-text">0</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
id="profileModerationSeedOnlyIndicator"
|
||||
class="mt-2 flex items-center gap-1 text-xs text-warning-strong"
|
||||
hidden
|
||||
>
|
||||
<span aria-hidden="true">●</span>
|
||||
<span>Seeded moderators only</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label
|
||||
class="block text-sm text-muted-strong"
|
||||
for="profileModerationBlurThreshold"
|
||||
>
|
||||
<span class="mb-2 block font-medium">Blur after trusted reports</span>
|
||||
<input
|
||||
id="profileModerationBlurThreshold"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
/>
|
||||
<span class="mt-1 block text-xs text-muted">
|
||||
Thumbnails blur when trusted contacts report nudity at or above this count.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="block text-sm text-muted-strong"
|
||||
for="profileModerationAutoplayThreshold"
|
||||
>
|
||||
<span class="mb-2 block font-medium">Block autoplay after reports</span>
|
||||
<input
|
||||
id="profileModerationAutoplayThreshold"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
/>
|
||||
<span class="mt-1 block text-xs text-muted">
|
||||
Autoplay previews pause once trusted reports meet this threshold.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="grid gap-4 sm:grid-cols-2"
|
||||
data-role="trusted-hide-controls"
|
||||
>
|
||||
<label
|
||||
class="block text-sm text-muted-strong"
|
||||
for="profileModerationMuteHideThreshold"
|
||||
data-role="trusted-hide-control"
|
||||
>
|
||||
<span class="mb-2 block font-medium">Hide after trusted mutes</span>
|
||||
<input
|
||||
id="profileModerationMuteHideThreshold"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
/>
|
||||
<span class="mt-1 block text-xs text-muted">
|
||||
Hide videos when this many trusted contacts add the author to their mute list.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="block text-sm text-muted-strong"
|
||||
for="profileModerationSpamHideThreshold"
|
||||
data-role="trusted-hide-control"
|
||||
>
|
||||
<span class="mb-2 block font-medium">Hide after trusted spam reports</span>
|
||||
<input
|
||||
id="profileModerationSpamHideThreshold"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
/>
|
||||
<span class="mt-1 block text-xs text-muted">
|
||||
Hide videos entirely once trusted contacts report spam at or above this count.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
id="profileModerationSave"
|
||||
type="button"
|
||||
class="btn focus-ring"
|
||||
disabled
|
||||
>
|
||||
Save moderation settings
|
||||
</button>
|
||||
<button
|
||||
id="profileModerationReset"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring"
|
||||
>
|
||||
Restore defaults
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="profileModerationStatus"
|
||||
class="text-sm text-muted"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
<div class="space-y-3 border-t border-border/60 pt-4">
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-text">Active overrides</h4>
|
||||
<p class="text-xs text-muted">
|
||||
Videos you unblocked with “Show anyway” stay listed here until reset.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="profileModerationOverridesEmpty"
|
||||
class="card border border-dashed border-border/60 p-4 text-center text-xs text-muted"
|
||||
>
|
||||
No moderation overrides yet.
|
||||
</div>
|
||||
<ul
|
||||
id="profileModerationOverridesList"
|
||||
class="grid-stack hidden"
|
||||
data-orientation="vertical"
|
||||
></ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -492,7 +358,7 @@
|
||||
<span class="mb-2 block font-medium">About</span>
|
||||
<textarea
|
||||
id="editAboutInput"
|
||||
class="input min-h-[80px]"
|
||||
class="input min-h-[var(--size-80px)]"
|
||||
placeholder="Tell us about yourself..."
|
||||
></textarea>
|
||||
</label>
|
||||
@@ -644,6 +510,44 @@
|
||||
</div>
|
||||
</div>
|
||||
<ul id="relayList" class="grid-stack" data-orientation="vertical"></ul>
|
||||
<div id="relayHealthPanel" class="card space-y-4 p-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-semibold text-text">Relay health</h3>
|
||||
<p class="text-sm text-muted">
|
||||
Monitor connectivity, latency, and errors for your configured relays.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="relayHealthRefreshBtn"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-xs"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<label
|
||||
for="relayHealthTelemetryOptIn"
|
||||
class="inline-flex items-center gap-2 text-xs font-medium text-muted-strong"
|
||||
>
|
||||
<input
|
||||
id="relayHealthTelemetryOptIn"
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
/>
|
||||
Share relay health telemetry
|
||||
</label>
|
||||
<p class="text-xs text-muted">
|
||||
Telemetry is opt-in and only includes relay URLs, latency, and error counts.
|
||||
</p>
|
||||
<ul id="relayHealthList" class="grid-stack" data-orientation="vertical"></ul>
|
||||
<p
|
||||
id="relayHealthStatus"
|
||||
class="text-xs text-muted"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -965,6 +869,189 @@
|
||||
<ul id="friendsList" class="grid-stack" data-orientation="vertical"></ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneSafety"
|
||||
class="profile-pane hidden space-y-6"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavSafety"
|
||||
>
|
||||
<div id="profileModerationSettings" class="card space-y-5 p-5">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-base font-semibold text-text">Safety</h3>
|
||||
<p class="text-sm text-muted">
|
||||
Choose how many trusted reports or mutes it takes to blur thumbnails, block autoplay previews, or hide videos entirely.
|
||||
Lower numbers are stricter.
|
||||
</p>
|
||||
<div class="rounded-lg border border-border bg-bg-muted p-3 text-xs text-muted">
|
||||
<p>
|
||||
Trusted counts include your first-degree follows (F1) plus seeded moderators.
|
||||
</p>
|
||||
<dl class="mt-2 grid gap-1">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt>Trusted contacts</dt>
|
||||
<dd id="profileModerationTrustedContactsCount" class="font-semibold text-text">0</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt>Active trusted mute contributors</dt>
|
||||
<dd id="profileModerationTrustedMuteCount" class="font-semibold text-text">0</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt>Active trusted report contributors</dt>
|
||||
<dd id="profileModerationTrustedReportCount" class="font-semibold text-text">0</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
id="profileModerationSeedOnlyIndicator"
|
||||
class="mt-2 flex items-center gap-1 text-xs text-warning-strong"
|
||||
hidden
|
||||
>
|
||||
<span aria-hidden="true">●</span>
|
||||
<span>Seeded moderators only</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label
|
||||
class="block text-sm text-muted-strong"
|
||||
for="profileModerationBlurThreshold"
|
||||
>
|
||||
<span class="mb-2 block font-medium">Blur after trusted reports</span>
|
||||
<input
|
||||
id="profileModerationBlurThreshold"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
/>
|
||||
<span class="mt-1 block text-xs text-muted">
|
||||
Thumbnails blur when trusted contacts report nudity at or above this count.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="block text-sm text-muted-strong"
|
||||
for="profileModerationAutoplayThreshold"
|
||||
>
|
||||
<span class="mb-2 block font-medium">Block autoplay after reports</span>
|
||||
<input
|
||||
id="profileModerationAutoplayThreshold"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
/>
|
||||
<span class="mt-1 block text-xs text-muted">
|
||||
Autoplay previews pause once trusted reports meet this threshold.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="grid gap-4 sm:grid-cols-2"
|
||||
data-role="trusted-hide-controls"
|
||||
>
|
||||
<label
|
||||
class="block text-sm text-muted-strong"
|
||||
for="profileModerationMuteHideThreshold"
|
||||
data-role="trusted-hide-control"
|
||||
>
|
||||
<span class="mb-2 block font-medium">Hide after trusted mutes</span>
|
||||
<input
|
||||
id="profileModerationMuteHideThreshold"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
/>
|
||||
<span class="mt-1 block text-xs text-muted">
|
||||
Hide videos when this many trusted contacts add the author to their mute list.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="block text-sm text-muted-strong"
|
||||
for="profileModerationSpamHideThreshold"
|
||||
data-role="trusted-hide-control"
|
||||
>
|
||||
<span class="mb-2 block font-medium">Hide after trusted spam reports</span>
|
||||
<input
|
||||
id="profileModerationSpamHideThreshold"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
/>
|
||||
<span class="mt-1 block text-xs text-muted">
|
||||
Hide videos entirely once trusted contacts report spam at or above this count.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
id="profileModerationSave"
|
||||
type="button"
|
||||
class="btn focus-ring"
|
||||
disabled
|
||||
>
|
||||
Save moderation settings
|
||||
</button>
|
||||
<button
|
||||
id="profileModerationReset"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring"
|
||||
>
|
||||
Restore defaults
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="profileModerationStatus"
|
||||
class="text-sm text-muted"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
<div class="space-y-3 border-t border-border/60 pt-4">
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-text">Active overrides</h4>
|
||||
<p class="text-xs text-muted">
|
||||
Videos you unblocked with “Show anyway” stay listed here until reset.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="profileModerationOverridesEmpty"
|
||||
class="card border border-dashed border-border/60 p-4 text-center text-xs text-muted"
|
||||
>
|
||||
No moderation overrides yet.
|
||||
</div>
|
||||
<ul
|
||||
id="profileModerationOverridesList"
|
||||
class="grid-stack hidden"
|
||||
data-orientation="vertical"
|
||||
></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="profileLinkPreviewSettings" class="card space-y-4 p-5">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-base font-semibold text-text">Link previews</h3>
|
||||
<p class="text-sm text-muted">
|
||||
Control when bitvid fetches link preview metadata. Disable auto-fetch to review new domains before previews load.
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
for="profileLinkPreviewAutoToggle"
|
||||
class="inline-flex items-center gap-2 text-xs font-medium text-muted-strong"
|
||||
>
|
||||
<input
|
||||
id="profileLinkPreviewAutoToggle"
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
/>
|
||||
Auto-fetch previews from new domains
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneBlocked"
|
||||
class="profile-pane hidden space-y-4"
|
||||
@@ -1009,91 +1096,7 @@
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavMessages"
|
||||
>
|
||||
<p class="text-sm text-muted">
|
||||
View encrypted conversations sent with NIP-04 or NIP-44. bitvid keeps message data on this device only.
|
||||
</p>
|
||||
<div
|
||||
id="profileMessagesStatus"
|
||||
class="sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div
|
||||
id="profileMessagesLoading"
|
||||
class="hidden py-6"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="sidebar-loading-wrapper">
|
||||
<div class="sidebar-loading-indicator">
|
||||
<span
|
||||
class="status-spinner status-spinner--inline"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="sidebar-loading-text"
|
||||
>Fetching direct messages from relays…</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="profileMessagesError"
|
||||
class="card hidden border border-critical/40 bg-critical/10 px-4 py-3 text-sm text-critical"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
></div>
|
||||
<div
|
||||
id="profileMessagesEmpty"
|
||||
class="card hidden border border-dashed border-border/60 p-6 text-center text-sm text-muted"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
No direct messages yet.
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
id="profileMessagesReload"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring inline-flex items-center gap-2"
|
||||
data-size="sm"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<ul
|
||||
id="profileMessagesList"
|
||||
class="grid-stack hidden"
|
||||
data-orientation="vertical"
|
||||
aria-live="polite"
|
||||
aria-label="Direct message threads"
|
||||
></ul>
|
||||
<div class="flex flex-col gap-3 border-t border-border/60 pt-4">
|
||||
<label class="sr-only" for="profileMessageInput">Message</label>
|
||||
<textarea
|
||||
id="profileMessageInput"
|
||||
class="input min-h-[96px]"
|
||||
rows="3"
|
||||
placeholder="Write a message…"
|
||||
></textarea>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p
|
||||
id="profileMessagesComposerHelper"
|
||||
class="text-xs text-muted hidden"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
Sign in to send messages.
|
||||
</p>
|
||||
<button
|
||||
id="profileMessageSendBtn"
|
||||
type="button"
|
||||
class="btn focus-ring inline-flex items-center gap-2"
|
||||
data-size="sm"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dmAppShellMount"></div>
|
||||
</section>
|
||||
<section
|
||||
id="profilePaneHistory"
|
||||
@@ -1349,7 +1352,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex items-center justify-end gap-3 border-t border-border/60 p-4">
|
||||
<div class="hidden lg:flex items-center justify-end gap-3 border-t border-border/60 p-4">
|
||||
<button id="profileLogoutBtn" class="btn focus-ring" data-variant="danger">
|
||||
Logout
|
||||
</button>
|
||||
|
||||
@@ -521,6 +521,12 @@
|
||||
>
|
||||
No description available.
|
||||
</p>
|
||||
<div
|
||||
id="videoDescriptionPreviews"
|
||||
class="mt-4 space-y-3"
|
||||
data-link-previews
|
||||
hidden
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -42,6 +42,23 @@ export const IS_VERBOSE_DEV_MODE = true;
|
||||
*/
|
||||
export const IS_LOCKDOWN_MODE = false;
|
||||
|
||||
/**
|
||||
* The title displayed in the header when the site is in lockdown mode.
|
||||
*
|
||||
* Customize this to match the tone of your maintenance window or incident
|
||||
* response. Default is "Maintenance Mode".
|
||||
*/
|
||||
export const LOCKDOWN_TITLE = "Maintenance Mode";
|
||||
|
||||
/**
|
||||
* The primary message displayed to visitors when the site is in lockdown mode.
|
||||
*
|
||||
* This text should explain why the site is unavailable and when users might
|
||||
* expect it to return. Default is generic maintenance copy.
|
||||
*/
|
||||
export const LOCKDOWN_MESSAGE =
|
||||
"We are currently undergoing maintenance. Please come back later.";
|
||||
|
||||
/**
|
||||
* The primary administrator for this bitvid instance.
|
||||
*
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
:root{--surface-base:var(--color-surface);--surface-alt:var(--color-surface-alt);--surface-raised:var(--color-card);--surface-raised-private:var(--color-card-private);--surface-raised-critical:var(--color-card-critical);--surface-panel:var(--color-panel);--surface-panel-hover:var(--color-panel-hover);--surface-sunken:var(--color-surface-sunken);--surface-muted:var(--color-surface-muted);--surface-veil:var(--color-overlay-veil);--surface-inverse:var(--color-page);--surface-overlay:var(--color-overlay);--surface-overlay-muted:var(--color-overlay-muted);--surface-overlay-strong:var(--color-overlay-strong);--surface-overlay-darker:var(--color-overlay-darker);--surface-overlay-black:var(--color-overlay-black);--surface-overlay-banner-base:var(--color-page);--surface-overlay-banner:var(--surface-overlay-banner-base);--surface-overlay-banner-opaque:var(--surface-overlay-banner-base);--surface-overlay-banner-strong:color-mix(in srgb,var(--surface-overlay-banner-base) 82%,transparent);--surface-overlay-banner-bottom:color-mix(in srgb,var(--surface-overlay-banner-base) 50%,transparent);--surface-overlay-banner-channel-mid:color-mix(in srgb,var(--surface-overlay-banner-base) 24%,transparent);--surface-overlay-banner-channel-faint:color-mix(in srgb,var(--surface-overlay-banner-base) 10%,transparent);--surface-overlay-banner-soft:color-mix(in srgb,var(--surface-overlay-banner-base) 56%,transparent);--surface-overlay-banner-faint:color-mix(in srgb,var(--surface-overlay-banner-base) 18%,transparent);--surface-overlay-banner-transparent:transparent;--surface-overlay-banner-gradient-header:linear-gradient(to bottom,var(--surface-overlay-banner-opaque) 0%,var(--surface-overlay-banner-strong) 38%,var(--surface-overlay-banner-soft) 70%,var(--surface-overlay-banner-faint) 88%,var(--surface-overlay-banner-transparent) 100%);--surface-overlay-banner-gradient-channel:linear-gradient(to top,var(--surface-overlay-banner-bottom) 0%,var(--surface-overlay-banner-channel-mid) 28%,var(--surface-overlay-banner-channel-faint) 42%,var(--surface-overlay-banner-transparent) 50%);--surface-overlay-banner-gradient:var(
|
||||
--surface-overlay-banner-gradient-header
|
||||
);--surface-overlay-panel:var(--color-overlay-panel);--surface-overlay-panel-soft:var(--color-overlay-panel-soft);--surface-overlay-panel-tint:var(--color-overlay-panel-tint);--surface-overlay-panel-glass:var(--color-overlay-panel-glass);--surface-overlay-panel-glass-soft:var(--color-overlay-panel-glass-soft);--surface-modal-backdrop:var(--color-modal-backdrop);--surface-modal:var(--color-modal-surface);--blur-modal-backdrop:18px;--blur-modal-alert:14px;--blur-thumbnail-blur:18px;--modal-nav-backdrop-blur:6px;--app-header-centerline-offset:calc(var(--space-sm) + var(--space-2xl-plus)/2);--border-default:var(--color-border);--border-subtle:var(--color-border-subtle);--border-strong:var(--color-border-translucent-stronger);--border-overlay:var(--color-border-translucent);--border-overlay-strong:var(--color-border-translucent-strongest);--border-overlay-glow:var(--color-border-translucent-glow);--border-neutral-soft:var(--color-border-neutral-soft);--border-info-soft:var(--color-border-info-soft);--border-sidebar-strong:var(--color-border-sidebar-strong);--border-width-hairline:1px;--border-width-fine:0.5px;--text-primary:var(--color-text);--text-inverse:var(--color-text-inverse);--text-muted:var(--color-text-muted);--text-muted-strong:var(--color-muted-strong);--text-subtle:var(--color-subtle-text);--text-frosted-primary:var(--color-text-frosted);--text-frosted-strong:var(--color-text-frosted-strong);--text-frosted-soft:var(--color-text-frosted-soft);--status-info:var(--color-info);--status-info-strong:var(--color-info-strong);--status-info-surface:var(--color-info-glass);--status-info-border:var(--color-info-border);--status-info-on:var(--color-info-on);--status-success:var(--color-success);--status-success-strong:var(--color-success-strong);--status-success-surface:var(--color-success-surface);--status-success-border:var(--color-success-border);--status-success-on:var(--color-success-on);--status-warning:var(--color-warning);--status-warning-strong:var(--color-warning-strong);--status-warning-surface:var(--color-warning-surface);--status-warning-border:var(--color-warning-border);--status-warning-on:var(--color-warning-on);--status-danger:var(--color-critical);--status-danger-strong:var(--color-critical-strong);--status-danger-surface:var(--color-danger-surface);--status-danger-border:var(--color-danger-border);--status-danger-on:var(--color-danger-on);--status-private:var(--color-private);--status-private-strong:var(--color-private-strong);--status-private-surface:var(--color-private-surface);--status-private-border:var(--color-private-border);--status-private-on:var(--color-private-on);--status-neutral:var(--color-muted);--status-neutral-strong:var(--color-muted-strong);--status-neutral-surface:var(--color-neutral-surface);--status-neutral-border:var(--color-neutral-border);--status-neutral-on:var(--color-neutral-on);--scrollbar-width:0.5rem;--scrollbar-track:var(--color-white-soft);--scrollbar-thumb:var(--color-white-softer);--scrollbar-thumb-hover:var(--color-white-softest);--z-app-header:40;--z-overlay-nav:60;--z-overlay-floating:70;--z-overlay-popover:140;--z-overlay-toast:180;--z-sidebar-overlay:10;--z-sidebar:20;--z-modal-root:100;--z-modal-overlay:110;--z-modal-content:120;--z-modal-player:130;--z-modal-top-root:140;--z-modal-top-overlay:150;--z-modal-top-content:160;--z-modal-peak-root:165;--z-modal-peak-overlay:175;--z-modal-peak-content:185;--overlay-backdrop-color:var(--color-overlay-backdrop);--overlay-backdrop-blur:12px;--sidebar-width-expanded-base:16rem;--sidebar-dropup-content-width:0px;--sidebar-width-expanded:max(var(--sidebar-width-expanded-base),var(--sidebar-dropup-content-width));--sidebar-width-collapsed:4rem;--sidebar-width:var(--sidebar-width-expanded);--sidebar-control-size:2.75rem;--sidebar-content-gap:1rem;--space-sidebar-shell-inline:var(--space-lg);--space-sidebar-shell-block-start:var(--space-sm);--space-sidebar-shell-block-end:1.75rem;--space-sidebar-scroll-gap:1.75rem;--space-sidebar-panel-gap:1.35rem;--space-sidebar-panel-padding-block:1.35rem;--space-sidebar-panel-padding-inline:1.1rem;--space-sidebar-footer-gap:0.85rem;--space-sidebar-trigger-padding-block:0.78rem;--space-sidebar-popover-offset:0.85rem;--space-sidebar-popover-padding-block:0.85rem;--space-sidebar-popover-padding-inline:0.65rem;--space-sidebar-popover-gap:0.45rem;--space-sidebar-dropup-scroll-reserve:calc(var(--scrollbar-thin-width) + var(--space-sm) + var(--space-2xs));--sidebar-dropup-max-height:18rem;--space-sidebar-nav-padding-block:0.7rem;--space-sidebar-nav-padding-inline-start:var(--space-xs);--space-sidebar-nav-padding-inline-end:calc(var(--space-lg) + var(--space-2xs));--space-sidebar-subnav-padding-block:0.55rem;--space-sidebar-nav-translate:0.35rem;--grid-card-min-width:20rem;--breakpoint-lg:64rem;--popover-backdrop-blur:12px;--overlay-panel-radius:var(--radius-lg);--overlay-panel-shadow:var(--shadow-lg);--overlay-panel-padding-inline:var(--popup-padding-inline);--overlay-panel-padding-blocLine truncated
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-family-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--font-family-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{font-size:80%}@media (min-width:768px){html{font-size:100%}}@media (min-width:1024px){html{font-size:75%}}*,:after,:before{scrollbar-width:thin;scrollbar-color:var(--scrollbar-thumb) var(--scrollbar-track)}::-webkit-scrollbar{width:var(--scrollbar-width);height:var(--scrollbar-width)}::-webkit-scrollbar-track{background:var(--scrollbar-track)}::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);border-radius:var(--radius-sm)}::-webkit-scrollbar-thumb:hover{background:var(--scrollbar-thumb-hover)}body{--app-header-centerline:calc(env(safe-area-inset-top, 0) + var(--app-header-centerline-offset));font-family:var(--font-family-sans);background-color:var(--color-page);color:var(--color-text-strong);line-height:1.5;margin:0;padding:0;overflow-x:hidden}:root[data-theme-accent=override]{--color-accent:var(--color-accent-override);--color-accent-strong:var(--color-accent-strong-override);--color-accent-pressed:var(--color-accent-pressed-override)}#uiOverlay{position:fixed;inset:0;pointer-events:none;z-index:var(--z-overlay-popover)}#uiOverlay>*{pointer-events:auto}#editNip71TagsFieldset>legend,#nip71TagsFieldset>legend{margin-left:0;padding:0}#app{background-color:var(--color-page);color:inherit}body.modal-open,html.modal-open{overflow:hidden;height:100%}body.modal-open #app{overflow:hidden}body.modal-open #sidebar{pointer-events:none;opacity:0;visibility:hidden;z-index:var(--z-sidebar-overlay)}.ng-cloak,.ng-hide:not(.ng-hide-animate),.x-ng-cloak,[data-ng-cloak],[ng-cloak],[ng\:cloak],[x-ng-cloak]{display:none!important}ng\:form{display:block}.\!container{width:100%!important}.container{width:100%}@media (min-width:25rem){.\!container{max-width:25rem!important}.container{max-width:25rem}}@media (min-width:30rem){.\!container{max-width:30rem!important}.container{max-width:30rem}}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}#playerModal.ds-header{display:block}.ds-header{display:flex;align-items:center;justify-content:flex-start;padding-top:var(--space-sm);padding-bottom:var(--space-sm)}.app-shell__header{position:sticky;top:env(safe-area-inset-top,0);z-index:var(--z-app-header);padding-top:calc(var(--space-sm) + env(safe-area-inset-top, 0));padding-bottom:vaLine truncated
|
||||
);--surface-overlay-panel:var(--color-overlay-panel);--surface-overlay-panel-soft:var(--color-overlay-panel-soft);--surface-overlay-panel-tint:var(--color-overlay-panel-tint);--surface-overlay-panel-glass:var(--color-overlay-panel-glass);--surface-overlay-panel-glass-soft:var(--color-overlay-panel-glass-soft);--surface-modal-backdrop:var(--color-modal-backdrop);--surface-modal:var(--color-modal-surface);--blur-modal-backdrop:18px;--blur-modal-alert:14px;--blur-thumbnail-blur:18px;--modal-nav-backdrop-blur:6px;--app-header-centerline-offset:calc(var(--space-sm) + var(--space-2xl-plus)/2);--border-default:var(--color-border);--border-subtle:var(--color-border-subtle);--border-strong:var(--color-border-translucent-stronger);--border-overlay:var(--color-border-translucent);--border-overlay-strong:var(--color-border-translucent-strongest);--border-overlay-glow:var(--color-border-translucent-glow);--border-neutral-soft:var(--color-border-neutral-soft);--border-info-soft:var(--color-border-info-soft);--border-sidebar-strong:var(--color-border-sidebar-strong);--border-width-hairline:1px;--border-width-fine:0.5px;--text-primary:var(--color-text);--text-inverse:var(--color-text-inverse);--text-muted:var(--color-text-muted);--text-muted-strong:var(--color-muted-strong);--text-subtle:var(--color-subtle-text);--text-frosted-primary:var(--color-text-frosted);--text-frosted-strong:var(--color-text-frosted-strong);--text-frosted-soft:var(--color-text-frosted-soft);--status-info:var(--color-info);--status-info-strong:var(--color-info-strong);--status-info-surface:var(--color-info-glass);--status-info-border:var(--color-info-border);--status-info-on:var(--color-info-on);--status-success:var(--color-success);--status-success-strong:var(--color-success-strong);--status-success-surface:var(--color-success-surface);--status-success-border:var(--color-success-border);--status-success-on:var(--color-success-on);--status-warning:var(--color-warning);--status-warning-strong:var(--color-warning-strong);--status-warning-surface:var(--color-warning-surface);--status-warning-border:var(--color-warning-border);--status-warning-on:var(--color-warning-on);--status-danger:var(--color-critical);--status-danger-strong:var(--color-critical-strong);--status-danger-surface:var(--color-danger-surface);--status-danger-border:var(--color-danger-border);--status-danger-on:var(--color-danger-on);--status-private:var(--color-private);--status-private-strong:var(--color-private-strong);--status-private-surface:var(--color-private-surface);--status-private-border:var(--color-private-border);--status-private-on:var(--color-private-on);--status-neutral:var(--color-muted);--status-neutral-strong:var(--color-muted-strong);--status-neutral-surface:var(--color-neutral-surface);--status-neutral-border:var(--color-neutral-border);--status-neutral-on:var(--color-neutral-on);--message-bubble-background:var(--surface-sunken);--message-bubble-border:var(--border-subtle);--message-bubble-text:var(--text-primary);--message-bubble-meta:var(--text-muted);--message-bubble-outgoing-background:var(--surface-overlay-panel);--message-bubble-outgoing-border:var(--border-overlay);--unread-badge-background:var(--status-info-strong);--unread-badge-text:var(--text-inverse);--reply-highlight-background:var(--status-info-surface);--reply-highlight-border:var(--status-info-border);--reply-highlight-text:var(--status-info-on);--reply-highlight-icon:var(--status-info-on);--notification-banner-accent:var(--color-accent-strong);--notification-banner-accent-strong:var(--color-accent);--notification-banner-border:var(--surface-overlay-panel-tint);--notification-banner-background:var(--surface-overlay-panel);--notification-banner-text:var(--text-primary);--notification-banner-icon:var(--text-primary);--notification-banner-critical-accent:var(--status-danger-strong);--notification-banner-critical-accent-strong:var(--status-danger);--notification-banner-critical-border:var(--color-ring-critical-soft);--notification-banner-critical-background:var(--surface-raised-critical);--notification-banner-critical-text:var(--status-danger-on);--notification-banner-success-accent:var(--status-success-strong);--notification-banner-success-accent-strong:var(--status-success);--notification-banner-success-border:var(--status-success-border);--notification-banner-success-background:var(--status-success-surface);--notification-banner-success-text:var(--status-success-on);--notification-banner-info-accent:var(--status-info-strong);--notification-banner-info-accent-strong:var(--status-info);--notification-banner-info-border:var(--status-info-border);--notification-banner-info-background:var(--status-info-surface);--notification-banner-info-text:var(--status-info-on);--notification-banner-warning-accent:var(--status-warning-strong);--notification-banner-warning-accent-strong:var(--status-warning);--notification-banner-warning-border:var(--status-warning-border);--notification-banner-warning-background:var(--Line truncated
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-family-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--font-family-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{font-size:80%}@media (min-width:768px){html{font-size:100%}}@media (min-width:1024px){html{font-size:75%}}*,:after,:before{scrollbar-width:thin;scrollbar-color:var(--scrollbar-thumb) var(--scrollbar-track)}::-webkit-scrollbar{width:var(--scrollbar-width);height:var(--scrollbar-width)}::-webkit-scrollbar-track{background:var(--scrollbar-track)}::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);border-radius:var(--radius-sm)}::-webkit-scrollbar-thumb:hover{background:var(--scrollbar-thumb-hover)}body{--app-header-centerline:calc(env(safe-area-inset-top, 0) + var(--app-header-centerline-offset));font-family:var(--font-family-sans);background-color:var(--color-page);color:var(--color-text-strong);line-height:1.5;margin:0;padding:0;overflow-x:hidden}:root[data-theme-accent=override]{--color-accent:var(--color-accent-override);--color-accent-strong:var(--color-accent-strong-override);--color-accent-pressed:var(--color-accent-pressed-override)}#uiOverlay{position:fixed;inset:0;pointer-events:none;z-index:var(--z-overlay-popover)}#uiOverlay>*{pointer-events:auto}#editNip71TagsFieldset>legend,#nip71TagsFieldset>legend{margin-left:0;padding:0}#app{background-color:var(--color-page);color:inherit}body.modal-open,html.modal-open{overflow:hidden;height:100%}body.modal-open #app{overflow:hidden}body.modal-open #sidebar{pointer-events:none;opacity:0;visibility:hidden;z-index:var(--z-sidebar-overlay)}.ng-cloak,.ng-hide:not(.ng-hide-animate),.x-ng-cloak,[data-ng-cloak],[ng-cloak],[ng\:cloak],[x-ng-cloak]{display:none!important}ng\:form{display:block}.\!container{width:100%!important}.container{width:100%}@media (min-width:25rem){.\!container{max-width:25rem!important}.container{max-width:25rem}}@media (min-width:30rem){.\!container{max-width:30rem!important}.container{max-width:30rem}}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}#playerModal.ds-header{display:block}.ds-header{display:flex;align-items:center;justify-content:flex-start;padding-top:var(--space-sm);padding-bottom:var(--space-sm)}.app-shell__header{position:sticky;top:env(safe-area-inset-top,0);z-index:var(--z-app-header);padding-top:calc(var(--space-sm) + env(safe-area-inset-top, 0));padding-bottom:vaLine truncated
|
||||
--motion-video-card-backdrop-translate
|
||||
);--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong)}.\!card:focus-within,.\!card:hover{--video-card-lift-translate-y:var(--motion-video-card-hover-translate)!important;--video-card-backdrop-opacity:0.425!important;--video-card-backdrop-translate-y:var(
|
||||
--motion-video-card-backdrop-translate
|
||||
)!important;--video-card-backdrop-scale:1.12!important;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong)!important;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.card:focus-within,.card:hover{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card:focus-within,.\!card:hover{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card[data-state=critical],.card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.\!card[data-state=private],.card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card[data-state=disabled],.card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.video-card__title{transition:color var(--motion-duration-base) var(--motion-ease-in-out)}.video-card__title:focus-visible,.video-card__title:hover{color:var(--color-accent)}.form-control{outline:2px solid transparent;outline-offset:2px}.form-control:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.form-control{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.form-control::-moz-placeholder{color:var(--text-muted)}.form-control::placeholder{color:var(--text-muted)}.form-control:hover{border-color:var(--border-default)}.form-control:focus-visible{border-color:var(--color-accent-strong)}.form-control:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.form-control:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.\!input[data-state=locked],.form-control[data-state=locked],.input[data-state=locked],.select[data-state=locked],.textarea[data-state=locked],select.form-control[data-state=locked],textarea.form-control[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.form-control[data-state=locked]:focus-visible,.input[data-state=locked]:focus-visible,.select[data-state=locked]:focus-visible,.textarea[data-state=locked]:focus-visible,select.form-control[data-state=locked]:focus-visible,textarea.form-control[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--Line truncated
|
||||
)!important;--video-card-backdrop-scale:1.12!important;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong)!important;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.card:focus-within,.card:hover{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card:focus-within,.\!card:hover{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card[data-state=critical],.card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.\!card[data-state=private],.card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card[data-state=disabled],.card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.video-card__title{transition:color var(--motion-duration-base) var(--motion-ease-in-out)}.video-card__title:focus-visible,.video-card__title:hover{color:var(--color-accent)}.form-control{outline:2px solid transparent;outline-offset:2px}.form-control:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.form-control{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.form-control::-moz-placeholder{color:var(--text-muted)}.form-control::placeholder{color:var(--text-muted)}.form-control:hover{border-color:var(--border-default)}.form-control:focus-visible{border-color:var(--color-accent-strong)}.form-control:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.form-control:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.\!input[data-state=locked],.form-control[data-state=locked],.input[data-state=locked],.select[data-state=locked],.textarea[data-state=locked],select.form-control[data-state=locked],textarea.form-control[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.form-control[data-state=locked]:focus-visible,.input[data-state=locked]:focus-visible,.select[data-state=locked]:focus-visible,.textarea[data-state=locked]:focus-visible,select.form-control[data-state=locked]:focus-visible,textarea.form-control[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--Line truncated
|
||||
--player-modal-inline-padding,clamp(var(--space-md),4vw,var(--space-xl-plus))
|
||||
);overflow:visible}#playerModal .watch-container.video-modal__similar{padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .video-modal__primary>.watch-container:first-child{padding-block-start:var(--player-modal-vertical-padding)}#playerModal .video-modal__primary>.watch-container:last-child,#playerModal .video-modal__secondary>.watch-container:last-child{padding-block-end:var(--player-modal-vertical-padding)}#playerModal .modal-nav .watch-container{max-width:none;margin-inline:0}#playerModal .modal-info-panel{display:flex;flex-direction:column;gap:var(--space-md-plus)}#playerModal h2#videoTitle{margin:0;font-size:clamp(var(--font-size-xl),calc(var(--font-size-modal-title-base) + .5vw),var(--font-size-xl-plus));font-weight:600;line-height:1.3;color:var(--color-text);min-width:0;overflow-wrap:break-word;word-break:break-word}@media (min-width:1024px){.video-modal__headline #videoTitle.video-modal__title{flex:1 1 0%;min-width:0;max-width:calc(100% - var(--space-3xl) - var(--space-md))}.video-modal__headline .video-modal__status{flex:0 0 auto;max-width:clamp(var(--space-xl),25%,var(--space-3xl));margin-left:auto}}#playerModal .modal-meta{font-size:var(--font-size-sm);line-height:1.4;color:var(--color-text-muted)}#playerModal .modal-view-count{font-weight:600;color:var(--color-text)}#playerModal .channel-card{align-items:center;gap:var(--space-md);padding:var(--space-md) var(--space-lg)}#playerModal .channel-card,#playerModal .player-modal__module{display:flex;border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-surface);box-shadow:var(--shadow-sm)}#playerModal .player-modal__module{flex-direction:column;gap:clamp(var(--space-2xs),1vw,var(--space-sm));width:100%;padding:clamp(var(--space-xs),1vw,var(--space-sm))}#playerModal .player-modal__module-title{margin:0;font-size:var(--font-size-lg);font-weight:600;line-height:1.3;color:var(--color-text)}#playerModal .player-modal__module-list{display:flex;flex-direction:column;gap:var(--space-sm);width:100%;margin:0;padding:0;list-style:none}#playerModal .video-modal__similar .player-modal__module{gap:clamp(var(--space-3xs),.6vw,var(--space-xs));padding:clamp(var(--space-2xs),.6vw,var(--space-xs));border-width:0;box-shadow:none;background-color:transparent}#playerModal .video-modal__similar .player-modal__module-title{padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .video-modal__similar .player-modal__module-list{gap:clamp(var(--space-3xs),.6vw,var(--space-xs));padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs));padding-bottom:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .player-modal__module-scroll{max-height:clamp(var(--player-modal-module-scroll-min-height),calc(100vh - var(--player-modal-vertical-padding)*2 - var(--space-2xl)),var(--player-modal-module-scroll-max-height));overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;scrollbar-gutter:stable;padding-inline-end:var(--space-xs)}#playerModal .video-modal__similar .player-modal__module-scroll{max-height:none;overflow:visible;padding-inline-end:0;scrollbar-gutter:auto}#playerModal .player-modal__module-scroll>li{scroll-margin-block:var(--space-xs)}@media (max-width:30rem){#playerModal .modal-nav .icon-button{width:var(--space-xl);height:var(--space-xl);min-width:var(--space-xl);min-height:var(--space-xl)}#playerModal .watch-container{padding-inline:var(--space-sm)}#playerModal .channel-card{gap:var(--space-sm);padding:var(--space-sm) var(--space-md)}}#playerModal .channel-card__avatar{flex-shrink:0;width:var(--avatar-size-lg);height:var(--avatar-size-lg);border-radius:var(--radius-full);overflow:hidden;background-color:var(--color-border-subtle)}#playerModal .channel-card__avatar img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}#playerModal .channel-card__name{margin:0;font-size:var(--font-size-lg);font-weight:600;color:var(--color-text)}#playerModal .channel-card__npub{margin:0;font-size:var(--font-size-sm);color:var(--color-text-muted)}#playerModal .description-panel{padding:var(--space-md-plus) var(--space-lg);border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-surface);box-shadow:var(--shadow-sm)}#playerModal .description-panel p{margin:0;font-size:var(--font-size-sm);line-height:1.6;color:var(--color-text)}.player-modal__content{position:relative;z-index:var(--z-modal-content);display:flex;flex:1 1 auto;width:100%;min-height:100%;flex-direction:column;overflow:visible;padding:0;margin:0;border:none;border-radius:0;background-color:var(--color-surface);box-shadow:none}@media (prefers-reduced-motion:reduce){.player-modal__content{transition-property:none}}#playerModal .modal-nav{position:sticky;top:0;z-index:var(--z-overlay-nav);--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) sLine truncated
|
||||
--player-modal-primary-inline-padding-lg,clamp(var(--space-sm),2.75vw,var(--space-lg))
|
||||
)}}.revert-modal__layout{display:grid;gap:1.5rem}@media (min-width:1024px){.revert-modal__layout{grid-template-columns:minmax(var(--revert-modal-sidebar-min),var(--revert-modal-sidebar-width)) minmax(0,1fr)}}#playerModal.video-modal__primary{display:block}.video-modal__primary{display:flex;flex-direction:column;gap:1.5rem;min-width:0;min-height:0}#playerModal.video-modal__secondary{display:block}.video-modal__secondary{display:flex;flex-direction:column;gap:1rem;min-width:0;min-height:0;width:100%;align-self:stretch}.video-modal__similar{display:none}@media (min-width:1024px){#playerModal.video-modal__similar{display:block}.video-modal__similar{display:flex;flex-direction:column;gap:1.5rem;align-self:stretch;flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module{height:100%;display:flex;flex:1 1 auto;min-height:0;flex-direction:column}#playerModal .video-modal__similar .player-modal__module-list{flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module-scroll{height:100%;max-height:none;min-height:0}}#playerModal .player-modal__module-item{width:100%}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))!important}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media,#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))!important}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal .video-modal__similar .player-modal__module-list{gap:clamp(var(--space-2xs),.85vw,var(--space-sm))}.player-modal__similar-card{outline:2px solid transparent;outline-offset:2px}.player-modal__similar-card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.player-modal__similar-card{display:block}.player-modal__similar-card{position:relative;displayLine truncated
|
||||
)}}.revert-modal__layout{display:grid;gap:1.5rem}@media (min-width:1024px){.revert-modal__layout{grid-template-columns:minmax(var(--revert-modal-sidebar-min),var(--revert-modal-sidebar-width)) minmax(0,1fr)}}#playerModal.video-modal__primary{display:block}.video-modal__primary{display:flex;flex-direction:column;gap:1.5rem;min-width:0;min-height:0}#playerModal.video-modal__secondary{display:block}.video-modal__secondary{display:flex;flex-direction:column;gap:1rem;min-width:0;min-height:0;width:100%;align-self:stretch}.video-modal__similar{display:none}@media (min-width:1024px){#playerModal.video-modal__similar{display:block}.video-modal__similar{display:flex;flex-direction:column;gap:1.5rem;align-self:stretch;flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module{height:100%;display:flex;flex:1 1 auto;min-height:0;flex-direction:column}#playerModal .video-modal__similar .player-modal__module-list{flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module-scroll{height:100%;max-height:none;min-height:0}}#playerModal .player-modal__module-item{width:100%}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))!important}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media,#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))!important}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal .video-modal__similar .player-modal__module-list{gap:clamp(var(--space-2xs),.85vw,var(--space-sm))}.player-modal__similar-card{outline:2px solid transparent;outline-offset:2px}.player-modal__similar-card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.player-modal__similar-card{display:block}.player-modal__similar-card{position:relative;displayLine truncated
|
||||
--motion-video-card-backdrop-translate
|
||||
);--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.watch-history-card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.watch-history-card[data-state=disabled],.watch-history-card[data-state=private]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.watch-history-card[data-alert=nsfw-owner]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--color-black)}.watch-history-card[data-alert=nsfw-owner]:hover{--tw-border-opacity:0.6;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card img[data-thumbnail-state=blurred],.watch-history-card img[data-visual-state=blurred]{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}.watch-history-card[data-owner-is-viewer=true][data-url-health-state=offline][data-stream-health-state=unhealthy]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max));flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}.watch-history-card[data-motion=enter]{opacity:0;animation:card-motion-enter .22s ease-out forwards}#playerModal.watch-history-card{display:block}.watch-history-card{display:flex;flex-wrap:wrap;align-items:stretch;gap:1.25rem;padding:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}@media (prefers-reduced-motion:reduce){.watch-history-card,.watch-history-card[data-motion=enter]{transition-property:none}.watch-history-card[data-motion=enter]{opacity:1;animation:none}}.watch-history-card:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.watch-history-card{gap:1.5rem;padding:1.25rem}}.watch-history-card--private{border-color:var(--color-info);--tw-border-opacity:0.6}#playerModal.watch-histoLine truncated
|
||||
);--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.watch-history-card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.watch-history-card[data-state=disabled],.watch-history-card[data-state=private]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.watch-history-card[data-alert=nsfw-owner]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--color-black)}.watch-history-card[data-alert=nsfw-owner]:hover{--tw-border-opacity:0.6;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card img[data-thumbnail-state=blurred],.watch-history-card img[data-visual-state=blurred]{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}.watch-history-card[data-owner-is-viewer=true][data-url-health-state=offline][data-stream-health-state=unhealthy]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max));flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}.watch-history-card[data-motion=enter]{opacity:0;animation:card-motion-enter .22s ease-out forwards}#playerModal.watch-history-card{display:block}.watch-history-card{display:flex;flex-wrap:wrap;align-items:stretch;gap:1.25rem;padding:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}@media (prefers-reduced-motion:reduce){.watch-history-card,.watch-history-card[data-motion=enter]{transition-property:none}.watch-history-card[data-motion=enter]{opacity:1;animation:none}}.watch-history-card:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.watch-history-card{gap:1.5rem;padding:1.25rem}}.watch-history-card--private{border-color:var(--color-info);--tw-border-opacity:0.6}#playerModal.watch-histoLine truncated
|
||||
+898
-25
@@ -235,6 +235,23 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
#profileUnreadDot {
|
||||
position: absolute;
|
||||
top: var(--space-2xs);
|
||||
right: var(--space-2xs);
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
border: var(--border-width-hairline) solid var(--color-page);
|
||||
background-color: var(--status-danger);
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#profileUnreadDot.is-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-icon-toggle[data-theme-state="light"]:hover,
|
||||
.header-icon-toggle[data-theme-state="light"]:focus-visible {
|
||||
color: var(--color-text);
|
||||
@@ -2803,6 +2820,7 @@
|
||||
min-block-size: 100%;
|
||||
max-block-size: none;
|
||||
overflow: hidden;
|
||||
--modal-sheet-inline-max: var(--profile-modal-inline-max);
|
||||
--profile-modal-scale: 0.9;
|
||||
--profile-modal-gap: calc(
|
||||
(var(--space-md) - var(--space-3xs)) * var(--profile-modal-scale)
|
||||
@@ -2988,7 +3006,26 @@
|
||||
flex-wrap: wrap;
|
||||
gap: var(--profile-modal-nav-gap);
|
||||
align-self: flex-start;
|
||||
overflow-x: hidden;
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.profile-nav__unread-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -6px;
|
||||
right: auto;
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
border: var(--border-width-hairline) solid var(--surface-panel);
|
||||
background-color: var(--status-danger);
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.profile-nav__unread-dot.is-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-modal__panes {
|
||||
@@ -3773,11 +3810,11 @@
|
||||
|
||||
.notification-banner,
|
||||
.torrent-toast {
|
||||
--notify-accent: var(--color-accent-strong);
|
||||
--notify-accent-strong: var(--color-accent);
|
||||
--notify-border: var(--surface-overlay-panel-tint);
|
||||
--notify-background: var(--surface-overlay-panel);
|
||||
--notify-foreground: var(--color-text);
|
||||
--notify-accent: var(--notification-banner-accent);
|
||||
--notify-accent-strong: var(--notification-banner-accent-strong);
|
||||
--notify-border: var(--notification-banner-border);
|
||||
--notify-background: var(--notification-banner-background);
|
||||
--notify-foreground: var(--notification-banner-text);
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -3845,37 +3882,38 @@
|
||||
|
||||
.notification-banner--critical,
|
||||
.torrent-toast--error {
|
||||
--notify-accent: var(--color-critical-strong);
|
||||
--notify-accent-strong: var(--color-critical);
|
||||
--notify-border: var(--color-ring-critical-soft);
|
||||
--notify-background: var(--color-card-critical);
|
||||
--notify-foreground: var(--color-critical-strong);
|
||||
--notify-accent: var(--notification-banner-critical-accent);
|
||||
--notify-accent-strong: var(--notification-banner-critical-accent-strong);
|
||||
--notify-border: var(--notification-banner-critical-border);
|
||||
--notify-background: var(--notification-banner-critical-background);
|
||||
--notify-foreground: var(--notification-banner-critical-text);
|
||||
}
|
||||
|
||||
.notification-banner--success,
|
||||
.torrent-toast--success {
|
||||
--notify-accent: var(--status-success-strong);
|
||||
--notify-accent-strong: var(--status-success);
|
||||
--notify-border: var(--status-success-border);
|
||||
--notify-background: var(--status-success-surface);
|
||||
--notify-foreground: var(--status-success-on);
|
||||
--notify-accent: var(--notification-banner-success-accent);
|
||||
--notify-accent-strong: var(--notification-banner-success-accent-strong);
|
||||
--notify-border: var(--notification-banner-success-border);
|
||||
--notify-background: var(--notification-banner-success-background);
|
||||
--notify-foreground: var(--notification-banner-success-text);
|
||||
}
|
||||
|
||||
.notification-banner--info,
|
||||
.torrent-toast--info {
|
||||
--notify-accent: var(--color-info-strong);
|
||||
--notify-accent-strong: var(--color-info);
|
||||
--notify-border: var(--color-info-border);
|
||||
--notify-background: var(--status-info-surface);
|
||||
--notify-accent: var(--notification-banner-info-accent);
|
||||
--notify-accent-strong: var(--notification-banner-info-accent-strong);
|
||||
--notify-border: var(--notification-banner-info-border);
|
||||
--notify-background: var(--notification-banner-info-background);
|
||||
--notify-foreground: var(--notification-banner-info-text);
|
||||
}
|
||||
|
||||
.notification-banner--warning,
|
||||
.torrent-toast--warn {
|
||||
--notify-accent: var(--color-warning-strong);
|
||||
--notify-accent-strong: var(--color-warning);
|
||||
--notify-border: var(--status-warning-border);
|
||||
--notify-background: var(--status-warning-surface);
|
||||
--notify-foreground: var(--status-warning-on);
|
||||
--notify-accent: var(--notification-banner-warning-accent);
|
||||
--notify-accent-strong: var(--notification-banner-warning-accent-strong);
|
||||
--notify-border: var(--notification-banner-warning-border);
|
||||
--notify-background: var(--notification-banner-warning-background);
|
||||
--notify-foreground: var(--notification-banner-warning-text);
|
||||
}
|
||||
|
||||
.status-banner .status-spinner {
|
||||
@@ -4976,3 +5014,838 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.dm-app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.profile-modal .dm-app-shell {
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.dm-app-shell__sidebar,
|
||||
.dm-app-shell__main {
|
||||
border-radius: var(--radius-lg);
|
||||
border: var(--border-width-hairline) solid var(--border-default);
|
||||
background: var(--surface-panel);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.profile-modal .dm-app-shell__sidebar,
|
||||
.profile-modal .dm-app-shell__main {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dm-app-shell__sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.dm-app-shell__title {
|
||||
font-size: var(--font-size-lg);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dm-app-shell__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.profile-modal .dm-app-shell__main,
|
||||
.profile-modal .dm-app-shell__sidebar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dm-conversation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.dm-conversation-list__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dm-conversation-list__title {
|
||||
font-size: var(--font-size-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dm-conversation-list__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
padding-right: var(--space-xs);
|
||||
}
|
||||
|
||||
.dm-conversation-list__state {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-md);
|
||||
text-align: center;
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.dm-contact-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: var(--border-width-hairline) solid var(--border-subtle);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-contact-row:hover,
|
||||
.dm-contact-row:focus-visible {
|
||||
background: var(--surface-muted);
|
||||
border-color: var(--border-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dm-contact-row--active {
|
||||
background: var(--surface-sunken);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.dm-contact-row__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xs);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dm-contact-row__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.dm-contact-row__meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.dm-contact-row__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dm-contact-row__time {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-contact-row__preview {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-contact-row__badge {
|
||||
align-self: flex-start;
|
||||
padding: var(--space-3xs) var(--space-xs);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-white);
|
||||
font-size: var(--font-size-3xs);
|
||||
}
|
||||
|
||||
.dm-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
border: var(--border-width-hairline) solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.dm-avatar--md {
|
||||
width: var(--space-xl-plus);
|
||||
height: var(--space-xl-plus);
|
||||
min-width: var(--space-xl-plus);
|
||||
min-height: var(--space-xl-plus);
|
||||
}
|
||||
|
||||
.dm-avatar--sm {
|
||||
width: var(--space-xl);
|
||||
height: var(--space-xl);
|
||||
min-width: var(--space-xl);
|
||||
min-height: var(--space-xl);
|
||||
}
|
||||
|
||||
.dm-avatar__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.dm-message-thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dm-message-thread__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.dm-message-thread__title {
|
||||
font-size: var(--font-size-base);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dm-message-thread__status {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-message-thread__zap-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-2xs) var(--space-xs);
|
||||
border-radius: var(--radius-full);
|
||||
border: var(--border-width-hairline) solid var(--border-subtle);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
.dm-message-thread__zap-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.dm-message-thread__zap-total {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dm-message-thread__zap-profile {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-message-thread__timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-panel);
|
||||
border: var(--border-width-hairline) solid var(--border-subtle);
|
||||
overflow-y: auto;
|
||||
min-height: var(--size-16rem);
|
||||
}
|
||||
|
||||
.dm-message-thread__state {
|
||||
padding: var(--space-md);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.dm-message-bubble {
|
||||
max-width: 85%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-lg);
|
||||
border: var(--border-width-hairline) solid var(--message-bubble-border);
|
||||
background: var(--message-bubble-background);
|
||||
color: var(--message-bubble-text);
|
||||
}
|
||||
|
||||
.dm-message-bubble--incoming {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.dm-message-bubble--outgoing {
|
||||
align-self: flex-end;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-white);
|
||||
border-color: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
.dm-message-bubble--outgoing .dm-message-bubble__meta,
|
||||
.dm-message-bubble--outgoing .dm-message-bubble__status {
|
||||
color: var(--color-white);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.dm-message-bubble__content {
|
||||
font-size: var(--font-size-sm);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dm-message-bubble__meta {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--message-bubble-meta);
|
||||
}
|
||||
|
||||
.dm-message-bubble__status {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-message-bubble__status[data-status="pending"] {
|
||||
color: var(--status-warning);
|
||||
}
|
||||
|
||||
.dm-message-bubble__status[data-status="sending"] {
|
||||
color: var(--status-info);
|
||||
}
|
||||
|
||||
.dm-message-bubble__status[data-status="failed"] {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.dm-message-thread__new {
|
||||
align-self: center;
|
||||
border-radius: var(--radius-full);
|
||||
border: var(--border-width-hairline) solid var(--border-subtle);
|
||||
padding: var(--space-2xs) var(--space-sm);
|
||||
font-size: var(--font-size-2xs);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-message-thread__new:hover,
|
||||
.dm-message-thread__new:focus-visible {
|
||||
background: var(--surface-overlay-panel-soft);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dm-day-divider {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
.dm-day-divider__line {
|
||||
height: var(--border-width-hairline);
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
.dm-notification-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.dm-notification-center__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.dm-notification-center__filter-button {
|
||||
border-radius: var(--radius-full);
|
||||
border: var(--border-width-hairline) solid var(--border-subtle);
|
||||
background: var(--surface-sunken);
|
||||
padding: var(--space-2xs) var(--space-sm);
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
transition:
|
||||
color var(--motion-duration-fast) var(--motion-ease-out),
|
||||
background var(--motion-duration-fast) var(--motion-ease-out),
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-out);
|
||||
}
|
||||
|
||||
.dm-notification-center__filter-button:hover,
|
||||
.dm-notification-center__filter-button:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dm-notification-center__filter-button--active {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-overlay-panel);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dm-notification-center__groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.dm-notification-center__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.dm-notification-center__group-title {
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-notification-center__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.dm-notification-center__empty {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-notification-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-md);
|
||||
border: var(--border-width-hairline) solid var(--border-subtle);
|
||||
background: var(--surface-sunken);
|
||||
font-size: var(--font-size-xs);
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-out),
|
||||
background var(--motion-duration-fast) var(--motion-ease-out);
|
||||
}
|
||||
|
||||
.dm-notification-item:hover,
|
||||
.dm-notification-item:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.dm-notification-item__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--icon-size-md);
|
||||
height: var(--icon-size-md);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-overlay-panel-soft);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.dm-notification-item__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dm-notification-item__title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dm-notification-item__message {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-notification-item__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-notification-item__timestamp {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dm-notification-item--info {
|
||||
background: var(--status-info-surface);
|
||||
border-color: var(--status-info-border);
|
||||
color: var(--status-info-on);
|
||||
}
|
||||
|
||||
.dm-notification-item--success {
|
||||
background: var(--status-success-surface);
|
||||
border-color: var(--status-success-border);
|
||||
color: var(--status-success-on);
|
||||
}
|
||||
|
||||
.dm-notification-item--warning {
|
||||
background: var(--status-warning-surface);
|
||||
border-color: var(--status-warning-border);
|
||||
color: var(--status-warning-on);
|
||||
}
|
||||
|
||||
.dm-notification-item--error {
|
||||
background: var(--status-danger-surface);
|
||||
border-color: var(--status-danger-border);
|
||||
color: var(--status-danger-on);
|
||||
}
|
||||
|
||||
.reply-highlight {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-md);
|
||||
border: var(--border-width-hairline) solid var(--reply-highlight-border);
|
||||
background: var(--reply-highlight-background);
|
||||
color: var(--reply-highlight-text);
|
||||
}
|
||||
|
||||
.reply-highlight svg {
|
||||
color: var(--reply-highlight-icon);
|
||||
}
|
||||
|
||||
.dm-composer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.dm-composer__label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-composer__input {
|
||||
resize: vertical;
|
||||
border-radius: var(--radius-md);
|
||||
border: var(--border-width-hairline) solid var(--border-default);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
padding: var(--space-sm);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dm-composer__input:focus-visible {
|
||||
outline: var(--space-3xs) solid var(--color-accent);
|
||||
outline-offset: var(--space-3xs);
|
||||
}
|
||||
|
||||
.dm-composer__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.dm-composer__tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dm-composer__hint {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-composer__attach,
|
||||
.dm-composer__more-btn {
|
||||
border-radius: var(--radius-md);
|
||||
border: var(--border-width-hairline) solid var(--border-default);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-2xs) var(--space-sm);
|
||||
font-size: var(--font-size-2xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-composer__more-btn {
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
min-width: var(--space-xl);
|
||||
}
|
||||
|
||||
.dm-composer__more-menu {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
margin-bottom: var(--space-xs);
|
||||
background: var(--surface-overlay-panel);
|
||||
border: var(--border-width-hairline) solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-popover);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: var(--size-8rem);
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.dm-composer__menu-item {
|
||||
text-align: left;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-duration-fast) ease-out;
|
||||
}
|
||||
|
||||
.dm-composer__menu-item:hover,
|
||||
.dm-composer__menu-item:focus-visible {
|
||||
background: var(--surface-overlay-panel-soft);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dm-composer__menu-item[aria-pressed="true"] {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.dm-composer__attach:hover,
|
||||
.dm-composer__attach:focus-visible,
|
||||
.dm-composer__more-btn:hover,
|
||||
.dm-composer__more-btn:focus-visible {
|
||||
background: var(--surface-overlay-panel-soft);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dm-composer__send {
|
||||
border-radius: var(--radius-md);
|
||||
border: var(--border-width-hairline) solid var(--border-strong);
|
||||
background: var(--surface-overlay-panel);
|
||||
color: var(--text-primary);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-composer__send:hover,
|
||||
.dm-composer__send:focus-visible {
|
||||
background: var(--surface-overlay-panel-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dm-composer__zap-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-md);
|
||||
border: var(--border-width-hairline) solid var(--border-subtle);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.dm-composer__zap-recipient {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dm-composer__zap-address {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-composer__zap-label {
|
||||
font-size: var(--font-size-2xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dm-composer__zap-input {
|
||||
border-radius: var(--radius-md);
|
||||
border: var(--border-width-hairline) solid var(--border-default);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
padding: var(--space-2xs) var(--space-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dm-composer__zap-input:focus-visible {
|
||||
outline: var(--size-2px) solid var(--color-accent);
|
||||
outline-offset: var(--size-2px);
|
||||
}
|
||||
|
||||
.dm-composer__zap-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dm-composer__zap-send {
|
||||
border-radius: var(--radius-md);
|
||||
border: var(--border-width-hairline) solid var(--border-strong);
|
||||
background: var(--surface-overlay-panel);
|
||||
color: var(--text-primary);
|
||||
padding: var(--space-2xs) var(--space-md);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-composer__zap-send:hover,
|
||||
.dm-composer__zap-send:focus-visible {
|
||||
background: var(--surface-overlay-panel-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dm-composer__zap-status {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
min-height: var(--space-md);
|
||||
}
|
||||
|
||||
.dm-composer__zap-status[data-state="error"] {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.dm-composer__zap-status[data-state="success"] {
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.dm-composer__status {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--status-danger);
|
||||
min-height: var(--space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.dm-app-shell[data-dm-view="list"] .dm-app-shell__main {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.dm-app-shell[data-dm-view="thread"] .dm-app-shell__sidebar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.dm-message-thread__back {
|
||||
@apply inline-flex items-center gap-2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: theme("screens.md")) {
|
||||
.dm-app-shell {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dm-app-shell__sidebar {
|
||||
flex: 0 0 var(--size-18rem);
|
||||
max-width: var(--size-20rem);
|
||||
height: calc(100vh - var(--space-lg) * 2);
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.dm-app-shell__main {
|
||||
height: calc(100vh - var(--space-lg) * 2);
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.dm-conversation-list__items {
|
||||
max-height: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dm-message-thread__back {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: theme("screens.md")) {
|
||||
.profile-modal .dm-app-shell {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.profile-modal .dm-app-shell__sidebar {
|
||||
flex: 0 0 var(--size-18rem);
|
||||
max-width: var(--size-20rem);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.profile-modal .dm-app-shell__main {
|
||||
flex: 1 1 auto;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: theme("screens.lg")) {
|
||||
.dm-app-shell__sidebar {
|
||||
flex-basis: var(--size-20rem);
|
||||
}
|
||||
|
||||
.dm-message-thread__timeline {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Profile Modal Full Screen Overrides */
|
||||
#profileModal {
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
#profileModal .bv-modal__panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Override responsive max-height constraint */
|
||||
@media (min-width: theme("screens.lg")) {
|
||||
#profileModal .profile-modal {
|
||||
max-block-size: none;
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,53 @@
|
||||
--status-neutral-border: var(--color-neutral-border);
|
||||
--status-neutral-on: var(--color-neutral-on);
|
||||
|
||||
/* Messaging & notifications */
|
||||
--message-bubble-background: var(--surface-sunken);
|
||||
--message-bubble-border: var(--border-subtle);
|
||||
--message-bubble-text: var(--text-primary);
|
||||
--message-bubble-meta: var(--text-muted);
|
||||
--message-bubble-outgoing-background: var(--surface-overlay-panel);
|
||||
--message-bubble-outgoing-border: var(--border-overlay);
|
||||
|
||||
--unread-badge-background: var(--status-info-strong);
|
||||
--unread-badge-text: var(--text-inverse);
|
||||
|
||||
--reply-highlight-background: var(--status-info-surface);
|
||||
--reply-highlight-border: var(--status-info-border);
|
||||
--reply-highlight-text: var(--status-info-on);
|
||||
--reply-highlight-icon: var(--status-info-on);
|
||||
|
||||
--notification-banner-accent: var(--color-accent-strong);
|
||||
--notification-banner-accent-strong: var(--color-accent);
|
||||
--notification-banner-border: var(--surface-overlay-panel-tint);
|
||||
--notification-banner-background: var(--surface-overlay-panel);
|
||||
--notification-banner-text: var(--text-primary);
|
||||
--notification-banner-icon: var(--text-primary);
|
||||
|
||||
--notification-banner-critical-accent: var(--status-danger-strong);
|
||||
--notification-banner-critical-accent-strong: var(--status-danger);
|
||||
--notification-banner-critical-border: var(--color-ring-critical-soft);
|
||||
--notification-banner-critical-background: var(--surface-raised-critical);
|
||||
--notification-banner-critical-text: var(--status-danger-on);
|
||||
|
||||
--notification-banner-success-accent: var(--status-success-strong);
|
||||
--notification-banner-success-accent-strong: var(--status-success);
|
||||
--notification-banner-success-border: var(--status-success-border);
|
||||
--notification-banner-success-background: var(--status-success-surface);
|
||||
--notification-banner-success-text: var(--status-success-on);
|
||||
|
||||
--notification-banner-info-accent: var(--status-info-strong);
|
||||
--notification-banner-info-accent-strong: var(--status-info);
|
||||
--notification-banner-info-border: var(--status-info-border);
|
||||
--notification-banner-info-background: var(--status-info-surface);
|
||||
--notification-banner-info-text: var(--status-info-on);
|
||||
|
||||
--notification-banner-warning-accent: var(--status-warning-strong);
|
||||
--notification-banner-warning-accent-strong: var(--status-warning);
|
||||
--notification-banner-warning-border: var(--status-warning-border);
|
||||
--notification-banner-warning-background: var(--status-warning-surface);
|
||||
--notification-banner-warning-text: var(--status-warning-on);
|
||||
|
||||
/* Layout & stacking */
|
||||
--scrollbar-width: 0.5rem;
|
||||
--scrollbar-track: var(--color-white-soft);
|
||||
@@ -256,6 +303,8 @@
|
||||
--size-30px: 30px;
|
||||
--size-32px: 32px;
|
||||
--size-64px: 64px;
|
||||
--size-80px: 80px;
|
||||
--size-96px: 96px;
|
||||
--size-128px: 128px;
|
||||
--size-140px: 140px;
|
||||
--size-168px: 168px;
|
||||
@@ -267,9 +316,13 @@
|
||||
--size-12rem: 12rem;
|
||||
--size-12-5rem: 12.5rem;
|
||||
--size-14rem: 14rem;
|
||||
--size-16rem: 16rem;
|
||||
--size-18rem: 18rem;
|
||||
--size-20rem: 20rem;
|
||||
--size-28rem: 28rem;
|
||||
--size-34rem: 34rem;
|
||||
--size-45rem: 45rem;
|
||||
--size-70rem: 70rem;
|
||||
|
||||
/* Border radii */
|
||||
--radius-sm: 0.25rem;
|
||||
@@ -291,6 +344,7 @@
|
||||
--modal-shell-max-height: 90vh;
|
||||
--modal-sheet-max-height: 92vh;
|
||||
--modal-body-max-height: calc(92vh - 4rem);
|
||||
--profile-modal-inline-max: var(--size-70rem);
|
||||
--player-modal-module-scroll-min-height: var(--size-14rem);
|
||||
--player-modal-module-scroll-max-height: var(--size-28rem);
|
||||
--modal-pane-max-height: 48vh;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
|
||||
import { __testExports } from "./js/nostr/commentEvents.js";
|
||||
const { isVideoCommentEvent, normalizeCommentTarget } = __testExports;
|
||||
import { devLogger, userLogger } from "./js/utils/logger.js";
|
||||
|
||||
// Logger mocking removed
|
||||
|
||||
const videoEventId = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const rootKind = "30078";
|
||||
const rootAuthor = "0000000000000000000000000000000000000000000000000000000000000002";
|
||||
|
||||
const targetInput = {
|
||||
videoEventId,
|
||||
videoKind: rootKind,
|
||||
videoAuthorPubkey: rootAuthor,
|
||||
};
|
||||
|
||||
const descriptor = normalizeCommentTarget(targetInput);
|
||||
|
||||
console.log("Descriptor:", JSON.stringify(descriptor, null, 2));
|
||||
|
||||
const validEvent = {
|
||||
kind: 1111,
|
||||
tags: [
|
||||
["e", videoEventId, "wss://relay.example.com", "root"],
|
||||
["K", rootKind],
|
||||
["P", rootAuthor],
|
||||
],
|
||||
};
|
||||
|
||||
const missingKTagEvent = {
|
||||
kind: 1111,
|
||||
tags: [
|
||||
["e", videoEventId, "wss://relay.example.com", "root"],
|
||||
["P", rootAuthor],
|
||||
],
|
||||
};
|
||||
|
||||
const mismatchKTagEvent = {
|
||||
kind: 1111,
|
||||
tags: [
|
||||
["e", videoEventId, "wss://relay.example.com", "root"],
|
||||
["K", "12345"], // Wrong kind
|
||||
["P", rootAuthor],
|
||||
],
|
||||
};
|
||||
|
||||
const legacyEvent = {
|
||||
kind: 1,
|
||||
tags: [
|
||||
["e", videoEventId, "wss://relay.example.com", "root"],
|
||||
],
|
||||
};
|
||||
|
||||
console.log("Testing Valid Event:", isVideoCommentEvent(validEvent, descriptor));
|
||||
console.log("Testing Missing K Tag Event:", isVideoCommentEvent(missingKTagEvent, descriptor));
|
||||
console.log("Testing Mismatch K Tag Event:", isVideoCommentEvent(mismatchKTagEvent, descriptor));
|
||||
console.log("Testing Legacy Event:", isVideoCommentEvent(legacyEvent, descriptor));
|
||||
@@ -1,80 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
def find_test_files(start_dir):
|
||||
test_files = []
|
||||
for root, dirs, files in os.walk(start_dir):
|
||||
if 'visual' in dirs:
|
||||
dirs.remove('visual')
|
||||
|
||||
for file in files:
|
||||
if file.endswith('.test.mjs') or file.endswith('.test.js'):
|
||||
full_path = os.path.join(root, file)
|
||||
try:
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if 'node:test' in content:
|
||||
test_files.append(full_path)
|
||||
except Exception as e:
|
||||
print(f"Error reading {full_path}: {e}")
|
||||
return sorted(test_files)
|
||||
|
||||
def run_test(test_file, setup_file):
|
||||
# We use node directly. sys.executable might be python, so we need 'node'.
|
||||
# Assuming 'node' is in PATH.
|
||||
cmd = ['node', '--import', setup_file, test_file]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
)
|
||||
return result.returncode == 0, result.stdout, result.stderr
|
||||
except Exception as e:
|
||||
return False, "", str(e)
|
||||
|
||||
def main():
|
||||
root_dir = os.getcwd()
|
||||
tests_dir = os.path.join(root_dir, 'tests')
|
||||
setup_file = os.path.join(root_dir, 'tests', 'test-helpers', 'setup-localstorage.mjs')
|
||||
|
||||
if not os.path.exists(setup_file):
|
||||
# The script uses a relative URL in the JS runner, let's try to resolve it relative to CWD
|
||||
# The JS runner uses: new URL("../tests/test-helpers/setup-localstorage.mjs", import.meta.url)
|
||||
# If running from root, it is tests/test-helpers/setup-localstorage.mjs
|
||||
pass
|
||||
|
||||
test_files = find_test_files(tests_dir)
|
||||
print(f"Found {len(test_files)} test files.")
|
||||
|
||||
flaky_tests = {}
|
||||
|
||||
for test_file in test_files:
|
||||
print(f"Checking {os.path.relpath(test_file, root_dir)}...")
|
||||
failures = []
|
||||
for i in range(10):
|
||||
success, stdout, stderr = run_test(test_file, f"file://{setup_file}")
|
||||
if not success:
|
||||
failures.append((i, stdout, stderr))
|
||||
print(f" Failed on iteration {i+1}")
|
||||
# We can stop early if we found it's flaky, or continue to see failure rate?
|
||||
# Let's stop early to save time for this task, as one failure is enough to be flaky.
|
||||
break
|
||||
|
||||
if failures:
|
||||
flaky_tests[test_file] = failures
|
||||
|
||||
if flaky_tests:
|
||||
print("\nFound flaky tests:")
|
||||
for test_file, failures in flaky_tests.items():
|
||||
print(f"- {os.path.relpath(test_file, root_dir)}")
|
||||
# Print the first failure output
|
||||
print(f" Output:\n{failures[0][2]}\n{failures[0][1]}")
|
||||
else:
|
||||
print("\nNo flaky tests found.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -135,3 +135,4 @@ attempting privileged calls.
|
||||
|
||||
- [`docs/nostr-auth.md`](nostr-auth.md)
|
||||
- [`docs/nip46-remote-signers.md`](nip46-remote-signers.md)
|
||||
- [`docs/signing-adapter.md`](signing-adapter.md)
|
||||
@@ -181,6 +181,15 @@ Mix in Tailwind utilities for icon spacing (`gap-3`), sizing (`px-lg`, `py-sm`),
|
||||
|
||||
`.badge` renders an inline pill (`bg-panel`, uppercase text). Opt into semantic variants with `data-variant="info"`, `data-variant="critical"`, or `data-variant="neutral"`. Layer on utilities for icon alignment or truncation.
|
||||
|
||||
### Messaging & Notification Tokens
|
||||
|
||||
Use the dedicated messaging tokens when styling chat surfaces and banners so they inherit theme-safe contrast and icon colors:
|
||||
|
||||
- **Message bubbles:** `--message-bubble-background`, `--message-bubble-border`, `--message-bubble-text`, and `--message-bubble-meta` drive the base bubble surface, while `--message-bubble-outgoing-background`/`--message-bubble-outgoing-border` cover the current-user variant. These map back to surface, border, and text semantic tokens so bubbles maintain AA contrast. 【F:css/tokens.css†L102-L115】
|
||||
- **Unread badges:** `--unread-badge-background` and `--unread-badge-text` keep badge text legible at WCAG AA while ensuring iconography stays ≥3:1 against the fill. 【F:css/tokens.css†L117-L118】
|
||||
- **Reply highlights:** `--reply-highlight-background`, `--reply-highlight-border`, `--reply-highlight-text`, and `--reply-highlight-icon` align reply callouts with the info status palette. 【F:css/tokens.css†L120-L123】
|
||||
- **Notification banners:** Base banner colors map through `--notification-banner-*` tokens, with per-variant slots (`critical`, `success`, `info`, `warning`) pulling from the status palettes for AA-compliant text and 3:1 icon contrast. 【F:css/tokens.css†L125-L154】
|
||||
|
||||
### Cards
|
||||
|
||||
`.card` is the base surface for grid items and panels:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Direct message privacy model (NIP-17 / NIP-59 / NIP-44)
|
||||
|
||||
This guide summarizes how bitvid delivers private direct messages, how relay
|
||||
hints are chosen, and which user controls affect delivery. It is meant to be
|
||||
read alongside the protocol references in `docs/nips/17.md`, `docs/nips/59.md`,
|
||||
and `docs/nips/44.md`.
|
||||
|
||||
## Privacy layers: rumor → seal → gift wrap
|
||||
|
||||
When privacy mode is enabled, bitvid uses the NIP-17 pipeline:
|
||||
|
||||
1. **Rumor (kind 14 / 15)**: a plaintext DM or attachment payload is created.
|
||||
2. **Seal (kind 13)**: the rumor is encrypted with NIP-44 to produce a sealed
|
||||
event signed by the sender.
|
||||
3. **Gift wrap (kind 1059)**: the seal is encrypted again with NIP-44 using a
|
||||
freshly generated ephemeral keypair. The outer wrap is what relays receive.
|
||||
|
||||
This layered approach hides the sender/recipient metadata and message timing
|
||||
from public relays while still allowing the receiver to unwrap the message.
|
||||
bitvid randomizes timestamps for seals and wraps to reduce correlation, and
|
||||
publishes a sender copy of the wrap so the sender can sync their own history.
|
||||
|
||||
## Relay hint selection
|
||||
|
||||
Relay hints decide where gift-wrapped NIP-17 events are published. Selection
|
||||
uses the following priority order:
|
||||
|
||||
1. **Explicit hints** – If the caller provides `recipientRelayHints` or
|
||||
`senderRelayHints`, those are used immediately.
|
||||
2. **Recipient discovery** – Otherwise, the client queries the configured read
|
||||
relays for the recipient's `kind:10050` relay list and uses any `relay` tags
|
||||
it finds.
|
||||
3. **Fallback relays** – If no relay list is found, the client falls back to the
|
||||
configured write relays (or the default relay list), and flags the selection
|
||||
as a privacy fallback.
|
||||
|
||||
This logic is shared by the DM service helper `resolveDmRelaySelection()` and
|
||||
by `NostrClient.sendDirectMessage()` when `useNip17` is enabled.
|
||||
|
||||
### Legacy NIP-04 relay selection
|
||||
|
||||
When privacy mode is disabled, bitvid sends a traditional kind `4` DM:
|
||||
|
||||
- It attempts to load the recipient's NIP-65 relay list (`kind:10002`) from the
|
||||
configured read relays.
|
||||
- If no relay list is available, it falls back to the configured write relays
|
||||
(or the default relay list).
|
||||
|
||||
The final relay set is where the signed NIP-04 ciphertext is published.
|
||||
|
||||
## User controls that affect privacy
|
||||
|
||||
### Privacy toggle (NIP-17 vs NIP-04)
|
||||
|
||||
The DM composer exposes a privacy toggle that switches between NIP-04 and
|
||||
NIP-17 delivery. When the toggle is enabled, the UI explains that NIP-17
|
||||
gift-wraps the message so relays only see the wrapper and relay hints.
|
||||
|
||||
### Relay hint management
|
||||
|
||||
Users can curate their own DM relay hints list in the profile modal. The UI
|
||||
lets them add and remove WSS relay URLs and publish the resulting list as a
|
||||
`kind:10050` event. This list is what other clients query during the relay
|
||||
selection phase above.
|
||||
|
||||
### Read receipts & typing indicators
|
||||
|
||||
Additional DM privacy toggles (read receipts and typing indicators) live in the
|
||||
same profile modal and persist to local state. Turning them off reduces
|
||||
metadata leaks by preventing extra ephemeral events from being sent.
|
||||
@@ -1,91 +1,68 @@
|
||||
# NostrClient Overview
|
||||
|
||||
`js/nostr/client.js` exports the `NostrClient` class, which serves as the central controller for all Nostr network interactions and state management within the application. It acts as a facade over `nostr-tools`, managing connections, subscriptions, caching, and signing.
|
||||
The `NostrClient` class in `js/nostr/client.js` is the central controller for all Nostr protocol interactions in the application. It handles connection management, event publishing, state synchronization, and caching.
|
||||
|
||||
## Key Responsibilities
|
||||
|
||||
1. **Connection Management**: Maintains a pool of connections to multiple relays (`SimplePool`).
|
||||
2. **State Management**: Tracks video events, deduplicates them, and manages deletions via tombstones.
|
||||
3. **Caching**: Implements a "stale-while-revalidate" strategy using IndexedDB (with localStorage fallback) to restore UI state immediately upon load.
|
||||
4. **Event Publishing**: Orchestrates the signing and broadcasting of complex event chains (Video Note -> NIP-94 Mirror -> NIP-71 Metadata).
|
||||
5. **Signer Abstraction**: Unifies NIP-07 (Extension), NIP-46 (Remote/Bunker), and Local (nsec) signing under a single interface.
|
||||
6. **Video History**: Reconstructs the edit history of videos using `videoRootId` and `d` tags.
|
||||
1. **Connection Management**: Maintains a pool of connections to Nostr relays (`SimplePool`).
|
||||
2. **State Management**:
|
||||
* `allEvents`: A raw map of all fetched events, useful for history.
|
||||
* `activeMap`: A "materialized view" containing only the latest version of each video (deduplicated by `videoRootId` or `d` tag).
|
||||
* `tombstones`: Tracks deleted events to prevent them from reappearing if an old relay broadcasts them.
|
||||
3. **Event Publishing**: Handles signing and broadcasting events (Kind 30078 video posts, Kind 1 metadata, etc.).
|
||||
4. **Buffering**: Implements a buffering strategy for incoming events to optimize UI rendering performance.
|
||||
5. **Caching**: Uses `EventsCacheStore` (IndexedDB + localStorage) to persist state across sessions for instant load times.
|
||||
6. **Signer Abstraction**: Manages different signing methods (NIP-07 extension, NIP-46 remote signer, local keys).
|
||||
|
||||
## Architecture & State
|
||||
## Core Concepts
|
||||
|
||||
The client maintains several internal maps to manage state:
|
||||
### Eventual Consistency & `activeMap`
|
||||
|
||||
* `allEvents`: A raw map of all fetched video events (key: event ID).
|
||||
* `activeMap`: A derived map containing only the *latest* version of each video, keyed by a stable identifier (`videoRootId` or addressable pointer).
|
||||
* `rawEvents`: A cache of the exact raw JSON events from relays, used for signature verification and re-publishing.
|
||||
* `tombstones`: Tracks timestamps of deletion events to prevent "zombie" events (deleted events reappearing from slow relays) from resurfacing.
|
||||
Nostr is a decentralized protocol where events can arrive out of order or be duplicated across relays. The `activeMap` is critical for presenting a consistent view to the user.
|
||||
|
||||
### Caching Strategy (`EventsCacheStore`)
|
||||
* **Logic**: When a new event arrives, `activeMap` only updates if the new event has a newer `created_at` timestamp than the existing entry for that `videoRootId` (or `d` tag).
|
||||
* **Deletions**: If an event is deleted (Kind 5 or explicit `deleted: true` flag), a "tombstone" is recorded. Future events with a timestamp older than the tombstone are ignored.
|
||||
|
||||
To ensure instant load times, the client persists its state to IndexedDB (`bitvid-events-cache`).
|
||||
* **Snapshot**: On a debounced timer (or idle callback), the current state (`allEvents`, `tombstones`) is serialized.
|
||||
* **Fingerprinting**: Events are fingerprinted to avoid writing unchanged data to the DB.
|
||||
* **Restore**: On `init()`, the client loads the latest snapshot into memory, allowing the UI to render before any network requests complete.
|
||||
### Buffering Strategy (`subscribeVideos`)
|
||||
|
||||
## Key Flows
|
||||
To prevent the UI from freezing when thousands of events arrive during the initial connection:
|
||||
|
||||
### 1. Initialization (`init`)
|
||||
1. Incoming events are pushed into an `eventBuffer`.
|
||||
2. A debounced flush function processes the buffer every ~75ms.
|
||||
3. Events are processed in batches: validation, deduplication, and state updates happen in bulk.
|
||||
4. The UI is notified only once per batch.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[init()] --> B[restoreLocalData]
|
||||
B --> C{IndexedDB Available?}
|
||||
C -- Yes --> D[Load Snapshot]
|
||||
C -- No --> E[Load localStorage]
|
||||
D & E --> F[Render UI with Cached Data]
|
||||
F --> G[ensurePool]
|
||||
G --> H[connectToRelays]
|
||||
H --> I[Ready for Subscriptions]
|
||||
```
|
||||
### Caching (`EventsCacheStore`)
|
||||
|
||||
### 2. Video Subscription (`subscribeVideos`)
|
||||
The client attempts to restore state immediately on load ("Stale-While-Revalidate"):
|
||||
|
||||
To prevent UI thrashing during high-volume event ingress (e.g., initial flood from relays), the client uses a **buffering and debouncing** strategy.
|
||||
1. **Restore**: `restoreLocalData` reads from IndexedDB (or localStorage fallback).
|
||||
2. **Display**: The UI renders cached content immediately.
|
||||
3. **Fetch**: The client connects to relays and fetches new events, updating the UI as they arrive.
|
||||
4. **Persist**: The `EventsCacheStore` periodically saves the current state to disk, using fingerprints to avoid unnecessary writes.
|
||||
|
||||
1. **Buffer**: Incoming events are pushed to `eventBuffer` instead of being processed immediately.
|
||||
2. **Debounce**: A flush timer is scheduled (75ms).
|
||||
3. **Flush**: When the timer fires, the entire buffer is processed:
|
||||
* Events are validated and converted.
|
||||
* `activeMap` is updated (latest-wins logic).
|
||||
* `onVideo` callback is fired once with the batch updates.
|
||||
* Cache persistence is scheduled.
|
||||
## Common Workflows
|
||||
|
||||
### 3. Publishing a Video (`publishVideo`)
|
||||
### 1. Publishing a Video
|
||||
`publishVideo(videoPayload, pubkey)`
|
||||
|
||||
Publishing is a multi-step process ensuring compatibility and discoverability.
|
||||
1. Constructs the video metadata (Kind 30078).
|
||||
2. Signs the event using the active signer.
|
||||
3. Publishes to configured relays.
|
||||
4. **Side Effects**:
|
||||
* If a hosted URL is present, publishes a NIP-94 mirror event (Kind 1063).
|
||||
* If NIP-71 categorization tags are present, publishes a wrapper event (Kind 22).
|
||||
|
||||
1. **Video Note (Kind 30078)**: The core content (magnet, infohash, metadata) is signed and published. A unique `videoRootId` is generated for new videos.
|
||||
2. **NIP-94 Mirror (Kind 1063)**: If a direct HTTP URL is provided, a file header event is published to point to the hosted file.
|
||||
3. **NIP-71 Metadata (Kind 22/21)**: Addressable metadata tags are published to support categorization/grouping without modifying the immutable video note.
|
||||
### 2. Loading the Feed
|
||||
`subscribeVideos(onVideo, options)`
|
||||
|
||||
### 4. Remote Signer Handshake (`connectRemoteSigner`)
|
||||
1. Checks cache for the latest timestamp.
|
||||
2. Subscribes to relays with `since` filter (if cached) or full history.
|
||||
3. Buffers incoming events.
|
||||
4. Updates `activeMap` and calls `onVideo` for valid updates.
|
||||
|
||||
For NIP-46 (Nostr Connect), the client implements a full handshake flow:
|
||||
## When to Modify
|
||||
|
||||
1. **Ephemeral Key**: Generates a local key pair for the session.
|
||||
2. **Handshake**: Publishes a Kind 24133 (`NIP46_RPC_KIND`) event to the relay.
|
||||
3. **Wait**: Listens for an acknowledgment or `auth_url` challenge from the signer.
|
||||
4. **Session**: Once connected, stores the session metadata to allow auto-reconnection.
|
||||
|
||||
## Why it works this way
|
||||
|
||||
* **Buffering**: essential for performance. React rendering cycles are expensive; updating state for every single event in a 5000-event relay dump would freeze the browser.
|
||||
* **Tombstones**: essential for eventual consistency. Relays are distributed and can be out of sync. If a user deletes a video, we must remember that deletion timestamp so a slow relay doesn't "undelete" it by serving the old event later.
|
||||
* **Dual-Layer Cache**: IndexedDB is asynchronous and large-capacity; localStorage is synchronous but small. We use IndexedDB for the bulk data but keep a fallback for environments where IDB might be flaky or slow to initialize.
|
||||
|
||||
## When to change
|
||||
|
||||
* **Refactor `EventsCacheStore`**: If moving to a more robust state management library (like RxDB or dedicated worker), this logic should be extracted.
|
||||
* **New Event Kinds**: If the video data model changes (e.g., V4 schema), `convertEventToVideo` and `publishVideo` will need updates.
|
||||
* **Signer upgrades**: As NIP-46 evolves (e.g., Bunker standards), the handshake and connection logic in `nip46Client.js` (and its orchestration here) will need adjustment.
|
||||
|
||||
## Related Files
|
||||
|
||||
* `js/nostrEventSchemas.js`: Defines the structure of the events being built/parsed.
|
||||
* `js/nostr/nip46Client.js`: Handles the low-level RPC for remote signers.
|
||||
* `js/state/profileCache.js`: Handles user profile metadata (Kind 0), which is separate from video data.
|
||||
* **New Event Kinds**: If adding support for a new NIP or event type, add handlers here.
|
||||
* **Performance Tuning**: Adjust buffering timeouts or cache policies in `EventsCacheStore` if UI responsiveness issues arise.
|
||||
* **Signer Logic**: If modifying how keys are handled (e.g., adding a new wallet adapter), update `signerHelpers.js` or `nip46Client.js`, but ensure `NostrClient` integrates it correctly.
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
setNostrEventSchemaOverrides,
|
||||
buildVideoPostEvent,
|
||||
buildRepostEvent,
|
||||
buildProfileMetadataEvent,
|
||||
} from "./nostrEventSchemas.js";
|
||||
|
||||
// Inspect the current schema
|
||||
@@ -78,6 +79,9 @@ sign/encrypt calls. Call `clearActiveSigner()` on logout if your integration
|
||||
manages session state manually; the built-in logout handler already does this
|
||||
for the default extension flow.
|
||||
|
||||
For adapter implementation details and test guidance, see
|
||||
[`docs/signing-adapter.md`](signing-adapter.md).
|
||||
|
||||
### Accessing raw events
|
||||
|
||||
`NostrClient` now keeps a lightweight cache of parsed videos alongside a
|
||||
@@ -142,23 +146,31 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
|
||||
| --- | --- | --- | --- |
|
||||
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `isNsfw`, `isForKids`, `enableComments`, `ws`, `xs`) |
|
||||
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
|
||||
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | Empty content |
|
||||
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | JSON-serialized event being reposted |
|
||||
| Video reaction (`NOTE_TYPES.VIDEO_REACTION`) | `7` | `['e', <event id>, <relay?>]` or `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <author pubkey>]` | Reaction content (e.g. `+`, `-`, or emoji) |
|
||||
| Video comment (`NOTE_TYPES.VIDEO_COMMENT`) | `1111` | NIP-22 root scope tags `['A'\|`E`\|`I`, <pointer>, <relay?>?], `['K', <root kind>]`, and `['P', <root author pubkey>, <relay?>?]` plus parent metadata `['E'\|`I`, <parent pointer>, <relay?>?, <author?>], `['K', <parent kind>]`, `['P', <parent author>, <relay?>?]`; inherits schema append tags | Plain text body sanitized to valid UTF-8 |
|
||||
| DM attachment (`NOTE_TYPES.DM_ATTACHMENT`) | `15` | `['p', <recipient pubkey>]`, `['x', <sha256 of uploaded bytes>]`, `['url', <download url>]`, optional `['name', <filename>]`, `['type', <mime>]`, `['size', <bytes>]`, optional `['k', <base64 key>]`; inherits schema append tags | Empty content; attachment metadata is represented as tags |
|
||||
| DM read receipt (`NOTE_TYPES.DM_READ_RECEIPT`) | `20001` | `['p', <recipient pubkey>]`, `['e', <message event id>]`, optional `['k', <message kind>]`; inherits schema append tags | Empty content; ephemeral receipt for message activity |
|
||||
| DM typing indicator (`NOTE_TYPES.DM_TYPING`) | `20002` | `['p', <recipient pubkey>]`, optional `['e', <conversation event id>]`, `['t','typing']`, `['expiration', <unix seconds>]`; inherits schema append tags | Empty content; ephemeral typing indicator that expires quickly |
|
||||
|
||||
> **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).
|
||||
|
||||
| NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', <title>]`, optional `['published_at', <unix seconds>]`, optional `['alt', <text>]`, repeated `['imeta', ...]` entries describing NIP-92 media variants, optional `['duration', <seconds>]`, repeated `['text-track', <url>, <kind>, <language>]`, optional `['content-warning', <reason>]`, repeated `['segment', <start>, <end>, <title>, <thumbnail>]`, repeated hashtags `['t', <tag>]`, repeated participants `['p', <pubkey>, <relay?>]`, repeated references `['r', <url>]` | Plain text summary carried in the content field. Publishing is gated by the `FEATURE_PUBLISH_NIP71` runtime flag while the rollout stabilizes. |
|
||||
| NIP-71 short video (`NOTE_TYPES.NIP71_SHORT_VIDEO`) | `22` | Same as `NOTE_TYPES.NIP71_VIDEO`; the kind differentiates short-form presentations. | Plain text summary; gated by `FEATURE_PUBLISH_NIP71`. |
|
||||
| Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content |
|
||||
| DM relay hints (`NOTE_TYPES.DM_RELAY_LIST`) | `10050` | Repeating `['relay', <relay url>]` tags to advertise delivery relays for NIP-17 gift-wrapped DMs | Empty content |
|
||||
| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', <eventId>]` or `['a', <address>]`), and a stable dedupe tag `['d', <view identifier>]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message |
|
||||
| Watch history month (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`) | Replaceable list tag `['d', `${WATCH_HISTORY_LIST_IDENTIFIER}:<YYYY-MM>`]` with optional `['month', <YYYY-MM>]` marker plus schema append tags; no chunk pointers required. | JSON payload `{ version, month: 'YYYY-MM', items: [{ id: <eventId\|address>, watched_at?: <unix seconds> }] }` |
|
||||
| Watch history month (`NOTE_TYPES.WATCH_HISTORY`) | `WATCH_HISTORY_KIND` (default `30079`) | Replaceable list tag `['d', `${WATCH_HISTORY_LIST_IDENTIFIER}:<YYYY-MM>`]` with optional `['month', <YYYY-MM>]` marker plus schema append tags; no chunk pointers required. | JSON payload `{ version, month: 'YYYY-MM', items: [{ id: <eventId\|address>, watched_at?: <unix seconds> }] }` |
|
||||
| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30000` | `['d', 'subscriptions']` | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', <hex>], …]`) |
|
||||
| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `10000` | `['d', 'user-blocks']` | NIP-04/NIP-44 encrypted JSON `{ blockedPubkeys: string[] }` |
|
||||
| Hashtag preferences (`NOTE_TYPES.HASHTAG_PREFERENCES`) | `30015` | `['d', 'bitvid:tag-preferences']` plus schema-appended `['encrypted','nip44_v2']` | NIP-44 encrypted JSON `{ version, interests: string[], disinterests: string[] }` |
|
||||
| Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Storage challenge (`NOTE_TYPES.STORAGE_CHALLENGE`) | `22242` | `['challenge', <hex>]`, `['purpose', 'bitvid-storage-key']` | Empty content. This event is **ephemeral** and never published to relays. It serves as a challenge for the signer; the deterministic signature produced is used to derive the local encryption Master Key for the Storage Service. |
|
||||
| Profile metadata (`NOTE_TYPES.PROFILE_METADATA`) | `0` | `['d', ...]` is not used | JSON payload with NIP-01 fields (`name`, `about`, `picture`, `nip05`, etc.) |
|
||||
| Mute list (`NOTE_TYPES.MUTE_LIST`) | `10000` | Repeated `['p', <pubkey>]` tags for blocked/muted users | Optional content (often encrypted) |
|
||||
| Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', <recipient pubkey>]` plus optional `['e', <event id>]`, `['a', <coordinate>]`, `['amount', <msats>]`, `['lnurl', <bech32>]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note |
|
||||
| Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', <invoice>]`, `['description', <zap request JSON>]`, `['p', <recipient pubkey>]` plus optional `['e', <event id>]` and `['a', <coordinate>]` | Empty content; receipts are published by the recipient's LNURL server |
|
||||
|
||||
Subscription lists therefore match the
|
||||
[NIP-51 follow-set specification](./nips/51.md#sets) by emitting kind `30000`
|
||||
@@ -180,6 +192,10 @@ bitvid now consumes both legacy direct messages (kind `4`) and modern gift-wrap
|
||||
|
||||
The `isPrivate` flag in Content Schema v3 marks cards that should stay off shared or public grids. Clients should suppress these events for everyone except the owner, even though the payload stays in clear text for compatibility.
|
||||
|
||||
For the privacy model, relay hint selection flow, and user controls (privacy
|
||||
toggle, relay list publishing, and metadata toggles), see
|
||||
[`docs/dm-privacy-model.md`](dm-privacy-model.md).
|
||||
|
||||
If you introduce a new Nostr feature, add its schema to
|
||||
`js/nostrEventSchemas.js` so that the catalogue stays complete and so existing
|
||||
builders inherit the same debugging knobs.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# SigningAdapter guide
|
||||
|
||||
`SigningAdapter` is the lightweight interface bitvid uses to sign direct-message
|
||||
payloads and message metadata without depending on a specific auth provider.
|
||||
The canonical implementations live in `js/auth/signingAdapter.js`, and callers
|
||||
such as `NostrClient.sendDirectMessage()` and the DM composer accept a
|
||||
`signingAdapter` option so they can work with NIP-07 extensions, remote signers,
|
||||
or test doubles.
|
||||
|
||||
## Interface contract
|
||||
|
||||
A `SigningAdapter` is an object with the following async methods:
|
||||
|
||||
- `getPubkey()` → resolves the hex public key that should be used as the DM
|
||||
author.
|
||||
- `getDisplayName()` → optional display name string for UI metadata.
|
||||
- `signEvent(unsignedEvent)` → returns a fully signed Nostr event object.
|
||||
- `signMessage(message)` → returns a signature for an arbitrary string.
|
||||
|
||||
`signEvent()` is required for DM publishing; `signMessage()` is optional but is
|
||||
used by the DM composer to attach a signature to the outgoing payload metadata.
|
||||
|
||||
## Implementing a new adapter
|
||||
|
||||
1. **Normalize the pubkey**: resolve and return a lowercased hex pubkey from
|
||||
`getPubkey()`. The NIP-07 adapter does this by delegating to
|
||||
`window.nostr.getPublicKey()` and normalizing the result.
|
||||
2. **Provide signing primitives**:
|
||||
- `signEvent()` must return an event with `id` and `sig` populated.
|
||||
- `signMessage()` should return a stable signature string for the message
|
||||
body when you need message-level signatures (for example, the DM composer
|
||||
includes this for UI payloads).
|
||||
3. **Expose a type label**: include a `type` field (for example `nip07`, `dev`,
|
||||
or `test`) to make debugging easier.
|
||||
|
||||
Reference implementations:
|
||||
|
||||
- `createNip07SigningAdapter({ extension })` uses a browser extension signer.
|
||||
- `createEphemeralDevSigningAdapter()` generates a local, throwaway keypair for
|
||||
development flows.
|
||||
- `createTestSigningAdapter()` returns deterministic signatures for tests.
|
||||
|
||||
## Testing a SigningAdapter
|
||||
|
||||
### Unit tests
|
||||
|
||||
For Node-based tests, use `createTestSigningAdapter()` so signatures are stable
|
||||
and deterministic. You can pass it directly to `NostrClient.sendDirectMessage()`
|
||||
through the `options.signingAdapter` field to avoid relying on active signers:
|
||||
|
||||
```js
|
||||
import { createTestSigningAdapter } from "../js/auth/signingAdapter.js";
|
||||
import { nostrClient } from "../js/nostrClientFacade.js";
|
||||
|
||||
const adapter = createTestSigningAdapter({
|
||||
pubkey: "f".repeat(64),
|
||||
});
|
||||
|
||||
const result = await nostrClient.sendDirectMessage(
|
||||
"npub...",
|
||||
"hello",
|
||||
null,
|
||||
{ signingAdapter: adapter }
|
||||
);
|
||||
```
|
||||
|
||||
Assertions can then verify that `result.ok` is `true` and that the signed event
|
||||
was built with the adapter's pubkey and signatures.
|
||||
|
||||
### Manual smoke checks
|
||||
|
||||
For browser-only checks, wire the adapter into the DM composer by passing it in
|
||||
as the `signingAdapter` prop. The composer will call `getPubkey()`,
|
||||
`getDisplayName()`, and `signMessage()` while building the outgoing payload
|
||||
object so you can inspect the resulting metadata in your handler.
|
||||
|
||||
## Usage reminders
|
||||
|
||||
- When the signer does **not** implement NIP-44 encryption, NIP-17 sends are
|
||||
blocked; keep the privacy toggle disabled until encryption is available.
|
||||
- Always surface errors from `getPubkey()` or `signMessage()` to the caller so
|
||||
the UI can display a meaningful failure state.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Repost Comment Visibility Bug
|
||||
|
||||
This reproducer demonstrates an issue where comments made on an original video are not visible when viewing that video via a Repost (Kind 6).
|
||||
|
||||
## The Bug
|
||||
|
||||
When viewing a repost, the application often passes the Repost event's metadata (Kind 6, Reposter Pubkey) along with the *Original Video's ID* to the comment system.
|
||||
|
||||
The `CommentThreadService` (via `normalizeCommentTarget`) creates a filter descriptor based on this input. It expects comments to tag the **Repost** (Kind 6) and the **Reposter**.
|
||||
|
||||
However, comments are almost always made on the **Original Video** (Kind 30078), tagging the **Original Author**.
|
||||
|
||||
Because `isVideoCommentEvent` enforces strict matching of `rootKind` and `rootAuthor` against the descriptor, it rejects the valid comments on the original video, resulting in an empty comment section.
|
||||
|
||||
## Running the Reproducer
|
||||
|
||||
```bash
|
||||
node --import ./tests/test-helpers/setup-localstorage.mjs examples/reproducers/2-repost-comment-visibility-bug/reproduce_issue.js
|
||||
```
|
||||
|
||||
## Expected Output (Failure)
|
||||
|
||||
```
|
||||
Input Target: { ... videoKind: 6 ... }
|
||||
Generated Descriptor: { ... rootKind: '6' ... }
|
||||
...
|
||||
Is Comment Visible? false
|
||||
FAIL: Comment on original video is NOT visible when viewing repost.
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
import { __testExports } from "../../../js/nostr/commentEvents.js";
|
||||
const { isVideoCommentEvent, normalizeCommentTarget } = __testExports;
|
||||
|
||||
/**
|
||||
* REPRODUCER: Repost Comment Visibility Bug
|
||||
*
|
||||
* ISSUE:
|
||||
* When a video is a repost (Kind 6), the UI may pass the Repost event's metadata
|
||||
* (kind: 6, author: reposter) along with the Original Video's ID to the comment service.
|
||||
*
|
||||
* `normalizeCommentTarget` uses this metadata to build a descriptor that expects comments
|
||||
* to tag the Repost event (Kind 6) and the Reposter.
|
||||
*
|
||||
* However, comments are typically made on the Original Video (Kind 30078), tagging the
|
||||
* Original Author and Original Kind.
|
||||
*
|
||||
* `isVideoCommentEvent` then filters out these valid comments because they don't match
|
||||
* the "expected" rootKind/rootAuthor (which are set to Repost details).
|
||||
*
|
||||
* EXPECTED BEHAVIOR:
|
||||
* Comments on the original video should be visible even when viewing the repost.
|
||||
* The descriptor should normalize to the Original Video's metadata if the ID belongs to it,
|
||||
* OR `isVideoCommentEvent` should be smart enough to handle this mismatch.
|
||||
*/
|
||||
|
||||
// Mock Data
|
||||
const originalVideoId =
|
||||
"0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const originalAuthor =
|
||||
"0000000000000000000000000000000000000000000000000000000000000002";
|
||||
const originalKind = 30078;
|
||||
|
||||
const reposterPubkey =
|
||||
"0000000000000000000000000000000000000000000000000000000000000003";
|
||||
const repostKind = 6;
|
||||
|
||||
// The Input provided by UI when viewing a Repost
|
||||
// It incorrectly mixes Original ID with Repost Kind/Author in the current flow
|
||||
const targetInput = {
|
||||
videoEventId: originalVideoId,
|
||||
videoKind: repostKind,
|
||||
videoAuthorPubkey: reposterPubkey
|
||||
};
|
||||
|
||||
console.log("Input Target:", targetInput);
|
||||
|
||||
const descriptor = normalizeCommentTarget(targetInput);
|
||||
console.log("Generated Descriptor:", JSON.stringify(descriptor, null, 2));
|
||||
|
||||
// A valid comment on the ORIGINAL video
|
||||
const validCommentOnOriginal = {
|
||||
kind: 1111,
|
||||
tags: [
|
||||
["e", originalVideoId, "wss://relay.example.com", "root"],
|
||||
["K", String(originalKind)],
|
||||
["P", originalAuthor]
|
||||
]
|
||||
};
|
||||
|
||||
console.log("Comment Event Tags:", validCommentOnOriginal.tags);
|
||||
|
||||
// Run the check
|
||||
const isVisible = isVideoCommentEvent(validCommentOnOriginal, descriptor);
|
||||
console.log(`\nIs Comment Visible? ${isVisible}`);
|
||||
|
||||
if (!isVisible) {
|
||||
console.error(
|
||||
"FAIL: Comment on original video is NOT visible when viewing repost."
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("PASS: Comment on original video is visible.");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Similar Content Logic Bug
|
||||
|
||||
This reproducer demonstrates bugs in the logic for calculating "Similar Content" candidates.
|
||||
|
||||
## The Bugs
|
||||
|
||||
The `computeSimilarContentCandidates` function in `js/app.js` (or related controller logic) has several issues identified by unit tests:
|
||||
|
||||
1. **Empty Results:** It seems to be returning empty results even when valid candidates exist.
|
||||
2. **Tag Handling:** It may not be correctly identifying or counting shared tags.
|
||||
3. **Filtering:** It fails to return public videos when mixed with NSFW/Private ones (possibly due to returning nothing at all).
|
||||
|
||||
## Running the Reproducer
|
||||
|
||||
```bash
|
||||
node --import ./tests/test-helpers/setup-localstorage.mjs examples/reproducers/3-similar-content-logic-bug/reproduce_issue.js
|
||||
```
|
||||
|
||||
## Expected Output (Failure)
|
||||
|
||||
```
|
||||
Running Similar Content Logic Reproducer...
|
||||
|
||||
FAIL: orders similar videos by shared tags then timestamp
|
||||
Expected 3 matches, got 0
|
||||
FAIL: skips candidates without tag metadata
|
||||
Expected 1 match, got 0
|
||||
FAIL: filters NSFW and private videos when NSFW content is disabled
|
||||
Expected ["video-public"], got []
|
||||
|
||||
Summary: FAIL
|
||||
```
|
||||
@@ -0,0 +1,241 @@
|
||||
|
||||
import Application from "../../../js/app.js";
|
||||
import assert from "assert";
|
||||
|
||||
// Mock the method we want to test by grabbing it from the prototype
|
||||
// Note: We use the prototype because the test file did so.
|
||||
const computeSimilarContent = Application.prototype.computeSimilarContentCandidates;
|
||||
|
||||
function createAppStub({ videos = [] } = {}) {
|
||||
const videosMap = new Map();
|
||||
for (const video of videos) {
|
||||
if (video && typeof video.id === "string") {
|
||||
videosMap.set(video.id, video);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
videoListView: { currentVideos: videos },
|
||||
videosMap,
|
||||
getKnownVideoPostedAt(video) {
|
||||
if (Number.isFinite(video?.rootCreatedAt)) {
|
||||
return Math.floor(video.rootCreatedAt);
|
||||
}
|
||||
if (Number.isFinite(video?.created_at)) {
|
||||
return Math.floor(video.created_at);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
buildShareUrlFromEventId(id) {
|
||||
return id ? `https://bitvid.invalid/${id}` : "";
|
||||
},
|
||||
formatTimeAgo(timestamp) {
|
||||
return Number.isFinite(timestamp) ? `${timestamp}s` : "";
|
||||
},
|
||||
deriveVideoPointerInfo(video) {
|
||||
if (video?.pointerInfo) {
|
||||
return video.pointerInfo;
|
||||
}
|
||||
if (typeof video?.pointerKey === "string" && video.pointerKey.trim()) {
|
||||
return { key: video.pointerKey.trim() };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
isAuthorBlocked() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createVideo({
|
||||
id,
|
||||
pubkey = "author",
|
||||
displayTags,
|
||||
tags,
|
||||
rootCreatedAt,
|
||||
created_at,
|
||||
pointerKey,
|
||||
pointerInfo,
|
||||
shareUrl,
|
||||
isPrivate = false,
|
||||
isNsfw = false,
|
||||
} = {}) {
|
||||
const video = {
|
||||
id,
|
||||
pubkey,
|
||||
isPrivate,
|
||||
isNsfw,
|
||||
};
|
||||
|
||||
if (Array.isArray(displayTags)) {
|
||||
video.displayTags = displayTags.slice();
|
||||
}
|
||||
if (Array.isArray(tags)) {
|
||||
video.tags = tags.slice();
|
||||
}
|
||||
if (Number.isFinite(rootCreatedAt)) {
|
||||
video.rootCreatedAt = Math.floor(rootCreatedAt);
|
||||
}
|
||||
if (Number.isFinite(created_at)) {
|
||||
video.created_at = Math.floor(created_at);
|
||||
}
|
||||
if (typeof pointerKey === "string") {
|
||||
video.pointerKey = pointerKey;
|
||||
}
|
||||
if (pointerInfo) {
|
||||
video.pointerInfo = pointerInfo;
|
||||
}
|
||||
if (typeof shareUrl === "string") {
|
||||
video.shareUrl = shareUrl;
|
||||
}
|
||||
|
||||
return video;
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log("Running Similar Content Logic Reproducer...\n");
|
||||
let failed = false;
|
||||
|
||||
// Test 1: Ordering by shared tags then timestamp
|
||||
try {
|
||||
const activeVideo = createVideo({
|
||||
id: "root-video",
|
||||
displayTags: ["Art", "Music", "News"],
|
||||
});
|
||||
|
||||
const candidateA = createVideo({
|
||||
id: "video-a",
|
||||
displayTags: ["art", "music"],
|
||||
rootCreatedAt: 100,
|
||||
});
|
||||
const candidateB = createVideo({
|
||||
id: "video-b",
|
||||
displayTags: ["art"],
|
||||
rootCreatedAt: 200,
|
||||
});
|
||||
const candidateC = createVideo({
|
||||
id: "video-c",
|
||||
displayTags: ["art", "music"],
|
||||
rootCreatedAt: 50,
|
||||
});
|
||||
|
||||
const app = createAppStub({
|
||||
videos: [candidateA, candidateB, candidateC],
|
||||
});
|
||||
|
||||
const matches = computeSimilarContent.call(app, { activeVideo, maxItems: 5 });
|
||||
|
||||
if (matches.length !== 3) {
|
||||
throw new Error(`Expected 3 matches, got ${matches.length}`);
|
||||
}
|
||||
|
||||
const ids = matches.map((entry) => entry.video.id);
|
||||
const counts = matches.map((entry) => entry.sharedTagCount);
|
||||
|
||||
const expectedIds = ["video-a", "video-c", "video-b"];
|
||||
const expectedCounts = [2, 2, 1];
|
||||
|
||||
if (JSON.stringify(ids) !== JSON.stringify(expectedIds)) {
|
||||
throw new Error(`Order mismatch. Expected ${JSON.stringify(expectedIds)}, got ${JSON.stringify(ids)}`);
|
||||
}
|
||||
|
||||
if (JSON.stringify(counts) !== JSON.stringify(expectedCounts)) {
|
||||
throw new Error(`Counts mismatch. Expected ${JSON.stringify(expectedCounts)}, got ${JSON.stringify(counts)}`);
|
||||
}
|
||||
|
||||
console.log("PASS: orders similar videos by shared tags then timestamp");
|
||||
|
||||
} catch (error) {
|
||||
console.error("FAIL: orders similar videos by shared tags then timestamp");
|
||||
console.error(" ", error.message);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
// Test 2: Skips candidates without tag metadata
|
||||
try {
|
||||
const activeVideo = createVideo({
|
||||
id: "root-video",
|
||||
displayTags: ["art"],
|
||||
});
|
||||
|
||||
const candidateWithTags = createVideo({
|
||||
id: "video-a",
|
||||
displayTags: ["art"],
|
||||
});
|
||||
const candidateWithoutTags = createVideo({
|
||||
id: "video-b",
|
||||
});
|
||||
|
||||
const app = createAppStub({
|
||||
videos: [candidateWithTags, candidateWithoutTags],
|
||||
});
|
||||
|
||||
const matches = computeSimilarContent.call(app, { activeVideo, maxItems: 5 });
|
||||
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Expected 1 match, got ${matches.length}`);
|
||||
}
|
||||
if (matches[0].video.id !== "video-a") {
|
||||
throw new Error(`Expected 'video-a', got '${matches[0]?.video?.id}'`);
|
||||
}
|
||||
|
||||
console.log("PASS: skips candidates without tag metadata");
|
||||
|
||||
} catch (error) {
|
||||
console.error("FAIL: skips candidates without tag metadata");
|
||||
console.error(" ", error.message);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
// Test 3: Filters NSFW and private videos
|
||||
try {
|
||||
const activeVideo = createVideo({
|
||||
id: "root-video",
|
||||
displayTags: ["art"],
|
||||
});
|
||||
|
||||
const publicVideo = createVideo({
|
||||
id: "video-public",
|
||||
displayTags: ["art"],
|
||||
});
|
||||
const nsfwVideo = createVideo({
|
||||
id: "video-nsfw",
|
||||
displayTags: ["art"],
|
||||
isNsfw: true,
|
||||
});
|
||||
const privateVideo = createVideo({
|
||||
id: "video-private",
|
||||
displayTags: ["art"],
|
||||
isPrivate: true,
|
||||
});
|
||||
|
||||
const app = createAppStub({
|
||||
videos: [publicVideo, nsfwVideo, privateVideo],
|
||||
});
|
||||
|
||||
const matches = computeSimilarContent.call(app, { activeVideo, maxItems: 5 });
|
||||
|
||||
const matchedIds = matches.map((entry) => entry.video.id);
|
||||
const expectedIds = ["video-public"];
|
||||
|
||||
if (JSON.stringify(matchedIds) !== JSON.stringify(expectedIds)) {
|
||||
throw new Error(`Expected ${JSON.stringify(expectedIds)}, got ${JSON.stringify(matchedIds)}`);
|
||||
}
|
||||
|
||||
console.log("PASS: filters NSFW and private videos when NSFW content is disabled");
|
||||
|
||||
} catch (error) {
|
||||
console.error("FAIL: filters NSFW and private videos when NSFW content is disabled");
|
||||
console.error(" ", error.message);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.log("\nSummary: FAIL");
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("\nSummary: PASS");
|
||||
}
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,41 @@
|
||||
# Reproducer for Channel Moderation Test Crash
|
||||
|
||||
This reproducer demonstrates a bug in the test harness setup used in `tests/app/channel-profile-moderation.test.mjs`.
|
||||
|
||||
## Issue Description
|
||||
|
||||
The test helper `createModerationAppHarness` in `tests/helpers/moderation-test-helpers.mjs` instantiates the `Application` class using `Object.create(Application.prototype)`, bypassing the constructor.
|
||||
|
||||
The `Application` constructor is responsible for initializing `this.moderationDecorator`.
|
||||
|
||||
When `app.decorateVideoModeration()` is called (which is done in `renderChannelVideosFromList`), it attempts to call `this.moderationDecorator.decorateVideo()`. Since `this.moderationDecorator` is undefined, it throws a `TypeError: Cannot read properties of undefined (reading 'decorateVideo')`.
|
||||
|
||||
This causes tests in `tests/app/channel-profile-moderation.test.mjs` to fail with exit code 1.
|
||||
|
||||
## How to Run
|
||||
|
||||
Run the reproducer script:
|
||||
|
||||
```bash
|
||||
node examples/reproducers/issue-channel-moderation-test-crash/repro.mjs
|
||||
```
|
||||
|
||||
A successful run (reproduction of the crash) will output:
|
||||
`SUCCESS: Caught expected error: Cannot read properties of undefined (reading 'decorateVideo')`
|
||||
|
||||
## Fix (Recommendation)
|
||||
|
||||
The `createModerationAppHarness` helper should ensure `moderationDecorator` is initialized on the mock `Application` instance, or use a method of instantiation that runs the constructor (if feasible with mocks).
|
||||
|
||||
Example fix in `tests/helpers/moderation-test-helpers.mjs`:
|
||||
|
||||
```javascript
|
||||
// ... inside createModerationAppHarness
|
||||
const app = Object.create(Application.prototype);
|
||||
// ...
|
||||
// Manually initialize moderationDecorator since constructor is skipped
|
||||
app.moderationDecorator = new ModerationDecorator({
|
||||
getProfileCacheEntry: (pubkey) => app.getProfileCacheEntry(pubkey),
|
||||
});
|
||||
// ...
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import "../../../tests/test-helpers/setup-localstorage.mjs";
|
||||
|
||||
// Mock globals for Application load
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
|
||||
url: "https://example.com",
|
||||
});
|
||||
globalThis.window = dom.window;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
// Override WebSocket if setup-localstorage didn't do it or if we need a close method
|
||||
globalThis.WebSocket = class { close() {} };
|
||||
|
||||
// Import Application
|
||||
const { Application } = await import("../../../js/app.js");
|
||||
|
||||
console.log("Creating Application instance bypassing constructor...");
|
||||
// This replicates what createModerationAppHarness does in tests/helpers/moderation-test-helpers.mjs
|
||||
const app = Object.create(Application.prototype);
|
||||
|
||||
// Attempt to call decorateVideoModeration which relies on this.moderationDecorator
|
||||
// initialized in constructor.
|
||||
try {
|
||||
console.log("Attempting to call decorateVideoModeration...");
|
||||
app.decorateVideoModeration({});
|
||||
console.log("FAIL: decorateVideoModeration did not throw.");
|
||||
process.exit(1);
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && error.message.includes("decorateVideo")) {
|
||||
console.log("SUCCESS: Caught expected error:", error.message);
|
||||
process.exit(0); // Exit success
|
||||
} else {
|
||||
console.log("FAIL: Caught unexpected error:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"hash": "18c960c07a1b14b0",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "211f1794817bb9666b6c4efa29c91560f72ceab6cb4b64088d08d14f470996b6",
|
||||
"content": "젴㨁ၱ괚æ\udb40麗ሉ傯\uda90뷉呉\udae4戩廈\udfd1갓賏\uda06굦ꪉ璔믡」십쑴떣笓˸\udc5a\udae2烜\ud950䧾䍂땃挠줁鐼屎\uddc3튏锄\udd4e\ude2c傏",
|
||||
"additionalTags": [
|
||||
[
|
||||
"q77wm",
|
||||
"fSO71LLMPq"
|
||||
]
|
||||
],
|
||||
"dTagValue": "hSAuaQM2FZ",
|
||||
"eventId": "7261d7919e592b16974bc6d0c043eb6321c83d78844ea4c30357ade246032aa2",
|
||||
"cxosb": 8411.55359292702
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.506Z"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"hash": "227e8db93b7456c9",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "cc401e3196abdc664a40862d3cd9025af82aa2ac55bbee7e69c7faefbd81cb74",
|
||||
"created_at": 1768926859,
|
||||
"content": "ꔳ幰\udbc7岽\udef9\udea2㦉땮\udeee䏓ᄁ⡏ﱳ䋯䓙\udaed굘ӹ旎⎪\udee6紌\udd16瑩Ꟈ\udf75鎍⑩ᭌ瘫ǻ刧敃摢穃翂櫵ᙾ拷젙\uddb3\udeb7씰\udc28␋\udfa4軤",
|
||||
"additionalTags": [
|
||||
[
|
||||
"OGAEp",
|
||||
"sCThZka3DJ"
|
||||
],
|
||||
[
|
||||
"elm64",
|
||||
"uN1X33bJV2"
|
||||
]
|
||||
],
|
||||
"dTagValue": "OAOTVCvayE",
|
||||
"eventId": "c5c0e46d9c509a99eaa7b237dd7e9741e07bf9fe424d83aaaab1f5c68ca810e3"
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.531Z"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hash": "2388d33b32f5dd1f",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "576173493bdf93c35219b1a1e842fd06493d6ca8a7e652f37e3e9e1dfdc03a74",
|
||||
"created_at": 1768926859,
|
||||
"content": "薁睶\ude68㩻﵆\udd0e菕
挠\ude84tℋ䂖騺鍱\udb31ᜧά갛\udc08\udcd4㐟䗝㲳忠\uda44⯒狙뻡㩼餵睊琻塘\udfbe\udb02鱫瀭鄉\udde3삭鲢䤫圢\udb48ᗷ\ude96",
|
||||
"dTagValue": "NVe9A407cH",
|
||||
"eventId": "7f6cf3739e77207b2d5f9f2aacb90fac8545125e372775e551716e184e2c63f9",
|
||||
"cpmtW": null
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.543Z"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"hash": "2d038dd6ff3603e4",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "d33cbc19b939f4c690253ba9586c0762968fb9cfcffcd085f1251441a51c737a",
|
||||
"created_at": 1768926859,
|
||||
"additionalTags": [
|
||||
[
|
||||
"EEL1e",
|
||||
"Zr5ZuL4HR8"
|
||||
],
|
||||
[
|
||||
"Q2rEl",
|
||||
"tTrkEYP54t"
|
||||
],
|
||||
[
|
||||
"upLL8",
|
||||
"AoPmhLHLNv"
|
||||
]
|
||||
],
|
||||
"dTagValue": "VR73sR2X8M",
|
||||
"eventId": "87e7c3605c9a88ef9d9e3984e7e6b49d3a790e746a35bbcb3157695828615e6a"
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.549Z"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"hash": "7d28c8f4e78c2a83",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "d1f3b06f1f9ab423b6bbcb9b9b8ae956e21893bdb09feba59cc01f451e92ae95",
|
||||
"content": "졹즮ﮕ냿㦫斮냲Ѻ㝫翗\udee3瑌⡚㢴\udb99刭繐시둥睧襼ﹶꃛꃿ哉狏騣隚䰰₤\udacf☯刴\udcc2搧\udce8㚲䜼傫ꦖ勃뱭",
|
||||
"additionalTags": [
|
||||
[
|
||||
"iVEpY",
|
||||
"Sm5vcAEAuR"
|
||||
],
|
||||
[
|
||||
"Ui2Kv",
|
||||
"C3lqEHkIIf"
|
||||
]
|
||||
],
|
||||
"dTagValue": "5M3K7s1CZ3",
|
||||
"eventId": "1428c4a55f48084251e0c8899f90f045b1346abfd938897aa424227d2d16ad4c",
|
||||
"fJXSq": false
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.508Z"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"hash": "85fde36936a9aa86",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "415eaf447da61c89b247562016620bb385c07e8b870d92c7d3d799eb300a720e",
|
||||
"created_at": 1768926859,
|
||||
"content": "锅瘜퇍\udbf7ヨ茸\udad9ꔶ궍Ⓒ\udfdc⣶ﹰ쿃咖\uda15\ud910ྀ餂뚲\udd7d蔪冷宷拉蟒綹𮭶錅死쭽뷟掴᳓ԯ縱ꏫ㺅㨼괾鯏",
|
||||
"additionalTags": [
|
||||
[
|
||||
"zGn0s",
|
||||
"YkXLolqsxO"
|
||||
]
|
||||
],
|
||||
"dTagValue": "OvQLBvFwWi",
|
||||
"eventId": "843ae1e4b92b44b37c439ebadcf3c62444dd544e266e51c45bee1535782ca20b",
|
||||
"cfiJH": {
|
||||
"obfx4": {
|
||||
"tRVaR": "倂\udaf8\udaf2ᜑ\udf10榦\ude58Ю쫪᳑礅ꈑᛖ竣皰遐伹狍致ㅧ䙠\ud97b颢萕酂侕秀㍓〧ퟮ탛\ud951Խ繶㩎",
|
||||
"oIPt2": 2116.4722459860054,
|
||||
"nYQ7a": {
|
||||
"tKuK2": "⏤췟蠚꛲昄팍朱Ừ㦄㲟킪塅\udb7b養ﭪੀꙋ珎儠罼꿇鬇ᡞ\udbde穎꣫蝿敐\udfb4⼺囱浊僵ڨ\uda97\ud8c7\ud949\ud90aᆿ\udc50\uddd6큪ꪷꁬꌉ⊢㇑\udb2c鶬槳ᐉ룧\ud862쥰ꐞ耔륥\ude7f\udf91廢阬룾\ud9ea遼祘펰涊Ꝓᆽ\udc6f𗗖狋ਸᅜ\ude1d魫碦炲붻",
|
||||
"BU1mk": "㺡ฑӢ쳁ﵑ䟫됯䠝鳚\ude01◜\udfa2蹒⋶柵\ud8d5鱍뒮᱈䝒\ud8a0섧Ọ㊓⺉횢邗玹\udf9e碊\udbda\ud8ec涁\ude12힛别걍堥㢯끂ꘀ︓\udddc孭걎셽则揽林傸독\ud934䴊蔵䉡䏐뱲椠ѕ\ude01頣꼱꼿맅謹\udae2\ud808콛玵長嘦봓暆哶죕鷱",
|
||||
"4oh3t": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.543Z"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"hash": "8d1d49645b90d7ce",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "897ba807670635e9028915f0d2a5d8dccfc3cef45dc00537013bed46915e0e2b",
|
||||
"created_at": 1768926859,
|
||||
"content": "寱嫤\udf71ᆲ褥齼㝃੨\ud839✈\udc53貂ꭣﵘᲬ잸⚩ڛ矈䛋ᵻ햙睡缳㙙\udd85䵇森즶┅䧊⌝လ㍼\udebfҩ뒮孴憅襖\ud987큜",
|
||||
"additionalTags": [
|
||||
[
|
||||
"WX2IY",
|
||||
"mpSajY2xeL"
|
||||
],
|
||||
[
|
||||
"nsM2D",
|
||||
"SdgpCEcGjK"
|
||||
],
|
||||
[
|
||||
"3Kfhr",
|
||||
"08NTDSgrSs"
|
||||
],
|
||||
[
|
||||
"0QMFV",
|
||||
"gNv4TRO4nD"
|
||||
],
|
||||
[
|
||||
"lDdW5",
|
||||
"XRC6qfbLWJ"
|
||||
]
|
||||
],
|
||||
"eventId": "8afbfbd0712f801f8dbcb769e04334ebc644b5098d9811db5e64f0b079833ffd",
|
||||
"9TAlG": null
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.517Z"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"hash": "91f935834c393ec6",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "407827d56783cf92690806e83584f140ac020f77863bde94b98b1be645dc754b",
|
||||
"created_at": 1768926859,
|
||||
"content": "괾趞劵呆쨱ꌓ⸢\uda4a艦Ꜻ瓸ʲ㴭\ud826䵌ꆪ즜뼙䝁껊맒ꉳ裟\udc48춾讄閅㛡\uddb5䲲ᴡ䥾\ude96飽ⅻ奈턍䁑鲉骆浀워\ud983멍쇳\ud9f5폘廨\udde9",
|
||||
"additionalTags": [
|
||||
[
|
||||
"Hex9u",
|
||||
"xuTpbZTCJZ"
|
||||
],
|
||||
[
|
||||
"0Pj8H",
|
||||
"xGRidktuGx"
|
||||
],
|
||||
[
|
||||
"50YMk",
|
||||
"L6x6dP7NuV"
|
||||
],
|
||||
[
|
||||
"G6Y7w",
|
||||
"rL5ziZzTJP"
|
||||
]
|
||||
],
|
||||
"dTagValue": "698DG6ofzq",
|
||||
"eventId": "c293397373b188cd1739673a53b3441d8366e97eec0b49ce42220d7dd52ea18d"
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.532Z"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"hash": "9542c7dff1386872",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "a1f6dc0c6ed04eb5a7c445d75b235503fab159dfe2a6c81908e18de21b276629",
|
||||
"created_at": 1768926859,
|
||||
"content": "띬띔횎\ud92eⵡ濐椻绍笜\udbe0\ud9e4꒝꒕\udf63\ud829\uddde羠珠䤑ꎺ郔\uda35願쐝剨鲨妆璐녮\udac2夆㍬㒔\udc19䚝\ud9df轛㹻䛊梨骪䬼",
|
||||
"additionalTags": [
|
||||
[
|
||||
"vamZy",
|
||||
"GSc8LqPhma"
|
||||
],
|
||||
[
|
||||
"kTMoy",
|
||||
"CVvoRUIw7e"
|
||||
],
|
||||
[
|
||||
"Xhqdf",
|
||||
"A6DC90idgK"
|
||||
]
|
||||
],
|
||||
"dTagValue": "ceP4ztRvZA",
|
||||
"eventId": "6223d12f986cfb6023dec140c80720bb96457cc5847a088b02e53b606ab70e88",
|
||||
"s3KNd": false
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.566Z"
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"hash": "97b4273a8fe15fbe",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "7c3724fd1df9c80af92bbdf94217cac149df0f4d5624da9d55b9be030ef1c476",
|
||||
"created_at": 1768926859,
|
||||
"content": "䁟面\ude5b뷌ﺃ蛿\ud973\ud87a潟\ud9a3狱癉뢓䁞⺵敊睚蟬탯䒌ﳕ\udfd5䟜\udcc7\ud82d윷\ud926ྖ惻\ude45馍ﱩ龵魯莺ᴚ洨⣐\ude4a߸덻",
|
||||
"additionalTags": [
|
||||
[
|
||||
"s1OI3",
|
||||
"gdsJFyYpaq"
|
||||
],
|
||||
[
|
||||
"ZQ69f",
|
||||
"oyglX4Gl01"
|
||||
],
|
||||
[
|
||||
"NlTNs",
|
||||
"O1U4EsfdJB"
|
||||
],
|
||||
[
|
||||
"1BSQP",
|
||||
"QlFRMWP1Aw"
|
||||
],
|
||||
[
|
||||
"yM5dw",
|
||||
"x7MC08UOCO"
|
||||
]
|
||||
],
|
||||
"eventId": "145f4a20c49b9e6eb5a5da9d080a49e346db18ef87311823bfb208ddaf99337c",
|
||||
"Kta5T": {
|
||||
"sjSQd": null,
|
||||
"EAIXe": "\udccc踞㈧\udb24⧪幖⥼븛\udf03\uded1\udc6d쫙㬵﹥檛ꗧ榗퉳\udea8⢑緿汅묰宮\ud985夂䩃ᴣ殄䡎\uda14琽寮硫ᵪ꺀곭ᒕྨ䯁Ⴐ螥䱰ﴟ\uddf6\udd6aZ汼獴攳䬎䣋\udc4b堠\udf46\udb55皒\uded7ֹ硓繌칪汴䡁\uda12Თ徝벹ꉓ㩥㙰敚\udfa3",
|
||||
"shQ9t": 9712.102376936997,
|
||||
"XQ27X": {
|
||||
"JGJTu": {
|
||||
"HkOET": [
|
||||
[
|
||||
[
|
||||
true,
|
||||
null,
|
||||
true
|
||||
],
|
||||
{
|
||||
"fO3Pp": 265.33605545970175,
|
||||
"PdIMd": {
|
||||
"o4z7g": 5652.840913340699,
|
||||
"ViLwB": {
|
||||
"T7DVJ": null,
|
||||
"hEVDx": null,
|
||||
"KSJNy": {},
|
||||
"Lg5pm": "듈輳\udf16ťᰭ份毽園맋乷⏥ᡙ\udb94뵺\udd79篱랁\ud8d3ᔠ菝⊍샺헡뷾㲐澴蛊⟖\udf95̭\uddca\ud8e4碄\ud828"
|
||||
}
|
||||
},
|
||||
"ddTzu": "",
|
||||
"hAmUf": [
|
||||
{
|
||||
"AxxMA": 5577.908593874583,
|
||||
"kMpqu": {
|
||||
"9tIb0": [
|
||||
9373.082458292405,
|
||||
910.9792573057307,
|
||||
null
|
||||
],
|
||||
"KLf1v": [
|
||||
[
|
||||
true,
|
||||
[
|
||||
null
|
||||
],
|
||||
null
|
||||
]
|
||||
],
|
||||
"7lfoe": "Ⲙ榵鐶\udf22\udeeb淽\udf41\udef7㙏\udaff\udaf8猔얒\ud8ad愽귿\udad3꤭倛ꢓ禈؟䵶\uda48跓鹒గ欺ﰩ䐆Ⴟ磶\ude2e킲\ud96d퇳ѱ⡘\udfe1㒖\udf8a"
|
||||
}
|
||||
}
|
||||
],
|
||||
"kwKRg": {
|
||||
"tIRii": [],
|
||||
"ED8W0": "鋻뺕哓鈍彚簎먇\udd4eⰱꙫꂓ䏏\udc39\uda1aう\udd3e뻺\udef3욞믊\ud9b6㶶윺槠㍱㖌ᄬ틭\ude1c죲칇绌䉎ﲃ\ude5aㄸᶪ棨䂍鱌納⻇淰⢦佾뜃ﲣ嘵㈥녏蝧ﭯ\udeaa馩㪚\udde2ॄ㑉㱋盆ⶌ"
|
||||
}
|
||||
},
|
||||
{
|
||||
"zu4Uv": 8618.712144946234,
|
||||
"gMudq": true,
|
||||
"xwA25": false
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"x99oQ": null,
|
||||
"OVXDu": 6984.617523492656,
|
||||
"GxxAh": [
|
||||
{},
|
||||
null
|
||||
],
|
||||
"Mbljc": [
|
||||
false,
|
||||
[]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.482Z"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"hash": "9a68a21eb821d150",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "eead25bac11265dbf5d5dc85e2a4e13475799a942db587944066a33d994dd535",
|
||||
"created_at": 1768926859,
|
||||
"content": "\udaa1Ĝ䗷ܬ\udf57荮\udd34࠶짽တ呦瘁埽\udec6ﳅ\ud869\udb63ẜȚ䒜냛谅ꛉ\udf64濧陋\udb95㕍ɔ炩抻⩄䬄쐕潝먔獽ꪟ쓶阻ꑫ嬀⡉ᳶ",
|
||||
"additionalTags": [
|
||||
[
|
||||
"707fp",
|
||||
"1loFnMJca4"
|
||||
],
|
||||
[
|
||||
"HhEan",
|
||||
"yciSPB5wlB"
|
||||
],
|
||||
[
|
||||
"0p6W5",
|
||||
"5IMUKnIeic"
|
||||
],
|
||||
[
|
||||
"cB5SQ",
|
||||
"BXCZ7JxWkl"
|
||||
],
|
||||
[
|
||||
"pt11s",
|
||||
"w2TOjbcwXl"
|
||||
]
|
||||
],
|
||||
"dTagValue": "exrKZf3lsj",
|
||||
"eventId": "be8ee7354cbabac4647130b09d4e050f81b61e8f3182dc9357ec29fed575cac4",
|
||||
"iJzUL": "삻翘㝃暞\ud977茇殸⪼닝峎땟˽蓽ᬇ\ud947智\udcffƐ䑳\udea7玖\udcafុ刯\udb8d廬ꯨ왙돕邢\udda3꠴ᢙ拘Ꜭ䁋\ud84e鱆\udd91ᷗ謷\ud980\udd70來ﱀ酐\udc0f끸噶翤ᘭ㚩ꉁ瞡䆇\ud811詔極孬팿\udb36渎䭢᪂푝ⓤ䄊\udd0a\udb03䔹雲ψ\uddce껵덹㟷鋯\uda3d낉軓\udb55\ud8a8ꦬ\udd67ߥ"
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.551Z"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"hash": "9e2b76583d4b46cf",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "eb2c35eeab443722897ed1add9c97202797ac45a0a58f9950de20460f30c8bad",
|
||||
"created_at": 1768926859,
|
||||
"content": "\udfe3죸⨘\udd6b힖䢍\udf04觚卵瀞佭\ude9aẕ䳽\ud9cd評롘잤桭貒暃뛀턻뢛閕慏ᘂﮔ괵ԫ\uda4e뙤岱\ud95d雉ߒ\ud9ab悜荙怟\udc34뙦\udfda㒬㈅",
|
||||
"additionalTags": [
|
||||
[
|
||||
"DuYKZ",
|
||||
"PfHnMIr5Bk"
|
||||
]
|
||||
],
|
||||
"dTagValue": "973aEhQQcO",
|
||||
"eventId": "e1e45c178921b470bc7018c909df5c14a08907cc4468a124ac80056182bbd4db"
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.541Z"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"hash": "a58dd9ee08c19eca",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "29f5fc4f9ed239aa66179a85a767352536e9e0eef3f7d3e7cfa15443bd66f4b4",
|
||||
"created_at": 1768926859,
|
||||
"content": "壳䨘둈貍䢹ӌꙈ鱨䲗\ud949粏窞\udb56㷧跿茶\uda13ᰜ騿貸싵\udb12۩꧅鞊꾾趹\udf6bꮆ㿅\udd87报㝒䢱啨\ude90⮅ึ✑記먙⦂砲문鯻瞗",
|
||||
"additionalTags": [
|
||||
[
|
||||
"4kSJm",
|
||||
"8PGGWlUJk7"
|
||||
],
|
||||
[
|
||||
"PoqmU",
|
||||
"yi5kUCLwTa"
|
||||
],
|
||||
[
|
||||
"VuPQD",
|
||||
"pWCxsp8Zgy"
|
||||
],
|
||||
[
|
||||
"A42dG",
|
||||
"aS26J6nAsP"
|
||||
]
|
||||
],
|
||||
"dTagValue": "9jQxiKeSIo",
|
||||
"eventId": "9b4eb0132046934e6f2507ad51e3d799038ea9b4d0610b6059bf5c33b1235115",
|
||||
"CON5u": [
|
||||
8867.612802646758
|
||||
]
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.540Z"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"hash": "c1a8c5dbd064fa0b",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "468deb5661f468407499e44c1a73b551625f052d35bf79f88829b0c95a288eb4",
|
||||
"content": "춁俳㌻鸂\udaea꠲\udb11\udd57\uddeaፋ菅酫鳾㋅䥴\udefb驩ᕦ熞䤺䐐\udd21뭌츔㵻\ud885㘪㭴埳躇৬쥢軎\udcbc᪹鵓럢쓶䋸铇ꦈ\ud851",
|
||||
"additionalTags": [
|
||||
[
|
||||
"tYyDa",
|
||||
"KspWQJHOod"
|
||||
]
|
||||
],
|
||||
"dTagValue": "vQbH1Ux6oW",
|
||||
"eventId": "c2fc8377e82146d86a87d0b738e910c29ca0ba3645b82dfe807e71532c8f902b",
|
||||
"9AH9x": []
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.554Z"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"hash": "c446637259d72e96",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "46f6cca0676ceb29c7e448929842451213b103a3bc11ee591646bc9e55b28d7f",
|
||||
"content": "린\udd2a䵿鳷k곊廚峡\udb2b缝睐\ude22ꀔ짧忀\ud8f4떰ꗰ\udc50풬ႆ蟶\udade兞\ud9e8떏楚儷䜼쐥鼶ᒜ䄗ꙫ皲莜\ud9c4⻛ギ␊",
|
||||
"additionalTags": [
|
||||
[
|
||||
"NpumS",
|
||||
"ufHBSpG49L"
|
||||
],
|
||||
[
|
||||
"t4p5f",
|
||||
"WnzWK88bUH"
|
||||
]
|
||||
],
|
||||
"dTagValue": "I0SdpsWAAn",
|
||||
"eventId": "d905ec3d877e235ec34b5292b7240c4501953be24fdb017b8105402c91da7157",
|
||||
"aS4ID": 1532.0835797977606
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.535Z"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"hash": "ca5e0379db09a508",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "61137f8c918da5e6a3efae6174d31b8870ec06b1e41ba6395fe593491106d7ec",
|
||||
"created_at": 1768926859,
|
||||
"content": "\ude91酭蓈\uddba鯳㶈\udd57뢫재\uda14俳皂ꥒ\udd28\udb2a㬛쬨\ud8db橥\ude01蹍隕뾣\ud81e㈖\udcd2竺\udfbb읩얏䛟읮本ᓰノ\udbe2\udff9뙰꧷퇷䶫焞\udce1\udec2\udb40",
|
||||
"additionalTags": [],
|
||||
"dTagValue": "YZMwTXBnF0",
|
||||
"eventId": "160f8475a536a8af8140239d59fb0e9b130b8428d1aaf99abb48ca6aada188f9",
|
||||
"DsDHJ": null
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.555Z"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hash": "d770efd177e76757",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"created_at": 1768926859,
|
||||
"content": "\ude79ꉋ詟\ud929\ud94d\udc4b⤘倳ꖨ萊䉕з忠쨌⦯ധ⊐神羈\udebc\uda86ᡵ쑰檨\ud8ee\ud875ꊛຒ敛\udd1f肿蠢둼襛✆荏",
|
||||
"additionalTags": [],
|
||||
"eventId": "798a916303e40319bdd0e7afcc95fdd448b4dc84501fdb8604e23d31f18a8fe4",
|
||||
"pzxvb": 9780.324581105677
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.561Z"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"hash": "e4db9b78fcd4b2a5",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:23)",
|
||||
"timestamp": "2026-01-20T16:32:00.400Z"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"hash": "e5ac4b49024a9315",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "63a10c52b6909024935c9e0b2a2c68e3db1ea1115f2434b50a797fb559b58bca",
|
||||
"created_at": 1768926859,
|
||||
"additionalTags": [
|
||||
[
|
||||
"Elx3b",
|
||||
"MmFA1VNdgc"
|
||||
],
|
||||
[
|
||||
"I3ybr",
|
||||
"XJDkDHqKnS"
|
||||
],
|
||||
[
|
||||
"ktZT5",
|
||||
"YVcLFQ1tgg"
|
||||
],
|
||||
[
|
||||
"rViGm",
|
||||
"Y58vBKHbbM"
|
||||
],
|
||||
[
|
||||
"HjDaJ",
|
||||
"ZY4F4o1jts"
|
||||
]
|
||||
],
|
||||
"dTagValue": "CU7mmWlnIL",
|
||||
"eventId": "728ee10a08594bde492cccd82af025639361b8ff608da37e5110d4bc4b98d785",
|
||||
"XP1x6": [
|
||||
{
|
||||
"PsmRN": [
|
||||
true
|
||||
],
|
||||
"EHNwp": 872.9927942691252,
|
||||
"oHHhu": "ⲉ㿙㘅磳쓿ֆ垜\ud8c7嘮ᭀᣵ\udd8d㨔\uda9d\ud933≄뎡\ude03\udaed旍菜㉉厕\udd94\udc2e쏞笒뚏\udb05ℛ鼇憑샄\udff4",
|
||||
"iT9Mb": 7962.070659919705
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.546Z"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"hash": "f5565f667ef0bd28",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "7e8b1efc8fef112e00addea54f4ff10ca29ab88ca2ab5e36a7fb0822627f2a3b",
|
||||
"created_at": 1768926859,
|
||||
"content": "ፂ킆ⅷ隰\ude47\udc98烥낷쪾⸛꒻ᕬ㦑屔\udef5㷣\ud8e7鄬䀟࠭ᩝ\udd6c䥶村\udffe\ud878嶁\udf84㩓䂹\ud81b㑤쇼᷒抪燉䷓\udaf5哐冡\udbfa\uda10",
|
||||
"additionalTags": [],
|
||||
"dTagValue": "Go7SKAG4O3",
|
||||
"eventId": "5374e07583039dbf7be03119c6d5e0c7ea8545ce55050620e1021b5ce7fc6924",
|
||||
"M5lVs": {}
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.526Z"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hash": "f5dd74590c3975b4",
|
||||
"message": "missing-event-relay",
|
||||
"stack": "Error: missing-event-relay\n at buildRepostEvent (file:///app/js/nostrEventSchemas.js:869:13)\n at test (file:///app/scripts/agent/fuzz-nostr-schemas.mjs:91:9)\n at Fuzzer.runFuzzLoop (file:///app/scripts/agent/fuzz-lib.mjs:142:15)",
|
||||
"input": {
|
||||
"target": "buildRepostEvent",
|
||||
"params": {
|
||||
"pubkey": "817aa90feff264c4dd1b9eb9281aea0f683364befe1c4f19504c67730688a033",
|
||||
"created_at": 1768926859,
|
||||
"content": "鋈፟돞\ud857苜퀯锬\ud99f嫴䝺嵵⤓\uddce葔埤↤\ude82߁㨡䓶㶻\udad6렃腫諧䑠數䐇它ꁦ㾝紵垥ᒸ듿⡗嗸ⶹ杮\udbe6\ud828ꏮ쮡낛鿻存",
|
||||
"dTagValue": "aYFKrxoRwN",
|
||||
"eventId": "1b7081e18c97001a19a5777da2d877331830f925db75f72161e504d01e889a4c",
|
||||
"FRqDH": {}
|
||||
}
|
||||
},
|
||||
"timestamp": "2026-01-20T16:34:19.529Z"
|
||||
}
|
||||
+3
-2
@@ -290,7 +290,7 @@
|
||||
<!-- Profile Button (hidden by default) -->
|
||||
<button
|
||||
id="profileButton"
|
||||
class="btn-ghost focus-ring hidden h-12 w-12 rounded-full p-0"
|
||||
class="btn-ghost focus-ring relative hidden h-12 w-12 rounded-full p-0"
|
||||
>
|
||||
<div class="h-10 w-10 overflow-hidden rounded-full">
|
||||
<img
|
||||
@@ -300,6 +300,7 @@
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span id="profileUnreadDot" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -378,7 +379,7 @@
|
||||
<!-- Mobile Search Container -->
|
||||
<div
|
||||
id="mobileSearchContainer"
|
||||
class="fixed bottom-6 right-6 z-50 hidden w-[calc(100%-3rem)] max-w-sm origin-bottom-right transition-all duration-200 md:hidden"
|
||||
class="fixed bottom-6 right-6 z-50 hidden w-[calc(100%-var(--sidebar-mobile-gutter))] max-w-sm origin-bottom-right transition-all duration-200 md:hidden"
|
||||
>
|
||||
<form
|
||||
id="mobileSearchForm"
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
export const ATTACHMENT_KIND = 15;
|
||||
|
||||
const HEX_64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
const TAG_NAMES = Object.freeze({
|
||||
hash: "x",
|
||||
url: "url",
|
||||
name: "name",
|
||||
type: "type",
|
||||
size: "size",
|
||||
key: "k",
|
||||
});
|
||||
|
||||
function normalizeTagValue(value) {
|
||||
if (typeof value !== "string") {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeHash(value) {
|
||||
const normalized = normalizeTagValue(value).toLowerCase();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return HEX_64_REGEX.test(normalized) ? normalized : "";
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric < 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
export function parseAttachmentTags(tags) {
|
||||
if (!Array.isArray(tags)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fields = {
|
||||
x: "",
|
||||
url: "",
|
||||
name: "",
|
||||
type: "",
|
||||
size: null,
|
||||
key: "",
|
||||
};
|
||||
|
||||
tags.forEach((tag) => {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = normalizeTagValue(tag[0]).toLowerCase();
|
||||
const value = normalizeTagValue(tag[1]);
|
||||
|
||||
switch (name) {
|
||||
case TAG_NAMES.hash:
|
||||
fields.x = normalizeHash(value);
|
||||
break;
|
||||
case TAG_NAMES.url:
|
||||
fields.url = value;
|
||||
break;
|
||||
case TAG_NAMES.name:
|
||||
fields.name = value;
|
||||
break;
|
||||
case TAG_NAMES.type:
|
||||
fields.type = value;
|
||||
break;
|
||||
case TAG_NAMES.size:
|
||||
fields.size = normalizeNumber(value);
|
||||
break;
|
||||
case TAG_NAMES.key:
|
||||
fields.key = value;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if (!fields.x && !fields.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: fields.x,
|
||||
url: fields.url,
|
||||
name: fields.name,
|
||||
type: fields.type,
|
||||
size: fields.size,
|
||||
key: fields.key,
|
||||
encrypted: Boolean(fields.key),
|
||||
};
|
||||
}
|
||||
|
||||
export function extractAttachmentsFromMessage(message) {
|
||||
if (!message || typeof message !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const event =
|
||||
(message.message && typeof message.message === "object"
|
||||
? message.message
|
||||
: null) ||
|
||||
(message.event && typeof message.event === "object" ? message.event : null);
|
||||
|
||||
if (!event || Number(event.kind) !== ATTACHMENT_KIND) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attachment = parseAttachmentTags(event.tags);
|
||||
if (!attachment) {
|
||||
devLogger.warn("[attachments] Attachment event missing tags.");
|
||||
return [];
|
||||
}
|
||||
|
||||
return [attachment];
|
||||
}
|
||||
|
||||
export function formatAttachmentSize(bytes) {
|
||||
if (!Number.isFinite(bytes)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = Math.max(bytes, 0);
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
const rounded = size >= 10 || unitIndex === 0 ? Math.round(size) : size.toFixed(1);
|
||||
return `${rounded} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function describeAttachment(attachment) {
|
||||
if (!attachment || typeof attachment !== "object") {
|
||||
return "Attachment";
|
||||
}
|
||||
|
||||
const name = typeof attachment.name === "string" ? attachment.name.trim() : "";
|
||||
if (name) {
|
||||
return `Attachment: ${name}`;
|
||||
}
|
||||
|
||||
if (attachment.type) {
|
||||
return `Attachment (${attachment.type})`;
|
||||
}
|
||||
|
||||
return "Attachment";
|
||||
}
|
||||
|
||||
export { TAG_NAMES as ATTACHMENT_TAG_NAMES };
|
||||
@@ -0,0 +1,177 @@
|
||||
import { bytesToHex } from "../../vendor/crypto-helpers.bundle.min.js";
|
||||
import { signEventWithPrivateKey } from "../nostr/publishHelpers.js";
|
||||
import { ensureNostrTools, getCachedNostrTools } from "../nostr/toolkit.js";
|
||||
import { normalizeActorKey } from "../nostr/watchHistory.js";
|
||||
|
||||
const DEFAULT_TEST_PUBKEY = "f".repeat(64);
|
||||
const DEFAULT_DEV_DISPLAY_NAME = "Ephemeral dev signer";
|
||||
const DEFAULT_TEST_DISPLAY_NAME = "Test signer";
|
||||
|
||||
function normalizePubkey(value) {
|
||||
const normalized = typeof value === "string" ? normalizeActorKey(value) : "";
|
||||
return normalized || "";
|
||||
}
|
||||
|
||||
function getSigningTimestamp() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} SigningAdapter
|
||||
* @property {() => Promise<string>} getPubkey
|
||||
* @property {() => Promise<string>} getDisplayName
|
||||
* @property {(unsignedEvent: object) => Promise<object>} signEvent
|
||||
* @property {(message: string) => Promise<string>} signMessage
|
||||
*/
|
||||
|
||||
export function createNip07SigningAdapter({ extension } = {}) {
|
||||
const resolvedExtension =
|
||||
extension ||
|
||||
(typeof window !== "undefined" && window?.nostr ? window.nostr : null);
|
||||
|
||||
const getPubkey = async () => {
|
||||
if (!resolvedExtension || typeof resolvedExtension.getPublicKey !== "function") {
|
||||
throw new Error("NIP-07 extension is unavailable.");
|
||||
}
|
||||
return normalizePubkey(await resolvedExtension.getPublicKey());
|
||||
};
|
||||
|
||||
const getDisplayName = async () => {
|
||||
if (!resolvedExtension || typeof resolvedExtension.getMetadata !== "function") {
|
||||
return "";
|
||||
}
|
||||
const metadata = await resolvedExtension.getMetadata();
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return "";
|
||||
}
|
||||
const displayName =
|
||||
typeof metadata.display_name === "string" ? metadata.display_name.trim() : "";
|
||||
if (displayName) {
|
||||
return displayName;
|
||||
}
|
||||
return typeof metadata.name === "string" ? metadata.name.trim() : "";
|
||||
};
|
||||
|
||||
const signEvent = async (event) => {
|
||||
if (!resolvedExtension || typeof resolvedExtension.signEvent !== "function") {
|
||||
throw new Error("NIP-07 extension is missing signEvent.");
|
||||
}
|
||||
return resolvedExtension.signEvent(event);
|
||||
};
|
||||
|
||||
const signMessage = async (message) => {
|
||||
if (!resolvedExtension || typeof resolvedExtension.signMessage !== "function") {
|
||||
throw new Error("NIP-07 extension does not support signMessage.");
|
||||
}
|
||||
return resolvedExtension.signMessage(message);
|
||||
};
|
||||
|
||||
return {
|
||||
type: "nip07",
|
||||
getPubkey,
|
||||
getDisplayName,
|
||||
signEvent,
|
||||
signMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEphemeralDevSigningAdapter({
|
||||
displayName = DEFAULT_DEV_DISPLAY_NAME,
|
||||
privateKey,
|
||||
} = {}) {
|
||||
let cachedPrivateKey = typeof privateKey === "string" ? privateKey.trim() : "";
|
||||
let cachedPubkey = "";
|
||||
|
||||
const ensurePrivateKey = () => {
|
||||
if (cachedPrivateKey) {
|
||||
return cachedPrivateKey;
|
||||
}
|
||||
const random = new Uint8Array(32);
|
||||
if (typeof window !== "undefined" && window.crypto?.getRandomValues) {
|
||||
window.crypto.getRandomValues(random);
|
||||
} else if (typeof globalThis?.crypto?.getRandomValues === "function") {
|
||||
globalThis.crypto.getRandomValues(random);
|
||||
} else {
|
||||
for (let i = 0; i < random.length; i += 1) {
|
||||
random[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
cachedPrivateKey = bytesToHex(random);
|
||||
return cachedPrivateKey;
|
||||
};
|
||||
|
||||
const ensurePubkey = async () => {
|
||||
if (cachedPubkey) {
|
||||
return cachedPubkey;
|
||||
}
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
if (!tools || typeof tools.getPublicKey !== "function") {
|
||||
throw new Error("Nostr tools are required to derive the dev pubkey.");
|
||||
}
|
||||
cachedPubkey = normalizePubkey(tools.getPublicKey(ensurePrivateKey()));
|
||||
if (!cachedPubkey) {
|
||||
throw new Error("Failed to derive dev pubkey.");
|
||||
}
|
||||
return cachedPubkey;
|
||||
};
|
||||
|
||||
const signEvent = async (event) =>
|
||||
signEventWithPrivateKey({ ...event, pubkey: await ensurePubkey() }, ensurePrivateKey());
|
||||
|
||||
const signMessage = async (message) => {
|
||||
const signed = signEventWithPrivateKey(
|
||||
{
|
||||
kind: 27235,
|
||||
created_at: getSigningTimestamp(),
|
||||
tags: [["u", "bitvid:signing-adapter"]],
|
||||
content: typeof message === "string" ? message : String(message ?? ""),
|
||||
pubkey: await ensurePubkey(),
|
||||
},
|
||||
ensurePrivateKey(),
|
||||
);
|
||||
if (!signed || typeof signed.sig !== "string") {
|
||||
throw new Error("Failed to sign message.");
|
||||
}
|
||||
return signed.sig;
|
||||
};
|
||||
|
||||
return {
|
||||
type: "dev",
|
||||
getPubkey: ensurePubkey,
|
||||
getDisplayName: async () => displayName,
|
||||
signEvent,
|
||||
signMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestSigningAdapter({
|
||||
pubkey = DEFAULT_TEST_PUBKEY,
|
||||
displayName = DEFAULT_TEST_DISPLAY_NAME,
|
||||
signEvent,
|
||||
signMessage,
|
||||
} = {}) {
|
||||
const normalizedPubkey = normalizePubkey(pubkey) || DEFAULT_TEST_PUBKEY;
|
||||
|
||||
const signEventImpl =
|
||||
typeof signEvent === "function"
|
||||
? signEvent
|
||||
: async (event) => ({
|
||||
...event,
|
||||
pubkey: normalizedPubkey || event.pubkey,
|
||||
id: "test-event-id",
|
||||
sig: "test-event-sig",
|
||||
});
|
||||
|
||||
const signMessageImpl =
|
||||
typeof signMessage === "function"
|
||||
? signMessage
|
||||
: async (message) => `signed:${message}`;
|
||||
|
||||
return {
|
||||
type: "test",
|
||||
getPubkey: async () => normalizedPubkey,
|
||||
getDisplayName: async () => displayName,
|
||||
signEvent: signEventImpl,
|
||||
signMessage: signMessageImpl,
|
||||
};
|
||||
}
|
||||
@@ -3357,6 +3357,14 @@ function setupChannelMessageControls() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof app?.openDirectMessageComposer === "function") {
|
||||
app.openDirectMessageComposer({
|
||||
recipientPubkey: resolvedHex,
|
||||
source: "channel-profile",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = app?.profileController;
|
||||
if (!controller) {
|
||||
app?.showError?.("Messages are unavailable right now.");
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
IS_DEV_MODE,
|
||||
IS_VERBOSE_DEV_MODE,
|
||||
IS_LOCKDOWN_MODE,
|
||||
LOCKDOWN_TITLE,
|
||||
LOCKDOWN_MESSAGE,
|
||||
ADMIN_SUPER_NPUB,
|
||||
ADMIN_DM_IMAGE_URL,
|
||||
BITVID_WEBSITE_URL,
|
||||
@@ -51,6 +53,7 @@ export { IS_VERBOSE_DEV_MODE };
|
||||
|
||||
export const isLockdownMode = Boolean(IS_LOCKDOWN_MODE);
|
||||
export { IS_LOCKDOWN_MODE };
|
||||
export { LOCKDOWN_TITLE, LOCKDOWN_MESSAGE };
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.__BITVID_DEV_MODE__ = isDevMode;
|
||||
|
||||
@@ -322,6 +322,3 @@ export async function clearDirectMessageSnapshot(pubkey) {
|
||||
return cleared;
|
||||
}
|
||||
|
||||
export function buildDirectMessageSnapshotPayload(entries) {
|
||||
return normalizeSnapshotList(entries);
|
||||
}
|
||||
+61
-19
@@ -23,6 +23,43 @@ function cloneEvent(event) {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function normalizeCreatedAt(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
function normalizeInnerMessage(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = cloneEvent(event) || {};
|
||||
normalized.content = typeof event.content === "string" ? event.content : "";
|
||||
|
||||
if (Number.isFinite(event.kind)) {
|
||||
normalized.kind = event.kind;
|
||||
}
|
||||
|
||||
const createdAt = normalizeCreatedAt(event.created_at);
|
||||
if (createdAt !== null) {
|
||||
normalized.created_at = createdAt;
|
||||
} else if ("created_at" in normalized) {
|
||||
delete normalized.created_at;
|
||||
}
|
||||
|
||||
if (Array.isArray(event.tags)) {
|
||||
normalized.tags = event.tags.map((tag) =>
|
||||
Array.isArray(tag) ? [...tag] : tag,
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeHex(candidate) {
|
||||
if (typeof candidate !== "string") {
|
||||
return "";
|
||||
@@ -268,16 +305,13 @@ function buildDecryptResult({
|
||||
envelope = null,
|
||||
errors = [],
|
||||
}) {
|
||||
const timestampCandidates = [];
|
||||
if (message && Number.isFinite(message.created_at)) {
|
||||
timestampCandidates.push(message.created_at);
|
||||
}
|
||||
if (event && Number.isFinite(event.created_at)) {
|
||||
timestampCandidates.push(event.created_at);
|
||||
}
|
||||
const timestamp = timestampCandidates.length
|
||||
? Math.max(...timestampCandidates)
|
||||
: Date.now() / 1000;
|
||||
const normalizedScheme = normalizeScheme(scheme || decryptor?.scheme || "");
|
||||
const messageTimestamp =
|
||||
message && Number.isFinite(message.created_at) ? message.created_at : null;
|
||||
const eventTimestamp =
|
||||
event && Number.isFinite(event.created_at) ? event.created_at : null;
|
||||
const timestamp =
|
||||
messageTimestamp ?? eventTimestamp ?? Date.now() / 1000;
|
||||
|
||||
return {
|
||||
ok,
|
||||
@@ -295,11 +329,12 @@ function buildDecryptResult({
|
||||
actorPubkey: normalizeHex(actorPubkey),
|
||||
decryptor: decryptor
|
||||
? {
|
||||
scheme: normalizeScheme(decryptor.scheme) || scheme || "",
|
||||
scheme: normalizeScheme(decryptor.scheme) || normalizedScheme || "",
|
||||
source: decryptor.source || "",
|
||||
}
|
||||
: { scheme: scheme || "", source: "" },
|
||||
scheme: normalizeScheme(scheme || decryptor?.scheme || ""),
|
||||
: { scheme: normalizedScheme || "", source: "" },
|
||||
scheme: normalizedScheme,
|
||||
encryption_scheme: normalizedScheme,
|
||||
envelope,
|
||||
direction: deriveDirection(actorPubkey, senderPubkey, recipients),
|
||||
timestamp: Number.isFinite(timestamp) ? timestamp : Date.now() / 1000,
|
||||
@@ -377,15 +412,19 @@ async function decryptGiftWrap(event, decryptors, actorPubkey) {
|
||||
stage: "seal",
|
||||
});
|
||||
const rumor = parseEventJson(rumorSerialized, "rumor");
|
||||
const normalizedRumor = normalizeInnerMessage(rumor);
|
||||
|
||||
const plaintext = typeof rumor?.content === "string" ? rumor.content : "";
|
||||
const senderPubkey = normalizeHex(rumor?.pubkey) || sealPubkey;
|
||||
const recipients = collectRecipients(rumor?.tags);
|
||||
const plaintext =
|
||||
typeof normalizedRumor?.content === "string"
|
||||
? normalizedRumor.content
|
||||
: "";
|
||||
const senderPubkey = normalizeHex(normalizedRumor?.pubkey) || sealPubkey;
|
||||
const recipients = collectRecipients(normalizedRumor?.tags);
|
||||
|
||||
return buildDecryptResult({
|
||||
ok: true,
|
||||
event,
|
||||
message: rumor,
|
||||
message: normalizedRumor,
|
||||
plaintext,
|
||||
recipients,
|
||||
senderPubkey,
|
||||
@@ -472,6 +511,10 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) {
|
||||
});
|
||||
|
||||
if (typeof plaintext === "string") {
|
||||
const resolvedScheme =
|
||||
normalizeScheme(hints.algorithms?.[0]) ||
|
||||
normalizeScheme(decryptor.scheme) ||
|
||||
"";
|
||||
return buildDecryptResult({
|
||||
ok: true,
|
||||
event,
|
||||
@@ -484,7 +527,7 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) {
|
||||
senderPubkey,
|
||||
actorPubkey,
|
||||
decryptor,
|
||||
scheme: decryptor.scheme || hints.algorithms?.[0] || "",
|
||||
scheme: resolvedScheme,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -586,4 +629,3 @@ export const __testUtils = {
|
||||
collectRecipients,
|
||||
orderDecryptors,
|
||||
};
|
||||
|
||||
+12
@@ -32,6 +32,8 @@ import {
|
||||
DNS_URL,
|
||||
TIP_JAR_URL,
|
||||
isLockdownMode,
|
||||
LOCKDOWN_TITLE,
|
||||
LOCKDOWN_MESSAGE,
|
||||
} from "./config.js";
|
||||
import { createHashChangeHandler } from "./hashChangeHandler.js";
|
||||
import AuthService from "./services/authService.js";
|
||||
@@ -325,6 +327,16 @@ async function prepareLockdownInterface() {
|
||||
throw new Error("Lockdown modal markup is missing after load.");
|
||||
}
|
||||
|
||||
const titleEl = document.getElementById("lockdownModalTitle");
|
||||
if (titleEl) {
|
||||
titleEl.textContent = LOCKDOWN_TITLE;
|
||||
}
|
||||
|
||||
const messageEl = document.getElementById("lockdownModalMessage");
|
||||
if (messageEl) {
|
||||
messageEl.textContent = LOCKDOWN_MESSAGE;
|
||||
}
|
||||
|
||||
const loginButton = modal.querySelector("[data-lockdown-login]");
|
||||
if (loginButton instanceof HTMLButtonElement) {
|
||||
loginButton.addEventListener("click", handleLockdownLoginClick);
|
||||
|
||||
+788
-158
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
export function summarizeDmEventForLog(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return {
|
||||
kind: null,
|
||||
createdAt: null,
|
||||
hasContent: false,
|
||||
contentLength: 0,
|
||||
tagCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const createdAt = Number.isFinite(event.created_at)
|
||||
? Math.floor(event.created_at)
|
||||
: null;
|
||||
|
||||
return {
|
||||
kind: Number.isFinite(event.kind) ? event.kind : null,
|
||||
createdAt,
|
||||
hasContent: typeof event.content === "string" && event.content.length > 0,
|
||||
contentLength: typeof event.content === "string" ? event.content.length : 0,
|
||||
tagCount: Array.isArray(event.tags) ? event.tags.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeDecryptError(error) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof error.message === "string"
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: "";
|
||||
|
||||
return {
|
||||
name: typeof error.name === "string" ? error.name : "",
|
||||
code: typeof error.code === "string" ? error.code : "",
|
||||
message,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js";
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
const workerScope = typeof self !== "undefined" ? self : null;
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
const postResponse = (payload) => {
|
||||
if (!workerScope || typeof workerScope.postMessage !== "function") {
|
||||
return;
|
||||
}
|
||||
workerScope.postMessage(payload);
|
||||
};
|
||||
|
||||
const normalizeScheme = (scheme) => {
|
||||
if (typeof scheme !== "string") {
|
||||
return "nip04";
|
||||
}
|
||||
const normalized = scheme.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "nip04";
|
||||
}
|
||||
if (normalized === "nip44-v2") {
|
||||
return "nip44_v2";
|
||||
}
|
||||
if (normalized === "nip-44") {
|
||||
return "nip44";
|
||||
}
|
||||
if (normalized === "nip-04") {
|
||||
return "nip04";
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizePayload = (payload) => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
typeof payload.id === "string" || typeof payload.id === "number"
|
||||
? payload.id
|
||||
: null;
|
||||
const scheme = normalizeScheme(payload.scheme);
|
||||
const privateKey =
|
||||
typeof payload.privateKey === "string" ? payload.privateKey.trim() : "";
|
||||
const targetPubkey =
|
||||
typeof payload.targetPubkey === "string" ? payload.targetPubkey.trim() : "";
|
||||
const ciphertext =
|
||||
typeof payload.ciphertext === "string" ? payload.ciphertext : "";
|
||||
const event =
|
||||
payload.event && typeof payload.event === "object" ? payload.event : null;
|
||||
|
||||
if (!id || !privateKey || !targetPubkey || !ciphertext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
scheme,
|
||||
privateKey,
|
||||
targetPubkey,
|
||||
ciphertext,
|
||||
event,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeEventForVerification = (event) => {
|
||||
if (!event || typeof event !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pubkey = typeof event.pubkey === "string" ? event.pubkey.trim() : "";
|
||||
const sig = typeof event.sig === "string" ? event.sig.trim() : "";
|
||||
const id = typeof event.id === "string" ? event.id.trim() : "";
|
||||
|
||||
if (!pubkey || !sig || !id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
pubkey,
|
||||
sig,
|
||||
kind: Number.isFinite(event.kind) ? event.kind : 0,
|
||||
created_at: Number.isFinite(event.created_at)
|
||||
? event.created_at
|
||||
: Math.floor(Date.now() / 1000),
|
||||
tags: Array.isArray(event.tags) ? event.tags : [],
|
||||
content: typeof event.content === "string" ? event.content : "",
|
||||
};
|
||||
};
|
||||
|
||||
const resolveHexToBytes = (tools) => {
|
||||
if (typeof tools?.utils?.hexToBytes === "function") {
|
||||
return (value) => tools.utils.hexToBytes(value);
|
||||
}
|
||||
|
||||
return (value) => {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Invalid hex input.");
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length % 2 !== 0) {
|
||||
throw new Error("Invalid hex input.");
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(trimmed.length / 2);
|
||||
for (let index = 0; index < trimmed.length; index += 2) {
|
||||
const byte = Number.parseInt(trimmed.slice(index, index + 2), 16);
|
||||
if (Number.isNaN(byte)) {
|
||||
throw new Error("Invalid hex input.");
|
||||
}
|
||||
bytes[index / 2] = byte;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
};
|
||||
|
||||
const ensureNip04 = async () => {
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
const nip04 = tools?.nip04;
|
||||
if (!nip04 || typeof nip04.decrypt !== "function") {
|
||||
throw new Error("nip04-unavailable");
|
||||
}
|
||||
return nip04;
|
||||
};
|
||||
|
||||
const ensureNip44 = async () => {
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
if (!tools) {
|
||||
throw new Error("nip44-unavailable");
|
||||
}
|
||||
|
||||
const nip44 = tools.nip44 || null;
|
||||
let decrypt = null;
|
||||
let getConversationKey = null;
|
||||
|
||||
if (nip44?.v2 && typeof nip44.v2 === "object") {
|
||||
if (typeof nip44.v2.decrypt === "function") {
|
||||
decrypt = nip44.v2.decrypt;
|
||||
}
|
||||
if (typeof nip44.v2?.utils?.getConversationKey === "function") {
|
||||
getConversationKey = nip44.v2.utils.getConversationKey;
|
||||
}
|
||||
}
|
||||
|
||||
if ((!decrypt || !getConversationKey) && nip44 && typeof nip44 === "object") {
|
||||
if (typeof nip44.decrypt === "function") {
|
||||
decrypt = nip44.decrypt;
|
||||
}
|
||||
if (typeof nip44.getConversationKey === "function") {
|
||||
getConversationKey = nip44.getConversationKey;
|
||||
} else if (typeof nip44.utils?.getConversationKey === "function") {
|
||||
getConversationKey = nip44.utils.getConversationKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (!decrypt || !getConversationKey) {
|
||||
throw new Error("nip44-unavailable");
|
||||
}
|
||||
|
||||
return {
|
||||
decrypt,
|
||||
getConversationKey,
|
||||
hexToBytes: resolveHexToBytes(tools),
|
||||
};
|
||||
};
|
||||
|
||||
const verifyEventSignature = async (event, tools = null) => {
|
||||
const candidate = normalizeEventForVerification(event);
|
||||
if (!candidate) {
|
||||
throw new Error("missing-event-signature");
|
||||
}
|
||||
|
||||
const resolvedTools = tools || (await ensureNostrTools()) || getCachedNostrTools();
|
||||
if (!resolvedTools || typeof resolvedTools !== "object") {
|
||||
throw new Error("signature-verification-unavailable");
|
||||
}
|
||||
|
||||
if (
|
||||
typeof resolvedTools.validateEvent !== "function" ||
|
||||
typeof resolvedTools.verifyEvent !== "function"
|
||||
) {
|
||||
throw new Error("signature-verification-unavailable");
|
||||
}
|
||||
|
||||
if (!resolvedTools.validateEvent(candidate) || !resolvedTools.verifyEvent(candidate)) {
|
||||
throw new Error("invalid-event-signature");
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleMessage = async (event) => {
|
||||
const payload = normalizePayload(event?.data);
|
||||
if (!payload) {
|
||||
postResponse({
|
||||
id: event?.data?.id ?? null,
|
||||
ok: false,
|
||||
error: { message: "invalid-payload" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HEX64_REGEX.test(payload.privateKey)) {
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: false,
|
||||
error: { message: "invalid-private-key" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (payload.scheme === "nip44" || payload.scheme === "nip44_v2") {
|
||||
await verifyEventSignature(payload.event);
|
||||
const nip44 = await ensureNip44();
|
||||
const privateKeyBytes = nip44.hexToBytes(payload.privateKey);
|
||||
const conversationKey = nip44.getConversationKey(
|
||||
privateKeyBytes,
|
||||
payload.targetPubkey,
|
||||
);
|
||||
const plaintext = await nip44.decrypt(
|
||||
payload.ciphertext,
|
||||
conversationKey,
|
||||
);
|
||||
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: true,
|
||||
plaintext,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nip04 = await ensureNip04();
|
||||
const plaintext = await nip04.decrypt(
|
||||
payload.privateKey,
|
||||
payload.targetPubkey,
|
||||
payload.ciphertext,
|
||||
);
|
||||
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: true,
|
||||
plaintext,
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[dmDecryptWorker] Decryption failed", error);
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: false,
|
||||
error: {
|
||||
message: error?.message || "dm-worker-failed",
|
||||
name: error?.name || "Error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (workerScope?.addEventListener) {
|
||||
workerScope.addEventListener("message", (event) => {
|
||||
void handleMessage(event);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 12000;
|
||||
|
||||
let workerInstance = null;
|
||||
let workerReady = false;
|
||||
let requestId = 0;
|
||||
const pending = new Map();
|
||||
|
||||
function attachWorkerListeners(worker) {
|
||||
if (!worker || workerReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
worker.addEventListener("message", (event) => {
|
||||
const data = event?.data || {};
|
||||
const id = data.id;
|
||||
if (!pending.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = pending.get(id);
|
||||
pending.delete(id);
|
||||
clearTimeout(entry.timeoutId);
|
||||
|
||||
if (data.ok && typeof data.plaintext === "string") {
|
||||
entry.resolve(data.plaintext);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = data?.error?.message || "dm-worker-error";
|
||||
const error = new Error(message);
|
||||
error.name = data?.error?.name || "Error";
|
||||
entry.reject(error);
|
||||
});
|
||||
|
||||
worker.addEventListener("error", (error) => {
|
||||
devLogger.warn("[dmDecryptWorkerClient] Worker error", error);
|
||||
for (const entry of pending.values()) {
|
||||
clearTimeout(entry.timeoutId);
|
||||
entry.reject(
|
||||
error instanceof Error ? error : new Error("dm-worker-error"),
|
||||
);
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
workerReady = true;
|
||||
}
|
||||
|
||||
function ensureWorker() {
|
||||
if (workerInstance) {
|
||||
return workerInstance;
|
||||
}
|
||||
|
||||
if (typeof Worker === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
workerInstance = new Worker(
|
||||
new URL("./dmDecryptWorker.js", import.meta.url),
|
||||
{
|
||||
type: "module",
|
||||
},
|
||||
);
|
||||
attachWorkerListeners(workerInstance);
|
||||
} catch (error) {
|
||||
devLogger.warn("[dmDecryptWorkerClient] Failed to create worker", error);
|
||||
workerInstance = null;
|
||||
}
|
||||
|
||||
return workerInstance;
|
||||
}
|
||||
|
||||
export function isDmDecryptWorkerSupported() {
|
||||
return typeof Worker !== "undefined";
|
||||
}
|
||||
|
||||
export function getDmDecryptWorkerQueueSize() {
|
||||
return pending.size;
|
||||
}
|
||||
|
||||
export function decryptDmInWorker({
|
||||
scheme = "nip04",
|
||||
privateKey,
|
||||
targetPubkey,
|
||||
ciphertext,
|
||||
event,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
const worker = ensureWorker();
|
||||
if (!worker) {
|
||||
return Promise.reject(new Error("dm-worker-unavailable"));
|
||||
}
|
||||
|
||||
const trimmedPrivateKey =
|
||||
typeof privateKey === "string" ? privateKey.trim() : "";
|
||||
const trimmedTarget =
|
||||
typeof targetPubkey === "string" ? targetPubkey.trim() : "";
|
||||
const payloadCiphertext = typeof ciphertext === "string" ? ciphertext : "";
|
||||
const payloadScheme = typeof scheme === "string" ? scheme : "";
|
||||
const payloadEvent = event && typeof event === "object" ? event : null;
|
||||
|
||||
if (!trimmedPrivateKey || !trimmedTarget || !payloadCiphertext) {
|
||||
return Promise.reject(new Error("dm-worker-invalid-input"));
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${requestId++}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error("dm-worker-timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
pending.set(id, { resolve, reject, timeoutId });
|
||||
worker.postMessage({
|
||||
id,
|
||||
scheme: payloadScheme,
|
||||
privateKey: trimmedPrivateKey,
|
||||
targetPubkey: trimmedTarget,
|
||||
ciphertext: payloadCiphertext,
|
||||
event: payloadEvent,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
buildDmReadReceiptEvent,
|
||||
buildDmTypingIndicatorEvent,
|
||||
sanitizeAdditionalTags,
|
||||
} from "../nostrEventSchemas.js";
|
||||
import { publishEventToRelay } from "../nostrPublish.js";
|
||||
import { RELAY_URLS } from "./toolkit.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import { queueSignEvent } from "./signRequestQueue.js";
|
||||
import { getActiveSigner } from "../nostrClientRegistry.js";
|
||||
import { isSessionActor } from "./sessionActor.js";
|
||||
|
||||
const DEFAULT_TYPING_EXPIRY_SECONDS = 15;
|
||||
|
||||
function sanitizeRelayList(primary, fallback) {
|
||||
if (Array.isArray(primary) && primary.length) {
|
||||
return primary;
|
||||
}
|
||||
if (Array.isArray(fallback) && fallback.length) {
|
||||
return fallback;
|
||||
}
|
||||
return RELAY_URLS;
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export async function publishDmReadReceipt(
|
||||
client,
|
||||
payload = {},
|
||||
options = {},
|
||||
{
|
||||
shouldRequestExtensionPermissions,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
} = {},
|
||||
) {
|
||||
if (!client?.pool) {
|
||||
return { ok: false, error: "nostr-uninitialized" };
|
||||
}
|
||||
|
||||
if (isSessionActor(client)) {
|
||||
const error = new Error(
|
||||
"Publishing read receipts is not allowed for session actors.",
|
||||
);
|
||||
error.code = "session-actor-publish-blocked";
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const eventId = normalizeString(payload.eventId);
|
||||
const recipientPubkey = normalizeString(payload.recipientPubkey);
|
||||
|
||||
if (!eventId || !recipientPubkey) {
|
||||
return { ok: false, error: "invalid-target" };
|
||||
}
|
||||
|
||||
const actorPubkey =
|
||||
typeof client?.pubkey === "string" && client.pubkey.trim()
|
||||
? client.pubkey.trim()
|
||||
: "";
|
||||
if (!actorPubkey) {
|
||||
return { ok: false, error: "auth-required" };
|
||||
}
|
||||
|
||||
const createdAt =
|
||||
typeof options.created_at === "number" && options.created_at > 0
|
||||
? Math.floor(options.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const additionalTags = sanitizeAdditionalTags(payload.additionalTags);
|
||||
|
||||
const event = buildDmReadReceiptEvent({
|
||||
pubkey: actorPubkey,
|
||||
created_at: createdAt,
|
||||
recipientPubkey,
|
||||
eventId,
|
||||
messageKind: payload.messageKind,
|
||||
additionalTags,
|
||||
});
|
||||
|
||||
let signedEvent = null;
|
||||
|
||||
const signer = getActiveSigner();
|
||||
|
||||
if (!signer || typeof signer.signEvent !== "function") {
|
||||
const error = new Error(
|
||||
"Login required: an active signer is needed to publish read receipts.",
|
||||
);
|
||||
error.code = "auth-required";
|
||||
return {
|
||||
ok: false,
|
||||
error: "auth-required",
|
||||
message: error.message,
|
||||
details: error,
|
||||
};
|
||||
}
|
||||
|
||||
let permissionResult = { ok: true };
|
||||
const shouldRequestPermissions =
|
||||
typeof shouldRequestExtensionPermissions === "function"
|
||||
? shouldRequestExtensionPermissions(signer)
|
||||
: false;
|
||||
|
||||
if (shouldRequestPermissions) {
|
||||
permissionResult = await client.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
}
|
||||
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[nostr] Active signer permissions missing; read receipts require login.",
|
||||
permissionResult.error,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: "auth-required",
|
||||
details: permissionResult,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
signedEvent = await queueSignEvent(signer, event, {
|
||||
timeoutMs: options?.timeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostr] Failed to sign read receipt event with active signer:",
|
||||
error,
|
||||
);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(
|
||||
payload.relays,
|
||||
client.writeRelays || client.relays,
|
||||
);
|
||||
|
||||
const publishResults = await Promise.all(
|
||||
relayList.map((url) => publishEventToRelay(client.pool, url, signedEvent)),
|
||||
);
|
||||
|
||||
const acceptedRelays = publishResults
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.url)
|
||||
.filter((url) => typeof url === "string" && url);
|
||||
|
||||
const success = acceptedRelays.length > 0;
|
||||
|
||||
if (success) {
|
||||
devLogger.info(
|
||||
`[nostr] Read receipt accepted by ${acceptedRelays.length} relay(s):`,
|
||||
acceptedRelays.join(", "),
|
||||
);
|
||||
} else {
|
||||
userLogger.warn("[nostr] Read receipt rejected by relays:", publishResults);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: success,
|
||||
event: signedEvent,
|
||||
results: publishResults,
|
||||
acceptedRelays,
|
||||
};
|
||||
}
|
||||
|
||||
export async function publishDmTypingIndicator(
|
||||
client,
|
||||
payload = {},
|
||||
options = {},
|
||||
{
|
||||
shouldRequestExtensionPermissions,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
} = {},
|
||||
) {
|
||||
if (!client?.pool) {
|
||||
return { ok: false, error: "nostr-uninitialized" };
|
||||
}
|
||||
|
||||
if (isSessionActor(client)) {
|
||||
const error = new Error(
|
||||
"Publishing typing indicators is not allowed for session actors.",
|
||||
);
|
||||
error.code = "session-actor-publish-blocked";
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const recipientPubkey = normalizeString(payload.recipientPubkey);
|
||||
if (!recipientPubkey) {
|
||||
return { ok: false, error: "invalid-target" };
|
||||
}
|
||||
|
||||
const actorPubkey =
|
||||
typeof client?.pubkey === "string" && client.pubkey.trim()
|
||||
? client.pubkey.trim()
|
||||
: "";
|
||||
if (!actorPubkey) {
|
||||
return { ok: false, error: "auth-required" };
|
||||
}
|
||||
|
||||
const createdAt =
|
||||
typeof options.created_at === "number" && options.created_at > 0
|
||||
? Math.floor(options.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const expiresInSeconds = Number.isFinite(payload.expiresInSeconds)
|
||||
? Math.max(1, Math.floor(payload.expiresInSeconds))
|
||||
: DEFAULT_TYPING_EXPIRY_SECONDS;
|
||||
const expiresAt = createdAt + expiresInSeconds;
|
||||
|
||||
const additionalTags = sanitizeAdditionalTags(payload.additionalTags);
|
||||
|
||||
const event = buildDmTypingIndicatorEvent({
|
||||
pubkey: actorPubkey,
|
||||
created_at: createdAt,
|
||||
recipientPubkey,
|
||||
eventId: payload.conversationEventId,
|
||||
expiresAt,
|
||||
additionalTags,
|
||||
});
|
||||
|
||||
let signedEvent = null;
|
||||
|
||||
const signer = getActiveSigner();
|
||||
|
||||
if (!signer || typeof signer.signEvent !== "function") {
|
||||
const error = new Error(
|
||||
"Login required: an active signer is needed to publish typing indicators.",
|
||||
);
|
||||
error.code = "auth-required";
|
||||
return {
|
||||
ok: false,
|
||||
error: "auth-required",
|
||||
message: error.message,
|
||||
details: error,
|
||||
};
|
||||
}
|
||||
|
||||
let permissionResult = { ok: true };
|
||||
const shouldRequestPermissions =
|
||||
typeof shouldRequestExtensionPermissions === "function"
|
||||
? shouldRequestExtensionPermissions(signer)
|
||||
: false;
|
||||
|
||||
if (shouldRequestPermissions) {
|
||||
permissionResult = await client.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
}
|
||||
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[nostr] Active signer permissions missing; typing indicators require login.",
|
||||
permissionResult.error,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: "auth-required",
|
||||
details: permissionResult,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
signedEvent = await queueSignEvent(signer, event, {
|
||||
timeoutMs: options?.timeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostr] Failed to sign typing indicator event with active signer:",
|
||||
error,
|
||||
);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(
|
||||
payload.relays,
|
||||
client.writeRelays || client.relays,
|
||||
);
|
||||
|
||||
const publishResults = await Promise.all(
|
||||
relayList.map((url) => publishEventToRelay(client.pool, url, signedEvent)),
|
||||
);
|
||||
|
||||
const acceptedRelays = publishResults
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.url)
|
||||
.filter((url) => typeof url === "string" && url);
|
||||
|
||||
const success = acceptedRelays.length > 0;
|
||||
|
||||
if (success) {
|
||||
devLogger.debug(
|
||||
`[nostr] Typing indicator accepted by ${acceptedRelays.length} relay(s).`,
|
||||
);
|
||||
} else {
|
||||
devLogger.warn(
|
||||
"[nostr] Typing indicator rejected by relays:",
|
||||
publishResults,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: success,
|
||||
event: signedEvent,
|
||||
results: publishResults,
|
||||
acceptedRelays,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js";
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
const workerScope = typeof self !== "undefined" ? self : null;
|
||||
|
||||
const postResponse = (payload) => {
|
||||
if (!workerScope || typeof workerScope.postMessage !== "function") {
|
||||
return;
|
||||
}
|
||||
workerScope.postMessage(payload);
|
||||
};
|
||||
|
||||
const ensureNip04 = async () => {
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
const nip04 = tools?.nip04;
|
||||
if (!nip04 || typeof nip04.encrypt !== "function") {
|
||||
throw new Error("nip04-unavailable");
|
||||
}
|
||||
return nip04;
|
||||
};
|
||||
|
||||
const normalizePayload = (payload) => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
typeof payload.id === "string" || typeof payload.id === "number"
|
||||
? payload.id
|
||||
: null;
|
||||
const privateKey =
|
||||
typeof payload.privateKey === "string" ? payload.privateKey.trim() : "";
|
||||
const targetPubkey =
|
||||
typeof payload.targetPubkey === "string" ? payload.targetPubkey.trim() : "";
|
||||
const plaintext =
|
||||
typeof payload.plaintext === "string" ? payload.plaintext : "";
|
||||
|
||||
if (!id || !privateKey || !targetPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
privateKey,
|
||||
targetPubkey,
|
||||
plaintext,
|
||||
};
|
||||
};
|
||||
|
||||
const handleMessage = async (event) => {
|
||||
const payload = normalizePayload(event?.data);
|
||||
if (!payload) {
|
||||
postResponse({
|
||||
id: event?.data?.id ?? null,
|
||||
ok: false,
|
||||
error: { message: "invalid-payload" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nip04 = await ensureNip04();
|
||||
const ciphertext = await nip04.encrypt(
|
||||
payload.privateKey,
|
||||
payload.targetPubkey,
|
||||
payload.plaintext,
|
||||
);
|
||||
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: true,
|
||||
ciphertext,
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[nip04Worker] Encryption failed", error);
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: false,
|
||||
error: {
|
||||
message: error?.message || "nip04-worker-failed",
|
||||
name: error?.name || "Error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (workerScope?.addEventListener) {
|
||||
workerScope.addEventListener("message", (event) => {
|
||||
void handleMessage(event);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 12000;
|
||||
|
||||
let workerInstance = null;
|
||||
let workerReady = false;
|
||||
let requestId = 0;
|
||||
const pending = new Map();
|
||||
|
||||
function attachWorkerListeners(worker) {
|
||||
if (!worker || workerReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
worker.addEventListener("message", (event) => {
|
||||
const data = event?.data || {};
|
||||
const id = data.id;
|
||||
if (!pending.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = pending.get(id);
|
||||
pending.delete(id);
|
||||
clearTimeout(entry.timeoutId);
|
||||
|
||||
if (data.ok && typeof data.ciphertext === "string") {
|
||||
entry.resolve(data.ciphertext);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = data?.error?.message || "nip04-worker-error";
|
||||
const error = new Error(message);
|
||||
error.name = data?.error?.name || "Error";
|
||||
entry.reject(error);
|
||||
});
|
||||
|
||||
worker.addEventListener("error", (error) => {
|
||||
devLogger.warn("[nip04WorkerClient] Worker error", error);
|
||||
for (const entry of pending.values()) {
|
||||
clearTimeout(entry.timeoutId);
|
||||
entry.reject(error instanceof Error ? error : new Error("nip04-worker-error"));
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
workerReady = true;
|
||||
}
|
||||
|
||||
function ensureWorker() {
|
||||
if (workerInstance) {
|
||||
return workerInstance;
|
||||
}
|
||||
|
||||
if (typeof Worker === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
workerInstance = new Worker(new URL("./nip04Worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
attachWorkerListeners(workerInstance);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nip04WorkerClient] Failed to create worker", error);
|
||||
workerInstance = null;
|
||||
}
|
||||
|
||||
return workerInstance;
|
||||
}
|
||||
|
||||
export function encryptNip04InWorker({
|
||||
privateKey,
|
||||
targetPubkey,
|
||||
plaintext,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
const worker = ensureWorker();
|
||||
if (!worker) {
|
||||
return Promise.reject(new Error("nip04-worker-unavailable"));
|
||||
}
|
||||
|
||||
const trimmedPrivateKey =
|
||||
typeof privateKey === "string" ? privateKey.trim() : "";
|
||||
const trimmedTarget =
|
||||
typeof targetPubkey === "string" ? targetPubkey.trim() : "";
|
||||
const message = typeof plaintext === "string" ? plaintext : "";
|
||||
|
||||
if (!trimmedPrivateKey || !trimmedTarget) {
|
||||
return Promise.reject(new Error("nip04-worker-invalid-input"));
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${requestId++}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error("nip04-worker-timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
pending.set(id, { resolve, reject, timeoutId });
|
||||
worker.postMessage({
|
||||
id,
|
||||
privateKey: trimmedPrivateKey,
|
||||
targetPubkey: trimmedTarget,
|
||||
plaintext: message,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -290,6 +290,22 @@ function buildParticipantTag(participant) {
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a NIP-71 metadata object into Nostr tags.
|
||||
*
|
||||
* Mapping:
|
||||
* - `title` -> `['title', ...]`
|
||||
* - `publishedAt` -> `['published_at', ...]`
|
||||
* - `imeta` -> `['imeta', 'url', ..., 'dim', ...]` (Video variants)
|
||||
* - `textTracks` -> `['text-track', ...]`
|
||||
* - `segments` -> `['segment', ...]`
|
||||
* - `hashtags` -> `['t', ...]`
|
||||
* - `participants` -> `['p', ...]`
|
||||
* - `references` -> `['r', ...]`
|
||||
*
|
||||
* @param {object} metadata - The metadata object (parsed structure).
|
||||
* @returns {string[][]} Array of tags ready for the event.
|
||||
*/
|
||||
export function buildNip71MetadataTags(metadata) {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return [];
|
||||
@@ -571,6 +587,16 @@ function parseHashtagTag(tag) {
|
||||
return value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a NIP-71 event (Kind 22 or 21) into a structured metadata object.
|
||||
*
|
||||
* Extracts:
|
||||
* - Standard NIP-71 tags (title, imeta, segments, etc.)
|
||||
* - Pointer references (a, e, d tags) used to link this metadata to the video.
|
||||
*
|
||||
* @param {import("nostr-tools").Event} event - The raw Nostr event.
|
||||
* @returns {{metadata: object, pointers: object, source: object}|null} The parsed structure or null if invalid.
|
||||
*/
|
||||
export function extractNip71MetadataFromTags(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return null;
|
||||
@@ -840,6 +866,21 @@ export function buildNip71PointerTags({
|
||||
return pointerTags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a Kind 22 (Video Wrapper) or Kind 21 (Video Segment) event.
|
||||
*
|
||||
* This event holds the NIP-71 categorization and metadata, separate from the
|
||||
* playable video note (Kind 30078). It links to the video via `a` or `e` tags.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} params.metadata - The NIP-71 metadata object.
|
||||
* @param {string} params.pubkey - The publisher's public key.
|
||||
* @param {string} params.title - The title (required by NIP-71).
|
||||
* @param {string} [params.summaryFallback] - Fallback content if summary is missing.
|
||||
* @param {number} [params.createdAt] - Timestamp.
|
||||
* @param {object} [params.pointerIdentifiers] - Pointers to the target video ({videoRootId, eventId, dTag}).
|
||||
* @returns {import("nostr-tools").Event|null} The unsigned event object.
|
||||
*/
|
||||
export function buildNip71VideoEvent({
|
||||
metadata,
|
||||
pubkey = "",
|
||||
@@ -1011,6 +1052,19 @@ function storeNip71RecordForRoot(nip71Cache, videoRootId, parsedRecord) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a batch of NIP-71 events and updates the cache.
|
||||
*
|
||||
* Logic:
|
||||
* - Parses each event.
|
||||
* - Identifies which video root(s) it belongs to (via `a` tag or cache lookup).
|
||||
* - Updates `nip71Cache` with the most recent valid metadata for that root.
|
||||
*
|
||||
* @param {import("nostr-tools").Event[]} events - List of fetched events.
|
||||
* @param {object} context
|
||||
* @param {Map} context.nip71Cache - The shared metadata cache.
|
||||
* @param {Map} [context.pointerMap] - Map of pointer values to video info (optimization).
|
||||
*/
|
||||
export function processNip71Events(events, { nip71Cache, pointerMap = null } = {}) {
|
||||
if (!Array.isArray(events) || !events.length || !(nip71Cache instanceof Map)) {
|
||||
return;
|
||||
@@ -1045,6 +1099,17 @@ export function processNip71Events(events, { nip71Cache, pointerMap = null } = {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates a video object with cached NIP-71 metadata.
|
||||
*
|
||||
* If metadata exists for the video's `videoRootId`, `eventId`, or `d` tag,
|
||||
* it is merged into `video.nip71` and `video.nip71Source`.
|
||||
*
|
||||
* @param {object} video - The video object to mutate.
|
||||
* @param {object} context
|
||||
* @param {Map} context.nip71Cache - The metadata cache.
|
||||
* @returns {object} The mutated video object.
|
||||
*/
|
||||
export function mergeNip71MetadataIntoVideo(video, { nip71Cache } = {}) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return video;
|
||||
|
||||
@@ -13,7 +13,6 @@ export const DEFAULT_RELAY_URLS = Object.freeze([
|
||||
"wss://nos.lol",
|
||||
"wss://relay.snort.social",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nostr.band",
|
||||
]);
|
||||
|
||||
export const RELAY_URLS = Array.from(DEFAULT_RELAY_URLS);
|
||||
|
||||
+599
-6
@@ -20,14 +20,23 @@ export const NOTE_TYPES = Object.freeze({
|
||||
VIEW_EVENT: "viewEvent",
|
||||
VIDEO_REACTION: "videoReaction",
|
||||
VIDEO_COMMENT: "videoComment",
|
||||
DM_ATTACHMENT: "dmAttachment",
|
||||
DM_READ_RECEIPT: "dmReadReceipt",
|
||||
DM_TYPING: "dmTypingIndicator",
|
||||
ZAP_REQUEST: "zapRequest",
|
||||
ZAP_RECEIPT: "zapReceipt",
|
||||
WATCH_HISTORY: "watchHistory",
|
||||
SUBSCRIPTION_LIST: "subscriptionList",
|
||||
USER_BLOCK_LIST: "userBlockList",
|
||||
HASHTAG_PREFERENCES: "hashtagPreferences",
|
||||
DM_RELAY_LIST: "dmRelayList",
|
||||
ADMIN_MODERATION_LIST: "adminModerationList",
|
||||
ADMIN_BLACKLIST: "adminBlacklist",
|
||||
ADMIN_WHITELIST: "adminWhitelist",
|
||||
PROFILE_METADATA: "profileMetadata",
|
||||
MUTE_LIST: "muteList",
|
||||
DELETION: "deletion",
|
||||
LEGACY_DM: "legacyDm",
|
||||
});
|
||||
|
||||
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
|
||||
@@ -270,8 +279,8 @@ const BASE_SCHEMAS = {
|
||||
kind: 6,
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "empty",
|
||||
description: "Content field intentionally empty for pure repost events.",
|
||||
format: "json",
|
||||
description: "Content field contains the JSON-serialized event being reposted.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.NIP71_VIDEO]: {
|
||||
@@ -308,6 +317,14 @@ const BASE_SCHEMAS = {
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: { format: "empty", description: "Content field unused." },
|
||||
},
|
||||
[NOTE_TYPES.DM_RELAY_LIST]: {
|
||||
type: NOTE_TYPES.DM_RELAY_LIST,
|
||||
label: "DM relay hints",
|
||||
kind: 10050,
|
||||
relayTagName: "relay",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: { format: "empty", description: "Content field unused." },
|
||||
},
|
||||
[NOTE_TYPES.VIEW_EVENT]: {
|
||||
type: NOTE_TYPES.VIEW_EVENT,
|
||||
label: "View counter",
|
||||
@@ -352,6 +369,73 @@ const BASE_SCHEMAS = {
|
||||
description: "Plain text comment body sanitized for UTF-8 compatibility.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.DM_ATTACHMENT]: {
|
||||
type: NOTE_TYPES.DM_ATTACHMENT,
|
||||
label: "DM attachment (NIP-17 file rumor)",
|
||||
kind: 15,
|
||||
participantTagName: "p",
|
||||
hashTagName: "x",
|
||||
urlTagName: "url",
|
||||
nameTagName: "name",
|
||||
typeTagName: "type",
|
||||
sizeTagName: "size",
|
||||
keyTagName: "k",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "empty",
|
||||
description: "Attachment metadata is carried in tags; content is empty.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.DM_READ_RECEIPT]: {
|
||||
type: NOTE_TYPES.DM_READ_RECEIPT,
|
||||
label: "DM read receipt",
|
||||
kind: 20001,
|
||||
recipientTagName: "p",
|
||||
eventTagName: "e",
|
||||
kindTagName: "k",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "empty",
|
||||
description: "Ephemeral read receipt pointer for a DM event.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.DM_TYPING]: {
|
||||
type: NOTE_TYPES.DM_TYPING,
|
||||
label: "DM typing indicator",
|
||||
kind: 20002,
|
||||
recipientTagName: "p",
|
||||
eventTagName: "e",
|
||||
statusTagName: "t",
|
||||
statusTagValue: "typing",
|
||||
expirationTagName: "expiration",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "empty",
|
||||
description: "Ephemeral typing indicator with an expiration timestamp.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.ZAP_REQUEST]: {
|
||||
type: NOTE_TYPES.ZAP_REQUEST,
|
||||
label: "Zap request",
|
||||
kind: 9734,
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description:
|
||||
"Optional zap message included in the zap request sent to LNURL pay callbacks.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.ZAP_RECEIPT]: {
|
||||
type: NOTE_TYPES.ZAP_RECEIPT,
|
||||
label: "Zap receipt",
|
||||
kind: 9735,
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description:
|
||||
"Zap receipts are published by lightning wallets to confirm zap requests.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.WATCH_HISTORY]: {
|
||||
type: NOTE_TYPES.WATCH_HISTORY,
|
||||
label: "Watch history month",
|
||||
@@ -458,6 +542,38 @@ const BASE_SCHEMAS = {
|
||||
description: "Standard NIP-01 profile metadata (name, about, picture, etc.).",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.MUTE_LIST]: {
|
||||
type: NOTE_TYPES.MUTE_LIST,
|
||||
label: "Mute list",
|
||||
kind: 10000,
|
||||
participantTagName: "p",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description: "Optional content (often encrypted) with public p tags.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.DELETION]: {
|
||||
type: NOTE_TYPES.DELETION,
|
||||
label: "Deletion",
|
||||
kind: 5,
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description: "Reason for deletion.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.LEGACY_DM]: {
|
||||
type: NOTE_TYPES.LEGACY_DM,
|
||||
label: "Legacy Direct Message",
|
||||
kind: 4,
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
recipientTagName: "p",
|
||||
content: {
|
||||
format: "text",
|
||||
description: "NIP-04 encrypted ciphertext.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let schemaOverrides = {};
|
||||
@@ -708,13 +824,19 @@ export function buildVideoPostEvent(params) {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const event = {
|
||||
kind: schema?.kind ?? 30078,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: serializedContent,
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
validateEventAgainstSchema(NOTE_TYPES.VIDEO_POST, event);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
export function buildVideoMirrorEvent(params) {
|
||||
@@ -766,10 +888,11 @@ export function buildRepostEvent(params) {
|
||||
typeof eventRelay === "string" ? eventRelay.trim() : "";
|
||||
|
||||
if (normalizedEventId) {
|
||||
if (!normalizedEventRelay) {
|
||||
throw new Error("missing-event-relay");
|
||||
if (normalizedEventRelay) {
|
||||
tags.push(["e", normalizedEventId, normalizedEventRelay]);
|
||||
} else {
|
||||
tags.push(["e", normalizedEventId]);
|
||||
}
|
||||
tags.push(["e", normalizedEventId, normalizedEventRelay]);
|
||||
}
|
||||
|
||||
const normalizedAddress = typeof address === "string" ? address.trim() : "";
|
||||
@@ -929,6 +1052,331 @@ export function buildRelayListEvent(params) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDmRelayListEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
relays = [],
|
||||
additionalTags = [],
|
||||
} = params || {};
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.DM_RELAY_LIST);
|
||||
const tags = [];
|
||||
const relayTagName = schema?.relayTagName || "relay";
|
||||
|
||||
if (Array.isArray(relays)) {
|
||||
relays.forEach((relay) => {
|
||||
const normalized = typeof relay === "string" ? relay.trim() : "";
|
||||
if (normalized) {
|
||||
tags.push([relayTagName, normalized]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||||
if (sanitizedAdditionalTags.length) {
|
||||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 10050,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProfileMetadataEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
metadata = {},
|
||||
content,
|
||||
additionalTags = [],
|
||||
} = params || {};
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.PROFILE_METADATA);
|
||||
const tags = [];
|
||||
appendSchemaTags(tags, schema);
|
||||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||||
if (sanitizedAdditionalTags.length) {
|
||||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||||
}
|
||||
|
||||
let serializedContent = "";
|
||||
if (typeof content === "string") {
|
||||
serializedContent = content;
|
||||
} else {
|
||||
try {
|
||||
serializedContent = JSON.stringify(metadata || {});
|
||||
} catch (error) {
|
||||
serializedContent = "{}";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 0,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: serializedContent,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMuteListEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
pTags = [],
|
||||
content = "",
|
||||
encrypted = false,
|
||||
encryptionTag = "",
|
||||
additionalTags = [],
|
||||
} = params || {};
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.MUTE_LIST);
|
||||
const tags = [];
|
||||
|
||||
const participantTagName = schema?.participantTagName || "p";
|
||||
if (Array.isArray(pTags)) {
|
||||
pTags.forEach((hex) => {
|
||||
if (typeof hex === "string" && hex.trim()) {
|
||||
tags.push([participantTagName, hex.trim()]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (encrypted && encryptionTag) {
|
||||
tags.push(["encrypted", encryptionTag]);
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||||
if (sanitizedAdditionalTags.length) {
|
||||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 10000,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof content === "string" ? content : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDeletionEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
eventIds = [],
|
||||
addresses = [],
|
||||
reason = "",
|
||||
additionalTags = [],
|
||||
} = params || {};
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.DELETION);
|
||||
const tags = [];
|
||||
|
||||
if (Array.isArray(eventIds)) {
|
||||
eventIds.forEach((id) => {
|
||||
const normalized = normalizePointerIdentifier(id);
|
||||
if (normalized) {
|
||||
tags.push(["e", normalized]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(addresses)) {
|
||||
addresses.forEach((addr) => {
|
||||
const normalized = typeof addr === "string" ? addr.trim() : "";
|
||||
if (normalized) {
|
||||
tags.push(["a", normalized]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||||
if (sanitizedAdditionalTags.length) {
|
||||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 5,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof reason === "string" ? reason : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLegacyDirectMessageEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey,
|
||||
ciphertext = "",
|
||||
additionalTags = [],
|
||||
} = params || {};
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.LEGACY_DM);
|
||||
const tags = [];
|
||||
|
||||
const recipient = normalizePointerIdentifier(recipientPubkey);
|
||||
if (recipient) {
|
||||
tags.push([schema?.recipientTagName || "p", recipient]);
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||||
if (sanitizedAdditionalTags.length) {
|
||||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 4,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: typeof ciphertext === "string" ? ciphertext : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDmAttachmentEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey,
|
||||
attachment = {},
|
||||
additionalTags = [],
|
||||
} = params || {};
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.DM_ATTACHMENT);
|
||||
const tags = [];
|
||||
|
||||
const participantTagName = schema?.participantTagName || "p";
|
||||
if (recipientPubkey && typeof recipientPubkey === "string") {
|
||||
tags.push([participantTagName, recipientPubkey.trim()]);
|
||||
}
|
||||
|
||||
if (attachment?.x) {
|
||||
tags.push([schema?.hashTagName || "x", attachment.x]);
|
||||
}
|
||||
if (attachment?.url) {
|
||||
tags.push([schema?.urlTagName || "url", attachment.url]);
|
||||
}
|
||||
if (attachment?.name) {
|
||||
tags.push([schema?.nameTagName || "name", attachment.name]);
|
||||
}
|
||||
if (attachment?.type) {
|
||||
tags.push([schema?.typeTagName || "type", attachment.type]);
|
||||
}
|
||||
if (Number.isFinite(attachment?.size)) {
|
||||
tags.push([schema?.sizeTagName || "size", String(attachment.size)]);
|
||||
}
|
||||
if (attachment?.key) {
|
||||
tags.push([schema?.keyTagName || "k", attachment.key]);
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||||
if (sanitizedAdditionalTags.length) {
|
||||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 15,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDmReadReceiptEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey,
|
||||
eventId,
|
||||
messageKind,
|
||||
additionalTags = [],
|
||||
} = params || {};
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.DM_READ_RECEIPT);
|
||||
const tags = [];
|
||||
|
||||
const recipient = normalizePointerIdentifier(recipientPubkey);
|
||||
if (recipient) {
|
||||
tags.push([schema?.recipientTagName || "p", recipient]);
|
||||
}
|
||||
|
||||
const normalizedEventId = normalizePointerIdentifier(eventId);
|
||||
if (normalizedEventId) {
|
||||
tags.push([schema?.eventTagName || "e", normalizedEventId]);
|
||||
}
|
||||
|
||||
if (Number.isFinite(messageKind)) {
|
||||
tags.push([schema?.kindTagName || "k", String(Math.floor(messageKind))]);
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||||
if (sanitizedAdditionalTags.length) {
|
||||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 20001,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDmTypingIndicatorEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey,
|
||||
eventId,
|
||||
expiresAt,
|
||||
additionalTags = [],
|
||||
} = params || {};
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.DM_TYPING);
|
||||
const tags = [];
|
||||
|
||||
const recipient = normalizePointerIdentifier(recipientPubkey);
|
||||
if (recipient) {
|
||||
tags.push([schema?.recipientTagName || "p", recipient]);
|
||||
}
|
||||
|
||||
const normalizedEventId = normalizePointerIdentifier(eventId);
|
||||
if (normalizedEventId) {
|
||||
tags.push([schema?.eventTagName || "e", normalizedEventId]);
|
||||
}
|
||||
|
||||
if (schema?.statusTagName && schema?.statusTagValue) {
|
||||
tags.push([schema.statusTagName, schema.statusTagValue]);
|
||||
}
|
||||
|
||||
if (Number.isFinite(expiresAt)) {
|
||||
tags.push([schema?.expirationTagName || "expiration", String(Math.floor(expiresAt))]);
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||||
if (sanitizedAdditionalTags.length) {
|
||||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 20002,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildViewEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
@@ -990,6 +1438,80 @@ export function buildViewEvent(params) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildZapRequestEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey,
|
||||
relays = [],
|
||||
amountSats,
|
||||
lnurl,
|
||||
eventId,
|
||||
coordinate,
|
||||
additionalTags = [],
|
||||
content = "",
|
||||
} = params || {};
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.ZAP_REQUEST);
|
||||
const tags = [];
|
||||
|
||||
const normalizedRecipient = normalizePointerIdentifier(recipientPubkey);
|
||||
if (normalizedRecipient) {
|
||||
tags.push(["p", normalizedRecipient]);
|
||||
}
|
||||
|
||||
const normalizedEventId = normalizePointerIdentifier(eventId);
|
||||
if (normalizedEventId) {
|
||||
tags.push(["e", normalizedEventId]);
|
||||
}
|
||||
|
||||
if (typeof coordinate === "string" && coordinate.trim()) {
|
||||
tags.push(["a", coordinate.trim()]);
|
||||
}
|
||||
|
||||
if (typeof lnurl === "string" && lnurl.trim()) {
|
||||
tags.push(["lnurl", lnurl.trim()]);
|
||||
}
|
||||
|
||||
if (Number.isFinite(amountSats)) {
|
||||
tags.push(["amount", String(Math.max(0, Math.round(amountSats)) * 1000)]);
|
||||
}
|
||||
|
||||
const relayList = Array.isArray(relays) ? relays : [];
|
||||
const relaySeen = new Set();
|
||||
const normalizedRelays = [];
|
||||
relayList.forEach((relay) => {
|
||||
if (typeof relay !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = relay.trim();
|
||||
if (!trimmed || relaySeen.has(trimmed)) {
|
||||
return;
|
||||
}
|
||||
relaySeen.add(trimmed);
|
||||
normalizedRelays.push(trimmed);
|
||||
});
|
||||
if (normalizedRelays.length) {
|
||||
tags.push(["relays", ...normalizedRelays]);
|
||||
}
|
||||
|
||||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||||
if (sanitizedAdditionalTags.length) {
|
||||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
const resolvedContent = ensureValidUtf8Content(content);
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 9734,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: resolvedContent,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildReactionEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
@@ -1542,11 +2064,82 @@ export function buildAdminListEvent(listKey, params) {
|
||||
};
|
||||
}
|
||||
|
||||
function hasTag(tags, tagName, tagValue = null) {
|
||||
if (!Array.isArray(tags)) return false;
|
||||
return tags.some(
|
||||
(tag) =>
|
||||
Array.isArray(tag) &&
|
||||
tag[0] === tagName &&
|
||||
(tagValue === null || tag[1] === tagValue)
|
||||
);
|
||||
}
|
||||
|
||||
export function validateEventAgainstSchema(type, event) {
|
||||
if (!isDevMode || !event) return;
|
||||
|
||||
const schema = getNostrEventSchema(type);
|
||||
if (!schema) return;
|
||||
|
||||
if (event.kind !== schema.kind) {
|
||||
devLogger.warn(
|
||||
`[schema] Kind mismatch for ${type}: expected ${schema.kind}, got ${event.kind}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.topicTag) {
|
||||
if (!hasTag(event.tags, schema.topicTag.name, schema.topicTag.value)) {
|
||||
devLogger.warn(
|
||||
`[schema] Missing topic tag for ${type}: ${schema.topicTag.name}=${schema.topicTag.value}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.identifierTag) {
|
||||
const expectedValue = schema.identifierTag.value;
|
||||
if (!hasTag(event.tags, schema.identifierTag.name, expectedValue)) {
|
||||
devLogger.warn(
|
||||
`[schema] Missing identifier tag for ${type}: ${schema.identifierTag.name}${
|
||||
expectedValue ? "=" + expectedValue : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.appendTags) {
|
||||
schema.appendTags.forEach((appendTag) => {
|
||||
if (Array.isArray(appendTag) && appendTag.length >= 2) {
|
||||
const tagName = appendTag[0];
|
||||
const tagValue = appendTag[1];
|
||||
if (!hasTag(event.tags, tagName, tagValue)) {
|
||||
devLogger.warn(
|
||||
`[schema] Missing append tag for ${type}: ${tagName}=${tagValue}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (schema.content) {
|
||||
if (schema.content.format === "json") {
|
||||
try {
|
||||
JSON.parse(event.content);
|
||||
} catch (e) {
|
||||
devLogger.warn(`[schema] Content is not valid JSON for ${type}`);
|
||||
}
|
||||
} else if (schema.content.format === "empty") {
|
||||
if (event.content !== "") {
|
||||
devLogger.warn(`[schema] Content should be empty for ${type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.bitvidNostrEvents = {
|
||||
NOTE_TYPES,
|
||||
getSchema: getNostrEventSchema,
|
||||
getAllSchemas: getAllNostrEventSchemas,
|
||||
setOverrides: setNostrEventSchemaOverrides,
|
||||
validateEvent: validateEventAgainstSchema,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// js/payments/zapRequests.js
|
||||
|
||||
import {
|
||||
resolveLightningAddress,
|
||||
fetchPayServiceData,
|
||||
encodeLnurlBech32,
|
||||
validateInvoiceAmount,
|
||||
requestInvoice,
|
||||
} from "./lnurl.js";
|
||||
import { buildZapRequestEvent, NOTE_TYPES } from "../nostrEventSchemas.js";
|
||||
import { publishEventToRelays, assertAnyRelayAccepted } from "../nostrPublish.js";
|
||||
import { queueSignEvent } from "../nostr/signRequestQueue.js";
|
||||
import { RELAY_URLS } from "../nostr/toolkit.js";
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
const DEFAULT_ZAP_RELAYS = Array.from(RELAY_URLS);
|
||||
|
||||
function normalizeRelayList(relays) {
|
||||
const list = Array.isArray(relays) ? relays : [];
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
list.forEach((relay) => {
|
||||
if (typeof relay !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = relay.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
return;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function resolveSignerPubkey(signer) {
|
||||
if (!signer || typeof signer !== "object") {
|
||||
return "";
|
||||
}
|
||||
if (typeof signer.getPublicKey === "function") {
|
||||
return signer.getPublicKey();
|
||||
}
|
||||
if (typeof signer.getPubkey === "function") {
|
||||
return signer.getPubkey();
|
||||
}
|
||||
if (typeof signer.pubkey === "string") {
|
||||
return signer.pubkey;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveLnurlTag(resolved) {
|
||||
const candidate = typeof resolved?.address === "string" ? resolved.address.trim() : "";
|
||||
if (candidate && candidate.toLowerCase().startsWith("lnurl")) {
|
||||
return candidate.toLowerCase();
|
||||
}
|
||||
|
||||
const url = typeof resolved?.url === "string" ? resolved.url.trim() : "";
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return encodeLnurlBech32(url);
|
||||
}
|
||||
|
||||
export async function resolveZapRecipient(address, { fetcher } = {}) {
|
||||
const resolved = resolveLightningAddress(address);
|
||||
const metadata = await fetchPayServiceData(resolved.url, { fetcher });
|
||||
const lnurl = resolveLnurlTag(resolved);
|
||||
return { resolved, metadata, lnurl };
|
||||
}
|
||||
|
||||
export function buildZapRequestPayload({
|
||||
senderPubkey,
|
||||
recipientPubkey,
|
||||
relays,
|
||||
amountSats,
|
||||
comment,
|
||||
lnurl,
|
||||
eventId,
|
||||
coordinate,
|
||||
createdAt,
|
||||
additionalTags = [],
|
||||
} = {}) {
|
||||
const created_at = Number.isFinite(createdAt)
|
||||
? Math.max(0, Math.floor(createdAt))
|
||||
: Math.floor(Date.now() / 1000);
|
||||
const event = buildZapRequestEvent({
|
||||
pubkey: senderPubkey,
|
||||
created_at,
|
||||
recipientPubkey,
|
||||
relays,
|
||||
amountSats,
|
||||
content: comment || "",
|
||||
lnurl,
|
||||
eventId,
|
||||
coordinate,
|
||||
additionalTags,
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
export async function signZapRequest(event, signer, options = {}) {
|
||||
if (!signer || typeof signer.signEvent !== "function") {
|
||||
throw new Error("A Nostr signer is required to create zap requests.");
|
||||
}
|
||||
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : undefined;
|
||||
return queueSignEvent(signer, event, { timeoutMs });
|
||||
}
|
||||
|
||||
export async function publishZapRequest(signedEvent, { relays, pool } = {}) {
|
||||
const targetRelays = normalizeRelayList(relays);
|
||||
const publishTargets = targetRelays.length ? targetRelays : DEFAULT_ZAP_RELAYS;
|
||||
const publishResults = await publishEventToRelays(pool, publishTargets, signedEvent);
|
||||
const summary = assertAnyRelayAccepted(publishResults, { context: "zap request" });
|
||||
return {
|
||||
relays: publishTargets,
|
||||
summary,
|
||||
results: publishResults,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAndPublishZapRequest({
|
||||
address,
|
||||
recipientPubkey,
|
||||
relays,
|
||||
amountSats,
|
||||
comment,
|
||||
eventId,
|
||||
coordinate,
|
||||
signer,
|
||||
pool,
|
||||
fetcher,
|
||||
additionalTags = [],
|
||||
} = {}) {
|
||||
const { resolved, metadata, lnurl } = await resolveZapRecipient(address, { fetcher });
|
||||
|
||||
if (!metadata?.allowsNostr && !metadata?.nostrPubkey) {
|
||||
throw new Error("Recipient LNURL endpoint does not support Nostr zaps.");
|
||||
}
|
||||
|
||||
const resolvedRecipient = recipientPubkey || metadata?.nostrPubkey || "";
|
||||
if (!resolvedRecipient) {
|
||||
throw new Error("Recipient pubkey is required to send a zap request.");
|
||||
}
|
||||
|
||||
const normalizedRelays = normalizeRelayList(relays);
|
||||
const senderPubkey = await resolveSignerPubkey(signer);
|
||||
if (!senderPubkey) {
|
||||
throw new Error("Unable to resolve sender pubkey for zap request.");
|
||||
}
|
||||
|
||||
const zapRequestEvent = buildZapRequestPayload({
|
||||
senderPubkey,
|
||||
recipientPubkey: resolvedRecipient,
|
||||
relays: normalizedRelays,
|
||||
amountSats,
|
||||
comment,
|
||||
lnurl,
|
||||
eventId,
|
||||
coordinate,
|
||||
additionalTags,
|
||||
});
|
||||
|
||||
const signedEvent = await signZapRequest(zapRequestEvent, signer);
|
||||
|
||||
const publishResult = await publishZapRequest(signedEvent, {
|
||||
relays: normalizedRelays,
|
||||
pool,
|
||||
});
|
||||
|
||||
let invoice = null;
|
||||
try {
|
||||
validateInvoiceAmount(metadata, amountSats);
|
||||
invoice = await requestInvoice(metadata, {
|
||||
amountSats,
|
||||
comment,
|
||||
zapRequest: JSON.stringify(signedEvent),
|
||||
lnurl,
|
||||
fetcher,
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[zapRequests] Failed to request invoice for zap request.", error);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: NOTE_TYPES.ZAP_REQUEST,
|
||||
resolved,
|
||||
metadata,
|
||||
lnurl,
|
||||
zapRequest: signedEvent,
|
||||
publishResult,
|
||||
invoice,
|
||||
};
|
||||
}
|
||||
|
||||
export const __TESTING__ = Object.freeze({
|
||||
normalizeRelayList,
|
||||
resolveLnurlTag,
|
||||
});
|
||||
@@ -363,7 +363,7 @@ function getPointerSnapshot(pointerInput) {
|
||||
return snapshotState(state);
|
||||
}
|
||||
|
||||
export function ingestLocalReaction({ event, pointer }) {
|
||||
function ingestLocalReaction({ event, pointer }) {
|
||||
if (!event || !pointer) {
|
||||
return;
|
||||
}
|
||||
@@ -785,7 +785,6 @@ export const reactionCounter = {
|
||||
},
|
||||
};
|
||||
|
||||
reactionCounter.ingestLocalReaction = ingestLocalReaction;
|
||||
reactionCounter.subscribe = subscribeToPointer;
|
||||
reactionCounter.unsubscribe = unsubscribeFromPointer;
|
||||
reactionCounter.getSnapshot = getPointerSnapshot;
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
const ATTACHMENT_CACHE_LIMIT = 20;
|
||||
const AES_GCM_IV_BYTES = 12;
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
function getCacheEntry(key) {
|
||||
if (!cache.has(key)) {
|
||||
return null;
|
||||
}
|
||||
const entry = cache.get(key);
|
||||
cache.delete(key);
|
||||
cache.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function revokeEntry(entry) {
|
||||
if (entry?.objectUrl && typeof URL !== "undefined") {
|
||||
try {
|
||||
URL.revokeObjectURL(entry.objectUrl);
|
||||
} catch (error) {
|
||||
devLogger.warn("[attachments] Failed to revoke object URL", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setCacheEntry(key, entry) {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cache.has(key)) {
|
||||
const existing = cache.get(key);
|
||||
if (existing && existing !== entry) {
|
||||
revokeEntry(existing);
|
||||
}
|
||||
cache.delete(key);
|
||||
} else if (cache.size >= ATTACHMENT_CACHE_LIMIT) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
const oldest = cache.get(oldestKey);
|
||||
cache.delete(oldestKey);
|
||||
revokeEntry(oldest);
|
||||
}
|
||||
|
||||
cache.set(key, entry);
|
||||
}
|
||||
|
||||
export function clearAttachmentCache() {
|
||||
cache.forEach((entry) => revokeEntry(entry));
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
function normalizeBase64(bytes) {
|
||||
if (!(bytes instanceof Uint8Array)) {
|
||||
return "";
|
||||
}
|
||||
let binary = "";
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function decodeBase64(value) {
|
||||
if (typeof value !== "string" || !value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
devLogger.warn("[attachments] Failed to decode base64 key", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readBlobAsUint8Array(blob) {
|
||||
if (!blob || typeof blob.arrayBuffer !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
} catch (error) {
|
||||
devLogger.warn("[attachments] Failed to read attachment bytes", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function computeSha256Hex(bytes) {
|
||||
if (!(bytes instanceof Uint8Array)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const digest = sha256(bytes);
|
||||
const hex = typeof digest === "string" ? digest : bytesToHex(digest);
|
||||
return hex ? hex.toLowerCase() : "";
|
||||
} catch (error) {
|
||||
devLogger.warn("[attachments] Failed to compute attachment hash", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function encryptAttachment(blob) {
|
||||
if (!blob) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error("Encryption unavailable in this environment.");
|
||||
}
|
||||
|
||||
const rawBytes = await readBlobAsUint8Array(blob);
|
||||
if (!rawBytes) {
|
||||
throw new Error("Failed to read attachment bytes for encryption.");
|
||||
}
|
||||
|
||||
const keyBytes = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(keyBytes);
|
||||
const ivBytes = new Uint8Array(AES_GCM_IV_BYTES);
|
||||
globalThis.crypto.getRandomValues(ivBytes);
|
||||
|
||||
const key = await globalThis.crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBytes,
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
|
||||
const encrypted = await globalThis.crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: ivBytes },
|
||||
key,
|
||||
rawBytes,
|
||||
);
|
||||
|
||||
const encryptedBytes = new Uint8Array(encrypted);
|
||||
const payload = new Uint8Array(ivBytes.length + encryptedBytes.length);
|
||||
payload.set(ivBytes, 0);
|
||||
payload.set(encryptedBytes, ivBytes.length);
|
||||
|
||||
return {
|
||||
payload,
|
||||
key: normalizeBase64(keyBytes),
|
||||
};
|
||||
}
|
||||
|
||||
async function decryptAttachment(bytes, keyBase64) {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error("Decryption unavailable in this environment.");
|
||||
}
|
||||
|
||||
const keyBytes = decodeBase64(keyBase64);
|
||||
if (!keyBytes) {
|
||||
throw new Error("Missing decryption key.");
|
||||
}
|
||||
|
||||
if (!(bytes instanceof Uint8Array) || bytes.length <= AES_GCM_IV_BYTES) {
|
||||
throw new Error("Encrypted payload is missing IV.");
|
||||
}
|
||||
|
||||
const iv = bytes.slice(0, AES_GCM_IV_BYTES);
|
||||
const cipherBytes = bytes.slice(AES_GCM_IV_BYTES);
|
||||
|
||||
const key = await globalThis.crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBytes,
|
||||
"AES-GCM",
|
||||
false,
|
||||
["decrypt"],
|
||||
);
|
||||
|
||||
const decrypted = await globalThis.crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
cipherBytes,
|
||||
);
|
||||
|
||||
return new Uint8Array(decrypted);
|
||||
}
|
||||
|
||||
export async function prepareAttachmentUpload({ file, encrypt = false } = {}) {
|
||||
if (!file) {
|
||||
throw new Error("Attachment file missing.");
|
||||
}
|
||||
|
||||
let payloadBytes = await readBlobAsUint8Array(file);
|
||||
if (!payloadBytes) {
|
||||
throw new Error("Attachment could not be read.");
|
||||
}
|
||||
|
||||
let key = "";
|
||||
let uploadBlob = file;
|
||||
|
||||
if (encrypt) {
|
||||
const encrypted = await encryptAttachment(file);
|
||||
if (!encrypted?.payload) {
|
||||
throw new Error("Attachment encryption failed.");
|
||||
}
|
||||
|
||||
payloadBytes = encrypted.payload;
|
||||
key = encrypted.key || "";
|
||||
uploadBlob = new File([payloadBytes], file.name, {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
}
|
||||
|
||||
const sha256 = await computeSha256Hex(payloadBytes);
|
||||
if (!sha256) {
|
||||
throw new Error("Failed to compute attachment hash.");
|
||||
}
|
||||
|
||||
return {
|
||||
uploadBlob,
|
||||
sha256,
|
||||
key,
|
||||
name: file.name,
|
||||
type: file.type || "application/octet-stream",
|
||||
size: file.size,
|
||||
encrypted: Boolean(key),
|
||||
};
|
||||
}
|
||||
|
||||
async function readResponseWithProgress(response, onProgress) {
|
||||
if (!response.body || typeof response.body.getReader !== "function") {
|
||||
const blob = await response.blob();
|
||||
if (typeof onProgress === "function") {
|
||||
onProgress(1);
|
||||
}
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const contentLength = Number(response.headers.get("content-length")) || 0;
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
if (contentLength && typeof onProgress === "function") {
|
||||
onProgress(Math.min(received / contentLength, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const merged = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
chunks.forEach((chunk) => {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
});
|
||||
|
||||
if (typeof onProgress === "function") {
|
||||
onProgress(1);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function downloadAttachment({
|
||||
url,
|
||||
expectedHash,
|
||||
key,
|
||||
mimeType,
|
||||
onProgress,
|
||||
} = {}) {
|
||||
if (!url) {
|
||||
throw new Error("Attachment URL missing.");
|
||||
}
|
||||
|
||||
const cached = expectedHash ? getCacheEntry(expectedHash) : null;
|
||||
if (cached) {
|
||||
return { ...cached, cached: true };
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Attachment download failed (${response.status}).`);
|
||||
}
|
||||
|
||||
const bytes = await readResponseWithProgress(response, onProgress);
|
||||
if (expectedHash) {
|
||||
const actual = await computeSha256Hex(bytes);
|
||||
if (actual && actual !== expectedHash) {
|
||||
throw new Error("Attachment hash mismatch.");
|
||||
}
|
||||
}
|
||||
|
||||
let payloadBytes = bytes;
|
||||
if (key) {
|
||||
payloadBytes = await decryptAttachment(bytes, key);
|
||||
}
|
||||
|
||||
const blob = new Blob([payloadBytes], {
|
||||
type: mimeType || "application/octet-stream",
|
||||
});
|
||||
const objectUrl = typeof URL !== "undefined" ? URL.createObjectURL(blob) : "";
|
||||
|
||||
const entry = {
|
||||
blob,
|
||||
objectUrl,
|
||||
mimeType,
|
||||
size: blob.size,
|
||||
};
|
||||
|
||||
if (expectedHash) {
|
||||
setCacheEntry(expectedHash, entry);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function uploadAttachment({
|
||||
r2Service,
|
||||
pubkey,
|
||||
file,
|
||||
encrypt = false,
|
||||
onProgress,
|
||||
buildKey,
|
||||
buildUrl,
|
||||
} = {}) {
|
||||
if (!r2Service || typeof r2Service.uploadFile !== "function") {
|
||||
throw new Error("Storage service unavailable.");
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
throw new Error("Attachment file missing.");
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
throw new Error("Active pubkey required for attachment upload.");
|
||||
}
|
||||
|
||||
const credentials =
|
||||
typeof r2Service.resolveConnection === "function"
|
||||
? await r2Service.resolveConnection(pubkey)
|
||||
: null;
|
||||
|
||||
if (!credentials) {
|
||||
throw new Error("Storage configuration missing.");
|
||||
}
|
||||
|
||||
const prepared = await prepareAttachmentUpload({ file, encrypt });
|
||||
const key = typeof buildKey === "function" ? buildKey(pubkey, file) : "";
|
||||
if (!key) {
|
||||
throw new Error("Unable to derive storage key for attachment.");
|
||||
}
|
||||
|
||||
await r2Service.uploadFile({
|
||||
file: prepared.uploadBlob,
|
||||
...credentials,
|
||||
bucket: credentials.bucket,
|
||||
key,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
const url = typeof buildUrl === "function" ? buildUrl(credentials.baseDomain, key) : "";
|
||||
if (!url) {
|
||||
userLogger.warn("[attachments] Attachment uploaded without public URL.");
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
x: prepared.sha256,
|
||||
key: prepared.key,
|
||||
name: prepared.name,
|
||||
type: prepared.type,
|
||||
size: prepared.size,
|
||||
encrypted: prepared.encrypted,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAttachmentCacheStats() {
|
||||
return {
|
||||
size: cache.size,
|
||||
maxSize: ATTACHMENT_CACHE_LIMIT,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
import { ensureNostrTools, resolveSimplePoolConstructor, shimLegacySimplePoolMethods } from "../nostr/toolkit.js";
|
||||
import { sanitizeRelayList } from "../nostr/nip46Client.js";
|
||||
import { publishEventToRelay } from "../nostrPublish.js";
|
||||
import logger from "../utils/logger.js";
|
||||
|
||||
const DEFAULT_BACKOFF = {
|
||||
baseMs: 1000,
|
||||
maxMs: 30000,
|
||||
jitterRatio: 0.3,
|
||||
};
|
||||
|
||||
const MAX_SEEN_EVENT_IDS = 5000;
|
||||
export const DM_RELAY_WARNING_FALLBACK = "dm-relays-fallback";
|
||||
|
||||
function normalizeRelayList(relays) {
|
||||
if (!Array.isArray(relays)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
relays
|
||||
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePubkey(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeLogger(candidate) {
|
||||
if (
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
candidate.dev &&
|
||||
candidate.user
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return logger;
|
||||
}
|
||||
|
||||
function extractRelayHintsFromEvent(event) {
|
||||
const tags = Array.isArray(event?.tags) ? event.tags : [];
|
||||
return sanitizeRelayList(
|
||||
tags
|
||||
.filter((tag) => Array.isArray(tag) && tag[0] === "relay")
|
||||
.map((tag) => (typeof tag[1] === "string" ? tag[1].trim() : "")),
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveDmRelaySelection({
|
||||
pubkey,
|
||||
relayHints = [],
|
||||
discoveryRelays = [],
|
||||
fallbackRelays = [],
|
||||
pool = null,
|
||||
log = null,
|
||||
} = {}) {
|
||||
const resolvedLogger = normalizeLogger(log || logger);
|
||||
const normalizedPubkey = normalizePubkey(pubkey);
|
||||
const hintedRelays = sanitizeRelayList(Array.isArray(relayHints) ? relayHints : []);
|
||||
|
||||
if (hintedRelays.length) {
|
||||
return { relays: hintedRelays, source: "hints", warning: null };
|
||||
}
|
||||
|
||||
const discoveryList = sanitizeRelayList(
|
||||
Array.isArray(discoveryRelays) ? discoveryRelays : [],
|
||||
);
|
||||
const fallbackList = sanitizeRelayList(
|
||||
Array.isArray(fallbackRelays) && fallbackRelays.length
|
||||
? fallbackRelays
|
||||
: discoveryRelays,
|
||||
);
|
||||
|
||||
if (
|
||||
normalizedPubkey &&
|
||||
pool &&
|
||||
typeof pool.list === "function" &&
|
||||
discoveryList.length
|
||||
) {
|
||||
try {
|
||||
const events = await pool.list(discoveryList, [
|
||||
{ kinds: [10050], authors: [normalizedPubkey], limit: 1 },
|
||||
]);
|
||||
const sorted = Array.isArray(events)
|
||||
? events
|
||||
.filter((entry) => entry && entry.pubkey === normalizedPubkey)
|
||||
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0))
|
||||
: [];
|
||||
if (sorted.length) {
|
||||
const relays = extractRelayHintsFromEvent(sorted[0]);
|
||||
if (relays.length) {
|
||||
return { relays, source: "kind-10050", warning: null };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
resolvedLogger.dev.warn("[dmNostrService] Failed to fetch DM relay hints.", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackList.length) {
|
||||
return {
|
||||
relays: fallbackList,
|
||||
source: "fallback",
|
||||
warning: DM_RELAY_WARNING_FALLBACK,
|
||||
};
|
||||
}
|
||||
|
||||
return { relays: [], source: "none", warning: null };
|
||||
}
|
||||
|
||||
function clampNumber(value, minimum, maximum) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return minimum;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
|
||||
function resolveBackoffConfig(custom = {}) {
|
||||
return {
|
||||
baseMs: clampNumber(custom.baseMs ?? DEFAULT_BACKOFF.baseMs, 250, 60000),
|
||||
maxMs: clampNumber(custom.maxMs ?? DEFAULT_BACKOFF.maxMs, 1000, 300000),
|
||||
jitterRatio: clampNumber(custom.jitterRatio ?? DEFAULT_BACKOFF.jitterRatio, 0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
function applyJitter(value, ratio) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) {
|
||||
return value;
|
||||
}
|
||||
const jitter = value * ratio;
|
||||
return Math.round(value + (Math.random() * jitter - jitter / 2));
|
||||
}
|
||||
|
||||
class DmNostrService {
|
||||
constructor({
|
||||
relays = [],
|
||||
preferredRelays = [],
|
||||
backoff = {},
|
||||
log = null,
|
||||
} = {}) {
|
||||
this.relays = normalizeRelayList(relays);
|
||||
this.preferredRelays = normalizeRelayList(preferredRelays);
|
||||
this.pool = null;
|
||||
this.poolPromise = null;
|
||||
this.logger = normalizeLogger(log || logger);
|
||||
this.backoffConfig = resolveBackoffConfig(backoff);
|
||||
this.relayStates = new Map();
|
||||
this.relayWatermarks = new Map();
|
||||
this.relayTimers = new Map();
|
||||
this.seenEventIds = new Set();
|
||||
this.seenEventOrder = [];
|
||||
this.subscriptions = new Map();
|
||||
this.activeFilters = [];
|
||||
this.actorPubkey = "";
|
||||
this.handlers = {
|
||||
onEvent: null,
|
||||
onEose: null,
|
||||
onError: null,
|
||||
};
|
||||
}
|
||||
|
||||
async ensurePool() {
|
||||
if (this.pool) {
|
||||
return this.pool;
|
||||
}
|
||||
|
||||
if (this.poolPromise) {
|
||||
return this.poolPromise;
|
||||
}
|
||||
|
||||
this.poolPromise = (async () => {
|
||||
const tools = await ensureNostrTools();
|
||||
const SimplePool = resolveSimplePoolConstructor(tools);
|
||||
if (typeof SimplePool !== "function") {
|
||||
throw new Error("nostr-tools SimplePool unavailable");
|
||||
}
|
||||
|
||||
const pool = new SimplePool();
|
||||
shimLegacySimplePoolMethods(pool);
|
||||
this.pool = pool;
|
||||
this.poolPromise = null;
|
||||
return pool;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await this.poolPromise;
|
||||
} catch (error) {
|
||||
this.poolPromise = null;
|
||||
this.logger.user.warn("[dmNostrService] Failed to initialize pool.", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getRelayStatus(relayUrl) {
|
||||
const relay = this.relayStates.get(relayUrl);
|
||||
return relay ? { ...relay } : null;
|
||||
}
|
||||
|
||||
listRelayStatuses() {
|
||||
return Array.from(this.relayStates.entries()).map(([url, state]) => ({
|
||||
url,
|
||||
...state,
|
||||
}));
|
||||
}
|
||||
|
||||
async connect({ relays = null } = {}) {
|
||||
if (Array.isArray(relays)) {
|
||||
this.relays = normalizeRelayList(relays);
|
||||
}
|
||||
|
||||
if (!this.relays.length) {
|
||||
this.logger.user.warn("[dmNostrService] No relays configured for DM service.");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ensurePool();
|
||||
|
||||
await Promise.all(
|
||||
this.relays.map(async (relayUrl) => {
|
||||
await this.connectRelay(relayUrl);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async connectRelay(relayUrl) {
|
||||
if (!this.pool) {
|
||||
await this.ensurePool();
|
||||
}
|
||||
|
||||
if (!relayUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const relayState = this.ensureRelayState(relayUrl);
|
||||
relayState.lastConnectAttempt = Date.now();
|
||||
|
||||
if (typeof this.pool.ensureRelay === "function") {
|
||||
try {
|
||||
const relay = await this.pool.ensureRelay(relayUrl);
|
||||
relayState.connected = Boolean(relay);
|
||||
if (relay) {
|
||||
this.attachRelayListeners(relayUrl, relay);
|
||||
this.recordRelaySuccess(relayUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
this.scheduleReconnect(relayUrl, "connect-failed");
|
||||
}
|
||||
} else {
|
||||
relayState.connected = true;
|
||||
this.recordRelaySuccess(relayUrl);
|
||||
}
|
||||
}
|
||||
|
||||
attachRelayListeners(relayUrl, relay) {
|
||||
if (!relay || typeof relay.on !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
relay.on("connect", () => {
|
||||
this.recordRelaySuccess(relayUrl);
|
||||
});
|
||||
relay.on("disconnect", () => {
|
||||
this.recordRelayFailure(relayUrl, new Error("relay-disconnect"));
|
||||
this.scheduleReconnect(relayUrl, "relay-disconnect");
|
||||
});
|
||||
relay.on("error", (error) => {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
this.scheduleReconnect(relayUrl, "relay-error");
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.dev.warn("[dmNostrService] Failed to attach relay listeners.", error);
|
||||
}
|
||||
}
|
||||
|
||||
ensureRelayState(relayUrl) {
|
||||
if (!this.relayStates.has(relayUrl)) {
|
||||
this.relayStates.set(relayUrl, {
|
||||
connected: false,
|
||||
okCount: 0,
|
||||
failCount: 0,
|
||||
lastOkAt: 0,
|
||||
lastFailAt: 0,
|
||||
lastConnectAttempt: 0,
|
||||
backoffMs: 0,
|
||||
attempts: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return this.relayStates.get(relayUrl);
|
||||
}
|
||||
|
||||
recordRelaySuccess(relayUrl) {
|
||||
const relayState = this.ensureRelayState(relayUrl);
|
||||
relayState.connected = true;
|
||||
relayState.okCount += 1;
|
||||
relayState.lastOkAt = Date.now();
|
||||
relayState.backoffMs = 0;
|
||||
relayState.attempts = 0;
|
||||
}
|
||||
|
||||
recordRelayFailure(relayUrl, error) {
|
||||
const relayState = this.ensureRelayState(relayUrl);
|
||||
relayState.connected = false;
|
||||
relayState.failCount += 1;
|
||||
relayState.lastFailAt = Date.now();
|
||||
|
||||
this.logger.dev.warn("[dmNostrService] Relay failure", {
|
||||
relayUrl,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
scheduleReconnect(relayUrl, reason) {
|
||||
const relayState = this.ensureRelayState(relayUrl);
|
||||
relayState.attempts += 1;
|
||||
|
||||
const backoffBase = this.backoffConfig.baseMs * 2 ** (relayState.attempts - 1);
|
||||
const backoffMs = applyJitter(
|
||||
Math.min(backoffBase, this.backoffConfig.maxMs),
|
||||
this.backoffConfig.jitterRatio,
|
||||
);
|
||||
|
||||
relayState.backoffMs = backoffMs;
|
||||
|
||||
if (this.relayTimers.has(relayUrl)) {
|
||||
clearTimeout(this.relayTimers.get(relayUrl));
|
||||
}
|
||||
|
||||
this.logger.dev.info("[dmNostrService] Scheduling relay reconnect", {
|
||||
relayUrl,
|
||||
reason,
|
||||
backoffMs,
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.relayTimers.delete(relayUrl);
|
||||
this.resubscribeRelay(relayUrl);
|
||||
}, backoffMs);
|
||||
|
||||
this.relayTimers.set(relayUrl, timer);
|
||||
}
|
||||
|
||||
buildFilters({ actorPubkey, authors = [], recipients = [] } = {}) {
|
||||
const normalizedActor = normalizePubkey(actorPubkey || this.actorPubkey);
|
||||
const authorSet = new Set(
|
||||
[normalizedActor, ...authors].map(normalizePubkey).filter(Boolean),
|
||||
);
|
||||
const recipientSet = new Set(
|
||||
[normalizedActor, ...recipients].map(normalizePubkey).filter(Boolean),
|
||||
);
|
||||
|
||||
const filters = [];
|
||||
|
||||
if (authorSet.size) {
|
||||
filters.push({
|
||||
kinds: [4],
|
||||
authors: Array.from(authorSet),
|
||||
});
|
||||
}
|
||||
|
||||
if (recipientSet.size) {
|
||||
filters.push({
|
||||
kinds: [4],
|
||||
"#p": Array.from(recipientSet),
|
||||
});
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
updateWatermark(relayUrl, event) {
|
||||
if (!event || typeof event.created_at !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.relayWatermarks.get(relayUrl) || 0;
|
||||
if (event.created_at > current) {
|
||||
this.relayWatermarks.set(relayUrl, event.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
hasSeenEvent(eventId) {
|
||||
if (!eventId) {
|
||||
return false;
|
||||
}
|
||||
return this.seenEventIds.has(eventId);
|
||||
}
|
||||
|
||||
rememberEvent(eventId) {
|
||||
if (!eventId || this.seenEventIds.has(eventId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.seenEventIds.add(eventId);
|
||||
this.seenEventOrder.push(eventId);
|
||||
|
||||
if (this.seenEventOrder.length > MAX_SEEN_EVENT_IDS) {
|
||||
const oldest = this.seenEventOrder.shift();
|
||||
if (oldest) {
|
||||
this.seenEventIds.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shouldProcessEvent(relayUrl, event) {
|
||||
if (!event || typeof event.id !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.hasSeenEvent(event.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const watermark = this.relayWatermarks.get(relayUrl) || 0;
|
||||
if (typeof event.created_at === "number" && event.created_at < watermark) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async subscribe({
|
||||
actorPubkey,
|
||||
authors,
|
||||
recipients,
|
||||
relays,
|
||||
onEvent,
|
||||
onEose,
|
||||
onError,
|
||||
} = {}) {
|
||||
if (typeof onEvent === "function") {
|
||||
this.handlers.onEvent = onEvent;
|
||||
}
|
||||
if (typeof onEose === "function") {
|
||||
this.handlers.onEose = onEose;
|
||||
}
|
||||
if (typeof onError === "function") {
|
||||
this.handlers.onError = onError;
|
||||
}
|
||||
|
||||
if (typeof actorPubkey === "string" && actorPubkey.trim()) {
|
||||
this.actorPubkey = normalizePubkey(actorPubkey);
|
||||
}
|
||||
|
||||
if (Array.isArray(relays)) {
|
||||
this.relays = normalizeRelayList(relays);
|
||||
}
|
||||
|
||||
if (!this.relays.length) {
|
||||
this.logger.user.warn("[dmNostrService] Cannot subscribe without relays.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeFilters = this.buildFilters({
|
||||
actorPubkey: this.actorPubkey,
|
||||
authors,
|
||||
recipients,
|
||||
});
|
||||
|
||||
if (!this.activeFilters.length) {
|
||||
this.logger.user.warn("[dmNostrService] Cannot subscribe without filters.");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.connect();
|
||||
|
||||
this.relays.forEach((relayUrl) => {
|
||||
this.subscribeRelay(relayUrl);
|
||||
});
|
||||
}
|
||||
|
||||
subscribeRelay(relayUrl) {
|
||||
if (!this.pool || !relayUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const since = this.relayWatermarks.get(relayUrl);
|
||||
const filters = this.activeFilters.map((filter) => {
|
||||
if (typeof since === "number" && since > 0) {
|
||||
return {
|
||||
...filter,
|
||||
since: Math.max(0, since - 1),
|
||||
};
|
||||
}
|
||||
return { ...filter };
|
||||
});
|
||||
|
||||
if (this.subscriptions.has(relayUrl)) {
|
||||
this.unsubscribeRelay(relayUrl);
|
||||
}
|
||||
|
||||
let subscription;
|
||||
try {
|
||||
subscription = this.pool.sub([relayUrl], filters);
|
||||
} catch (error) {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
this.scheduleReconnect(relayUrl, "subscribe-failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subscription || typeof subscription.on !== "function") {
|
||||
this.recordRelayFailure(relayUrl, new Error("subscription-unavailable"));
|
||||
this.scheduleReconnect(relayUrl, "subscribe-unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
subscription.on("event", (event) => {
|
||||
if (!this.shouldProcessEvent(relayUrl, event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rememberEvent(event.id);
|
||||
this.updateWatermark(relayUrl, event);
|
||||
this.recordRelaySuccess(relayUrl);
|
||||
|
||||
if (typeof this.handlers.onEvent === "function") {
|
||||
try {
|
||||
this.handlers.onEvent(event, { relayUrl });
|
||||
} catch (error) {
|
||||
this.logger.dev.warn("[dmNostrService] onEvent handler failed.", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
subscription.on("eose", () => {
|
||||
if (typeof this.handlers.onEose === "function") {
|
||||
try {
|
||||
this.handlers.onEose({ relayUrl });
|
||||
} catch (error) {
|
||||
this.logger.dev.warn("[dmNostrService] onEose handler failed.", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
subscription.on("close", () => {
|
||||
this.recordRelayFailure(relayUrl, new Error("subscription-closed"));
|
||||
this.scheduleReconnect(relayUrl, "subscription-closed");
|
||||
});
|
||||
|
||||
this.subscriptions.set(relayUrl, subscription);
|
||||
}
|
||||
|
||||
resubscribeRelay(relayUrl) {
|
||||
if (!relayUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.subscribeRelay(relayUrl);
|
||||
}
|
||||
|
||||
unsubscribeRelay(relayUrl) {
|
||||
const subscription = this.subscriptions.get(relayUrl);
|
||||
if (subscription && typeof subscription.unsub === "function") {
|
||||
try {
|
||||
subscription.unsub();
|
||||
} catch (error) {
|
||||
this.logger.dev.warn("[dmNostrService] Failed to unsubscribe relay.", error);
|
||||
}
|
||||
}
|
||||
this.subscriptions.delete(relayUrl);
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const relayUrl of this.subscriptions.keys()) {
|
||||
this.unsubscribeRelay(relayUrl);
|
||||
}
|
||||
|
||||
for (const timer of this.relayTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
this.relayTimers.clear();
|
||||
}
|
||||
|
||||
async publishSignedEvent(signedEvent, { relays = null } = {}) {
|
||||
if (!signedEvent || typeof signedEvent !== "object") {
|
||||
throw new Error("signed-event-required");
|
||||
}
|
||||
|
||||
if (signedEvent.kind !== 4) {
|
||||
throw new Error("invalid-event-kind");
|
||||
}
|
||||
|
||||
const relayList = normalizeRelayList(
|
||||
Array.isArray(relays) && relays.length
|
||||
? relays
|
||||
: this.preferredRelays.length
|
||||
? this.preferredRelays
|
||||
: this.relays,
|
||||
);
|
||||
|
||||
if (!relayList.length) {
|
||||
throw new Error("no-relays-configured");
|
||||
}
|
||||
|
||||
await this.ensurePool();
|
||||
|
||||
const results = await Promise.all(
|
||||
relayList.map(async (relayUrl) => {
|
||||
let result;
|
||||
try {
|
||||
result = await publishEventToRelay(this.pool, relayUrl, signedEvent);
|
||||
} catch (error) {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
return { success: false, error, relayUrl };
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
this.recordRelaySuccess(relayUrl);
|
||||
} else {
|
||||
this.recordRelayFailure(
|
||||
relayUrl,
|
||||
result?.error || new Error("publish-failed"),
|
||||
);
|
||||
}
|
||||
|
||||
return { ...result, relayUrl };
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export default DmNostrService;
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
getConversation,
|
||||
listConversations,
|
||||
updateConversationFromMessage,
|
||||
updateConversationOpenState,
|
||||
} from "../storage/dmDb.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
|
||||
function sanitizeTimestamp(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
function normalizeConversationState(conversation) {
|
||||
if (!conversation || typeof conversation !== "object") {
|
||||
return {
|
||||
conversation_id: "",
|
||||
opened_until: 0,
|
||||
downloaded_until: 0,
|
||||
unseen_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
conversation_id: conversation.conversation_id || "",
|
||||
opened_until: sanitizeTimestamp(conversation.opened_until),
|
||||
downloaded_until: sanitizeTimestamp(conversation.downloaded_until),
|
||||
last_message_at: sanitizeTimestamp(conversation.last_message_at),
|
||||
unseen_count: Number.isFinite(Number(conversation.unseen_count))
|
||||
? Number(conversation.unseen_count)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export class DmNotificationManager {
|
||||
constructor({ logger } = {}) {
|
||||
this.logger = logger || userLogger;
|
||||
this.conversationCache = new Map();
|
||||
this.focusedConversationId = "";
|
||||
}
|
||||
|
||||
getFocusedConversationId() {
|
||||
return this.focusedConversationId;
|
||||
}
|
||||
|
||||
setFocusedConversation(conversationId, isFocused = true) {
|
||||
if (typeof conversationId !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = conversationId.trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFocused) {
|
||||
this.focusedConversationId = normalized;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.focusedConversationId === normalized) {
|
||||
this.focusedConversationId = "";
|
||||
}
|
||||
}
|
||||
|
||||
async ensureConversationState(conversationId) {
|
||||
if (typeof conversationId !== "string" || !conversationId.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = conversationId.trim();
|
||||
if (this.conversationCache.has(normalized)) {
|
||||
return this.conversationCache.get(normalized);
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = await getConversation(normalized);
|
||||
const normalizedState = normalizeConversationState({
|
||||
conversation_id: normalized,
|
||||
...stored,
|
||||
});
|
||||
this.conversationCache.set(normalized, normalizedState);
|
||||
return normalizedState;
|
||||
} catch (error) {
|
||||
this.logger.warn("[dmNotifications] Failed to hydrate conversation", error);
|
||||
const fallback = normalizeConversationState({ conversation_id: normalized });
|
||||
this.conversationCache.set(normalized, fallback);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async recordMessage({ record, preview = "", direction = "" } = {}) {
|
||||
if (!record || typeof record !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const conversationId =
|
||||
typeof record.conversation_id === "string"
|
||||
? record.conversation_id.trim()
|
||||
: "";
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createdAt = sanitizeTimestamp(record.created_at);
|
||||
const conversationState = await this.ensureConversationState(conversationId);
|
||||
if (!conversationState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isIncoming = direction === "incoming";
|
||||
const isFocused = this.focusedConversationId === conversationId;
|
||||
const shouldNotify =
|
||||
isIncoming && !isFocused && createdAt > conversationState.opened_until;
|
||||
const unseenDelta =
|
||||
isIncoming && createdAt > conversationState.opened_until ? 1 : 0;
|
||||
|
||||
let updated = null;
|
||||
try {
|
||||
updated = await updateConversationFromMessage(record, {
|
||||
preview,
|
||||
unseenDelta,
|
||||
downloadedUntil: createdAt,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn("[dmNotifications] Failed to update conversation", error);
|
||||
}
|
||||
|
||||
const normalizedUpdate = normalizeConversationState({
|
||||
conversation_id: conversationId,
|
||||
...updated,
|
||||
opened_until: conversationState.opened_until,
|
||||
});
|
||||
|
||||
this.conversationCache.set(conversationId, normalizedUpdate);
|
||||
|
||||
return {
|
||||
conversation: normalizedUpdate,
|
||||
shouldNotify,
|
||||
isFocused,
|
||||
};
|
||||
}
|
||||
|
||||
async acknowledgeRenderedMessages({ conversationId, renderedUntil } = {}) {
|
||||
if (typeof conversationId !== "string" || !conversationId.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = conversationId.trim();
|
||||
const conversationState = await this.ensureConversationState(normalized);
|
||||
if (!conversationState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextOpenedUntil = Math.max(
|
||||
conversationState.opened_until || 0,
|
||||
sanitizeTimestamp(renderedUntil),
|
||||
);
|
||||
|
||||
let updated = null;
|
||||
try {
|
||||
updated = await updateConversationOpenState(normalized, {
|
||||
openedUntil: nextOpenedUntil,
|
||||
unseenCount: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn("[dmNotifications] Failed to update opened_until", error);
|
||||
}
|
||||
|
||||
const normalizedUpdate = normalizeConversationState({
|
||||
conversation_id: normalized,
|
||||
...conversationState,
|
||||
...updated,
|
||||
opened_until: nextOpenedUntil,
|
||||
unseen_count: 0,
|
||||
});
|
||||
|
||||
this.conversationCache.set(normalized, normalizedUpdate);
|
||||
return normalizedUpdate;
|
||||
}
|
||||
|
||||
async listConversationSummaries() {
|
||||
try {
|
||||
const stored = await listConversations();
|
||||
const summaries = stored.map((conversation) =>
|
||||
normalizeConversationState(conversation),
|
||||
);
|
||||
|
||||
for (const summary of summaries) {
|
||||
if (summary.conversation_id) {
|
||||
this.conversationCache.set(summary.conversation_id, summary);
|
||||
}
|
||||
}
|
||||
|
||||
return summaries;
|
||||
} catch (error) {
|
||||
this.logger.warn("[dmNotifications] Failed to list conversations", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getUnseenCount(conversationId) {
|
||||
if (typeof conversationId !== "string" || !conversationId.trim()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cached = this.conversationCache.get(conversationId.trim());
|
||||
return cached && Number.isFinite(Number(cached.unseen_count))
|
||||
? Number(cached.unseen_count)
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
const DB_NAME = "bitvid-link-previews";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "previews";
|
||||
const DEFAULT_TTL_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
const normalizeUrl = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const resolveMetaContent = (doc, selector) => {
|
||||
if (!doc) {
|
||||
return "";
|
||||
}
|
||||
const element = doc.querySelector(selector);
|
||||
if (!element) {
|
||||
return "";
|
||||
}
|
||||
const content = element.getAttribute("content") || "";
|
||||
return content.trim();
|
||||
};
|
||||
|
||||
const resolveFirstMetaContent = (doc, selectors) => {
|
||||
if (!doc || !Array.isArray(selectors)) {
|
||||
return "";
|
||||
}
|
||||
for (const selector of selectors) {
|
||||
const value = resolveMetaContent(doc, selector);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const resolveAbsoluteUrl = (candidate, baseUrl) => {
|
||||
if (!candidate) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const resolved = new URL(candidate, baseUrl);
|
||||
return resolved.toString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export class LinkPreviewService {
|
||||
constructor({ ttlMs = DEFAULT_TTL_MS } = {}) {
|
||||
this.ttlMs = Number.isFinite(ttlMs) ? Math.max(0, ttlMs) : DEFAULT_TTL_MS;
|
||||
this.db = null;
|
||||
this.dbPromise = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.db) {
|
||||
return this.db;
|
||||
}
|
||||
if (this.dbPromise) {
|
||||
return this.dbPromise;
|
||||
}
|
||||
|
||||
this.dbPromise = new Promise((resolve, reject) => {
|
||||
if (typeof indexedDB === "undefined") {
|
||||
reject(new Error("IndexedDB is not supported."));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: "url" });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
this.db = event.target.result;
|
||||
resolve(this.db);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error("IndexedDB open failed."));
|
||||
};
|
||||
});
|
||||
|
||||
return this.dbPromise;
|
||||
}
|
||||
|
||||
async withStore(mode, callback) {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, mode);
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = callback(store);
|
||||
|
||||
tx.oncomplete = () => resolve(request?.result ?? null);
|
||||
tx.onerror = () => reject(tx.error || new Error("IndexedDB transaction failed."));
|
||||
tx.onabort = () => reject(tx.error || new Error("IndexedDB transaction aborted."));
|
||||
});
|
||||
}
|
||||
|
||||
async getCachedPreview(url) {
|
||||
const normalized = normalizeUrl(url);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const entry = await this.withStore("readonly", (store) => store.get(normalized));
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
const expiresAt = Number(entry.expiresAt) || 0;
|
||||
if (expiresAt && expiresAt < Date.now()) {
|
||||
void this.deletePreview(normalized);
|
||||
return null;
|
||||
}
|
||||
return entry.data || null;
|
||||
} catch (error) {
|
||||
userLogger.warn("[LinkPreviewService] Failed to read cache.", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async deletePreview(url) {
|
||||
const normalized = normalizeUrl(url);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.withStore("readwrite", (store) => store.delete(normalized));
|
||||
} catch (error) {
|
||||
devLogger.warn("[LinkPreviewService] Failed to clear cached preview.", error);
|
||||
}
|
||||
}
|
||||
|
||||
async setCachedPreview(url, data) {
|
||||
const normalized = normalizeUrl(url);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const payload = {
|
||||
url: normalized,
|
||||
fetchedAt: now,
|
||||
expiresAt: now + this.ttlMs,
|
||||
data,
|
||||
};
|
||||
try {
|
||||
await this.withStore("readwrite", (store) => store.put(payload));
|
||||
} catch (error) {
|
||||
userLogger.warn("[LinkPreviewService] Failed to cache preview.", error);
|
||||
}
|
||||
}
|
||||
|
||||
async getPreview(url, { signal } = {}) {
|
||||
const normalized = normalizeUrl(url);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const cached = await this.getCachedPreview(normalized);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const preview = await this.fetchPreview(normalized, { signal });
|
||||
if (preview) {
|
||||
await this.setCachedPreview(normalized, preview);
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
async fetchPreview(url, { signal } = {}) {
|
||||
const normalized = normalizeUrl(url);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(normalized, {
|
||||
mode: "cors",
|
||||
credentials: "omit",
|
||||
redirect: "follow",
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
return null;
|
||||
}
|
||||
devLogger.warn("[LinkPreviewService] Preview fetch failed.", error);
|
||||
return null;
|
||||
}
|
||||
if (!response || !response.ok) {
|
||||
return null;
|
||||
}
|
||||
let html = "";
|
||||
try {
|
||||
html = await response.text();
|
||||
} catch (error) {
|
||||
devLogger.warn("[LinkPreviewService] Failed to read preview response.", error);
|
||||
return null;
|
||||
}
|
||||
return this.parsePreview(html, normalized);
|
||||
}
|
||||
|
||||
parsePreview(html, url) {
|
||||
if (!html) {
|
||||
return null;
|
||||
}
|
||||
let doc;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(html, "text/html");
|
||||
} catch (error) {
|
||||
devLogger.warn("[LinkPreviewService] Failed to parse preview HTML.", error);
|
||||
return null;
|
||||
}
|
||||
const title = resolveFirstMetaContent(doc, [
|
||||
'meta[property="og:title"]',
|
||||
'meta[name="twitter:title"]',
|
||||
'meta[name="title"]',
|
||||
]) || doc.title?.trim() || "";
|
||||
const description = resolveFirstMetaContent(doc, [
|
||||
'meta[property="og:description"]',
|
||||
'meta[name="twitter:description"]',
|
||||
'meta[name="description"]',
|
||||
]);
|
||||
const image = resolveFirstMetaContent(doc, [
|
||||
'meta[property="og:image"]',
|
||||
'meta[name="twitter:image"]',
|
||||
'meta[name="twitter:image:src"]',
|
||||
]);
|
||||
const siteName = resolveFirstMetaContent(doc, [
|
||||
'meta[property="og:site_name"]',
|
||||
]);
|
||||
const resolvedImage = resolveAbsoluteUrl(image, url);
|
||||
|
||||
return {
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image: resolvedImage,
|
||||
siteName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
// js/services/moderationDecorator.js
|
||||
|
||||
import { getTrustedMuteHideThreshold, getTrustedSpamHideThreshold, DEFAULT_BLUR_THRESHOLD, DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD } from "../constants.js";
|
||||
import { formatShortNpub } from "../utils/formatters.js";
|
||||
import { getModerationOverride, getModerationSettings, getDefaultModerationSettings } from "../state/cache.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
|
||||
/**
|
||||
* Service class that handles decorating video objects with moderation state.
|
||||
* Extracts complex logic from Application class.
|
||||
*/
|
||||
export class ModerationDecorator {
|
||||
constructor(services = {}) {
|
||||
this.services = {
|
||||
getProfileCacheEntry: services.getProfileCacheEntry || (() => null),
|
||||
};
|
||||
this.moderationSettings = this.normalizeModerationSettings(getModerationSettings());
|
||||
}
|
||||
|
||||
deriveModerationReportType(summary) {
|
||||
if (!summary || typeof summary !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const types = summary.types && typeof summary.types === "object" ? summary.types : null;
|
||||
if (!types) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let bestType = "";
|
||||
let bestScore = -1;
|
||||
for (const [type, stats] of Object.entries(types)) {
|
||||
if (!stats || typeof stats !== "object") {
|
||||
continue;
|
||||
}
|
||||
const trusted = Number.isFinite(stats.trusted) ? Math.floor(stats.trusted) : 0;
|
||||
if (trusted > bestScore) {
|
||||
bestScore = trusted;
|
||||
bestType = typeof type === "string" ? type : bestType;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof bestType === "string" ? bestType.toLowerCase() : "";
|
||||
}
|
||||
|
||||
deriveModerationTrustedCount(summary, reportType) {
|
||||
if (!summary || typeof summary !== "object") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalizedType = typeof reportType === "string" ? reportType.toLowerCase() : "";
|
||||
const types = summary.types && typeof summary.types === "object" ? summary.types : {};
|
||||
|
||||
if (normalizedType && types[normalizedType]) {
|
||||
const entry = types[normalizedType];
|
||||
if (entry && Number.isFinite(entry.trusted)) {
|
||||
return Math.max(0, Math.floor(entry.trusted));
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isFinite(summary.totalTrusted)) {
|
||||
return Math.max(0, Math.floor(summary.totalTrusted));
|
||||
}
|
||||
|
||||
for (const stats of Object.values(types)) {
|
||||
if (stats && Number.isFinite(stats.trusted)) {
|
||||
return Math.max(0, Math.floor(stats.trusted));
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
getReporterDisplayName(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const cachedProfile = this.services.getProfileCacheEntry(trimmed);
|
||||
const cachedName = cachedProfile?.profile?.name;
|
||||
if (typeof cachedName === "string" && cachedName.trim()) {
|
||||
return cachedName.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.NostrTools?.nip19?.npubEncode) {
|
||||
const encoded = window.NostrTools.nip19.npubEncode(trimmed);
|
||||
if (encoded && typeof encoded === "string") {
|
||||
return formatShortNpub(encoded);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn("[Application] Failed to encode reporter npub", error);
|
||||
}
|
||||
|
||||
return formatShortNpub(trimmed);
|
||||
}
|
||||
|
||||
normalizeModerationSettings(settings = null) {
|
||||
const defaults = this.defaultModerationSettings || getDefaultModerationSettings();
|
||||
const sanitizeThresholdMap = (value, fallback = {}) => {
|
||||
const fallbackMap = fallback && typeof fallback === "object" ? fallback : {};
|
||||
if (!value || typeof value !== "object") {
|
||||
return { ...fallbackMap };
|
||||
}
|
||||
const sanitized = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (typeof key !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalizedKey = key.trim().toLowerCase();
|
||||
if (!normalizedKey) {
|
||||
continue;
|
||||
}
|
||||
const numeric = Number(entry);
|
||||
if (Number.isFinite(numeric)) {
|
||||
sanitized[normalizedKey] = Math.max(0, Math.floor(numeric));
|
||||
}
|
||||
}
|
||||
return { ...fallbackMap, ...sanitized };
|
||||
};
|
||||
const defaultBlur = Number.isFinite(defaults?.blurThreshold)
|
||||
? Math.max(0, Math.floor(defaults.blurThreshold))
|
||||
: DEFAULT_BLUR_THRESHOLD;
|
||||
const defaultAutoplay = Number.isFinite(defaults?.autoplayBlockThreshold)
|
||||
? Math.max(0, Math.floor(defaults.autoplayBlockThreshold))
|
||||
: DEFAULT_AUTOPLAY_BLOCK_THRESHOLD;
|
||||
|
||||
const runtimeMuteSource = getTrustedMuteHideThreshold();
|
||||
const runtimeTrustedMute = Number.isFinite(runtimeMuteSource)
|
||||
? Math.max(0, Math.floor(runtimeMuteSource))
|
||||
: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD;
|
||||
const defaultTrustedMuteHide = Number.isFinite(
|
||||
defaults?.trustedMuteHideThreshold,
|
||||
)
|
||||
? Math.max(0, Math.floor(defaults.trustedMuteHideThreshold))
|
||||
: runtimeTrustedMute;
|
||||
|
||||
const runtimeSpamSource = getTrustedSpamHideThreshold();
|
||||
const runtimeTrustedSpam = Number.isFinite(runtimeSpamSource)
|
||||
? Math.max(0, Math.floor(runtimeSpamSource))
|
||||
: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD;
|
||||
const defaultTrustedSpamHide = Number.isFinite(
|
||||
defaults?.trustedSpamHideThreshold,
|
||||
)
|
||||
? Math.max(0, Math.floor(defaults.trustedSpamHideThreshold))
|
||||
: runtimeTrustedSpam;
|
||||
|
||||
const defaultTrustedMuteHideThresholds = sanitizeThresholdMap(
|
||||
defaults?.trustedMuteHideThresholds,
|
||||
{},
|
||||
);
|
||||
|
||||
const blurSource = Number.isFinite(settings?.blurThreshold)
|
||||
? Math.max(0, Math.floor(settings.blurThreshold))
|
||||
: defaultBlur;
|
||||
const autoplaySource = Number.isFinite(settings?.autoplayBlockThreshold)
|
||||
? Math.max(0, Math.floor(settings.autoplayBlockThreshold))
|
||||
: defaultAutoplay;
|
||||
const muteHideSource = Number.isFinite(settings?.trustedMuteHideThreshold)
|
||||
? Math.max(0, Math.floor(settings.trustedMuteHideThreshold))
|
||||
: defaultTrustedMuteHide;
|
||||
const spamHideSource = Number.isFinite(settings?.trustedSpamHideThreshold)
|
||||
? Math.max(0, Math.floor(settings.trustedSpamHideThreshold))
|
||||
: defaultTrustedSpamHide;
|
||||
const muteHideThresholdsSource = sanitizeThresholdMap(
|
||||
settings?.trustedMuteHideThresholds,
|
||||
defaultTrustedMuteHideThresholds,
|
||||
);
|
||||
|
||||
return {
|
||||
blurThreshold: blurSource,
|
||||
autoplayBlockThreshold: autoplaySource,
|
||||
trustedMuteHideThreshold: muteHideSource,
|
||||
trustedMuteHideThresholds: muteHideThresholdsSource,
|
||||
trustedSpamHideThreshold: spamHideSource,
|
||||
};
|
||||
}
|
||||
|
||||
getActiveModerationThresholds() {
|
||||
this.moderationSettings = this.normalizeModerationSettings(this.moderationSettings);
|
||||
return { ...this.moderationSettings };
|
||||
}
|
||||
|
||||
updateSettings(settings) {
|
||||
this.moderationSettings = this.normalizeModerationSettings(settings);
|
||||
}
|
||||
|
||||
decorateVideo(video, feedContext = {}) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return video;
|
||||
}
|
||||
|
||||
const existingModeration =
|
||||
video.moderation && typeof video.moderation === "object"
|
||||
? { ...video.moderation }
|
||||
: {};
|
||||
|
||||
const summary =
|
||||
existingModeration.summary && typeof existingModeration.summary === "object"
|
||||
? existingModeration.summary
|
||||
: null;
|
||||
|
||||
const rawReportType =
|
||||
typeof existingModeration.reportType === "string" &&
|
||||
existingModeration.reportType.trim()
|
||||
? existingModeration.reportType.trim().toLowerCase()
|
||||
: "";
|
||||
|
||||
const reportType = rawReportType || this.deriveModerationReportType(summary) || "";
|
||||
|
||||
const sanitizedReporters = Array.isArray(existingModeration.trustedReporters)
|
||||
? existingModeration.trustedReporters
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
const pubkey =
|
||||
typeof entry.pubkey === "string" ? entry.pubkey.trim().toLowerCase() : "";
|
||||
if (!pubkey) {
|
||||
return null;
|
||||
}
|
||||
const latest = Number.isFinite(entry.latest)
|
||||
? Math.floor(entry.latest)
|
||||
: 0;
|
||||
return { pubkey, latest };
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const reporterPubkeys = sanitizedReporters.map((entry) => entry.pubkey);
|
||||
|
||||
const rawTrustedMuters = Array.isArray(existingModeration.trustedMuters)
|
||||
? existingModeration.trustedMuters
|
||||
.map((value) => (typeof value === "string" ? value.trim().toLowerCase() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const trustedMuteCount = Number.isFinite(existingModeration.trustedMuteCount)
|
||||
? Math.max(0, Math.floor(existingModeration.trustedMuteCount))
|
||||
: rawTrustedMuters.length;
|
||||
|
||||
const trustedMuted = existingModeration.trustedMuted === true || trustedMuteCount > 0;
|
||||
|
||||
const reporterDisplayNames = [];
|
||||
const seenNames = new Set();
|
||||
for (const reporterPubkey of reporterPubkeys) {
|
||||
const name = this.getReporterDisplayName(reporterPubkey);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) {
|
||||
continue;
|
||||
}
|
||||
const key = normalizedName.toLowerCase();
|
||||
if (seenNames.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenNames.add(key);
|
||||
reporterDisplayNames.push(normalizedName);
|
||||
}
|
||||
|
||||
const trustedMuterDisplayNames = [];
|
||||
if (trustedMuted) {
|
||||
const seenMuteNames = new Set();
|
||||
for (const muterPubkey of rawTrustedMuters) {
|
||||
const name = this.getReporterDisplayName(muterPubkey);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) {
|
||||
continue;
|
||||
}
|
||||
const key = normalizedName.toLowerCase();
|
||||
if (seenMuteNames.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenMuteNames.add(key);
|
||||
trustedMuterDisplayNames.push(normalizedName);
|
||||
}
|
||||
}
|
||||
|
||||
const trustedCount = Number.isFinite(existingModeration.trustedCount)
|
||||
? Math.max(0, Math.floor(existingModeration.trustedCount))
|
||||
: this.deriveModerationTrustedCount(summary, reportType);
|
||||
|
||||
const viewerMuted = existingModeration.viewerMuted === true;
|
||||
const existingBlockAutoplay = existingModeration.blockAutoplay === true;
|
||||
const existingBlurThumbnail = existingModeration.blurThumbnail === true;
|
||||
const existingBlurReason =
|
||||
typeof existingModeration.blurReason === "string"
|
||||
? existingModeration.blurReason.trim()
|
||||
: "";
|
||||
|
||||
const thresholds = this.getActiveModerationThresholds();
|
||||
const computedBlockAutoplayBase =
|
||||
trustedCount >= thresholds.autoplayBlockThreshold || trustedMuted;
|
||||
const computedBlockAutoplay =
|
||||
computedBlockAutoplayBase || viewerMuted || existingBlockAutoplay;
|
||||
|
||||
const blurFromReports = trustedCount >= thresholds.blurThreshold;
|
||||
let computedBlurThumbnail =
|
||||
blurFromReports || trustedMuted || viewerMuted || existingBlurThumbnail;
|
||||
let computedBlurReason = "";
|
||||
|
||||
if (blurFromReports) {
|
||||
computedBlurReason = "trusted-report";
|
||||
} else if (trustedMuted) {
|
||||
computedBlurReason = "trusted-mute";
|
||||
} else if (viewerMuted) {
|
||||
computedBlurReason = "viewer-mute";
|
||||
} else if (existingBlurThumbnail && existingBlurReason) {
|
||||
computedBlurReason = existingBlurReason;
|
||||
}
|
||||
|
||||
const muteHideThreshold = Number.isFinite(thresholds.trustedMuteHideThreshold)
|
||||
? Math.max(0, Math.floor(thresholds.trustedMuteHideThreshold))
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const reportHideThreshold = Number.isFinite(thresholds.trustedSpamHideThreshold)
|
||||
? Math.max(0, Math.floor(thresholds.trustedSpamHideThreshold))
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
const existingHideReason =
|
||||
typeof existingModeration.hideReason === "string"
|
||||
? existingModeration.hideReason.trim() : "";
|
||||
const existingHideBypass =
|
||||
typeof existingModeration.hideBypass === "string"
|
||||
? existingModeration.hideBypass.trim() : "";
|
||||
const existingHideCounts =
|
||||
existingModeration.hideCounts && typeof existingModeration.hideCounts === "object"
|
||||
? existingModeration.hideCounts
|
||||
: null;
|
||||
|
||||
let hideReason = "";
|
||||
let hideTriggered = false;
|
||||
|
||||
if (trustedMuted && trustedMuteCount >= muteHideThreshold) {
|
||||
hideReason = "trusted-mute-hide";
|
||||
hideTriggered = true;
|
||||
} else if (trustedCount >= reportHideThreshold) {
|
||||
hideReason = "trusted-report-hide";
|
||||
hideTriggered = true;
|
||||
} else if (existingHideReason && existingHideCounts) {
|
||||
hideReason = existingHideReason;
|
||||
hideTriggered = true;
|
||||
}
|
||||
|
||||
if (!computedBlurThumbnail && (viewerMuted || trustedMuted || hideTriggered)) {
|
||||
computedBlurThumbnail = true;
|
||||
if (hideTriggered) {
|
||||
computedBlurReason = hideReason || "trusted-hide";
|
||||
} else if (viewerMuted) {
|
||||
computedBlurReason = "viewer-mute";
|
||||
} else if (trustedMuted) {
|
||||
computedBlurReason = "trusted-mute";
|
||||
}
|
||||
} else if (computedBlurThumbnail) {
|
||||
if (hideTriggered) {
|
||||
computedBlurReason = hideReason || "trusted-hide";
|
||||
} else if (viewerMuted && !blurFromReports && !trustedMuted) {
|
||||
computedBlurReason = "viewer-mute";
|
||||
} else if (trustedMuted && !blurFromReports) {
|
||||
computedBlurReason = "trusted-mute";
|
||||
} else if (!computedBlurReason && blurFromReports) {
|
||||
computedBlurReason = "trusted-report";
|
||||
}
|
||||
}
|
||||
|
||||
if (computedBlurThumbnail && !computedBlurReason && existingBlurReason) {
|
||||
computedBlurReason = existingBlurReason;
|
||||
}
|
||||
|
||||
const hideCounts = hideTriggered
|
||||
? {
|
||||
trustedMuteCount,
|
||||
trustedReportCount: trustedCount,
|
||||
}
|
||||
: null;
|
||||
|
||||
const FEED_HIDE_BYPASS_NAMES = new Set(["home", "recent"]);
|
||||
const normalizedFeedName =
|
||||
typeof feedContext?.feedName === "string" ? feedContext.feedName.trim().toLowerCase() : "";
|
||||
const normalizedFeedVariant =
|
||||
typeof feedContext?.feedVariant === "string"
|
||||
? feedContext.feedVariant.trim().toLowerCase()
|
||||
: "";
|
||||
const feedPolicyBypass =
|
||||
(normalizedFeedName && FEED_HIDE_BYPASS_NAMES.has(normalizedFeedName)) ||
|
||||
(normalizedFeedVariant && FEED_HIDE_BYPASS_NAMES.has(normalizedFeedVariant));
|
||||
|
||||
let hideBypass = hideTriggered ? existingHideBypass : "";
|
||||
|
||||
if (hideTriggered && !hideBypass && feedPolicyBypass) {
|
||||
hideBypass = "feed-policy";
|
||||
}
|
||||
|
||||
const computedHidden = hideTriggered && !hideBypass;
|
||||
|
||||
const overrideEntry = getModerationOverride({
|
||||
eventId: video.id,
|
||||
authorPubkey: video.pubkey || video.author?.pubkey || "",
|
||||
});
|
||||
const overrideActive = overrideEntry?.showAnyway === true;
|
||||
const overrideUpdatedAt = Number.isFinite(overrideEntry?.updatedAt)
|
||||
? Math.floor(overrideEntry.updatedAt)
|
||||
: Date.now();
|
||||
|
||||
const originalHideCounts = hideCounts
|
||||
? {
|
||||
trustedMuteCount: Math.max(0, Math.floor(hideCounts.trustedMuteCount)),
|
||||
trustedReportCount: Math.max(0, Math.floor(hideCounts.trustedReportCount)),
|
||||
}
|
||||
: null;
|
||||
|
||||
const originalState = {
|
||||
blockAutoplay: computedBlockAutoplay,
|
||||
blurThumbnail: computedBlurThumbnail,
|
||||
hidden: computedHidden,
|
||||
hideReason: hideTriggered ? hideReason : "",
|
||||
hideCounts: originalHideCounts,
|
||||
hideBypass,
|
||||
hideTriggered,
|
||||
blurReason: computedBlurThumbnail ? computedBlurReason : "",
|
||||
};
|
||||
|
||||
const decoratedModeration = {
|
||||
...existingModeration,
|
||||
reportType,
|
||||
trustedCount,
|
||||
trustedReporters: sanitizedReporters,
|
||||
reporterPubkeys,
|
||||
reporterDisplayNames,
|
||||
trustedMuted,
|
||||
trustedMuters: rawTrustedMuters,
|
||||
trustedMuteCount,
|
||||
trustedMuterDisplayNames,
|
||||
blurReason: computedBlurThumbnail ? computedBlurReason : "",
|
||||
original: {
|
||||
blockAutoplay: originalState.blockAutoplay,
|
||||
blurThumbnail: originalState.blurThumbnail,
|
||||
hidden: originalState.hidden,
|
||||
hideReason: originalState.hideReason,
|
||||
hideCounts: originalState.hideCounts,
|
||||
hideBypass: originalState.hideBypass,
|
||||
hideTriggered: originalState.hideTriggered,
|
||||
blurReason: originalState.blurReason,
|
||||
},
|
||||
};
|
||||
|
||||
if (!computedBlurThumbnail && decoratedModeration.blurReason) {
|
||||
delete decoratedModeration.blurReason;
|
||||
}
|
||||
|
||||
if (overrideActive) {
|
||||
decoratedModeration.blockAutoplay = false;
|
||||
decoratedModeration.blurThumbnail = false;
|
||||
decoratedModeration.hidden = false;
|
||||
if (decoratedModeration.hideReason) {
|
||||
delete decoratedModeration.hideReason;
|
||||
}
|
||||
if (decoratedModeration.hideCounts) {
|
||||
delete decoratedModeration.hideCounts;
|
||||
}
|
||||
if (decoratedModeration.hideBypass) {
|
||||
delete decoratedModeration.hideBypass;
|
||||
}
|
||||
decoratedModeration.viewerOverride = {
|
||||
showAnyway: true,
|
||||
updatedAt: overrideUpdatedAt,
|
||||
};
|
||||
} else {
|
||||
decoratedModeration.blockAutoplay = originalState.blockAutoplay;
|
||||
decoratedModeration.blurThumbnail = originalState.blurThumbnail;
|
||||
decoratedModeration.hidden = originalState.hidden;
|
||||
if (originalState.hideReason) {
|
||||
decoratedModeration.hideReason = originalState.hideReason;
|
||||
} else if (decoratedModeration.hideReason) {
|
||||
delete decoratedModeration.hideReason;
|
||||
}
|
||||
if (originalState.hideCounts) {
|
||||
decoratedModeration.hideCounts = { ...originalState.hideCounts };
|
||||
} else if (decoratedModeration.hideCounts) {
|
||||
delete decoratedModeration.hideCounts;
|
||||
}
|
||||
if (originalState.hideBypass) {
|
||||
decoratedModeration.hideBypass = originalState.hideBypass;
|
||||
} else if (decoratedModeration.hideBypass) {
|
||||
delete decoratedModeration.hideBypass;
|
||||
}
|
||||
if (decoratedModeration.viewerOverride) {
|
||||
delete decoratedModeration.viewerOverride;
|
||||
}
|
||||
}
|
||||
|
||||
video.moderation = decoratedModeration;
|
||||
return video;
|
||||
}
|
||||
}
|
||||
|
||||
export default ModerationDecorator;
|
||||
+351
-6
@@ -2,13 +2,22 @@ import { nostrClient } from "../nostrClientFacade.js";
|
||||
import { convertEventToVideo } from "../nostr/index.js";
|
||||
import { accessControl } from "../accessControl.js";
|
||||
import { ALLOW_NSFW_CONTENT } from "../config.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import { LRUCache } from "../utils/lruCache.js";
|
||||
import { describeAttachment, extractAttachmentsFromMessage } from "../attachments/attachmentUtils.js";
|
||||
import moderationService from "./moderationService.js";
|
||||
import {
|
||||
DM_RELAY_WARNING_FALLBACK,
|
||||
resolveDmRelaySelection,
|
||||
} from "./dmNostrService.js";
|
||||
import {
|
||||
loadDirectMessageSnapshot,
|
||||
saveDirectMessageSnapshot,
|
||||
clearDirectMessageSnapshot,
|
||||
} from "../directMessagesStore.js";
|
||||
import { writeMessages } from "../storage/dmDb.js";
|
||||
import { DmNotificationManager } from "./dmNotificationManager.js";
|
||||
import { getDmDecryptWorkerQueueSize } from "../nostr/dmDecryptWorkerClient.js";
|
||||
import {
|
||||
getVideosMap as getStoredVideosMap,
|
||||
setVideosMap as setStoredVideosMap,
|
||||
@@ -19,6 +28,9 @@ import {
|
||||
const VIDEO_KIND = 30078;
|
||||
const STORAGE_KEY_FILTERED_VIDEOS = "bitvid:filtered-videos:v1";
|
||||
const MAX_PERSISTED_VIDEOS = 100;
|
||||
const DM_DEFAULT_PAGE_LIMIT = 200;
|
||||
const DM_INITIAL_DECRYPT_LIMIT = 120;
|
||||
const DM_PREVIEW_CACHE_LIMIT = 500;
|
||||
|
||||
function loadPersistedFilteredVideos() {
|
||||
if (
|
||||
@@ -200,7 +212,65 @@ function extractSnapshotPreview(message) {
|
||||
}
|
||||
|
||||
const selected = candidates.find((candidate) => candidate && candidate.trim());
|
||||
return sanitizeSnapshotPreview(selected || "");
|
||||
if (selected) {
|
||||
return sanitizeSnapshotPreview(selected);
|
||||
}
|
||||
|
||||
const attachments = extractAttachmentsFromMessage(message);
|
||||
if (attachments.length) {
|
||||
return sanitizeSnapshotPreview(describeAttachment(attachments[0]));
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolvePreviewCacheKey(message) {
|
||||
if (!message || typeof message !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const eventId = typeof message?.event?.id === "string" ? message.event.id : "";
|
||||
if (eventId) {
|
||||
return eventId;
|
||||
}
|
||||
|
||||
const remote =
|
||||
typeof message?.snapshot?.remotePubkey === "string"
|
||||
? message.snapshot.remotePubkey.trim()
|
||||
: typeof message?.remotePubkey === "string"
|
||||
? message.remotePubkey.trim()
|
||||
: "";
|
||||
const timestamp = Number.isFinite(message?.timestamp)
|
||||
? Math.floor(message.timestamp)
|
||||
: Number.isFinite(message?.message?.created_at)
|
||||
? Math.floor(message.message.created_at)
|
||||
: 0;
|
||||
|
||||
if (remote && timestamp) {
|
||||
return `snapshot:${remote}:${timestamp}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function toPositiveInteger(value, fallback) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
function getMemoryUsageSnapshot() {
|
||||
if (typeof performance === "undefined" || !performance?.memory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
usedJSHeapSize: performance.memory.usedJSHeapSize,
|
||||
totalJSHeapSize: performance.memory.totalJSHeapSize,
|
||||
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
|
||||
};
|
||||
}
|
||||
|
||||
function getActiveKey(video) {
|
||||
@@ -286,7 +356,96 @@ function resolveDirectMessageRemotePubkey(message, actorPubkey = "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildSnapshotFromMessages(messages, actorPubkey = "") {
|
||||
function buildConversationId(actorPubkey, remotePubkey) {
|
||||
const normalizedActor = normalizeHexPubkey(actorPubkey);
|
||||
const normalizedRemote = normalizeHexPubkey(remotePubkey);
|
||||
|
||||
if (!normalizedActor || !normalizedRemote) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `dm:${[normalizedActor, normalizedRemote].sort().join(":")}`;
|
||||
}
|
||||
|
||||
function normalizeDirectMessageRecord(message, actorPubkey = "") {
|
||||
if (!message || message.ok !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const actor = normalizeHexPubkey(
|
||||
actorPubkey || message.actorPubkey || "",
|
||||
);
|
||||
const remote = resolveDirectMessageRemotePubkey(message, actor);
|
||||
const conversationId = buildConversationId(actor, remote);
|
||||
const eventId =
|
||||
typeof message?.event?.id === "string" ? message.event.id : "";
|
||||
|
||||
if (!conversationId || !eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const senderPubkey = normalizeHexPubkey(
|
||||
message?.sender?.pubkey ||
|
||||
message?.message?.pubkey ||
|
||||
message?.event?.pubkey ||
|
||||
"",
|
||||
);
|
||||
const direction = message.direction;
|
||||
let receiverPubkey = "";
|
||||
|
||||
if (direction === "incoming") {
|
||||
receiverPubkey = actor;
|
||||
} else if (direction === "outgoing") {
|
||||
receiverPubkey = remote;
|
||||
} else if (senderPubkey) {
|
||||
receiverPubkey = senderPubkey === actor ? remote : actor;
|
||||
}
|
||||
|
||||
const createdAt = sanitizeSnapshotTimestamp(
|
||||
message?.message?.created_at ??
|
||||
message?.event?.created_at ??
|
||||
message?.timestamp,
|
||||
);
|
||||
|
||||
const kind =
|
||||
Number.isFinite(message?.message?.kind)
|
||||
? message.message.kind
|
||||
: Number.isFinite(message?.event?.kind)
|
||||
? message.event.kind
|
||||
: 0;
|
||||
|
||||
const tags = Array.isArray(message?.message?.tags)
|
||||
? message.message.tags
|
||||
: Array.isArray(message?.event?.tags)
|
||||
? message.event.tags
|
||||
: [];
|
||||
|
||||
const encryptionScheme =
|
||||
typeof message?.encryption_scheme === "string"
|
||||
? message.encryption_scheme.trim()
|
||||
: typeof message?.scheme === "string"
|
||||
? message.scheme.trim()
|
||||
: typeof message?.decryptor?.scheme === "string"
|
||||
? message.decryptor.scheme.trim()
|
||||
: "";
|
||||
|
||||
return {
|
||||
id: eventId,
|
||||
conversation_id: conversationId,
|
||||
sender_pubkey: senderPubkey || actor,
|
||||
receiver_pubkey: receiverPubkey,
|
||||
created_at: createdAt,
|
||||
kind,
|
||||
content: typeof message?.plaintext === "string" ? message.plaintext : "",
|
||||
tags,
|
||||
relay: typeof message?.event?.relay === "string" ? message.event.relay : "",
|
||||
status: "published",
|
||||
seen: direction !== "incoming",
|
||||
encryption_scheme: encryptionScheme,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSnapshotFromMessages(messages, actorPubkey = "", resolvePreview = null) {
|
||||
const normalizedActor = normalizeHexPubkey(actorPubkey);
|
||||
const threadMap = new Map();
|
||||
|
||||
@@ -301,7 +460,10 @@ function buildSnapshotFromMessages(messages, actorPubkey = "") {
|
||||
}
|
||||
|
||||
const timestamp = sanitizeSnapshotTimestamp(message.timestamp);
|
||||
const preview = extractSnapshotPreview(message);
|
||||
const preview =
|
||||
typeof resolvePreview === "function"
|
||||
? resolvePreview(message)
|
||||
: extractSnapshotPreview(message);
|
||||
|
||||
const existing = threadMap.get(remote);
|
||||
if (!existing || timestamp > existing.latestTimestamp) {
|
||||
@@ -353,6 +515,7 @@ function hydrateMessagesFromSnapshot(snapshot, actorPubkey = "") {
|
||||
recipients: [],
|
||||
direction: "unknown",
|
||||
scheme: "",
|
||||
encryption_scheme: "",
|
||||
decryptor: { scheme: "", source: "snapshot" },
|
||||
event: {
|
||||
id: `dm-snapshot:${normalizedActor || "actor"}:${remote}:${timestamp}:${index}`,
|
||||
@@ -397,6 +560,10 @@ export class NostrService {
|
||||
this.dmSubscription = null;
|
||||
this.dmActorPubkey = null;
|
||||
this.dmHydratedFromSnapshot = false;
|
||||
this.dmNotificationManager = new DmNotificationManager({
|
||||
logger: userLogger,
|
||||
});
|
||||
this.dmPreviewCache = new LRUCache({ maxSize: DM_PREVIEW_CACHE_LIMIT });
|
||||
try {
|
||||
if (this.moderationService && typeof this.moderationService.setNostrClient === "function") {
|
||||
this.moderationService.setNostrClient(this.nostrClient);
|
||||
@@ -414,6 +581,49 @@ export class NostrService {
|
||||
}
|
||||
}
|
||||
|
||||
getDirectMessagePreviewCacheStats() {
|
||||
if (!this.dmPreviewCache || typeof this.dmPreviewCache.getStats !== "function") {
|
||||
return null;
|
||||
}
|
||||
return this.dmPreviewCache.getStats();
|
||||
}
|
||||
|
||||
resolveDirectMessagePreview(message) {
|
||||
const cacheKey = resolvePreviewCacheKey(message);
|
||||
if (cacheKey && this.dmPreviewCache) {
|
||||
const cached = this.dmPreviewCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const preview = extractSnapshotPreview(message);
|
||||
if (cacheKey && this.dmPreviewCache) {
|
||||
this.dmPreviewCache.set(cacheKey, preview);
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
logDirectMessageDiagnostics({ context = "", limit, decryptLimit } = {}) {
|
||||
const cacheStats =
|
||||
typeof this.nostrClient?.getDmDecryptCacheStats === "function"
|
||||
? this.nostrClient.getDmDecryptCacheStats()
|
||||
: null;
|
||||
const previewStats = this.getDirectMessagePreviewCacheStats();
|
||||
const memory = getMemoryUsageSnapshot();
|
||||
|
||||
devLogger.debug("[nostrService] DM diagnostics", {
|
||||
context,
|
||||
limit,
|
||||
decryptLimit,
|
||||
workerQueueSize: getDmDecryptWorkerQueueSize(),
|
||||
messageCount: Array.isArray(this.dmMessages) ? this.dmMessages.length : 0,
|
||||
decryptCache: cacheStats,
|
||||
previewCache: previewStats,
|
||||
memory,
|
||||
});
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
return this.emitter.on(eventName, handler);
|
||||
}
|
||||
@@ -486,6 +696,9 @@ export class NostrService {
|
||||
this.dmMessages = [];
|
||||
this.dmMessageIndex = new Map();
|
||||
this.dmHydratedFromSnapshot = false;
|
||||
if (this.dmPreviewCache) {
|
||||
this.dmPreviewCache.clear();
|
||||
}
|
||||
const activeActor = normalizeHexPubkey(
|
||||
typeof actorPubkey === "string" && actorPubkey
|
||||
? actorPubkey
|
||||
@@ -510,6 +723,66 @@ export class NostrService {
|
||||
}
|
||||
}
|
||||
|
||||
async persistDirectMessageRecord(message, { actorPubkey = null } = {}) {
|
||||
const record = normalizeDirectMessageRecord(
|
||||
message,
|
||||
actorPubkey || message?.actorPubkey || this.dmActorPubkey,
|
||||
);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await writeMessages(record);
|
||||
const notificationResult = await this.dmNotificationManager.recordMessage({
|
||||
record,
|
||||
preview: this.resolveDirectMessagePreview(message),
|
||||
direction: message?.direction || "",
|
||||
});
|
||||
if (notificationResult?.conversation) {
|
||||
this.emit("directMessages:conversationUpdated", {
|
||||
conversation: notificationResult.conversation,
|
||||
});
|
||||
}
|
||||
if (notificationResult?.shouldNotify) {
|
||||
this.emit("directMessages:notification", {
|
||||
conversationId: record.conversation_id,
|
||||
message: record,
|
||||
unseenCount: notificationResult.conversation?.unseen_count || 0,
|
||||
});
|
||||
}
|
||||
return record;
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostrService] Failed to persist DM record", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
setFocusedDirectMessageConversation(conversationId, isFocused = true) {
|
||||
this.dmNotificationManager.setFocusedConversation(conversationId, isFocused);
|
||||
}
|
||||
|
||||
async acknowledgeRenderedDirectMessages(conversationId, renderedUntil) {
|
||||
const conversation = await this.dmNotificationManager.acknowledgeRenderedMessages({
|
||||
conversationId,
|
||||
renderedUntil,
|
||||
});
|
||||
if (conversation) {
|
||||
this.emit("directMessages:conversationUpdated", {
|
||||
conversation,
|
||||
});
|
||||
}
|
||||
return conversation;
|
||||
}
|
||||
|
||||
getDirectMessageUnseenCount(conversationId) {
|
||||
return this.dmNotificationManager.getUnseenCount(conversationId);
|
||||
}
|
||||
|
||||
async listDirectMessageConversationSummaries() {
|
||||
return this.dmNotificationManager.listConversationSummaries();
|
||||
}
|
||||
|
||||
applyDirectMessage(message, { reason = "update", event = null } = {}) {
|
||||
if (!message || message.ok !== true) {
|
||||
return;
|
||||
@@ -575,6 +848,10 @@ export class NostrService {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!normalized.snapshot) {
|
||||
void this.persistDirectMessageRecord(normalized, { actorPubkey: actor });
|
||||
}
|
||||
}
|
||||
|
||||
async hydrateDirectMessagesFromStore({
|
||||
@@ -641,6 +918,28 @@ export class NostrService {
|
||||
return [];
|
||||
}
|
||||
|
||||
const initialLoad = options.initialLoad !== false;
|
||||
const limitCandidate = Number(options.limit);
|
||||
const resolvedLimit = toPositiveInteger(limitCandidate, DM_DEFAULT_PAGE_LIMIT);
|
||||
const decryptLimitCandidate = Number(options.decryptLimit);
|
||||
let resolvedDecryptLimit = initialLoad
|
||||
? toPositiveInteger(decryptLimitCandidate, DM_INITIAL_DECRYPT_LIMIT)
|
||||
: Number.isFinite(decryptLimitCandidate) && decryptLimitCandidate > 0
|
||||
? Math.floor(decryptLimitCandidate)
|
||||
: null;
|
||||
|
||||
if (resolvedDecryptLimit && resolvedDecryptLimit > resolvedLimit) {
|
||||
resolvedDecryptLimit = resolvedLimit;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(limitCandidate) || limitCandidate <= 0) {
|
||||
devLogger.debug("[nostrService] Using default DM pagination limit", {
|
||||
limit: resolvedLimit,
|
||||
decryptLimit: resolvedDecryptLimit,
|
||||
initialLoad,
|
||||
});
|
||||
}
|
||||
|
||||
const forceHydration =
|
||||
this.dmActorPubkey && this.dmActorPubkey !== normalizedActor;
|
||||
|
||||
@@ -657,6 +956,8 @@ export class NostrService {
|
||||
messages = await this.nostrClient.listDirectMessages(normalizedActor, {
|
||||
relays,
|
||||
...options,
|
||||
limit: resolvedLimit,
|
||||
decryptLimit: resolvedDecryptLimit,
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostrService] Failed to load direct messages", error);
|
||||
@@ -718,10 +1019,16 @@ export class NostrService {
|
||||
);
|
||||
}
|
||||
|
||||
this.logDirectMessageDiagnostics({
|
||||
context: "load",
|
||||
limit: resolvedLimit,
|
||||
decryptLimit: resolvedDecryptLimit,
|
||||
});
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) {
|
||||
async ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) {
|
||||
if (this.dmSubscription) {
|
||||
return this.dmSubscription;
|
||||
}
|
||||
@@ -732,11 +1039,40 @@ export class NostrService {
|
||||
return null;
|
||||
}
|
||||
|
||||
let resolvedRelays = Array.isArray(relays) ? relays : null;
|
||||
let relaySelection = null;
|
||||
|
||||
if (!resolvedRelays) {
|
||||
const discoveryRelays = Array.isArray(this.nostrClient?.readRelays)
|
||||
? this.nostrClient.readRelays
|
||||
: Array.isArray(this.nostrClient?.relays)
|
||||
? this.nostrClient.relays
|
||||
: [];
|
||||
|
||||
let pool = this.nostrClient?.pool || null;
|
||||
if (!pool && typeof this.nostrClient?.ensurePool === "function") {
|
||||
try {
|
||||
pool = await this.nostrClient.ensurePool();
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostrService] Failed to initialize DM relay pool.", error);
|
||||
}
|
||||
}
|
||||
|
||||
relaySelection = await resolveDmRelaySelection({
|
||||
pubkey: normalizedActor,
|
||||
discoveryRelays,
|
||||
fallbackRelays: discoveryRelays,
|
||||
pool,
|
||||
log: { dev: devLogger, user: userLogger },
|
||||
});
|
||||
resolvedRelays = relaySelection.relays;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = this.nostrClient.subscribeDirectMessages(
|
||||
normalizedActor,
|
||||
{
|
||||
relays,
|
||||
relays: resolvedRelays,
|
||||
onEvent: handlers.onEvent,
|
||||
onMessage: (message, context = {}) => {
|
||||
this.applyDirectMessage(message, {
|
||||
@@ -804,6 +1140,14 @@ export class NostrService {
|
||||
this.dmSubscription = subscription;
|
||||
this.dmActorPubkey = normalizedActor;
|
||||
this.emit("directMessages:subscribed", { subscription });
|
||||
if (relaySelection?.warning === DM_RELAY_WARNING_FALLBACK) {
|
||||
this.emit("directMessages:relayWarning", {
|
||||
actorPubkey: normalizedActor,
|
||||
warning: relaySelection.warning,
|
||||
relays: resolvedRelays,
|
||||
source: relaySelection.source,
|
||||
});
|
||||
}
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
@@ -843,6 +1187,7 @@ export class NostrService {
|
||||
const snapshotPayload = buildSnapshotFromMessages(
|
||||
sourceMessages,
|
||||
normalizedActor,
|
||||
(message) => this.resolveDirectMessagePreview(message),
|
||||
);
|
||||
|
||||
if (!snapshotPayload.length) {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
const TELEMETRY_STORAGE_KEY = "bitvid:relay-health-telemetry-opt-in";
|
||||
|
||||
function resolveLogger(logger) {
|
||||
if (logger && logger.dev && logger.user) {
|
||||
return logger;
|
||||
}
|
||||
return { dev: devLogger, user: userLogger };
|
||||
}
|
||||
|
||||
function nowMs() {
|
||||
return typeof performance !== "undefined" && performance.now
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
|
||||
function readTelemetryOptIn() {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(TELEMETRY_STORAGE_KEY);
|
||||
return stored === "true";
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[relayHealth] Failed to read telemetry preference from storage.",
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeTelemetryOptIn(nextValue) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
TELEMETRY_STORAGE_KEY,
|
||||
nextValue ? "true" : "false",
|
||||
);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[relayHealth] Failed to persist telemetry preference.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RelayHealthService {
|
||||
constructor({ relayManager, nostrClient, logger, telemetryEmitter } = {}) {
|
||||
this.relayManager = relayManager || null;
|
||||
this.nostrClient = nostrClient || null;
|
||||
this.logger = resolveLogger(logger);
|
||||
this.telemetryEmitter =
|
||||
typeof telemetryEmitter === "function" ? telemetryEmitter : null;
|
||||
this.relayStates = new Map();
|
||||
this.attachedRelays = new Set();
|
||||
this.cachedTelemetryOptIn = readTelemetryOptIn();
|
||||
}
|
||||
|
||||
getTelemetryOptIn() {
|
||||
return this.cachedTelemetryOptIn;
|
||||
}
|
||||
|
||||
setTelemetryOptIn(value) {
|
||||
const normalized = Boolean(value);
|
||||
this.cachedTelemetryOptIn = normalized;
|
||||
writeTelemetryOptIn(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
getRelayUrls() {
|
||||
if (!this.relayManager || typeof this.relayManager.getEntries !== "function") {
|
||||
return [];
|
||||
}
|
||||
const entries = this.relayManager.getEntries();
|
||||
return entries
|
||||
.map((entry) => (typeof entry?.url === "string" ? entry.url.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
ensureRelayState(relayUrl) {
|
||||
if (!this.relayStates.has(relayUrl)) {
|
||||
this.relayStates.set(relayUrl, {
|
||||
url: relayUrl,
|
||||
connected: false,
|
||||
lastLatencyMs: null,
|
||||
errorCount: 0,
|
||||
lastCheckedAt: null,
|
||||
lastErrorAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
return this.relayStates.get(relayUrl);
|
||||
}
|
||||
|
||||
attachRelayListeners(relayUrl, relay) {
|
||||
if (!relay || typeof relay.on !== "function") {
|
||||
return;
|
||||
}
|
||||
if (this.attachedRelays.has(relayUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
relay.on("connect", () => {
|
||||
this.recordRelaySuccess(relayUrl, null);
|
||||
});
|
||||
relay.on("disconnect", () => {
|
||||
this.recordRelayFailure(relayUrl, new Error("relay-disconnect"));
|
||||
});
|
||||
relay.on("error", (error) => {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
});
|
||||
this.attachedRelays.add(relayUrl);
|
||||
} catch (error) {
|
||||
this.logger.dev.warn(
|
||||
"[relayHealth] Failed to attach relay listeners.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
recordRelaySuccess(relayUrl, latencyMs) {
|
||||
const state = this.ensureRelayState(relayUrl);
|
||||
state.connected = true;
|
||||
state.lastCheckedAt = Date.now();
|
||||
if (Number.isFinite(latencyMs)) {
|
||||
state.lastLatencyMs = Math.max(0, Math.round(latencyMs));
|
||||
}
|
||||
}
|
||||
|
||||
recordRelayFailure(relayUrl, error) {
|
||||
const state = this.ensureRelayState(relayUrl);
|
||||
state.connected = false;
|
||||
state.errorCount += 1;
|
||||
state.lastCheckedAt = Date.now();
|
||||
state.lastErrorAt = Date.now();
|
||||
|
||||
this.logger.dev.warn("[relayHealth] Relay check failed.", {
|
||||
relayUrl,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
async checkRelay(relayUrl) {
|
||||
if (!relayUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.nostrClient) {
|
||||
this.recordRelayFailure(relayUrl, new Error("nostr-client-unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof this.nostrClient.ensurePool === "function") {
|
||||
await this.nostrClient.ensurePool();
|
||||
}
|
||||
} catch (error) {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.nostrClient.pool || typeof this.nostrClient.pool.ensureRelay !== "function") {
|
||||
this.recordRelayFailure(relayUrl, new Error("relay-pool-unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
const start = nowMs();
|
||||
try {
|
||||
const relay = await this.nostrClient.pool.ensureRelay(relayUrl);
|
||||
if (!relay) {
|
||||
throw new Error("relay-unavailable");
|
||||
}
|
||||
|
||||
this.attachRelayListeners(relayUrl, relay);
|
||||
this.recordRelaySuccess(relayUrl, nowMs() - start);
|
||||
} catch (error) {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot(relayUrls = null) {
|
||||
const urls = Array.isArray(relayUrls) ? relayUrls : this.getRelayUrls();
|
||||
return urls.map((url) => ({ ...this.ensureRelayState(url) }));
|
||||
}
|
||||
|
||||
async refresh(relayUrls = null) {
|
||||
const urls = Array.isArray(relayUrls) ? relayUrls : this.getRelayUrls();
|
||||
if (!urls.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await Promise.allSettled(urls.map((url) => this.checkRelay(url)));
|
||||
const snapshot = this.getSnapshot(urls);
|
||||
this.emitTelemetry(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
emitTelemetry(snapshot) {
|
||||
if (!this.telemetryEmitter || !this.getTelemetryOptIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sampledAt: Date.now(),
|
||||
relays: Array.isArray(snapshot)
|
||||
? snapshot.map((entry) => ({
|
||||
url: entry.url,
|
||||
connected: Boolean(entry.connected),
|
||||
latencyMs:
|
||||
Number.isFinite(entry.lastLatencyMs) ? entry.lastLatencyMs : null,
|
||||
errorCount: Number.isFinite(entry.errorCount)
|
||||
? entry.errorCount
|
||||
: 0,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
|
||||
try {
|
||||
this.telemetryEmitter("relay_health_snapshot", payload);
|
||||
} catch (error) {
|
||||
this.logger.user.warn(
|
||||
"[relayHealth] Failed to emit relay health telemetry.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default RelayHealthService;
|
||||
@@ -34,6 +34,8 @@ const LEGACY_MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1";
|
||||
const MODERATION_OVERRIDE_STORAGE_VERSION = 2;
|
||||
const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1";
|
||||
const MODERATION_SETTINGS_STORAGE_VERSION = 3;
|
||||
const DM_PRIVACY_SETTINGS_STORAGE_KEY = "bitvid:dmPrivacySettings:v1";
|
||||
const DM_PRIVACY_SETTINGS_STORAGE_VERSION = 1;
|
||||
|
||||
function computeDefaultModerationSettings() {
|
||||
const defaultBlur = sanitizeModerationThreshold(
|
||||
@@ -114,6 +116,22 @@ moderationSettingsStore.set(
|
||||
createDefaultModerationSettings(),
|
||||
);
|
||||
|
||||
function createDefaultDmPrivacySettings() {
|
||||
return {
|
||||
readReceiptsEnabled: false,
|
||||
typingIndicatorsEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
const GLOBAL_DM_PRIVACY_PROFILE_KEY = "__dmPrivacyDefault__";
|
||||
const dmPrivacySettingsStore = createProfileSettingsStore({
|
||||
clone: (value) => ({ ...value }),
|
||||
});
|
||||
dmPrivacySettingsStore.set(
|
||||
GLOBAL_DM_PRIVACY_PROFILE_KEY,
|
||||
createDefaultDmPrivacySettings(),
|
||||
);
|
||||
|
||||
function sanitizeModerationThreshold(value, fallback) {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
@@ -1120,6 +1138,208 @@ function persistModerationSettingsToStorage() {
|
||||
}
|
||||
}
|
||||
|
||||
function getDmPrivacyStoreKeyForPubkey(pubkey) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
return normalized || GLOBAL_DM_PRIVACY_PROFILE_KEY;
|
||||
}
|
||||
|
||||
function ensureDmPrivacySettingsForKey(key) {
|
||||
const storeKey = key || GLOBAL_DM_PRIVACY_PROFILE_KEY;
|
||||
if (!dmPrivacySettingsStore.has(storeKey)) {
|
||||
dmPrivacySettingsStore.set(storeKey, createDefaultDmPrivacySettings());
|
||||
}
|
||||
return dmPrivacySettingsStore.get(storeKey);
|
||||
}
|
||||
|
||||
function normalizeDmPrivacySettings(partial = {}) {
|
||||
const defaults = createDefaultDmPrivacySettings();
|
||||
const resolved = partial && typeof partial === "object" ? partial : {};
|
||||
return {
|
||||
readReceiptsEnabled:
|
||||
typeof resolved.readReceiptsEnabled === "boolean"
|
||||
? resolved.readReceiptsEnabled
|
||||
: defaults.readReceiptsEnabled,
|
||||
typingIndicatorsEnabled:
|
||||
typeof resolved.typingIndicatorsEnabled === "boolean"
|
||||
? resolved.typingIndicatorsEnabled
|
||||
: defaults.typingIndicatorsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultDmPrivacySettings() {
|
||||
return createDefaultDmPrivacySettings();
|
||||
}
|
||||
|
||||
export function getDmPrivacySettingsForPubkey(pubkey) {
|
||||
const key = getDmPrivacyStoreKeyForPubkey(pubkey);
|
||||
const settings = ensureDmPrivacySettingsForKey(key);
|
||||
return { ...settings };
|
||||
}
|
||||
|
||||
export function getDmPrivacySettings() {
|
||||
return getDmPrivacySettingsForPubkey(activeProfilePubkey);
|
||||
}
|
||||
|
||||
export function setDmPrivacySettings(partial = {}, options = {}) {
|
||||
const targetPubkey =
|
||||
typeof options?.pubkey === "string" ? options.pubkey : activeProfilePubkey;
|
||||
const key = getDmPrivacyStoreKeyForPubkey(targetPubkey);
|
||||
const existing = ensureDmPrivacySettingsForKey(key);
|
||||
const normalized = normalizeDmPrivacySettings({ ...existing, ...partial });
|
||||
dmPrivacySettingsStore.set(key, normalized);
|
||||
persistDmPrivacySettingsToStorage();
|
||||
return { ...normalized };
|
||||
}
|
||||
|
||||
export function loadDmPrivacySettingsFromStorage() {
|
||||
dmPrivacySettingsStore.clear();
|
||||
dmPrivacySettingsStore.set(
|
||||
GLOBAL_DM_PRIVACY_PROFILE_KEY,
|
||||
createDefaultDmPrivacySettings(),
|
||||
);
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return getDmPrivacySettings();
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(DM_PRIVACY_SETTINGS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return getDmPrivacySettings();
|
||||
}
|
||||
|
||||
let shouldRewrite = false;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(raw);
|
||||
if (!payload || typeof payload !== "object") {
|
||||
shouldRewrite = true;
|
||||
} else if (payload.version === DM_PRIVACY_SETTINGS_STORAGE_VERSION) {
|
||||
const defaults = createDefaultDmPrivacySettings();
|
||||
const entries =
|
||||
payload.entries && typeof payload.entries === "object"
|
||||
? payload.entries
|
||||
: {};
|
||||
|
||||
for (const [key, entry] of Object.entries(entries)) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
shouldRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const overrides =
|
||||
entry.overrides && typeof entry.overrides === "object"
|
||||
? entry.overrides
|
||||
: {};
|
||||
|
||||
const storeKey =
|
||||
key === "default"
|
||||
? GLOBAL_DM_PRIVACY_PROFILE_KEY
|
||||
: getDmPrivacyStoreKeyForPubkey(key);
|
||||
|
||||
if (!storeKey) {
|
||||
shouldRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const settings = {
|
||||
readReceiptsEnabled:
|
||||
typeof overrides.readReceiptsEnabled === "boolean"
|
||||
? overrides.readReceiptsEnabled
|
||||
: defaults.readReceiptsEnabled,
|
||||
typingIndicatorsEnabled:
|
||||
typeof overrides.typingIndicatorsEnabled === "boolean"
|
||||
? overrides.typingIndicatorsEnabled
|
||||
: defaults.typingIndicatorsEnabled,
|
||||
};
|
||||
|
||||
dmPrivacySettingsStore.set(storeKey, settings);
|
||||
}
|
||||
} else {
|
||||
shouldRewrite = true;
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[cache.loadDmPrivacySettingsFromStorage] Failed to parse payload:",
|
||||
error,
|
||||
);
|
||||
shouldRewrite = true;
|
||||
}
|
||||
|
||||
if (shouldRewrite) {
|
||||
persistDmPrivacySettingsToStorage();
|
||||
}
|
||||
|
||||
return getDmPrivacySettings();
|
||||
}
|
||||
|
||||
function persistDmPrivacySettingsToStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaults = createDefaultDmPrivacySettings();
|
||||
const entries = {};
|
||||
|
||||
for (const [key, value] of dmPrivacySettingsStore.entries()) {
|
||||
const settings =
|
||||
value && typeof value === "object"
|
||||
? value
|
||||
: createDefaultDmPrivacySettings();
|
||||
|
||||
const overrides = {};
|
||||
|
||||
if (settings.readReceiptsEnabled !== defaults.readReceiptsEnabled) {
|
||||
overrides.readReceiptsEnabled = settings.readReceiptsEnabled;
|
||||
}
|
||||
|
||||
if (settings.typingIndicatorsEnabled !== defaults.typingIndicatorsEnabled) {
|
||||
overrides.typingIndicatorsEnabled = settings.typingIndicatorsEnabled;
|
||||
}
|
||||
|
||||
if (Object.keys(overrides).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const storageKey =
|
||||
key === GLOBAL_DM_PRIVACY_PROFILE_KEY ? "default" : key;
|
||||
|
||||
entries[storageKey] = {
|
||||
overrides,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(entries).length === 0) {
|
||||
try {
|
||||
localStorage.removeItem(DM_PRIVACY_SETTINGS_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[cache.persistDmPrivacySettingsToStorage] Failed to clear overrides:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: DM_PRIVACY_SETTINGS_STORAGE_VERSION,
|
||||
entries,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
DM_PRIVACY_SETTINGS_STORAGE_KEY,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[cache.persistDmPrivacySettingsToStorage] Failed to persist overrides:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function setModerationSettings(partial = {}, { persist = true } = {}) {
|
||||
const key = getActiveModerationStoreKey();
|
||||
const defaults = createDefaultModerationSettings();
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
const DB_NAME = "bitvidDm";
|
||||
const DB_VERSION = 2;
|
||||
|
||||
const STORES = Object.freeze({
|
||||
CONVERSATIONS: "conversations",
|
||||
MESSAGES: "messages",
|
||||
CONTACTS: "contacts",
|
||||
RELAYS: "relays",
|
||||
SEEN_CACHE: "seen_cache",
|
||||
});
|
||||
|
||||
const MIGRATIONS = [
|
||||
{
|
||||
version: 1,
|
||||
migrate: (db) => {
|
||||
if (!db.objectStoreNames.contains(STORES.CONVERSATIONS)) {
|
||||
const store = db.createObjectStore(STORES.CONVERSATIONS, {
|
||||
keyPath: "conversation_id",
|
||||
});
|
||||
store.createIndex("created_at", "created_at", { unique: false });
|
||||
store.createIndex("unseen_count", "unseen_count", { unique: false });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(STORES.MESSAGES)) {
|
||||
const store = db.createObjectStore(STORES.MESSAGES, { keyPath: "id" });
|
||||
store.createIndex("conversation_id", "conversation_id", {
|
||||
unique: false,
|
||||
});
|
||||
store.createIndex("created_at", "created_at", { unique: false });
|
||||
store.createIndex("sender_pubkey", "sender_pubkey", {
|
||||
unique: false,
|
||||
});
|
||||
store.createIndex("conversation_created_at", [
|
||||
"conversation_id",
|
||||
"created_at",
|
||||
]);
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(STORES.CONTACTS)) {
|
||||
db.createObjectStore(STORES.CONTACTS, { keyPath: "pubkey" });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(STORES.RELAYS)) {
|
||||
db.createObjectStore(STORES.RELAYS, { keyPath: "url" });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(STORES.SEEN_CACHE)) {
|
||||
const store = db.createObjectStore(STORES.SEEN_CACHE, {
|
||||
keyPath: "id",
|
||||
});
|
||||
store.createIndex("conversation_id", "conversation_id", {
|
||||
unique: false,
|
||||
});
|
||||
store.createIndex("created_at", "created_at", { unique: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
migrate: (db, transaction) => {
|
||||
if (!db.objectStoreNames.contains(STORES.CONVERSATIONS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If transaction is provided (during upgrade), use it.
|
||||
// Otherwise create a new one (but this path is questionable during upgrade).
|
||||
const store = transaction
|
||||
? transaction.objectStore(STORES.CONVERSATIONS)
|
||||
: db.createObjectStore(STORES.CONVERSATIONS, { keyPath: "conversation_id" });
|
||||
// Fallback: If store doesn't exist and no transaction provided (unlikely in upgrade), create it?
|
||||
// Actually, if transaction is missing in migration, we are in trouble.
|
||||
// But for safe-guarding against "store not found":
|
||||
if (!store.indexNames.contains("opened_until")) {
|
||||
store.createIndex("opened_until", "opened_until", { unique: false });
|
||||
}
|
||||
if (!store.indexNames.contains("downloaded_until")) {
|
||||
store.createIndex("downloaded_until", "downloaded_until", {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function isIndexedDbAvailable() {
|
||||
try {
|
||||
return typeof indexedDB !== "undefined";
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyMigrations(db, oldVersion, newVersion, transaction) {
|
||||
for (const migration of MIGRATIONS) {
|
||||
if (migration.version > oldVersion && migration.version <= newVersion) {
|
||||
migration.migrate(db, transaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openDmDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isIndexedDbAvailable()) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = request.result;
|
||||
const oldVersion = event.oldVersion ?? 0;
|
||||
const newVersion = event.newVersion ?? DB_VERSION;
|
||||
const upgradeTransaction =
|
||||
request.transaction || event.target?.transaction || null;
|
||||
applyMigrations(db, oldVersion, newVersion, upgradeTransaction);
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () =>
|
||||
reject(request.error || new Error("Failed to open DM database."));
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeTimestamp(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
function normalizePubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return "";
|
||||
}
|
||||
return pubkey.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeMessage(raw) {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = typeof raw.id === "string" ? raw.id.trim() : "";
|
||||
const conversationId =
|
||||
typeof raw.conversation_id === "string"
|
||||
? raw.conversation_id.trim()
|
||||
: typeof raw.conversationId === "string"
|
||||
? raw.conversationId.trim()
|
||||
: "";
|
||||
const senderPubkey = normalizePubkey(
|
||||
raw.sender_pubkey ?? raw.senderPubkey ?? raw.pubkey,
|
||||
);
|
||||
|
||||
if (!id || !conversationId || !senderPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const receiverPubkey = normalizePubkey(
|
||||
raw.receiver_pubkey ?? raw.receiverPubkey ?? raw.to_pubkey,
|
||||
);
|
||||
const statusRaw = typeof raw.status === "string" ? raw.status.trim() : "";
|
||||
const status =
|
||||
statusRaw === "pending" || statusRaw === "published" || statusRaw === "failed"
|
||||
? statusRaw
|
||||
: "";
|
||||
const seen = raw.seen === true;
|
||||
const encryptionScheme =
|
||||
typeof raw.encryption_scheme === "string"
|
||||
? raw.encryption_scheme.trim()
|
||||
: typeof raw.encryptionScheme === "string"
|
||||
? raw.encryptionScheme.trim()
|
||||
: typeof raw.scheme === "string"
|
||||
? raw.scheme.trim()
|
||||
: "";
|
||||
|
||||
return {
|
||||
id,
|
||||
conversation_id: conversationId,
|
||||
sender_pubkey: senderPubkey,
|
||||
receiver_pubkey: receiverPubkey,
|
||||
created_at: sanitizeTimestamp(raw.created_at ?? raw.createdAt),
|
||||
kind: Number.isFinite(Number(raw.kind)) ? Number(raw.kind) : 0,
|
||||
content: typeof raw.content === "string" ? raw.content : "",
|
||||
tags: Array.isArray(raw.tags) ? raw.tags : [],
|
||||
relay: typeof raw.relay === "string" ? raw.relay : "",
|
||||
status,
|
||||
seen,
|
||||
encryption_scheme: encryptionScheme,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConversation(raw) {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const conversationId =
|
||||
typeof raw.conversation_id === "string"
|
||||
? raw.conversation_id.trim()
|
||||
: typeof raw.conversationId === "string"
|
||||
? raw.conversationId.trim()
|
||||
: "";
|
||||
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
conversation_id: conversationId,
|
||||
created_at: sanitizeTimestamp(raw.created_at ?? raw.createdAt),
|
||||
last_message_at: sanitizeTimestamp(
|
||||
raw.last_message_at ?? raw.lastMessageAt,
|
||||
),
|
||||
downloaded_until: sanitizeTimestamp(
|
||||
raw.downloaded_until ?? raw.downloadedUntil,
|
||||
),
|
||||
opened_until: sanitizeTimestamp(raw.opened_until ?? raw.openedUntil),
|
||||
last_message_preview:
|
||||
typeof raw.last_message_preview === "string"
|
||||
? raw.last_message_preview
|
||||
: typeof raw.lastMessagePreview === "string"
|
||||
? raw.lastMessagePreview
|
||||
: "",
|
||||
unseen_count: Number.isFinite(Number(raw.unseen_count))
|
||||
? Number(raw.unseen_count)
|
||||
: Number.isFinite(Number(raw.unseenCount))
|
||||
? Number(raw.unseenCount)
|
||||
: 0,
|
||||
participants: Array.isArray(raw.participants) ? raw.participants : [],
|
||||
};
|
||||
}
|
||||
|
||||
function runRequest(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeTransaction(tx) {
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error || new Error("Transaction aborted"));
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeConversationMetadata(conversation) {
|
||||
const normalized = normalizeConversation(conversation);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.CONVERSATIONS, "readwrite");
|
||||
tx.objectStore(STORES.CONVERSATIONS).put(normalized);
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to write conversation metadata", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeMessages(messages) {
|
||||
const list = Array.isArray(messages) ? messages : [messages];
|
||||
const normalized = list
|
||||
.map((message) => normalizeMessage(message))
|
||||
.filter(Boolean);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.MESSAGES, "readwrite");
|
||||
const store = tx.objectStore(STORES.MESSAGES);
|
||||
|
||||
for (const message of normalized) {
|
||||
store.put(message);
|
||||
}
|
||||
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to persist messages", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConversation(conversationId) {
|
||||
if (typeof conversationId !== "string" || !conversationId.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.CONVERSATIONS, "readonly");
|
||||
const store = tx.objectStore(STORES.CONVERSATIONS);
|
||||
const result = await runRequest(store.get(conversationId.trim()));
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to read conversation metadata", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMessagesByConversation(conversationId, options = {}) {
|
||||
if (typeof conversationId !== "string" || !conversationId.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const limit =
|
||||
Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
|
||||
? Number(options.limit)
|
||||
: 50;
|
||||
const direction = options.direction === "asc" ? "next" : "prev";
|
||||
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.MESSAGES, "readonly");
|
||||
const store = tx.objectStore(STORES.MESSAGES);
|
||||
const index = store.index("conversation_created_at");
|
||||
const range = IDBKeyRange.bound(
|
||||
[conversationId.trim(), 0],
|
||||
[conversationId.trim(), Number.MAX_SAFE_INTEGER],
|
||||
);
|
||||
|
||||
const results = [];
|
||||
const request = index.openCursor(range, direction);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor || results.length >= limit) {
|
||||
resolve(results);
|
||||
return;
|
||||
}
|
||||
results.push(cursor.value);
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => {
|
||||
userLogger.warn("[dmDb] Failed to list messages", request.error);
|
||||
resolve(results);
|
||||
};
|
||||
}).finally(() => db.close());
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to list messages", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConversationFromMessage(message, options = {}) {
|
||||
const normalized = normalizeMessage(message);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preview =
|
||||
typeof options.preview === "string" && options.preview.trim()
|
||||
? options.preview.trim()
|
||||
: normalized.content.slice(0, 160);
|
||||
const unseenDelta =
|
||||
Number.isFinite(Number(options.unseenDelta))
|
||||
? Number(options.unseenDelta)
|
||||
: 0;
|
||||
const downloadedUntil = sanitizeTimestamp(
|
||||
options.downloadedUntil ?? normalized.created_at,
|
||||
);
|
||||
const openedUntil = sanitizeTimestamp(options.openedUntil);
|
||||
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.CONVERSATIONS, "readwrite");
|
||||
const store = tx.objectStore(STORES.CONVERSATIONS);
|
||||
const existing = (await runRequest(
|
||||
store.get(normalized.conversation_id),
|
||||
)) || {
|
||||
conversation_id: normalized.conversation_id,
|
||||
created_at: normalized.created_at,
|
||||
unseen_count: 0,
|
||||
};
|
||||
|
||||
const next = {
|
||||
...existing,
|
||||
created_at: existing.created_at || normalized.created_at,
|
||||
last_message_at: normalized.created_at,
|
||||
downloaded_until: Math.max(
|
||||
existing.downloaded_until || 0,
|
||||
downloadedUntil,
|
||||
),
|
||||
opened_until: Math.max(existing.opened_until || 0, openedUntil),
|
||||
last_message_preview: preview,
|
||||
unseen_count: Math.max(
|
||||
0,
|
||||
Number(existing.unseen_count || 0) + unseenDelta,
|
||||
),
|
||||
};
|
||||
|
||||
store.put(next);
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
return next;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to update conversation metadata", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConversationOpenState(
|
||||
conversationId,
|
||||
{ openedUntil, unseenCount } = {},
|
||||
) {
|
||||
if (typeof conversationId !== "string" || !conversationId.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedId = conversationId.trim();
|
||||
const nextOpenedUntil = sanitizeTimestamp(openedUntil);
|
||||
const nextUnseenCount = Number.isFinite(Number(unseenCount))
|
||||
? Math.max(0, Number(unseenCount))
|
||||
: null;
|
||||
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.CONVERSATIONS, "readwrite");
|
||||
const store = tx.objectStore(STORES.CONVERSATIONS);
|
||||
const existing =
|
||||
(await runRequest(store.get(normalizedId))) ||
|
||||
normalizeConversation({ conversation_id: normalizedId });
|
||||
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next = {
|
||||
...existing,
|
||||
opened_until: Math.max(existing.opened_until || 0, nextOpenedUntil),
|
||||
unseen_count:
|
||||
nextUnseenCount === null
|
||||
? existing.unseen_count
|
||||
: nextUnseenCount,
|
||||
};
|
||||
|
||||
store.put(next);
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
return next;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to update conversation open state", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listConversations() {
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.CONVERSATIONS, "readonly");
|
||||
const store = tx.objectStore(STORES.CONVERSATIONS);
|
||||
const results = [];
|
||||
const request = store.openCursor();
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve(results);
|
||||
return;
|
||||
}
|
||||
results.push(cursor.value);
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => {
|
||||
userLogger.warn("[dmDb] Failed to list conversations", request.error);
|
||||
resolve(results);
|
||||
};
|
||||
}).finally(() => db.close());
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to list conversations", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function describeDmDbSchema() {
|
||||
return {
|
||||
name: DB_NAME,
|
||||
version: DB_VERSION,
|
||||
stores: STORES,
|
||||
migrations: MIGRATIONS.map((migration) => migration.version),
|
||||
};
|
||||
}
|
||||
|
||||
export async function clearDmDb() {
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tx = db.transaction(
|
||||
[
|
||||
STORES.CONVERSATIONS,
|
||||
STORES.MESSAGES,
|
||||
STORES.CONTACTS,
|
||||
STORES.RELAYS,
|
||||
STORES.SEEN_CACHE,
|
||||
],
|
||||
"readwrite",
|
||||
);
|
||||
|
||||
for (const storeName of tx.objectStoreNames) {
|
||||
tx.objectStore(storeName).clear();
|
||||
}
|
||||
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
return true;
|
||||
} catch (error) {
|
||||
devLogger.warn("[dmDb] Failed to clear DM database", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const dmDbStores = STORES;
|
||||
export const dmDbVersion = DB_VERSION;
|
||||
@@ -18,6 +18,7 @@ export default class AppChromeController {
|
||||
this.elements = {
|
||||
logoutButton: this.normalizeElement(elements.logoutButton),
|
||||
profileButton: this.normalizeElement(elements.profileButton),
|
||||
profileUnreadDot: this.normalizeElement(elements.profileUnreadDot),
|
||||
uploadButton: this.normalizeElement(elements.uploadButton),
|
||||
loginButton: this.normalizeElement(elements.loginButton),
|
||||
closeLoginModalButton: this.normalizeElement(
|
||||
@@ -206,6 +207,21 @@ export default class AppChromeController {
|
||||
}
|
||||
}
|
||||
|
||||
setUnreadDmIndicator(visible) {
|
||||
const dot = this.normalizeElement(this.elements.profileUnreadDot);
|
||||
if (!dot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profileButton = this.normalizeElement(this.elements.profileButton);
|
||||
const isProfileVisible =
|
||||
profileButton &&
|
||||
!profileButton.classList.contains("hidden") &&
|
||||
!profileButton.hasAttribute("hidden");
|
||||
|
||||
dot.classList.toggle("is-visible", Boolean(visible) && isProfileVisible);
|
||||
}
|
||||
|
||||
bindUploadButton() {
|
||||
const button = this.normalizeElement(this.elements.uploadButton);
|
||||
if (this.boundElements.uploadButton === button) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import nostrService from "../services/nostrService.js";
|
||||
import storageService from "../services/storageService.js";
|
||||
import watchHistoryService from "../watchHistoryService.js";
|
||||
import r2Service from "../services/r2Service.js";
|
||||
import RelayHealthService from "../services/relayHealthService.js";
|
||||
import { createFeedEngine } from "../feedEngine/index.js";
|
||||
import { URL_FIRST_ENABLED } from "../constants.js";
|
||||
import { ALLOW_NSFW_CONTENT } from "../config.js";
|
||||
@@ -41,6 +42,8 @@ import {
|
||||
getModerationSettings,
|
||||
setModerationSettings,
|
||||
resetModerationSettings,
|
||||
getDmPrivacySettings,
|
||||
setDmPrivacySettings,
|
||||
clearModerationOverride,
|
||||
persistSavedProfiles,
|
||||
getSavedProfiles,
|
||||
@@ -406,6 +409,7 @@ export default class ApplicationBootstrap {
|
||||
|
||||
app.profileButton = doc?.getElementById("profileButton") || null;
|
||||
app.profileAvatar = doc?.getElementById("profileAvatar") || null;
|
||||
app.profileUnreadDot = doc?.getElementById("profileUnreadDot") || null;
|
||||
|
||||
const modalManager = new ModalManager({
|
||||
app,
|
||||
@@ -424,6 +428,12 @@ export default class ApplicationBootstrap {
|
||||
const modalContainer = doc?.getElementById("modalContainer") || null;
|
||||
try {
|
||||
if (modalContainer) {
|
||||
const relayHealthService = new RelayHealthService({
|
||||
relayManager,
|
||||
nostrClient,
|
||||
telemetryEmitter: (eventName, payload) =>
|
||||
app.emitTelemetryEvent(eventName, payload),
|
||||
});
|
||||
const profileModalServices = {
|
||||
normalizeHexPubkey: (value) => app.normalizeHexPubkey(value),
|
||||
safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey),
|
||||
@@ -432,6 +442,7 @@ export default class ApplicationBootstrap {
|
||||
formatShortNpub: (value) => formatShortNpub(value),
|
||||
getProfileCacheEntry: (pubkey) => app.getProfileCacheEntry(pubkey),
|
||||
batchFetchProfiles: (authorSet) => app.batchFetchProfiles(authorSet),
|
||||
fetchDmRelayHints: (pubkey) => app.fetchDmRelayHints(pubkey),
|
||||
switchProfile: (pubkey) => app.authService.switchProfile(pubkey),
|
||||
removeSavedProfile: (pubkey) =>
|
||||
app.authService.removeSavedProfile(pubkey),
|
||||
@@ -477,6 +488,7 @@ export default class ApplicationBootstrap {
|
||||
app.describeHashtagPreferencesError(error, {
|
||||
fallbackMessage,
|
||||
}),
|
||||
relayHealthService,
|
||||
log: (...args) => app.log(...args),
|
||||
closeAllMoreMenus: (options) => app.closeAllMoreMenus(options),
|
||||
clipboard:
|
||||
@@ -499,6 +511,16 @@ export default class ApplicationBootstrap {
|
||||
setStoredActiveProfilePubkey(normalized, options);
|
||||
return getActiveProfilePubkey();
|
||||
},
|
||||
getDmRecipient: () => app.getDmRecipientPubkey(),
|
||||
setDmRecipient: (pubkey) => app.setDmRecipientPubkey(pubkey),
|
||||
getDmRelayHints: (pubkey) => app.getDmRelayHints(pubkey),
|
||||
setDmRelayHints: (pubkey, hints) => app.setDmRelayHints(pubkey, hints),
|
||||
getDmRelayPreferences: (pubkey) => app.getDmRelayHints(pubkey),
|
||||
setDmRelayPreferences: (pubkey, hints) =>
|
||||
app.setDmRelayHints(pubkey, hints),
|
||||
getDmPrivacySettings: () => getDmPrivacySettings(),
|
||||
setDmPrivacySettings: (settings, options = {}) =>
|
||||
setDmPrivacySettings(settings, options),
|
||||
};
|
||||
|
||||
const profileModalCallbacks = {
|
||||
@@ -532,6 +554,12 @@ export default class ApplicationBootstrap {
|
||||
app.handleProfileHistoryEvent(payload),
|
||||
onModerationSettingsChange: (payload) =>
|
||||
app.handleModerationSettingsChange(payload),
|
||||
onSendDm: (payload) => app.handleProfileSendDmRequest(payload),
|
||||
onOpenRelays: (payload) => app.handleProfileUseDmRelays(payload),
|
||||
onTogglePrivacy: (payload) =>
|
||||
app.handleProfilePrivacyToggle(payload),
|
||||
onPublishDmRelayPreferences: (payload) =>
|
||||
app.handleProfilePublishDmRelayPreferences(payload),
|
||||
};
|
||||
|
||||
app.profileController = new ProfileModalController({
|
||||
@@ -658,12 +686,88 @@ export default class ApplicationBootstrap {
|
||||
};
|
||||
|
||||
app.videosMap = app.nostrService.getVideosMap();
|
||||
app.unsubscribeFromNostrService = app.nostrService.on(
|
||||
"subscription:changed",
|
||||
({ subscription }) => {
|
||||
app.refreshUnreadDmIndicator = async ({ reason = "" } = {}) => {
|
||||
const applyIndicatorState = (visible) => {
|
||||
if (
|
||||
app.appChromeController &&
|
||||
typeof app.appChromeController.setUnreadDmIndicator === "function"
|
||||
) {
|
||||
app.appChromeController.setUnreadDmIndicator(visible);
|
||||
}
|
||||
|
||||
if (
|
||||
app.profileController &&
|
||||
typeof app.profileController.setMessagesUnreadIndicator === "function"
|
||||
) {
|
||||
app.profileController.setMessagesUnreadIndicator(visible);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof app.isUserLoggedIn === "function" && !app.isUserLoggedIn()) {
|
||||
applyIndicatorState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!app.nostrService ||
|
||||
typeof app.nostrService.listDirectMessageConversationSummaries !==
|
||||
"function"
|
||||
) {
|
||||
applyIndicatorState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const summaries =
|
||||
await app.nostrService.listDirectMessageConversationSummaries();
|
||||
const totalUnseen = Array.isArray(summaries)
|
||||
? summaries.reduce((sum, summary) => {
|
||||
const count = Number(summary?.unseen_count);
|
||||
return sum + (Number.isFinite(count) ? count : 0);
|
||||
}, 0)
|
||||
: 0;
|
||||
applyIndicatorState(totalUnseen > 0);
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[Application] Failed to refresh DM unread indicator",
|
||||
reason,
|
||||
error,
|
||||
);
|
||||
applyIndicatorState(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nostrUnsubscribes = [];
|
||||
nostrUnsubscribes.push(
|
||||
app.nostrService.on("subscription:changed", ({ subscription }) => {
|
||||
app.videoSubscription = subscription || null;
|
||||
},
|
||||
}),
|
||||
);
|
||||
nostrUnsubscribes.push(
|
||||
app.nostrService.on("directMessages:notification", () => {
|
||||
void app.refreshUnreadDmIndicator({ reason: "dm-notification" });
|
||||
}),
|
||||
);
|
||||
nostrUnsubscribes.push(
|
||||
app.nostrService.on("directMessages:conversationUpdated", () => {
|
||||
void app.refreshUnreadDmIndicator({ reason: "dm-conversation-update" });
|
||||
}),
|
||||
);
|
||||
nostrUnsubscribes.push(
|
||||
app.nostrService.on("directMessages:cleared", () => {
|
||||
if (typeof app.refreshUnreadDmIndicator === "function") {
|
||||
void app.refreshUnreadDmIndicator({ reason: "dm-cleared" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
app.unsubscribeFromNostrService = () => {
|
||||
while (nostrUnsubscribes.length) {
|
||||
const unsubscribe = nostrUnsubscribes.pop();
|
||||
if (typeof unsubscribe === "function") {
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
};
|
||||
app.boundNwcSettingsToastHandler = null;
|
||||
Object.defineProperty(app, "savedProfiles", {
|
||||
configurable: false,
|
||||
@@ -899,6 +1003,7 @@ export default class ApplicationBootstrap {
|
||||
elements: {
|
||||
logoutButton: app.logoutButton,
|
||||
profileButton: app.profileButton,
|
||||
profileUnreadDot: app.profileUnreadDot,
|
||||
uploadButton: app.uploadButton,
|
||||
loginButton: app.loginButton,
|
||||
closeLoginModalButton: app.closeLoginModalBtn,
|
||||
|
||||
@@ -37,6 +37,13 @@ import {
|
||||
import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js";
|
||||
import { normalizeHashtag } from "../../utils/hashtagNormalization.js";
|
||||
import { getBreakpointLg } from "../../designSystem/metrics.js";
|
||||
import { LinkPreviewService } from "../../services/linkPreviewService.js";
|
||||
import {
|
||||
allowLinkPreviewDomain,
|
||||
getLinkPreviewSettings,
|
||||
isLinkPreviewDomainAllowed,
|
||||
subscribeToLinkPreviewSettings,
|
||||
} from "../../utils/linkPreviewSettings.js";
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const SIMILAR_CONTENT_LIMIT = 10;
|
||||
@@ -127,6 +134,7 @@ export class VideoModal {
|
||||
this.modalDownloaded = null;
|
||||
this.videoTitle = null;
|
||||
this.videoDescription = null;
|
||||
this.videoDescriptionPreviews = null;
|
||||
this.videoTimestamp = null;
|
||||
this.videoEditedTimestamp = null;
|
||||
this.videoViewCountEl = null;
|
||||
@@ -248,6 +256,12 @@ export class VideoModal {
|
||||
this.commentCallbacks = {
|
||||
teardown: null,
|
||||
};
|
||||
this.linkPreviewService = new LinkPreviewService();
|
||||
this.linkPreviewAbortControllers = new Map();
|
||||
this.linkPreviewSettingsUnsubscribe = null;
|
||||
this.currentDescriptionText = "";
|
||||
this.handleLinkPreviewSettingsChange =
|
||||
this.handleLinkPreviewSettingsChange.bind(this);
|
||||
this.boundCommentSubmitHandler =
|
||||
this.handleCommentComposerSubmit.bind(this);
|
||||
this.boundCommentInputHandler =
|
||||
@@ -598,6 +612,8 @@ export class VideoModal {
|
||||
this.videoTitle = playerModal.querySelector("#videoTitle") || null;
|
||||
this.videoDescription =
|
||||
playerModal.querySelector("#videoDescription") || null;
|
||||
this.videoDescriptionPreviews =
|
||||
playerModal.querySelector("#videoDescriptionPreviews") || null;
|
||||
this.videoTimestamp = playerModal.querySelector("#videoTimestamp") || null;
|
||||
this.videoEditedTimestamp =
|
||||
playerModal.querySelector("#videoEditedTimestamp") || null;
|
||||
@@ -650,6 +666,13 @@ export class VideoModal {
|
||||
|
||||
this.commentsController.initialize({ playerModal });
|
||||
|
||||
if (this.linkPreviewSettingsUnsubscribe) {
|
||||
this.linkPreviewSettingsUnsubscribe();
|
||||
}
|
||||
this.linkPreviewSettingsUnsubscribe = subscribeToLinkPreviewSettings(
|
||||
this.handleLinkPreviewSettingsChange,
|
||||
);
|
||||
|
||||
const closeButton = playerModal.querySelector("#closeModal");
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener("click", () => {
|
||||
@@ -1998,6 +2021,11 @@ export class VideoModal {
|
||||
this.similarContentController.destroy();
|
||||
this.moderationController.destroy();
|
||||
this.invokeCommentTeardown();
|
||||
this.clearLinkPreviews();
|
||||
if (this.linkPreviewSettingsUnsubscribe) {
|
||||
this.linkPreviewSettingsUnsubscribe();
|
||||
this.linkPreviewSettingsUnsubscribe = null;
|
||||
}
|
||||
|
||||
if (this.document) {
|
||||
this.document.removeEventListener(
|
||||
@@ -2783,6 +2811,7 @@ export class VideoModal {
|
||||
this.resetCommentComposer();
|
||||
this.renderVideoTags([]);
|
||||
this.clearSimilarContent();
|
||||
this.clearLinkPreviews();
|
||||
this.forceRemovePoster("close");
|
||||
}
|
||||
|
||||
@@ -4708,6 +4737,7 @@ export class VideoModal {
|
||||
}
|
||||
|
||||
if (description === null || description === undefined) {
|
||||
this.clearLinkPreviews();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4715,6 +4745,7 @@ export class VideoModal {
|
||||
typeof description === "string" ? description : String(description ?? "");
|
||||
|
||||
if (!normalized) {
|
||||
this.clearLinkPreviews();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4729,6 +4760,7 @@ export class VideoModal {
|
||||
});
|
||||
|
||||
target.appendChild(fragment);
|
||||
this.renderLinkPreviews(normalized);
|
||||
}
|
||||
|
||||
appendDescriptionLine(target, line) {
|
||||
@@ -4804,6 +4836,265 @@ export class VideoModal {
|
||||
return { anchor, trailing };
|
||||
}
|
||||
|
||||
handleLinkPreviewSettingsChange(event) {
|
||||
const detail = event?.detail?.settings || null;
|
||||
if (!this.videoDescriptionPreviews) {
|
||||
return;
|
||||
}
|
||||
const nextText = this.currentDescriptionText || "";
|
||||
if (!nextText) {
|
||||
return;
|
||||
}
|
||||
if (detail && typeof detail.autoFetchUnknownDomains === "boolean") {
|
||||
this.renderLinkPreviews(nextText);
|
||||
}
|
||||
}
|
||||
|
||||
clearLinkPreviewRequests() {
|
||||
if (!(this.linkPreviewAbortControllers instanceof Map)) {
|
||||
this.linkPreviewAbortControllers = new Map();
|
||||
return;
|
||||
}
|
||||
this.linkPreviewAbortControllers.forEach((controller) => {
|
||||
try {
|
||||
controller.abort();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
});
|
||||
this.linkPreviewAbortControllers.clear();
|
||||
}
|
||||
|
||||
clearLinkPreviews() {
|
||||
this.clearLinkPreviewRequests();
|
||||
this.currentDescriptionText = "";
|
||||
if (this.videoDescriptionPreviews) {
|
||||
this.videoDescriptionPreviews.textContent = "";
|
||||
this.videoDescriptionPreviews.setAttribute("hidden", "");
|
||||
}
|
||||
}
|
||||
|
||||
renderLinkPreviews(description) {
|
||||
const root = this.videoDescriptionPreviews;
|
||||
if (!root || !this.document) {
|
||||
return;
|
||||
}
|
||||
const text =
|
||||
typeof description === "string" ? description : String(description ?? "");
|
||||
this.currentDescriptionText = text;
|
||||
this.clearLinkPreviewRequests();
|
||||
root.textContent = "";
|
||||
|
||||
const urls = this.extractDescriptionUrls(text);
|
||||
if (!urls.length) {
|
||||
root.setAttribute("hidden", "");
|
||||
return;
|
||||
}
|
||||
|
||||
root.removeAttribute("hidden");
|
||||
const fragment = this.document.createDocumentFragment();
|
||||
urls.forEach((url) => {
|
||||
const domain = this.extractPreviewDomain(url);
|
||||
const card = this.createLinkPreviewCard({ url, domain });
|
||||
fragment.appendChild(card);
|
||||
void this.resolveLinkPreview(url, domain, card);
|
||||
});
|
||||
root.appendChild(fragment);
|
||||
}
|
||||
|
||||
extractDescriptionUrls(text) {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const urlPattern = /\bhttps?:\/\/[^\s<>"']+/gi;
|
||||
const urls = new Set();
|
||||
let match;
|
||||
while ((match = urlPattern.exec(text)) !== null) {
|
||||
const normalized = this.normalizePreviewUrl(match[0]);
|
||||
if (normalized) {
|
||||
urls.add(normalized);
|
||||
}
|
||||
}
|
||||
return Array.from(urls);
|
||||
}
|
||||
|
||||
normalizePreviewUrl(candidate) {
|
||||
if (!candidate) {
|
||||
return "";
|
||||
}
|
||||
let href = typeof candidate === "string" ? candidate : String(candidate ?? "");
|
||||
let trailing = "";
|
||||
const trailingPattern = /[)\]\}>"',.;!?]+$/;
|
||||
while (href && trailingPattern.test(href)) {
|
||||
trailing = href.slice(-1) + trailing;
|
||||
href = href.slice(0, -1);
|
||||
}
|
||||
return href.trim();
|
||||
}
|
||||
|
||||
extractPreviewDomain(url) {
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return new URL(url).hostname || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
createLinkPreviewCard({ url, domain }) {
|
||||
const card = this.document.createElement("article");
|
||||
card.className = "card flex flex-col gap-2 p-3";
|
||||
card.dataset.linkPreviewUrl = url;
|
||||
|
||||
const header = this.document.createElement("div");
|
||||
header.className = "flex items-start gap-3";
|
||||
|
||||
const imageWrapper = this.document.createElement("div");
|
||||
imageWrapper.className =
|
||||
"flex h-16 w-16 flex-shrink-0 items-center justify-center overflow-hidden rounded-md border border-border/60 bg-surface-muted text-2xs text-muted";
|
||||
const image = this.document.createElement("img");
|
||||
image.hidden = true;
|
||||
image.loading = "lazy";
|
||||
image.decoding = "async";
|
||||
image.className = "h-full w-full object-cover";
|
||||
const imagePlaceholder = this.document.createElement("span");
|
||||
imagePlaceholder.textContent = "Preview";
|
||||
imageWrapper.appendChild(image);
|
||||
imageWrapper.appendChild(imagePlaceholder);
|
||||
|
||||
const content = this.document.createElement("div");
|
||||
content.className = "flex min-w-0 flex-1 flex-col gap-1";
|
||||
|
||||
const domainEl = this.document.createElement("p");
|
||||
domainEl.className = "text-3xs uppercase tracking-wide text-muted";
|
||||
domainEl.textContent = domain || "Link preview";
|
||||
|
||||
const titleEl = this.document.createElement("a");
|
||||
titleEl.className =
|
||||
"text-sm font-semibold text-text break-words hover:underline focus-ring";
|
||||
titleEl.href = url;
|
||||
titleEl.target = "_blank";
|
||||
titleEl.rel = "noopener noreferrer";
|
||||
titleEl.textContent = url;
|
||||
|
||||
const descEl = this.document.createElement("p");
|
||||
descEl.className = "text-xs text-muted";
|
||||
descEl.textContent = "";
|
||||
|
||||
content.appendChild(domainEl);
|
||||
content.appendChild(titleEl);
|
||||
content.appendChild(descEl);
|
||||
|
||||
header.appendChild(imageWrapper);
|
||||
header.appendChild(content);
|
||||
|
||||
const statusEl = this.document.createElement("p");
|
||||
statusEl.className = "text-xs text-muted";
|
||||
|
||||
const actions = this.document.createElement("div");
|
||||
actions.className = "flex flex-wrap gap-2";
|
||||
|
||||
card.appendChild(header);
|
||||
card.appendChild(statusEl);
|
||||
card.appendChild(actions);
|
||||
|
||||
card.__linkPreviewElements = {
|
||||
imageWrapper,
|
||||
image,
|
||||
imagePlaceholder,
|
||||
domainEl,
|
||||
titleEl,
|
||||
descEl,
|
||||
statusEl,
|
||||
actions,
|
||||
};
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
setLinkPreviewStatus(card, { message = "", actionLabel = "", onAction } = {}) {
|
||||
const elements = card?.__linkPreviewElements;
|
||||
if (!elements) {
|
||||
return;
|
||||
}
|
||||
const statusText = typeof message === "string" ? message : "";
|
||||
elements.statusEl.textContent = statusText;
|
||||
elements.actions.textContent = "";
|
||||
|
||||
if (actionLabel && typeof onAction === "function") {
|
||||
const button = this.document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn-ghost focus-ring text-xs";
|
||||
button.textContent = actionLabel;
|
||||
button.addEventListener("click", onAction);
|
||||
elements.actions.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
updateLinkPreviewCard(card, preview, { domain } = {}) {
|
||||
const elements = card?.__linkPreviewElements;
|
||||
if (!elements || !preview) {
|
||||
return;
|
||||
}
|
||||
const title = preview.title || preview.siteName || preview.url || "";
|
||||
elements.titleEl.textContent = title || preview.url;
|
||||
elements.descEl.textContent = preview.description || "";
|
||||
const label = preview.siteName || domain || "Link preview";
|
||||
elements.domainEl.textContent = label;
|
||||
|
||||
if (preview.image) {
|
||||
elements.image.src = preview.image;
|
||||
elements.image.alt = title || "Link preview";
|
||||
elements.image.hidden = false;
|
||||
elements.imagePlaceholder.hidden = true;
|
||||
} else {
|
||||
elements.image.hidden = true;
|
||||
elements.imagePlaceholder.hidden = false;
|
||||
}
|
||||
|
||||
this.setLinkPreviewStatus(card, { message: "" });
|
||||
}
|
||||
|
||||
async resolveLinkPreview(url, domain, card) {
|
||||
const settings = getLinkPreviewSettings();
|
||||
const isAllowed =
|
||||
settings.autoFetchUnknownDomains ||
|
||||
isLinkPreviewDomainAllowed(domain, settings);
|
||||
|
||||
if (!isAllowed) {
|
||||
this.setLinkPreviewStatus(card, {
|
||||
message: "Preview disabled for new domains.",
|
||||
actionLabel: domain ? `Allow previews for ${domain}` : "Allow previews",
|
||||
onAction: () => {
|
||||
if (domain) {
|
||||
allowLinkPreviewDomain(domain);
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLinkPreviewStatus(card, { message: "Loading preview…" });
|
||||
const controller = new AbortController();
|
||||
this.linkPreviewAbortControllers.set(url, controller);
|
||||
const preview = await this.linkPreviewService.getPreview(url, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
this.linkPreviewAbortControllers.delete(url);
|
||||
|
||||
if (!preview) {
|
||||
this.setLinkPreviewStatus(card, { message: "Preview unavailable." });
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateLinkPreviewCard(card, preview, { domain });
|
||||
if (domain) {
|
||||
allowLinkPreviewDomain(domain, { silent: true });
|
||||
}
|
||||
}
|
||||
|
||||
updateTimestamps({ posted, edited } = {}) {
|
||||
if (this.videoTimestamp) {
|
||||
if (posted) {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { Composer } from "./Composer.js";
|
||||
import { ConversationList } from "./ConversationList.js";
|
||||
import { MessageThread } from "./MessageThread.js";
|
||||
import { NotificationCenter } from "./NotificationCenter.js";
|
||||
import { DMPrivacySettings } from "./DMPrivacySettings.js";
|
||||
import { aggregateZapTotals } from "./zapHelpers.js";
|
||||
|
||||
function createElement(doc, tag, className, text) {
|
||||
const element = doc.createElement(tag);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
if (typeof text === "string") {
|
||||
element.textContent = text;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
const DEFAULT_CONVERSATIONS = [
|
||||
{
|
||||
id: "conv-1",
|
||||
name: "Nia Nova",
|
||||
preview: "The relay looks stable now.",
|
||||
timestamp: "2m",
|
||||
unreadCount: 2,
|
||||
status: "Online",
|
||||
avatarSrc: "",
|
||||
pubkey: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
|
||||
lightningAddress: "[email protected]",
|
||||
relayHints: ["wss://relay.damus.io", "wss://nostr.wine"],
|
||||
},
|
||||
{
|
||||
id: "conv-2",
|
||||
name: "Orbit Ops",
|
||||
preview: "Can you share the stream link?",
|
||||
timestamp: "1h",
|
||||
unreadCount: 0,
|
||||
status: "Away",
|
||||
avatarSrc: "",
|
||||
pubkey: "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
|
||||
lightningAddress: "[email protected]",
|
||||
relayHints: ["wss://relay.primal.net"],
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_MESSAGES = [
|
||||
{ type: "day", label: "Today" },
|
||||
{
|
||||
id: "msg-1",
|
||||
direction: "incoming",
|
||||
body: "Hey! The new DM view looks promising.",
|
||||
timestamp: "09:12",
|
||||
},
|
||||
{
|
||||
id: "msg-2",
|
||||
direction: "outgoing",
|
||||
body: "Thanks! I'm wiring up the layout and keyboard flow now.",
|
||||
timestamp: "09:14",
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
id: "msg-3",
|
||||
direction: "outgoing",
|
||||
body: "Next up: privacy mode toggles and attachment rails.",
|
||||
timestamp: "09:16",
|
||||
status: "sent",
|
||||
},
|
||||
{
|
||||
id: "msg-4",
|
||||
direction: "outgoing",
|
||||
body: "I can resend if this didn't go through.",
|
||||
timestamp: "09:18",
|
||||
status: "failed",
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_ZAP_RECEIPTS = [
|
||||
{
|
||||
id: "zap-1",
|
||||
kind: 9735,
|
||||
conversationId: "conv-1",
|
||||
profileId: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
|
||||
senderName: "You",
|
||||
amountSats: 120,
|
||||
note: "For the quick relay fix!",
|
||||
timestamp: "09:20",
|
||||
status: "confirmed",
|
||||
},
|
||||
{
|
||||
id: "zap-2",
|
||||
kind: 9735,
|
||||
conversationId: "conv-1",
|
||||
profileId: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
|
||||
senderName: "Nia Nova",
|
||||
amountSats: 42,
|
||||
note: "Thanks for testing the DM flow.",
|
||||
timestamp: "09:22",
|
||||
status: "confirmed",
|
||||
},
|
||||
{
|
||||
id: "zap-3",
|
||||
kind: 9735,
|
||||
conversationId: "conv-2",
|
||||
profileId: "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
|
||||
senderName: "You",
|
||||
amountSats: 250,
|
||||
note: "Ops support fund.",
|
||||
timestamp: "Yesterday",
|
||||
status: "pending",
|
||||
},
|
||||
];
|
||||
|
||||
export class AppShell {
|
||||
constructor({
|
||||
document: doc,
|
||||
conversations = DEFAULT_CONVERSATIONS,
|
||||
activeConversationId = DEFAULT_CONVERSATIONS[0]?.id || "",
|
||||
conversationState = "idle",
|
||||
messages = DEFAULT_MESSAGES,
|
||||
zapReceipts = DEFAULT_ZAP_RECEIPTS,
|
||||
threadState = "idle",
|
||||
threadErrorType = "",
|
||||
composerState = "idle",
|
||||
privacyMode = "nip04",
|
||||
dmPrivacySettings = { readReceiptsEnabled: false, typingIndicatorsEnabled: false },
|
||||
notifications = [],
|
||||
signingAdapter = null,
|
||||
zapConfig = null,
|
||||
mobileView = "list",
|
||||
onSelectConversation,
|
||||
onSendMessage,
|
||||
onSendZap,
|
||||
onMarkConversationRead,
|
||||
onMarkAllRead,
|
||||
onToggleReadReceipts,
|
||||
onToggleTypingIndicators,
|
||||
onBack,
|
||||
} = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("AppShell requires a document reference.");
|
||||
}
|
||||
|
||||
this.document = doc;
|
||||
this.root = createElement(doc, "div", "dm-app-shell");
|
||||
if (typeof mobileView === "string") {
|
||||
this.root.dataset.dmView = mobileView;
|
||||
}
|
||||
|
||||
const { totalsByConversation, totalsByProfile } = aggregateZapTotals(zapReceipts);
|
||||
const enrichedConversations = conversations.map((conversation) => ({
|
||||
...conversation,
|
||||
zapTotalSats: totalsByConversation.get(conversation.id) || 0,
|
||||
profileZapTotalSats: totalsByProfile.get(conversation.pubkey) || 0,
|
||||
}));
|
||||
|
||||
const activeConversation =
|
||||
enrichedConversations.find((conversation) => conversation.id === activeConversationId) ||
|
||||
enrichedConversations[0] ||
|
||||
{};
|
||||
const activeZapReceipts = zapReceipts.filter(
|
||||
(receipt) => receipt.conversationId === activeConversation.id,
|
||||
);
|
||||
|
||||
const sidebar = createElement(doc, "aside", "dm-app-shell__sidebar");
|
||||
const sidebarHeader = createElement(doc, "div", "dm-app-shell__sidebar-header flex items-center justify-between p-4 relative");
|
||||
const sidebarTitle = createElement(
|
||||
doc,
|
||||
"h1",
|
||||
"dm-app-shell__title text-lg font-bold",
|
||||
"Direct Messages",
|
||||
);
|
||||
sidebarHeader.appendChild(sidebarTitle);
|
||||
|
||||
const settingsBtn = createElement(doc, "button", "btn-ghost btn-icon");
|
||||
settingsBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`;
|
||||
settingsBtn.ariaLabel = "Direct message settings";
|
||||
settingsBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
let popover = sidebar.querySelector(".dm-settings-popover");
|
||||
if (popover) {
|
||||
popover.remove();
|
||||
return;
|
||||
}
|
||||
popover = DMPrivacySettings({
|
||||
document: doc,
|
||||
readReceiptsEnabled: dmPrivacySettings.readReceiptsEnabled,
|
||||
typingIndicatorsEnabled: dmPrivacySettings.typingIndicatorsEnabled,
|
||||
onToggleReadReceipts,
|
||||
onToggleTypingIndicators,
|
||||
});
|
||||
popover.classList.add("dm-settings-popover", "absolute", "z-50", "right-4", "top-12", "bg-surface", "border", "border-border", "rounded-xl");
|
||||
sidebarHeader.appendChild(popover);
|
||||
|
||||
const closeHandler = (event) => {
|
||||
if (!popover.contains(event.target) && event.target !== settingsBtn && !settingsBtn.contains(event.target)) {
|
||||
popover.remove();
|
||||
doc.removeEventListener("click", closeHandler);
|
||||
}
|
||||
};
|
||||
doc.addEventListener("click", closeHandler);
|
||||
});
|
||||
sidebarHeader.appendChild(settingsBtn);
|
||||
sidebar.appendChild(sidebarHeader);
|
||||
|
||||
sidebar.appendChild(
|
||||
ConversationList({
|
||||
document: doc,
|
||||
conversations: enrichedConversations,
|
||||
activeId: activeConversationId,
|
||||
state: conversationState,
|
||||
onSelect: onSelectConversation,
|
||||
onMarkAllRead,
|
||||
}),
|
||||
);
|
||||
|
||||
const main = createElement(doc, "main", "dm-app-shell__main");
|
||||
|
||||
main.appendChild(
|
||||
MessageThread({
|
||||
document: doc,
|
||||
contact: activeConversation,
|
||||
messages,
|
||||
state: threadState,
|
||||
errorType: threadErrorType,
|
||||
privacyMode,
|
||||
zapReceipts: activeZapReceipts,
|
||||
conversationZapTotalSats:
|
||||
totalsByConversation.get(activeConversation.id) || 0,
|
||||
profileZapTotalSats: totalsByProfile.get(activeConversation.pubkey) || 0,
|
||||
onMarkRead: onMarkConversationRead,
|
||||
onSendZap,
|
||||
onBack,
|
||||
zapConfig,
|
||||
}),
|
||||
);
|
||||
main.appendChild(
|
||||
Composer({
|
||||
document: doc,
|
||||
state: composerState,
|
||||
privacyMode,
|
||||
signingAdapter,
|
||||
zapRecipient: activeConversation,
|
||||
zapConfig,
|
||||
onSend: onSendMessage,
|
||||
// Zap button moved to MessageThread, so onSendZap here might be redundant if triggered from thread
|
||||
// But Composer still handles text message sending.
|
||||
}),
|
||||
);
|
||||
main.appendChild(
|
||||
NotificationCenter({
|
||||
document: doc,
|
||||
notices: notifications,
|
||||
}),
|
||||
);
|
||||
|
||||
this.root.appendChild(sidebar);
|
||||
this.root.appendChild(main);
|
||||
}
|
||||
|
||||
getRoot() {
|
||||
return this.root;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
const DEFAULT_AVATAR_ALT = "Avatar";
|
||||
|
||||
function getInitials(label = "") {
|
||||
if (typeof label !== "string") {
|
||||
return "";
|
||||
}
|
||||
const words = label.trim().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) {
|
||||
return "";
|
||||
}
|
||||
const initials = words.slice(0, 2).map((word) => word[0].toUpperCase());
|
||||
return initials.join("");
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
document: doc,
|
||||
src = "",
|
||||
alt = DEFAULT_AVATAR_ALT,
|
||||
size = "md",
|
||||
initials = "",
|
||||
status = "",
|
||||
} = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("Avatar requires a document reference.");
|
||||
}
|
||||
|
||||
const avatar = doc.createElement("div");
|
||||
// Explicit mapping ensures Tailwind scanner detects these classes
|
||||
const sizeClass = {
|
||||
sm: "dm-avatar--sm",
|
||||
md: "dm-avatar--md",
|
||||
}[size] || `dm-avatar--${size}`;
|
||||
|
||||
avatar.className = `dm-avatar ${sizeClass}`;
|
||||
if (status) {
|
||||
avatar.dataset.status = status;
|
||||
}
|
||||
|
||||
const resolvedInitials = initials || getInitials(alt);
|
||||
|
||||
if (src) {
|
||||
const img = doc.createElement("img");
|
||||
img.className = "dm-avatar__image";
|
||||
img.alt = alt || DEFAULT_AVATAR_ALT;
|
||||
img.loading = "lazy";
|
||||
img.decoding = "async";
|
||||
img.src = src;
|
||||
avatar.appendChild(img);
|
||||
} else {
|
||||
const fallback = doc.createElement("span");
|
||||
fallback.className = "dm-avatar__fallback";
|
||||
fallback.textContent = resolvedInitials || "?";
|
||||
avatar.appendChild(fallback);
|
||||
}
|
||||
|
||||
return avatar;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
function createElement(doc, tag, className, text) {
|
||||
const element = doc.createElement(tag);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
if (typeof text === "string") {
|
||||
element.textContent = text;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
document: doc,
|
||||
placeholder = "Write a message…",
|
||||
state = "idle",
|
||||
privacyMode: initialPrivacyMode = "nip04",
|
||||
signingAdapter = null,
|
||||
zapRecipient = null,
|
||||
zapConfig = null,
|
||||
onSend,
|
||||
} = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("Composer requires a document reference.");
|
||||
}
|
||||
|
||||
const form = createElement(doc, "form", "dm-composer");
|
||||
form.noValidate = true;
|
||||
let privacyMode = initialPrivacyMode;
|
||||
|
||||
const label = createElement(doc, "label", "dm-composer__label", "Message");
|
||||
label.setAttribute("for", "dm-composer-input");
|
||||
|
||||
const textarea = createElement(doc, "textarea", "dm-composer__input");
|
||||
textarea.id = "dm-composer-input";
|
||||
textarea.placeholder = placeholder;
|
||||
textarea.rows = 2;
|
||||
textarea.setAttribute("aria-label", "Message");
|
||||
|
||||
const hintId = "dm-composer-hint";
|
||||
|
||||
const actions = createElement(doc, "div", "dm-composer__actions");
|
||||
const tools = createElement(doc, "div", "dm-composer__tools");
|
||||
const hint = createElement(
|
||||
doc,
|
||||
"span",
|
||||
"dm-composer__hint",
|
||||
"Enter to send, Shift + Enter for newline",
|
||||
);
|
||||
hint.id = hintId;
|
||||
textarea.setAttribute("aria-describedby", hintId);
|
||||
|
||||
const attachButton = createElement(
|
||||
doc,
|
||||
"button",
|
||||
"dm-composer__attach",
|
||||
"Attach",
|
||||
);
|
||||
attachButton.type = "button";
|
||||
attachButton.setAttribute("aria-label", "Attach a file");
|
||||
|
||||
const moreButton = createElement(
|
||||
doc,
|
||||
"button",
|
||||
"btn-ghost dm-composer__more-btn",
|
||||
"…",
|
||||
);
|
||||
moreButton.type = "button";
|
||||
moreButton.setAttribute("aria-label", "More options");
|
||||
moreButton.setAttribute("aria-expanded", "false");
|
||||
moreButton.setAttribute("aria-haspopup", "true");
|
||||
|
||||
const moreMenu = createElement(doc, "div", "dm-composer__more-menu hidden");
|
||||
moreMenu.setAttribute("role", "menu");
|
||||
|
||||
const privacyToggle = createElement(
|
||||
doc,
|
||||
"button",
|
||||
"dm-composer__menu-item",
|
||||
"NIP-04",
|
||||
);
|
||||
privacyToggle.type = "button";
|
||||
privacyToggle.setAttribute("role", "menuitem");
|
||||
privacyToggle.setAttribute("aria-pressed", "false");
|
||||
privacyToggle.setAttribute("aria-label", "Toggle privacy mode");
|
||||
|
||||
moreMenu.appendChild(privacyToggle);
|
||||
|
||||
tools.appendChild(attachButton);
|
||||
tools.appendChild(moreButton);
|
||||
tools.appendChild(moreMenu);
|
||||
tools.appendChild(hint);
|
||||
|
||||
moreButton.addEventListener("click", () => {
|
||||
const isExpanded = moreButton.getAttribute("aria-expanded") === "true";
|
||||
moreButton.setAttribute("aria-expanded", String(!isExpanded));
|
||||
if (!isExpanded) {
|
||||
moreMenu.classList.remove("hidden");
|
||||
} else {
|
||||
moreMenu.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
const button = createElement(doc, "button", "dm-composer__send", "Send");
|
||||
button.type = "submit";
|
||||
button.setAttribute("aria-label", "Send message");
|
||||
|
||||
actions.appendChild(tools);
|
||||
actions.appendChild(button);
|
||||
|
||||
const status = createElement(doc, "div", "dm-composer__status");
|
||||
if (state === "error") {
|
||||
status.textContent = "Send failed. Try again.";
|
||||
} else if (state === "sending") {
|
||||
status.textContent = "Sending…";
|
||||
}
|
||||
|
||||
form.appendChild(label);
|
||||
form.appendChild(textarea);
|
||||
form.appendChild(actions);
|
||||
form.appendChild(status);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (typeof onSend !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { privacyMode, attachments: [] };
|
||||
if (signingAdapter) {
|
||||
try {
|
||||
if (typeof signingAdapter.getPubkey === "function") {
|
||||
payload.pubkey = await signingAdapter.getPubkey();
|
||||
}
|
||||
if (typeof signingAdapter.getDisplayName === "function") {
|
||||
payload.displayName = await signingAdapter.getDisplayName();
|
||||
}
|
||||
if (typeof signingAdapter.signMessage === "function") {
|
||||
payload.signature = await signingAdapter.signMessage(textarea.value);
|
||||
}
|
||||
} catch (error) {
|
||||
payload.signingError =
|
||||
error instanceof Error ? error.message : "Signing failed.";
|
||||
}
|
||||
}
|
||||
|
||||
onSend(textarea.value, payload);
|
||||
};
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
});
|
||||
|
||||
const updatePrivacyLabel = () => {
|
||||
const normalized =
|
||||
typeof privacyMode === "string" ? privacyMode.trim().toLowerCase() : "";
|
||||
const isNip17 = normalized === "nip17" || normalized === "private";
|
||||
privacyToggle.textContent = isNip17 ? "NIP-17" : "NIP-04";
|
||||
privacyToggle.setAttribute("aria-pressed", isNip17 ? "true" : "false");
|
||||
privacyToggle.title = isNip17
|
||||
? "NIP-17 gift-wraps your DM so relays only see the wrapper and relay hints."
|
||||
: "NIP-04 sends a direct encrypted DM; relays can still see sender and recipient metadata.";
|
||||
};
|
||||
|
||||
updatePrivacyLabel();
|
||||
|
||||
privacyToggle.addEventListener("click", () => {
|
||||
const normalized =
|
||||
typeof privacyMode === "string" ? privacyMode.trim().toLowerCase() : "";
|
||||
const isNip17 = normalized === "nip17" || normalized === "private";
|
||||
privacyMode = isNip17 ? "nip04" : "nip17";
|
||||
updatePrivacyLabel();
|
||||
});
|
||||
|
||||
textarea.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Avatar } from "./Avatar.js";
|
||||
import { formatZapAmount } from "./zapHelpers.js";
|
||||
|
||||
function createElement(doc, tag, className, text) {
|
||||
const element = doc.createElement(tag);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
if (typeof text === "string") {
|
||||
element.textContent = text;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
export function ContactRow({
|
||||
document: doc,
|
||||
contact = {},
|
||||
isActive = false,
|
||||
tabIndex = -1,
|
||||
onSelect,
|
||||
} = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("ContactRow requires a document reference.");
|
||||
}
|
||||
|
||||
const {
|
||||
id = "",
|
||||
name = "Unknown",
|
||||
preview = "",
|
||||
timestamp = "",
|
||||
unreadCount = 0,
|
||||
avatarSrc = "",
|
||||
status = "",
|
||||
zapTotalSats = null,
|
||||
} = contact;
|
||||
|
||||
const button = createElement(doc, "button", "dm-contact-row");
|
||||
button.type = "button";
|
||||
button.dataset.conversationId = id;
|
||||
button.setAttribute("role", "option");
|
||||
button.setAttribute("aria-selected", String(Boolean(isActive)));
|
||||
button.tabIndex = tabIndex;
|
||||
if (isActive) {
|
||||
button.classList.add("dm-contact-row--active");
|
||||
}
|
||||
|
||||
const avatar = Avatar({
|
||||
document: doc,
|
||||
src: avatarSrc,
|
||||
alt: name,
|
||||
size: "md",
|
||||
initials: name,
|
||||
status,
|
||||
});
|
||||
button.appendChild(avatar);
|
||||
|
||||
const content = createElement(doc, "div", "dm-contact-row__content");
|
||||
const header = createElement(doc, "div", "dm-contact-row__header");
|
||||
const nameEl = createElement(doc, "span", "dm-contact-row__name", name);
|
||||
header.appendChild(nameEl);
|
||||
|
||||
const meta = createElement(doc, "span", "dm-contact-row__meta");
|
||||
|
||||
if (Number.isFinite(zapTotalSats)) {
|
||||
const zapTotal = createElement(
|
||||
doc,
|
||||
"span",
|
||||
"dm-contact-row__zap",
|
||||
formatZapAmount(zapTotalSats, { compact: true }),
|
||||
);
|
||||
zapTotal.setAttribute(
|
||||
"aria-label",
|
||||
`Total zaps ${formatZapAmount(zapTotalSats)}`,
|
||||
);
|
||||
meta.appendChild(zapTotal);
|
||||
}
|
||||
|
||||
header.appendChild(meta);
|
||||
content.appendChild(header);
|
||||
|
||||
if (unreadCount > 0) {
|
||||
const badge = createElement(doc, "span", "dm-contact-row__badge");
|
||||
badge.textContent = `${unreadCount}`;
|
||||
badge.setAttribute("aria-label", `${unreadCount} unread messages`);
|
||||
content.appendChild(badge);
|
||||
}
|
||||
|
||||
button.appendChild(content);
|
||||
|
||||
if (typeof onSelect === "function") {
|
||||
button.addEventListener("click", () => onSelect(contact));
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ContactRow } from "./ContactRow.js";
|
||||
|
||||
function createElement(doc, tag, className, text) {
|
||||
const element = doc.createElement(tag);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
if (typeof text === "string") {
|
||||
element.textContent = text;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function getFocusableItems(listEl) {
|
||||
return [...listEl.querySelectorAll("[data-conversation-id]")];
|
||||
}
|
||||
|
||||
export function ConversationList({
|
||||
document: doc,
|
||||
conversations = [],
|
||||
activeId = "",
|
||||
state = "idle",
|
||||
onSelect,
|
||||
onMarkAllRead,
|
||||
} = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("ConversationList requires a document reference.");
|
||||
}
|
||||
|
||||
const root = createElement(doc, "section", "dm-conversation-list");
|
||||
const header = createElement(doc, "div", "dm-conversation-list__header");
|
||||
header.appendChild(createElement(doc, "h2", "dm-conversation-list__title", "Messages"));
|
||||
if (typeof onMarkAllRead === "function") {
|
||||
const markAllButton = createElement(
|
||||
doc,
|
||||
"button",
|
||||
"btn-ghost",
|
||||
"Mark all as read",
|
||||
);
|
||||
markAllButton.type = "button";
|
||||
markAllButton.dataset.size = "sm";
|
||||
markAllButton.disabled = !conversations.length;
|
||||
markAllButton.addEventListener("click", () => {
|
||||
onMarkAllRead();
|
||||
});
|
||||
header.appendChild(markAllButton);
|
||||
}
|
||||
root.appendChild(header);
|
||||
|
||||
const list = createElement(doc, "div", "dm-conversation-list__items");
|
||||
list.setAttribute("role", "listbox");
|
||||
list.setAttribute("aria-label", "Conversation list");
|
||||
|
||||
const renderState = createElement(doc, "div", "dm-conversation-list__state");
|
||||
|
||||
if (state === "loading") {
|
||||
renderState.textContent = "Loading conversations…";
|
||||
list.appendChild(renderState);
|
||||
} else if (state === "empty") {
|
||||
renderState.textContent = "No conversations yet.";
|
||||
list.appendChild(renderState);
|
||||
} else if (state === "error") {
|
||||
renderState.textContent = "We couldn't load conversations.";
|
||||
list.appendChild(renderState);
|
||||
} else {
|
||||
const resolvedActiveId = activeId || (conversations[0] ? conversations[0].id : "");
|
||||
conversations.forEach((conversation, index) => {
|
||||
const isActive = conversation.id === resolvedActiveId;
|
||||
const row = ContactRow({
|
||||
document: doc,
|
||||
contact: conversation,
|
||||
isActive,
|
||||
tabIndex: isActive || (!resolvedActiveId && index === 0) ? 0 : -1,
|
||||
onSelect,
|
||||
});
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
list.addEventListener("keydown", (event) => {
|
||||
const items = getFocusableItems(list);
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = items.indexOf(doc.activeElement);
|
||||
let nextIndex = currentIndex;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
nextIndex = Math.min(items.length - 1, currentIndex + 1);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
nextIndex = Math.max(0, currentIndex - 1);
|
||||
break;
|
||||
case "Home":
|
||||
nextIndex = 0;
|
||||
break;
|
||||
case "End":
|
||||
nextIndex = items.length - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
items[nextIndex].focus();
|
||||
});
|
||||
|
||||
root.appendChild(list);
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export function DMPrivacySettings({
|
||||
document: doc,
|
||||
readReceiptsEnabled = false,
|
||||
typingIndicatorsEnabled = false,
|
||||
onToggleReadReceipts,
|
||||
onToggleTypingIndicators,
|
||||
} = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("DMPrivacySettings requires a document reference.");
|
||||
}
|
||||
|
||||
const root = doc.createElement("div");
|
||||
root.className = "dm-privacy-settings card p-4 space-y-4 shadow-lg";
|
||||
|
||||
const title = doc.createElement("h4");
|
||||
title.className = "text-sm font-semibold text-text";
|
||||
title.textContent = "Direct message privacy";
|
||||
root.appendChild(title);
|
||||
|
||||
const desc = doc.createElement("p");
|
||||
desc.className = "text-xs text-muted mb-4";
|
||||
desc.textContent = "Control read receipts and typing indicators for direct messages.";
|
||||
root.appendChild(desc);
|
||||
|
||||
// Read Receipts Toggle
|
||||
const readReceiptsRow = doc.createElement("div");
|
||||
readReceiptsRow.className = "flex items-center justify-between";
|
||||
|
||||
const readReceiptsLabel = doc.createElement("label");
|
||||
readReceiptsLabel.className = "text-sm text-text cursor-pointer";
|
||||
readReceiptsLabel.textContent = "Read receipts";
|
||||
readReceiptsLabel.setAttribute("for", "dm-read-receipts-toggle");
|
||||
readReceiptsRow.appendChild(readReceiptsLabel);
|
||||
|
||||
const readReceiptsToggle = doc.createElement("input");
|
||||
readReceiptsToggle.type = "checkbox";
|
||||
readReceiptsToggle.id = "dm-read-receipts-toggle";
|
||||
readReceiptsToggle.className = "toggle toggle-sm toggle-accent";
|
||||
readReceiptsToggle.checked = readReceiptsEnabled;
|
||||
readReceiptsToggle.addEventListener("change", (e) => {
|
||||
if (typeof onToggleReadReceipts === "function") {
|
||||
onToggleReadReceipts(e.target.checked);
|
||||
}
|
||||
});
|
||||
readReceiptsRow.appendChild(readReceiptsToggle);
|
||||
root.appendChild(readReceiptsRow);
|
||||
|
||||
// Typing Indicators Toggle
|
||||
const typingIndicatorsRow = doc.createElement("div");
|
||||
typingIndicatorsRow.className = "flex items-center justify-between";
|
||||
|
||||
const typingIndicatorsLabel = doc.createElement("label");
|
||||
typingIndicatorsLabel.className = "text-sm text-text cursor-pointer";
|
||||
typingIndicatorsLabel.textContent = "Typing indicators";
|
||||
typingIndicatorsLabel.setAttribute("for", "dm-typing-indicators-toggle");
|
||||
typingIndicatorsRow.appendChild(typingIndicatorsLabel);
|
||||
|
||||
const typingIndicatorsToggle = doc.createElement("input");
|
||||
typingIndicatorsToggle.type = "checkbox";
|
||||
typingIndicatorsToggle.id = "dm-typing-indicators-toggle";
|
||||
typingIndicatorsToggle.className = "toggle toggle-sm toggle-accent";
|
||||
typingIndicatorsToggle.checked = typingIndicatorsEnabled;
|
||||
typingIndicatorsToggle.addEventListener("change", (e) => {
|
||||
if (typeof onToggleTypingIndicators === "function") {
|
||||
onToggleTypingIndicators(e.target.checked);
|
||||
}
|
||||
});
|
||||
typingIndicatorsRow.appendChild(typingIndicatorsToggle);
|
||||
root.appendChild(typingIndicatorsRow);
|
||||
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
function createElement(doc, tag, className, text) {
|
||||
const element = doc.createElement(tag);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
if (typeof text === "string") {
|
||||
element.textContent = text;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
export function DayDivider({ document: doc, label = "" } = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("DayDivider requires a document reference.");
|
||||
}
|
||||
|
||||
const divider = createElement(doc, "div", "dm-day-divider");
|
||||
const line = createElement(doc, "span", "dm-day-divider__line");
|
||||
const text = createElement(doc, "span", "dm-day-divider__label", label);
|
||||
divider.appendChild(line);
|
||||
divider.appendChild(text);
|
||||
divider.appendChild(line.cloneNode());
|
||||
return divider;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
function createElement(doc, tag, className, text) {
|
||||
const element = doc.createElement(tag);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
if (typeof text === "string") {
|
||||
element.textContent = text;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
document: doc,
|
||||
message = {},
|
||||
variant = "incoming",
|
||||
} = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("MessageBubble requires a document reference.");
|
||||
}
|
||||
|
||||
const {
|
||||
body = "",
|
||||
timestamp = "",
|
||||
status = "sent",
|
||||
} = message;
|
||||
|
||||
const wrapper = createElement(doc, "div", "dm-message-bubble");
|
||||
wrapper.classList.add(`dm-message-bubble--${variant}`);
|
||||
wrapper.dataset.status = status;
|
||||
|
||||
const content = createElement(doc, "div", "dm-message-bubble__content", body);
|
||||
wrapper.appendChild(content);
|
||||
|
||||
const meta = createElement(doc, "div", "dm-message-bubble__meta");
|
||||
if (timestamp) {
|
||||
// Added 'text-muted' class for theme-respecting color (usually handles light/dark text)
|
||||
const timeSpan = createElement(doc, "span", "dm-message-bubble__time text-muted", timestamp);
|
||||
meta.appendChild(timeSpan);
|
||||
}
|
||||
|
||||
if (variant === "outgoing") {
|
||||
const statusLabel = createElement(
|
||||
doc,
|
||||
"span",
|
||||
"dm-message-bubble__status",
|
||||
status === "failed"
|
||||
? "Failed to send"
|
||||
: status === "pending"
|
||||
? "Pending…"
|
||||
: status === "sending"
|
||||
? "Sending…"
|
||||
: "Sent",
|
||||
);
|
||||
statusLabel.dataset.status = status;
|
||||
meta.appendChild(statusLabel);
|
||||
}
|
||||
|
||||
wrapper.appendChild(meta);
|
||||
return wrapper;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { DayDivider } from "./DayDivider.js";
|
||||
import { MessageBubble } from "./MessageBubble.js";
|
||||
import { ZapReceiptList } from "./ZapReceiptList.js";
|
||||
import { formatZapAmount } from "./zapHelpers.js";
|
||||
import { createAndPublishZapRequest, resolveZapRecipient } from "../../payments/zapRequests.js";
|
||||
import { userLogger, devLogger } from "../../utils/logger.js";
|
||||
|
||||
function createElement(doc, tag, className, text) {
|
||||
const element = doc.createElement(tag);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
if (typeof text === "string") {
|
||||
element.textContent = text;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
export function MessageThread({
|
||||
document: doc,
|
||||
contact = {},
|
||||
messages = [],
|
||||
state = "idle",
|
||||
errorType = "",
|
||||
privacyMode = "nip04",
|
||||
zapReceipts = [],
|
||||
conversationZapTotalSats = 0,
|
||||
profileZapTotalSats = 0,
|
||||
onMarkRead,
|
||||
onSendZap,
|
||||
onBack,
|
||||
zapConfig = null,
|
||||
} = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("MessageThread requires a document reference.");
|
||||
}
|
||||
|
||||
const root = createElement(doc, "section", "dm-message-thread");
|
||||
|
||||
const header = createElement(doc, "header", "dm-message-thread__header");
|
||||
|
||||
if (typeof onBack === "function") {
|
||||
const backBtn = createElement(doc, "button", "dm-message-thread__back btn-ghost btn-icon");
|
||||
backBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>`;
|
||||
backBtn.ariaLabel = "Back to conversations";
|
||||
backBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
onBack();
|
||||
});
|
||||
header.appendChild(backBtn);
|
||||
}
|
||||
|
||||
header.appendChild(
|
||||
createElement(
|
||||
doc,
|
||||
"h3",
|
||||
"dm-message-thread__title",
|
||||
contact.name || "Direct Messages",
|
||||
),
|
||||
);
|
||||
if (contact.status) {
|
||||
header.appendChild(
|
||||
createElement(doc, "span", "dm-message-thread__status", contact.status),
|
||||
);
|
||||
}
|
||||
const normalizedPrivacy =
|
||||
typeof privacyMode === "string" ? privacyMode.trim().toLowerCase() : "";
|
||||
const isNip17 = normalizedPrivacy === "nip17" || normalizedPrivacy === "private";
|
||||
const privacyLabel = `Privacy: ${isNip17 ? "NIP-17" : "NIP-04"}`;
|
||||
const privacyHint = isNip17
|
||||
? "NIP-17 gift-wraps your DM so relays only see the wrapper and relay hints."
|
||||
: "NIP-04 sends a direct encrypted DM; relays can still see sender and recipient metadata.";
|
||||
const privacyBadge = createElement(
|
||||
doc,
|
||||
"span",
|
||||
"dm-message-thread__status",
|
||||
privacyLabel,
|
||||
);
|
||||
privacyBadge.title = privacyHint;
|
||||
header.appendChild(privacyBadge);
|
||||
|
||||
const zapSummary = createElement(doc, "div", "dm-message-thread__zap-summary");
|
||||
zapSummary.appendChild(
|
||||
createElement(doc, "span", "dm-message-thread__zap-label", "Zaps"),
|
||||
);
|
||||
zapSummary.appendChild(
|
||||
createElement(
|
||||
doc,
|
||||
"span",
|
||||
"dm-message-thread__zap-total",
|
||||
formatZapAmount(conversationZapTotalSats),
|
||||
),
|
||||
);
|
||||
zapSummary.appendChild(
|
||||
createElement(
|
||||
doc,
|
||||
"span",
|
||||
"dm-message-thread__zap-profile",
|
||||
`Profile: ${formatZapAmount(profileZapTotalSats)}`,
|
||||
),
|
||||
);
|
||||
header.appendChild(zapSummary);
|
||||
|
||||
// Zap Button
|
||||
const zapBtn = createElement(doc, "button", "btn-ghost btn-icon");
|
||||
zapBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`;
|
||||
zapBtn.ariaLabel = "Zap";
|
||||
zapBtn.addEventListener("click", () => {
|
||||
const existingInterface = root.querySelector(".dm-zap-interface");
|
||||
if (existingInterface) {
|
||||
existingInterface.remove();
|
||||
return;
|
||||
}
|
||||
const zapInterface = createZapInterface(doc, { contact, zapReceipts, onSendZap, zapConfig });
|
||||
root.appendChild(zapInterface);
|
||||
});
|
||||
header.appendChild(zapBtn);
|
||||
|
||||
if (typeof onMarkRead === "function") {
|
||||
const actions = createElement(doc, "div", "ml-auto flex items-center gap-2");
|
||||
const markReadButton = createElement(doc, "button", "btn-ghost", "Mark read");
|
||||
markReadButton.type = "button";
|
||||
markReadButton.dataset.size = "sm";
|
||||
const conversationId =
|
||||
typeof contact.id === "string" ? contact.id.trim() : "";
|
||||
if (!conversationId) {
|
||||
markReadButton.disabled = true;
|
||||
}
|
||||
markReadButton.addEventListener("click", () => {
|
||||
onMarkRead(contact);
|
||||
});
|
||||
actions.appendChild(markReadButton);
|
||||
header.appendChild(actions);
|
||||
}
|
||||
root.appendChild(header);
|
||||
|
||||
const timeline = createElement(doc, "div", "dm-message-thread__timeline");
|
||||
timeline.setAttribute("role", "log");
|
||||
timeline.setAttribute("aria-live", "polite");
|
||||
|
||||
const newMessagesBanner = createElement(
|
||||
doc,
|
||||
"button",
|
||||
"dm-message-thread__new",
|
||||
"New messages",
|
||||
);
|
||||
newMessagesBanner.type = "button";
|
||||
newMessagesBanner.setAttribute("aria-label", "Jump to newest messages");
|
||||
timeline.appendChild(newMessagesBanner);
|
||||
|
||||
const stateEl = createElement(doc, "div", "dm-message-thread__state");
|
||||
|
||||
if (state === "loading") {
|
||||
stateEl.textContent = "Decrypting messages…";
|
||||
timeline.appendChild(stateEl);
|
||||
} else if (state === "empty") {
|
||||
stateEl.textContent = "Say hello to start the conversation.";
|
||||
timeline.appendChild(stateEl);
|
||||
} else if (state === "error") {
|
||||
const errorMessage =
|
||||
errorType === "decrypt"
|
||||
? "We couldn't decrypt some messages."
|
||||
: "Something went wrong loading this thread.";
|
||||
stateEl.textContent = errorMessage;
|
||||
timeline.appendChild(stateEl);
|
||||
} else {
|
||||
messages.forEach((message) => {
|
||||
if (message.type === "day") {
|
||||
timeline.appendChild(DayDivider({ document: doc, label: message.label }));
|
||||
return;
|
||||
}
|
||||
const bubble = MessageBubble({
|
||||
document: doc,
|
||||
message,
|
||||
variant: message.direction === "outgoing" ? "outgoing" : "incoming",
|
||||
});
|
||||
timeline.appendChild(bubble);
|
||||
});
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
if (messages.length > 0) {
|
||||
setTimeout(() => {
|
||||
timeline.scrollTop = timeline.scrollHeight;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
root.appendChild(timeline);
|
||||
return root;
|
||||
}
|
||||
|
||||
function createZapInterface(doc, { contact, zapReceipts, onSendZap, zapConfig }) {
|
||||
const container = createElement(doc, "div", "dm-zap-interface card p-4 absolute bottom-16 right-4 z-20 w-80 shadow-xl border border-border bg-surface");
|
||||
|
||||
const closeBtn = createElement(doc, "button", "absolute top-2 right-2 btn-ghost btn-xs", "✕");
|
||||
closeBtn.addEventListener("click", () => container.remove());
|
||||
container.appendChild(closeBtn);
|
||||
|
||||
const title = createElement(doc, "h4", "text-sm font-bold mb-2", "Send Zap");
|
||||
container.appendChild(title);
|
||||
|
||||
// Zap Form
|
||||
const form = createElement(doc, "form", "space-y-2 mb-4");
|
||||
const amountInput = createElement(doc, "input", "input input-sm w-full");
|
||||
amountInput.type = "number";
|
||||
amountInput.placeholder = "Amount (sats)";
|
||||
amountInput.min = "1";
|
||||
|
||||
const noteInput = createElement(doc, "input", "input input-sm w-full");
|
||||
noteInput.type = "text";
|
||||
noteInput.placeholder = "Comment (optional)";
|
||||
|
||||
const sendBtn = createElement(doc, "button", "btn btn-sm btn-primary w-full", "Zap ⚡");
|
||||
const statusDiv = createElement(doc, "div", "text-xs text-center mt-1");
|
||||
|
||||
sendBtn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
const amount = parseInt(amountInput.value);
|
||||
const comment = noteInput.value;
|
||||
if (!amount || amount <= 0) {
|
||||
statusDiv.textContent = "Invalid amount";
|
||||
statusDiv.className = "text-error text-xs text-center mt-1";
|
||||
return;
|
||||
}
|
||||
|
||||
statusDiv.textContent = "Sending...";
|
||||
statusDiv.className = "text-muted text-xs text-center mt-1";
|
||||
sendBtn.disabled = true;
|
||||
|
||||
try {
|
||||
if (typeof onSendZap === "function") {
|
||||
// Use provided callback if available (e.g. from controller)
|
||||
if (!contact.lightningAddress) {
|
||||
throw new Error("No lightning address for this contact");
|
||||
}
|
||||
|
||||
const resolver = zapConfig?.resolveRecipient || resolveZapRecipient;
|
||||
const resolved = await resolver(contact.lightningAddress, { fetcher: zapConfig?.fetcher });
|
||||
if (!resolved) throw new Error("Could not resolve lightning address");
|
||||
|
||||
const payload = {
|
||||
amountSats: amount,
|
||||
comment,
|
||||
recipient: contact,
|
||||
resolved
|
||||
};
|
||||
await onSendZap(payload);
|
||||
statusDiv.textContent = "Zap sent!";
|
||||
statusDiv.className = "text-success text-xs text-center mt-1";
|
||||
amountInput.value = "";
|
||||
noteInput.value = "";
|
||||
} else if (zapConfig?.signer) {
|
||||
await createAndPublishZapRequest({
|
||||
address: contact.lightningAddress,
|
||||
recipientPubkey: contact.pubkey,
|
||||
relays: contact.relayHints || zapConfig?.relays,
|
||||
amountSats: amount,
|
||||
comment,
|
||||
signer: zapConfig.signer,
|
||||
pool: zapConfig.pool,
|
||||
fetcher: zapConfig.fetcher,
|
||||
});
|
||||
statusDiv.textContent = "Zap sent!";
|
||||
statusDiv.className = "text-success text-xs text-center mt-1";
|
||||
amountInput.value = "";
|
||||
noteInput.value = "";
|
||||
} else {
|
||||
throw new Error("No signer available");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
statusDiv.textContent = "Failed to send zap";
|
||||
statusDiv.className = "text-error text-xs text-center mt-1";
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
form.appendChild(amountInput);
|
||||
form.appendChild(noteInput);
|
||||
form.appendChild(sendBtn);
|
||||
form.appendChild(statusDiv);
|
||||
container.appendChild(form);
|
||||
|
||||
// Receipts List
|
||||
container.appendChild(
|
||||
ZapReceiptList({
|
||||
document: doc,
|
||||
receipts: zapReceipts,
|
||||
emptyLabel: "No recent zaps.",
|
||||
}),
|
||||
);
|
||||
|
||||
return container;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
function createElement(doc, tag, className, text) {
|
||||
const element = doc.createElement(tag);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
if (typeof text === "string") {
|
||||
element.textContent = text;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
const FILTERS = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "dms", label: "DMs" },
|
||||
{ id: "mentions", label: "Mentions" },
|
||||
{ id: "zaps", label: "Zaps" },
|
||||
];
|
||||
|
||||
const GROUPS = [
|
||||
{ id: "unread", label: "Unread" },
|
||||
{ id: "new", label: "New" },
|
||||
];
|
||||
|
||||
const GROUP_EMPTY_COPY = {
|
||||
unread: "No unread notifications.",
|
||||
new: "No new notifications.",
|
||||
};
|
||||
|
||||
function isElement(value) {
|
||||
return Boolean(value && typeof value === "object" && value.nodeType === 1);
|
||||
}
|
||||
|
||||
export function NotificationItem({ document: doc, notice = {}, onSelect } = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("NotificationItem requires a document reference.");
|
||||
}
|
||||
|
||||
const item = createElement(doc, "button", "dm-notification-item");
|
||||
item.type = "button";
|
||||
if (notice.variant) {
|
||||
item.classList.add(`dm-notification-item--${notice.variant}`);
|
||||
}
|
||||
if (notice.id) {
|
||||
item.dataset.noticeId = notice.id;
|
||||
}
|
||||
|
||||
const iconSlot = createElement(doc, "span", "dm-notification-item__icon");
|
||||
if (notice.icon) {
|
||||
if (isElement(notice.icon)) {
|
||||
iconSlot.appendChild(notice.icon);
|
||||
} else if (typeof notice.icon === "string") {
|
||||
iconSlot.textContent = notice.icon;
|
||||
}
|
||||
}
|
||||
|
||||
const body = createElement(doc, "div", "dm-notification-item__body");
|
||||
if (notice.title) {
|
||||
const title = createElement(
|
||||
doc,
|
||||
"div",
|
||||
"dm-notification-item__title",
|
||||
notice.title,
|
||||
);
|
||||
body.appendChild(title);
|
||||
}
|
||||
const message = createElement(
|
||||
doc,
|
||||
"div",
|
||||
"dm-notification-item__message",
|
||||
notice.message || "",
|
||||
);
|
||||
body.appendChild(message);
|
||||
|
||||
const meta = createElement(doc, "div", "dm-notification-item__meta");
|
||||
const timestamp = createElement(
|
||||
doc,
|
||||
"time",
|
||||
"dm-notification-item__timestamp",
|
||||
notice.timestamp || "",
|
||||
);
|
||||
if (notice.timestampISO) {
|
||||
timestamp.dateTime = notice.timestampISO;
|
||||
}
|
||||
meta.appendChild(timestamp);
|
||||
|
||||
item.appendChild(iconSlot);
|
||||
item.appendChild(body);
|
||||
item.appendChild(meta);
|
||||
|
||||
if (typeof onSelect === "function") {
|
||||
item.addEventListener("click", () => {
|
||||
onSelect(notice);
|
||||
});
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export function NotificationCenter({
|
||||
document: doc,
|
||||
notices = [],
|
||||
activeFilter = "all",
|
||||
onFilterSelect,
|
||||
onNoticeSelect,
|
||||
} = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("NotificationCenter requires a document reference.");
|
||||
}
|
||||
|
||||
const root = createElement(doc, "section", "dm-notification-center");
|
||||
let currentFilter = activeFilter;
|
||||
|
||||
const filterBar = createElement(doc, "div", "dm-notification-center__filters");
|
||||
const filterButtons = new Map();
|
||||
|
||||
const updateFilterState = (nextFilter) => {
|
||||
currentFilter = nextFilter;
|
||||
filterButtons.forEach((button, filterId) => {
|
||||
const isActive = filterId === currentFilter;
|
||||
button.classList.toggle("dm-notification-center__filter-button--active", isActive);
|
||||
button.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||||
});
|
||||
renderGroups();
|
||||
};
|
||||
|
||||
FILTERS.forEach((filter) => {
|
||||
const button = createElement(
|
||||
doc,
|
||||
"button",
|
||||
"dm-notification-center__filter-button",
|
||||
filter.label,
|
||||
);
|
||||
button.type = "button";
|
||||
button.dataset.filterId = filter.id;
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
button.addEventListener("click", () => {
|
||||
if (filter.id === currentFilter) {
|
||||
return;
|
||||
}
|
||||
updateFilterState(filter.id);
|
||||
if (typeof onFilterSelect === "function") {
|
||||
onFilterSelect(filter.id);
|
||||
}
|
||||
});
|
||||
filterButtons.set(filter.id, button);
|
||||
filterBar.appendChild(button);
|
||||
});
|
||||
|
||||
const groupsContainer = createElement(doc, "div", "dm-notification-center__groups");
|
||||
|
||||
const getFilteredNotices = () => {
|
||||
if (currentFilter === "all") {
|
||||
return notices;
|
||||
}
|
||||
return notices.filter((notice) => notice.type === currentFilter);
|
||||
};
|
||||
|
||||
const renderGroups = () => {
|
||||
groupsContainer.innerHTML = "";
|
||||
const filteredNotices = getFilteredNotices();
|
||||
const groupMap = new Map();
|
||||
|
||||
filteredNotices.forEach((notice) => {
|
||||
const groupId = notice.group || "new";
|
||||
if (!groupMap.has(groupId)) {
|
||||
groupMap.set(groupId, []);
|
||||
}
|
||||
groupMap.get(groupId).push(notice);
|
||||
});
|
||||
|
||||
GROUPS.forEach((group) => {
|
||||
const groupSection = createElement(
|
||||
doc,
|
||||
"section",
|
||||
"dm-notification-center__group",
|
||||
);
|
||||
const title = createElement(
|
||||
doc,
|
||||
"div",
|
||||
"dm-notification-center__group-title",
|
||||
group.label,
|
||||
);
|
||||
const list = createElement(doc, "div", "dm-notification-center__list");
|
||||
const groupNotices = groupMap.get(group.id) || [];
|
||||
|
||||
if (!groupNotices.length) {
|
||||
list.appendChild(
|
||||
createElement(
|
||||
doc,
|
||||
"div",
|
||||
"dm-notification-center__empty",
|
||||
GROUP_EMPTY_COPY[group.id] || "No notifications.",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
groupNotices.forEach((notice) => {
|
||||
list.appendChild(
|
||||
NotificationItem({
|
||||
document: doc,
|
||||
notice,
|
||||
onSelect: onNoticeSelect,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
groupSection.appendChild(title);
|
||||
groupSection.appendChild(list);
|
||||
groupsContainer.appendChild(groupSection);
|
||||
});
|
||||
};
|
||||
|
||||
root.appendChild(filterBar);
|
||||
root.appendChild(groupsContainer);
|
||||
|
||||
updateFilterState(currentFilter);
|
||||
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { formatZapAmount, normalizeZapReceipt } from "./zapHelpers.js";
|
||||
|
||||
function createElement(doc, tag, className, text) {
|
||||
const element = doc.createElement(tag);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
if (typeof text === "string") {
|
||||
element.textContent = text;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function formatStatus(status) {
|
||||
const normalized = typeof status === "string" ? status.trim().toLowerCase() : "";
|
||||
if (!normalized) {
|
||||
return "Unverified";
|
||||
}
|
||||
if (normalized === "confirmed" || normalized === "paid") {
|
||||
return "Confirmed";
|
||||
}
|
||||
if (normalized === "pending") {
|
||||
return "Pending";
|
||||
}
|
||||
if (normalized === "failed") {
|
||||
return "Failed";
|
||||
}
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||
}
|
||||
|
||||
export function ZapReceiptList({
|
||||
document: doc,
|
||||
receipts = [],
|
||||
emptyLabel = "No zap receipts yet.",
|
||||
} = {}) {
|
||||
if (!doc) {
|
||||
throw new Error("ZapReceiptList requires a document reference.");
|
||||
}
|
||||
|
||||
const root = createElement(doc, "section", "dm-zap-receipts");
|
||||
const header = createElement(doc, "div", "dm-zap-receipts__header");
|
||||
header.appendChild(createElement(doc, "h4", "dm-zap-receipts__title", "Zap receipts"));
|
||||
root.appendChild(header);
|
||||
|
||||
const list = createElement(doc, "ul", "dm-zap-receipts__list");
|
||||
const normalizedReceipts = Array.isArray(receipts) ? receipts.map(normalizeZapReceipt) : [];
|
||||
|
||||
if (!normalizedReceipts.length) {
|
||||
const empty = createElement(doc, "p", "dm-zap-receipts__empty", emptyLabel);
|
||||
root.appendChild(empty);
|
||||
return root;
|
||||
}
|
||||
|
||||
normalizedReceipts.forEach((receipt) => {
|
||||
const item = createElement(doc, "li", "dm-zap-receipts__item");
|
||||
item.dataset.kind = String(receipt?.kind || 9735);
|
||||
|
||||
const amount = createElement(
|
||||
doc,
|
||||
"span",
|
||||
"dm-zap-receipts__amount",
|
||||
formatZapAmount(receipt.amountSats),
|
||||
);
|
||||
item.appendChild(amount);
|
||||
|
||||
const details = createElement(doc, "div", "dm-zap-receipts__details");
|
||||
const senderName = receipt?.senderName || receipt?.sender || "Unknown sender";
|
||||
details.appendChild(
|
||||
createElement(doc, "span", "dm-zap-receipts__sender", senderName),
|
||||
);
|
||||
|
||||
if (receipt?.note) {
|
||||
details.appendChild(createElement(doc, "span", "dm-zap-receipts__note", receipt.note));
|
||||
}
|
||||
|
||||
item.appendChild(details);
|
||||
|
||||
const meta = createElement(doc, "div", "dm-zap-receipts__meta");
|
||||
if (receipt?.timestamp) {
|
||||
meta.appendChild(
|
||||
createElement(doc, "span", "dm-zap-receipts__time", receipt.timestamp),
|
||||
);
|
||||
}
|
||||
|
||||
const status = createElement(
|
||||
doc,
|
||||
"span",
|
||||
"dm-zap-receipts__status",
|
||||
formatStatus(receipt?.status),
|
||||
);
|
||||
status.dataset.status = receipt?.status || "unverified";
|
||||
meta.appendChild(status);
|
||||
|
||||
item.appendChild(meta);
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
root.appendChild(list);
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { AppShell } from "./AppShell.js";
|
||||
export { Avatar } from "./Avatar.js";
|
||||
export { ContactRow } from "./ContactRow.js";
|
||||
export { ConversationList } from "./ConversationList.js";
|
||||
export { MessageThread } from "./MessageThread.js";
|
||||
export { MessageBubble } from "./MessageBubble.js";
|
||||
export { Composer } from "./Composer.js";
|
||||
export { NotificationCenter } from "./NotificationCenter.js";
|
||||
export { DayDivider } from "./DayDivider.js";
|
||||
export { ZapReceiptList } from "./ZapReceiptList.js";
|
||||
export { aggregateZapTotals, formatZapAmount, normalizeZapReceipt } from "./zapHelpers.js";
|
||||
@@ -0,0 +1,58 @@
|
||||
function normalizeAmount(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.round(numeric));
|
||||
}
|
||||
|
||||
export function formatZapAmount(amountSats, options = {}) {
|
||||
const normalized = normalizeAmount(amountSats);
|
||||
const { compact = false } = options || {};
|
||||
const formatter = new Intl.NumberFormat("en-US", {
|
||||
notation: compact ? "compact" : "standard",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
return `${formatter.format(normalized)} sats`;
|
||||
}
|
||||
|
||||
export function aggregateZapTotals(receipts = []) {
|
||||
const totalsByConversation = new Map();
|
||||
const totalsByProfile = new Map();
|
||||
let overallSats = 0;
|
||||
|
||||
const list = Array.isArray(receipts) ? receipts : [];
|
||||
list.forEach((receipt) => {
|
||||
const amount = normalizeAmount(receipt?.amountSats ?? receipt?.amount ?? 0);
|
||||
if (!amount) {
|
||||
return;
|
||||
}
|
||||
|
||||
overallSats += amount;
|
||||
|
||||
const conversationId = typeof receipt?.conversationId === "string" ? receipt.conversationId : "";
|
||||
if (conversationId) {
|
||||
totalsByConversation.set(
|
||||
conversationId,
|
||||
(totalsByConversation.get(conversationId) || 0) + amount,
|
||||
);
|
||||
}
|
||||
|
||||
const profileId = typeof receipt?.profileId === "string" ? receipt.profileId : "";
|
||||
if (profileId) {
|
||||
totalsByProfile.set(profileId, (totalsByProfile.get(profileId) || 0) + amount);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
overallSats,
|
||||
totalsByConversation,
|
||||
totalsByProfile,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeZapReceipt(receipt = {}) {
|
||||
const normalized = { ...receipt };
|
||||
normalized.amountSats = normalizeAmount(receipt?.amountSats ?? receipt?.amount ?? 0);
|
||||
return normalized;
|
||||
}
|
||||
+2649
-23
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,333 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
import { collectVideoTags } from "../utils/videoTags.js";
|
||||
import { ALLOW_NSFW_CONTENT } from "../config.js";
|
||||
import resolveVideoPointer from "../utils/videoPointer.js";
|
||||
|
||||
/**
|
||||
* Controller for managing "Similar Content" logic and updates for the video modal.
|
||||
*/
|
||||
export default class SimilarContentController {
|
||||
constructor({
|
||||
services = {},
|
||||
callbacks = {},
|
||||
ui = {},
|
||||
state = {},
|
||||
helpers = {}
|
||||
}) {
|
||||
this.services = services;
|
||||
this.callbacks = callbacks;
|
||||
this.ui = ui;
|
||||
this.state = state;
|
||||
this.helpers = helpers;
|
||||
}
|
||||
|
||||
extractDTagValue(tags) {
|
||||
if (!Array.isArray(tags)) {
|
||||
return "";
|
||||
}
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
if (tag[0] === "d" && typeof tag[1] === "string") {
|
||||
return tag[1];
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
deriveVideoPointerInfo(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dTagValue = (this.extractDTagValue(video.tags) || "").trim();
|
||||
|
||||
return resolveVideoPointer({
|
||||
kind: video.kind,
|
||||
pubkey: video.pubkey,
|
||||
videoRootId: video.videoRootId,
|
||||
dTag: dTagValue,
|
||||
fallbackEventId: video.id,
|
||||
relay: video.relay,
|
||||
});
|
||||
}
|
||||
|
||||
computeCandidates({ activeVideo, maxItems = 5 } = {}) {
|
||||
const decorateCandidate = (video) => {
|
||||
if (!video || typeof video !== "object") {
|
||||
return video;
|
||||
}
|
||||
let decoratedVideo = video;
|
||||
if (typeof this.callbacks.decorateVideoModeration === "function") {
|
||||
try {
|
||||
const decorated = this.callbacks.decorateVideoModeration(video);
|
||||
if (decorated && typeof decorated === "object") {
|
||||
decoratedVideo = decorated;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[SimilarContentController] Failed to decorate similar content candidate",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof this.callbacks.decorateVideoCreatorIdentity === "function") {
|
||||
try {
|
||||
const identityDecorated = this.callbacks.decorateVideoCreatorIdentity(
|
||||
decoratedVideo,
|
||||
);
|
||||
if (identityDecorated && typeof identityDecorated === "object") {
|
||||
decoratedVideo = identityDecorated;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[SimilarContentController] Failed to decorate similar content identity",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return decoratedVideo;
|
||||
};
|
||||
|
||||
const target = activeVideo && typeof activeVideo === "object" ? decorateCandidate(activeVideo) : null;
|
||||
if (!target) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activeTagsSource = Array.isArray(target.displayTags) && target.displayTags.length
|
||||
? target.displayTags
|
||||
: collectVideoTags(target);
|
||||
|
||||
const activeTagSet = new Set();
|
||||
for (const tag of activeTagsSource) {
|
||||
if (typeof tag !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalized = tag.trim().toLowerCase();
|
||||
if (normalized) {
|
||||
activeTagSet.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTagSet.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const limit = Number.isFinite(maxItems) && maxItems > 0
|
||||
? Math.max(1, Math.floor(maxItems))
|
||||
: 5;
|
||||
|
||||
let candidateSource = [];
|
||||
const videoListView = this.state.getVideoListView ? this.state.getVideoListView() : null;
|
||||
const videosMap = this.state.getVideosMap ? this.state.getVideosMap() : null;
|
||||
const nostrClient = this.services.nostrClient;
|
||||
|
||||
if (videoListView && Array.isArray(videoListView.currentVideos) && videoListView.currentVideos.length) {
|
||||
candidateSource = videoListView.currentVideos;
|
||||
} else if (videosMap instanceof Map && videosMap.size) {
|
||||
candidateSource = Array.from(videosMap.values());
|
||||
} else if (nostrClient && typeof nostrClient.getActiveVideos === "function") {
|
||||
try {
|
||||
const activeVideos = nostrClient.getActiveVideos();
|
||||
if (Array.isArray(activeVideos)) {
|
||||
candidateSource = activeVideos;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[SimilarContentController] Failed to read active videos for similar content:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(candidateSource) || candidateSource.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activeId = typeof target.id === "string" ? target.id : "";
|
||||
const activePointerKey = typeof target.pointerKey === "string" ? target.pointerKey : "";
|
||||
const seenKeys = new Set();
|
||||
if (activeId) {
|
||||
const normalizedId = activeId.trim().toLowerCase();
|
||||
if (normalizedId) {
|
||||
seenKeys.add(normalizedId);
|
||||
}
|
||||
}
|
||||
if (activePointerKey) {
|
||||
const normalizedPointer = activePointerKey.trim().toLowerCase();
|
||||
if (normalizedPointer) {
|
||||
seenKeys.add(normalizedPointer);
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const candidate of candidateSource) {
|
||||
const decoratedCandidate = decorateCandidate(candidate);
|
||||
if (!decoratedCandidate || typeof decoratedCandidate !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (decoratedCandidate === target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateId = typeof decoratedCandidate.id === "string" ? decoratedCandidate.id : "";
|
||||
if (candidateId && candidateId === activeId) {
|
||||
continue;
|
||||
}
|
||||
if (decoratedCandidate.deleted === true) {
|
||||
continue;
|
||||
}
|
||||
if (decoratedCandidate.isPrivate === true) {
|
||||
continue;
|
||||
}
|
||||
if (decoratedCandidate.isNsfw === true && ALLOW_NSFW_CONTENT !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidatePubkey = typeof decoratedCandidate.pubkey === "string" ? decoratedCandidate.pubkey : "";
|
||||
if (candidatePubkey && typeof this.callbacks.isAuthorBlocked === "function" && this.callbacks.isAuthorBlocked(candidatePubkey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateTagsSource = Array.isArray(decoratedCandidate.displayTags) && decoratedCandidate.displayTags.length
|
||||
? decoratedCandidate.displayTags
|
||||
: collectVideoTags(decoratedCandidate);
|
||||
if (!Array.isArray(candidateTagsSource) || candidateTagsSource.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateTagSet = new Set();
|
||||
for (const tag of candidateTagsSource) {
|
||||
if (typeof tag !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalized = tag.trim().toLowerCase();
|
||||
if (normalized) {
|
||||
candidateTagSet.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateTagSet.size === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let sharedCount = 0;
|
||||
for (const tag of candidateTagSet) {
|
||||
if (activeTagSet.has(tag)) {
|
||||
sharedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (sharedCount === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pointerInfo = this.deriveVideoPointerInfo(candidate);
|
||||
const pointerKey = typeof candidate.pointerKey === "string" && candidate.pointerKey.trim()
|
||||
? candidate.pointerKey.trim()
|
||||
: typeof pointerInfo?.key === "string" && pointerInfo.key
|
||||
? pointerInfo.key
|
||||
: "";
|
||||
|
||||
const dedupeKeyRaw = (candidateId || pointerKey || "").trim();
|
||||
if (dedupeKeyRaw) {
|
||||
const dedupeKey = dedupeKeyRaw.toLowerCase();
|
||||
if (seenKeys.has(dedupeKey)) {
|
||||
continue;
|
||||
}
|
||||
seenKeys.add(dedupeKey);
|
||||
}
|
||||
|
||||
// Ensure the candidate itself has displayTags populated for the result view
|
||||
if (!Array.isArray(decoratedCandidate.displayTags) || decoratedCandidate.displayTags.length === 0) {
|
||||
decoratedCandidate.displayTags = Array.isArray(candidateTagsSource)
|
||||
? candidateTagsSource.slice()
|
||||
: [];
|
||||
}
|
||||
|
||||
let postedAt = null;
|
||||
if (typeof this.helpers.getKnownVideoPostedAt === "function") {
|
||||
postedAt = this.helpers.getKnownVideoPostedAt(decoratedCandidate);
|
||||
}
|
||||
if (!Number.isFinite(postedAt) && Number.isFinite(decoratedCandidate.rootCreatedAt)) {
|
||||
postedAt = Math.floor(decoratedCandidate.rootCreatedAt);
|
||||
}
|
||||
if (!Number.isFinite(postedAt) && Number.isFinite(decoratedCandidate.created_at)) {
|
||||
postedAt = Math.floor(decoratedCandidate.created_at);
|
||||
}
|
||||
if (!Number.isFinite(postedAt)) {
|
||||
postedAt = null;
|
||||
}
|
||||
|
||||
let shareUrl = "";
|
||||
if (typeof decoratedCandidate.shareUrl === "string" && decoratedCandidate.shareUrl.trim()) {
|
||||
shareUrl = decoratedCandidate.shareUrl.trim();
|
||||
} else if (candidateId && typeof this.helpers.buildShareUrlFromEventId === "function") {
|
||||
shareUrl = this.helpers.buildShareUrlFromEventId(candidateId) || "";
|
||||
}
|
||||
|
||||
results.push({
|
||||
video: decoratedCandidate,
|
||||
pointerInfo: pointerInfo || null,
|
||||
shareUrl,
|
||||
postedAt,
|
||||
sharedTagCount: sharedCount,
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
results.sort((a, b) => {
|
||||
if (b.sharedTagCount !== a.sharedTagCount) {
|
||||
return b.sharedTagCount - a.sharedTagCount;
|
||||
}
|
||||
const tsA = Number.isFinite(a.postedAt) ? a.postedAt : 0;
|
||||
const tsB = Number.isFinite(b.postedAt) ? b.postedAt : 0;
|
||||
return tsB - tsA;
|
||||
});
|
||||
|
||||
return results.slice(0, limit).map((entry) => {
|
||||
const normalizedPostedAt = Number.isFinite(entry.postedAt)
|
||||
? Math.floor(entry.postedAt)
|
||||
: null;
|
||||
const timeAgo = normalizedPostedAt !== null && typeof this.helpers.formatTimeAgo === "function"
|
||||
? this.helpers.formatTimeAgo(normalizedPostedAt)
|
||||
: "";
|
||||
return {
|
||||
video: entry.video,
|
||||
pointerInfo: entry.pointerInfo,
|
||||
shareUrl: entry.shareUrl,
|
||||
postedAt: normalizedPostedAt,
|
||||
timeAgo,
|
||||
sharedTagCount: entry.sharedTagCount,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
updateModal({ activeVideo, maxItems } = {}) {
|
||||
if (!this.ui.videoModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = activeVideo && typeof activeVideo === "object" ? activeVideo : null;
|
||||
if (!target) {
|
||||
if (typeof this.ui.videoModal.clearSimilarContent === "function") {
|
||||
this.ui.videoModal.clearSimilarContent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = this.computeCandidates({ activeVideo: target, maxItems });
|
||||
if (matches.length > 0) {
|
||||
if (typeof this.ui.videoModal.setSimilarContent === "function") {
|
||||
this.ui.videoModal.setSimilarContent(matches);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.ui.videoModal.clearSimilarContent === "function") {
|
||||
this.ui.videoModal.clearSimilarContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,3 @@ export function attachUrlHealthBadges(container, onCheck) {
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshUrlHealthBadges(container) {
|
||||
urlCardObserver.refresh(container);
|
||||
}
|
||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user