Merge branch 'unstable' into ai/onboarding-20231027-15933194820812210320

This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-19 14:38:33 -05:00
commit f2484cb163
73 files changed
+11578 -1035

No files matched your search

+120
View File
@@ -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
+7 -1
View File
@@ -126,6 +126,9 @@ http://localhost:8000
### Common Commands
- **Run unit tests**: `npm run test:unit`
- **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`
- **Format code**: `npm run format`
- **Lint code**: `npm run lint`
@@ -393,10 +396,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
+60 -139
View File
@@ -443,6 +443,26 @@
></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
@@ -492,7 +512,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 +664,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
@@ -1009,144 +1067,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 class="card space-y-4 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<h3 class="text-sm font-semibold text-text">Direct message actions</h3>
<p class="text-xs text-muted">
Start a new DM or jump to relay preferences before you send.
</p>
</div>
<button
id="profileMessagesSendDm"
type="button"
class="btn focus-ring inline-flex items-center gap-2"
data-size="sm"
>
Send DM
</button>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-3">
<div class="space-y-1">
<h4 class="text-xs font-semibold text-text">Preferred DM relay</h4>
<p class="text-xs text-muted">
Select a relay to prioritize encrypted message delivery.
</p>
</div>
<button
id="profileMessagesOpenRelays"
type="button"
class="btn-ghost focus-ring inline-flex items-center gap-2"
data-size="sm"
>
Manage relays
</button>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-3">
<div class="space-y-1">
<h4 class="text-xs font-semibold text-text">Privacy mode (coming soon)</h4>
<p class="text-xs text-muted">
Pause inbound DM sync and clear cached previews on this device.
</p>
</div>
<label
for="profileMessagesPrivacyToggle"
class="inline-flex items-center gap-2 text-xs font-medium text-muted-strong"
>
<input
id="profileMessagesPrivacyToggle"
type="checkbox"
class="checkbox"
/>
Enable
</label>
</div>
</div>
<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"
+6
View File
@@ -521,6 +521,12 @@
>
No description available.
</p>
<div
id="videoDescriptionPreviews"
class="mt-4 space-y-3"
data-link-previews
hidden
></div>
</div>
<div
+1 -1
View File
@@ -11,4 +11,4 @@
--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
--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
+134
View File
@@ -5095,6 +5095,12 @@
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;
}
@@ -5170,6 +5176,32 @@
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;
@@ -5497,6 +5529,29 @@
cursor: pointer;
}
.dm-composer__zap-toggle {
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__zap-toggle[aria-pressed="true"] {
border-color: var(--border-strong);
background: var(--surface-overlay-panel);
color: var(--text-primary);
}
.dm-composer__zap-toggle:hover,
.dm-composer__zap-toggle:focus-visible {
background: var(--surface-overlay-panel-soft);
color: var(--text-primary);
outline: none;
}
.dm-composer__attach:hover,
.dm-composer__attach:focus-visible,
.dm-composer__privacy:hover,
@@ -5522,6 +5577,85 @@
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);
+2
View File
@@ -303,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;
-58
View File
@@ -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
View File
@@ -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)
+70
View File
@@ -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.
+62 -74
View File
@@ -1,91 +1,79 @@
# 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.
`NostrClient` (`js/nostr/client.js`) is the central controller for all Nostr network interactions and state management in the application. It acts as a facade over `nostr-tools`, managing connections to multiple relays, signing events, and maintaining the local application state.
## Key Responsibilities
## 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 configured Nostr relays. It handles initialization, reconnection, and error logging.
2. **Event Publishing**: Handles the creation, signing, and broadcasting of various Nostr event kinds (Video Post: 30078, Reaction: 7, Comment: 1, etc.). It supports NIP-07 (Extension), NIP-46 (Remote/Bunker), and NIP-01 (Local nsec) signers.
3. **State Management**:
* `allEvents`: A raw map of all fetched video events (Kind 30078), preserving history.
* `activeMap`: A derived map containing only the *latest* version of each video, deduplicated by `videoRootId` or `d` tag. This is the source of truth for the UI.
* `rawEvents`: A cache of the exact raw JSON events from relays, used for signature verification and republishing.
* `tombstones`: Tracks deletion timestamps to prevent deleted events from reappearing.
4. **Caching**: Implements a dual-layer cache (`IndexedDB` + `localStorage`) to persist `activeMap`, `allEvents`, and `tombstones`. This allows the app to restore its state instantly on load ("stale-while-revalidate").
5. **Video Lifecycle**: Manages the full lifecycle of a video post: Publish -> Edit (new event with same `d` tag) -> Revert (new event, same `d`, deleted flag) -> Delete (Kind 5).
## Architecture & State
## Key Concepts
The client maintains several internal maps to manage state:
### Active Video Logic
Nostr is an append-only protocol. Edits are new events. `NostrClient` implements "latest-wins" logic to present a coherent view:
- **Identifier**: Videos are identified by a `videoRootId` (in the content) or a NIP-33 `d` tag.
- **Resolution**: When a new event arrives, the client checks if it's newer than the current active version in `activeMap`. If so, it replaces the entry.
- **Deletions**: If an event has `deleted: true` or is a Kind 5 deletion, it is removed from `activeMap`, and a tombstone is recorded.
* `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.
### Buffering & Debouncing
To handle high volumes of events (e.g., during initial relay dump), `subscribeVideos` uses a buffering strategy:
1. Incoming events are pushed to `eventBuffer`.
2. A flush is scheduled with a short debounce (e.g., 75ms).
3. On flush, the batch is processed: validation, state updates, and UI notification.
This prevents UI thrashing.
### Caching Strategy (`EventsCacheStore`)
### Tombstones
Because relays are eventually consistent, a deleted event might arrive *after* a deletion command.
- `tombstones` map `activeKey` -> `timestamp`.
- If an incoming event is older than the tombstone for its key, it is ignored (`applyTombstoneGuard`).
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.
## Key Flows
### 1. Initialization (`init`)
## Example Flow: `subscribeVideos`
```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]
sequenceDiagram
participant UI
participant Client as NostrClient
participant Buffer as EventBuffer
participant State as activeMap
participant Relays
UI->>Client: subscribeVideos(onVideo)
Client->>Relays: REQ Kind 30078
Relays-->>Client: EVENT (Video A, v1)
Client->>Buffer: Push Event
Client->>Buffer: Schedule Flush (75ms)
Relays-->>Client: EVENT (Video B, v1)
Client->>Buffer: Push Event
Note over Client, Buffer: 75ms passes...
Client->>Buffer: Flush()
loop For each event
Buffer->>State: Is newer?
alt Yes
State->>State: Update Entry
State->>UI: onVideo(Video)
else No
State->>State: Ignore
end
end
```
### 2. Video Subscription (`subscribeVideos`)
## When to Modify
To prevent UI thrashing during high-volume event ingress (e.g., initial flood from relays), the client uses a **buffering and debouncing** strategy.
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.
### 3. Publishing a Video (`publishVideo`)
Publishing is a multi-step process ensuring compatibility and discoverability.
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.
### 4. Remote Signer Handshake (`connectRemoteSigner`)
For NIP-46 (Nostr Connect), the client implements a full handshake flow:
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.
- **Adding a new Event Kind**: If the app needs to support a new type of interaction (e.g., playlists), add the handling logic here and in `js/nostrEventSchemas.js`.
- **Changing Caching Strategy**: Modify `EventsCacheStore` or `persistLocalData` if the caching requirements change.
- **Updating Signing Logic**: Changes to NIP-07 or NIP-46 integration should be carefully reviewed here.
## 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.
- `js/nostrEventSchemas.js`: Definitions of event structures.
- `js/nostr/nip71.js`: Helper logic for video categorization (NIP-71).
- `js/nostr/watchHistory.js`: Manages user watch history state.
+17 -1
View File
@@ -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,14 +146,18 @@ 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 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> }] }` |
| 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>], …]`) |
@@ -159,6 +167,10 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| 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.
+82
View File
@@ -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.");
}
+1 -1
View File
@@ -378,7 +378,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"
+310
View File
@@ -68,6 +68,12 @@ import { devLogger, userLogger } from "./utils/logger.js";
import createPopover from "./ui/overlay/popoverEngine.js";
import { createVideoSettingsMenuPanel } from "./ui/components/videoMenuRenderers.js";
import moderationService from "./services/moderationService.js";
import { sanitizeRelayList } from "./nostr/nip46Client.js";
import { buildDmRelayListEvent } from "./nostrEventSchemas.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
} from "./nostrPublish.js";
import {
initViewCounter,
subscribeToVideoViewCount,
@@ -139,6 +145,7 @@ import {
setModerationSettings,
resetModerationSettings,
loadModerationSettingsFromStorage,
loadDmPrivacySettingsFromStorage,
URL_PROBE_TIMEOUT_MS,
urlHealthConstants,
} from "./state/cache.js";
@@ -220,6 +227,8 @@ class Application {
this.modalManager = modalManager;
this.modalCreatorProfileRequestToken = null;
this.dmRecipientPubkey = null;
this.dmRelayHints = new Map();
this.commentController = null;
this.initializeCommentController();
@@ -415,6 +424,7 @@ class Application {
loadModerationOverridesFromStorage();
loadModerationSettingsFromStorage();
loadDmPrivacySettingsFromStorage();
this.moderationSettings = this.normalizeModerationSettings(
getModerationSettings(),
);
@@ -3058,6 +3068,306 @@ class Application {
});
}
getDmRecipientPubkey() {
return this.dmRecipientPubkey;
}
setDmRecipientPubkey(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
this.dmRecipientPubkey = normalized || null;
return this.dmRecipientPubkey;
}
getDmRelayHints(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
if (!normalized || !(this.dmRelayHints instanceof Map)) {
return [];
}
const hints = this.dmRelayHints.get(normalized);
return Array.isArray(hints) ? hints.slice() : [];
}
setDmRelayHints(pubkey, hints = []) {
const normalized = this.normalizeHexPubkey(pubkey);
if (!normalized) {
return [];
}
if (!(this.dmRelayHints instanceof Map)) {
this.dmRelayHints = new Map();
}
const stored = sanitizeRelayList(Array.isArray(hints) ? hints : []);
this.dmRelayHints.set(normalized, stored);
return stored.slice();
}
async publishDmRelayPreferences({ pubkey, relays } = {}) {
const normalized = this.normalizeHexPubkey(pubkey || this.pubkey);
if (!normalized) {
const error = new Error("A valid pubkey is required to publish DM relays.");
error.code = "invalid-pubkey";
throw error;
}
const relayHints = sanitizeRelayList(Array.isArray(relays) ? relays : []);
if (!relayHints.length) {
const error = new Error("Add at least one DM relay before publishing.");
error.code = "empty";
throw error;
}
if (!nostrClient?.pool) {
const error = new Error(
"Nostr is not connected yet. Please try again once relays are ready.",
);
error.code = "nostr-uninitialized";
throw error;
}
const signer = await nostrClient.ensureActiveSignerForPubkey(normalized);
if (!signer || typeof signer.signEvent !== "function") {
const error = new Error(
"An active signer with signEvent support is required to publish DM relays.",
);
error.code = "nostr-extension-missing";
throw error;
}
if (signer.type === "extension" && nostrClient.ensureExtensionPermissions) {
const permissionResult = await nostrClient.ensureExtensionPermissions();
if (!permissionResult?.ok) {
userLogger.warn(
"[Application] Signer permissions denied while publishing DM relays.",
permissionResult?.error,
);
const error = new Error(
"The active signer must allow signing before publishing DM relays.",
);
error.code = "extension-permission-denied";
error.cause = permissionResult?.error;
throw error;
}
}
const event = buildDmRelayListEvent({
pubkey: normalized,
created_at: Math.floor(Date.now() / 1000),
relays: relayHints,
});
const signedEvent = await signer.signEvent(event);
const relayTargets = sanitizeRelayList(
Array.isArray(nostrClient.writeRelays) && nostrClient.writeRelays.length
? nostrClient.writeRelays
: Array.isArray(nostrClient.relays)
? nostrClient.relays
: [],
);
if (!relayTargets.length) {
const error = new Error("No relay targets are available for publishing.");
error.code = "no-targets";
throw error;
}
const publishResults = await publishEventToRelays(
nostrClient.pool,
relayTargets,
signedEvent,
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "dm relay hints update",
message: "No relays accepted the DM relay list.",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
userLogger.error(
`[Application] Relay ${url} rejected DM relay list: ${reason}`,
relayError || reason,
);
},
);
}
throw publishError;
}
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
userLogger.warn(
`[Application] Relay ${url} did not acknowledge DM relay hints: ${reason}`,
relayError,
);
});
}
return {
ok: true,
event: signedEvent,
accepted: publishSummary.accepted.map(({ url }) => url),
failed: publishSummary.failed.map(({ url, error: relayError }) => ({
url,
error: relayError || null,
})),
};
}
handleProfilePublishDmRelayPreferences({ relays, pubkey } = {}) {
return this.publishDmRelayPreferences({ relays, pubkey });
}
async fetchDmRelayHints(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
if (!normalized) {
return [];
}
const cached = this.getDmRelayHints(normalized);
if (cached.length) {
return cached;
}
const relayCandidates =
Array.isArray(nostrClient?.readRelays) && nostrClient.readRelays.length
? nostrClient.readRelays
: Array.isArray(nostrClient?.relays)
? nostrClient.relays
: [];
const relayList = sanitizeRelayList(relayCandidates);
if (!relayList.length) {
return [];
}
if (!nostrClient?.pool || typeof nostrClient.pool.list !== "function") {
return [];
}
try {
const events = await nostrClient.pool.list(relayList, [
{ kinds: [10050], authors: [normalized], limit: 1 },
]);
const sorted = Array.isArray(events)
? events
.filter((event) => event && event.pubkey === normalized)
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0))
: [];
if (!sorted.length) {
return [];
}
const tags = Array.isArray(sorted[0]?.tags) ? sorted[0].tags : [];
const relayHints = sanitizeRelayList(
tags
.filter((tag) => Array.isArray(tag) && tag[0] === "relay")
.map((tag) => (typeof tag[1] === "string" ? tag[1].trim() : "")),
);
this.setDmRelayHints(normalized, relayHints);
return relayHints;
} catch (error) {
devLogger.warn(
"[Application] Failed to load DM relay hints:",
error,
);
}
return [];
}
openDirectMessageComposer({ recipientPubkey, source = "" } = {}) {
const normalized = this.normalizeHexPubkey(recipientPubkey);
if (!normalized) {
this.showError("Please select a valid message recipient.");
return false;
}
this.setDmRecipientPubkey(normalized);
if (this.profileController) {
try {
if (typeof this.profileController.setDirectMessageRecipient === "function") {
this.profileController.setDirectMessageRecipient(normalized, {
reason: source || "external",
});
}
if (typeof this.profileController.show === "function") {
this.profileController.show("messages");
}
if (typeof this.profileController.focusMessageComposer === "function") {
this.profileController.focusMessageComposer();
}
} catch (error) {
devLogger.warn(
"[Application] Failed to open DM composer:",
error,
);
}
}
return true;
}
handleProfileSendDmRequest({ recipient } = {}) {
const recipientPubkey =
typeof recipient?.pubkey === "string"
? recipient.pubkey
: null;
return this.openDirectMessageComposer({
recipientPubkey,
source: "profile-modal",
});
}
handleProfileUseDmRelays({ recipient, controller } = {}) {
const relayHints = Array.isArray(recipient?.relayHints)
? recipient.relayHints
: [];
if (!relayHints.length) {
this.showError("No DM relays found for this recipient.");
return false;
}
if (recipient?.pubkey) {
this.setDmRelayHints(recipient.pubkey, relayHints);
}
this.showSuccess("Recipient DM relays ready for use.");
if (controller?.focusMessageComposer) {
controller.focusMessageComposer();
}
return true;
}
handleProfilePrivacyToggle({ enabled, controller, recipient } = {}) {
const relayHints = Array.isArray(recipient?.relayHints)
? recipient.relayHints
: [];
if (enabled && !relayHints.length) {
this.showStatus(
"Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.",
);
}
this.showStatus(
enabled
? "NIP-17 privacy delivery enabled for this recipient."
: "Using standard DM delivery for this recipient.",
);
}
updateProfileInDOM(pubkey, profile) {
if (this.profileIdentityController) {
this.profileIdentityController.updateProfileIdentity({
+162
View File
@@ -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 };
+177
View File
@@ -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,
};
}
+8
View File
@@ -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.");
+61 -19
View File
@@ -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,
};
+735 -131
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -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,
};
}
+265
View File
@@ -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);
});
}
+127
View File
@@ -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,
});
});
}
+308
View File
@@ -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,
};
}
+91
View File
@@ -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);
});
}
+108
View File
@@ -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,
});
});
}
+418 -2
View File
@@ -20,14 +20,21 @@ 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",
});
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
@@ -270,8 +277,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 +315,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 +367,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 +540,17 @@ 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.",
},
},
};
let schemaOverrides = {};
@@ -929,6 +1022,255 @@ 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 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 +1332,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,
+200
View File
@@ -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,
});
+393
View File
@@ -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,
};
}
+640
View File
@@ -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;
+214
View File
@@ -0,0 +1,214 @@
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),
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;
}
}
+249
View File
@@ -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,
};
}
}
+345 -6
View File
@@ -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,60 @@ 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) {
return this.dmNotificationManager.acknowledgeRenderedMessages({
conversationId,
renderedUntil,
});
}
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 +842,10 @@ export class NostrService {
);
});
}
if (!normalized.snapshot) {
void this.persistDirectMessageRecord(normalized, { actorPubkey: actor });
}
}
async hydrateDirectMessagesFromStore({
@@ -641,6 +912,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 +950,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 +1013,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 +1033,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 +1134,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 +1181,7 @@ export class NostrService {
const snapshotPayload = buildSnapshotFromMessages(
sourceMessages,
normalizedActor,
(message) => this.resolveDirectMessagePreview(message),
);
if (!snapshotPayload.length) {
+236
View File
@@ -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;
+220
View File
@@ -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();
+563
View File
@@ -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;
+27
View File
@@ -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,
@@ -424,6 +427,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 +441,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 +487,7 @@ export default class ApplicationBootstrap {
app.describeHashtagPreferencesError(error, {
fallbackMessage,
}),
relayHealthService,
log: (...args) => app.log(...args),
closeAllMoreMenus: (options) => app.closeAllMoreMenus(options),
clipboard:
@@ -499,6 +510,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 +553,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({
+291
View File
@@ -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) {
+75 -2
View File
@@ -2,6 +2,7 @@ import { Composer } from "./Composer.js";
import { ConversationList } from "./ConversationList.js";
import { MessageThread } from "./MessageThread.js";
import { NotificationCenter } from "./NotificationCenter.js";
import { aggregateZapTotals } from "./zapHelpers.js";
function createElement(doc, tag, className, text) {
const element = doc.createElement(tag);
@@ -23,6 +24,9 @@ const DEFAULT_CONVERSATIONS = [
unreadCount: 2,
status: "Online",
avatarSrc: "",
pubkey: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
lightningAddress: "[email protected]",
relayHints: ["wss://relay.damus.io", "wss://nostr.wine"],
},
{
id: "conv-2",
@@ -32,6 +36,9 @@ const DEFAULT_CONVERSATIONS = [
unreadCount: 0,
status: "Away",
avatarSrc: "",
pubkey: "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
lightningAddress: "[email protected]",
relayHints: ["wss://relay.primal.net"],
},
];
@@ -66,6 +73,42 @@ const DEFAULT_MESSAGES = [
},
];
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,
@@ -73,12 +116,17 @@ export class AppShell {
activeConversationId = DEFAULT_CONVERSATIONS[0]?.id || "",
conversationState = "idle",
messages = DEFAULT_MESSAGES,
zapReceipts = DEFAULT_ZAP_RECEIPTS,
threadState = "idle",
threadErrorType = "",
composerState = "idle",
privacyMode = "nip04",
notifications = [],
signingAdapter = null,
zapConfig = null,
onSelectConversation,
onSendMessage,
onSendZap,
} = {}) {
if (!doc) {
throw new Error("AppShell requires a document reference.");
@@ -87,6 +135,21 @@ export class AppShell {
this.document = doc;
this.root = createElement(doc, "div", "dm-app-shell");
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 sidebarTitle = createElement(
doc,
@@ -98,7 +161,7 @@ export class AppShell {
sidebar.appendChild(
ConversationList({
document: doc,
conversations,
conversations: enrichedConversations,
activeId: activeConversationId,
state: conversationState,
onSelect: onSelectConversation,
@@ -115,17 +178,27 @@ export class AppShell {
main.appendChild(
MessageThread({
document: doc,
contact: conversations.find((conversation) => conversation.id === activeConversationId) || {},
contact: activeConversation,
messages,
state: threadState,
errorType: threadErrorType,
privacyMode,
zapReceipts: activeZapReceipts,
conversationZapTotalSats:
totalsByConversation.get(activeConversation.id) || 0,
profileZapTotalSats: totalsByProfile.get(activeConversation.pubkey) || 0,
}),
);
main.appendChild(
Composer({
document: doc,
state: composerState,
privacyMode,
signingAdapter,
zapRecipient: activeConversation,
zapConfig,
onSend: onSendMessage,
onSendZap,
}),
);
+224 -11
View File
@@ -1,3 +1,6 @@
import { createAndPublishZapRequest, resolveZapRecipient } from "../../payments/zapRequests.js";
import { devLogger, userLogger } from "../../utils/logger.js";
function createElement(doc, tag, className, text) {
const element = doc.createElement(tag);
if (className) {
@@ -13,8 +16,12 @@ export function Composer({
document: doc,
placeholder = "Write a message…",
state = "idle",
privacyMode: initialPrivacyMode = "standard",
privacyMode: initialPrivacyMode = "nip04",
signingAdapter = null,
zapRecipient = null,
zapConfig = null,
onSend,
onSendZap,
} = {}) {
if (!doc) {
throw new Error("Composer requires a document reference.");
@@ -59,14 +66,21 @@ export function Composer({
doc,
"button",
"dm-composer__privacy",
"Standard",
"NIP-04",
);
privacyToggle.type = "button";
privacyToggle.setAttribute("aria-pressed", "false");
privacyToggle.setAttribute("aria-label", "Toggle privacy mode");
const zapToggle = createElement(doc, "button", "dm-composer__zap-toggle", "Zap");
zapToggle.type = "button";
zapToggle.setAttribute("aria-expanded", "false");
zapToggle.setAttribute("aria-label", "Open zap composer");
zapToggle.setAttribute("aria-pressed", "false");
tools.appendChild(attachButton);
tools.appendChild(privacyToggle);
tools.appendChild(zapToggle);
tools.appendChild(hint);
const button = createElement(doc, "button", "dm-composer__send", "Send");
@@ -83,33 +97,232 @@ export function Composer({
status.textContent = "Sending…";
}
const zapPanel = createElement(doc, "div", "dm-composer__zap-panel");
zapPanel.id = "dm-composer-zap-panel";
zapPanel.hidden = true;
zapToggle.setAttribute("aria-controls", zapPanel.id);
const zapRecipientLabel = createElement(
doc,
"span",
"dm-composer__zap-recipient",
zapRecipient?.name
? `Zap ${zapRecipient.name}`
: "Zap this contact",
);
zapPanel.appendChild(zapRecipientLabel);
const zapAddress = createElement(
doc,
"span",
"dm-composer__zap-address",
zapRecipient?.lightningAddress || "Lightning address required.",
);
zapPanel.appendChild(zapAddress);
const zapAmountLabel = createElement(doc, "label", "dm-composer__zap-label", "Amount (sats)");
zapAmountLabel.setAttribute("for", "dm-zap-amount-input");
const zapAmountInput = createElement(doc, "input", "dm-composer__zap-input");
zapAmountInput.type = "number";
zapAmountInput.min = "1";
zapAmountInput.step = "1";
zapAmountInput.inputMode = "numeric";
zapAmountInput.id = "dm-zap-amount-input";
zapAmountInput.placeholder = "100";
zapAmountInput.setAttribute("aria-label", "Zap amount in sats");
zapPanel.appendChild(zapAmountLabel);
zapPanel.appendChild(zapAmountInput);
const zapNoteLabel = createElement(doc, "label", "dm-composer__zap-label", "Zap note");
zapNoteLabel.setAttribute("for", "dm-zap-note-input");
const zapNoteInput = createElement(doc, "input", "dm-composer__zap-input");
zapNoteInput.type = "text";
zapNoteInput.id = "dm-zap-note-input";
zapNoteInput.placeholder = "Optional note for this zap";
zapNoteInput.setAttribute("aria-label", "Zap note");
zapPanel.appendChild(zapNoteLabel);
zapPanel.appendChild(zapNoteInput);
const zapActions = createElement(doc, "div", "dm-composer__zap-actions");
const zapSendButton = createElement(doc, "button", "dm-composer__zap-send", "Send zap");
zapSendButton.type = "button";
zapSendButton.setAttribute("aria-label", "Send zap request");
zapActions.appendChild(zapSendButton);
zapPanel.appendChild(zapActions);
const zapStatus = createElement(doc, "div", "dm-composer__zap-status");
zapPanel.appendChild(zapStatus);
form.appendChild(label);
form.appendChild(textarea);
form.appendChild(zapPanel);
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);
};
let zapOpen = false;
let resolvedRecipient = null;
const setZapStatus = (message, variant = "info") => {
zapStatus.textContent = message || "";
zapStatus.dataset.state = variant;
};
const resolveRecipient = async () => {
if (!zapRecipient?.lightningAddress) {
setZapStatus("Add a Lightning address to enable zaps.", "error");
return null;
}
setZapStatus("Resolving LNURL…", "loading");
try {
const resolver =
typeof zapConfig?.resolveRecipient === "function"
? zapConfig.resolveRecipient
: resolveZapRecipient;
resolvedRecipient = await resolver(zapRecipient.lightningAddress, {
fetcher: zapConfig?.fetcher,
});
if (
!resolvedRecipient?.metadata?.allowsNostr &&
!resolvedRecipient?.metadata?.nostrPubkey
) {
setZapStatus("Recipient LNURL does not support Nostr zaps.", "error");
resolvedRecipient = null;
return null;
}
setZapStatus("LNURL ready for zaps.", "success");
return resolvedRecipient;
} catch (error) {
devLogger.warn("[dm] Failed to resolve LNURL for zap recipient.", error);
setZapStatus(
error instanceof Error ? error.message : "Failed to resolve LNURL.",
"error",
);
return null;
}
};
form.addEventListener("submit", (event) => {
event.preventDefault();
if (typeof onSend === "function") {
onSend(textarea.value, { privacyMode, attachments: [] });
}
void handleSubmit();
});
const updatePrivacyLabel = () => {
privacyToggle.textContent = privacyMode === "private" ? "Private" : "Standard";
privacyToggle.setAttribute(
"aria-pressed",
privacyMode === "private" ? "true" : "false",
);
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", () => {
privacyMode = privacyMode === "private" ? "standard" : "private";
const normalized =
typeof privacyMode === "string" ? privacyMode.trim().toLowerCase() : "";
const isNip17 = normalized === "nip17" || normalized === "private";
privacyMode = isNip17 ? "nip04" : "nip17";
updatePrivacyLabel();
});
zapToggle.addEventListener("click", () => {
zapOpen = !zapOpen;
zapPanel.hidden = !zapOpen;
zapToggle.setAttribute("aria-expanded", zapOpen ? "true" : "false");
zapToggle.setAttribute("aria-pressed", zapOpen ? "true" : "false");
if (zapOpen) {
void resolveRecipient();
} else {
setZapStatus("");
}
});
zapSendButton.addEventListener("click", async () => {
const amount = Number(zapAmountInput.value);
if (!Number.isFinite(amount) || amount <= 0) {
setZapStatus("Enter a zap amount greater than zero.", "error");
return;
}
const resolved = resolvedRecipient || (await resolveRecipient());
if (!resolved) {
return;
}
const comment = zapNoteInput.value.trim();
const payload = {
amountSats: Math.round(amount),
comment,
recipient: zapRecipient,
resolved,
};
if (typeof onSendZap === "function") {
try {
await onSendZap(payload);
setZapStatus("Zap request sent. Awaiting receipt…", "success");
} catch (error) {
userLogger.error("[dm] Zap request failed.", error);
setZapStatus(
error instanceof Error ? error.message : "Zap request failed.",
"error",
);
}
return;
}
if (zapConfig?.signer) {
try {
await createAndPublishZapRequest({
address: zapRecipient?.lightningAddress,
recipientPubkey: zapRecipient?.pubkey,
relays: zapRecipient?.relayHints || zapConfig?.relays,
amountSats: Math.round(amount),
comment,
signer: zapConfig.signer,
pool: zapConfig.pool,
fetcher: zapConfig.fetcher,
});
setZapStatus("Zap request published. Awaiting receipt…", "success");
} catch (error) {
userLogger.error("[dm] Zap publish failed.", error);
setZapStatus(
error instanceof Error ? error.message : "Zap request failed.",
"error",
);
}
} else {
setZapStatus("Connect a Nostr signer to send zap requests.", "error");
}
});
textarea.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
+20 -1
View File
@@ -1,4 +1,5 @@
import { Avatar } from "./Avatar.js";
import { formatZapAmount } from "./zapHelpers.js";
function createElement(doc, tag, className, text) {
const element = doc.createElement(tag);
@@ -30,6 +31,7 @@ export function ContactRow({
unreadCount = 0,
avatarSrc = "",
status = "",
zapTotalSats = null,
} = contact;
const button = createElement(doc, "button", "dm-contact-row");
@@ -56,8 +58,25 @@ export function ContactRow({
const nameEl = createElement(doc, "span", "dm-contact-row__name", name);
header.appendChild(nameEl);
const meta = createElement(doc, "span", "dm-contact-row__meta");
const timeEl = createElement(doc, "span", "dm-contact-row__time", timestamp);
header.appendChild(timeEl);
meta.appendChild(timeEl);
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);
const previewEl = createElement(
+51
View File
@@ -1,5 +1,7 @@
import { DayDivider } from "./DayDivider.js";
import { MessageBubble } from "./MessageBubble.js";
import { ZapReceiptList } from "./ZapReceiptList.js";
import { formatZapAmount } from "./zapHelpers.js";
function createElement(doc, tag, className, text) {
const element = doc.createElement(tag);
@@ -18,6 +20,10 @@ export function MessageThread({
messages = [],
state = "idle",
errorType = "",
privacyMode = "nip04",
zapReceipts = [],
conversationZapTotalSats = 0,
profileZapTotalSats = 0,
} = {}) {
if (!doc) {
throw new Error("MessageThread requires a document reference.");
@@ -39,6 +45,43 @@ export function MessageThread({
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);
root.appendChild(header);
const timeline = createElement(doc, "div", "dm-message-thread__timeline");
@@ -86,5 +129,13 @@ export function MessageThread({
}
root.appendChild(timeline);
root.appendChild(
ZapReceiptList({
document: doc,
receipts: zapReceipts,
emptyLabel: "No zap receipts published yet.",
}),
);
return root;
}
+100
View File
@@ -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;
}
+2
View File
@@ -7,3 +7,5 @@ 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";
+58
View File
@@ -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;
}
File diff suppressed because it is too large. Load diff
+11 -16
View File
@@ -6,7 +6,7 @@ import {
import { getActiveSigner } from "./nostr/index.js";
import { isSessionActor } from "./nostr/sessionActor.js";
import { normalizeNostrPubkey } from "./nostr/nip46Client.js";
import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER, NOTE_TYPES } from "./nostrEventSchemas.js";
import { buildBlockListEvent, buildMuteListEvent, BLOCK_LIST_IDENTIFIER, NOTE_TYPES } from "./nostrEventSchemas.js";
import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js";
import { devLogger, userLogger } from "./utils/logger.js";
import {
@@ -1536,20 +1536,13 @@ class UserBlockListManager {
return null;
}
const tags = sanitizeMuteTags(blockedPubkeys, owner);
const sanitizedTags = sanitizeMuteTags(blockedPubkeys, owner);
const pTags = sanitizedTags.map((tag) => tag[1]);
const timestamp = Number.isFinite(createdAt)
? Math.max(0, Math.floor(createdAt))
: Math.floor(Date.now() / 1000);
const event = {
kind: 10000,
pubkey: owner,
created_at: timestamp,
tags,
content: "",
};
const payloadText = typeof plaintext === "string" ? plaintext : "";
let encryptedContent = "";
let encryptionTagValue = "";
@@ -1579,12 +1572,14 @@ class UserBlockListManager {
}
}
if (encryptedContent) {
event.content = encryptedContent;
if (encryptionTagValue) {
event.tags.push(["encrypted", encryptionTagValue]);
}
}
const event = buildMuteListEvent({
pubkey: owner,
created_at: timestamp,
pTags,
content: encryptedContent,
encrypted: !!encryptionTagValue,
encryptionTag: encryptionTagValue,
});
onStatus?.({ status: "mute-publishing" });
+150
View File
@@ -0,0 +1,150 @@
import { devLogger } from "./logger.js";
const STORAGE_KEY = "bitvid:linkPreviewSettings:v1";
const EVENT_NAME = "bitvid:link-preview-settings";
const DEFAULT_SETTINGS = Object.freeze({
autoFetchUnknownDomains: true,
allowedDomains: [],
});
const getStorage = () => {
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage;
}
if (typeof globalThis !== "undefined" && globalThis.localStorage) {
return globalThis.localStorage;
}
return null;
};
const getEventTarget = () =>
typeof window !== "undefined" ? window : globalThis;
const normalizeDomain = (value) => {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim().toLowerCase();
return trimmed;
};
const sanitizeDomains = (domains) => {
if (!Array.isArray(domains)) {
return [];
}
const normalized = domains
.map((domain) => normalizeDomain(domain))
.filter(Boolean);
return Array.from(new Set(normalized));
};
const sanitizeSettings = (settings) => {
if (!settings || typeof settings !== "object") {
return {
autoFetchUnknownDomains: DEFAULT_SETTINGS.autoFetchUnknownDomains,
allowedDomains: [],
};
}
const autoFetchUnknownDomains =
typeof settings.autoFetchUnknownDomains === "boolean"
? settings.autoFetchUnknownDomains
: DEFAULT_SETTINGS.autoFetchUnknownDomains;
const allowedDomains = sanitizeDomains(settings.allowedDomains);
return {
autoFetchUnknownDomains,
allowedDomains,
};
};
const emitSettings = (settings) => {
const target = getEventTarget();
if (!target || typeof target.dispatchEvent !== "function") {
return;
}
target.dispatchEvent(
new CustomEvent(EVENT_NAME, { detail: { settings } }),
);
};
export const getLinkPreviewSettings = () => {
const storage = getStorage();
if (!storage) {
return { ...DEFAULT_SETTINGS, allowedDomains: [] };
}
const raw = storage.getItem(STORAGE_KEY);
if (!raw) {
return { ...DEFAULT_SETTINGS, allowedDomains: [] };
}
try {
const parsed = JSON.parse(raw);
const sanitized = sanitizeSettings(parsed);
return { ...sanitized, allowedDomains: [...sanitized.allowedDomains] };
} catch (error) {
devLogger.warn("[linkPreviewSettings] Failed to parse settings.", error);
return { ...DEFAULT_SETTINGS, allowedDomains: [] };
}
};
const persistSettings = (settings, { silent = false } = {}) => {
const storage = getStorage();
if (!storage) {
return;
}
try {
storage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (error) {
devLogger.warn("[linkPreviewSettings] Failed to persist settings.", error);
}
if (!silent) {
emitSettings(settings);
}
};
export const setLinkPreviewAutoFetch = (enabled) => {
const current = getLinkPreviewSettings();
const next = {
...current,
autoFetchUnknownDomains: Boolean(enabled),
};
persistSettings(next);
return next;
};
export const allowLinkPreviewDomain = (domain, { silent = false } = {}) => {
const normalized = normalizeDomain(domain);
if (!normalized) {
return getLinkPreviewSettings();
}
const current = getLinkPreviewSettings();
if (current.allowedDomains.includes(normalized)) {
return current;
}
const next = {
...current,
allowedDomains: [...current.allowedDomains, normalized],
};
persistSettings(next, { silent });
return next;
};
export const isLinkPreviewDomainAllowed = (domain, settings) => {
const normalized = normalizeDomain(domain);
if (!normalized) {
return false;
}
const resolvedSettings = settings || getLinkPreviewSettings();
return resolvedSettings.allowedDomains.includes(normalized);
};
export const subscribeToLinkPreviewSettings = (callback) => {
const target = getEventTarget();
if (!target || typeof target.addEventListener !== "function") {
return () => {};
}
const handler =
typeof callback === "function"
? callback
: () => {};
target.addEventListener(EVENT_NAME, handler);
return () => target.removeEventListener(EVENT_NAME, handler);
};
+3
View File
@@ -20,6 +20,9 @@
"check:css": "npm run build:css",
"test:visual": "npm run build && npx playwright test",
"test:visual:update": "npm run build && cross-env UPDATE_VISUAL_BASELINES=1 npx playwright test",
"test:e2e": "npm run build && npx playwright test tests/e2e",
"test:dm:unit": "node scripts/run-targeted-tests.mjs tests/dm-db.test.mjs tests/dm-decryptor.test.mjs tests/dm-normalization.test.mjs tests/nostr-send-direct-message.test.mjs",
"test:dm:integration": "node scripts/run-targeted-tests.mjs tests/nostr/dm-direct-message-flow.test.mjs",
"test:unit": "node scripts/run-unit-tests.mjs",
"telemetry:aggregate": "node scripts/telemetry-aggregator.mjs"
},
-53
View File
@@ -1,53 +0,0 @@
import { __testExports } from "./js/nostr/commentEvents.js";
const { isVideoCommentEvent, normalizeCommentTarget } = __testExports;
const originalVideoId = "0000000000000000000000000000000000000000000000000000000000000001";
const originalAuthor = "0000000000000000000000000000000000000000000000000000000000000002";
const originalKind = "30078";
const reposterPubkey = "0000000000000000000000000000000000000000000000000000000000000003";
const repostKind = "6";
// Scenario: UI passes the Repost event as the "video"
const targetInput = {
videoEventId: originalVideoId, // The ID is usually the original ID even in reposts? No, repost has its own ID.
// But CommentThreadService uses video.id.
// If video is Repost, video.id is Repost ID.
// But comments are on Original ID.
// So if video.id is Repost ID, then we are fetching comments for the Repost ID!
// And the comment event tags the Repost ID? No, it tags the Original ID.
// So we wouldn't even FETCH the comments if we used Repost ID.
// Wait, if loadThread is called with Repost object, videoEventId = Repost ID.
// fetchThread queries for #e = Repost ID.
// But comments are on Original ID.
// So we get NO comments.
// So the issue is even earlier: we are querying for the wrong ID.
// BUT, assuming we somehow got the comment (maybe via legacy #e tag pointing to Repost?),
// let's see if it passes filter.
// Let's assume the UI unwraps the ID but passes the Repost Kind/Pubkey?
// If video.id is Original ID, but video.kind is 6 (Repost Kind) and video.pubkey is Reposter.
videoEventId: originalVideoId,
videoKind: repostKind,
videoAuthorPubkey: reposterPubkey,
};
const descriptor = normalizeCommentTarget(targetInput);
console.log("Descriptor:", JSON.stringify(descriptor, null, 2));
const validCommentOnOriginal = {
kind: 1111,
tags: [
["e", originalVideoId, "wss://relay.example.com", "root"],
["K", originalKind],
["P", originalAuthor],
],
};
console.log("Testing Valid Comment on Original (against Repost metadata):", isVideoCommentEvent(validCommentOnOriginal, descriptor));
+50 -24
View File
@@ -1,6 +1,6 @@
import { decryptDM } from '../../js/dmDecryptor.js';
import { getRandomFuzzInput, randomHex } from './fuzz-utils.mjs';
import { getRandomFuzzInput, randomHex, randomString } from './fuzz-utils.mjs';
import fs from 'fs';
import path from 'path';
@@ -47,17 +47,29 @@ function runFuzz(name, fn, inputGenerator, iterations = 1000) {
}
}
// Mock decryptor
const mockDecryptor = {
scheme: 'nip44_v2',
decrypt: async (pubkey, ciphertext) => {
// sometimes succeed, sometimes fail
if (Math.random() > 0.5) {
return JSON.stringify({ content: 'decrypted', pubkey: randomHex(), created_at: Date.now()/1000 });
} else {
throw new Error('Decrypt failed');
// Deterministic Mock decryptor factory based on input seed
const createMockDecryptor = (seedValue) => {
return {
scheme: 'nip44_v2',
decrypt: async (pubkey, ciphertext, options) => {
// Use seed to determine behavior deterministically
// seedValue comes from input.
const mode = (typeof seedValue === 'number' ? seedValue : 0) % 10;
if (mode > 6) {
// Success
return JSON.stringify({ content: 'decrypted', pubkey: randomHex(), created_at: Date.now()/1000 });
} else if (mode > 3) {
// Garbage JSON
return "{ bad json";
} else if (mode > 1) {
// Not JSON
return "Just a string";
} else {
throw new Error('Decrypt failed');
}
}
}
};
};
const throwingDecryptor = {
@@ -69,27 +81,41 @@ const wrapper = async (input) => {
// input is fuzzy junk.
// We construct a call to decryptDM(event, context)
let event = input;
let context = {
actorPubkey: randomHex(),
decryptors: [mockDecryptor, throwingDecryptor]
};
// Deterministic randomness from input if object
let seed = 0;
if (input && typeof input === 'object') {
seed = Object.keys(input).length;
} else if (typeof input === 'string') {
seed = input.length;
} else if (typeof input === 'number') {
seed = Math.floor(input);
}
let event = input;
// Deterministically force supported kinds based on seed
if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
// If input is an object, treat it as the event structure, but potentially corrupted
const kinds = [4, 1059, input.kind];
event = {
kind: input.kind !== undefined ? input.kind : 4,
...input,
kind: kinds[seed % kinds.length],
content: input.content || 'ciphertext',
pubkey: input.pubkey || randomHex(),
tags: input.tags || [['p', randomHex()]],
created_at: input.created_at || Date.now()/1000,
...input
created_at: input.created_at || Date.now()/1000
};
}
// Also fuzz context sometimes
if (Math.random() > 0.8) {
context = input.context || getRandomFuzzInput();
}
const mockDec = createMockDecryptor(seed);
let context = {
actorPubkey: randomHex(),
decryptors: [mockDec, throwingDecryptor]
};
// Deterministically fuzz context
if ((seed % 10) > 8) {
context = input.context || getRandomFuzzInput();
}
await decryptDM(event, context);
+1
View File
@@ -64,6 +64,7 @@ const wrapper = (input) => {
};
}
// Deterministic execution: input fully defines execution path.
normalizeAndAugmentMagnet(rawValue, options);
};
+21 -15
View File
@@ -12,9 +12,10 @@ import {
buildBlockListEvent,
buildHashtagPreferenceEvent,
buildAdminListEvent,
buildStorageChallengeEvent,
sanitizeAdditionalTags,
getNostrEventSchema
getNostrEventSchema,
setNostrEventSchemaOverrides,
NOTE_TYPES
} from '../../js/nostrEventSchemas.js';
import { getRandomFuzzInput, randomString, randomHex } from './fuzz-utils.mjs';
import fs from 'fs';
@@ -66,8 +67,6 @@ function runFuzz(name, fn, inputGenerator, iterations = 1000) {
// Wrap functions that take multiple arguments or objects
const wrappers = {
buildVideoPostEvent: (input) => {
// Input is expected to be an object, but we fuzz it with anything.
// If it is an object, we fuzz its properties too.
if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
buildVideoPostEvent({
pubkey: input.pubkey || getRandomFuzzInput(),
@@ -205,22 +204,29 @@ const wrappers = {
buildAdminListEvent(key, input);
}
},
buildStorageChallengeEvent: (input) => {
if (typeof input === 'object' && input !== null) {
buildStorageChallengeEvent({
...input,
pubkey: input.pubkey || getRandomFuzzInput(),
created_at: input.created_at || getRandomFuzzInput()
});
} else {
buildStorageChallengeEvent(input);
}
},
sanitizeAdditionalTags: (input) => {
sanitizeAdditionalTags(input);
},
getNostrEventSchema: (input) => {
getNostrEventSchema(input);
},
setNostrEventSchemaOverrides: (input) => {
try {
setNostrEventSchemaOverrides(input);
// Deterministically pick a type based on input properties if possible
const types = Object.values(NOTE_TYPES);
let index = 0;
if (typeof input === 'object' && input !== null) {
index = Object.keys(input).length % types.length;
} else if (typeof input === 'string') {
index = input.length % types.length;
}
const type = types[index];
getNostrEventSchema(type);
} finally {
// Clean up global state to avoid pollution
setNostrEventSchemaOverrides({});
}
}
};
+316 -189
View File
@@ -1,198 +1,325 @@
import { chromium } from '@playwright/test';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { WebSocket } from 'ws';
import * as crypto from 'node:crypto';
import * as NostrTools from 'nostr-tools';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '../../');
// Polyfills
globalThis.WebSocket = WebSocket;
if (!globalThis.crypto) {
globalThis.crypto = crypto;
const ARTIFACTS_DIR = path.join(REPO_ROOT, 'artifacts');
const TIMESTAMP = new Date().toISOString().replace(/[:.]/g, '-');
const LOG_FILE = path.join(ARTIFACTS_DIR, `interop-${TIMESTAMP}.log`);
const REPORT_FILE = path.join(ARTIFACTS_DIR, `interop-report-${TIMESTAMP}.json`);
const RELAY_PORT = 8008;
const HTTP_PORT = 8001;
// Ensure artifacts dir exists
if (!fs.existsSync(ARTIFACTS_DIR)) {
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
}
globalThis.NostrTools = NostrTools;
// Mock localStorage
const localStorageMock = {
store: {},
getItem: function(key) { return this.store[key] || null; },
setItem: function(key, value) { this.store[key] = String(value); },
removeItem: function(key) { delete this.store[key]; },
clear: function() { this.store = {}; }
function log(message, type = 'INFO') {
const msg = `[${new Date().toISOString()}] [${type}] ${message}`;
console.log(msg);
fs.appendFileSync(LOG_FILE, msg + '\n');
}
const stats = {
steps: 0,
failures: 0,
details: []
};
globalThis.localStorage = localStorageMock;
// Prevent crash on unhandled rejection (e.g. from nostr-tools timeouts)
process.on('unhandledRejection', (reason, p) => {
// console.log('[TEST] Unhandled Rejection (suppressed):', reason);
});
// Configuration
const TEST_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol'
];
async function runTest() {
const { NostrClient } = await import('../../js/nostr/client.js');
// Removed unused imports
console.log('Starting Interop Test...');
const logs = [];
const log = (msg, data) => {
console.log(`[TEST] ${msg}`, data ? JSON.stringify(data, null, 2) : '');
logs.push({ msg, data });
};
try {
// -------------------------------------------------------------------------
// 1. Setup Sender
// -------------------------------------------------------------------------
log('Initializing Sender Client...');
const senderClient = new NostrClient();
// Explicitly set relays
senderClient.relays = [...TEST_RELAYS];
senderClient.writeRelays = [...TEST_RELAYS];
senderClient.readRelays = [...TEST_RELAYS];
log('Sender Relays set to:', senderClient.relays);
// Generate Ephemeral Key for Sender
const senderSecret = NostrTools.generateSecretKey();
const senderPrivKey = Buffer.from(senderSecret).toString('hex');
const { pubkey: senderPubkey } = await senderClient.registerPrivateKeySigner({ privateKey: senderPrivKey });
senderClient.pubkey = senderPubkey;
log('Sender Pubkey:', senderClient.pubkey);
await senderClient.init();
log('Sender Client Initialized.');
// -------------------------------------------------------------------------
// 2. Publish Video Post
// -------------------------------------------------------------------------
log('Test A: Publish Video Post');
const videoRootId = `interop-test-${Date.now()}`;
const magnet = `magnet:?xt=urn:btih:${crypto.randomBytes(20).toString('hex')}&dn=test-video`;
const title = 'Interop Test Video ' + new Date().toISOString();
const videoData = {
title,
videoRootId,
magnet,
description: 'Automated interop test video',
mimeType: 'video/mp4'
};
log('Publishing video...', videoData);
const signedVideoEvent = await senderClient.publishVideo(videoData, senderClient.pubkey);
log('Video Published. Event ID:', signedVideoEvent.id);
// Verify
log('Verifying video event fetch...');
// Allow some propagation time
await new Promise(r => setTimeout(r, 2000));
const fetchedVideo = await senderClient.getEventById(signedVideoEvent.id);
if (!fetchedVideo) throw new Error('Failed to fetch published video event');
if (fetchedVideo.id !== signedVideoEvent.id) throw new Error('Fetched event ID mismatch');
const isSigValid = NostrTools.verifyEvent(signedVideoEvent);
if (!isSigValid) throw new Error('Invalid event signature');
// Verify content shape
if (fetchedVideo.title !== title) throw new Error(`Title mismatch: expected "${title}", got "${fetchedVideo.title}"`);
if (fetchedVideo.magnet !== magnet) throw new Error(`Magnet mismatch: expected "${magnet}", got "${fetchedVideo.magnet}"`);
// Note: publishVideo generates a new videoRootId, so we check against the signed event's content, not our input
const signedContent = JSON.parse(signedVideoEvent.content);
if (fetchedVideo.videoRootId !== signedContent.videoRootId) throw new Error(`Root ID mismatch: expected "${signedContent.videoRootId}", got "${fetchedVideo.videoRootId}"`);
log('Test A PASSED: Video event published and verified (content matched).');
// -------------------------------------------------------------------------
// 3. Publish View Event
// -------------------------------------------------------------------------
log('Test B: Publish View Event');
const pointer = {
type: 'e',
value: signedVideoEvent.id,
relay: TEST_RELAYS[0]
};
log('Publishing view event...', pointer);
const viewResult = await senderClient.publishViewEvent(pointer);
if (!viewResult.ok) throw new Error(`Failed to publish view event: ${viewResult.error}`);
log('View event published.');
// Verify View Event Visibility
log('Verifying view event visibility...');
await new Promise(r => setTimeout(r, 2000));
// We can list view events for this pointer
const viewEvents = await senderClient.listVideoViewEvents(pointer, { limit: 5 });
const foundView = viewEvents.find(v => v.pubkey === senderPubkey); // It should be signed by sender
if (!foundView) {
log('Fetched View Events:', viewEvents);
throw new Error('Could not verify visibility of published view event.');
function recordStep(name, success, error = null) {
stats.steps++;
const detail = { name, success, timestamp: new Date().toISOString() };
if (!success) {
stats.failures++;
detail.error = error?.message || String(error);
log(`Step Failed: ${name} - ${detail.error}`, 'ERROR');
if (error?.stack) {
log(error.stack, 'ERROR');
}
} else {
log(`Step Passed: ${name}`, 'SUCCESS');
}
log('Test B PASSED: View event verified visible.');
// -------------------------------------------------------------------------
// 4. Encrypted Direct Message (Sender -> Receiver)
// -------------------------------------------------------------------------
log('Test C: Encrypted DM');
// Setup Receiver
const receiverClient = new NostrClient();
receiverClient.relays = [...TEST_RELAYS];
receiverClient.writeRelays = [...TEST_RELAYS];
receiverClient.readRelays = [...TEST_RELAYS];
const receiverSecret = NostrTools.generateSecretKey();
const receiverPrivKey = Buffer.from(receiverSecret).toString('hex');
const { pubkey: receiverPubkey } = await receiverClient.registerPrivateKeySigner({ privateKey: receiverPrivKey });
receiverClient.pubkey = receiverPubkey;
log('Receiver Pubkey:', receiverPubkey);
await receiverClient.init();
// Sender sends DM
const messageContent = `Hello Interop ${Date.now()}`;
log(`Sender sending DM to ${receiverPubkey}: "${messageContent}"`);
// sendDirectMessage is on the sender client
const sendResult = await senderClient.sendDirectMessage(
NostrTools.nip19.npubEncode(receiverPubkey),
messageContent
);
if (!sendResult.ok) throw new Error(`Failed to send DM: ${sendResult.error}`);
log('DM Sent.');
// Wait for propagation
await new Promise(r => setTimeout(r, 5000));
// Receiver fetches DMs
log('Receiver fetching DMs...');
const dms = await receiverClient.listDirectMessages(receiverPubkey);
const foundDm = dms.find(dm => dm.plaintext === messageContent);
if (!foundDm) {
log('Fetched DMs:', dms.map(d => ({ plain: d.plaintext, sender: d.sender })));
throw new Error('Receiver could not find or decrypt the sent message.');
}
log('Test C PASSED: DM received and decrypted.');
console.log('\nAll Interop Tests PASSED!');
process.exit(0);
} catch (err) {
console.error('\nTest FAILED:', err);
process.exit(1);
}
stats.details.push(detail);
}
runTest();
async function startProcess(command, args, cwd, readyCheck) {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { cwd, stdio: 'pipe' });
let started = false;
const label = args.some(a => a.includes('relay')) ? 'RELAY' : command;
const checkData = (data) => {
const output = data.toString();
if (started) {
if (label === 'RELAY') log(`[RELAY] ${output.trim()}`, 'DEBUG');
return;
}
if (readyCheck(output)) {
started = true;
resolve(proc);
}
};
proc.stdout.on('data', (data) => {
checkData(data);
});
proc.stderr.on('data', (data) => {
checkData(data);
});
proc.on('error', (err) => reject(err));
proc.on('exit', (code) => {
if (!started) reject(new Error(`${command} exited early with code ${code}`));
});
// Timeout fallback
setTimeout(() => {
if (!started) {
// For http server, sometimes it doesn't output much
if (command.includes('python') || command.includes('http.server')) {
started = true;
resolve(proc);
} else {
reject(new Error(`${command} timed out waiting for ready signal`));
}
}
}, 3000);
});
}
async function runInteropTest() {
let relayProc, serverProc, browser;
try {
log('Starting interop test...');
// 1. Start Relay
log('Starting local relay...');
relayProc = await startProcess(
'node',
['scripts/agent/simple-relay.mjs'],
REPO_ROOT,
(output) => output.includes(`running on ws://localhost:${RELAY_PORT}`) || output.includes(`running on port ${RELAY_PORT}`)
);
} catch (e) {
log('Failed to start relay: ' + e.message, 'FATAL');
}
try {
// 2. Start HTTP Server
log('Starting HTTP server...');
serverProc = await startProcess(
'python3',
['-m', 'http.server', String(HTTP_PORT)],
REPO_ROOT,
(output) => output.includes(`Serving HTTP on 0.0.0.0 port ${HTTP_PORT}`)
);
// 3. Launch Browser
log('Launching browser...');
browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
// 4. Navigate to app
log(`Navigating to http://localhost:${HTTP_PORT}...`);
await page.goto(`http://localhost:${HTTP_PORT}`, { waitUntil: 'networkidle' });
// 5. Inject Test Logic
log('Executing test logic in browser...');
const testResult = await page.evaluate(async ({ relayUrl }) => {
const logs = [];
const steps = [];
function log(msg) { console.log(msg); logs.push(msg); }
try {
// Import dependencies
const { nostrClient } = await import('./js/nostr/defaultClient.js');
const schemas = await import('./js/nostrEventSchemas.js');
// Wait for NostrTools
if (!window.NostrTools) {
await new Promise(r => setTimeout(r, 1000));
if (!window.NostrTools) throw new Error("NostrTools not available on window");
}
const NostrTools = window.NostrTools;
// --- Step 1: Init ---
nostrClient.relays = [relayUrl];
nostrClient.readRelays = [relayUrl];
nostrClient.writeRelays = [relayUrl];
await nostrClient.init();
await nostrClient.connectToRelays();
steps.push({ name: 'Init & Connect', success: true });
// --- Step 2: Setup Alice (Video Publisher) ---
const skAlice = NostrTools.generateSecretKey();
const pkAlice = NostrTools.getPublicKey(skAlice);
const hexSkAlice = NostrTools.utils.bytesToHex(skAlice);
await nostrClient.registerPrivateKeySigner({ privateKey: hexSkAlice, pubkey: pkAlice });
steps.push({ name: 'Setup Alice', success: true });
// --- Test A: Publish VIDEO_POST ---
const dTagValue = `test-video-${Date.now()}`;
const videoContent = {
version: 3,
title: 'Interop Test Video',
description: 'Testing event schemas',
videoRootId: `root-${dTagValue}`,
mode: 'dev',
url: 'https://example.com/video.mp4',
magnet: 'magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678&dn=video.mp4'
};
const videoEventSkeleton = schemas.buildVideoPostEvent({
pubkey: pkAlice,
created_at: Math.floor(Date.now() / 1000),
dTagValue: dTagValue,
content: videoContent
});
// Use signAndPublishEvent to sign and publish
const { signedEvent: publishedVideo } = await nostrClient.signAndPublishEvent(videoEventSkeleton);
if (!publishedVideo || !publishedVideo.id || !publishedVideo.sig) {
throw new Error("Video publish returned incomplete event");
}
steps.push({ name: 'Publish VIDEO_POST', success: true });
// --- Test B: Fetch and Validate Video ---
await new Promise(r => setTimeout(r, 500)); // wait for relay
const fetchedVideo = await nostrClient.getEventById(publishedVideo.id, { includeRaw: true });
if (!fetchedVideo) throw new Error("Failed to fetch video event back");
// Validate shape
const fetchedRaw = fetchedVideo.rawEvent || fetchedVideo; // handle wrapper
if (fetchedRaw.id !== publishedVideo.id) throw new Error("ID mismatch");
if (fetchedRaw.pubkey !== pkAlice) throw new Error("Pubkey mismatch");
// Parse content
const fetchedContent = typeof fetchedRaw.content === 'string' ? JSON.parse(fetchedRaw.content) : fetchedRaw.content;
if (fetchedContent.title !== videoContent.title) throw new Error("Content title mismatch");
if (fetchedContent.videoRootId !== videoContent.videoRootId) throw new Error("Content rootId mismatch");
steps.push({ name: 'Verify VIDEO_POST Roundtrip', success: true });
// --- Test C: Publish VIEW_EVENT ---
const viewEventSkeleton = schemas.buildViewEvent({
pubkey: pkAlice,
created_at: Math.floor(Date.now() / 1000),
pointerTag: ['e', publishedVideo.id], // pointing to the video
content: 'test view'
});
const { signedEvent: publishedView } = await nostrClient.signAndPublishEvent(viewEventSkeleton);
await new Promise(r => setTimeout(r, 500));
const fetchedView = await nostrClient.fetchRawEventById(publishedView.id);
if (!fetchedView) throw new Error("Failed to fetch view event back");
steps.push({ name: 'Publish & Verify VIEW_EVENT', success: true });
// --- Test D: Encrypted DM (Alice -> Bob) ---
const skBob = NostrTools.generateSecretKey();
const pkBob = NostrTools.getPublicKey(skBob);
const hexSkBob = NostrTools.utils.bytesToHex(skBob);
const npubBob = NostrTools.nip19.npubEncode(pkBob);
// Alice is currently logged in
const dmMessage = `Hello Bob ${Date.now()}`;
const dmResult = await nostrClient.sendDirectMessage(npubBob, dmMessage);
if (!dmResult.ok) throw new Error("Failed to send DM: " + dmResult.error);
steps.push({ name: 'Send Encrypted DM', success: true });
// Switch to Bob to decrypt
nostrClient.logout();
await nostrClient.registerPrivateKeySigner({ privateKey: hexSkBob, pubkey: pkBob });
await new Promise(r => setTimeout(r, 500));
const dms = await nostrClient.listDirectMessages(pkBob);
const receivedDm = dms.find(m => m.plaintext === dmMessage);
if (!receivedDm) {
throw new Error(`Bob could not find/decrypt the DM. Found: ${dms.length} messages.`);
}
if (receivedDm.sender.pubkey !== pkAlice) {
throw new Error("DM sender mismatch");
}
steps.push({ name: 'Decrypt DM', success: true });
return { success: true, steps, logs };
} catch (e) {
return { success: false, steps, logs, error: e.message, stack: e.stack };
}
}, { relayUrl: `ws://localhost:${RELAY_PORT}` });
// Process results
if (testResult.logs) {
testResult.logs.forEach(l => log(`[Browser] ${l}`));
}
if (testResult.steps) {
testResult.steps.forEach(s => recordStep(s.name, s.success));
}
if (!testResult.success) {
throw new Error(testResult.error || "Unknown browser test failure");
}
} catch (err) {
log(`Test failed: ${err.message}`, 'FATAL');
recordStep('Interop Test Suite', false, err);
// Take screenshot if browser is active
if (browser) {
try {
const page = browser.contexts()[0].pages()[0];
if (page) {
await page.screenshot({ path: path.join(ARTIFACTS_DIR, `interop-failure-${TIMESTAMP}.png`) });
log(`Screenshot saved to interop-failure-${TIMESTAMP}.png`);
}
} catch (e) {
log('Failed to capture screenshot: ' + e.message);
}
}
} finally {
// Cleanup
if (browser) await browser.close();
if (serverProc) {
try { process.kill(serverProc.pid); } catch (e) { /* ignore */ }
}
if (relayProc) {
try { process.kill(relayProc.pid); } catch (e) { /* ignore */ }
}
// Report
fs.writeFileSync(REPORT_FILE, JSON.stringify({
timestamp: new Date().toISOString(),
stats,
logFile: LOG_FILE
}, null, 2));
console.log(`Report saved to ${REPORT_FILE}`);
if (stats.failures > 0) process.exit(1);
}
}
runInteropTest();
+276 -264
View File
@@ -1,306 +1,318 @@
import WebSocket from 'ws';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools';
import { writeFile, mkdir } from 'fs/promises';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { performance } from 'perf_hooks';
import { fileURLToPath } from 'url';
// --- Configuration ---
const ARGS = process.argv.slice(2);
const getConfig = (key, defaultVal) => {
const idx = ARGS.indexOf(`--${key}`);
return idx !== -1 ? ARGS[idx + 1] : defaultVal;
};
const hasFlag = (flag) => ARGS.includes(`--${flag}`);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PROJECT_ROOT = path.resolve(__dirname, '../..');
// Configuration
const CONFIG = {
clients: parseInt(getConfig('clients', '100'), 10),
duration: parseInt(getConfig('duration', '60'), 10), // seconds
relay: getConfig('relay', 'ws://localhost:8008'),
rate: parseInt(getConfig('rate', '50'), 10), // target events per second (global)
unsafe: hasFlag('unsafe'),
outputDir: 'artifacts',
relayUrl: 'ws://localhost:8008',
numClients: 1000,
durationSeconds: 60,
eventsPerSecond: 100,
mix: {
viewEvent: 0.8,
videoPost: 0.2
},
unsafe: false
};
// --- Safety Check ---
// Parse args
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--clients') CONFIG.numClients = parseInt(args[++i], 10);
else if (arg === '--duration') CONFIG.durationSeconds = parseInt(args[++i], 10);
else if (arg === '--rate') CONFIG.eventsPerSecond = parseInt(args[++i], 10);
else if (arg === '--relay') CONFIG.relayUrl = args[++i];
else if (arg === '--unsafe') CONFIG.unsafe = true;
}
console.log('Load Test Configuration:', CONFIG);
// Guardrail
function isSafeRelay(url) {
if (CONFIG.unsafe) return true;
try {
const u = new URL(url);
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]') return true;
// Check for private IPs (10.x, 192.168.x, 172.16-31.x) could be added here,
// but for now localhost is the strict guardrail.
return false;
} catch (e) {
return u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
} catch {
return false;
}
}
if (!isSafeRelay(CONFIG.relay)) {
console.error(`\n[FATAL] Guardrail triggered: Attempting to load test a non-local relay (${CONFIG.relay}) without --unsafe flag.`);
console.error("Please target a local relay or use --unsafe if you are sure you own the infrastructure.\n");
if (!isSafeRelay(CONFIG.relayUrl)) {
console.error('ERROR: Guardrail active. Cannot run against public relays without --unsafe flag.');
process.exit(1);
}
// --- Helpers ---
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Artifacts setup
const ARTIFACTS_DIR = path.join(PROJECT_ROOT, 'artifacts');
if (!fs.existsSync(ARTIFACTS_DIR)) {
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
}
const NOTE_TYPES = {
VIDEO_POST: 30078,
VIEW_EVENT: 30079
// Helpers
function buildViewEvent(pubkey) {
return {
kind: 30079,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', 'watch-history'],
['t', 'view'],
['session', 'true']
],
content: JSON.stringify({ timestamp: Date.now(), random: Math.random() }),
pubkey
};
}
function buildVideoPostEvent(pubkey) {
return {
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', Math.random().toString(36).substring(7)],
['t', 'video']
],
content: JSON.stringify({
version: 1,
title: `Load Test Video ${Math.random().toString(36)}`,
videoRootId: Math.random().toString(36),
mode: 's',
duration: 120
}),
pubkey
};
}
// State
const metrics = {
sent: 0,
ok: 0,
failed: 0,
latencies: [],
signingTimes: [],
startTime: Date.now(),
startCpu: process.cpuUsage(),
errors: []
};
// Simplified Event Builders based on js/nostrEventSchemas.js
function buildVideoPostEvent(signer, contentData) {
const created_at = Math.floor(Date.now() / 1000);
const event = {
kind: NOTE_TYPES.VIDEO_POST,
created_at,
tags: [
['t', 'video'],
['d', `video-${created_at}-${Math.random().toString(36).slice(2)}`]
],
content: JSON.stringify(contentData),
pubkey: getPublicKey(signer.privateKey),
};
return finalizeEvent(event, signer.privateKey);
}
// Main execution
async function main() {
let relayProcess = null;
const isLocalRelay = CONFIG.relayUrl.includes('localhost') || CONFIG.relayUrl.includes('127.0.0.1');
function buildViewEvent(signer, videoId) {
const created_at = Math.floor(Date.now() / 1000);
const event = {
kind: NOTE_TYPES.VIEW_EVENT,
created_at,
tags: [
['t', 'view'],
['e', videoId],
['d', `view-${created_at}-${Math.random().toString(36).slice(2)}`] // Dedupe tag
],
content: '',
pubkey: getPublicKey(signer.privateKey),
};
return finalizeEvent(event, signer.privateKey);
}
// --- Client Class ---
class LoadClient {
constructor(relayUrl, id) {
this.id = id;
this.relayUrl = relayUrl;
this.privateKey = generateSecretKey();
this.pubkey = getPublicKey(this.privateKey);
this.ws = null;
this.connected = false;
this.pending = new Map(); // eventId -> startTime
if (isLocalRelay) {
try {
await checkRelay(CONFIG.relayUrl);
console.log('Relay is already running.');
} catch (e) {
console.log('Relay not running, spawning simple-relay.mjs...');
const relayScript = path.join(PROJECT_ROOT, 'scripts/agent/simple-relay.mjs');
if (!fs.existsSync(relayScript)) {
console.error(`Simple relay script not found at ${relayScript}`);
process.exit(1);
}
relayProcess = spawn('node', [relayScript], {
stdio: 'inherit',
detached: false
});
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
connect() {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.relayUrl);
this.ws.on('open', () => {
this.connected = true;
resolve();
});
this.ws.on('error', (err) => {
// console.error(`Client ${this.id} error:`, err.message);
reject(err);
});
this.ws.on('message', (data) => this.handleMessage(data));
this.ws.on('close', () => {
this.connected = false;
});
console.log(`Simulating ${CONFIG.numClients} clients...`);
const MAX_CONNS = Math.min(CONFIG.numClients, 100);
const connections = [];
for (let i = 0; i < MAX_CONNS; i++) {
const ws = new WebSocket(CONFIG.relayUrl);
ws.on('error', (err) => {
metrics.errors.push(err.message);
});
connections.push(ws);
}
await Promise.all(connections.map(ws => new Promise(resolve => {
if (ws.readyState === WebSocket.OPEN) resolve();
else ws.on('open', resolve);
setTimeout(resolve, 1000);
})));
console.log(`${connections.length} connections established.`);
const keypairs = [];
for (let i = 0; i < CONFIG.numClients; i++) {
const sk = generateSecretKey();
keypairs.push({
sk,
pk: getPublicKey(sk)
});
}
console.log('Starting load generation...');
const intervalMs = 1000 / CONFIG.eventsPerSecond;
const endAt = Date.now() + (CONFIG.durationSeconds * 1000);
const pendingLatencies = new Map();
connections.forEach(ws => {
ws.removeAllListeners('message');
ws.on('message', (data) => {
try {
const msg = JSON.parse(data);
if (msg[0] === 'OK') {
const eventId = msg[1];
if (pendingLatencies.has(eventId)) {
const start = pendingLatencies.get(eventId);
const latency = Date.now() - start;
metrics.latencies.push(latency);
pendingLatencies.delete(eventId);
}
metrics.ok++;
} else if (msg[0] === 'NOTICE') {
metrics.errors.push(msg[1]);
}
} catch (e) {}
});
});
const loop = async () => {
while (Date.now() < endAt) {
const loopStart = Date.now();
const client = keypairs[Math.floor(Math.random() * keypairs.length)];
const ws = connections[Math.floor(Math.random() * connections.length)];
if (ws.readyState === WebSocket.OPEN) {
let eventTemplate;
if (Math.random() < CONFIG.mix.viewEvent) {
eventTemplate = buildViewEvent(client.pk);
} else {
eventTemplate = buildVideoPostEvent(client.pk);
}
try {
const signStart = process.hrtime();
const event = finalizeEvent(eventTemplate, client.sk);
const signEnd = process.hrtime(signStart);
const signMs = (signEnd[0] * 1000) + (signEnd[1] / 1e6);
metrics.signingTimes.push(signMs);
const msg = JSON.stringify(['EVENT', event]);
if (Math.random() < 0.05) {
pendingLatencies.set(event.id, Date.now());
}
ws.send(msg);
metrics.sent++;
} catch (err) {
metrics.failed++;
metrics.errors.push(err.message);
}
}
const elapsed = Date.now() - loopStart;
const targetDelay = 1000 / CONFIG.eventsPerSecond;
if (elapsed < targetDelay) {
await new Promise(r => setTimeout(r, targetDelay - elapsed));
}
}
finish(relayProcess);
};
loop();
}
function checkRelay(url) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.on('open', () => {
ws.close();
resolve();
});
}
handleMessage(data) {
try {
const msg = JSON.parse(data);
if (msg[0] === 'OK') {
const eventId = msg[1];
const success = msg[2];
const message = msg[3];
if (this.pending.has(eventId)) {
const startTime = this.pending.get(eventId);
const latency = Date.now() - startTime;
this.pending.delete(eventId);
stats.recordAck(latency, success, message);
}
}
} catch (e) {
console.error('Error parsing message:', e);
}
}
publish(event) {
if (!this.connected) return;
this.pending.set(event.id, Date.now());
this.ws.send(JSON.stringify(['EVENT', event]));
stats.recordSent();
}
close() {
if (this.ws) this.ws.close();
}
ws.on('error', (err) => {
reject(err);
});
});
}
// --- Metrics ---
const stats = {
startTime: Date.now(),
sent: 0,
acked: 0,
failed: 0,
latencies: [],
signingTimes: [],
errors: [],
startCpu: process.cpuUsage(),
function finish(relayProcess) {
console.log('Test finished. Generating report...');
recordSent() {
this.sent++;
},
const cpuUsage = process.cpuUsage(metrics.startCpu);
const memUsage = process.memoryUsage();
recordAck(latency, success, message) {
if (success) {
this.acked++;
this.latencies.push(latency);
} else {
this.failed++;
this.errors.push(message);
}
},
const avgLatency = metrics.latencies.reduce((a, b) => a + b, 0) / (metrics.latencies.length || 1);
const sortedLatencies = metrics.latencies.sort((a, b) => a - b);
const p95 = sortedLatencies[Math.floor(sortedLatencies.length * 0.95)] || 0;
const maxLatency = sortedLatencies[sortedLatencies.length - 1] || 0;
recordSigningTime(ms) {
this.signingTimes.push(ms);
},
const avgSignTime = metrics.signingTimes.reduce((a, b) => a + b, 0) / (metrics.signingTimes.length || 1);
const sortedSignTimes = metrics.signingTimes.sort((a, b) => a - b);
const p95SignTime = sortedSignTimes[Math.floor(sortedSignTimes.length * 0.95)] || 0;
getSummary() {
this.latencies.sort((a, b) => a - b);
this.signingTimes.sort((a, b) => a - b);
const durationSec = (Date.now() - metrics.startTime) / 1000;
const throughput = metrics.ok / durationSec;
const calcStats = (arr) => {
const p50 = arr[Math.floor(arr.length * 0.5)] || 0;
const p95 = arr[Math.floor(arr.length * 0.95)] || 0;
const p99 = arr[Math.floor(arr.length * 0.99)] || 0;
const avg = arr.reduce((a, b) => a + b, 0) / (arr.length || 1);
return { avg: avg.toFixed(2), p50: p50.toFixed(2), p95: p95.toFixed(2), p99: p99.toFixed(2) };
};
const durationSec = (Date.now() - this.startTime) / 1000;
const latencyStats = calcStats(this.latencies);
const signingStats = calcStats(this.signingTimes);
const cpuUsage = process.cpuUsage(this.startCpu);
const memUsage = process.memoryUsage();
return {
config: CONFIG,
durationSec: durationSec.toFixed(2),
events: {
sent: this.sent,
acked: this.acked,
failed: this.failed,
const report = {
timestamp: new Date().toISOString(),
config: CONFIG,
metrics: {
durationSeconds: durationSec,
totalSent: metrics.sent,
totalOk: metrics.ok,
totalFailed: metrics.failed,
throughputEventsPerSec: throughput,
latencyMs: {
avg: avgLatency,
p95: p95,
max: maxLatency
},
throughput: {
sentPerSec: (this.sent / durationSec).toFixed(2),
ackedPerSec: (this.acked / durationSec).toFixed(2),
crypto: {
avgSigningTimeMs: avgSignTime,
p95SigningTimeMs: p95SignTime
},
latencyMs: latencyStats,
signingTimeMs: signingStats,
resources: {
cpuUserSec: (cpuUsage.user / 1e6).toFixed(2),
cpuSystemSec: (cpuUsage.system / 1e6).toFixed(2),
memoryRssMb: (memUsage.rss / 1024 / 1024).toFixed(2),
memoryHeapMb: (memUsage.heapUsed / 1024 / 1024).toFixed(2)
cpuUser: cpuUsage.user,
cpuSystem: cpuUsage.system,
memoryRss: memUsage.rss,
memoryHeapTotal: memUsage.heapTotal,
memoryHeapUsed: memUsage.heapUsed
},
errors: this.errors.slice(0, 10), // Sample top 10
hotFunctions: [
{ name: "finalizeEvent (signing)", avgMs: signingStats.avg }
],
bottlenecks: signingStats.avg > 100 ? ["Cryptographic operations (signing)"] : []
};
}
};
// --- Main ---
async function run() {
console.log(`Starting load test with ${CONFIG.clients} clients against ${CONFIG.relay}`);
console.log(`Target rate: ${CONFIG.rate} events/sec`);
console.log(`Duration: ${CONFIG.duration} seconds`);
const clients = [];
for (let i = 0; i < CONFIG.clients; i++) {
clients.push(new LoadClient(CONFIG.relay, i));
}
console.log('Connecting clients...');
await Promise.all(clients.map(c => c.connect().catch(e => null)));
const connectedCount = clients.filter(c => c.connected).length;
console.log(`Connected ${connectedCount}/${CONFIG.clients} clients.`);
if (connectedCount === 0) {
console.error("No clients connected. Aborting.");
process.exit(1);
}
const endTime = Date.now() + (CONFIG.duration * 1000);
const intervalMs = 1000 / CONFIG.rate; // Delay between events to match rate
console.log('Starting load generation...');
// Simple loop to maintain rate
let running = true;
const loop = async () => {
while (running && Date.now() < endTime) {
const startLoop = Date.now();
// Pick random client
const client = clients[Math.floor(Math.random() * clients.length)];
// 10% Video Post, 90% View Event
const isVideo = Math.random() < 0.1;
let event;
const signStart = performance.now();
if (isVideo) {
const content = {
title: `Load Test Video ${Date.now()}`,
description: "Load test content",
videoRootId: `root-${Date.now()}`,
version: 3
};
event = buildVideoPostEvent(client, content);
} else {
event = buildViewEvent(client, "some-video-id");
}
const signEnd = performance.now();
stats.recordSigningTime(signEnd - signStart);
client.publish(event);
const elapsed = Date.now() - startLoop;
const wait = Math.max(0, intervalMs - elapsed);
await delay(wait);
}
errorSample: metrics.errors.slice(0, 10)
},
bottlenecks: [
avgLatency > 1000 ? "High Average Latency (>1s)" : null,
avgSignTime > 10 ? "Cryptographic Bottleneck (Signing > 10ms)" : null,
metrics.failed > 0 ? "Failed Events Detected" : null
].filter(Boolean),
remediation: "If latency is high, consider scaling the relay. If signing is slow, review crypto library or CPU resources."
};
await loop();
running = false;
const dateStr = new Date().toISOString().split('T')[0].replace(/-/g, '');
const reportPath = path.join(ARTIFACTS_DIR, `load-report-${dateStr}.json`);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log('Load generation finished. Waiting for pending ACKs (5s)...');
await delay(5000);
console.log(`Report saved to ${reportPath}`);
console.log(JSON.stringify(report.metrics, null, 2));
clients.forEach(c => c.close());
const summary = stats.getSummary();
console.log('--- Load Test Report ---');
console.log(JSON.stringify(summary, null, 2));
await mkdir(CONFIG.outputDir, { recursive: true });
// Use fixed date format YYYYMMDD for PR requirements
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const filename = path.join(CONFIG.outputDir, `load-report-${dateStr}.json`);
await writeFile(filename, JSON.stringify(summary, null, 2));
console.log(`Report saved to ${filename}`);
if (relayProcess) {
console.log('Stopping local relay...');
relayProcess.kill();
}
process.exit(0);
}
run().catch(console.error);
main().catch(err => {
console.error(err);
process.exit(1);
});
+286
View File
@@ -0,0 +1,286 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.resolve(__dirname, '../../');
const ARTIFACTS_DIR = path.join(ROOT_DIR, 'artifacts');
const REPORTS_DIR = path.join(ROOT_DIR, 'ai/reports');
const LOG_FILES = [
'test_output.log',
'npm_output.log',
'server.log',
'python_server.log'
];
// Privacy Sanitization Patterns
const PATTERNS = {
ipv4: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
ipv6: /\b([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}\b/g,
email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
hexKey: /\b[0-9a-fA-F]{64}\b/g,
bech32: /\b(npub|nsec|note|nevent|nprofile|naddr)1[0-9a-z]{50,}\b/g,
// Simplify paths to remove user names or absolute paths
absPath: /(?:\/[a-zA-Z0-9_\-.]+)+\/bitvid\//g, // Try to find root of repo if possible
genericPath: /(\/[a-zA-Z0-9_\-.]+){3,}/g, // Catch long paths
};
function sanitize(text) {
if (!text) return text;
let sanitized = text;
sanitized = sanitized.replace(PATTERNS.ipv4, '<IPV4>');
// sanitized = sanitized.replace(PATTERNS.ipv6, '<IPV6>'); // IPv6 regex is tricky, leaving it out for now to avoid false positives on simple text
sanitized = sanitized.replace(PATTERNS.email, '<EMAIL>');
sanitized = sanitized.replace(PATTERNS.hexKey, '<HEX_KEY>');
sanitized = sanitized.replace(PATTERNS.bech32, '<BECH32>');
// Custom path sanitization
// Assuming the repo is mounted at /app in the sandbox, or we want to normalize it
sanitized = sanitized.replace(/\/app\//g, '<REPO>/');
// Try to mask home directories
sanitized = sanitized.replace(/\/home\/[a-zA-Z0-9_\-.]+\//g, '<HOME>/');
// Use defined patterns for other paths (e.g. if the repo is checked out elsewhere)
sanitized = sanitized.replace(PATTERNS.absPath, '<REPO>/');
// Generic path sanitization (careful to avoid breaking URLs too much, though strict PII policy prefers over-sanitization)
// We only apply this if it looks like a long absolute path (starts with /)
sanitized = sanitized.replace(PATTERNS.genericPath, (match) => {
// Avoid sanitizing if it's already sanitized
if (match.includes('<REPO>') || match.includes('<HOME>')) return match;
return '/.../';
});
return sanitized;
}
function parseTestLogs(content) {
const errors = [];
const lines = content.split('\n');
let currentError = null;
let captureStack = false;
for (const line of lines) {
// Detect TAP failure
if (line.match(/^not ok \d+/)) {
if (currentError) {
errors.push(currentError);
}
currentError = {
message: line.trim(),
stack: [],
source: 'test_output'
};
captureStack = true;
continue;
}
// Detect general Error
if (line.includes('Error:') || line.includes('Exception:')) {
// If we are already capturing a stack for a TAP failure, this is likely the detail
if (currentError && captureStack) {
currentError.message += ' | ' + line.trim();
} else {
if (currentError) {
errors.push(currentError);
}
currentError = {
message: line.trim(),
stack: [line.trim()],
source: 'unknown'
};
captureStack = true;
}
continue;
}
if (captureStack) {
// Stack trace lines usually start with " at " or are indented
if (line.match(/^\s+at /) || (line.match(/^\s+/) && currentError)) {
currentError.stack.push(line.trim());
} else if (line.match(/^TAP version/) || line.match(/^→ Running/) || line.trim() === '') {
// End of error block
if (currentError) {
errors.push(currentError);
currentError = null;
captureStack = false;
}
} else {
// Continue capturing if it looks like part of the error report (e.g. TAP diagnostics)
if (line.match(/^\s+/)) {
currentError.stack.push(line.trim());
} else {
if (currentError) {
errors.push(currentError);
currentError = null;
captureStack = false;
}
}
}
}
}
if (currentError) {
errors.push(currentError);
}
return errors;
}
function processFiles() {
const allErrors = [];
for (const file of LOG_FILES) {
const filePath = path.join(ROOT_DIR, file);
if (fs.existsSync(filePath)) {
console.log(`Processing ${file}...`);
const content = fs.readFileSync(filePath, 'utf8');
// We can add more specific parsers here
if (file === 'test_output.log') {
allErrors.push(...parseTestLogs(content));
} else {
// Generic parsing for other logs: just look for Error: blocks
const lines = content.split('\n');
let currentError = null;
for (const line of lines) {
if (line.includes('Error:')) {
if (currentError) allErrors.push(currentError);
currentError = {
message: line.trim(),
stack: [line.trim()],
source: file
};
} else if (currentError) {
if (line.match(/^\s+at /) || line.match(/^\s+/)) {
currentError.stack.push(line.trim());
} else {
allErrors.push(currentError);
currentError = null;
}
}
}
if (currentError) allErrors.push(currentError);
}
} else {
console.log(`Skipping ${file} (not found)`);
}
}
return allErrors;
}
function aggregateErrors(errors) {
const groups = {};
for (const error of errors) {
// Sanitize before grouping
const sanitizedMsg = sanitize(error.message);
const sanitizedStack = error.stack.map(sanitize).join('\n');
// Create a fingerprint. We use the stack trace if available, otherwise the message.
// Truncating stack to first few lines can help group similar errors with slightly different line numbers if code shifts,
// but for now let's use the whole stack but maybe ignore line numbers?
// Actually, exact stack match (sanitized) is a good start.
// To make it more robust, let's remove line numbers from the fingerprint?
// e.g. (file.js:123:45) -> (file.js)
// For now, let's stick to sanitized stack as fingerprint.
const fingerprint = sanitizedStack || sanitizedMsg;
if (!groups[fingerprint]) {
groups[fingerprint] = {
message: sanitizedMsg,
stack: sanitizedStack,
count: 0,
sources: new Set(),
originalSample: error
};
}
groups[fingerprint].count++;
groups[fingerprint].sources.add(error.source);
}
return Object.values(groups).sort((a, b) => b.count - a.count);
}
function generateReport(aggregatedErrors) {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const reportFile = path.join(REPORTS_DIR, `telemetry-${date}.md`);
const jsonFile = path.join(ARTIFACTS_DIR, 'error-aggregates.json');
// JSON Output
if (!fs.existsSync(ARTIFACTS_DIR)) {
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
}
// Convert sets to arrays for JSON
const jsonOutput = aggregatedErrors.map(e => ({
...e,
sources: Array.from(e.sources)
}));
fs.writeFileSync(jsonFile, JSON.stringify(jsonOutput, null, 2));
console.log(`Wrote JSON artifact to ${jsonFile}`);
// Markdown Output
let md = `# Telemetry Report - ${new Date().toISOString().split('T')[0]}\n\n`;
md += `**Total Unique Errors:** ${aggregatedErrors.length}\n\n`;
md += `| Count | Message | Sources | Suggested Owner |\n`;
md += `|-------|---------|---------|-----------------|\n`;
const top10 = aggregatedErrors.slice(0, 10);
for (const error of top10) {
// Truncate message for table
let shortMsg = error.message.replace(/\|/g, '\\|').substring(0, 80);
if (error.message.length > 80) shortMsg += '...';
const sources = Array.from(error.sources).join(', ');
// Guess owner based on stack
let owner = 'TBD';
if (error.stack.includes('nostr')) owner = 'Nostr Team';
if (error.stack.includes('torrent') || error.stack.includes('webtorrent')) owner = 'P2P Team';
if (error.stack.includes('ui/') || error.stack.includes('components/')) owner = 'Frontend Team';
if (error.stack.includes('test')) owner = 'QA';
md += `| ${error.count} | \`${shortMsg}\` | ${sources} | ${owner} |\n`;
}
md += `\n## Details (Top 10)\n\n`;
for (const [index, error] of top10.entries()) {
md += `### ${index + 1}. ${error.message}\n`;
md += `- **Count:** ${error.count}\n`;
md += `- **Sources:** ${Array.from(error.sources).join(', ')}\n`;
md += `- **Stack Trace:**\n`;
md += `\`\`\`\n${error.stack}\n\`\`\`\n\n`;
}
if (!fs.existsSync(REPORTS_DIR)) {
fs.mkdirSync(REPORTS_DIR, { recursive: true });
}
fs.writeFileSync(reportFile, md);
console.log(`Wrote Markdown report to ${reportFile}`);
}
// Main execution
try {
const errors = processFiles();
console.log(`Found ${errors.length} raw errors.`);
const aggregated = aggregateErrors(errors);
console.log(`Aggregated into ${aggregated.length} unique issues.`);
generateReport(aggregated);
} catch (err) {
console.error('Fatal error in telemetry script:', err);
process.exit(1);
}
+295
View File
@@ -0,0 +1,295 @@
import {
getNostrEventSchema,
buildVideoPostEvent,
buildVideoMirrorEvent,
buildRepostEvent,
buildRelayListEvent,
buildViewEvent,
buildReactionEvent,
buildCommentEvent,
buildWatchHistoryEvent,
buildSubscriptionListEvent,
buildBlockListEvent,
buildHashtagPreferenceEvent,
buildAdminListEvent,
buildProfileMetadataEvent,
buildMuteListEvent,
NOTE_TYPES,
ADMIN_LIST_IDENTIFIERS
} from "../../js/nostrEventSchemas.js";
const TEST_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000001";
const TEST_TIMESTAMP = 1700000000;
function validateEvent(event, schema, label) {
const errors = [];
// Check Kind
if (schema.kind !== undefined && event.kind !== schema.kind) {
errors.push(`Kind mismatch: expected ${schema.kind}, got ${event.kind}`);
}
// Check Content Format
if (schema.content) {
if (schema.content.format === "json") {
try {
const parsed = JSON.parse(event.content);
if (schema.content.fields) {
for (const field of schema.content.fields) {
if (field.required && parsed[field.key] === undefined) {
errors.push(`Missing required content field: ${field.key}`);
}
if (parsed[field.key] !== undefined) {
// Basic type checking
if (field.type === 'number' && typeof parsed[field.key] !== 'number') {
errors.push(`Field ${field.key} expected number, got ${typeof parsed[field.key]}`);
}
if (field.type === 'string' && typeof parsed[field.key] !== 'string') {
errors.push(`Field ${field.key} expected string, got ${typeof parsed[field.key]}`);
}
if (field.type === 'boolean' && typeof parsed[field.key] !== 'boolean') {
errors.push(`Field ${field.key} expected boolean, got ${typeof parsed[field.key]}`);
}
}
}
}
} catch (e) {
errors.push(`Content is not valid JSON: ${e.message}`);
}
} else if (schema.content.format === "empty") {
if (event.content !== "") {
errors.push(`Content expected to be empty, got length ${event.content.length}`);
}
}
}
// Check Tags
if (schema.topicTag) {
const hasTag = event.tags.some(t => t[0] === schema.topicTag.name && t[1] === schema.topicTag.value);
if (!hasTag) {
errors.push(`Missing topic tag: ${schema.topicTag.name}=${schema.topicTag.value}`);
}
}
if (schema.identifierTag) {
const hasTag = event.tags.some(t => t[0] === schema.identifierTag.name);
if (!hasTag) {
errors.push(`Missing identifier tag: ${schema.identifierTag.name}`);
}
if (schema.identifierTag.value) {
const hasValue = event.tags.some(t => t[0] === schema.identifierTag.name && t[1] === schema.identifierTag.value);
if (!hasValue) {
errors.push(`Missing identifier tag value: ${schema.identifierTag.name}=${schema.identifierTag.value}`);
}
}
}
// Append Tags Check (Simple existence check for fixed tags)
if (schema.appendTags) {
schema.appendTags.forEach(expectedTag => {
if (Array.isArray(expectedTag)) {
const found = event.tags.some(t =>
t.length >= expectedTag.length &&
expectedTag.every((val, i) => t[i] === val)
);
if (!found) {
errors.push(`Missing appended tag: ${JSON.stringify(expectedTag)}`);
}
}
});
}
if (errors.length > 0) {
console.error(`❌ ${label} validation failed:`);
errors.forEach(e => console.error(` - ${e}`));
return false;
} else {
console.log(`✅ ${label} validated successfully.`);
return true;
}
}
async function runValidation() {
console.log("Starting Event Schema Validation...\n");
let allPassed = true;
// 1. Video Post
{
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST);
const event = buildVideoPostEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
dTagValue: "test-video",
content: {
version: 1,
title: "Test Video",
videoRootId: "root-id"
}
});
if (!validateEvent(event, schema, "Video Post")) allPassed = false;
}
// 2. Video Mirror
{
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_MIRROR);
const event = buildVideoMirrorEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
content: "mirror description"
});
if (!validateEvent(event, schema, "Video Mirror")) allPassed = false;
}
// 3. Repost
{
const schema = getNostrEventSchema(NOTE_TYPES.REPOST);
const event = buildRepostEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
eventId: "event-id",
eventRelay: "wss://relay.example.com",
serializedEvent: JSON.stringify({ id: "event-id", kind: 1, tags: [], content: "test" })
});
// Schema updated to expect JSON content, so this should pass now.
if (!validateEvent(event, schema, "Repost")) allPassed = false;
}
// 4. Relay List
{
const schema = getNostrEventSchema(NOTE_TYPES.RELAY_LIST);
const event = buildRelayListEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
relays: ["wss://relay.one", { url: "wss://relay.two", mode: "read" }]
});
if (!validateEvent(event, schema, "Relay List")) allPassed = false;
}
// 5. View Event
{
const schema = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
const event = buildViewEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
dedupeTag: "dedupe-val",
includeSessionTag: true,
content: "view log"
});
if (!validateEvent(event, schema, "View Event")) allPassed = false;
}
// 6. Reaction Event
{
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_REACTION);
const event = buildReactionEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
targetPointer: { type: "e", value: "target-id", relay: "wss://r.com" },
content: "+"
});
if (!validateEvent(event, schema, "Reaction Event")) allPassed = false;
}
// 7. Comment Event
{
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
const event = buildCommentEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
videoEventId: "vid-id",
content: "Nice video!"
});
if (!validateEvent(event, schema, "Comment Event")) allPassed = false;
}
// 8. Watch History
{
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY);
const event = buildWatchHistoryEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
monthIdentifier: "2023-10",
content: JSON.stringify(["watched-id-1"])
});
if (!validateEvent(event, schema, "Watch History")) allPassed = false;
}
// 9. Subscription List
{
const schema = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST);
const event = buildSubscriptionListEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
content: "encrypted-stuff"
});
if (!validateEvent(event, schema, "Subscription List")) allPassed = false;
}
// 10. Block List
{
const schema = getNostrEventSchema(NOTE_TYPES.USER_BLOCK_LIST);
const event = buildBlockListEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
content: "encrypted-stuff"
});
if (!validateEvent(event, schema, "Block List")) allPassed = false;
}
// 11. Hashtag Preference
{
const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES);
const event = buildHashtagPreferenceEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
content: JSON.stringify({ version: 1, interests: [], disinterests: [] })
});
if (!validateEvent(event, schema, "Hashtag Preferences")) allPassed = false;
}
// 12. Admin List (Moderation)
{
const schema = getNostrEventSchema(NOTE_TYPES.ADMIN_MODERATION_LIST);
const event = buildAdminListEvent("moderation", {
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
hexPubkeys: ["0000000000000000000000000000000000000000000000000000000000000002"]
});
if (!validateEvent(event, schema, "Admin Moderation List")) allPassed = false;
}
// 13. Profile Metadata
{
const schema = getNostrEventSchema(NOTE_TYPES.PROFILE_METADATA);
const event = buildProfileMetadataEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
metadata: { name: "Test User", about: "Testing" }
});
if (!validateEvent(event, schema, "Profile Metadata")) allPassed = false;
}
// 14. Mute List
{
const schema = getNostrEventSchema(NOTE_TYPES.MUTE_LIST);
const event = buildMuteListEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
pTags: ["0000000000000000000000000000000000000000000000000000000000000002"]
});
if (!validateEvent(event, schema, "Mute List")) allPassed = false;
}
if (!allPassed) {
console.error("\n❌ Some validations failed.");
process.exit(1);
} else {
console.log("\n✅ All validations passed.");
}
}
runValidation().catch(e => {
console.error(e);
process.exit(1);
});
+5
View File
@@ -221,6 +221,11 @@ function shouldSkipMatch(content, index) {
if (previousChar === "[") {
return true;
}
// Check if it's part of a variable name (e.g. --size-2px)
// We check if the preceding character is '-'
if (previousChar === "-") {
return true;
}
return false;
}
+57
View File
@@ -0,0 +1,57 @@
import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
const rootDir = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
const setupImport = new URL("../tests/test-helpers/setup-localstorage.mjs", import.meta.url);
const requestedFiles = process.argv.slice(2);
if (!requestedFiles.length) {
console.error("No test files provided. Usage: node scripts/run-targeted-tests.mjs <file...>");
process.exit(1);
}
const testFiles = requestedFiles.map((file) =>
path.isAbsolute(file) ? file : path.join(rootDir, file),
);
for (const testFile of testFiles) {
try {
await access(testFile);
} catch (error) {
console.error(`Test file not found: ${testFile}`);
throw error;
}
}
for (const testFile of testFiles) {
const relativePath = path.relative(rootDir, testFile);
console.log(`\n→ Running ${relativePath}`);
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ["--import", setupImport.href, testFile], {
stdio: "inherit",
});
child.on("close", (code, signal) => {
if (signal) {
reject(new Error(`${relativePath} exited with signal ${signal}`));
return;
}
if (code !== 0) {
reject(new Error(`${relativePath} failed with exit code ${code}`));
return;
}
resolve();
});
child.on("error", (error) => {
reject(error);
});
});
}
console.log("\n✔ Targeted tests passed");
+1 -1
View File
@@ -2,7 +2,7 @@ const VAR_PATTERN = /^var\(--.*\)$/;
const CONTAINS_VAR_PATTERN = /var\(--.*\)/;
const LINEAR_GRADIENT_PATTERN = /^linear-gradient\(/;
const COLOR_MIX_PATTERN = /^color-mix\([\s\S]+\)$/;
const NON_TOKEN_LENGTH_PATTERN = /(?!.*var\().*\b\d*\.?\d+(?:px|rem)\b/;
const NON_TOKEN_LENGTH_PATTERN = /^(?!.*var\().*\b\d*\.?\d+(?:px|rem)\b/;
module.exports = {
ignoreFiles: ['css/tailwind.generated.css'],
+88
View File
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { test, afterEach } from "node:test";
import {
getConversation,
listMessagesByConversation,
updateConversationFromMessage,
writeMessages,
} from "../js/storage/dmDb.js";
const { indexedDB, IDBKeyRange } = await import("fake-indexeddb");
if (!globalThis.indexedDB) {
globalThis.indexedDB = indexedDB;
globalThis.IDBKeyRange = IDBKeyRange;
}
async function deleteDatabase(name) {
await new Promise((resolve) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
request.onblocked = () => resolve();
});
}
afterEach(async () => {
await deleteDatabase("bitvidDm");
});
test("writeMessages normalizes records before storing", async () => {
const conversationId = "dm:abcd:efgh";
const result = await writeMessages({
id: "message-1",
conversationId: ` ${conversationId} `,
senderPubkey: "SENDERPUBKEY",
receiverPubkey: "RECEIVERPUBKEY",
createdAt: 1_700_000_500,
kind: 4,
content: "Hello",
tags: [["p", "RECEIVERPUBKEY"]],
status: "published",
seen: true,
encryptionScheme: "nip04",
});
assert.equal(result.length, 1);
const stored = await listMessagesByConversation(conversationId);
assert.equal(stored.length, 1);
assert.equal(stored[0].conversation_id, conversationId);
assert.equal(stored[0].sender_pubkey, "senderpubkey");
assert.equal(stored[0].receiver_pubkey, "receiverpubkey");
assert.equal(stored[0].status, "published");
assert.equal(stored[0].seen, true);
assert.equal(stored[0].encryption_scheme, "nip04");
});
test("updateConversationFromMessage persists metadata updates", async () => {
const conversationId = "dm:meta:updates";
const message = {
id: "message-2",
conversation_id: conversationId,
sender_pubkey: "sender",
receiver_pubkey: "receiver",
created_at: 1_700_000_510,
kind: 4,
content: "Preview payload",
seen: false,
};
const updated = await updateConversationFromMessage(message, {
preview: "Preview payload",
unseenDelta: 1,
downloadedUntil: 1_700_000_510,
});
assert.ok(updated);
assert.equal(updated.conversation_id, conversationId);
assert.equal(updated.last_message_preview, "Preview payload");
assert.equal(updated.unseen_count, 1);
const stored = await getConversation(conversationId);
assert.ok(stored);
assert.equal(stored.conversation_id, conversationId);
assert.equal(stored.last_message_preview, "Preview payload");
assert.equal(stored.unseen_count, 1);
});
+112
View File
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { test, afterEach } from "node:test";
import { NostrService } from "../js/services/nostrService.js";
const { indexedDB, IDBKeyRange } = await import("fake-indexeddb");
if (!globalThis.indexedDB) {
globalThis.indexedDB = indexedDB;
globalThis.IDBKeyRange = IDBKeyRange;
}
async function deleteDatabase(name) {
await new Promise((resolve) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
request.onblocked = () => resolve();
});
}
afterEach(async () => {
await deleteDatabase("bitvidDm");
await deleteDatabase("bitvidSettings");
});
test("direct message normalization hashes conversations consistently", async () => {
const service = new NostrService({ logger: () => {} });
const sender = "b".repeat(64);
const receiver = "a".repeat(64);
const incoming = {
ok: true,
direction: "incoming",
plaintext: "Hello there",
sender: { pubkey: sender },
recipients: [{ pubkey: receiver }],
event: {
id: "event-incoming",
created_at: 1_700_000_001,
kind: 4,
tags: [["p", receiver]],
pubkey: sender,
},
};
const outgoing = {
ok: true,
direction: "outgoing",
plaintext: "Hello there",
sender: { pubkey: sender },
recipients: [{ pubkey: receiver }],
event: {
id: "event-outgoing",
created_at: 1_700_000_002,
kind: 4,
tags: [["p", receiver]],
pubkey: sender,
},
};
const incomingRecord = await service.persistDirectMessageRecord(incoming, {
actorPubkey: receiver,
});
const outgoingRecord = await service.persistDirectMessageRecord(outgoing, {
actorPubkey: sender,
});
const expectedId = `dm:${[sender, receiver].sort().join(":")}`;
assert.equal(incomingRecord.conversation_id, expectedId);
assert.equal(outgoingRecord.conversation_id, expectedId);
});
test("direct message list dedupes entries by event id", async () => {
const service = new NostrService({ logger: () => {} });
const sender = "c".repeat(64);
const receiver = "d".repeat(64);
const baseMessage = {
ok: true,
direction: "incoming",
actorPubkey: receiver,
plaintext: "First payload",
sender: { pubkey: sender },
recipients: [{ pubkey: receiver }],
event: {
id: "event-dedupe",
created_at: 1_700_000_010,
kind: 4,
tags: [["p", receiver]],
pubkey: sender,
},
};
service.applyDirectMessage(baseMessage, { reason: "initial" });
service.applyDirectMessage(
{
...baseMessage,
plaintext: "Updated payload",
preview: "Updated payload",
},
{ reason: "duplicate" },
);
await new Promise((resolve) => setTimeout(resolve, 10));
const messages = service.getDirectMessages();
assert.equal(messages.length, 1);
assert.equal(messages[0].plaintext, "Updated payload");
});
+2 -2
View File
@@ -257,10 +257,10 @@ await (async function testTombstonePersistenceAcrossSaveRestore() {
client.allEvents.set(legacyVideo.id, { ...legacyVideo });
client.recordTombstone(activeKey, legacyVideo.created_at + 50);
client.saveLocalData();
await client.saveLocalData("test", { immediate: true });
const restored = new NostrClient();
restored.restoreLocalData();
await restored.restoreLocalData();
assert.equal(
restored.tombstones.get(activeKey),
+378
View File
@@ -0,0 +1,378 @@
import assert from "node:assert/strict";
import { test, afterEach } from "node:test";
import { NostrClient, clearActiveSigner, setActiveSigner } from "../../js/nostr/client.js";
import { NostrService } from "../../js/services/nostrService.js";
import { listMessagesByConversation } from "../../js/storage/dmDb.js";
const { indexedDB, IDBKeyRange } = await import("fake-indexeddb");
if (!globalThis.indexedDB) {
globalThis.indexedDB = indexedDB;
globalThis.IDBKeyRange = IDBKeyRange;
}
async function deleteDatabase(name) {
await new Promise((resolve) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
request.onblocked = () => resolve();
});
}
afterEach(async () => {
await deleteDatabase("bitvidDm");
await deleteDatabase("bitvidSettings");
clearActiveSigner();
});
class FakeRelayPool {
constructor() {
this.subscriptions = new Set();
this.queue = [];
this.published = [];
}
publish(urls, event) {
this.queue.push(event);
this.published.push(event);
return {
on(eventName, handler) {
if (eventName === "ok") {
setTimeout(() => handler(), 0);
}
return true;
},
};
}
list() {
return Promise.resolve([]);
}
sub() {
const handlers = new Map();
const subscription = {
on(eventName, handler) {
handlers.set(eventName, handler);
},
emit(eventName, payload) {
const handler = handlers.get(eventName);
if (handler) {
handler(payload);
}
},
unsub: () => {
this.subscriptions.delete(subscription);
},
};
this.subscriptions.add(subscription);
return subscription;
}
flushQueue({ duplicate = false } = {}) {
const batch = [...this.queue];
this.queue = [];
batch.forEach((event) => {
this.deliver(event);
if (duplicate) {
this.deliver(event);
}
});
}
deliver(event) {
for (const subscription of this.subscriptions) {
subscription.emit("event", event);
}
}
}
async function configureNostrTools() {
const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
const previousNostrTools = globalThis.NostrTools;
const previousReady = globalThis.nostrToolsReady;
const nostrTools = await import("nostr-tools");
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = nostrTools;
globalThis.NostrTools = nostrTools;
globalThis.nostrToolsReady = Promise.resolve({ ok: true, value: nostrTools });
const restore = () => {
if (previousCanonical === undefined) {
delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
} else {
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical;
}
if (previousNostrTools === undefined) {
delete globalThis.NostrTools;
} else {
globalThis.NostrTools = previousNostrTools;
}
if (previousReady === undefined) {
delete globalThis.nostrToolsReady;
} else {
globalThis.nostrToolsReady = previousReady;
}
};
return { nostrTools, restore };
}
async function setupDmScenario() {
const { nostrTools, restore } = await configureNostrTools();
const senderSecret = nostrTools.generateSecretKey();
const receiverSecret = nostrTools.generateSecretKey();
const senderPrivateKey = nostrTools.utils.bytesToHex(senderSecret);
const receiverPrivateKey = nostrTools.utils.bytesToHex(receiverSecret);
const senderPubkey = nostrTools.getPublicKey(senderPrivateKey);
const receiverPubkey = nostrTools.getPublicKey(receiverPrivateKey);
const createSigner = (privateKey, pubkey) => ({
type: "test",
pubkey,
signEvent: (event) => {
const finalized = nostrTools.finalizeEvent(event, privateKey);
return { ...event, id: finalized.id, sig: finalized.sig };
},
nip04Encrypt: (target, plaintext) =>
nostrTools.nip04.encrypt(privateKey, target, plaintext),
nip04Decrypt: (target, ciphertext) =>
nostrTools.nip04.decrypt(privateKey, target, ciphertext),
});
const senderSigner = createSigner(senderPrivateKey, senderPubkey);
const receiverSigner = createSigner(receiverPrivateKey, receiverPubkey);
const relayPool = new FakeRelayPool();
const senderClient = new NostrClient();
senderClient.pool = relayPool;
senderClient.relays = ["wss://relay.unit.test"];
senderClient.readRelays = [];
senderClient.writeRelays = [];
senderClient.pubkey = senderPubkey;
const receiverClient = new NostrClient();
receiverClient.pool = relayPool;
receiverClient.relays = ["wss://relay.unit.test"];
receiverClient.readRelays = [];
receiverClient.writeRelays = [];
receiverClient.pubkey = receiverPubkey;
const receiverService = new NostrService({ logger: () => {} });
receiverService.nostrClient = receiverClient;
return {
nostrTools,
restore,
senderSigner,
receiverSigner,
senderPubkey,
receiverPubkey,
relayPool,
senderClient,
receiverClient,
receiverService,
};
}
test("DM relay duplication is deduped", async () => {
const {
nostrTools,
restore,
senderSigner,
receiverSigner,
receiverPubkey,
relayPool,
senderClient,
receiverClient,
receiverService,
} = await setupDmScenario();
try {
const notifications = [];
receiverService.on("directMessages:notification", (payload) => {
notifications.push(payload);
});
const received = [];
const subscription = receiverClient.subscribeDirectMessages(receiverPubkey, {
onMessage: (message, { event }) => {
received.push(message);
receiverService.applyDirectMessage(
{ ...message, actorPubkey: receiverPubkey },
{ reason: "subscription", event },
);
},
});
setActiveSigner(senderSigner);
const sendResult = await senderClient.sendDirectMessage(
nostrTools.nip19.npubEncode(receiverPubkey),
"Hello from sender",
);
assert.equal(sendResult.ok, true);
setActiveSigner(receiverSigner);
relayPool.flushQueue({ duplicate: true });
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(received.length, 1, "duplicate relay events should be deduped");
assert.equal(notifications.length, 1, "incoming messages should notify once");
subscription.unsub();
} finally {
restore();
}
});
test("out-of-order DM delivery keeps newest messages first", async () => {
const {
nostrTools,
restore,
senderSigner,
receiverSigner,
receiverPubkey,
relayPool,
senderClient,
receiverClient,
receiverService,
} = await setupDmScenario();
try {
setActiveSigner(receiverSigner);
receiverClient.subscribeDirectMessages(receiverPubkey, {
onMessage: (message, { event }) => {
receiverService.applyDirectMessage(
{ ...message, actorPubkey: receiverPubkey },
{ reason: "subscription", event },
);
},
});
setActiveSigner(senderSigner);
const firstSend = await senderClient.sendDirectMessage(
nostrTools.nip19.npubEncode(receiverPubkey),
"First message",
);
const secondSend = await senderClient.sendDirectMessage(
nostrTools.nip19.npubEncode(receiverPubkey),
"Second message",
);
assert.equal(firstSend.ok, true);
assert.equal(secondSend.ok, true);
relayPool.queue[0].created_at = 100;
relayPool.queue[1].created_at = 200;
setActiveSigner(receiverSigner);
relayPool.deliver(relayPool.queue[1]);
relayPool.deliver(relayPool.queue[0]);
relayPool.queue = [];
await new Promise((resolve) => setTimeout(resolve, 10));
const messages = receiverService.getDirectMessages();
assert.equal(messages.length, 2, "two DM messages should be present");
assert.equal(messages[0].plaintext, "Second message");
assert.equal(messages[1].plaintext, "First message");
} finally {
restore();
}
});
test("DM reconnect replays do not duplicate messages or reset seen state", async () => {
const {
nostrTools,
restore,
senderSigner,
receiverSigner,
senderPubkey,
receiverPubkey,
relayPool,
senderClient,
receiverClient,
receiverService,
} = await setupDmScenario();
try {
const notifications = [];
receiverService.on("directMessages:notification", (payload) => {
notifications.push(payload);
});
const received = [];
let subscription = null;
const subscribe = () => {
subscription = receiverClient.subscribeDirectMessages(receiverPubkey, {
onMessage: (message, { event }) => {
received.push(message);
receiverService.applyDirectMessage(
{ ...message, actorPubkey: receiverPubkey },
{ reason: "subscription", event },
);
},
});
};
setActiveSigner(receiverSigner);
subscribe();
setActiveSigner(senderSigner);
const sendResult = await senderClient.sendDirectMessage(
nostrTools.nip19.npubEncode(receiverPubkey),
"Hello from sender",
);
assert.equal(sendResult.ok, true);
setActiveSigner(receiverSigner);
relayPool.flushQueue();
await new Promise((resolve) => setTimeout(resolve, 10));
const conversationId = `dm:${[senderPubkey, receiverPubkey].sort().join(":")}`;
assert.equal(receiverService.getDirectMessageUnseenCount(conversationId), 1);
const stored = await listMessagesByConversation(conversationId);
assert.equal(stored.length, 1, "message should persist in the DM store");
await receiverService.acknowledgeRenderedDirectMessages(
conversationId,
received[0].timestamp || stored[0].created_at,
);
assert.equal(receiverService.getDirectMessageUnseenCount(conversationId), 0);
subscription.unsub();
subscribe();
relayPool.deliver(relayPool.published[0]);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(
receiverService.getDirectMessages().length,
1,
"reconnection deliveries should not duplicate messages",
);
assert.equal(
receiverService.getDirectMessageUnseenCount(conversationId),
0,
"replayed events should not reset seen state",
);
assert.equal(notifications.length, 1, "replays should not trigger notifications");
} finally {
restore();
}
});
+120
View File
@@ -0,0 +1,120 @@
import test from "node:test";
import assert from "node:assert";
import {
encryptSessionPrivateKey,
decryptSessionPrivateKey,
persistSessionActor,
readStoredSessionActorEntry,
clearStoredSessionActor,
SESSION_ACTOR_STORAGE_KEY,
__testExports
} from "../../js/nostr/sessionActor.js";
const { generateRandomBytes, isSubtleCryptoAvailable } = __testExports;
// Ensure localStorage is mocked
import "../../tests/test-helpers/setup-localstorage.mjs";
test("js/nostr/sessionActor.js", async (t) => {
if (!isSubtleCryptoAvailable()) {
t.skip("WebCrypto not available in this environment");
return;
}
t.beforeEach(async () => {
localStorage.clear();
await clearStoredSessionActor();
});
await t.test("Encryption and Decryption Roundtrip", async () => {
const privateKey = "nsec1testkey..."; // In reality this would be hex or bech32, but for encryption it is just a string payload
const passphrase = "secure-password";
const encrypted = await encryptSessionPrivateKey(privateKey, passphrase);
assert.ok(encrypted.ciphertext);
assert.ok(encrypted.salt);
assert.ok(encrypted.iv);
assert.strictEqual(encrypted.algorithm, "AES-GCM");
const payload = {
privateKeyEncrypted: encrypted.ciphertext,
encryption: encrypted,
};
const decrypted = await decryptSessionPrivateKey(payload, passphrase);
assert.strictEqual(decrypted, privateKey);
});
await t.test("Decryption fails with wrong passphrase", async () => {
const privateKey = "secret";
const passphrase = "correct";
const wrongPassphrase = "wrong";
const encrypted = await encryptSessionPrivateKey(privateKey, passphrase);
const payload = {
privateKeyEncrypted: encrypted.ciphertext,
encryption: encrypted,
};
await assert.rejects(
async () => {
await decryptSessionPrivateKey(payload, wrongPassphrase);
},
(err) => err.code === "decrypt-failed" || err.message.includes("decrypt-failed")
);
});
await t.test("Persistence and Retrieval", async () => {
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
const privateKey = "my-secret-key";
const passphrase = "my-password";
const encrypted = await encryptSessionPrivateKey(privateKey, passphrase);
const actor = {
pubkey,
privateKeyEncrypted: encrypted.ciphertext,
encryption: encrypted,
createdAt: Date.now(),
};
persistSessionActor(actor);
// Verify localStorage
const storedRaw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY);
assert.ok(storedRaw);
const stored = readStoredSessionActorEntry();
assert.ok(stored);
assert.strictEqual(stored.pubkey, pubkey);
assert.strictEqual(stored.privateKeyEncrypted, encrypted.ciphertext);
// Verify we can decrypt what we retrieved
const decrypted = await decryptSessionPrivateKey(stored, passphrase);
assert.strictEqual(decrypted, privateKey);
});
await t.test("Clear Stored Session Actor", async () => {
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
const actor = {
pubkey,
privateKeyEncrypted: "fake-encrypted-data",
encryption: {
salt: "fake-salt",
iv: "fake-iv",
iterations: 1000,
hash: "SHA-256",
algorithm: "AES-GCM",
version: 1
},
createdAt: Date.now(),
};
persistSessionActor(actor);
assert.ok(readStoredSessionActorEntry());
clearStoredSessionActor();
assert.strictEqual(readStoredSessionActorEntry(), null);
});
});
+106
View File
@@ -0,0 +1,106 @@
import test from "node:test";
import assert from "node:assert";
import r2Service from "../../js/services/r2Service.js";
// Ensure localStorage is mocked
import "../../tests/test-helpers/setup-localstorage.mjs";
test("js/services/r2Service.js", async (t) => {
t.beforeEach(async () => {
localStorage.clear();
await r2Service.clearSettings();
});
await t.test("Default Settings", () => {
const settings = r2Service.getSettings();
// The actual default settings might contain more fields than we checked.
// Let's verify the core fields match.
assert.strictEqual(settings.accountId, "");
assert.strictEqual(settings.accessKeyId, "");
assert.strictEqual(settings.secretAccessKey, "");
assert.strictEqual(settings.baseDomain, "");
assert.deepStrictEqual(settings.buckets, {});
});
await t.test("handleCloudflareSettingsSubmit - Valid Input", async () => {
const input = {
accountId: "acc123",
accessKeyId: "key123",
secretAccessKey: "secret123",
baseDomain: "https://pub-xxx.r2.dev",
};
const result = await r2Service.handleCloudflareSettingsSubmit(input);
assert.strictEqual(result, true);
const saved = r2Service.getSettings();
assert.strictEqual(saved.accountId, input.accountId);
assert.strictEqual(saved.accessKeyId, input.accessKeyId);
assert.strictEqual(saved.secretAccessKey, input.secretAccessKey);
assert.strictEqual(saved.baseDomain, input.baseDomain);
});
await t.test("handleCloudflareSettingsSubmit - Invalid S3 URL", async () => {
const input = {
accountId: "acc123",
accessKeyId: "key123",
secretAccessKey: "secret123",
baseDomain: "https://something.r2.cloudflarestorage.com",
};
let status = "";
const cleanup = r2Service.on("settingsStatus", (s) => (status = s.message));
const result = await r2Service.handleCloudflareSettingsSubmit(input);
assert.strictEqual(result, false);
assert.match(status, /entered the S3 API URL/);
cleanup();
});
await t.test("handleCloudflareSettingsSubmit - Missing Fields", async () => {
const input = {
accountId: "acc123",
// missing accessKeyId
secretAccessKey: "secret123",
baseDomain: "https://pub-xxx.r2.dev",
};
const result = await r2Service.handleCloudflareSettingsSubmit(input);
assert.strictEqual(result, false);
});
await t.test("ensureBucketConfigForNpub - Uses Explicit Credentials", async () => {
const npub = "npub1test";
const credentials = {
accountId: "acc123",
accessKeyId: "key123",
secretAccessKey: "secret123",
baseDomain: "https://pub-xxx.r2.dev",
bucket: "custom-bucket",
isLegacy: true,
};
const result = await r2Service.ensureBucketConfigForNpub(npub, { credentials });
assert.strictEqual(result.entry.bucket, "custom-bucket");
assert.strictEqual(result.entry.publicBaseUrl, "https://pub-xxx.r2.dev");
assert.strictEqual(result.customDomainStatus, "manual");
});
await t.test("resolveConnection - Returns Legacy Settings if no StorageService", async () => {
const input = {
accountId: "legacyAcc",
accessKeyId: "legacyKey",
secretAccessKey: "legacySecret",
baseDomain: "https://legacy.com",
};
await r2Service.handleCloudflareSettingsSubmit(input);
const npub = "npub1any";
const resolved = await r2Service.resolveConnection(npub);
assert.ok(resolved);
assert.strictEqual(resolved.accountId, "legacyAcc");
assert.strictEqual(resolved.isLegacy, true);
});
});
+105
View File
@@ -0,0 +1,105 @@
import test from "node:test";
import assert from "node:assert";
import {
loadSavedProfilesFromStorage,
persistSavedProfiles,
setActiveProfilePubkey,
getSavedProfiles,
getActiveProfilePubkey,
setSavedProfiles,
storeUrlHealth,
getCachedUrlHealth,
getModerationSettings,
setModerationSettings,
urlHealthConstants,
} from "../../js/state/cache.js";
// Ensure localStorage is mocked
import "../../tests/test-helpers/setup-localstorage.mjs";
test("js/state/cache.js", async (t) => {
t.beforeEach(() => {
localStorage.clear();
// Reset internal state if possible, or relying on overwrites
setSavedProfiles([]);
setActiveProfilePubkey(null, { persist: false });
});
await t.test("Saved Profiles Persistence", async () => {
const profiles = [
{ pubkey: "0000000000000000000000000000000000000000000000000000000000000001", name: "Alice" },
{ pubkey: "0000000000000000000000000000000000000000000000000000000000000002", name: "Bob" },
];
setSavedProfiles(profiles, { persist: true });
// Simulate reload
const { profiles: loadedProfiles } = loadSavedProfilesFromStorage();
assert.strictEqual(loadedProfiles.length, 2);
assert.strictEqual(loadedProfiles[0].name, "Alice");
assert.strictEqual(loadedProfiles[1].name, "Bob");
});
await t.test("Active Profile Pubkey", async () => {
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
// We need to set saved profiles first so the active one is considered valid in some logic paths,
// or just rely on setActiveProfilePubkey which should handle it.
// However, looking at cache.js `loadSavedProfilesFromStorage` implementation:
// It checks if the active candidate exists in `seenPubkeys` (from `savedProfiles`).
// If not, it might not set it or treat it as valid if we are loading from storage.
// Let's add the profile to saved profiles first to be safe for persistence reload check.
setSavedProfiles([{ pubkey, name: "Alice" }]);
setActiveProfilePubkey(pubkey);
assert.strictEqual(getActiveProfilePubkey(), pubkey);
// Verify persistence
const { activePubkey } = loadSavedProfilesFromStorage();
assert.strictEqual(activePubkey, pubkey);
});
await t.test("URL Health Caching", async () => {
const eventId = "event1";
const url = "https://example.com/video.mp4";
const result = { status: "ok", message: "All good" };
storeUrlHealth(eventId, url, result);
const cached = getCachedUrlHealth(eventId, url);
assert.ok(cached);
assert.strictEqual(cached.status, "ok");
assert.strictEqual(cached.url, url);
});
await t.test("URL Health Expiration", async (t) => {
const eventId = "event2";
const url = "https://example.com/expired.mp4";
const result = { status: "checking" };
// Store with very short TTL
storeUrlHealth(eventId, url, result, 1); // 1ms TTL
await new Promise((resolve) => setTimeout(resolve, 10));
const cached = getCachedUrlHealth(eventId, url);
assert.strictEqual(cached, null, "Should return null for expired cache");
});
await t.test("Moderation Settings", async () => {
const defaults = getModerationSettings();
assert.ok(defaults);
const newSettings = {
blurThreshold: 1,
autoplayBlockThreshold: 2,
};
setModerationSettings(newSettings);
const updated = getModerationSettings();
assert.strictEqual(updated.blurThreshold, 1);
assert.strictEqual(updated.autoplayBlockThreshold, 2);
});
});
@@ -43,3 +43,7 @@ if (typeof globalThis.localStorage === "undefined") {
if (typeof globalThis.window.localStorage === "undefined") {
globalThis.window.localStorage = globalThis.localStorage;
}
if (typeof globalThis.structuredClone === "undefined") {
globalThis.structuredClone = (val) => JSON.parse(JSON.stringify(val));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+30
View File
@@ -0,0 +1,30 @@
const { chromium } = require('playwright');
const fs = require('fs');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
// Navigate to the index page locally
await page.goto('file://' + process.cwd() + '/index.html');
// Set viewport to simulate mobile for some checks
await page.setViewportSize({ width: 375, height: 667 });
// Wait for Tailwind classes to be effective (loaded from CSS)
// We can't easily wait for CSS file load in file protocol without network idle,
// but file protocol is fast.
await page.waitForTimeout(1000);
// Take screenshot of mobile view (to verify search bar width fix)
await page.screenshot({ path: 'verification/mobile-view.png' });
// Navigate to profile modal by finding a trigger (this is harder without a full app running)
// But we can check if the profile modal component file has the right classes by reading it?
// No, visual verification of static HTML file is better.
// Let's just screenshot index.html in mobile view which had the w-[calc...] fix.
await browser.close();
})();
+31
View File
@@ -0,0 +1,31 @@
import { chromium } from 'playwright';
import fs from 'fs';
import path from 'path';
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
// Navigate to the index page locally
await page.goto('file://' + process.cwd() + '/index.html');
// Set viewport to simulate mobile for some checks
await page.setViewportSize({ width: 375, height: 667 });
// Wait for Tailwind classes to be effective
await page.waitForTimeout(1000);
// Take screenshot of mobile view (to verify search bar width fix)
// The search bar is hidden by default, we might need to reveal it or just check layout.
// The class was: fixed bottom-6 right-6 z-50 hidden w-[calc(100%-var(--sidebar-mobile-gutter))]
// We can try to make it visible.
await page.evaluate(() => {
const el = document.getElementById('mobileSearchContainer');
if (el) el.classList.remove('hidden');
});
await page.screenshot({ path: 'verification/mobile-view.png' });
await browser.close();
})();