mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge branch 'unstable' into ai/onboarding-20231027-15933194820812210320
This commit is contained in:
73 files changed
+11578
-1035
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -521,6 +521,12 @@
|
||||
>
|
||||
No description available.
|
||||
</p>
|
||||
<div
|
||||
id="videoDescriptionPreviews"
|
||||
class="mt-4 space-y-3"
|
||||
data-link-previews
|
||||
hidden
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
@@ -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)
|
||||
@@ -0,0 +1,70 @@
|
||||
# Direct message privacy model (NIP-17 / NIP-59 / NIP-44)
|
||||
|
||||
This guide summarizes how bitvid delivers private direct messages, how relay
|
||||
hints are chosen, and which user controls affect delivery. It is meant to be
|
||||
read alongside the protocol references in `docs/nips/17.md`, `docs/nips/59.md`,
|
||||
and `docs/nips/44.md`.
|
||||
|
||||
## Privacy layers: rumor → seal → gift wrap
|
||||
|
||||
When privacy mode is enabled, bitvid uses the NIP-17 pipeline:
|
||||
|
||||
1. **Rumor (kind 14 / 15)**: a plaintext DM or attachment payload is created.
|
||||
2. **Seal (kind 13)**: the rumor is encrypted with NIP-44 to produce a sealed
|
||||
event signed by the sender.
|
||||
3. **Gift wrap (kind 1059)**: the seal is encrypted again with NIP-44 using a
|
||||
freshly generated ephemeral keypair. The outer wrap is what relays receive.
|
||||
|
||||
This layered approach hides the sender/recipient metadata and message timing
|
||||
from public relays while still allowing the receiver to unwrap the message.
|
||||
bitvid randomizes timestamps for seals and wraps to reduce correlation, and
|
||||
publishes a sender copy of the wrap so the sender can sync their own history.
|
||||
|
||||
## Relay hint selection
|
||||
|
||||
Relay hints decide where gift-wrapped NIP-17 events are published. Selection
|
||||
uses the following priority order:
|
||||
|
||||
1. **Explicit hints** – If the caller provides `recipientRelayHints` or
|
||||
`senderRelayHints`, those are used immediately.
|
||||
2. **Recipient discovery** – Otherwise, the client queries the configured read
|
||||
relays for the recipient's `kind:10050` relay list and uses any `relay` tags
|
||||
it finds.
|
||||
3. **Fallback relays** – If no relay list is found, the client falls back to the
|
||||
configured write relays (or the default relay list), and flags the selection
|
||||
as a privacy fallback.
|
||||
|
||||
This logic is shared by the DM service helper `resolveDmRelaySelection()` and
|
||||
by `NostrClient.sendDirectMessage()` when `useNip17` is enabled.
|
||||
|
||||
### Legacy NIP-04 relay selection
|
||||
|
||||
When privacy mode is disabled, bitvid sends a traditional kind `4` DM:
|
||||
|
||||
- It attempts to load the recipient's NIP-65 relay list (`kind:10002`) from the
|
||||
configured read relays.
|
||||
- If no relay list is available, it falls back to the configured write relays
|
||||
(or the default relay list).
|
||||
|
||||
The final relay set is where the signed NIP-04 ciphertext is published.
|
||||
|
||||
## User controls that affect privacy
|
||||
|
||||
### Privacy toggle (NIP-17 vs NIP-04)
|
||||
|
||||
The DM composer exposes a privacy toggle that switches between NIP-04 and
|
||||
NIP-17 delivery. When the toggle is enabled, the UI explains that NIP-17
|
||||
gift-wraps the message so relays only see the wrapper and relay hints.
|
||||
|
||||
### Relay hint management
|
||||
|
||||
Users can curate their own DM relay hints list in the profile modal. The UI
|
||||
lets them add and remove WSS relay URLs and publish the resulting list as a
|
||||
`kind:10050` event. This list is what other clients query during the relay
|
||||
selection phase above.
|
||||
|
||||
### Read receipts & typing indicators
|
||||
|
||||
Additional DM privacy toggles (read receipts and typing indicators) live in the
|
||||
same profile modal and persist to local state. Turning them off reduces
|
||||
metadata leaks by preventing extra ephemeral events from being sent.
|
||||
@@ -1,91 +1,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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
export const ATTACHMENT_KIND = 15;
|
||||
|
||||
const HEX_64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
const TAG_NAMES = Object.freeze({
|
||||
hash: "x",
|
||||
url: "url",
|
||||
name: "name",
|
||||
type: "type",
|
||||
size: "size",
|
||||
key: "k",
|
||||
});
|
||||
|
||||
function normalizeTagValue(value) {
|
||||
if (typeof value !== "string") {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeHash(value) {
|
||||
const normalized = normalizeTagValue(value).toLowerCase();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return HEX_64_REGEX.test(normalized) ? normalized : "";
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric < 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
export function parseAttachmentTags(tags) {
|
||||
if (!Array.isArray(tags)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fields = {
|
||||
x: "",
|
||||
url: "",
|
||||
name: "",
|
||||
type: "",
|
||||
size: null,
|
||||
key: "",
|
||||
};
|
||||
|
||||
tags.forEach((tag) => {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = normalizeTagValue(tag[0]).toLowerCase();
|
||||
const value = normalizeTagValue(tag[1]);
|
||||
|
||||
switch (name) {
|
||||
case TAG_NAMES.hash:
|
||||
fields.x = normalizeHash(value);
|
||||
break;
|
||||
case TAG_NAMES.url:
|
||||
fields.url = value;
|
||||
break;
|
||||
case TAG_NAMES.name:
|
||||
fields.name = value;
|
||||
break;
|
||||
case TAG_NAMES.type:
|
||||
fields.type = value;
|
||||
break;
|
||||
case TAG_NAMES.size:
|
||||
fields.size = normalizeNumber(value);
|
||||
break;
|
||||
case TAG_NAMES.key:
|
||||
fields.key = value;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if (!fields.x && !fields.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: fields.x,
|
||||
url: fields.url,
|
||||
name: fields.name,
|
||||
type: fields.type,
|
||||
size: fields.size,
|
||||
key: fields.key,
|
||||
encrypted: Boolean(fields.key),
|
||||
};
|
||||
}
|
||||
|
||||
export function extractAttachmentsFromMessage(message) {
|
||||
if (!message || typeof message !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const event =
|
||||
(message.message && typeof message.message === "object"
|
||||
? message.message
|
||||
: null) ||
|
||||
(message.event && typeof message.event === "object" ? message.event : null);
|
||||
|
||||
if (!event || Number(event.kind) !== ATTACHMENT_KIND) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attachment = parseAttachmentTags(event.tags);
|
||||
if (!attachment) {
|
||||
devLogger.warn("[attachments] Attachment event missing tags.");
|
||||
return [];
|
||||
}
|
||||
|
||||
return [attachment];
|
||||
}
|
||||
|
||||
export function formatAttachmentSize(bytes) {
|
||||
if (!Number.isFinite(bytes)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = Math.max(bytes, 0);
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
const rounded = size >= 10 || unitIndex === 0 ? Math.round(size) : size.toFixed(1);
|
||||
return `${rounded} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function describeAttachment(attachment) {
|
||||
if (!attachment || typeof attachment !== "object") {
|
||||
return "Attachment";
|
||||
}
|
||||
|
||||
const name = typeof attachment.name === "string" ? attachment.name.trim() : "";
|
||||
if (name) {
|
||||
return `Attachment: ${name}`;
|
||||
}
|
||||
|
||||
if (attachment.type) {
|
||||
return `Attachment (${attachment.type})`;
|
||||
}
|
||||
|
||||
return "Attachment";
|
||||
}
|
||||
|
||||
export { TAG_NAMES as ATTACHMENT_TAG_NAMES };
|
||||
@@ -0,0 +1,177 @@
|
||||
import { bytesToHex } from "../../vendor/crypto-helpers.bundle.min.js";
|
||||
import { signEventWithPrivateKey } from "../nostr/publishHelpers.js";
|
||||
import { ensureNostrTools, getCachedNostrTools } from "../nostr/toolkit.js";
|
||||
import { normalizeActorKey } from "../nostr/watchHistory.js";
|
||||
|
||||
const DEFAULT_TEST_PUBKEY = "f".repeat(64);
|
||||
const DEFAULT_DEV_DISPLAY_NAME = "Ephemeral dev signer";
|
||||
const DEFAULT_TEST_DISPLAY_NAME = "Test signer";
|
||||
|
||||
function normalizePubkey(value) {
|
||||
const normalized = typeof value === "string" ? normalizeActorKey(value) : "";
|
||||
return normalized || "";
|
||||
}
|
||||
|
||||
function getSigningTimestamp() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} SigningAdapter
|
||||
* @property {() => Promise<string>} getPubkey
|
||||
* @property {() => Promise<string>} getDisplayName
|
||||
* @property {(unsignedEvent: object) => Promise<object>} signEvent
|
||||
* @property {(message: string) => Promise<string>} signMessage
|
||||
*/
|
||||
|
||||
export function createNip07SigningAdapter({ extension } = {}) {
|
||||
const resolvedExtension =
|
||||
extension ||
|
||||
(typeof window !== "undefined" && window?.nostr ? window.nostr : null);
|
||||
|
||||
const getPubkey = async () => {
|
||||
if (!resolvedExtension || typeof resolvedExtension.getPublicKey !== "function") {
|
||||
throw new Error("NIP-07 extension is unavailable.");
|
||||
}
|
||||
return normalizePubkey(await resolvedExtension.getPublicKey());
|
||||
};
|
||||
|
||||
const getDisplayName = async () => {
|
||||
if (!resolvedExtension || typeof resolvedExtension.getMetadata !== "function") {
|
||||
return "";
|
||||
}
|
||||
const metadata = await resolvedExtension.getMetadata();
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return "";
|
||||
}
|
||||
const displayName =
|
||||
typeof metadata.display_name === "string" ? metadata.display_name.trim() : "";
|
||||
if (displayName) {
|
||||
return displayName;
|
||||
}
|
||||
return typeof metadata.name === "string" ? metadata.name.trim() : "";
|
||||
};
|
||||
|
||||
const signEvent = async (event) => {
|
||||
if (!resolvedExtension || typeof resolvedExtension.signEvent !== "function") {
|
||||
throw new Error("NIP-07 extension is missing signEvent.");
|
||||
}
|
||||
return resolvedExtension.signEvent(event);
|
||||
};
|
||||
|
||||
const signMessage = async (message) => {
|
||||
if (!resolvedExtension || typeof resolvedExtension.signMessage !== "function") {
|
||||
throw new Error("NIP-07 extension does not support signMessage.");
|
||||
}
|
||||
return resolvedExtension.signMessage(message);
|
||||
};
|
||||
|
||||
return {
|
||||
type: "nip07",
|
||||
getPubkey,
|
||||
getDisplayName,
|
||||
signEvent,
|
||||
signMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEphemeralDevSigningAdapter({
|
||||
displayName = DEFAULT_DEV_DISPLAY_NAME,
|
||||
privateKey,
|
||||
} = {}) {
|
||||
let cachedPrivateKey = typeof privateKey === "string" ? privateKey.trim() : "";
|
||||
let cachedPubkey = "";
|
||||
|
||||
const ensurePrivateKey = () => {
|
||||
if (cachedPrivateKey) {
|
||||
return cachedPrivateKey;
|
||||
}
|
||||
const random = new Uint8Array(32);
|
||||
if (typeof window !== "undefined" && window.crypto?.getRandomValues) {
|
||||
window.crypto.getRandomValues(random);
|
||||
} else if (typeof globalThis?.crypto?.getRandomValues === "function") {
|
||||
globalThis.crypto.getRandomValues(random);
|
||||
} else {
|
||||
for (let i = 0; i < random.length; i += 1) {
|
||||
random[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
cachedPrivateKey = bytesToHex(random);
|
||||
return cachedPrivateKey;
|
||||
};
|
||||
|
||||
const ensurePubkey = async () => {
|
||||
if (cachedPubkey) {
|
||||
return cachedPubkey;
|
||||
}
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
if (!tools || typeof tools.getPublicKey !== "function") {
|
||||
throw new Error("Nostr tools are required to derive the dev pubkey.");
|
||||
}
|
||||
cachedPubkey = normalizePubkey(tools.getPublicKey(ensurePrivateKey()));
|
||||
if (!cachedPubkey) {
|
||||
throw new Error("Failed to derive dev pubkey.");
|
||||
}
|
||||
return cachedPubkey;
|
||||
};
|
||||
|
||||
const signEvent = async (event) =>
|
||||
signEventWithPrivateKey({ ...event, pubkey: await ensurePubkey() }, ensurePrivateKey());
|
||||
|
||||
const signMessage = async (message) => {
|
||||
const signed = signEventWithPrivateKey(
|
||||
{
|
||||
kind: 27235,
|
||||
created_at: getSigningTimestamp(),
|
||||
tags: [["u", "bitvid:signing-adapter"]],
|
||||
content: typeof message === "string" ? message : String(message ?? ""),
|
||||
pubkey: await ensurePubkey(),
|
||||
},
|
||||
ensurePrivateKey(),
|
||||
);
|
||||
if (!signed || typeof signed.sig !== "string") {
|
||||
throw new Error("Failed to sign message.");
|
||||
}
|
||||
return signed.sig;
|
||||
};
|
||||
|
||||
return {
|
||||
type: "dev",
|
||||
getPubkey: ensurePubkey,
|
||||
getDisplayName: async () => displayName,
|
||||
signEvent,
|
||||
signMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestSigningAdapter({
|
||||
pubkey = DEFAULT_TEST_PUBKEY,
|
||||
displayName = DEFAULT_TEST_DISPLAY_NAME,
|
||||
signEvent,
|
||||
signMessage,
|
||||
} = {}) {
|
||||
const normalizedPubkey = normalizePubkey(pubkey) || DEFAULT_TEST_PUBKEY;
|
||||
|
||||
const signEventImpl =
|
||||
typeof signEvent === "function"
|
||||
? signEvent
|
||||
: async (event) => ({
|
||||
...event,
|
||||
pubkey: normalizedPubkey || event.pubkey,
|
||||
id: "test-event-id",
|
||||
sig: "test-event-sig",
|
||||
});
|
||||
|
||||
const signMessageImpl =
|
||||
typeof signMessage === "function"
|
||||
? signMessage
|
||||
: async (message) => `signed:${message}`;
|
||||
|
||||
return {
|
||||
type: "test",
|
||||
getPubkey: async () => normalizedPubkey,
|
||||
getDisplayName: async () => displayName,
|
||||
signEvent: signEventImpl,
|
||||
signMessage: signMessageImpl,
|
||||
};
|
||||
}
|
||||
@@ -3357,6 +3357,14 @@ function setupChannelMessageControls() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof app?.openDirectMessageComposer === "function") {
|
||||
app.openDirectMessageComposer({
|
||||
recipientPubkey: resolvedHex,
|
||||
source: "channel-profile",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = app?.profileController;
|
||||
if (!controller) {
|
||||
app?.showError?.("Messages are unavailable right now.");
|
||||
|
||||
+61
-19
@@ -23,6 +23,43 @@ function cloneEvent(event) {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function normalizeCreatedAt(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
function normalizeInnerMessage(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = cloneEvent(event) || {};
|
||||
normalized.content = typeof event.content === "string" ? event.content : "";
|
||||
|
||||
if (Number.isFinite(event.kind)) {
|
||||
normalized.kind = event.kind;
|
||||
}
|
||||
|
||||
const createdAt = normalizeCreatedAt(event.created_at);
|
||||
if (createdAt !== null) {
|
||||
normalized.created_at = createdAt;
|
||||
} else if ("created_at" in normalized) {
|
||||
delete normalized.created_at;
|
||||
}
|
||||
|
||||
if (Array.isArray(event.tags)) {
|
||||
normalized.tags = event.tags.map((tag) =>
|
||||
Array.isArray(tag) ? [...tag] : tag,
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeHex(candidate) {
|
||||
if (typeof candidate !== "string") {
|
||||
return "";
|
||||
@@ -268,16 +305,13 @@ function buildDecryptResult({
|
||||
envelope = null,
|
||||
errors = [],
|
||||
}) {
|
||||
const timestampCandidates = [];
|
||||
if (message && Number.isFinite(message.created_at)) {
|
||||
timestampCandidates.push(message.created_at);
|
||||
}
|
||||
if (event && Number.isFinite(event.created_at)) {
|
||||
timestampCandidates.push(event.created_at);
|
||||
}
|
||||
const timestamp = timestampCandidates.length
|
||||
? Math.max(...timestampCandidates)
|
||||
: Date.now() / 1000;
|
||||
const normalizedScheme = normalizeScheme(scheme || decryptor?.scheme || "");
|
||||
const messageTimestamp =
|
||||
message && Number.isFinite(message.created_at) ? message.created_at : null;
|
||||
const eventTimestamp =
|
||||
event && Number.isFinite(event.created_at) ? event.created_at : null;
|
||||
const timestamp =
|
||||
messageTimestamp ?? eventTimestamp ?? Date.now() / 1000;
|
||||
|
||||
return {
|
||||
ok,
|
||||
@@ -295,11 +329,12 @@ function buildDecryptResult({
|
||||
actorPubkey: normalizeHex(actorPubkey),
|
||||
decryptor: decryptor
|
||||
? {
|
||||
scheme: normalizeScheme(decryptor.scheme) || scheme || "",
|
||||
scheme: normalizeScheme(decryptor.scheme) || normalizedScheme || "",
|
||||
source: decryptor.source || "",
|
||||
}
|
||||
: { scheme: scheme || "", source: "" },
|
||||
scheme: normalizeScheme(scheme || decryptor?.scheme || ""),
|
||||
: { scheme: normalizedScheme || "", source: "" },
|
||||
scheme: normalizedScheme,
|
||||
encryption_scheme: normalizedScheme,
|
||||
envelope,
|
||||
direction: deriveDirection(actorPubkey, senderPubkey, recipients),
|
||||
timestamp: Number.isFinite(timestamp) ? timestamp : Date.now() / 1000,
|
||||
@@ -377,15 +412,19 @@ async function decryptGiftWrap(event, decryptors, actorPubkey) {
|
||||
stage: "seal",
|
||||
});
|
||||
const rumor = parseEventJson(rumorSerialized, "rumor");
|
||||
const normalizedRumor = normalizeInnerMessage(rumor);
|
||||
|
||||
const plaintext = typeof rumor?.content === "string" ? rumor.content : "";
|
||||
const senderPubkey = normalizeHex(rumor?.pubkey) || sealPubkey;
|
||||
const recipients = collectRecipients(rumor?.tags);
|
||||
const plaintext =
|
||||
typeof normalizedRumor?.content === "string"
|
||||
? normalizedRumor.content
|
||||
: "";
|
||||
const senderPubkey = normalizeHex(normalizedRumor?.pubkey) || sealPubkey;
|
||||
const recipients = collectRecipients(normalizedRumor?.tags);
|
||||
|
||||
return buildDecryptResult({
|
||||
ok: true,
|
||||
event,
|
||||
message: rumor,
|
||||
message: normalizedRumor,
|
||||
plaintext,
|
||||
recipients,
|
||||
senderPubkey,
|
||||
@@ -472,6 +511,10 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) {
|
||||
});
|
||||
|
||||
if (typeof plaintext === "string") {
|
||||
const resolvedScheme =
|
||||
normalizeScheme(hints.algorithms?.[0]) ||
|
||||
normalizeScheme(decryptor.scheme) ||
|
||||
"";
|
||||
return buildDecryptResult({
|
||||
ok: true,
|
||||
event,
|
||||
@@ -484,7 +527,7 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) {
|
||||
senderPubkey,
|
||||
actorPubkey,
|
||||
decryptor,
|
||||
scheme: decryptor.scheme || hints.algorithms?.[0] || "",
|
||||
scheme: resolvedScheme,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -586,4 +629,3 @@ export const __testUtils = {
|
||||
collectRecipients,
|
||||
orderDecryptors,
|
||||
};
|
||||
|
||||
+735
-131
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
export function summarizeDmEventForLog(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return {
|
||||
kind: null,
|
||||
createdAt: null,
|
||||
hasContent: false,
|
||||
contentLength: 0,
|
||||
tagCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const createdAt = Number.isFinite(event.created_at)
|
||||
? Math.floor(event.created_at)
|
||||
: null;
|
||||
|
||||
return {
|
||||
kind: Number.isFinite(event.kind) ? event.kind : null,
|
||||
createdAt,
|
||||
hasContent: typeof event.content === "string" && event.content.length > 0,
|
||||
contentLength: typeof event.content === "string" ? event.content.length : 0,
|
||||
tagCount: Array.isArray(event.tags) ? event.tags.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeDecryptError(error) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof error.message === "string"
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: "";
|
||||
|
||||
return {
|
||||
name: typeof error.name === "string" ? error.name : "",
|
||||
code: typeof error.code === "string" ? error.code : "",
|
||||
message,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js";
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
const workerScope = typeof self !== "undefined" ? self : null;
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
const postResponse = (payload) => {
|
||||
if (!workerScope || typeof workerScope.postMessage !== "function") {
|
||||
return;
|
||||
}
|
||||
workerScope.postMessage(payload);
|
||||
};
|
||||
|
||||
const normalizeScheme = (scheme) => {
|
||||
if (typeof scheme !== "string") {
|
||||
return "nip04";
|
||||
}
|
||||
const normalized = scheme.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "nip04";
|
||||
}
|
||||
if (normalized === "nip44-v2") {
|
||||
return "nip44_v2";
|
||||
}
|
||||
if (normalized === "nip-44") {
|
||||
return "nip44";
|
||||
}
|
||||
if (normalized === "nip-04") {
|
||||
return "nip04";
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizePayload = (payload) => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
typeof payload.id === "string" || typeof payload.id === "number"
|
||||
? payload.id
|
||||
: null;
|
||||
const scheme = normalizeScheme(payload.scheme);
|
||||
const privateKey =
|
||||
typeof payload.privateKey === "string" ? payload.privateKey.trim() : "";
|
||||
const targetPubkey =
|
||||
typeof payload.targetPubkey === "string" ? payload.targetPubkey.trim() : "";
|
||||
const ciphertext =
|
||||
typeof payload.ciphertext === "string" ? payload.ciphertext : "";
|
||||
const event =
|
||||
payload.event && typeof payload.event === "object" ? payload.event : null;
|
||||
|
||||
if (!id || !privateKey || !targetPubkey || !ciphertext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
scheme,
|
||||
privateKey,
|
||||
targetPubkey,
|
||||
ciphertext,
|
||||
event,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeEventForVerification = (event) => {
|
||||
if (!event || typeof event !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pubkey = typeof event.pubkey === "string" ? event.pubkey.trim() : "";
|
||||
const sig = typeof event.sig === "string" ? event.sig.trim() : "";
|
||||
const id = typeof event.id === "string" ? event.id.trim() : "";
|
||||
|
||||
if (!pubkey || !sig || !id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
pubkey,
|
||||
sig,
|
||||
kind: Number.isFinite(event.kind) ? event.kind : 0,
|
||||
created_at: Number.isFinite(event.created_at)
|
||||
? event.created_at
|
||||
: Math.floor(Date.now() / 1000),
|
||||
tags: Array.isArray(event.tags) ? event.tags : [],
|
||||
content: typeof event.content === "string" ? event.content : "",
|
||||
};
|
||||
};
|
||||
|
||||
const resolveHexToBytes = (tools) => {
|
||||
if (typeof tools?.utils?.hexToBytes === "function") {
|
||||
return (value) => tools.utils.hexToBytes(value);
|
||||
}
|
||||
|
||||
return (value) => {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Invalid hex input.");
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length % 2 !== 0) {
|
||||
throw new Error("Invalid hex input.");
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(trimmed.length / 2);
|
||||
for (let index = 0; index < trimmed.length; index += 2) {
|
||||
const byte = Number.parseInt(trimmed.slice(index, index + 2), 16);
|
||||
if (Number.isNaN(byte)) {
|
||||
throw new Error("Invalid hex input.");
|
||||
}
|
||||
bytes[index / 2] = byte;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
};
|
||||
|
||||
const ensureNip04 = async () => {
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
const nip04 = tools?.nip04;
|
||||
if (!nip04 || typeof nip04.decrypt !== "function") {
|
||||
throw new Error("nip04-unavailable");
|
||||
}
|
||||
return nip04;
|
||||
};
|
||||
|
||||
const ensureNip44 = async () => {
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
if (!tools) {
|
||||
throw new Error("nip44-unavailable");
|
||||
}
|
||||
|
||||
const nip44 = tools.nip44 || null;
|
||||
let decrypt = null;
|
||||
let getConversationKey = null;
|
||||
|
||||
if (nip44?.v2 && typeof nip44.v2 === "object") {
|
||||
if (typeof nip44.v2.decrypt === "function") {
|
||||
decrypt = nip44.v2.decrypt;
|
||||
}
|
||||
if (typeof nip44.v2?.utils?.getConversationKey === "function") {
|
||||
getConversationKey = nip44.v2.utils.getConversationKey;
|
||||
}
|
||||
}
|
||||
|
||||
if ((!decrypt || !getConversationKey) && nip44 && typeof nip44 === "object") {
|
||||
if (typeof nip44.decrypt === "function") {
|
||||
decrypt = nip44.decrypt;
|
||||
}
|
||||
if (typeof nip44.getConversationKey === "function") {
|
||||
getConversationKey = nip44.getConversationKey;
|
||||
} else if (typeof nip44.utils?.getConversationKey === "function") {
|
||||
getConversationKey = nip44.utils.getConversationKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (!decrypt || !getConversationKey) {
|
||||
throw new Error("nip44-unavailable");
|
||||
}
|
||||
|
||||
return {
|
||||
decrypt,
|
||||
getConversationKey,
|
||||
hexToBytes: resolveHexToBytes(tools),
|
||||
};
|
||||
};
|
||||
|
||||
const verifyEventSignature = async (event, tools = null) => {
|
||||
const candidate = normalizeEventForVerification(event);
|
||||
if (!candidate) {
|
||||
throw new Error("missing-event-signature");
|
||||
}
|
||||
|
||||
const resolvedTools = tools || (await ensureNostrTools()) || getCachedNostrTools();
|
||||
if (!resolvedTools || typeof resolvedTools !== "object") {
|
||||
throw new Error("signature-verification-unavailable");
|
||||
}
|
||||
|
||||
if (
|
||||
typeof resolvedTools.validateEvent !== "function" ||
|
||||
typeof resolvedTools.verifyEvent !== "function"
|
||||
) {
|
||||
throw new Error("signature-verification-unavailable");
|
||||
}
|
||||
|
||||
if (!resolvedTools.validateEvent(candidate) || !resolvedTools.verifyEvent(candidate)) {
|
||||
throw new Error("invalid-event-signature");
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleMessage = async (event) => {
|
||||
const payload = normalizePayload(event?.data);
|
||||
if (!payload) {
|
||||
postResponse({
|
||||
id: event?.data?.id ?? null,
|
||||
ok: false,
|
||||
error: { message: "invalid-payload" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HEX64_REGEX.test(payload.privateKey)) {
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: false,
|
||||
error: { message: "invalid-private-key" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (payload.scheme === "nip44" || payload.scheme === "nip44_v2") {
|
||||
await verifyEventSignature(payload.event);
|
||||
const nip44 = await ensureNip44();
|
||||
const privateKeyBytes = nip44.hexToBytes(payload.privateKey);
|
||||
const conversationKey = nip44.getConversationKey(
|
||||
privateKeyBytes,
|
||||
payload.targetPubkey,
|
||||
);
|
||||
const plaintext = await nip44.decrypt(
|
||||
payload.ciphertext,
|
||||
conversationKey,
|
||||
);
|
||||
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: true,
|
||||
plaintext,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nip04 = await ensureNip04();
|
||||
const plaintext = await nip04.decrypt(
|
||||
payload.privateKey,
|
||||
payload.targetPubkey,
|
||||
payload.ciphertext,
|
||||
);
|
||||
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: true,
|
||||
plaintext,
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[dmDecryptWorker] Decryption failed", error);
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: false,
|
||||
error: {
|
||||
message: error?.message || "dm-worker-failed",
|
||||
name: error?.name || "Error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (workerScope?.addEventListener) {
|
||||
workerScope.addEventListener("message", (event) => {
|
||||
void handleMessage(event);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 12000;
|
||||
|
||||
let workerInstance = null;
|
||||
let workerReady = false;
|
||||
let requestId = 0;
|
||||
const pending = new Map();
|
||||
|
||||
function attachWorkerListeners(worker) {
|
||||
if (!worker || workerReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
worker.addEventListener("message", (event) => {
|
||||
const data = event?.data || {};
|
||||
const id = data.id;
|
||||
if (!pending.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = pending.get(id);
|
||||
pending.delete(id);
|
||||
clearTimeout(entry.timeoutId);
|
||||
|
||||
if (data.ok && typeof data.plaintext === "string") {
|
||||
entry.resolve(data.plaintext);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = data?.error?.message || "dm-worker-error";
|
||||
const error = new Error(message);
|
||||
error.name = data?.error?.name || "Error";
|
||||
entry.reject(error);
|
||||
});
|
||||
|
||||
worker.addEventListener("error", (error) => {
|
||||
devLogger.warn("[dmDecryptWorkerClient] Worker error", error);
|
||||
for (const entry of pending.values()) {
|
||||
clearTimeout(entry.timeoutId);
|
||||
entry.reject(
|
||||
error instanceof Error ? error : new Error("dm-worker-error"),
|
||||
);
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
workerReady = true;
|
||||
}
|
||||
|
||||
function ensureWorker() {
|
||||
if (workerInstance) {
|
||||
return workerInstance;
|
||||
}
|
||||
|
||||
if (typeof Worker === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
workerInstance = new Worker(
|
||||
new URL("./dmDecryptWorker.js", import.meta.url),
|
||||
{
|
||||
type: "module",
|
||||
},
|
||||
);
|
||||
attachWorkerListeners(workerInstance);
|
||||
} catch (error) {
|
||||
devLogger.warn("[dmDecryptWorkerClient] Failed to create worker", error);
|
||||
workerInstance = null;
|
||||
}
|
||||
|
||||
return workerInstance;
|
||||
}
|
||||
|
||||
export function isDmDecryptWorkerSupported() {
|
||||
return typeof Worker !== "undefined";
|
||||
}
|
||||
|
||||
export function getDmDecryptWorkerQueueSize() {
|
||||
return pending.size;
|
||||
}
|
||||
|
||||
export function decryptDmInWorker({
|
||||
scheme = "nip04",
|
||||
privateKey,
|
||||
targetPubkey,
|
||||
ciphertext,
|
||||
event,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
const worker = ensureWorker();
|
||||
if (!worker) {
|
||||
return Promise.reject(new Error("dm-worker-unavailable"));
|
||||
}
|
||||
|
||||
const trimmedPrivateKey =
|
||||
typeof privateKey === "string" ? privateKey.trim() : "";
|
||||
const trimmedTarget =
|
||||
typeof targetPubkey === "string" ? targetPubkey.trim() : "";
|
||||
const payloadCiphertext = typeof ciphertext === "string" ? ciphertext : "";
|
||||
const payloadScheme = typeof scheme === "string" ? scheme : "";
|
||||
const payloadEvent = event && typeof event === "object" ? event : null;
|
||||
|
||||
if (!trimmedPrivateKey || !trimmedTarget || !payloadCiphertext) {
|
||||
return Promise.reject(new Error("dm-worker-invalid-input"));
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${requestId++}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error("dm-worker-timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
pending.set(id, { resolve, reject, timeoutId });
|
||||
worker.postMessage({
|
||||
id,
|
||||
scheme: payloadScheme,
|
||||
privateKey: trimmedPrivateKey,
|
||||
targetPubkey: trimmedTarget,
|
||||
ciphertext: payloadCiphertext,
|
||||
event: payloadEvent,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
buildDmReadReceiptEvent,
|
||||
buildDmTypingIndicatorEvent,
|
||||
sanitizeAdditionalTags,
|
||||
} from "../nostrEventSchemas.js";
|
||||
import { publishEventToRelay } from "../nostrPublish.js";
|
||||
import { RELAY_URLS } from "./toolkit.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import { queueSignEvent } from "./signRequestQueue.js";
|
||||
import { getActiveSigner } from "../nostrClientRegistry.js";
|
||||
import { isSessionActor } from "./sessionActor.js";
|
||||
|
||||
const DEFAULT_TYPING_EXPIRY_SECONDS = 15;
|
||||
|
||||
function sanitizeRelayList(primary, fallback) {
|
||||
if (Array.isArray(primary) && primary.length) {
|
||||
return primary;
|
||||
}
|
||||
if (Array.isArray(fallback) && fallback.length) {
|
||||
return fallback;
|
||||
}
|
||||
return RELAY_URLS;
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export async function publishDmReadReceipt(
|
||||
client,
|
||||
payload = {},
|
||||
options = {},
|
||||
{
|
||||
shouldRequestExtensionPermissions,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
} = {},
|
||||
) {
|
||||
if (!client?.pool) {
|
||||
return { ok: false, error: "nostr-uninitialized" };
|
||||
}
|
||||
|
||||
if (isSessionActor(client)) {
|
||||
const error = new Error(
|
||||
"Publishing read receipts is not allowed for session actors.",
|
||||
);
|
||||
error.code = "session-actor-publish-blocked";
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const eventId = normalizeString(payload.eventId);
|
||||
const recipientPubkey = normalizeString(payload.recipientPubkey);
|
||||
|
||||
if (!eventId || !recipientPubkey) {
|
||||
return { ok: false, error: "invalid-target" };
|
||||
}
|
||||
|
||||
const actorPubkey =
|
||||
typeof client?.pubkey === "string" && client.pubkey.trim()
|
||||
? client.pubkey.trim()
|
||||
: "";
|
||||
if (!actorPubkey) {
|
||||
return { ok: false, error: "auth-required" };
|
||||
}
|
||||
|
||||
const createdAt =
|
||||
typeof options.created_at === "number" && options.created_at > 0
|
||||
? Math.floor(options.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const additionalTags = sanitizeAdditionalTags(payload.additionalTags);
|
||||
|
||||
const event = buildDmReadReceiptEvent({
|
||||
pubkey: actorPubkey,
|
||||
created_at: createdAt,
|
||||
recipientPubkey,
|
||||
eventId,
|
||||
messageKind: payload.messageKind,
|
||||
additionalTags,
|
||||
});
|
||||
|
||||
let signedEvent = null;
|
||||
|
||||
const signer = getActiveSigner();
|
||||
|
||||
if (!signer || typeof signer.signEvent !== "function") {
|
||||
const error = new Error(
|
||||
"Login required: an active signer is needed to publish read receipts.",
|
||||
);
|
||||
error.code = "auth-required";
|
||||
return {
|
||||
ok: false,
|
||||
error: "auth-required",
|
||||
message: error.message,
|
||||
details: error,
|
||||
};
|
||||
}
|
||||
|
||||
let permissionResult = { ok: true };
|
||||
const shouldRequestPermissions =
|
||||
typeof shouldRequestExtensionPermissions === "function"
|
||||
? shouldRequestExtensionPermissions(signer)
|
||||
: false;
|
||||
|
||||
if (shouldRequestPermissions) {
|
||||
permissionResult = await client.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
}
|
||||
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[nostr] Active signer permissions missing; read receipts require login.",
|
||||
permissionResult.error,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: "auth-required",
|
||||
details: permissionResult,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
signedEvent = await queueSignEvent(signer, event, {
|
||||
timeoutMs: options?.timeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostr] Failed to sign read receipt event with active signer:",
|
||||
error,
|
||||
);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(
|
||||
payload.relays,
|
||||
client.writeRelays || client.relays,
|
||||
);
|
||||
|
||||
const publishResults = await Promise.all(
|
||||
relayList.map((url) => publishEventToRelay(client.pool, url, signedEvent)),
|
||||
);
|
||||
|
||||
const acceptedRelays = publishResults
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.url)
|
||||
.filter((url) => typeof url === "string" && url);
|
||||
|
||||
const success = acceptedRelays.length > 0;
|
||||
|
||||
if (success) {
|
||||
devLogger.info(
|
||||
`[nostr] Read receipt accepted by ${acceptedRelays.length} relay(s):`,
|
||||
acceptedRelays.join(", "),
|
||||
);
|
||||
} else {
|
||||
userLogger.warn("[nostr] Read receipt rejected by relays:", publishResults);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: success,
|
||||
event: signedEvent,
|
||||
results: publishResults,
|
||||
acceptedRelays,
|
||||
};
|
||||
}
|
||||
|
||||
export async function publishDmTypingIndicator(
|
||||
client,
|
||||
payload = {},
|
||||
options = {},
|
||||
{
|
||||
shouldRequestExtensionPermissions,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
} = {},
|
||||
) {
|
||||
if (!client?.pool) {
|
||||
return { ok: false, error: "nostr-uninitialized" };
|
||||
}
|
||||
|
||||
if (isSessionActor(client)) {
|
||||
const error = new Error(
|
||||
"Publishing typing indicators is not allowed for session actors.",
|
||||
);
|
||||
error.code = "session-actor-publish-blocked";
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
const recipientPubkey = normalizeString(payload.recipientPubkey);
|
||||
if (!recipientPubkey) {
|
||||
return { ok: false, error: "invalid-target" };
|
||||
}
|
||||
|
||||
const actorPubkey =
|
||||
typeof client?.pubkey === "string" && client.pubkey.trim()
|
||||
? client.pubkey.trim()
|
||||
: "";
|
||||
if (!actorPubkey) {
|
||||
return { ok: false, error: "auth-required" };
|
||||
}
|
||||
|
||||
const createdAt =
|
||||
typeof options.created_at === "number" && options.created_at > 0
|
||||
? Math.floor(options.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const expiresInSeconds = Number.isFinite(payload.expiresInSeconds)
|
||||
? Math.max(1, Math.floor(payload.expiresInSeconds))
|
||||
: DEFAULT_TYPING_EXPIRY_SECONDS;
|
||||
const expiresAt = createdAt + expiresInSeconds;
|
||||
|
||||
const additionalTags = sanitizeAdditionalTags(payload.additionalTags);
|
||||
|
||||
const event = buildDmTypingIndicatorEvent({
|
||||
pubkey: actorPubkey,
|
||||
created_at: createdAt,
|
||||
recipientPubkey,
|
||||
eventId: payload.conversationEventId,
|
||||
expiresAt,
|
||||
additionalTags,
|
||||
});
|
||||
|
||||
let signedEvent = null;
|
||||
|
||||
const signer = getActiveSigner();
|
||||
|
||||
if (!signer || typeof signer.signEvent !== "function") {
|
||||
const error = new Error(
|
||||
"Login required: an active signer is needed to publish typing indicators.",
|
||||
);
|
||||
error.code = "auth-required";
|
||||
return {
|
||||
ok: false,
|
||||
error: "auth-required",
|
||||
message: error.message,
|
||||
details: error,
|
||||
};
|
||||
}
|
||||
|
||||
let permissionResult = { ok: true };
|
||||
const shouldRequestPermissions =
|
||||
typeof shouldRequestExtensionPermissions === "function"
|
||||
? shouldRequestExtensionPermissions(signer)
|
||||
: false;
|
||||
|
||||
if (shouldRequestPermissions) {
|
||||
permissionResult = await client.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
}
|
||||
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[nostr] Active signer permissions missing; typing indicators require login.",
|
||||
permissionResult.error,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: "auth-required",
|
||||
details: permissionResult,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
signedEvent = await queueSignEvent(signer, event, {
|
||||
timeoutMs: options?.timeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostr] Failed to sign typing indicator event with active signer:",
|
||||
error,
|
||||
);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(
|
||||
payload.relays,
|
||||
client.writeRelays || client.relays,
|
||||
);
|
||||
|
||||
const publishResults = await Promise.all(
|
||||
relayList.map((url) => publishEventToRelay(client.pool, url, signedEvent)),
|
||||
);
|
||||
|
||||
const acceptedRelays = publishResults
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.url)
|
||||
.filter((url) => typeof url === "string" && url);
|
||||
|
||||
const success = acceptedRelays.length > 0;
|
||||
|
||||
if (success) {
|
||||
devLogger.debug(
|
||||
`[nostr] Typing indicator accepted by ${acceptedRelays.length} relay(s).`,
|
||||
);
|
||||
} else {
|
||||
devLogger.warn(
|
||||
"[nostr] Typing indicator rejected by relays:",
|
||||
publishResults,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: success,
|
||||
event: signedEvent,
|
||||
results: publishResults,
|
||||
acceptedRelays,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js";
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
const workerScope = typeof self !== "undefined" ? self : null;
|
||||
|
||||
const postResponse = (payload) => {
|
||||
if (!workerScope || typeof workerScope.postMessage !== "function") {
|
||||
return;
|
||||
}
|
||||
workerScope.postMessage(payload);
|
||||
};
|
||||
|
||||
const ensureNip04 = async () => {
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
const nip04 = tools?.nip04;
|
||||
if (!nip04 || typeof nip04.encrypt !== "function") {
|
||||
throw new Error("nip04-unavailable");
|
||||
}
|
||||
return nip04;
|
||||
};
|
||||
|
||||
const normalizePayload = (payload) => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
typeof payload.id === "string" || typeof payload.id === "number"
|
||||
? payload.id
|
||||
: null;
|
||||
const privateKey =
|
||||
typeof payload.privateKey === "string" ? payload.privateKey.trim() : "";
|
||||
const targetPubkey =
|
||||
typeof payload.targetPubkey === "string" ? payload.targetPubkey.trim() : "";
|
||||
const plaintext =
|
||||
typeof payload.plaintext === "string" ? payload.plaintext : "";
|
||||
|
||||
if (!id || !privateKey || !targetPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
privateKey,
|
||||
targetPubkey,
|
||||
plaintext,
|
||||
};
|
||||
};
|
||||
|
||||
const handleMessage = async (event) => {
|
||||
const payload = normalizePayload(event?.data);
|
||||
if (!payload) {
|
||||
postResponse({
|
||||
id: event?.data?.id ?? null,
|
||||
ok: false,
|
||||
error: { message: "invalid-payload" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nip04 = await ensureNip04();
|
||||
const ciphertext = await nip04.encrypt(
|
||||
payload.privateKey,
|
||||
payload.targetPubkey,
|
||||
payload.plaintext,
|
||||
);
|
||||
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: true,
|
||||
ciphertext,
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[nip04Worker] Encryption failed", error);
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: false,
|
||||
error: {
|
||||
message: error?.message || "nip04-worker-failed",
|
||||
name: error?.name || "Error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (workerScope?.addEventListener) {
|
||||
workerScope.addEventListener("message", (event) => {
|
||||
void handleMessage(event);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 12000;
|
||||
|
||||
let workerInstance = null;
|
||||
let workerReady = false;
|
||||
let requestId = 0;
|
||||
const pending = new Map();
|
||||
|
||||
function attachWorkerListeners(worker) {
|
||||
if (!worker || workerReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
worker.addEventListener("message", (event) => {
|
||||
const data = event?.data || {};
|
||||
const id = data.id;
|
||||
if (!pending.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = pending.get(id);
|
||||
pending.delete(id);
|
||||
clearTimeout(entry.timeoutId);
|
||||
|
||||
if (data.ok && typeof data.ciphertext === "string") {
|
||||
entry.resolve(data.ciphertext);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = data?.error?.message || "nip04-worker-error";
|
||||
const error = new Error(message);
|
||||
error.name = data?.error?.name || "Error";
|
||||
entry.reject(error);
|
||||
});
|
||||
|
||||
worker.addEventListener("error", (error) => {
|
||||
devLogger.warn("[nip04WorkerClient] Worker error", error);
|
||||
for (const entry of pending.values()) {
|
||||
clearTimeout(entry.timeoutId);
|
||||
entry.reject(error instanceof Error ? error : new Error("nip04-worker-error"));
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
workerReady = true;
|
||||
}
|
||||
|
||||
function ensureWorker() {
|
||||
if (workerInstance) {
|
||||
return workerInstance;
|
||||
}
|
||||
|
||||
if (typeof Worker === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
workerInstance = new Worker(new URL("./nip04Worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
attachWorkerListeners(workerInstance);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nip04WorkerClient] Failed to create worker", error);
|
||||
workerInstance = null;
|
||||
}
|
||||
|
||||
return workerInstance;
|
||||
}
|
||||
|
||||
export function encryptNip04InWorker({
|
||||
privateKey,
|
||||
targetPubkey,
|
||||
plaintext,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
const worker = ensureWorker();
|
||||
if (!worker) {
|
||||
return Promise.reject(new Error("nip04-worker-unavailable"));
|
||||
}
|
||||
|
||||
const trimmedPrivateKey =
|
||||
typeof privateKey === "string" ? privateKey.trim() : "";
|
||||
const trimmedTarget =
|
||||
typeof targetPubkey === "string" ? targetPubkey.trim() : "";
|
||||
const message = typeof plaintext === "string" ? plaintext : "";
|
||||
|
||||
if (!trimmedPrivateKey || !trimmedTarget) {
|
||||
return Promise.reject(new Error("nip04-worker-invalid-input"));
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${requestId++}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error("nip04-worker-timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
pending.set(id, { resolve, reject, timeoutId });
|
||||
worker.postMessage({
|
||||
id,
|
||||
privateKey: trimmedPrivateKey,
|
||||
targetPubkey: trimmedTarget,
|
||||
plaintext: message,
|
||||
});
|
||||
});
|
||||
}
|
||||
+418
-2
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
import { bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
const ATTACHMENT_CACHE_LIMIT = 20;
|
||||
const AES_GCM_IV_BYTES = 12;
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
function getCacheEntry(key) {
|
||||
if (!cache.has(key)) {
|
||||
return null;
|
||||
}
|
||||
const entry = cache.get(key);
|
||||
cache.delete(key);
|
||||
cache.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function revokeEntry(entry) {
|
||||
if (entry?.objectUrl && typeof URL !== "undefined") {
|
||||
try {
|
||||
URL.revokeObjectURL(entry.objectUrl);
|
||||
} catch (error) {
|
||||
devLogger.warn("[attachments] Failed to revoke object URL", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setCacheEntry(key, entry) {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cache.has(key)) {
|
||||
const existing = cache.get(key);
|
||||
if (existing && existing !== entry) {
|
||||
revokeEntry(existing);
|
||||
}
|
||||
cache.delete(key);
|
||||
} else if (cache.size >= ATTACHMENT_CACHE_LIMIT) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
const oldest = cache.get(oldestKey);
|
||||
cache.delete(oldestKey);
|
||||
revokeEntry(oldest);
|
||||
}
|
||||
|
||||
cache.set(key, entry);
|
||||
}
|
||||
|
||||
export function clearAttachmentCache() {
|
||||
cache.forEach((entry) => revokeEntry(entry));
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
function normalizeBase64(bytes) {
|
||||
if (!(bytes instanceof Uint8Array)) {
|
||||
return "";
|
||||
}
|
||||
let binary = "";
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function decodeBase64(value) {
|
||||
if (typeof value !== "string" || !value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
devLogger.warn("[attachments] Failed to decode base64 key", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readBlobAsUint8Array(blob) {
|
||||
if (!blob || typeof blob.arrayBuffer !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
} catch (error) {
|
||||
devLogger.warn("[attachments] Failed to read attachment bytes", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function computeSha256Hex(bytes) {
|
||||
if (!(bytes instanceof Uint8Array)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const digest = sha256(bytes);
|
||||
const hex = typeof digest === "string" ? digest : bytesToHex(digest);
|
||||
return hex ? hex.toLowerCase() : "";
|
||||
} catch (error) {
|
||||
devLogger.warn("[attachments] Failed to compute attachment hash", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function encryptAttachment(blob) {
|
||||
if (!blob) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error("Encryption unavailable in this environment.");
|
||||
}
|
||||
|
||||
const rawBytes = await readBlobAsUint8Array(blob);
|
||||
if (!rawBytes) {
|
||||
throw new Error("Failed to read attachment bytes for encryption.");
|
||||
}
|
||||
|
||||
const keyBytes = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(keyBytes);
|
||||
const ivBytes = new Uint8Array(AES_GCM_IV_BYTES);
|
||||
globalThis.crypto.getRandomValues(ivBytes);
|
||||
|
||||
const key = await globalThis.crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBytes,
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
|
||||
const encrypted = await globalThis.crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: ivBytes },
|
||||
key,
|
||||
rawBytes,
|
||||
);
|
||||
|
||||
const encryptedBytes = new Uint8Array(encrypted);
|
||||
const payload = new Uint8Array(ivBytes.length + encryptedBytes.length);
|
||||
payload.set(ivBytes, 0);
|
||||
payload.set(encryptedBytes, ivBytes.length);
|
||||
|
||||
return {
|
||||
payload,
|
||||
key: normalizeBase64(keyBytes),
|
||||
};
|
||||
}
|
||||
|
||||
async function decryptAttachment(bytes, keyBase64) {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error("Decryption unavailable in this environment.");
|
||||
}
|
||||
|
||||
const keyBytes = decodeBase64(keyBase64);
|
||||
if (!keyBytes) {
|
||||
throw new Error("Missing decryption key.");
|
||||
}
|
||||
|
||||
if (!(bytes instanceof Uint8Array) || bytes.length <= AES_GCM_IV_BYTES) {
|
||||
throw new Error("Encrypted payload is missing IV.");
|
||||
}
|
||||
|
||||
const iv = bytes.slice(0, AES_GCM_IV_BYTES);
|
||||
const cipherBytes = bytes.slice(AES_GCM_IV_BYTES);
|
||||
|
||||
const key = await globalThis.crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBytes,
|
||||
"AES-GCM",
|
||||
false,
|
||||
["decrypt"],
|
||||
);
|
||||
|
||||
const decrypted = await globalThis.crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
cipherBytes,
|
||||
);
|
||||
|
||||
return new Uint8Array(decrypted);
|
||||
}
|
||||
|
||||
export async function prepareAttachmentUpload({ file, encrypt = false } = {}) {
|
||||
if (!file) {
|
||||
throw new Error("Attachment file missing.");
|
||||
}
|
||||
|
||||
let payloadBytes = await readBlobAsUint8Array(file);
|
||||
if (!payloadBytes) {
|
||||
throw new Error("Attachment could not be read.");
|
||||
}
|
||||
|
||||
let key = "";
|
||||
let uploadBlob = file;
|
||||
|
||||
if (encrypt) {
|
||||
const encrypted = await encryptAttachment(file);
|
||||
if (!encrypted?.payload) {
|
||||
throw new Error("Attachment encryption failed.");
|
||||
}
|
||||
|
||||
payloadBytes = encrypted.payload;
|
||||
key = encrypted.key || "";
|
||||
uploadBlob = new File([payloadBytes], file.name, {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
}
|
||||
|
||||
const sha256 = await computeSha256Hex(payloadBytes);
|
||||
if (!sha256) {
|
||||
throw new Error("Failed to compute attachment hash.");
|
||||
}
|
||||
|
||||
return {
|
||||
uploadBlob,
|
||||
sha256,
|
||||
key,
|
||||
name: file.name,
|
||||
type: file.type || "application/octet-stream",
|
||||
size: file.size,
|
||||
encrypted: Boolean(key),
|
||||
};
|
||||
}
|
||||
|
||||
async function readResponseWithProgress(response, onProgress) {
|
||||
if (!response.body || typeof response.body.getReader !== "function") {
|
||||
const blob = await response.blob();
|
||||
if (typeof onProgress === "function") {
|
||||
onProgress(1);
|
||||
}
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const contentLength = Number(response.headers.get("content-length")) || 0;
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
if (contentLength && typeof onProgress === "function") {
|
||||
onProgress(Math.min(received / contentLength, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const merged = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
chunks.forEach((chunk) => {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
});
|
||||
|
||||
if (typeof onProgress === "function") {
|
||||
onProgress(1);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function downloadAttachment({
|
||||
url,
|
||||
expectedHash,
|
||||
key,
|
||||
mimeType,
|
||||
onProgress,
|
||||
} = {}) {
|
||||
if (!url) {
|
||||
throw new Error("Attachment URL missing.");
|
||||
}
|
||||
|
||||
const cached = expectedHash ? getCacheEntry(expectedHash) : null;
|
||||
if (cached) {
|
||||
return { ...cached, cached: true };
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Attachment download failed (${response.status}).`);
|
||||
}
|
||||
|
||||
const bytes = await readResponseWithProgress(response, onProgress);
|
||||
if (expectedHash) {
|
||||
const actual = await computeSha256Hex(bytes);
|
||||
if (actual && actual !== expectedHash) {
|
||||
throw new Error("Attachment hash mismatch.");
|
||||
}
|
||||
}
|
||||
|
||||
let payloadBytes = bytes;
|
||||
if (key) {
|
||||
payloadBytes = await decryptAttachment(bytes, key);
|
||||
}
|
||||
|
||||
const blob = new Blob([payloadBytes], {
|
||||
type: mimeType || "application/octet-stream",
|
||||
});
|
||||
const objectUrl = typeof URL !== "undefined" ? URL.createObjectURL(blob) : "";
|
||||
|
||||
const entry = {
|
||||
blob,
|
||||
objectUrl,
|
||||
mimeType,
|
||||
size: blob.size,
|
||||
};
|
||||
|
||||
if (expectedHash) {
|
||||
setCacheEntry(expectedHash, entry);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function uploadAttachment({
|
||||
r2Service,
|
||||
pubkey,
|
||||
file,
|
||||
encrypt = false,
|
||||
onProgress,
|
||||
buildKey,
|
||||
buildUrl,
|
||||
} = {}) {
|
||||
if (!r2Service || typeof r2Service.uploadFile !== "function") {
|
||||
throw new Error("Storage service unavailable.");
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
throw new Error("Attachment file missing.");
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
throw new Error("Active pubkey required for attachment upload.");
|
||||
}
|
||||
|
||||
const credentials =
|
||||
typeof r2Service.resolveConnection === "function"
|
||||
? await r2Service.resolveConnection(pubkey)
|
||||
: null;
|
||||
|
||||
if (!credentials) {
|
||||
throw new Error("Storage configuration missing.");
|
||||
}
|
||||
|
||||
const prepared = await prepareAttachmentUpload({ file, encrypt });
|
||||
const key = typeof buildKey === "function" ? buildKey(pubkey, file) : "";
|
||||
if (!key) {
|
||||
throw new Error("Unable to derive storage key for attachment.");
|
||||
}
|
||||
|
||||
await r2Service.uploadFile({
|
||||
file: prepared.uploadBlob,
|
||||
...credentials,
|
||||
bucket: credentials.bucket,
|
||||
key,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
const url = typeof buildUrl === "function" ? buildUrl(credentials.baseDomain, key) : "";
|
||||
if (!url) {
|
||||
userLogger.warn("[attachments] Attachment uploaded without public URL.");
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
x: prepared.sha256,
|
||||
key: prepared.key,
|
||||
name: prepared.name,
|
||||
type: prepared.type,
|
||||
size: prepared.size,
|
||||
encrypted: prepared.encrypted,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAttachmentCacheStats() {
|
||||
return {
|
||||
size: cache.size,
|
||||
maxSize: ATTACHMENT_CACHE_LIMIT,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
import { ensureNostrTools, resolveSimplePoolConstructor, shimLegacySimplePoolMethods } from "../nostr/toolkit.js";
|
||||
import { sanitizeRelayList } from "../nostr/nip46Client.js";
|
||||
import { publishEventToRelay } from "../nostrPublish.js";
|
||||
import logger from "../utils/logger.js";
|
||||
|
||||
const DEFAULT_BACKOFF = {
|
||||
baseMs: 1000,
|
||||
maxMs: 30000,
|
||||
jitterRatio: 0.3,
|
||||
};
|
||||
|
||||
const MAX_SEEN_EVENT_IDS = 5000;
|
||||
export const DM_RELAY_WARNING_FALLBACK = "dm-relays-fallback";
|
||||
|
||||
function normalizeRelayList(relays) {
|
||||
if (!Array.isArray(relays)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
relays
|
||||
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePubkey(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeLogger(candidate) {
|
||||
if (
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
candidate.dev &&
|
||||
candidate.user
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return logger;
|
||||
}
|
||||
|
||||
function extractRelayHintsFromEvent(event) {
|
||||
const tags = Array.isArray(event?.tags) ? event.tags : [];
|
||||
return sanitizeRelayList(
|
||||
tags
|
||||
.filter((tag) => Array.isArray(tag) && tag[0] === "relay")
|
||||
.map((tag) => (typeof tag[1] === "string" ? tag[1].trim() : "")),
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveDmRelaySelection({
|
||||
pubkey,
|
||||
relayHints = [],
|
||||
discoveryRelays = [],
|
||||
fallbackRelays = [],
|
||||
pool = null,
|
||||
log = null,
|
||||
} = {}) {
|
||||
const resolvedLogger = normalizeLogger(log || logger);
|
||||
const normalizedPubkey = normalizePubkey(pubkey);
|
||||
const hintedRelays = sanitizeRelayList(Array.isArray(relayHints) ? relayHints : []);
|
||||
|
||||
if (hintedRelays.length) {
|
||||
return { relays: hintedRelays, source: "hints", warning: null };
|
||||
}
|
||||
|
||||
const discoveryList = sanitizeRelayList(
|
||||
Array.isArray(discoveryRelays) ? discoveryRelays : [],
|
||||
);
|
||||
const fallbackList = sanitizeRelayList(
|
||||
Array.isArray(fallbackRelays) && fallbackRelays.length
|
||||
? fallbackRelays
|
||||
: discoveryRelays,
|
||||
);
|
||||
|
||||
if (
|
||||
normalizedPubkey &&
|
||||
pool &&
|
||||
typeof pool.list === "function" &&
|
||||
discoveryList.length
|
||||
) {
|
||||
try {
|
||||
const events = await pool.list(discoveryList, [
|
||||
{ kinds: [10050], authors: [normalizedPubkey], limit: 1 },
|
||||
]);
|
||||
const sorted = Array.isArray(events)
|
||||
? events
|
||||
.filter((entry) => entry && entry.pubkey === normalizedPubkey)
|
||||
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0))
|
||||
: [];
|
||||
if (sorted.length) {
|
||||
const relays = extractRelayHintsFromEvent(sorted[0]);
|
||||
if (relays.length) {
|
||||
return { relays, source: "kind-10050", warning: null };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
resolvedLogger.dev.warn("[dmNostrService] Failed to fetch DM relay hints.", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackList.length) {
|
||||
return {
|
||||
relays: fallbackList,
|
||||
source: "fallback",
|
||||
warning: DM_RELAY_WARNING_FALLBACK,
|
||||
};
|
||||
}
|
||||
|
||||
return { relays: [], source: "none", warning: null };
|
||||
}
|
||||
|
||||
function clampNumber(value, minimum, maximum) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return minimum;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
|
||||
function resolveBackoffConfig(custom = {}) {
|
||||
return {
|
||||
baseMs: clampNumber(custom.baseMs ?? DEFAULT_BACKOFF.baseMs, 250, 60000),
|
||||
maxMs: clampNumber(custom.maxMs ?? DEFAULT_BACKOFF.maxMs, 1000, 300000),
|
||||
jitterRatio: clampNumber(custom.jitterRatio ?? DEFAULT_BACKOFF.jitterRatio, 0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
function applyJitter(value, ratio) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) {
|
||||
return value;
|
||||
}
|
||||
const jitter = value * ratio;
|
||||
return Math.round(value + (Math.random() * jitter - jitter / 2));
|
||||
}
|
||||
|
||||
class DmNostrService {
|
||||
constructor({
|
||||
relays = [],
|
||||
preferredRelays = [],
|
||||
backoff = {},
|
||||
log = null,
|
||||
} = {}) {
|
||||
this.relays = normalizeRelayList(relays);
|
||||
this.preferredRelays = normalizeRelayList(preferredRelays);
|
||||
this.pool = null;
|
||||
this.poolPromise = null;
|
||||
this.logger = normalizeLogger(log || logger);
|
||||
this.backoffConfig = resolveBackoffConfig(backoff);
|
||||
this.relayStates = new Map();
|
||||
this.relayWatermarks = new Map();
|
||||
this.relayTimers = new Map();
|
||||
this.seenEventIds = new Set();
|
||||
this.seenEventOrder = [];
|
||||
this.subscriptions = new Map();
|
||||
this.activeFilters = [];
|
||||
this.actorPubkey = "";
|
||||
this.handlers = {
|
||||
onEvent: null,
|
||||
onEose: null,
|
||||
onError: null,
|
||||
};
|
||||
}
|
||||
|
||||
async ensurePool() {
|
||||
if (this.pool) {
|
||||
return this.pool;
|
||||
}
|
||||
|
||||
if (this.poolPromise) {
|
||||
return this.poolPromise;
|
||||
}
|
||||
|
||||
this.poolPromise = (async () => {
|
||||
const tools = await ensureNostrTools();
|
||||
const SimplePool = resolveSimplePoolConstructor(tools);
|
||||
if (typeof SimplePool !== "function") {
|
||||
throw new Error("nostr-tools SimplePool unavailable");
|
||||
}
|
||||
|
||||
const pool = new SimplePool();
|
||||
shimLegacySimplePoolMethods(pool);
|
||||
this.pool = pool;
|
||||
this.poolPromise = null;
|
||||
return pool;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await this.poolPromise;
|
||||
} catch (error) {
|
||||
this.poolPromise = null;
|
||||
this.logger.user.warn("[dmNostrService] Failed to initialize pool.", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getRelayStatus(relayUrl) {
|
||||
const relay = this.relayStates.get(relayUrl);
|
||||
return relay ? { ...relay } : null;
|
||||
}
|
||||
|
||||
listRelayStatuses() {
|
||||
return Array.from(this.relayStates.entries()).map(([url, state]) => ({
|
||||
url,
|
||||
...state,
|
||||
}));
|
||||
}
|
||||
|
||||
async connect({ relays = null } = {}) {
|
||||
if (Array.isArray(relays)) {
|
||||
this.relays = normalizeRelayList(relays);
|
||||
}
|
||||
|
||||
if (!this.relays.length) {
|
||||
this.logger.user.warn("[dmNostrService] No relays configured for DM service.");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ensurePool();
|
||||
|
||||
await Promise.all(
|
||||
this.relays.map(async (relayUrl) => {
|
||||
await this.connectRelay(relayUrl);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async connectRelay(relayUrl) {
|
||||
if (!this.pool) {
|
||||
await this.ensurePool();
|
||||
}
|
||||
|
||||
if (!relayUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const relayState = this.ensureRelayState(relayUrl);
|
||||
relayState.lastConnectAttempt = Date.now();
|
||||
|
||||
if (typeof this.pool.ensureRelay === "function") {
|
||||
try {
|
||||
const relay = await this.pool.ensureRelay(relayUrl);
|
||||
relayState.connected = Boolean(relay);
|
||||
if (relay) {
|
||||
this.attachRelayListeners(relayUrl, relay);
|
||||
this.recordRelaySuccess(relayUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
this.scheduleReconnect(relayUrl, "connect-failed");
|
||||
}
|
||||
} else {
|
||||
relayState.connected = true;
|
||||
this.recordRelaySuccess(relayUrl);
|
||||
}
|
||||
}
|
||||
|
||||
attachRelayListeners(relayUrl, relay) {
|
||||
if (!relay || typeof relay.on !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
relay.on("connect", () => {
|
||||
this.recordRelaySuccess(relayUrl);
|
||||
});
|
||||
relay.on("disconnect", () => {
|
||||
this.recordRelayFailure(relayUrl, new Error("relay-disconnect"));
|
||||
this.scheduleReconnect(relayUrl, "relay-disconnect");
|
||||
});
|
||||
relay.on("error", (error) => {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
this.scheduleReconnect(relayUrl, "relay-error");
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.dev.warn("[dmNostrService] Failed to attach relay listeners.", error);
|
||||
}
|
||||
}
|
||||
|
||||
ensureRelayState(relayUrl) {
|
||||
if (!this.relayStates.has(relayUrl)) {
|
||||
this.relayStates.set(relayUrl, {
|
||||
connected: false,
|
||||
okCount: 0,
|
||||
failCount: 0,
|
||||
lastOkAt: 0,
|
||||
lastFailAt: 0,
|
||||
lastConnectAttempt: 0,
|
||||
backoffMs: 0,
|
||||
attempts: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return this.relayStates.get(relayUrl);
|
||||
}
|
||||
|
||||
recordRelaySuccess(relayUrl) {
|
||||
const relayState = this.ensureRelayState(relayUrl);
|
||||
relayState.connected = true;
|
||||
relayState.okCount += 1;
|
||||
relayState.lastOkAt = Date.now();
|
||||
relayState.backoffMs = 0;
|
||||
relayState.attempts = 0;
|
||||
}
|
||||
|
||||
recordRelayFailure(relayUrl, error) {
|
||||
const relayState = this.ensureRelayState(relayUrl);
|
||||
relayState.connected = false;
|
||||
relayState.failCount += 1;
|
||||
relayState.lastFailAt = Date.now();
|
||||
|
||||
this.logger.dev.warn("[dmNostrService] Relay failure", {
|
||||
relayUrl,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
scheduleReconnect(relayUrl, reason) {
|
||||
const relayState = this.ensureRelayState(relayUrl);
|
||||
relayState.attempts += 1;
|
||||
|
||||
const backoffBase = this.backoffConfig.baseMs * 2 ** (relayState.attempts - 1);
|
||||
const backoffMs = applyJitter(
|
||||
Math.min(backoffBase, this.backoffConfig.maxMs),
|
||||
this.backoffConfig.jitterRatio,
|
||||
);
|
||||
|
||||
relayState.backoffMs = backoffMs;
|
||||
|
||||
if (this.relayTimers.has(relayUrl)) {
|
||||
clearTimeout(this.relayTimers.get(relayUrl));
|
||||
}
|
||||
|
||||
this.logger.dev.info("[dmNostrService] Scheduling relay reconnect", {
|
||||
relayUrl,
|
||||
reason,
|
||||
backoffMs,
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.relayTimers.delete(relayUrl);
|
||||
this.resubscribeRelay(relayUrl);
|
||||
}, backoffMs);
|
||||
|
||||
this.relayTimers.set(relayUrl, timer);
|
||||
}
|
||||
|
||||
buildFilters({ actorPubkey, authors = [], recipients = [] } = {}) {
|
||||
const normalizedActor = normalizePubkey(actorPubkey || this.actorPubkey);
|
||||
const authorSet = new Set(
|
||||
[normalizedActor, ...authors].map(normalizePubkey).filter(Boolean),
|
||||
);
|
||||
const recipientSet = new Set(
|
||||
[normalizedActor, ...recipients].map(normalizePubkey).filter(Boolean),
|
||||
);
|
||||
|
||||
const filters = [];
|
||||
|
||||
if (authorSet.size) {
|
||||
filters.push({
|
||||
kinds: [4],
|
||||
authors: Array.from(authorSet),
|
||||
});
|
||||
}
|
||||
|
||||
if (recipientSet.size) {
|
||||
filters.push({
|
||||
kinds: [4],
|
||||
"#p": Array.from(recipientSet),
|
||||
});
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
updateWatermark(relayUrl, event) {
|
||||
if (!event || typeof event.created_at !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.relayWatermarks.get(relayUrl) || 0;
|
||||
if (event.created_at > current) {
|
||||
this.relayWatermarks.set(relayUrl, event.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
hasSeenEvent(eventId) {
|
||||
if (!eventId) {
|
||||
return false;
|
||||
}
|
||||
return this.seenEventIds.has(eventId);
|
||||
}
|
||||
|
||||
rememberEvent(eventId) {
|
||||
if (!eventId || this.seenEventIds.has(eventId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.seenEventIds.add(eventId);
|
||||
this.seenEventOrder.push(eventId);
|
||||
|
||||
if (this.seenEventOrder.length > MAX_SEEN_EVENT_IDS) {
|
||||
const oldest = this.seenEventOrder.shift();
|
||||
if (oldest) {
|
||||
this.seenEventIds.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shouldProcessEvent(relayUrl, event) {
|
||||
if (!event || typeof event.id !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.hasSeenEvent(event.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const watermark = this.relayWatermarks.get(relayUrl) || 0;
|
||||
if (typeof event.created_at === "number" && event.created_at < watermark) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async subscribe({
|
||||
actorPubkey,
|
||||
authors,
|
||||
recipients,
|
||||
relays,
|
||||
onEvent,
|
||||
onEose,
|
||||
onError,
|
||||
} = {}) {
|
||||
if (typeof onEvent === "function") {
|
||||
this.handlers.onEvent = onEvent;
|
||||
}
|
||||
if (typeof onEose === "function") {
|
||||
this.handlers.onEose = onEose;
|
||||
}
|
||||
if (typeof onError === "function") {
|
||||
this.handlers.onError = onError;
|
||||
}
|
||||
|
||||
if (typeof actorPubkey === "string" && actorPubkey.trim()) {
|
||||
this.actorPubkey = normalizePubkey(actorPubkey);
|
||||
}
|
||||
|
||||
if (Array.isArray(relays)) {
|
||||
this.relays = normalizeRelayList(relays);
|
||||
}
|
||||
|
||||
if (!this.relays.length) {
|
||||
this.logger.user.warn("[dmNostrService] Cannot subscribe without relays.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeFilters = this.buildFilters({
|
||||
actorPubkey: this.actorPubkey,
|
||||
authors,
|
||||
recipients,
|
||||
});
|
||||
|
||||
if (!this.activeFilters.length) {
|
||||
this.logger.user.warn("[dmNostrService] Cannot subscribe without filters.");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.connect();
|
||||
|
||||
this.relays.forEach((relayUrl) => {
|
||||
this.subscribeRelay(relayUrl);
|
||||
});
|
||||
}
|
||||
|
||||
subscribeRelay(relayUrl) {
|
||||
if (!this.pool || !relayUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const since = this.relayWatermarks.get(relayUrl);
|
||||
const filters = this.activeFilters.map((filter) => {
|
||||
if (typeof since === "number" && since > 0) {
|
||||
return {
|
||||
...filter,
|
||||
since: Math.max(0, since - 1),
|
||||
};
|
||||
}
|
||||
return { ...filter };
|
||||
});
|
||||
|
||||
if (this.subscriptions.has(relayUrl)) {
|
||||
this.unsubscribeRelay(relayUrl);
|
||||
}
|
||||
|
||||
let subscription;
|
||||
try {
|
||||
subscription = this.pool.sub([relayUrl], filters);
|
||||
} catch (error) {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
this.scheduleReconnect(relayUrl, "subscribe-failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subscription || typeof subscription.on !== "function") {
|
||||
this.recordRelayFailure(relayUrl, new Error("subscription-unavailable"));
|
||||
this.scheduleReconnect(relayUrl, "subscribe-unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
subscription.on("event", (event) => {
|
||||
if (!this.shouldProcessEvent(relayUrl, event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rememberEvent(event.id);
|
||||
this.updateWatermark(relayUrl, event);
|
||||
this.recordRelaySuccess(relayUrl);
|
||||
|
||||
if (typeof this.handlers.onEvent === "function") {
|
||||
try {
|
||||
this.handlers.onEvent(event, { relayUrl });
|
||||
} catch (error) {
|
||||
this.logger.dev.warn("[dmNostrService] onEvent handler failed.", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
subscription.on("eose", () => {
|
||||
if (typeof this.handlers.onEose === "function") {
|
||||
try {
|
||||
this.handlers.onEose({ relayUrl });
|
||||
} catch (error) {
|
||||
this.logger.dev.warn("[dmNostrService] onEose handler failed.", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
subscription.on("close", () => {
|
||||
this.recordRelayFailure(relayUrl, new Error("subscription-closed"));
|
||||
this.scheduleReconnect(relayUrl, "subscription-closed");
|
||||
});
|
||||
|
||||
this.subscriptions.set(relayUrl, subscription);
|
||||
}
|
||||
|
||||
resubscribeRelay(relayUrl) {
|
||||
if (!relayUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.subscribeRelay(relayUrl);
|
||||
}
|
||||
|
||||
unsubscribeRelay(relayUrl) {
|
||||
const subscription = this.subscriptions.get(relayUrl);
|
||||
if (subscription && typeof subscription.unsub === "function") {
|
||||
try {
|
||||
subscription.unsub();
|
||||
} catch (error) {
|
||||
this.logger.dev.warn("[dmNostrService] Failed to unsubscribe relay.", error);
|
||||
}
|
||||
}
|
||||
this.subscriptions.delete(relayUrl);
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const relayUrl of this.subscriptions.keys()) {
|
||||
this.unsubscribeRelay(relayUrl);
|
||||
}
|
||||
|
||||
for (const timer of this.relayTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
this.relayTimers.clear();
|
||||
}
|
||||
|
||||
async publishSignedEvent(signedEvent, { relays = null } = {}) {
|
||||
if (!signedEvent || typeof signedEvent !== "object") {
|
||||
throw new Error("signed-event-required");
|
||||
}
|
||||
|
||||
if (signedEvent.kind !== 4) {
|
||||
throw new Error("invalid-event-kind");
|
||||
}
|
||||
|
||||
const relayList = normalizeRelayList(
|
||||
Array.isArray(relays) && relays.length
|
||||
? relays
|
||||
: this.preferredRelays.length
|
||||
? this.preferredRelays
|
||||
: this.relays,
|
||||
);
|
||||
|
||||
if (!relayList.length) {
|
||||
throw new Error("no-relays-configured");
|
||||
}
|
||||
|
||||
await this.ensurePool();
|
||||
|
||||
const results = await Promise.all(
|
||||
relayList.map(async (relayUrl) => {
|
||||
let result;
|
||||
try {
|
||||
result = await publishEventToRelay(this.pool, relayUrl, signedEvent);
|
||||
} catch (error) {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
return { success: false, error, relayUrl };
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
this.recordRelaySuccess(relayUrl);
|
||||
} else {
|
||||
this.recordRelayFailure(
|
||||
relayUrl,
|
||||
result?.error || new Error("publish-failed"),
|
||||
);
|
||||
}
|
||||
|
||||
return { ...result, relayUrl };
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export default DmNostrService;
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
const TELEMETRY_STORAGE_KEY = "bitvid:relay-health-telemetry-opt-in";
|
||||
|
||||
function resolveLogger(logger) {
|
||||
if (logger && logger.dev && logger.user) {
|
||||
return logger;
|
||||
}
|
||||
return { dev: devLogger, user: userLogger };
|
||||
}
|
||||
|
||||
function nowMs() {
|
||||
return typeof performance !== "undefined" && performance.now
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
|
||||
function readTelemetryOptIn() {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(TELEMETRY_STORAGE_KEY);
|
||||
return stored === "true";
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[relayHealth] Failed to read telemetry preference from storage.",
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeTelemetryOptIn(nextValue) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
TELEMETRY_STORAGE_KEY,
|
||||
nextValue ? "true" : "false",
|
||||
);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[relayHealth] Failed to persist telemetry preference.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RelayHealthService {
|
||||
constructor({ relayManager, nostrClient, logger, telemetryEmitter } = {}) {
|
||||
this.relayManager = relayManager || null;
|
||||
this.nostrClient = nostrClient || null;
|
||||
this.logger = resolveLogger(logger);
|
||||
this.telemetryEmitter =
|
||||
typeof telemetryEmitter === "function" ? telemetryEmitter : null;
|
||||
this.relayStates = new Map();
|
||||
this.attachedRelays = new Set();
|
||||
this.cachedTelemetryOptIn = readTelemetryOptIn();
|
||||
}
|
||||
|
||||
getTelemetryOptIn() {
|
||||
return this.cachedTelemetryOptIn;
|
||||
}
|
||||
|
||||
setTelemetryOptIn(value) {
|
||||
const normalized = Boolean(value);
|
||||
this.cachedTelemetryOptIn = normalized;
|
||||
writeTelemetryOptIn(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
getRelayUrls() {
|
||||
if (!this.relayManager || typeof this.relayManager.getEntries !== "function") {
|
||||
return [];
|
||||
}
|
||||
const entries = this.relayManager.getEntries();
|
||||
return entries
|
||||
.map((entry) => (typeof entry?.url === "string" ? entry.url.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
ensureRelayState(relayUrl) {
|
||||
if (!this.relayStates.has(relayUrl)) {
|
||||
this.relayStates.set(relayUrl, {
|
||||
url: relayUrl,
|
||||
connected: false,
|
||||
lastLatencyMs: null,
|
||||
errorCount: 0,
|
||||
lastCheckedAt: null,
|
||||
lastErrorAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
return this.relayStates.get(relayUrl);
|
||||
}
|
||||
|
||||
attachRelayListeners(relayUrl, relay) {
|
||||
if (!relay || typeof relay.on !== "function") {
|
||||
return;
|
||||
}
|
||||
if (this.attachedRelays.has(relayUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
relay.on("connect", () => {
|
||||
this.recordRelaySuccess(relayUrl, null);
|
||||
});
|
||||
relay.on("disconnect", () => {
|
||||
this.recordRelayFailure(relayUrl, new Error("relay-disconnect"));
|
||||
});
|
||||
relay.on("error", (error) => {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
});
|
||||
this.attachedRelays.add(relayUrl);
|
||||
} catch (error) {
|
||||
this.logger.dev.warn(
|
||||
"[relayHealth] Failed to attach relay listeners.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
recordRelaySuccess(relayUrl, latencyMs) {
|
||||
const state = this.ensureRelayState(relayUrl);
|
||||
state.connected = true;
|
||||
state.lastCheckedAt = Date.now();
|
||||
if (Number.isFinite(latencyMs)) {
|
||||
state.lastLatencyMs = Math.max(0, Math.round(latencyMs));
|
||||
}
|
||||
}
|
||||
|
||||
recordRelayFailure(relayUrl, error) {
|
||||
const state = this.ensureRelayState(relayUrl);
|
||||
state.connected = false;
|
||||
state.errorCount += 1;
|
||||
state.lastCheckedAt = Date.now();
|
||||
state.lastErrorAt = Date.now();
|
||||
|
||||
this.logger.dev.warn("[relayHealth] Relay check failed.", {
|
||||
relayUrl,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
async checkRelay(relayUrl) {
|
||||
if (!relayUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.nostrClient) {
|
||||
this.recordRelayFailure(relayUrl, new Error("nostr-client-unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof this.nostrClient.ensurePool === "function") {
|
||||
await this.nostrClient.ensurePool();
|
||||
}
|
||||
} catch (error) {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.nostrClient.pool || typeof this.nostrClient.pool.ensureRelay !== "function") {
|
||||
this.recordRelayFailure(relayUrl, new Error("relay-pool-unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
const start = nowMs();
|
||||
try {
|
||||
const relay = await this.nostrClient.pool.ensureRelay(relayUrl);
|
||||
if (!relay) {
|
||||
throw new Error("relay-unavailable");
|
||||
}
|
||||
|
||||
this.attachRelayListeners(relayUrl, relay);
|
||||
this.recordRelaySuccess(relayUrl, nowMs() - start);
|
||||
} catch (error) {
|
||||
this.recordRelayFailure(relayUrl, error);
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot(relayUrls = null) {
|
||||
const urls = Array.isArray(relayUrls) ? relayUrls : this.getRelayUrls();
|
||||
return urls.map((url) => ({ ...this.ensureRelayState(url) }));
|
||||
}
|
||||
|
||||
async refresh(relayUrls = null) {
|
||||
const urls = Array.isArray(relayUrls) ? relayUrls : this.getRelayUrls();
|
||||
if (!urls.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await Promise.allSettled(urls.map((url) => this.checkRelay(url)));
|
||||
const snapshot = this.getSnapshot(urls);
|
||||
this.emitTelemetry(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
emitTelemetry(snapshot) {
|
||||
if (!this.telemetryEmitter || !this.getTelemetryOptIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sampledAt: Date.now(),
|
||||
relays: Array.isArray(snapshot)
|
||||
? snapshot.map((entry) => ({
|
||||
url: entry.url,
|
||||
connected: Boolean(entry.connected),
|
||||
latencyMs:
|
||||
Number.isFinite(entry.lastLatencyMs) ? entry.lastLatencyMs : null,
|
||||
errorCount: Number.isFinite(entry.errorCount)
|
||||
? entry.errorCount
|
||||
: 0,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
|
||||
try {
|
||||
this.telemetryEmitter("relay_health_snapshot", payload);
|
||||
} catch (error) {
|
||||
this.logger.user.warn(
|
||||
"[relayHealth] Failed to emit relay health telemetry.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default RelayHealthService;
|
||||
@@ -34,6 +34,8 @@ const LEGACY_MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1";
|
||||
const MODERATION_OVERRIDE_STORAGE_VERSION = 2;
|
||||
const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1";
|
||||
const MODERATION_SETTINGS_STORAGE_VERSION = 3;
|
||||
const DM_PRIVACY_SETTINGS_STORAGE_KEY = "bitvid:dmPrivacySettings:v1";
|
||||
const DM_PRIVACY_SETTINGS_STORAGE_VERSION = 1;
|
||||
|
||||
function computeDefaultModerationSettings() {
|
||||
const defaultBlur = sanitizeModerationThreshold(
|
||||
@@ -114,6 +116,22 @@ moderationSettingsStore.set(
|
||||
createDefaultModerationSettings(),
|
||||
);
|
||||
|
||||
function createDefaultDmPrivacySettings() {
|
||||
return {
|
||||
readReceiptsEnabled: false,
|
||||
typingIndicatorsEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
const GLOBAL_DM_PRIVACY_PROFILE_KEY = "__dmPrivacyDefault__";
|
||||
const dmPrivacySettingsStore = createProfileSettingsStore({
|
||||
clone: (value) => ({ ...value }),
|
||||
});
|
||||
dmPrivacySettingsStore.set(
|
||||
GLOBAL_DM_PRIVACY_PROFILE_KEY,
|
||||
createDefaultDmPrivacySettings(),
|
||||
);
|
||||
|
||||
function sanitizeModerationThreshold(value, fallback) {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
@@ -1120,6 +1138,208 @@ function persistModerationSettingsToStorage() {
|
||||
}
|
||||
}
|
||||
|
||||
function getDmPrivacyStoreKeyForPubkey(pubkey) {
|
||||
const normalized = normalizeHexPubkey(pubkey);
|
||||
return normalized || GLOBAL_DM_PRIVACY_PROFILE_KEY;
|
||||
}
|
||||
|
||||
function ensureDmPrivacySettingsForKey(key) {
|
||||
const storeKey = key || GLOBAL_DM_PRIVACY_PROFILE_KEY;
|
||||
if (!dmPrivacySettingsStore.has(storeKey)) {
|
||||
dmPrivacySettingsStore.set(storeKey, createDefaultDmPrivacySettings());
|
||||
}
|
||||
return dmPrivacySettingsStore.get(storeKey);
|
||||
}
|
||||
|
||||
function normalizeDmPrivacySettings(partial = {}) {
|
||||
const defaults = createDefaultDmPrivacySettings();
|
||||
const resolved = partial && typeof partial === "object" ? partial : {};
|
||||
return {
|
||||
readReceiptsEnabled:
|
||||
typeof resolved.readReceiptsEnabled === "boolean"
|
||||
? resolved.readReceiptsEnabled
|
||||
: defaults.readReceiptsEnabled,
|
||||
typingIndicatorsEnabled:
|
||||
typeof resolved.typingIndicatorsEnabled === "boolean"
|
||||
? resolved.typingIndicatorsEnabled
|
||||
: defaults.typingIndicatorsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultDmPrivacySettings() {
|
||||
return createDefaultDmPrivacySettings();
|
||||
}
|
||||
|
||||
export function getDmPrivacySettingsForPubkey(pubkey) {
|
||||
const key = getDmPrivacyStoreKeyForPubkey(pubkey);
|
||||
const settings = ensureDmPrivacySettingsForKey(key);
|
||||
return { ...settings };
|
||||
}
|
||||
|
||||
export function getDmPrivacySettings() {
|
||||
return getDmPrivacySettingsForPubkey(activeProfilePubkey);
|
||||
}
|
||||
|
||||
export function setDmPrivacySettings(partial = {}, options = {}) {
|
||||
const targetPubkey =
|
||||
typeof options?.pubkey === "string" ? options.pubkey : activeProfilePubkey;
|
||||
const key = getDmPrivacyStoreKeyForPubkey(targetPubkey);
|
||||
const existing = ensureDmPrivacySettingsForKey(key);
|
||||
const normalized = normalizeDmPrivacySettings({ ...existing, ...partial });
|
||||
dmPrivacySettingsStore.set(key, normalized);
|
||||
persistDmPrivacySettingsToStorage();
|
||||
return { ...normalized };
|
||||
}
|
||||
|
||||
export function loadDmPrivacySettingsFromStorage() {
|
||||
dmPrivacySettingsStore.clear();
|
||||
dmPrivacySettingsStore.set(
|
||||
GLOBAL_DM_PRIVACY_PROFILE_KEY,
|
||||
createDefaultDmPrivacySettings(),
|
||||
);
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return getDmPrivacySettings();
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(DM_PRIVACY_SETTINGS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return getDmPrivacySettings();
|
||||
}
|
||||
|
||||
let shouldRewrite = false;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(raw);
|
||||
if (!payload || typeof payload !== "object") {
|
||||
shouldRewrite = true;
|
||||
} else if (payload.version === DM_PRIVACY_SETTINGS_STORAGE_VERSION) {
|
||||
const defaults = createDefaultDmPrivacySettings();
|
||||
const entries =
|
||||
payload.entries && typeof payload.entries === "object"
|
||||
? payload.entries
|
||||
: {};
|
||||
|
||||
for (const [key, entry] of Object.entries(entries)) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
shouldRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const overrides =
|
||||
entry.overrides && typeof entry.overrides === "object"
|
||||
? entry.overrides
|
||||
: {};
|
||||
|
||||
const storeKey =
|
||||
key === "default"
|
||||
? GLOBAL_DM_PRIVACY_PROFILE_KEY
|
||||
: getDmPrivacyStoreKeyForPubkey(key);
|
||||
|
||||
if (!storeKey) {
|
||||
shouldRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const settings = {
|
||||
readReceiptsEnabled:
|
||||
typeof overrides.readReceiptsEnabled === "boolean"
|
||||
? overrides.readReceiptsEnabled
|
||||
: defaults.readReceiptsEnabled,
|
||||
typingIndicatorsEnabled:
|
||||
typeof overrides.typingIndicatorsEnabled === "boolean"
|
||||
? overrides.typingIndicatorsEnabled
|
||||
: defaults.typingIndicatorsEnabled,
|
||||
};
|
||||
|
||||
dmPrivacySettingsStore.set(storeKey, settings);
|
||||
}
|
||||
} else {
|
||||
shouldRewrite = true;
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[cache.loadDmPrivacySettingsFromStorage] Failed to parse payload:",
|
||||
error,
|
||||
);
|
||||
shouldRewrite = true;
|
||||
}
|
||||
|
||||
if (shouldRewrite) {
|
||||
persistDmPrivacySettingsToStorage();
|
||||
}
|
||||
|
||||
return getDmPrivacySettings();
|
||||
}
|
||||
|
||||
function persistDmPrivacySettingsToStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaults = createDefaultDmPrivacySettings();
|
||||
const entries = {};
|
||||
|
||||
for (const [key, value] of dmPrivacySettingsStore.entries()) {
|
||||
const settings =
|
||||
value && typeof value === "object"
|
||||
? value
|
||||
: createDefaultDmPrivacySettings();
|
||||
|
||||
const overrides = {};
|
||||
|
||||
if (settings.readReceiptsEnabled !== defaults.readReceiptsEnabled) {
|
||||
overrides.readReceiptsEnabled = settings.readReceiptsEnabled;
|
||||
}
|
||||
|
||||
if (settings.typingIndicatorsEnabled !== defaults.typingIndicatorsEnabled) {
|
||||
overrides.typingIndicatorsEnabled = settings.typingIndicatorsEnabled;
|
||||
}
|
||||
|
||||
if (Object.keys(overrides).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const storageKey =
|
||||
key === GLOBAL_DM_PRIVACY_PROFILE_KEY ? "default" : key;
|
||||
|
||||
entries[storageKey] = {
|
||||
overrides,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(entries).length === 0) {
|
||||
try {
|
||||
localStorage.removeItem(DM_PRIVACY_SETTINGS_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[cache.persistDmPrivacySettingsToStorage] Failed to clear overrides:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: DM_PRIVACY_SETTINGS_STORAGE_VERSION,
|
||||
entries,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
DM_PRIVACY_SETTINGS_STORAGE_KEY,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[cache.persistDmPrivacySettingsToStorage] Failed to persist overrides:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function setModerationSettings(partial = {}, { persist = true } = {}) {
|
||||
const key = getActiveModerationStoreKey();
|
||||
const defaults = createDefaultModerationSettings();
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
const DB_NAME = "bitvidDm";
|
||||
const DB_VERSION = 2;
|
||||
|
||||
const STORES = Object.freeze({
|
||||
CONVERSATIONS: "conversations",
|
||||
MESSAGES: "messages",
|
||||
CONTACTS: "contacts",
|
||||
RELAYS: "relays",
|
||||
SEEN_CACHE: "seen_cache",
|
||||
});
|
||||
|
||||
const MIGRATIONS = [
|
||||
{
|
||||
version: 1,
|
||||
migrate: (db) => {
|
||||
if (!db.objectStoreNames.contains(STORES.CONVERSATIONS)) {
|
||||
const store = db.createObjectStore(STORES.CONVERSATIONS, {
|
||||
keyPath: "conversation_id",
|
||||
});
|
||||
store.createIndex("created_at", "created_at", { unique: false });
|
||||
store.createIndex("unseen_count", "unseen_count", { unique: false });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(STORES.MESSAGES)) {
|
||||
const store = db.createObjectStore(STORES.MESSAGES, { keyPath: "id" });
|
||||
store.createIndex("conversation_id", "conversation_id", {
|
||||
unique: false,
|
||||
});
|
||||
store.createIndex("created_at", "created_at", { unique: false });
|
||||
store.createIndex("sender_pubkey", "sender_pubkey", {
|
||||
unique: false,
|
||||
});
|
||||
store.createIndex("conversation_created_at", [
|
||||
"conversation_id",
|
||||
"created_at",
|
||||
]);
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(STORES.CONTACTS)) {
|
||||
db.createObjectStore(STORES.CONTACTS, { keyPath: "pubkey" });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(STORES.RELAYS)) {
|
||||
db.createObjectStore(STORES.RELAYS, { keyPath: "url" });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(STORES.SEEN_CACHE)) {
|
||||
const store = db.createObjectStore(STORES.SEEN_CACHE, {
|
||||
keyPath: "id",
|
||||
});
|
||||
store.createIndex("conversation_id", "conversation_id", {
|
||||
unique: false,
|
||||
});
|
||||
store.createIndex("created_at", "created_at", { unique: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
migrate: (db, transaction) => {
|
||||
if (!db.objectStoreNames.contains(STORES.CONVERSATIONS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If transaction is provided (during upgrade), use it.
|
||||
// Otherwise create a new one (but this path is questionable during upgrade).
|
||||
const store = transaction
|
||||
? transaction.objectStore(STORES.CONVERSATIONS)
|
||||
: db.createObjectStore(STORES.CONVERSATIONS, { keyPath: "conversation_id" });
|
||||
// Fallback: If store doesn't exist and no transaction provided (unlikely in upgrade), create it?
|
||||
// Actually, if transaction is missing in migration, we are in trouble.
|
||||
// But for safe-guarding against "store not found":
|
||||
if (!store.indexNames.contains("opened_until")) {
|
||||
store.createIndex("opened_until", "opened_until", { unique: false });
|
||||
}
|
||||
if (!store.indexNames.contains("downloaded_until")) {
|
||||
store.createIndex("downloaded_until", "downloaded_until", {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function isIndexedDbAvailable() {
|
||||
try {
|
||||
return typeof indexedDB !== "undefined";
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyMigrations(db, oldVersion, newVersion, transaction) {
|
||||
for (const migration of MIGRATIONS) {
|
||||
if (migration.version > oldVersion && migration.version <= newVersion) {
|
||||
migration.migrate(db, transaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openDmDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isIndexedDbAvailable()) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = request.result;
|
||||
const oldVersion = event.oldVersion ?? 0;
|
||||
const newVersion = event.newVersion ?? DB_VERSION;
|
||||
const upgradeTransaction =
|
||||
request.transaction || event.target?.transaction || null;
|
||||
applyMigrations(db, oldVersion, newVersion, upgradeTransaction);
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () =>
|
||||
reject(request.error || new Error("Failed to open DM database."));
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeTimestamp(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
function normalizePubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return "";
|
||||
}
|
||||
return pubkey.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeMessage(raw) {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = typeof raw.id === "string" ? raw.id.trim() : "";
|
||||
const conversationId =
|
||||
typeof raw.conversation_id === "string"
|
||||
? raw.conversation_id.trim()
|
||||
: typeof raw.conversationId === "string"
|
||||
? raw.conversationId.trim()
|
||||
: "";
|
||||
const senderPubkey = normalizePubkey(
|
||||
raw.sender_pubkey ?? raw.senderPubkey ?? raw.pubkey,
|
||||
);
|
||||
|
||||
if (!id || !conversationId || !senderPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const receiverPubkey = normalizePubkey(
|
||||
raw.receiver_pubkey ?? raw.receiverPubkey ?? raw.to_pubkey,
|
||||
);
|
||||
const statusRaw = typeof raw.status === "string" ? raw.status.trim() : "";
|
||||
const status =
|
||||
statusRaw === "pending" || statusRaw === "published" || statusRaw === "failed"
|
||||
? statusRaw
|
||||
: "";
|
||||
const seen = raw.seen === true;
|
||||
const encryptionScheme =
|
||||
typeof raw.encryption_scheme === "string"
|
||||
? raw.encryption_scheme.trim()
|
||||
: typeof raw.encryptionScheme === "string"
|
||||
? raw.encryptionScheme.trim()
|
||||
: typeof raw.scheme === "string"
|
||||
? raw.scheme.trim()
|
||||
: "";
|
||||
|
||||
return {
|
||||
id,
|
||||
conversation_id: conversationId,
|
||||
sender_pubkey: senderPubkey,
|
||||
receiver_pubkey: receiverPubkey,
|
||||
created_at: sanitizeTimestamp(raw.created_at ?? raw.createdAt),
|
||||
kind: Number.isFinite(Number(raw.kind)) ? Number(raw.kind) : 0,
|
||||
content: typeof raw.content === "string" ? raw.content : "",
|
||||
tags: Array.isArray(raw.tags) ? raw.tags : [],
|
||||
relay: typeof raw.relay === "string" ? raw.relay : "",
|
||||
status,
|
||||
seen,
|
||||
encryption_scheme: encryptionScheme,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConversation(raw) {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const conversationId =
|
||||
typeof raw.conversation_id === "string"
|
||||
? raw.conversation_id.trim()
|
||||
: typeof raw.conversationId === "string"
|
||||
? raw.conversationId.trim()
|
||||
: "";
|
||||
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
conversation_id: conversationId,
|
||||
created_at: sanitizeTimestamp(raw.created_at ?? raw.createdAt),
|
||||
last_message_at: sanitizeTimestamp(
|
||||
raw.last_message_at ?? raw.lastMessageAt,
|
||||
),
|
||||
downloaded_until: sanitizeTimestamp(
|
||||
raw.downloaded_until ?? raw.downloadedUntil,
|
||||
),
|
||||
opened_until: sanitizeTimestamp(raw.opened_until ?? raw.openedUntil),
|
||||
last_message_preview:
|
||||
typeof raw.last_message_preview === "string"
|
||||
? raw.last_message_preview
|
||||
: typeof raw.lastMessagePreview === "string"
|
||||
? raw.lastMessagePreview
|
||||
: "",
|
||||
unseen_count: Number.isFinite(Number(raw.unseen_count))
|
||||
? Number(raw.unseen_count)
|
||||
: Number.isFinite(Number(raw.unseenCount))
|
||||
? Number(raw.unseenCount)
|
||||
: 0,
|
||||
participants: Array.isArray(raw.participants) ? raw.participants : [],
|
||||
};
|
||||
}
|
||||
|
||||
function runRequest(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeTransaction(tx) {
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error || new Error("Transaction aborted"));
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeConversationMetadata(conversation) {
|
||||
const normalized = normalizeConversation(conversation);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.CONVERSATIONS, "readwrite");
|
||||
tx.objectStore(STORES.CONVERSATIONS).put(normalized);
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to write conversation metadata", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeMessages(messages) {
|
||||
const list = Array.isArray(messages) ? messages : [messages];
|
||||
const normalized = list
|
||||
.map((message) => normalizeMessage(message))
|
||||
.filter(Boolean);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.MESSAGES, "readwrite");
|
||||
const store = tx.objectStore(STORES.MESSAGES);
|
||||
|
||||
for (const message of normalized) {
|
||||
store.put(message);
|
||||
}
|
||||
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to persist messages", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConversation(conversationId) {
|
||||
if (typeof conversationId !== "string" || !conversationId.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.CONVERSATIONS, "readonly");
|
||||
const store = tx.objectStore(STORES.CONVERSATIONS);
|
||||
const result = await runRequest(store.get(conversationId.trim()));
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to read conversation metadata", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMessagesByConversation(conversationId, options = {}) {
|
||||
if (typeof conversationId !== "string" || !conversationId.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const limit =
|
||||
Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
|
||||
? Number(options.limit)
|
||||
: 50;
|
||||
const direction = options.direction === "asc" ? "next" : "prev";
|
||||
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.MESSAGES, "readonly");
|
||||
const store = tx.objectStore(STORES.MESSAGES);
|
||||
const index = store.index("conversation_created_at");
|
||||
const range = IDBKeyRange.bound(
|
||||
[conversationId.trim(), 0],
|
||||
[conversationId.trim(), Number.MAX_SAFE_INTEGER],
|
||||
);
|
||||
|
||||
const results = [];
|
||||
const request = index.openCursor(range, direction);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor || results.length >= limit) {
|
||||
resolve(results);
|
||||
return;
|
||||
}
|
||||
results.push(cursor.value);
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => {
|
||||
userLogger.warn("[dmDb] Failed to list messages", request.error);
|
||||
resolve(results);
|
||||
};
|
||||
}).finally(() => db.close());
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to list messages", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConversationFromMessage(message, options = {}) {
|
||||
const normalized = normalizeMessage(message);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preview =
|
||||
typeof options.preview === "string" && options.preview.trim()
|
||||
? options.preview.trim()
|
||||
: normalized.content.slice(0, 160);
|
||||
const unseenDelta =
|
||||
Number.isFinite(Number(options.unseenDelta))
|
||||
? Number(options.unseenDelta)
|
||||
: 0;
|
||||
const downloadedUntil = sanitizeTimestamp(
|
||||
options.downloadedUntil ?? normalized.created_at,
|
||||
);
|
||||
const openedUntil = sanitizeTimestamp(options.openedUntil);
|
||||
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.CONVERSATIONS, "readwrite");
|
||||
const store = tx.objectStore(STORES.CONVERSATIONS);
|
||||
const existing = (await runRequest(
|
||||
store.get(normalized.conversation_id),
|
||||
)) || {
|
||||
conversation_id: normalized.conversation_id,
|
||||
created_at: normalized.created_at,
|
||||
unseen_count: 0,
|
||||
};
|
||||
|
||||
const next = {
|
||||
...existing,
|
||||
created_at: existing.created_at || normalized.created_at,
|
||||
last_message_at: normalized.created_at,
|
||||
downloaded_until: Math.max(
|
||||
existing.downloaded_until || 0,
|
||||
downloadedUntil,
|
||||
),
|
||||
opened_until: Math.max(existing.opened_until || 0, openedUntil),
|
||||
last_message_preview: preview,
|
||||
unseen_count: Math.max(
|
||||
0,
|
||||
Number(existing.unseen_count || 0) + unseenDelta,
|
||||
),
|
||||
};
|
||||
|
||||
store.put(next);
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
return next;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to update conversation metadata", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConversationOpenState(
|
||||
conversationId,
|
||||
{ openedUntil, unseenCount } = {},
|
||||
) {
|
||||
if (typeof conversationId !== "string" || !conversationId.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedId = conversationId.trim();
|
||||
const nextOpenedUntil = sanitizeTimestamp(openedUntil);
|
||||
const nextUnseenCount = Number.isFinite(Number(unseenCount))
|
||||
? Math.max(0, Number(unseenCount))
|
||||
: null;
|
||||
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.CONVERSATIONS, "readwrite");
|
||||
const store = tx.objectStore(STORES.CONVERSATIONS);
|
||||
const existing =
|
||||
(await runRequest(store.get(normalizedId))) ||
|
||||
normalizeConversation({ conversation_id: normalizedId });
|
||||
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next = {
|
||||
...existing,
|
||||
opened_until: Math.max(existing.opened_until || 0, nextOpenedUntil),
|
||||
unseen_count:
|
||||
nextUnseenCount === null
|
||||
? existing.unseen_count
|
||||
: nextUnseenCount,
|
||||
};
|
||||
|
||||
store.put(next);
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
return next;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to update conversation open state", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listConversations() {
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORES.CONVERSATIONS, "readonly");
|
||||
const store = tx.objectStore(STORES.CONVERSATIONS);
|
||||
const results = [];
|
||||
const request = store.openCursor();
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve(results);
|
||||
return;
|
||||
}
|
||||
results.push(cursor.value);
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => {
|
||||
userLogger.warn("[dmDb] Failed to list conversations", request.error);
|
||||
resolve(results);
|
||||
};
|
||||
}).finally(() => db.close());
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to list conversations", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function describeDmDbSchema() {
|
||||
return {
|
||||
name: DB_NAME,
|
||||
version: DB_VERSION,
|
||||
stores: STORES,
|
||||
migrations: MIGRATIONS.map((migration) => migration.version),
|
||||
};
|
||||
}
|
||||
|
||||
export async function clearDmDb() {
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
if (!db) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tx = db.transaction(
|
||||
[
|
||||
STORES.CONVERSATIONS,
|
||||
STORES.MESSAGES,
|
||||
STORES.CONTACTS,
|
||||
STORES.RELAYS,
|
||||
STORES.SEEN_CACHE,
|
||||
],
|
||||
"readwrite",
|
||||
);
|
||||
|
||||
for (const storeName of tx.objectStoreNames) {
|
||||
tx.objectStore(storeName).clear();
|
||||
}
|
||||
|
||||
await finalizeTransaction(tx);
|
||||
db.close();
|
||||
return true;
|
||||
} catch (error) {
|
||||
devLogger.warn("[dmDb] Failed to clear DM database", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const dmDbStores = STORES;
|
||||
export const dmDbVersion = DB_VERSION;
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
+1745
-24
File diff suppressed because it is too large.
Load diff
+11
-16
@@ -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" });
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
|
||||
@@ -64,6 +64,7 @@ const wrapper = (input) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Deterministic execution: input fully defines execution path.
|
||||
normalizeAndAugmentMagnet(rawValue, options);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 |
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
Reference in new issue
Block a user