diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47b92474..35ae9828 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 5de0cf26..109cf05d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/components/profile-modal.html b/components/profile-modal.html index 5b19d656..b10ac6e2 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -443,6 +443,26 @@ > + +
+
+

Link previews

+

+ Control when bitvid fetches link preview metadata. Disable auto-fetch to review new domains before previews load. +

+
+ +
About @@ -644,6 +664,44 @@ +
+
+
+

Relay health

+

+ Monitor connectivity, latency, and errors for your configured relays. +

+
+ +
+ +

+ Telemetry is opt-in and only includes relay URLs, latency, and error counts. +

+ +

+
-

- View encrypted conversations sent with NIP-04 or NIP-44. bitvid keeps message data on this device only. -

-
-
-
-

Direct message actions

-

- Start a new DM or jump to relay preferences before you send. -

-
- -
-
-
-

Preferred DM relay

-

- Select a relay to prioritize encrypted message delivery. -

-
- -
-
-
-

Privacy mode (coming soon)

-

- Pause inbound DM sync and clear cached previews on this device. -

-
- -
-
-
- - - -
- -
- -
- - -
- - -
-
+
No description available.

+
.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;display:flex;width:100%;min-width:0;align-items:flex-start;gap:.75rem;border-radius:var(--radius-xl);padding:.5rem;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)}.player-modal__similar-card:focus-within,.player-modal__similar-card:hover{background-color:var(--surface-panel-hover)}.player-modal__similar-card-media{position:relative;display:block;flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg);background-color:var(--surface-alt);width:var(--similar-card-media-width);aspect-ratio:16/9;background-image:var(--similar-card-thumb-url,none);background-position:50%;background-size:cover}@media (max-width:640px){.player-modal__similar-card-media{width:var(--similar-card-media-width-sm)}}.player-modal__similar-card-media img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover;border-radius:inherit}#playerModal.player-modal__similar-card-content{display:block}.player-modal__similar-card-content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:.25rem}.player-modal__similar-card-title{font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;line-height:1.375;color:var(--text-primary);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;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);min-width:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.player-modal__similar-card-title:focus-visible,.player-modal__similar-card:hover .player-modal__similar-card-title{color:var(--color-accent-strong)}#playerModal.player-modal__similar-card-author{display:block}.player-modal__similar-card-author{display:flex;min-width:0;align-items:center}#playerModal.player-modal__similar-card-author-meta{display:block}.player-modal__similar-card-author-meta{display:flex;min-width:0;flex-direction:column}.player-modal__similar-card-author-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:500;color:var(--text-muted);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;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)}.player-modal__similar-card:hover .player-modal__similar-card-author-name{color:var(--color-text-strong)}.player-modal__similar-card-author-npub{display:none}#playerModal.player-modal__similar-card-meta{display:block}.player-modal__similar-card-meta{display:flex;flex-wrap:wrap;align-items:center;gap:.25rem;font-size:var(--font-size-2xs);line-height:var(--line-height-tight);color:var(--text-muted-strong);min-width:0}.player-modal__similar-card-timestamp{white-space:nowrap}.video-modal__stage{position:relative;isolation:isolate;overflow:visible;--video-modal-ambient-bleed:clamp(var(--space-2xl),12vw,var(--space-5xl));--video-modal-ambient-scale:1.38;--video-modal-ambient-blur:clamp(var(--video-modal-ambient-blur-min),20vw,var(--video-modal-ambient-blur-max));--video-modal-ambient-opacity:1}.video-modal__video{position:relative;z-index:1;border-radius:var(--radius-xl);padding-block:clamp(var(--space-xs),2.5vw,var(--space-md-plus));background-color:transparent;box-shadow:0 0 0 var(--border-width-hairline) color-mix(in srgb,var(--color-border-strong) 8%,transparent) inset;overflow:visible}.video-modal__ambient{position:absolute;inset:calc(var(--video-modal-ambient-bleed)*-1);border-radius:calc(var(--radius-3xl) + var(--video-modal-ambient-bleed));pointer-events:none;z-index:0;display:block;overflow:visible}.video-modal__ambient:before{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--color-overlay-strong) 18%,transparent) 0,color-mix(in srgb,var(--color-overlay) 34%,transparent) 62%,transparent 100%);opacity:.9;mix-blend-mode:screen;pointer-events:none}.video-modal__ambient-canvas{position:relative;width:100%;height:100%;display:block;transform:scale(var(--video-modal-ambient-scale));filter:blur(var(--video-modal-ambient-blur)) saturate(2) brightness(1.24);opacity:var(--video-modal-ambient-opacity)}@media (min-width:1024px){.video-modal__video{position:sticky;top:calc(var(--space-2xl) + var(--space-3xl));z-index:1}.video-modal__status{flex-basis:clamp(var(--space-xl),25%,var(--space-3xl));max-width:clamp(var(--space-xl),25%,var(--space-3xl))}.video-modal__details{position:relative;z-index:2;margin-top:var(--space-lg)}}#playerModal .video-modal__video video{position:relative;z-index:1;background-color:var(--color-black);border-radius:var(--radius-lg);box-shadow:var(--shadow-video-modal);max-height:min(80vh,56.25vw);-o-object-fit:contain;object-fit:contain}.video-modal__media{display:block;aspect-ratio:16/9;width:100%;-o-object-fit:contain;object-fit:contain}#playerModal.video-modal__details{display:block}.video-modal__details{display:flex;flex-direction:column;gap:1.5rem}.video-modal__meta{border-bottom-width:1px;border-color:var(--border-default);--tw-border-opacity:0.2;padding-bottom:1rem}#playerModal.video-modal__tags{display:block}.video-modal__tags{margin-left:-.25rem;margin-right:-.25rem;display:flex;gap:.5rem;padding-left:.25rem;padding-right:.25rem}.video-modal__similar .video-modal__tags{margin-bottom:clamp(var(--space-xs),.9vw,var(--space-md))}.video-modal__tags[hidden]{display:none}.video-modal__tags[data-fade]{position:relative}.video-modal__tags[data-fade]:after,.video-modal__tags[data-fade]:before{content:"";position:sticky;top:0;bottom:0;width:var(--space-lg);pointer-events:none;z-index:1}.video-modal__tags[data-fade]:before{left:0;background:linear-gradient(to right,var(--surface-overlay-muted),transparent)}.video-modal__tags[data-fade]:after{right:0;background:linear-gradient(to left,var(--surface-overlay-muted),transparent)}.video-tag-strip{display:flex;flex-wrap:nowrap;align-items:center;gap:var(--space-xs);overflow:hidden}.video-tag-pill{display:inline-flex;align-items:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);padding:.25rem .75rem;font-size:var(--font-size-2xs);line-height:var(--line-height-tight);font-weight:500;color:var(--status-neutral-on);position:relative;justify-content:center;white-space:nowrap;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;min-height:calc(var(--space-sm) + var(--space-xs));margin-inline-end:.25rem}.video-tag-pill__label{transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);opacity:1}#playerModal.video-tag-pill__icon{display:block}.video-tag-pill__icon{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);opacity:0;transform:scale(.8)}.video-tag-pill__icon svg{height:.75rem;width:.75rem}.video-tag-pill:focus-visible .video-tag-pill__label,.video-tag-pill:hover .video-tag-pill__label{opacity:0}.video-tag-pill:focus-visible .video-tag-pill__icon,.video-tag-pill:hover .video-tag-pill__icon{opacity:1;transform:scale(1)}#playerModal.video-modal__actions-row{display:block}.video-modal__actions-row{display:flex;width:100%;flex-wrap:wrap;align-items:flex-start;-moz-column-gap:.75rem;column-gap:.75rem;row-gap:.75rem}@media (min-width:640px){.video-modal__actions-row{-moz-column-gap:1rem;column-gap:1rem}}.video-modal__actions-group,.video-modal__reactions{flex:1 1 var(--menu-min-width);min-inline-size:0}#playerModal.video-modal__reactions{display:block}.video-modal__reactions{display:flex;flex-basis:100%;flex-wrap:wrap;align-items:flex-start;gap:.75rem}@media (min-width:640px){.video-modal__reactions{gap:1rem}}@media (min-width:768px){.video-modal__reactions{flex-basis:auto}}.video-modal__reaction-buttons{display:inline-flex;flex-wrap:wrap;align-items:center;gap:.5rem}.video-modal__reaction-button{--video-modal-reaction-active-bg:color-mix(in srgb,var(--color-accent) 22%,var(--surface-overlay-panel));--video-modal-reaction-active-border:color-mix(in srgb,var(--color-accent) 38%,var(--border-overlay))}.video-modal__reaction-button[aria-pressed=true]{color:var(--color-accent-strong);background-color:var(--video-modal-reaction-active-bg);border-color:var(--video-modal-reaction-active-border)}.video-modal__reaction-button[aria-pressed=true] .video-modal__reaction-count{color:var(--color-accent-strong)}.video-modal__reaction-count{font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;color:var(--text-muted)}.video-modal__reaction-meter{display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap;flex:1 1 var(--menu-min-width);max-inline-size:100%}.video-modal__reaction-meter-label{font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:500;color:var(--text-muted);min-inline-size:var(--video-modal-reaction-label-min-inline)}.video-modal__reaction-meter-track{position:relative;flex:1 1 auto;inline-size:100%;block-size:var(--space-xs);border-radius:var(--radius-full);background-color:var(--color-border-muted);overflow:hidden}.video-modal__reaction-meter-fill{position:absolute;inset-inline-start:0;inset-block-start:0;block-size:100%;max-inline-size:100%;inline-size:var(--video-modal-reaction-fill,0);width:var(--video-modal-reaction-fill,0);background-image:linear-gradient(90deg,var(--color-accent) 0,var(--color-accent-strong) 100%);transition:inline-size var(--motion-duration-base) var(--motion-ease-out)}.video-modal__reaction-meter-fill--empty{inline-size:0;width:0}#playerModal.video-modal__actions-group{display:block}.video-modal__actions-group{display:flex;flex-basis:100%;flex-wrap:wrap;align-items:flex-start;justify-content:flex-start;gap:.75rem}@media (min-width:768px){.video-modal__actions-group{flex-basis:auto;justify-content:flex-end;margin-inline-start:auto}}.video-modal__action-button{display:inline-flex;height:2.75rem;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;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);min-width:var(--video-modal-action-min-width);padding-inline:var(--space-md);border-width:var(--border-width-hairline);border-style:solid}.bv-modal.video-modal__action-label,.profile-pane.video-modal__action-label,.video-modal__action-label{display:none}@media (min-width:640px){.video-modal__action-label{display:inline}}.video-modal__action-icon{height:1.25rem;width:1.25rem}.video-modal__channel,.video-modal__description,.video-modal__stats{border-radius:1rem;border-width:1px;border-color:var(--border-default);--tw-border-opacity:0.2;background-color:var(--surface-base);padding:1rem 1.25rem;--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.video-modal__description-body{overflow-wrap:anywhere;word-break:break-word}.video-modal__description-body a{color:var(--color-accent-strong);text-decoration:underline;text-decoration-color:currentColor;word-break:break-word}.video-modal__description-body a:focus-visible,.video-modal__description-body a:hover{color:var(--color-accent-pressed)}.video-modal__comments{border-radius:1rem;border-width:1px;border-color:var(--border-default);--tw-border-opacity:0.2;background-color:var(--surface-base);padding:1.25rem;--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.comment-thread,.video-modal__comments{display:flex;flex-direction:column;gap:var(--space-lg)}.comment-thread__header{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-sm)}.comment-thread__title{font-size:var(--font-size-lg);line-height:var(--line-height-base);font-weight:600;color:var(--text-primary)}.comment-thread__count,.comment-thread__empty{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted);margin:0}.comment-thread__empty{padding:var(--space-md);border-radius:var(--radius-lg);border:var(--border-width-hairline) dashed color-mix(in srgb,var(--border-default) 40%,transparent);background-color:var(--surface-alt)}.comment-thread__list{display:flex;flex-direction:column;gap:var(--space-md);margin:0;padding:0;list-style:none}.comment-thread__item{display:grid;grid-template-columns:auto 1fr;gap:var(--space-md);padding-block:var(--space-sm);border-bottom:var(--border-width-hairline) solid var(--border-subtle)}.comment-thread__item:last-child{border-bottom:none;padding-bottom:0}.comment-thread__avatar{inline-size:var(--comment-avatar-size);block-size:var(--comment-avatar-size)}.comment-thread__avatar img{border-radius:var(--radius-full);-o-object-fit:cover;object-fit:cover;inline-size:100%;block-size:100%}.comment-thread__content{display:flex;flex-direction:column;gap:var(--space-xs);min-inline-size:0}.comment-thread__meta{font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted);display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-xs)}.comment-thread__author{font-weight:600;color:var(--text-primary)}.comment-thread__timestamp{color:var(--text-muted)}.comment-thread__body{color:var(--text-primary);line-height:1.6;word-break:break-word}.comment-thread__actions{font-size:var(--font-size-xs);line-height:var(--line-height-tight);display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-sm)}.comment-thread__actions button{outline:2px solid transparent;outline-offset:2px}.comment-thread__actions button: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)}.comment-thread__actions button{display:inline-flex;align-items:center;gap:.25rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-base);padding:.25rem .75rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:600;color:var(--text-muted);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)}.comment-thread__actions button:focus-visible,.comment-thread__actions button:hover{color:var(--text-primary);border-color:var(--border-default)}.comment-thread__replies{display:flex;flex-direction:column;gap:var(--space-md);margin:0;margin-inline-start:calc(var(--space-lg) + var(--space-sm));padding-inline-start:var(--space-md);padding-block-start:var(--space-md);border-inline-start:var(--border-width-hairline) solid var(--border-subtle);list-style:none}.comment-thread__load-more{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-base);padding:.5rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;color:var(--text-primary);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)}.comment-thread__load-more:focus-visible,.comment-thread__load-more:hover{border-color:var(--border-default);color:var(--text-primary);background-color:color-mix(in srgb,var(--surface-alt) 70%,transparent)}.comment-thread__disabled{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted);border-radius:var(--radius-lg);border:var(--border-width-hairline) dashed color-mix(in srgb,var(--border-default) 32%,transparent);background-color:var(--surface-alt);padding:var(--space-md)}.comment-composer{display:flex;flex-direction:column;gap:var(--space-md);border-top:var(--border-width-hairline) solid var(--border-subtle);padding-top:var(--space-lg)}.comment-composer__field{display:flex;flex-direction:column;gap:var(--space-2xs)}.comment-composer__label{font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;color:var(--text-primary)}.comment-composer__textarea{min-block-size:var(--comment-composer-min-height)}.comment-composer__footer{display:flex;flex-direction:column;gap:var(--space-sm)}.comment-composer__hint{font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted);margin:0}.comment-composer__actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:var(--space-sm)}.comment-composer__char-count{font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:500;color:var(--text-muted)}.comment-composer__submit{outline:2px solid transparent;outline-offset:2px}.comment-composer__submit: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)}.comment-composer__status{margin:0}@media (min-width:640px){.comment-composer__footer{flex-direction:row;align-items:center;justify-content:space-between}}.video-modal__progress{height:.5rem;width:100%;overflow:hidden;border-radius:var(--radius-full);background-color:var(--border-subtle)}#playerModal.video-modal__stat-metrics{display:block}.video-modal__stat-metrics{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:.75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted)}.profile-modal{display:flex;flex-direction:column;inline-size:100%;block-size:100%;min-block-size:100%;max-block-size:none;overflow:hidden;--profile-modal-scale:0.9;--profile-modal-gap:calc((var(--space-md) - var(--space-3xs))*var(--profile-modal-scale));--profile-modal-padding-inline:calc((var(--space-lg) - var(--space-md))*var(--profile-modal-scale));--profile-modal-padding-block-start:calc((var(--space-md) - var(--space-2xs))*var(--profile-modal-scale));--profile-modal-padding-block-end:calc((var(--space-lg) - var(--space-sm))*var(--profile-modal-scale));--profile-modal-nav-gap:calc(var(--space-xs)*var(--profile-modal-scale))}.profile-modal__content{display:flex;flex:1 1 auto;flex-direction:column;block-size:100%;min-block-size:0;transform:scale(var(--profile-modal-scale));transform-origin:top center}.profile-modal__active-profile{outline:2px solid transparent;outline-offset:2px}.profile-modal__active-profile: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.profile-modal__active-profile{display:block}.profile-modal__active-profile{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding:1rem}.profile-modal__active-details{display:flex;flex:1 1 var(--profile-modal-active-min-width);flex-direction:column;gap:var(--space-2xs);min-inline-size:0}.profile-modal__active-meta{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-xs)}.profile-hashtag-list{display:flex;flex-wrap:wrap;gap:var(--space-xs);margin:0;padding:0;list-style:none}.profile-hashtag-item{display:inline-flex;align-items:center;gap:var(--space-3xs);padding-block:var(--space-3xs);padding-inline:var(--space-sm);border-radius:var(--radius-full);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-panel);color:var(--color-text);font-size:var(--font-size-sm);font-weight:var(--font-weight-medium);line-height:var(--font-lineheight-tight)}.profile-hashtag-remove{display:inline-flex;align-items:center;justify-content:center;inline-size:var(--space-md);block-size:var(--space-md);border-radius:var(--radius-full);color:var(--color-text-muted);background:transparent;border:none;padding:0;cursor:pointer}.profile-hashtag-remove:hover{color:var(--color-text)}.profile-modal__remove-button{border-width:2px}.profile-modal__remove-button:focus-visible{--tw-ring-color:var(--color-critical-strong)}.profile-modal__add-profile{--tw-ring-color:var(--color-ring-alert);outline:2px solid transparent;outline-offset:2px}.profile-modal__add-profile: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.profile-modal__add-profile{display:block}.profile-modal__add-profile{position:relative;display:flex;width:100%;align-items:center;justify-content:flex-start;gap:1rem;border-radius:var(--radius-xl);padding:1rem 1.25rem;text-align:left;color:var(--text-primary);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);border:var(--outline-thick-width) solid var(--color-ring-alert);background-color:var(--surface-panel-hover)}.profile-modal__add-profile:hover{background-color:color-mix(in srgb,var(--surface-panel-hover) 88%,var(--color-ring-alert) 12%)}.profile-modal__add-profile:focus-visible{background-color:color-mix(in srgb,var(--surface-panel-hover) 80%,var(--color-ring-alert) 20%)}#playerModal.profile-modal__add-profile-icon{display:block}.profile-modal__add-profile-icon{display:flex;height:2.5rem;width:2.5rem;align-items:center;justify-content:center;border-radius:var(--radius-full);background-color:color-mix(in srgb,var(--color-ring-alert) 18%,var(--surface-panel));color:var(--color-ring-alert)}.profile-modal__body{overflow:hidden;overflow-x:hidden;padding-block-start:var(--profile-modal-padding-block-start);padding-block-end:var(--profile-modal-padding-block-end);padding-inline:var(--profile-modal-padding-inline)}.profile-modal__body,.profile-modal__layout{display:flex;flex:1 1 auto;flex-direction:column;gap:var(--profile-modal-gap);inline-size:100%;min-block-size:0}.profile-modal__header{align-items:center}.profile-modal__header-title{display:flex;align-items:center;gap:var(--space-sm)}.profile-modal__menu{display:flex;flex-direction:column;gap:var(--space-sm)}.profile-modal__pane-wrapper{display:flex;flex:1 1 auto;flex-direction:column;min-block-size:0}.profile-modal__nav{display:flex;flex-wrap:wrap;gap:var(--profile-modal-nav-gap);align-self:flex-start;overflow-x:hidden}.profile-modal__panes{flex:1 1 auto;block-size:100%;min-block-size:0;overflow-y:auto;padding-inline-end:var(--profile-modal-scrollbar-offset);scrollbar-gutter:stable}.profile-modal__panes,.profile-pane{display:flex;flex-direction:column;overflow-x:hidden}.profile-pane{min-block-size:auto}.profile-pane.hidden{display:none!important}@supports not (scrollbar-gutter:stable){.profile-modal__panes{padding-inline-end:calc(var(--profile-modal-scrollbar-offset) + var(--space-2xs))}}@media (max-width:calc(1024px - var(--breakpoint-edge-offset ))){.profile-modal__layout[data-mobile-view=menu] .profile-modal__pane-wrapper,.profile-modal__layout[data-mobile-view=pane] .profile-modal__menu{display:none}}@media (min-width:1024px){.profile-modal{max-block-size:var(--modal-shell-max-height)}.profile-modal__body{padding-inline:calc(var(--profile-modal-padding-inline) + var(--space-xs))}.profile-modal__body,.profile-modal__layout{gap:calc(var(--profile-modal-gap) + var(--space-2xs))}.profile-modal__layout{display:grid;align-items:stretch;block-size:100%;grid-template-columns:minmax(var(--profile-modal-nav-min-width),var(--profile-modal-nav-max-width)) minmax(0,1fr);grid-auto-rows:minmax(0,1fr)}.profile-modal__nav{flex-direction:column;flex-wrap:nowrap;gap:var(--profile-modal-nav-gap);justify-self:start;inline-size:var(--profile-modal-nav-max-width);min-inline-size:var(--profile-modal-nav-min-width)}.profile-modal__pane-wrapper{block-size:100%}}.beacon-hidden-download,.torrent-download-anchor,[data-beacon-hidden-download]{display:none!important}.beacon-clipboard-offscreen,.torrent-clipboard-textarea,[data-beacon-clipboard-state=offscreen],[data-beacon-clipboard=offscreen]{position:fixed;top:0;left:var(--offscreen-offset);opacity:0;width:0;height:0;pointer-events:none}.beacon-toast-motion,.torrent-toast-motion{transition-property:opacity,transform;will-change:opacity,transform;opacity:0;transform:translateY(var(--toast-translate))}.beacon-toast-motion[data-beacon-motion=enter],.beacon-toast-motion[data-beacon-motion=show],.torrent-toast-motion[data-beacon-motion=enter],.torrent-toast-motion[data-beacon-motion=show]{opacity:1;transform:translateY(0)}.beacon-toast-motion[data-beacon-motion=exit],.torrent-toast-motion[data-beacon-motion=exit]{opacity:0;transform:translateY(var(--toast-translate))}.torrent-toast{pointer-events:auto;width:100%;max-width:28rem;border-radius:var(--radius-lg);padding:.75rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:500;letter-spacing:-.025em;--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);--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(1px + 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-inset:inset}.torrent-toast--info{border-width:1px;border-color:var(--border-overlay);background-color:var(--surface-base);color:var(--text-primary)}.torrent-toast--success{border-width:1px;border-color:var(--color-info);--tw-border-opacity:0.6;background-color:var(--color-info);color:var(--color-white)}.torrent-toast--error{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.6;background-color:var(--color-critical);color:var(--color-white)}.torrent-toast--warn{border-width:1px;border-color:var(--status-warning-strong);--tw-border-opacity:0.7;background-color:var(--status-warning-strong);color:var(--status-warning-strong)}.watch-history-card{--video-card-lift-translate-y:0;--video-card-backdrop-opacity:0;--video-card-backdrop-translate-y:var(--space-md-plus);--video-card-backdrop-scale:1.08;--video-card-backdrop-blur:var(--blur-thumbnail-blur);outline:2px solid transparent;outline-offset:2px}.watch-history-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)}.watch-history-card{position:relative;border-radius:var(--radius-lg);border-width:1px;border-color:transparent;background-color:var(--surface-raised);color:var(--text-primary);--tw-shadow:var(--shadow-md);--tw-shadow-colored:var(--shadow-md);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transform:translate3d(0,var(--video-card-lift-translate-y),0);transition-property:transform,box-shadow,border-color,background-color,color;will-change:transform;isolation:isolate}.watch-history-card:before{content:"";position:absolute;inset:0;z-index:0;opacity:var(--video-card-backdrop-opacity);background-image:var(--video-card-thumb-url,none);background-position:50%;background-repeat:no-repeat;background-size:cover;transform:translate3d(0,var(--video-card-backdrop-translate-y),0) scale(var(--video-card-backdrop-scale));filter:blur(var(--video-card-backdrop-blur));transition:opacity var(--motion-duration-base) var(--motion-ease-in-out),transform var(--motion-duration-base) var(--motion-ease-in-out),filter var(--motion-duration-base) var(--motion-ease-in-out);pointer-events:none;will-change:opacity,transform,filter}.watch-history-card>*{position:relative;z-index:1}.watch-history-card:focus-within,.watch-history-card:hover{--video-card-lift-translate-y:var(--motion-video-card-hover-translate);--video-card-backdrop-opacity:0.425;--video-card-backdrop-translate-y:var( --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-history-card__primary{display:block}.watch-history-card__primary{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;align-items:stretch;gap:1rem}@media (min-width:640px){.watch-history-card__primary{flex-direction:row}}.watch-history-card__thumbnail{display:block;width:14rem;max-width:100%;flex:none;color:var(--text-primary);text-decoration-line:none}.watch-history-card__thumbnailInner{aspect-ratio:16/9;width:100%;overflow:hidden;border-radius:var(--radius-lg);background-color:var(--surface-base)}.watch-history-card__thumbnailInner img{height:100%;width:100%;-o-object-fit:cover;object-fit:cover}.watch-history-card [data-thumbnail-state=blurred] img,.watch-history-card [data-visual-state=blurred] img{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)}#playerModal.watch-history-card__details{display:block}.watch-history-card__details{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;justify-content:center;gap:var(--space-sm)}.watch-history-card__title{cursor:pointer;font-size:var(--font-size-lg);line-height:var(--line-height-base);font-weight:600;line-height:1.375;color:var(--text-primary);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);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.watch-history-card__title:focus,.watch-history-card__title:hover{color:var(--color-accent)}.watch-history-card__created{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted)}#playerModal.watch-history-card__meta{display:block}.watch-history-card__meta{display:flex;min-width:0;flex-direction:column;justify-content:space-between;gap:1rem}@media (min-width:640px){.watch-history-card__meta{min-width:14rem;flex:none;flex-basis:16rem}}.watch-history-card__watched{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--border-subtle)}#playerModal.watch-history-card__creator{display:block}.watch-history-card__creator{display:flex;align-items:center;gap:.75rem}#playerModal.watch-history-card__creatorAvatar{display:block}.watch-history-card__creatorAvatar{display:flex;height:2.5rem;width:2.5rem;align-items:center;justify-content:center;overflow:hidden;border-radius:var(--radius-full);background-color:var(--surface-panel);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)}.watch-history-card__creatorAvatar:hover{--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.5}.watch-history-card__creatorAvatar: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.5}.watch-history-card__creatorAvatar:focus-visible,.watch-history-card__creatorAvatar:hover{--tw-ring-color:color-mix(in srgb,var(--color-accent) 38%,transparent)}.watch-history-card__creatorAvatar img{height:100%;width:100%;border-radius:var(--radius-full);-o-object-fit:cover;object-fit:cover}.watch-history-card__creatorName{cursor:pointer;background-color:transparent;padding:0;text-align:left;font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:500;color:var(--text-muted);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)}.watch-history-card__creatorName:hover{color:var(--color-accent)}.watch-history-card__creatorName:focus-visible{color:var(--color-accent)}#playerModal.watch-history-card__actions{display:block}.watch-history-card__actions{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}.watch-history-card__action{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:transparent;background-color:var(--surface-panel);padding:.5rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:500;color:var(--text-muted);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)}.watch-history-card__action:hover{border-color:var(--border-default);background-color:var(--surface-alt);color:var(--text-primary)}.watch-history-card__action:focus-visible{border-color:var(--border-default);background-color:var(--surface-alt);color:var(--text-primary)}.watch-history-card__action--primary{background-color:var(--color-accent-strong);color:var(--color-white)}.watch-history-card__action--primary:hover{background-color:var(--color-accent-pressed)}.watch-history-card__action--primary:focus-visible{background-color:var(--color-accent-pressed)}.watch-history-card__action--danger{background-color:var(--color-critical);color:var(--color-white)}.watch-history-card__action--danger:hover{background-color:var(--color-critical-strong)}.watch-history-card__action--danger:focus-visible{background-color:var(--color-critical-strong)}#playerModal.watch-history-card__moderation{display:block}.watch-history-card__moderation{margin-top:var(--space-sm);display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-sm)}.watch-history-card__hidden{margin-top:var(--space-md);border-radius:var(--radius-lg);border-width:1px;border-style:dashed;border-color:var(--border-default);background-color:var(--surface-overlay-panel-soft);font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary)}.watch-history-card__hidden .moderation-badge{align-self:flex-start}#playerModal .channel-card__avatar 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)}#playerModal .video-modal__moderation-bar .moderation-badge{--text-status-neutral-on:var(--color-text);--text-status-warning-on:var(--color-warning-on)}#playerModal .video-modal__moderation-bar .moderation-badge[data-variant=neutral]{color:var(--color-text)}.\!container{max-width:var(--layout-docs-max-width)!important;margin:0 auto!important;padding:var(--space-md)!important}.container{max-width:var(--layout-docs-max-width);margin:0 auto;padding:var(--space-md)}.qr-code__table{margin:var(--qr-code-table-margin,0)}.qr-code__cell,.qr-code__table{border:0;border-collapse:collapse}.qr-code__cell{padding:0;margin:0;width:var(--qr-code-cell-width);height:var(--qr-code-cell-height);background-color:var(--qr-code-color-light)}.qr-code__cell--dark{background-color:var(--qr-code-color-dark)}.qr-code__container[data-qr-code-mode=image] .qr-code__canvas{display:none}.qr-code__container[data-qr-code-mode=canvas] .qr-code__canvas,.qr-code__container[data-qr-code-mode=image] .qr-code__image{display:block}.qr-code__container[data-qr-code-mode=canvas] .qr-code__image{display:none}.fade-in{opacity:0;animation:interface-fade-in .28s ease-out forwards;animation-delay:var(--fade-in-delay,0ms)}.fade-in-delay-100{--fade-in-delay:100ms}.fade-in-delay-200{--fade-in-delay:200ms}.fade-in-delay-300{--fade-in-delay:300ms}.\!card[data-motion=enter]{opacity:0!important;animation:card-motion-enter .22s ease-out forwards!important}.card[data-motion=enter]{opacity:0;animation:card-motion-enter .22s ease-out forwards}.icon-button{display:inline-flex;align-items:center;justify-content:center;width:var(--icon-size-lg);height:var(--icon-size-lg);min-width:var(--icon-size-lg);min-height:var(--icon-size-lg);flex:0 0 var(--icon-size-lg);line-height:0;background-color:var(--color-neutral-700);color:var(--color-white);border:none;border-radius:var(--radius-full);cursor:pointer;transition:background-color .2s ease,box-shadow .2s ease}.icon-button:hover{background-color:var(--color-neutral-600)}.icon-button:active,.icon-button:focus{outline:none;box-shadow:0 0 0 var(--icon-button-ring-width) var(--color-ring-alert)}.icon-image{width:var(--icon-size-md);height:var(--icon-size-md);pointer-events:none}.accent-action-button,.channel-action-button{--accent-action-bg:color-mix(in srgb,var(--surface-overlay-panel) 78%,transparent);--accent-action-border:color-mix(in srgb,var(--border-overlay) 92%,transparent);--accent-action-bg-hover:color-mix(in srgb,var(--color-accent) 22%,var(--surface-overlay-panel));--accent-action-ring:color-mix(in srgb,var(--color-accent) 48%,transparent);--accent-action-bg-active:color-mix(in srgb,var(--color-accent-pressed) 28%,var(--surface-overlay-panel));color:var(--color-text);background-color:var(--accent-action-bg);border-color:var(--accent-action-border);border-style:solid;border-width:var(--border-width-hairline)}.accent-action-button:focus-visible,.accent-action-button:hover,.channel-action-button:focus-visible,.channel-action-button:hover{color:var(--color-accent-strong);background-color:var(--accent-action-bg-hover)}.accent-action-button:focus,.accent-action-button:focus-visible,.channel-action-button:focus,.channel-action-button:focus-visible{box-shadow:0 0 0 var(--icon-button-ring-width) var(--accent-action-ring)}.accent-action-button:active,.channel-action-button:active{color:var(--color-accent-pressed);background-color:var(--accent-action-bg-active)}.modal-nav .icon-button{box-shadow:none;padding:0;gap:0;width:var(--icon-size-lg);height:var(--icon-size-lg);min-width:var(--icon-size-lg);min-height:var(--icon-size-lg)}.modal-nav .icon-button:not(.accent-action-button):not(.channel-action-button){border:none}.modal-meta{gap:var(--space-xs)}.modal-meta,.modal-view-count{display:inline-flex;align-items:center}.modal-view-count{gap:var(--space-2xs);font-size:var(--font-size-sm);line-height:var(--space-md-plus);color:var(--color-muted-alt);white-space:nowrap}.view-count-text{white-space:nowrap;color:var(--color-muted-strong)}.ratio-16-9{position:relative;width:100%;padding-top:56.25%;background-color:var(--color-card)}.ratio-16-9>img{position:absolute;inset:0;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}#modalContainer{position:fixed;inset:0;z-index:var(--z-modal-root);pointer-events:none;isolation:isolate}#modalContainer>*{pointer-events:auto}#modalContainer>[id$=Modal]:not(#playerModal){z-index:var(--z-modal-root)}#modalContainer>[id$=Modal]:not(#playerModal)>.bv-modal-backdrop{z-index:var(--z-modal-overlay)}#modalContainer>[id$=Modal]:not(#playerModal) .bv-modal__panel{z-index:var(--z-modal-content)}#modalContainer>#profileModal,#profileModal[data-modal-stack=top]{z-index:var(--z-modal-top-root)}#modalContainer>#profileModal>.bv-modal-backdrop,#profileModal[data-modal-stack=top]>.bv-modal-backdrop{z-index:var(--z-modal-top-overlay)}#modalContainer>#profileModal .bv-modal__panel,#profileModal[data-modal-stack=top] .bv-modal__panel{z-index:var(--z-modal-top-content)}#modalContainer>#eventDetailsModal{z-index:var(--z-modal-top-root)}#modalContainer>#eventDetailsModal>.bv-modal-backdrop{z-index:var(--z-modal-top-overlay)}#modalContainer>#eventDetailsModal .bv-modal__panel{z-index:var(--z-modal-top-content)}.bv-modal[data-nested-modal-active=true]{pointer-events:none;visibility:hidden}#modalContainer>#loginModal{z-index:var(--z-modal-peak-root)}#modalContainer>#loginModal>.bv-modal-backdrop{z-index:var(--z-modal-peak-overlay)}#modalContainer>#loginModal .bv-modal__panel{z-index:var(--z-modal-peak-content)}#modalContainer>#playerModal{z-index:var(--z-modal-player)}#modalContainer>#playerModal .player-modal__container,#modalContainer>#playerModal .player-modal__content{z-index:var(--z-modal-content)}#playerModal{position:fixed;inset:0;z-index:var(--z-modal-player);display:none;flex-direction:column;align-items:stretch;justify-content:flex-start;width:100vw;height:100vh;padding:0;background-color:var(--color-surface);color:var(--color-subtle-text);--player-modal-inline-max:var(--layout-player-max-width);--player-modal-inline-padding:clamp(var(--space-md-plus),4vw,var(--space-2xl));overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}#playerModal.flex{display:block}#modalVideo{width:100%;aspect-ratio:16/9;background-color:var(--color-black)}.notification-portal{position:fixed;top:0;left:0;right:0;z-index:var(--z-overlay-toast);display:flex;flex-direction:column;align-items:center;gap:var(--space-md);padding:calc(var(--space-xl) + env(safe-area-inset-top)) var(--space-lg) var(--space-lg);pointer-events:none;opacity:0;transform:translateY(var(--toast-translate));transition:transform var(--motion-duration-base) var(--motion-ease-out),opacity var(--motion-duration-base) var(--motion-ease-out)}@media (min-width:640px){.notification-portal{padding-inline:var(--space-xl)}}.notification-portal:has(.notification-banner:not(.hidden)){opacity:1;transform:translateY(0)}.notification-portal--active{opacity:1;transform:translateY(0)}.notification-banner,.torrent-toast{--notify-accent:var(--notification-banner-accent);--notify-accent-strong:var(--notification-banner-accent-strong);--notify-border:var(--notification-banner-border);--notify-background:var(--notification-banner-background);--notify-foreground:var(--notification-banner-text);position:relative;display:flex;align-items:center;gap:var(--space-md);width:min(var(--notification-banner-max-inline),100%);padding:var(--space-md) var(--space-xl);border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--notify-border);background:var(--notify-background);color:var(--notify-foreground);box-shadow:0 var(--shadow-notification-offset-y) var(--shadow-notification-blur) var(--color-shadow-notification);backdrop-filter:blur(var(--overlay-backdrop-blur));pointer-events:auto;isolation:isolate;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;line-height:1.375;transition:transform var(--motion-duration-base) var(--motion-ease-out),opacity var(--motion-duration-base) var(--motion-ease-out)}.notification-banner:before,.torrent-toast:before{content:"";position:absolute;inset:0;border-radius:inherit;background:linear-gradient(135deg,var(--notify-accent) 0,var(--notify-accent-strong) 100%);opacity:.18;pointer-events:none}.notification-banner>*,.torrent-toast>*{position:relative;z-index:1}.notification-dismiss{display:inline-flex;height:2rem;width:2rem;align-items:center;justify-content:center;border-radius:var(--radius-full);border-width:1px;border-color:transparent;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);color:inherit;opacity:.75;background-color:var(--notify-dismiss-background)}.notification-dismiss:focus-visible,.notification-dismiss:hover{opacity:1;background-color:var(--notify-dismiss-background-hover)}.notification-dismiss svg{height:1rem;width:1rem}.notification-banner:not(.hidden){animation:notification-slide-in var(--motion-duration-base) var(--motion-ease-out)}.notification-banner--critical,.torrent-toast--error{--notify-accent:var(--notification-banner-critical-accent);--notify-accent-strong:var(--notification-banner-critical-accent-strong);--notify-border:var(--notification-banner-critical-border);--notify-background:var(--notification-banner-critical-background);--notify-foreground:var(--notification-banner-critical-text)}.notification-banner--success,.torrent-toast--success{--notify-accent:var(--notification-banner-success-accent);--notify-accent-strong:var(--notification-banner-success-accent-strong);--notify-border:var(--notification-banner-success-border);--notify-background:var(--notification-banner-success-background);--notify-foreground:var(--notification-banner-success-text)}.notification-banner--info,.torrent-toast--info{--notify-accent:var(--notification-banner-info-accent);--notify-accent-strong:var(--notification-banner-info-accent-strong);--notify-border:var(--notification-banner-info-border);--notify-background:var(--notification-banner-info-background);--notify-foreground:var(--notification-banner-info-text)}.notification-banner--warning,.torrent-toast--warn{--notify-accent:var(--notification-banner-warning-accent);--notify-accent-strong:var(--notification-banner-warning-accent-strong);--notify-border:var(--notification-banner-warning-border);--notify-background:var(--notification-banner-warning-background);--notify-foreground:var(--notification-banner-warning-text)}.status-banner .status-spinner{animation:spin 1s linear infinite;border:var(--space-3xs) solid var(--color-info-border);border-radius:var(--radius-full);border-top-color:var(--color-info);flex-shrink:0;height:var(--icon-size-md);width:var(--icon-size-md)}.status-banner .status-message{flex:1 1 auto}@keyframes notification-slide-in{0%{opacity:0;transform:translateY(var(--toast-translate))}to{opacity:1;transform:translateY(0)}}.status-spinner--inline{animation:spin 1s linear infinite;border:var(--space-3xs) solid var(--color-border-info-soft);border-radius:var(--radius-full);border-top-color:var(--color-info);display:inline-block;height:var(--space-md);width:var(--space-md)}body.iframe-form{background-color:transparent;color:var(--color-text);font-family:var(--font-family-sans);margin:var(--space-lg)}body.iframe-form .container{width:100%;max-width:100%;margin:0;padding:0}body.iframe-form .\!container{width:100%!important;max-width:100%!important;margin:0!important;padding:0!important}body.iframe-form .form-container{background-color:var(--color-surface-alt);border-radius:var(--radius-md);box-shadow:var(--shadow-md);color:var(--color-text);display:flex;flex-direction:column;gap:var(--space-md);padding:var(--space-lg)}body.iframe-form .form-container>p{margin:0}body.iframe-form form{display:flex;flex-direction:column}body.iframe-form form>*+*{margin-top:var(--space-sm)}body.iframe-form form h1,body.iframe-form form h2{color:var(--color-text);margin-top:var(--space-md)}body.iframe-form label{color:var(--color-subtle-text);display:block;font-weight:600;margin-top:var(--space-sm)}body.iframe-form label+.form-control,body.iframe-form label+.select,body.iframe-form label+.textarea{margin-top:var(--space-xs)}body.iframe-form .checkbox-group,body.iframe-form .radio-group{display:flex;flex-wrap:wrap;gap:var(--space-sm);margin-top:var(--space-xs)}body.iframe-form .checkbox-group label,body.iframe-form .radio-group label{align-items:center;color:var(--color-subtle-text);display:inline-flex;font-weight:500;gap:var(--space-xs);margin:0}body.iframe-form .checkbox-group input[type=checkbox],body.iframe-form .radio-group input[type=radio]{margin:0}body.iframe-form .radio-group input[type=radio]{accent-color:var(--color-accent-strong)}body.iframe-form button.btn{align-self:flex-start;margin-top:var(--space-md)}body.iframe-form button.\!btn{align-self:flex-start!important;margin-top:var(--space-md)!important}body.iframe-form #status{background:var(--color-surface);border-radius:var(--radius-sm);box-shadow:inset 0 0 0 var(--border-width-hairline) var(--color-border-translucent);color:var(--color-subtle-text);min-height:calc(var(--space-2xl) + var(--space-xl));padding:var(--space-sm);white-space:pre-wrap}body.iframe-form .status-line{color:var(--color-subtle-text);margin:var(--space-2xs) 0}body.iframe-form .status-line.error{color:var(--color-critical-strong)}body.iframe-form .status-line.\!error{color:var(--color-critical-strong)!important}body.iframe-form .status-line.success{color:var(--color-info-strong)}body.iframe-form .status-line.\!success{color:var(--color-info-strong)!important}body.iframe-form .status-line.warn{color:var(--color-warning-strong)}@media (max-width:640px){body.iframe-form{margin:var(--space-md)}body.iframe-form .form-container{padding:var(--space-md)}}#sidebarContainer{position:relative;overflow:visible}#sidebar{position:fixed;top:0;left:0;width:var(--sidebar-width);min-width:var(--sidebar-width);height:100vh;height:100dvh;color:var(--color-text-frosted-strong);overflow-x:hidden;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;overscroll-behavior-inline:contain;touch-action:pan-y;scrollbar-gutter:stable both-edges;background:transparent;border-right:none;backdrop-filter:none;-webkit-backdrop-filter:none;transition:width .2s ease,transform .3s ease;z-index:var(--z-sidebar);box-shadow:none;isolation:isolate}#app.sidebar-collapsed,#sidebar.sidebar-collapsed,#sidebar[data-state=collapsed],body.sidebar-collapsed{--sidebar-width:var(--sidebar-width-collapsed)}#app.sidebar-expanded,#sidebar.sidebar-expanded,#sidebar[data-state=expanded],body.sidebar-expanded{--sidebar-width:var(--sidebar-width-expanded)}#sidebar .sidebar__inner{padding-block-start:max(var(--space-sidebar-shell-block-start),calc(var(--app-header-centerline) - var(--sidebar-control-size)/2));padding-block-end:var(--space-sidebar-shell-block-end);padding-inline:var(--space-sidebar-shell-inline);border-radius:var(--radius-sidebar-shell);border:none;background:none;box-shadow:none;backdrop-filter:none;-webkit-backdrop-filter:none;isolation:isolate;transition:color .2s ease}#sidebar .sidebar__inner,.sidebar-scroll{display:flex;flex-direction:column;height:100%;width:100%;overflow:visible}.sidebar-scroll{gap:var(--space-sidebar-scroll-gap);min-height:0;flex:1 1 auto}.sidebar-panel{position:relative;width:100%;display:flex;flex-direction:column;gap:var(--space-sidebar-panel-gap);padding:var(--space-sidebar-panel-padding-block) var(--space-sidebar-panel-padding-inline);border-radius:var(--radius-sidebar-panel);border:none;background:transparent;box-shadow:none;color:inherit;backdrop-filter:none;-webkit-backdrop-filter:none;overflow:hidden;isolation:isolate;transition:border-color .2s ease,box-shadow .22s ease,background .22s ease}.sidebar-panel--primary{flex:1 1 auto;min-height:0;padding-block-start:0}.sidebar-panel__header{display:flex;justify-content:flex-end;margin-bottom:var(--space-md)}.sidebar-collapse-toggle{display:inline-flex;align-items:center;justify-content:center;width:var(--sidebar-control-size);height:var(--sidebar-control-size);border-radius:var(--radius-full);border:var(--border-width-hairline) solid var(--color-border-translucent-medium);background:var(--color-surface-tint-subtle);color:inherit;cursor:pointer;transition:background .18s ease,color .18s ease,border-color .18s ease;flex-shrink:0;box-sizing:border-box}.sidebar-collapse-toggle:hover{background:var(--color-border-translucent);border-color:var(--color-border-translucent-stronger);color:var(--color-text)}.sidebar-collapse-toggle:focus-visible{outline:var(--space-3xs) solid var(--color-secondary);outline-offset:var(--space-2xs)}.sidebar-collapse-toggle__icon{width:var(--icon-size-sm);height:var(--icon-size-sm);transition:transform .2s ease}.sidebar-nav{display:flex;flex-direction:column;gap:var(--space-xs);opacity:1;transition:opacity .16s ease;width:100%}.sidebar-nav-link{--sidebar-nav-translate-x:0;--sidebar-nav-link-highlight-opacity:0;--sidebar-nav-link-highlight-scale:0.96;display:flex;align-items:center;gap:var(--space-sidebar-footer-gap);padding-block:var(--space-sidebar-nav-padding-block);padding-inline-start:var(--space-sidebar-nav-padding-inline-start);padding-inline-end:var(--space-sidebar-nav-padding-inline-end);min-height:var(--sidebar-control-size);border-radius:var(--radius-sidebar-nav);font-size:var(--font-size-sidebar-nav);font-weight:600;letter-spacing:.015em;color:var(--color-text);text-decoration:none;width:100%;position:relative;border:var(--border-width-hairline) solid transparent;background:none;box-shadow:none;transform:translateX(var(--sidebar-nav-translate-x));transition:color .18s ease,border-color .2s ease,box-shadow .22s ease,transform .22s ease;overflow:hidden;isolation:isolate;backdrop-filter:none;-webkit-backdrop-filter:none}.sidebar-nav-link:after,.sidebar-nav-link:before{content:"";position:absolute;inset:calc(var(--border-width-hairline)*-1);border-radius:inherit;pointer-events:none;transition:opacity .22s ease,transform .24s ease;z-index:-1}.sidebar-nav-link:before{background:linear-gradient(120deg,var(--color-sidebar-accent-start) 0,var(--color-sidebar-accent-end) 100%);opacity:var(--sidebar-nav-link-highlight-opacity);transform:scale(var(--sidebar-nav-link-highlight-scale));filter:saturate(140%)}.sidebar-nav-link:after{background:linear-gradient(105deg,var(--color-sidebar-hover) 0,transparent 75%);opacity:.22;mix-blend-mode:screen;z-index:-2}.sidebar-nav-link:focus-visible,.sidebar-nav-link:hover{--sidebar-nav-link-highlight-opacity:1;--sidebar-nav-link-highlight-scale:1.02;--sidebar-nav-translate-x:var(--space-sidebar-nav-translate);color:var(--color-white);border-color:var(--color-sidebar-hover-strong);box-shadow:0 0 0 var(--border-width-hairline) var(--color-sidebar-hover-strong),var(--shadow-sidebar-accent)}.sidebar-nav-link:focus-visible{outline:none;box-shadow:var(--shadow-sidebar-focus-ring),var(--shadow-sidebar-accent)}.sidebar-nav-link:focus-visible:after,.sidebar-nav-link:hover:after{opacity:.35}.sidebar-nav-link--sub{font-size:var(--font-size-sidebar-subnav);font-weight:500;color:var(--color-sidebar-text-muted);padding-block:var(--space-sidebar-subnav-padding-block);letter-spacing:.02em;--sidebar-nav-link-highlight-opacity:0.25;--sidebar-nav-link-highlight-scale:1}.sidebar-icon{width:var(--space-lg);height:var(--space-lg);min-width:var(--space-lg);min-height:var(--space-lg);flex-shrink:0;display:block;filter:var(--sidebar-icon-filter);transition:filter .18s ease}.sidebar-footer{flex-direction:column;margin-top:auto;z-index:2}.sidebar-dropup-trigger,.sidebar-footer{display:flex;gap:var(--space-sidebar-footer-gap);position:relative}.sidebar-dropup-trigger{align-items:center;justify-content:space-between;width:100%;padding-block:var(--space-sidebar-trigger-padding-block);padding-inline-start:var(--space-sidebar-nav-padding-inline-start);padding-inline-end:var(--space-sidebar-nav-padding-inline-end);min-height:var(--sidebar-control-size);border-radius:var(--radius-sidebar-nav);border:var(--border-width-hairline) solid transparent;background:none;color:var(--color-text);font-size:var(--font-size-sidebar-trigger);font-weight:600;cursor:pointer;box-sizing:border-box;flex-shrink:0;overflow:hidden;isolation:isolate;backdrop-filter:none;-webkit-backdrop-filter:none;box-shadow:none;transition:border-color .2s ease,color .2s ease,box-shadow .22s ease}.sidebar-dropup-trigger:before{background:linear-gradient(120deg,var(--color-sidebar-accent-start) 0,var(--color-sidebar-accent-end) 100%);opacity:0;transform:scale(.96);transition:opacity .2s ease,transform .22s ease;z-index:-1;filter:saturate(140%)}.sidebar-dropup-trigger:after,.sidebar-dropup-trigger:before{content:"";position:absolute;inset:calc(var(--border-width-hairline)*-1);border-radius:inherit;pointer-events:none}.sidebar-dropup-trigger:after{background:linear-gradient(110deg,var(--color-sidebar-hover) 0,transparent 80%);opacity:.18;mix-blend-mode:screen;transition:opacity .2s ease;z-index:-2}.sidebar-dropup-trigger:focus-visible,.sidebar-dropup-trigger:hover{color:var(--color-white);border-color:var(--color-sidebar-hover-strong);box-shadow:0 0 0 var(--border-width-hairline) var(--color-sidebar-hover-strong),var(--shadow-sidebar-dropup)}.sidebar-dropup-trigger:focus-visible:before,.sidebar-dropup-trigger:hover:before{opacity:1;transform:scale(1.02)}.sidebar-dropup-trigger:focus-visible:after,.sidebar-dropup-trigger:hover:after{opacity:.35}.sidebar-dropup-trigger:focus-visible{outline:none;box-shadow:var(--shadow-sidebar-focus-ring),var(--shadow-sidebar-dropup)}.sidebar-dropup-trigger__glyph{width:var(--icon-size-md);height:var(--icon-size-md);opacity:.9}.sidebar-dropup-trigger__chevron{width:var(--icon-size-sm);height:var(--icon-size-sm);transition:transform .18s ease}.sidebar-dropup-trigger[aria-expanded=true] .sidebar-dropup-trigger__chevron,.sidebar-dropup-trigger__chevron.is-rotated{transform:rotate(180deg)}.sidebar-dropup-panel{position:absolute;left:0;right:0;bottom:calc(100% + var(--space-sidebar-popover-offset));border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-sidebar-border-strong);background:linear-gradient(160deg,color-mix(in srgb,var(--color-sidebar-surface) 88%,transparent) 0,color-mix(in srgb,var(--color-sidebar-surface-alt) 68%,transparent) 100%);box-shadow:inset 0 0 0 var(--border-width-fine) var(--color-sidebar-border),var(--shadow-sidebar-panel);padding:var(--space-sidebar-popover-padding-block) var(--space-sidebar-popover-padding-inline);color:var(--color-text-frosted);opacity:0;visibility:hidden;pointer-events:none;transform:translateY(var(--space-sm));transition:opacity .18s ease,transform .18s ease,visibility .18s ease,box-shadow .22s ease;backdrop-filter:blur(var(--sidebar-panel-backdrop-blur)) saturate(var(--sidebar-panel-backdrop-saturation));-webkit-backdrop-filter:blur(var(--sidebar-panel-backdrop-blur)) saturate(var(--sidebar-panel-backdrop-saturation));z-index:5;isolation:isolate}.sidebar-dropup-panel[data-state=expanded]{opacity:1;visibility:visible;pointer-events:auto;transform:translateY(0)}.sidebar-dropup-panel__inner{display:flex;flex-direction:column;gap:var(--space-sidebar-popover-gap);width:100%;padding-inline-end:var(--space-sidebar-dropup-scroll-reserve);max-height:var(--sidebar-dropup-max-height);overflow-y:auto;overflow-x:hidden;scrollbar-gutter:stable both-edges}.sidebar-dropup-panel__inner.is-measuring{position:absolute;width:-moz-max-content;width:max-content;max-width:none;visibility:hidden;pointer-events:none;left:var(--offscreen-offset);right:auto;top:auto;bottom:auto}.sidebar-dropup-measure{position:absolute;visibility:hidden;pointer-events:none;height:0;overflow:hidden}.sidebar-dropup-panel__inner>*{width:100%;min-width:0}#sidebar[data-footer-state=expanded] .sidebar-nav{opacity:0;pointer-events:none}#sidebar[data-footer-state=collapsed] .sidebar-nav{opacity:1}.sidebar-loading-wrapper{display:flex;justify-content:center;align-items:center;padding:var(--space-2xl) 0;width:100%;grid-column:1/-1}.sidebar-loading-indicator{display:inline-flex;align-items:center;gap:var(--space-sm);color:var(--color-muted-strong);font-size:var(--font-size-sm);font-weight:500;letter-spacing:.01em}.sidebar-loading-text{white-space:nowrap}#app{box-sizing:border-box;transition:margin-left .2s ease,width .2s ease,max-width .2s ease}#app[data-sidebar-state=collapsed]{margin-left:var(--sidebar-width-collapsed);width:calc(100vw - var(--sidebar-width-collapsed));max-width:calc(100vw - var(--sidebar-width-collapsed))}#app[data-sidebar-state=expanded]{margin-left:max(var(--sidebar-width-expanded-base),var(--sidebar-dropup-content-width));width:min(calc(100vw - var(--sidebar-width-expanded-base)),calc(100vw - var(--sidebar-dropup-content-width)));max-width:min(calc(100vw - var(--sidebar-width-expanded-base)),calc(100vw - var(--sidebar-dropup-content-width)))}#sidebar.sidebar-collapsed .sidebar-nav-link,body.sidebar-collapsed #sidebar .sidebar-nav-link{justify-content:center;width:var(--sidebar-control-size);padding:0;margin-inline:auto;border-radius:var(--radius-full);--sidebar-nav-translate-x:0;--sidebar-nav-link-highlight-scale:1}#sidebar.sidebar-expanded .sidebar-nav-link,body.sidebar-expanded #sidebar .sidebar-nav-link{justify-content:flex-start}#sidebar.sidebar-collapsed .sidebar-text,body.sidebar-collapsed #sidebar .sidebar-text{display:none}#sidebar.sidebar-collapsed .sidebar__inner,body.sidebar-collapsed #sidebar .sidebar__inner{padding-inline:var(--space-2xs)}#sidebar.sidebar-collapsed .sidebar-panel,body.sidebar-collapsed #sidebar .sidebar-panel{padding-inline:calc(var(--space-3xs)*3)}#sidebar.sidebar-collapsed .sidebar-panel__header,body.sidebar-collapsed #sidebar .sidebar-panel__header{justify-content:center}#sidebar.sidebar-collapsed .sidebar-collapse-toggle,body.sidebar-collapsed #sidebar .sidebar-collapse-toggle{margin-inline:auto}#sidebar .sidebar-toggle-icon--rotated{transform:rotate(180deg)}#sidebar.sidebar-collapsed .sidebar-toggle-icon--rotated,body.sidebar-collapsed #sidebar .sidebar-toggle-icon--rotated{transform:rotate(0deg)}#sidebar.sidebar-collapsed .sidebar-dropup-trigger,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger{justify-content:center;width:var(--sidebar-control-size);height:var(--sidebar-control-size);padding:0;margin-inline:auto;border-radius:var(--radius-full)}#sidebar.sidebar-collapsed .sidebar-dropup-trigger .sidebar-text,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger .sidebar-text,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger__chevron{display:none}#sidebar.sidebar-collapsed .sidebar-dropup-trigger__glyph,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger__glyph{margin:0}#sidebar.sidebar-collapsed .sidebar-nav-link:focus-visible,#sidebar.sidebar-collapsed .sidebar-nav-link:hover,body.sidebar-collapsed #sidebar .sidebar-nav-link:focus-visible,body.sidebar-collapsed #sidebar .sidebar-nav-link:hover{--sidebar-nav-translate-x:0}@media (min-width:768px){.sidebar-panel__header{display:flex}.sidebar-collapse-toggle{display:inline-flex}#sidebar{transform:translateX(0)!important;width:var(--sidebar-width);overflow:visible}#app[data-sidebar-state=collapsed]{margin-left:calc(var(--sidebar-width-collapsed) + var(--sidebar-content-gap));width:calc(100vw - var(--sidebar-width-collapsed) - var(--sidebar-content-gap));max-width:calc(100vw - var(--sidebar-width-collapsed) - var(--sidebar-content-gap))}#app[data-sidebar-state=expanded]{margin-left:max(calc(var(--sidebar-width-expanded-base) + var(--sidebar-content-gap)),calc(var(--sidebar-dropup-content-width) + var(--sidebar-content-gap)));width:min(calc(100vw - var(--sidebar-width-expanded-base) - var(--sidebar-content-gap)),calc(100vw - var(--sidebar-dropup-content-width) - var(--sidebar-content-gap)));max-width:min(calc(100vw - var(--sidebar-width-expanded-base) - var(--sidebar-content-gap)),calc(100vw - var(--sidebar-dropup-content-width) - var(--sidebar-content-gap)))}}@keyframes card-motion-enter{0%{opacity:0;transform:translateY(var(--motion-card-enter-translate))}to{opacity:1;transform:translateY(0)}}@keyframes video-thumbnail-fade-in{0%{opacity:0}to{opacity:1}}@keyframes interface-fade-in{0%{opacity:0}to{opacity:1}}@keyframes fadeIn{0%{opacity:0;transform:translateY(var(--motion-fade-in-translate))}to{opacity:1;transform:translateY(0)}}.status-line{margin-top:.25rem;margin-bottom:.25rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);line-height:1.375;color:var(--text-primary)}.status-line.error{color:var(--color-critical-strong)}.status-line.\!error{color:var(--color-critical-strong)!important}.status-line.warn{color:var(--color-warning-strong)}@keyframes ztr-rotate{to{transform:rotate(1turn)}}@keyframes ztr-dash{0%{stroke-dasharray:1,150;stroke-dashoffset:0}50%{stroke-dasharray:90,150;stroke-dashoffset:-35}to{stroke-dasharray:90,150;stroke-dashoffset:-124}}@keyframes svelte-181fgen-loader-animation{0%{transform:rotate(0)}to{transform:rotate(1turn)}}@keyframes svelte-181fgen-fade-in{to{opacity:1}}.dm-app-shell{display:flex;flex-direction:column;gap:var(--space-lg);padding:var(--space-lg);background:var(--surface-base);color:var(--text-primary);min-height:100vh}.dm-app-shell__main,.dm-app-shell__sidebar{border-radius:var(--radius-lg);border:var(--border-width-hairline) solid var(--border-default);background:var(--surface-panel);box-shadow:var(--shadow-soft)}.dm-app-shell__sidebar{display:flex;flex-direction:column;gap:var(--space-md);padding:var(--space-md)}.dm-app-shell__title{font-size:var(--font-size-lg);margin:0}.dm-app-shell__main{flex:1;display:flex;flex-direction:column;gap:var(--space-md);padding:var(--space-md);background:var(--surface-raised)}.dm-conversation-list{display:flex;flex-direction:column;gap:var(--space-sm)}.dm-conversation-list__header{display:flex;justify-content:space-between;align-items:center}.dm-conversation-list__title{font-size:var(--font-size-sm);text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);margin:0}.dm-conversation-list__items{display:flex;flex-direction:column;gap:var(--space-xs);overflow-y:auto;max-height:60vh;padding-right:var(--space-xs)}.dm-conversation-list__state{font-size:var(--font-size-sm);color:var(--text-muted);padding:var(--space-md);text-align:center;background:var(--surface-sunken);border-radius:var(--radius-md)}.dm-contact-row{display:flex;align-items:center;gap:var(--space-sm);padding:var(--space-sm) var(--space-md);border-radius:var(--radius-md);border:var(--border-width-hairline) solid var(--border-subtle);background:transparent;color:inherit;text-align:left;cursor:pointer}.dm-contact-row:focus-visible,.dm-contact-row:hover{background:var(--surface-muted);border-color:var(--border-strong);outline:none}.dm-contact-row--active{background:var(--surface-sunken);border-color:var(--border-strong)}.dm-contact-row__content{display:flex;flex-direction:column;gap:var(--space-2xs);flex:1}.dm-contact-row__header{display:flex;justify-content:space-between;align-items:baseline;gap:var(--space-xs)}.dm-contact-row__name{font-weight:600}.dm-contact-row__time{font-size:var(--font-size-2xs);color:var(--text-muted)}.dm-contact-row__preview{font-size:var(--font-size-xs);color:var(--text-muted)}.dm-contact-row__badge{align-self:flex-start;padding:var(--space-3xs) var(--space-xs);border-radius:var(--radius-full);background:var(--unread-badge-background);color:var(--unread-badge-text);font-size:var(--font-size-3xs)}.dm-avatar{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--radius-full);background:var(--surface-sunken);color:var(--text-muted);font-weight:600;overflow:hidden;border:var(--border-width-hairline) solid var(--border-subtle)}.dm-avatar__image{width:100%;height:100%;-o-object-fit:cover;object-fit:cover}.dm-message-thread{display:flex;flex-direction:column;gap:var(--space-sm);flex:1;min-height:0}.dm-message-thread__header{display:flex;align-items:baseline;gap:var(--space-sm)}.dm-message-thread__title{font-size:var(--font-size-base);margin:0}.dm-message-thread__status{font-size:var(--font-size-xs);color:var(--text-muted)}.dm-message-thread__timeline{display:flex;flex-direction:column;gap:var(--space-sm);padding:var(--space-sm);border-radius:var(--radius-md);background:var(--surface-panel);border:var(--border-width-hairline) solid var(--border-subtle);overflow-y:auto;min-height:16rem}.dm-message-thread__state{padding:var(--space-md);text-align:center;color:var(--text-muted);background:var(--surface-sunken);border-radius:var(--radius-md)}.dm-message-bubble{max-width:85%;display:flex;flex-direction:column;gap:var(--space-2xs);padding:var(--space-sm) var(--space-md);border-radius:var(--radius-lg);border:var(--border-width-hairline) solid var(--message-bubble-border);background:var(--message-bubble-background);color:var(--message-bubble-text)}.dm-message-bubble__content{font-size:var(--font-size-sm)}.dm-message-bubble__meta{display:flex;gap:var(--space-xs);font-size:var(--font-size-2xs);color:var(--message-bubble-meta)}.dm-message-bubble__status{color:var(--text-muted)}.dm-message-bubble__status[data-status=pending]{color:var(--status-warning)}.dm-message-bubble__status[data-status=sending]{color:var(--status-info)}.dm-message-bubble__status[data-status=failed]{color:var(--status-danger)}.dm-message-thread__new{align-self:center;border-radius:var(--radius-full);border:var(--border-width-hairline) solid var(--border-subtle);padding:var(--space-2xs) var(--space-sm);font-size:var(--font-size-2xs);background:var(--surface-sunken);color:var(--text-muted)}.dm-message-thread__new:focus-visible,.dm-message-thread__new:hover{background:var(--surface-overlay-panel-soft);color:var(--text-primary);outline:none}.dm-day-divider{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:var(--space-xs);color:var(--text-muted);font-size:var(--font-size-2xs)}.dm-day-divider__line{height:1px;background:var(--border-subtle)}.dm-notification-center{display:flex;flex-direction:column;gap:var(--space-sm)}.dm-notification-center__filters{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-xs)}.dm-notification-center__filter-button{border-radius:var(--radius-full);border:var(--border-width-hairline) solid var(--border-subtle);background:var(--surface-sunken);padding:var(--space-2xs) var(--space-sm);font-size:var(--font-size-2xs);font-weight:600;color:var(--text-muted);transition:color var(--motion-duration-fast) var(--motion-ease-out),background var(--motion-duration-fast) var(--motion-ease-out),border-color var(--motion-duration-fast) var(--motion-ease-out)}.dm-notification-center__filter-button:focus-visible,.dm-notification-center__filter-button:hover{outline:none;border-color:var(--border-strong);background:var(--surface-raised);color:var(--text-primary)}.dm-notification-center__filter-button--active{border-color:var(--border-strong);background:var(--surface-overlay-panel);color:var(--text-primary)}.dm-notification-center__groups{display:flex;flex-direction:column;gap:var(--space-md)}.dm-notification-center__group{display:flex;flex-direction:column;gap:var(--space-xs)}.dm-notification-center__group-title{font-size:var(--font-size-2xs);font-weight:600;text-transform:uppercase;letter-spacing:var(--tracking-wide);color:var(--text-muted)}.dm-notification-center__list{display:flex;flex-direction:column;gap:var(--space-xs)}.dm-notification-center__empty{font-size:var(--font-size-xs);color:var(--text-muted)}.dm-notification-item{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:var(--space-sm);padding:var(--space-sm);border-radius:var(--radius-md);border:var(--border-width-hairline) solid var(--border-subtle);background:var(--surface-sunken);font-size:var(--font-size-xs);text-align:left;transition:border-color var(--motion-duration-fast) var(--motion-ease-out),background var(--motion-duration-fast) var(--motion-ease-out)}.dm-notification-item:focus-visible,.dm-notification-item:hover{outline:none;border-color:var(--border-strong);background:var(--surface-raised)}.dm-notification-item__icon{display:inline-flex;align-items:center;justify-content:center;width:var(--icon-size-md);height:var(--icon-size-md);border-radius:var(--radius-full);background:var(--surface-overlay-panel-soft);color:var(--text-muted);font-size:var(--font-size-xs)}.dm-notification-item__body{display:flex;flex-direction:column;gap:var(--space-2xs);min-width:0}.dm-notification-item__title{font-weight:600;color:var(--text-primary)}.dm-notification-item__message{color:var(--text-muted)}.dm-notification-item__meta{display:flex;align-items:center;gap:var(--space-2xs);color:var(--text-muted)}.dm-notification-item__timestamp{font-variant-numeric:tabular-nums;white-space:nowrap}.dm-composer{display:flex;flex-direction:column;gap:var(--space-xs)}.dm-composer__label{font-size:var(--font-size-xs);color:var(--text-muted)}.dm-composer__input{resize:vertical;border-radius:var(--radius-md);border:var(--border-width-hairline) solid var(--border-default);background:var(--surface-base);color:var(--text-primary);padding:var(--space-sm);font-family:inherit}.dm-composer__input:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}.dm-composer__actions{justify-content:space-between}.dm-composer__actions,.dm-composer__tools{display:flex;align-items:center;gap:var(--space-sm)}.dm-composer__tools{flex-wrap:wrap}.dm-composer__attach,.dm-composer__hint,.dm-composer__privacy{font-size:var(--font-size-2xs);color:var(--text-muted)}.dm-composer__attach,.dm-composer__privacy{border-radius:var(--radius-md);border:var(--border-width-hairline) solid var(--border-default);background:var(--surface-base);padding:var(--space-2xs) var(--space-sm);cursor:pointer}.dm-composer__attach:focus-visible,.dm-composer__attach:hover,.dm-composer__privacy:focus-visible,.dm-composer__privacy:hover{background:var(--surface-overlay-panel-soft);color:var(--text-primary);outline:none}.dm-composer__send{border-radius:var(--radius-md);border:var(--border-width-hairline) solid var(--border-strong);background:var(--surface-overlay-panel);color:var(--text-primary);padding:var(--space-xs) var(--space-md);font-weight:600;cursor:pointer}.dm-composer__send:focus-visible,.dm-composer__send:hover{background:var(--surface-overlay-panel-soft);outline:none}.dm-composer__status{font-size:var(--font-size-2xs);color:var(--status-danger);min-height:1rem}@media (min-width:768px){.dm-app-shell{flex-direction:row;align-items:stretch}.dm-app-shell__sidebar{flex:0 0 18rem;max-width:20rem}.dm-app-shell__main,.dm-app-shell__sidebar{height:calc(100vh - var(--space-lg)*2)}.dm-conversation-list__items{max-height:none;flex:1}}@media (min-width:1024px){.dm-app-shell__sidebar{flex-basis:20rem}.dm-message-thread__timeline{padding:var(--space-md)}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.\!visible{visibility:visible!important}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-4{bottom:1rem}.bottom-6{bottom:1.5rem}.left-4{left:1rem}.right-3{right:.75rem}.right-4{right:1rem}.right-6{right:1.5rem}.top-0{top:0}.top-3{top:.75rem}.z-10{z-index:10}.z-50{z-index:50}.z-\[140\]{z-index:140}.z-overlay-floating{z-index:var(--z-overlay-floating)}.z-overlay-toast{z-index:var(--z-overlay-toast)}.order-1{order:1}.order-2{order:2}.col-span-full{grid-column:1/-1}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-4{margin-top:1rem;margin-bottom:1rem}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.-ml-1{margin-left:-.25rem}.-ml-2{margin-left:-.5rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.mt-sm{margin-top:var(--space-sm)}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-square{aspect-ratio:1/1}.h-1{height:.25rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2xl{height:var(--space-2xl)}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-auto{height:auto}.h-full{height:100%}.max-h-\[90vh\]{max-height:90vh}.max-h-modal-body{max-height:var(--modal-body-max-height)}.max-h-modal-pane{max-height:var(--modal-pane-max-height)}.max-h-modal-sheet{max-height:var(--modal-sheet-max-height)}.max-h-modal-shell{max-height:var(--modal-shell-max-height)}.min-h-0{min-height:0}.min-h-\[200px\]{min-height:200px}.min-h-\[80px\]{min-height:80px}.min-h-\[96px\]{min-height:96px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-20{width:5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-44{width:11rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-72{width:18rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[calc\(100\%-3rem\)\]{width:calc(100% - 3rem)}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-\[200px\]{max-width:200px}.max-w-docs{max-width:var(--layout-docs-max-width)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-popover-safe{max-width:var(--popover-inline-safe-max)}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow,.grow{flex-grow:1}.origin-bottom-right{transform-origin:bottom right}.rotate-90{--tw-rotate:90deg}.rotate-90,.scale-\[1\.2\]{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))}.scale-\[1\.2\]{--tw-scale-x:1.2;--tw-scale-y:1.2}.transform{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))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.cursor-wait{cursor:wait}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize-y{resize:vertical}.resize{resize:both}.list-disc{list-style-type:disc}.auto-rows-fr{grid-auto-rows:minmax(0,1fr)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-sm{gap:var(--space-sm)}.gap-xl{gap:var(--space-xl)}.gap-xs{gap:var(--space-xs)}.gap-y-6{row-gap:1.5rem}.gap-y-8{row-gap:2rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-border-translucent>:not([hidden])~:not([hidden]){border-color:var(--border-overlay)}.self-start{align-self:flex-start}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:var(--radius-full)}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l-2{border-left-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-none{border-style:none}.border-border{border-color:var(--border-default)}.border-border-strong{border-color:var(--border-strong)}.border-border-translucent{border-color:var(--border-overlay)}.border-border-translucent-bright{border-color:var(--color-border-translucent-bright)}.border-border-translucent-medium{border-color:var(--color-border-translucent-medium)}.border-overlay{border-color:var(--border-overlay)}.border-overlay-strong{border-color:var(--border-overlay-strong)}.border-status-danger-border{border-color:var(--status-danger-border)}.border-status-info-border{border-color:var(--status-info-border)}.border-status-success-border{border-color:var(--status-success-border)}.border-status-warning-border{border-color:var(--status-warning-border)}.border-surface-strong{border-color:var(--border-strong)}.border-opacity-20{--tw-border-opacity:0.2}.bg-accent-strong{background-color:var(--color-accent-strong)}.bg-overlay-muted{background-color:var(--surface-overlay-muted)}.bg-overlay-panel{background-color:var(--surface-overlay-panel)}.bg-overlay-panel-soft{background-color:var(--surface-overlay-panel-soft)}.bg-overlay-strong{background-color:var(--surface-overlay-strong)}.bg-panel{background-color:var(--surface-panel)}.bg-status-danger-surface{background-color:var(--status-danger-surface)}.bg-status-info-surface{background-color:var(--status-info-surface)}.bg-status-success-surface{background-color:var(--status-success-surface)}.bg-status-warning-surface{background-color:var(--status-warning-surface)}.bg-surface{background-color:var(--surface-base)}.bg-surface-alt{background-color:var(--surface-alt)}.bg-transparent{background-color:transparent}.bg-white{background-color:var(--color-white)}.bg-opacity-95{--tw-bg-opacity:0.95}.bg-overlay-channel{background-image:var(--surface-overlay-banner-gradient-channel)}.stroke-border-translucent{stroke:var(--border-overlay)}.stroke-current{stroke:currentColor}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-lg{padding:var(--space-lg)}.p-md{padding:var(--space-md)}.px-0{padding-left:0;padding-right:0}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-lg{padding-left:var(--space-lg);padding-right:var(--space-lg)}.px-md{padding-left:var(--space-md);padding-right:var(--space-md)}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.py-md{padding-top:var(--space-md);padding-bottom:var(--space-md)}.py-xl{padding-top:var(--space-xl);padding-bottom:var(--space-xl)}.pb-16{padding-bottom:4rem}.pb-2{padding-bottom:.5rem}.pb-6{padding-bottom:1.5rem}.pb-8{padding-bottom:2rem}.pl-2{padding-left:.5rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-1{padding-right:.25rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-family-mono)}.font-sans{font-family:var(--font-family-sans)}.text-2xl{font-size:var(--font-size-2xl);line-height:var(--line-height-relaxed)}.text-2xs{font-size:var(--font-size-2xs);line-height:var(--line-height-tight)}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-3xs{font-size:var(--font-size-3xs);line-height:var(--line-height-tight)}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-4xs{font-size:var(--font-size-4xs);line-height:var(--line-height-tight)}.text-\[0\.75rem\]{font-size:.75rem}.text-base{font-size:var(--font-size-base)}.text-base,.text-lg{line-height:var(--line-height-base)}.text-lg{font-size:var(--font-size-lg)}.text-sm{font-size:var(--font-size-sm);line-height:var(--line-height-snug)}.text-xl{font-size:var(--font-size-xl);line-height:var(--line-height-relaxed)}.text-xs{font-size:var(--font-size-xs);line-height:var(--line-height-tight)}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.ordinal{--tw-ordinal:ordinal}.ordinal,.tabular-nums{font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.tabular-nums{--tw-numeric-spacing:tabular-nums}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-extra-wide{letter-spacing:var(--tracking-extra-wide)}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-accent{color:var(--color-accent)}.text-critical{color:var(--color-critical)}.text-critical-strong{color:var(--color-critical-strong)}.text-info{color:var(--color-info)}.text-info-strong{color:var(--color-info-strong)}.text-muted{color:var(--text-muted)}.text-muted-strong{color:var(--text-muted-strong)}.text-primary{color:var(--text-primary)}.text-status-danger{color:var(--status-danger)}.text-status-danger-on{color:var(--status-danger-on)}.text-status-info{color:var(--status-info)}.text-status-info-on{color:var(--status-info-on)}.text-status-neutral{color:var(--status-neutral)}.text-status-private-on{color:var(--status-private-on)}.text-status-success{color:var(--status-success)}.text-status-success-on{color:var(--status-success-on)}.text-status-warning{color:var(--status-warning)}.text-status-warning-on{color:var(--status-warning-on)}.text-subtle{color:var(--text-subtle)}.text-text{color:var(--text-primary)}.text-text-frosted{color:var(--text-frosted-primary)}.text-text-muted{color:var(--text-muted)}.text-text-strong{color:var(--color-text-strong)}.text-warning{color:var(--status-warning)}.text-warning-strong{color:var(--status-warning-strong)}.text-white{color:var(--color-white)}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.opacity-95{opacity:.95}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color)}.shadow-inner,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.shadow-md{--tw-shadow:var(--shadow-md);--tw-shadow-colored:var(--shadow-md)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.blur{--tw-blur:blur(8px)}.blur,.blur-xl{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-xl{--tw-blur:blur(24px)}.\!filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)!important}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px)}.backdrop-blur-md,.backdrop-blur-sm{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.transition{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-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-fast{transition-duration:var(--motion-duration-fast)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@media (max-width:640px){.bv-grid-video{gap:var(--space-md);grid-template-columns:1fr}}@media (prefers-reduced-motion:reduce){:root{--motion-duration-fast:0s;--motion-duration-base:0s;--motion-duration-slow:0s;--modal-motion-duration:0s}*,:after,:before{animation-delay:0s!important;animation-duration:0s!important;animation-iteration-count:1!important;animation-timing-function:linear!important;transition-delay:0s!important;transition-duration:0s!important;transition-timing-function:linear!important}.player-modal__similar-card:before{transition:none!important}#app,#playerModal .player-modal__content,#sidebar,.btn,.btn-ghost,.card,.card[data-motion=enter],.fade-in,.icon-button,.menu__item,.modal-nav,.notification-banner,.notification-portal,.popover-panel,.popover__panel,.progress-bar-fill,.sidebar-collapse-toggle,.sidebar-collapse-toggle__icon,.sidebar-dropup-panel,.sidebar-dropup-trigger,.sidebar-dropup-trigger__chevron,.sidebar-loading-indicator,.sidebar-loading-wrapper,.sidebar-nav,.sidebar-nav-link,.status-banner .status-spinner,.status-spinner--inline,.torrent-toast,.video-modal__panel,.watch-history-card,.watch-history-card__action,.watch-history-card__creatorAvatar,.watch-history-card__creatorName,.watch-history-card__title{transition-property:none!important}.popover-panel,.popover__panel{transform:none!important}.popover-panel[data-state=open],.popover__panel[data-state=open]{opacity:1}.popover-panel[data-state=closed],.popover__panel[data-state=closed]{opacity:0}.card[data-motion=enter],.fade-in,[data-video-thumbnail][data-thumbnail-loaded=true]{opacity:1;animation:none!important}.notification-banner,.status-banner .status-spinner,.status-spinner--inline,.torrent-toast{animation:none!important}.notification-portal{transform:none!important;opacity:1!important}}@media (min-width:640px){.sm\:flex#playerModal{display:block}}@media (min-width:768px){.md\:hidden .bv-modal,.md\:hidden.profile-pane{display:none!important}.md\:flex#playerModal{display:block}}.placeholder\:text-muted::-moz-placeholder{color:var(--text-muted)}.placeholder\:text-muted::placeholder{color:var(--text-muted)}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;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))}.hover\:border-status-danger-border:hover{border-color:var(--status-danger-border)}.hover\:bg-overlay-panel:hover{background-color:var(--surface-overlay-panel)}.hover\:bg-panel-hover:hover{background-color:var(--surface-panel-hover)}.hover\:text-accent:hover{color:var(--color-accent)}.hover\:text-critical:hover{color:var(--color-critical)}.hover\:text-info-strong:hover{color:var(--color-info-strong)}.hover\:text-text:hover{color:var(--text-primary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-0:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-0:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--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)}.focus\:ring-info:focus{--tw-ring-color:var(--color-info)}.focus\:ring-status-info:focus{--tw-ring-color:var(--status-info)}.focus\:ring-status-success:focus{--tw-ring-color:var(--status-success)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus\:ring-offset-surface:focus{--tw-ring-offset-color:var(--surface-base)}.focus-visible\:bg-primary:focus-visible{background-color:var(--color-primary)}.focus-visible\:outline:focus-visible{outline-style:solid}.focus-visible\:outline-2:focus-visible{outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-info:focus-visible{outline-color:var(--color-info)}.active\:scale-95:active{--tw-scale-x:.95;--tw-scale-y:.95;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))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05;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))}.group:hover .group-hover\:text-primary{color:var(--text-primary)}@media (min-width:640px){.sm\:order-1{order:1}.sm\:order-2{order:2}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:flex{display:flex}.sm\:w-1\/3{width:33.333333%}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:20rem}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-start{align-items:flex-start}.sm\:items-end{align-items:flex-end}.sm\:items-center{align-items:center}.sm\:justify-end{justify-content:flex-end}.sm\:justify-between{justify-content:space-between}.sm\:gap-2{gap:.5rem}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px*var(--tw-space-y-reverse))}.sm\:self-end{align-self:flex-end}.sm\:p-5{padding:1.25rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-0{padding-top:0;padding-bottom:0}.sm\:pt-8{padding-top:2rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:flex{display:flex}.md\:hidden{display:none}.md\:h-64{height:16rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:items-center{align-items:center}.md\:justify-end{justify-content:flex-end}.md\:gap-md{gap:var(--space-md)}.md\:pb-12{padding-bottom:3rem}}@media (min-width:1024px){.lg\:ml-auto{margin-left:auto}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-wrap{flex-wrap:wrap}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:text-right{text-align:right}}@media (min-width:1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file + );--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-history-card__primary{display:block}.watch-history-card__primary{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;align-items:stretch;gap:1rem}@media (min-width:640px){.watch-history-card__primary{flex-direction:row}}.watch-history-card__thumbnail{display:block;width:14rem;max-width:100%;flex:none;color:var(--text-primary);text-decoration-line:none}.watch-history-card__thumbnailInner{aspect-ratio:16/9;width:100%;overflow:hidden;border-radius:var(--radius-lg);background-color:var(--surface-base)}.watch-history-card__thumbnailInner img{height:100%;width:100%;-o-object-fit:cover;object-fit:cover}.watch-history-card [data-thumbnail-state=blurred] img,.watch-history-card [data-visual-state=blurred] img{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)}#playerModal.watch-history-card__details{display:block}.watch-history-card__details{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;justify-content:center;gap:var(--space-sm)}.watch-history-card__title{cursor:pointer;font-size:var(--font-size-lg);line-height:var(--line-height-base);font-weight:600;line-height:1.375;color:var(--text-primary);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);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.watch-history-card__title:focus,.watch-history-card__title:hover{color:var(--color-accent)}.watch-history-card__created{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted)}#playerModal.watch-history-card__meta{display:block}.watch-history-card__meta{display:flex;min-width:0;flex-direction:column;justify-content:space-between;gap:1rem}@media (min-width:640px){.watch-history-card__meta{min-width:14rem;flex:none;flex-basis:16rem}}.watch-history-card__watched{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--border-subtle)}#playerModal.watch-history-card__creator{display:block}.watch-history-card__creator{display:flex;align-items:center;gap:.75rem}#playerModal.watch-history-card__creatorAvatar{display:block}.watch-history-card__creatorAvatar{display:flex;height:2.5rem;width:2.5rem;align-items:center;justify-content:center;overflow:hidden;border-radius:var(--radius-full);background-color:var(--surface-panel);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)}.watch-history-card__creatorAvatar:hover{--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.5}.watch-history-card__creatorAvatar: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.5}.watch-history-card__creatorAvatar:focus-visible,.watch-history-card__creatorAvatar:hover{--tw-ring-color:color-mix(in srgb,var(--color-accent) 38%,transparent)}.watch-history-card__creatorAvatar img{height:100%;width:100%;border-radius:var(--radius-full);-o-object-fit:cover;object-fit:cover}.watch-history-card__creatorName{cursor:pointer;background-color:transparent;padding:0;text-align:left;font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:500;color:var(--text-muted);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)}.watch-history-card__creatorName:hover{color:var(--color-accent)}.watch-history-card__creatorName:focus-visible{color:var(--color-accent)}#playerModal.watch-history-card__actions{display:block}.watch-history-card__actions{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}.watch-history-card__action{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:transparent;background-color:var(--surface-panel);padding:.5rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:500;color:var(--text-muted);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)}.watch-history-card__action:hover{border-color:var(--border-default);background-color:var(--surface-alt);color:var(--text-primary)}.watch-history-card__action:focus-visible{border-color:var(--border-default);background-color:var(--surface-alt);color:var(--text-primary)}.watch-history-card__action--primary{background-color:var(--color-accent-strong);color:var(--color-white)}.watch-history-card__action--primary:hover{background-color:var(--color-accent-pressed)}.watch-history-card__action--primary:focus-visible{background-color:var(--color-accent-pressed)}.watch-history-card__action--danger{background-color:var(--color-critical);color:var(--color-white)}.watch-history-card__action--danger:hover{background-color:var(--color-critical-strong)}.watch-history-card__action--danger:focus-visible{background-color:var(--color-critical-strong)}#playerModal.watch-history-card__moderation{display:block}.watch-history-card__moderation{margin-top:var(--space-sm);display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-sm)}.watch-history-card__hidden{margin-top:var(--space-md);border-radius:var(--radius-lg);border-width:1px;border-style:dashed;border-color:var(--border-default);background-color:var(--surface-overlay-panel-soft);font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary)}.watch-history-card__hidden .moderation-badge{align-self:flex-start}#playerModal .channel-card__avatar 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)}#playerModal .video-modal__moderation-bar .moderation-badge{--text-status-neutral-on:var(--color-text);--text-status-warning-on:var(--color-warning-on)}#playerModal .video-modal__moderation-bar .moderation-badge[data-variant=neutral]{color:var(--color-text)}.\!container{max-width:var(--layout-docs-max-width)!important;margin:0 auto!important;padding:var(--space-md)!important}.container{max-width:var(--layout-docs-max-width);margin:0 auto;padding:var(--space-md)}.qr-code__table{margin:var(--qr-code-table-margin,0)}.qr-code__cell,.qr-code__table{border:0;border-collapse:collapse}.qr-code__cell{padding:0;margin:0;width:var(--qr-code-cell-width);height:var(--qr-code-cell-height);background-color:var(--qr-code-color-light)}.qr-code__cell--dark{background-color:var(--qr-code-color-dark)}.qr-code__container[data-qr-code-mode=image] .qr-code__canvas{display:none}.qr-code__container[data-qr-code-mode=canvas] .qr-code__canvas,.qr-code__container[data-qr-code-mode=image] .qr-code__image{display:block}.qr-code__container[data-qr-code-mode=canvas] .qr-code__image{display:none}.fade-in{opacity:0;animation:interface-fade-in .28s ease-out forwards;animation-delay:var(--fade-in-delay,0ms)}.fade-in-delay-100{--fade-in-delay:100ms}.fade-in-delay-200{--fade-in-delay:200ms}.fade-in-delay-300{--fade-in-delay:300ms}.\!card[data-motion=enter]{opacity:0!important;animation:card-motion-enter .22s ease-out forwards!important}.card[data-motion=enter]{opacity:0;animation:card-motion-enter .22s ease-out forwards}.icon-button{display:inline-flex;align-items:center;justify-content:center;width:var(--icon-size-lg);height:var(--icon-size-lg);min-width:var(--icon-size-lg);min-height:var(--icon-size-lg);flex:0 0 var(--icon-size-lg);line-height:0;background-color:var(--color-neutral-700);color:var(--color-white);border:none;border-radius:var(--radius-full);cursor:pointer;transition:background-color .2s ease,box-shadow .2s ease}.icon-button:hover{background-color:var(--color-neutral-600)}.icon-button:active,.icon-button:focus{outline:none;box-shadow:0 0 0 var(--icon-button-ring-width) var(--color-ring-alert)}.icon-image{width:var(--icon-size-md);height:var(--icon-size-md);pointer-events:none}.accent-action-button,.channel-action-button{--accent-action-bg:color-mix(in srgb,var(--surface-overlay-panel) 78%,transparent);--accent-action-border:color-mix(in srgb,var(--border-overlay) 92%,transparent);--accent-action-bg-hover:color-mix(in srgb,var(--color-accent) 22%,var(--surface-overlay-panel));--accent-action-ring:color-mix(in srgb,var(--color-accent) 48%,transparent);--accent-action-bg-active:color-mix(in srgb,var(--color-accent-pressed) 28%,var(--surface-overlay-panel));color:var(--color-text);background-color:var(--accent-action-bg);border-color:var(--accent-action-border);border-style:solid;border-width:var(--border-width-hairline)}.accent-action-button:focus-visible,.accent-action-button:hover,.channel-action-button:focus-visible,.channel-action-button:hover{color:var(--color-accent-strong);background-color:var(--accent-action-bg-hover)}.accent-action-button:focus,.accent-action-button:focus-visible,.channel-action-button:focus,.channel-action-button:focus-visible{box-shadow:0 0 0 var(--icon-button-ring-width) var(--accent-action-ring)}.accent-action-button:active,.channel-action-button:active{color:var(--color-accent-pressed);background-color:var(--accent-action-bg-active)}.modal-nav .icon-button{box-shadow:none;padding:0;gap:0;width:var(--icon-size-lg);height:var(--icon-size-lg);min-width:var(--icon-size-lg);min-height:var(--icon-size-lg)}.modal-nav .icon-button:not(.accent-action-button):not(.channel-action-button){border:none}.modal-meta{gap:var(--space-xs)}.modal-meta,.modal-view-count{display:inline-flex;align-items:center}.modal-view-count{gap:var(--space-2xs);font-size:var(--font-size-sm);line-height:var(--space-md-plus);color:var(--color-muted-alt);white-space:nowrap}.view-count-text{white-space:nowrap;color:var(--color-muted-strong)}.ratio-16-9{position:relative;width:100%;padding-top:56.25%;background-color:var(--color-card)}.ratio-16-9>img{position:absolute;inset:0;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}#modalContainer{position:fixed;inset:0;z-index:var(--z-modal-root);pointer-events:none;isolation:isolate}#modalContainer>*{pointer-events:auto}#modalContainer>[id$=Modal]:not(#playerModal){z-index:var(--z-modal-root)}#modalContainer>[id$=Modal]:not(#playerModal)>.bv-modal-backdrop{z-index:var(--z-modal-overlay)}#modalContainer>[id$=Modal]:not(#playerModal) .bv-modal__panel{z-index:var(--z-modal-content)}#modalContainer>#profileModal,#profileModal[data-modal-stack=top]{z-index:var(--z-modal-top-root)}#modalContainer>#profileModal>.bv-modal-backdrop,#profileModal[data-modal-stack=top]>.bv-modal-backdrop{z-index:var(--z-modal-top-overlay)}#modalContainer>#profileModal .bv-modal__panel,#profileModal[data-modal-stack=top] .bv-modal__panel{z-index:var(--z-modal-top-content)}#modalContainer>#eventDetailsModal{z-index:var(--z-modal-top-root)}#modalContainer>#eventDetailsModal>.bv-modal-backdrop{z-index:var(--z-modal-top-overlay)}#modalContainer>#eventDetailsModal .bv-modal__panel{z-index:var(--z-modal-top-content)}.bv-modal[data-nested-modal-active=true]{pointer-events:none;visibility:hidden}#modalContainer>#loginModal{z-index:var(--z-modal-peak-root)}#modalContainer>#loginModal>.bv-modal-backdrop{z-index:var(--z-modal-peak-overlay)}#modalContainer>#loginModal .bv-modal__panel{z-index:var(--z-modal-peak-content)}#modalContainer>#playerModal{z-index:var(--z-modal-player)}#modalContainer>#playerModal .player-modal__container,#modalContainer>#playerModal .player-modal__content{z-index:var(--z-modal-content)}#playerModal{position:fixed;inset:0;z-index:var(--z-modal-player);display:none;flex-direction:column;align-items:stretch;justify-content:flex-start;width:100vw;height:100vh;padding:0;background-color:var(--color-surface);color:var(--color-subtle-text);--player-modal-inline-max:var(--layout-player-max-width);--player-modal-inline-padding:clamp(var(--space-md-plus),4vw,var(--space-2xl));overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}#playerModal.flex{display:block}#modalVideo{width:100%;aspect-ratio:16/9;background-color:var(--color-black)}.notification-portal{position:fixed;top:0;left:0;right:0;z-index:var(--z-overlay-toast);display:flex;flex-direction:column;align-items:center;gap:var(--space-md);padding:calc(var(--space-xl) + env(safe-area-inset-top)) var(--space-lg) var(--space-lg);pointer-events:none;opacity:0;transform:translateY(var(--toast-translate));transition:transform var(--motion-duration-base) var(--motion-ease-out),opacity var(--motion-duration-base) var(--motion-ease-out)}@media (min-width:640px){.notification-portal{padding-inline:var(--space-xl)}}.notification-portal:has(.notification-banner:not(.hidden)){opacity:1;transform:translateY(0)}.notification-portal--active{opacity:1;transform:translateY(0)}.notification-banner,.torrent-toast{--notify-accent:var(--notification-banner-accent);--notify-accent-strong:var(--notification-banner-accent-strong);--notify-border:var(--notification-banner-border);--notify-background:var(--notification-banner-background);--notify-foreground:var(--notification-banner-text);position:relative;display:flex;align-items:center;gap:var(--space-md);width:min(var(--notification-banner-max-inline),100%);padding:var(--space-md) var(--space-xl);border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--notify-border);background:var(--notify-background);color:var(--notify-foreground);box-shadow:0 var(--shadow-notification-offset-y) var(--shadow-notification-blur) var(--color-shadow-notification);backdrop-filter:blur(var(--overlay-backdrop-blur));pointer-events:auto;isolation:isolate;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;line-height:1.375;transition:transform var(--motion-duration-base) var(--motion-ease-out),opacity var(--motion-duration-base) var(--motion-ease-out)}.notification-banner:before,.torrent-toast:before{content:"";position:absolute;inset:0;border-radius:inherit;background:linear-gradient(135deg,var(--notify-accent) 0,var(--notify-accent-strong) 100%);opacity:.18;pointer-events:none}.notification-banner>*,.torrent-toast>*{position:relative;z-index:1}.notification-dismiss{display:inline-flex;height:2rem;width:2rem;align-items:center;justify-content:center;border-radius:var(--radius-full);border-width:1px;border-color:transparent;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);color:inherit;opacity:.75;background-color:var(--notify-dismiss-background)}.notification-dismiss:focus-visible,.notification-dismiss:hover{opacity:1;background-color:var(--notify-dismiss-background-hover)}.notification-dismiss svg{height:1rem;width:1rem}.notification-banner:not(.hidden){animation:notification-slide-in var(--motion-duration-base) var(--motion-ease-out)}.notification-banner--critical,.torrent-toast--error{--notify-accent:var(--notification-banner-critical-accent);--notify-accent-strong:var(--notification-banner-critical-accent-strong);--notify-border:var(--notification-banner-critical-border);--notify-background:var(--notification-banner-critical-background);--notify-foreground:var(--notification-banner-critical-text)}.notification-banner--success,.torrent-toast--success{--notify-accent:var(--notification-banner-success-accent);--notify-accent-strong:var(--notification-banner-success-accent-strong);--notify-border:var(--notification-banner-success-border);--notify-background:var(--notification-banner-success-background);--notify-foreground:var(--notification-banner-success-text)}.notification-banner--info,.torrent-toast--info{--notify-accent:var(--notification-banner-info-accent);--notify-accent-strong:var(--notification-banner-info-accent-strong);--notify-border:var(--notification-banner-info-border);--notify-background:var(--notification-banner-info-background);--notify-foreground:var(--notification-banner-info-text)}.notification-banner--warning,.torrent-toast--warn{--notify-accent:var(--notification-banner-warning-accent);--notify-accent-strong:var(--notification-banner-warning-accent-strong);--notify-border:var(--notification-banner-warning-border);--notify-background:var(--notification-banner-warning-background);--notify-foreground:var(--notification-banner-warning-text)}.status-banner .status-spinner{animation:spin 1s linear infinite;border:var(--space-3xs) solid var(--color-info-border);border-radius:var(--radius-full);border-top-color:var(--color-info);flex-shrink:0;height:var(--icon-size-md);width:var(--icon-size-md)}.status-banner .status-message{flex:1 1 auto}@keyframes notification-slide-in{0%{opacity:0;transform:translateY(var(--toast-translate))}to{opacity:1;transform:translateY(0)}}.status-spinner--inline{animation:spin 1s linear infinite;border:var(--space-3xs) solid var(--color-border-info-soft);border-radius:var(--radius-full);border-top-color:var(--color-info);display:inline-block;height:var(--space-md);width:var(--space-md)}body.iframe-form{background-color:transparent;color:var(--color-text);font-family:var(--font-family-sans);margin:var(--space-lg)}body.iframe-form .container{width:100%;max-width:100%;margin:0;padding:0}body.iframe-form .\!container{width:100%!important;max-width:100%!important;margin:0!important;padding:0!important}body.iframe-form .form-container{background-color:var(--color-surface-alt);border-radius:var(--radius-md);box-shadow:var(--shadow-md);color:var(--color-text);display:flex;flex-direction:column;gap:var(--space-md);padding:var(--space-lg)}body.iframe-form .form-container>p{margin:0}body.iframe-form form{display:flex;flex-direction:column}body.iframe-form form>*+*{margin-top:var(--space-sm)}body.iframe-form form h1,body.iframe-form form h2{color:var(--color-text);margin-top:var(--space-md)}body.iframe-form label{color:var(--color-subtle-text);display:block;font-weight:600;margin-top:var(--space-sm)}body.iframe-form label+.form-control,body.iframe-form label+.select,body.iframe-form label+.textarea{margin-top:var(--space-xs)}body.iframe-form .checkbox-group,body.iframe-form .radio-group{display:flex;flex-wrap:wrap;gap:var(--space-sm);margin-top:var(--space-xs)}body.iframe-form .checkbox-group label,body.iframe-form .radio-group label{align-items:center;color:var(--color-subtle-text);display:inline-flex;font-weight:500;gap:var(--space-xs);margin:0}body.iframe-form .checkbox-group input[type=checkbox],body.iframe-form .radio-group input[type=radio]{margin:0}body.iframe-form .radio-group input[type=radio]{accent-color:var(--color-accent-strong)}body.iframe-form button.btn{align-self:flex-start;margin-top:var(--space-md)}body.iframe-form button.\!btn{align-self:flex-start!important;margin-top:var(--space-md)!important}body.iframe-form #status{background:var(--color-surface);border-radius:var(--radius-sm);box-shadow:inset 0 0 0 var(--border-width-hairline) var(--color-border-translucent);color:var(--color-subtle-text);min-height:calc(var(--space-2xl) + var(--space-xl));padding:var(--space-sm);white-space:pre-wrap}body.iframe-form .status-line{color:var(--color-subtle-text);margin:var(--space-2xs) 0}body.iframe-form .status-line.error{color:var(--color-critical-strong)}body.iframe-form .status-line.\!error{color:var(--color-critical-strong)!important}body.iframe-form .status-line.success{color:var(--color-info-strong)}body.iframe-form .status-line.\!success{color:var(--color-info-strong)!important}body.iframe-form .status-line.warn{color:var(--color-warning-strong)}@media (max-width:640px){body.iframe-form{margin:var(--space-md)}body.iframe-form .form-container{padding:var(--space-md)}}#sidebarContainer{position:relative;overflow:visible}#sidebar{position:fixed;top:0;left:0;width:var(--sidebar-width);min-width:var(--sidebar-width);height:100vh;height:100dvh;color:var(--color-text-frosted-strong);overflow-x:hidden;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;overscroll-behavior-inline:contain;touch-action:pan-y;scrollbar-gutter:stable both-edges;background:transparent;border-right:none;backdrop-filter:none;-webkit-backdrop-filter:none;transition:width .2s ease,transform .3s ease;z-index:var(--z-sidebar);box-shadow:none;isolation:isolate}#app.sidebar-collapsed,#sidebar.sidebar-collapsed,#sidebar[data-state=collapsed],body.sidebar-collapsed{--sidebar-width:var(--sidebar-width-collapsed)}#app.sidebar-expanded,#sidebar.sidebar-expanded,#sidebar[data-state=expanded],body.sidebar-expanded{--sidebar-width:var(--sidebar-width-expanded)}#sidebar .sidebar__inner{padding-block-start:max(var(--space-sidebar-shell-block-start),calc(var(--app-header-centerline) - var(--sidebar-control-size)/2));padding-block-end:var(--space-sidebar-shell-block-end);padding-inline:var(--space-sidebar-shell-inline);border-radius:var(--radius-sidebar-shell);border:none;background:none;box-shadow:none;backdrop-filter:none;-webkit-backdrop-filter:none;isolation:isolate;transition:color .2s ease}#sidebar .sidebar__inner,.sidebar-scroll{display:flex;flex-direction:column;height:100%;width:100%;overflow:visible}.sidebar-scroll{gap:var(--space-sidebar-scroll-gap);min-height:0;flex:1 1 auto}.sidebar-panel{position:relative;width:100%;display:flex;flex-direction:column;gap:var(--space-sidebar-panel-gap);padding:var(--space-sidebar-panel-padding-block) var(--space-sidebar-panel-padding-inline);border-radius:var(--radius-sidebar-panel);border:none;background:transparent;box-shadow:none;color:inherit;backdrop-filter:none;-webkit-backdrop-filter:none;overflow:hidden;isolation:isolate;transition:border-color .2s ease,box-shadow .22s ease,background .22s ease}.sidebar-panel--primary{flex:1 1 auto;min-height:0;padding-block-start:0}.sidebar-panel__header{display:flex;justify-content:flex-end;margin-bottom:var(--space-md)}.sidebar-collapse-toggle{display:inline-flex;align-items:center;justify-content:center;width:var(--sidebar-control-size);height:var(--sidebar-control-size);border-radius:var(--radius-full);border:var(--border-width-hairline) solid var(--color-border-translucent-medium);background:var(--color-surface-tint-subtle);color:inherit;cursor:pointer;transition:background .18s ease,color .18s ease,border-color .18s ease;flex-shrink:0;box-sizing:border-box}.sidebar-collapse-toggle:hover{background:var(--color-border-translucent);border-color:var(--color-border-translucent-stronger);color:var(--color-text)}.sidebar-collapse-toggle:focus-visible{outline:var(--space-3xs) solid var(--color-secondary);outline-offset:var(--space-2xs)}.sidebar-collapse-toggle__icon{width:var(--icon-size-sm);height:var(--icon-size-sm);transition:transform .2s ease}.sidebar-nav{display:flex;flex-direction:column;gap:var(--space-xs);opacity:1;transition:opacity .16s ease;width:100%}.sidebar-nav-link{--sidebar-nav-translate-x:0;--sidebar-nav-link-highlight-opacity:0;--sidebar-nav-link-highlight-scale:0.96;display:flex;align-items:center;gap:var(--space-sidebar-footer-gap);padding-block:var(--space-sidebar-nav-padding-block);padding-inline-start:var(--space-sidebar-nav-padding-inline-start);padding-inline-end:var(--space-sidebar-nav-padding-inline-end);min-height:var(--sidebar-control-size);border-radius:var(--radius-sidebar-nav);font-size:var(--font-size-sidebar-nav);font-weight:600;letter-spacing:.015em;color:var(--color-text);text-decoration:none;width:100%;position:relative;border:var(--border-width-hairline) solid transparent;background:none;box-shadow:none;transform:translateX(var(--sidebar-nav-translate-x));transition:color .18s ease,border-color .2s ease,box-shadow .22s ease,transform .22s ease;overflow:hidden;isolation:isolate;backdrop-filter:none;-webkit-backdrop-filter:none}.sidebar-nav-link:after,.sidebar-nav-link:before{content:"";position:absolute;inset:calc(var(--border-width-hairline)*-1);border-radius:inherit;pointer-events:none;transition:opacity .22s ease,transform .24s ease;z-index:-1}.sidebar-nav-link:before{background:linear-gradient(120deg,var(--color-sidebar-accent-start) 0,var(--color-sidebar-accent-end) 100%);opacity:var(--sidebar-nav-link-highlight-opacity);transform:scale(var(--sidebar-nav-link-highlight-scale));filter:saturate(140%)}.sidebar-nav-link:after{background:linear-gradient(105deg,var(--color-sidebar-hover) 0,transparent 75%);opacity:.22;mix-blend-mode:screen;z-index:-2}.sidebar-nav-link:focus-visible,.sidebar-nav-link:hover{--sidebar-nav-link-highlight-opacity:1;--sidebar-nav-link-highlight-scale:1.02;--sidebar-nav-translate-x:var(--space-sidebar-nav-translate);color:var(--color-white);border-color:var(--color-sidebar-hover-strong);box-shadow:0 0 0 var(--border-width-hairline) var(--color-sidebar-hover-strong),var(--shadow-sidebar-accent)}.sidebar-nav-link:focus-visible{outline:none;box-shadow:var(--shadow-sidebar-focus-ring),var(--shadow-sidebar-accent)}.sidebar-nav-link:focus-visible:after,.sidebar-nav-link:hover:after{opacity:.35}.sidebar-nav-link--sub{font-size:var(--font-size-sidebar-subnav);font-weight:500;color:var(--color-sidebar-text-muted);padding-block:var(--space-sidebar-subnav-padding-block);letter-spacing:.02em;--sidebar-nav-link-highlight-opacity:0.25;--sidebar-nav-link-highlight-scale:1}.sidebar-icon{width:var(--space-lg);height:var(--space-lg);min-width:var(--space-lg);min-height:var(--space-lg);flex-shrink:0;display:block;filter:var(--sidebar-icon-filter);transition:filter .18s ease}.sidebar-footer{flex-direction:column;margin-top:auto;z-index:2}.sidebar-dropup-trigger,.sidebar-footer{display:flex;gap:var(--space-sidebar-footer-gap);position:relative}.sidebar-dropup-trigger{align-items:center;justify-content:space-between;width:100%;padding-block:var(--space-sidebar-trigger-padding-block);padding-inline-start:var(--space-sidebar-nav-padding-inline-start);padding-inline-end:var(--space-sidebar-nav-padding-inline-end);min-height:var(--sidebar-control-size);border-radius:var(--radius-sidebar-nav);border:var(--border-width-hairline) solid transparent;background:none;color:var(--color-text);font-size:var(--font-size-sidebar-trigger);font-weight:600;cursor:pointer;box-sizing:border-box;flex-shrink:0;overflow:hidden;isolation:isolate;backdrop-filter:none;-webkit-backdrop-filter:none;box-shadow:none;transition:border-color .2s ease,color .2s ease,box-shadow .22s ease}.sidebar-dropup-trigger:before{background:linear-gradient(120deg,var(--color-sidebar-accent-start) 0,var(--color-sidebar-accent-end) 100%);opacity:0;transform:scale(.96);transition:opacity .2s ease,transform .22s ease;z-index:-1;filter:saturate(140%)}.sidebar-dropup-trigger:after,.sidebar-dropup-trigger:before{content:"";position:absolute;inset:calc(var(--border-width-hairline)*-1);border-radius:inherit;pointer-events:none}.sidebar-dropup-trigger:after{background:linear-gradient(110deg,var(--color-sidebar-hover) 0,transparent 80%);opacity:.18;mix-blend-mode:screen;transition:opacity .2s ease;z-index:-2}.sidebar-dropup-trigger:focus-visible,.sidebar-dropup-trigger:hover{color:var(--color-white);border-color:var(--color-sidebar-hover-strong);box-shadow:0 0 0 var(--border-width-hairline) var(--color-sidebar-hover-strong),var(--shadow-sidebar-dropup)}.sidebar-dropup-trigger:focus-visible:before,.sidebar-dropup-trigger:hover:before{opacity:1;transform:scale(1.02)}.sidebar-dropup-trigger:focus-visible:after,.sidebar-dropup-trigger:hover:after{opacity:.35}.sidebar-dropup-trigger:focus-visible{outline:none;box-shadow:var(--shadow-sidebar-focus-ring),var(--shadow-sidebar-dropup)}.sidebar-dropup-trigger__glyph{width:var(--icon-size-md);height:var(--icon-size-md);opacity:.9}.sidebar-dropup-trigger__chevron{width:var(--icon-size-sm);height:var(--icon-size-sm);transition:transform .18s ease}.sidebar-dropup-trigger[aria-expanded=true] .sidebar-dropup-trigger__chevron,.sidebar-dropup-trigger__chevron.is-rotated{transform:rotate(180deg)}.sidebar-dropup-panel{position:absolute;left:0;right:0;bottom:calc(100% + var(--space-sidebar-popover-offset));border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-sidebar-border-strong);background:linear-gradient(160deg,color-mix(in srgb,var(--color-sidebar-surface) 88%,transparent) 0,color-mix(in srgb,var(--color-sidebar-surface-alt) 68%,transparent) 100%);box-shadow:inset 0 0 0 var(--border-width-fine) var(--color-sidebar-border),var(--shadow-sidebar-panel);padding:var(--space-sidebar-popover-padding-block) var(--space-sidebar-popover-padding-inline);color:var(--color-text-frosted);opacity:0;visibility:hidden;pointer-events:none;transform:translateY(var(--space-sm));transition:opacity .18s ease,transform .18s ease,visibility .18s ease,box-shadow .22s ease;backdrop-filter:blur(var(--sidebar-panel-backdrop-blur)) saturate(var(--sidebar-panel-backdrop-saturation));-webkit-backdrop-filter:blur(var(--sidebar-panel-backdrop-blur)) saturate(var(--sidebar-panel-backdrop-saturation));z-index:5;isolation:isolate}.sidebar-dropup-panel[data-state=expanded]{opacity:1;visibility:visible;pointer-events:auto;transform:translateY(0)}.sidebar-dropup-panel__inner{display:flex;flex-direction:column;gap:var(--space-sidebar-popover-gap);width:100%;padding-inline-end:var(--space-sidebar-dropup-scroll-reserve);max-height:var(--sidebar-dropup-max-height);overflow-y:auto;overflow-x:hidden;scrollbar-gutter:stable both-edges}.sidebar-dropup-panel__inner.is-measuring{position:absolute;width:-moz-max-content;width:max-content;max-width:none;visibility:hidden;pointer-events:none;left:var(--offscreen-offset);right:auto;top:auto;bottom:auto}.sidebar-dropup-measure{position:absolute;visibility:hidden;pointer-events:none;height:0;overflow:hidden}.sidebar-dropup-panel__inner>*{width:100%;min-width:0}#sidebar[data-footer-state=expanded] .sidebar-nav{opacity:0;pointer-events:none}#sidebar[data-footer-state=collapsed] .sidebar-nav{opacity:1}.sidebar-loading-wrapper{display:flex;justify-content:center;align-items:center;padding:var(--space-2xl) 0;width:100%;grid-column:1/-1}.sidebar-loading-indicator{display:inline-flex;align-items:center;gap:var(--space-sm);color:var(--color-muted-strong);font-size:var(--font-size-sm);font-weight:500;letter-spacing:.01em}.sidebar-loading-text{white-space:nowrap}#app{box-sizing:border-box;transition:margin-left .2s ease,width .2s ease,max-width .2s ease}#app[data-sidebar-state=collapsed]{margin-left:var(--sidebar-width-collapsed);width:calc(100vw - var(--sidebar-width-collapsed));max-width:calc(100vw - var(--sidebar-width-collapsed))}#app[data-sidebar-state=expanded]{margin-left:max(var(--sidebar-width-expanded-base),var(--sidebar-dropup-content-width));width:min(calc(100vw - var(--sidebar-width-expanded-base)),calc(100vw - var(--sidebar-dropup-content-width)));max-width:min(calc(100vw - var(--sidebar-width-expanded-base)),calc(100vw - var(--sidebar-dropup-content-width)))}#sidebar.sidebar-collapsed .sidebar-nav-link,body.sidebar-collapsed #sidebar .sidebar-nav-link{justify-content:center;width:var(--sidebar-control-size);padding:0;margin-inline:auto;border-radius:var(--radius-full);--sidebar-nav-translate-x:0;--sidebar-nav-link-highlight-scale:1}#sidebar.sidebar-expanded .sidebar-nav-link,body.sidebar-expanded #sidebar .sidebar-nav-link{justify-content:flex-start}#sidebar.sidebar-collapsed .sidebar-text,body.sidebar-collapsed #sidebar .sidebar-text{display:none}#sidebar.sidebar-collapsed .sidebar__inner,body.sidebar-collapsed #sidebar .sidebar__inner{padding-inline:var(--space-2xs)}#sidebar.sidebar-collapsed .sidebar-panel,body.sidebar-collapsed #sidebar .sidebar-panel{padding-inline:calc(var(--space-3xs)*3)}#sidebar.sidebar-collapsed .sidebar-panel__header,body.sidebar-collapsed #sidebar .sidebar-panel__header{justify-content:center}#sidebar.sidebar-collapsed .sidebar-collapse-toggle,body.sidebar-collapsed #sidebar .sidebar-collapse-toggle{margin-inline:auto}#sidebar .sidebar-toggle-icon--rotated{transform:rotate(180deg)}#sidebar.sidebar-collapsed .sidebar-toggle-icon--rotated,body.sidebar-collapsed #sidebar .sidebar-toggle-icon--rotated{transform:rotate(0deg)}#sidebar.sidebar-collapsed .sidebar-dropup-trigger,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger{justify-content:center;width:var(--sidebar-control-size);height:var(--sidebar-control-size);padding:0;margin-inline:auto;border-radius:var(--radius-full)}#sidebar.sidebar-collapsed .sidebar-dropup-trigger .sidebar-text,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger .sidebar-text,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger__chevron{display:none}#sidebar.sidebar-collapsed .sidebar-dropup-trigger__glyph,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger__glyph{margin:0}#sidebar.sidebar-collapsed .sidebar-nav-link:focus-visible,#sidebar.sidebar-collapsed .sidebar-nav-link:hover,body.sidebar-collapsed #sidebar .sidebar-nav-link:focus-visible,body.sidebar-collapsed #sidebar .sidebar-nav-link:hover{--sidebar-nav-translate-x:0}@media (min-width:768px){.sidebar-panel__header{display:flex}.sidebar-collapse-toggle{display:inline-flex}#sidebar{transform:translateX(0)!important;width:var(--sidebar-width);overflow:visible}#app[data-sidebar-state=collapsed]{margin-left:calc(var(--sidebar-width-collapsed) + var(--sidebar-content-gap));width:calc(100vw - var(--sidebar-width-collapsed) - var(--sidebar-content-gap));max-width:calc(100vw - var(--sidebar-width-collapsed) - var(--sidebar-content-gap))}#app[data-sidebar-state=expanded]{margin-left:max(calc(var(--sidebar-width-expanded-base) + var(--sidebar-content-gap)),calc(var(--sidebar-dropup-content-width) + var(--sidebar-content-gap)));width:min(calc(100vw - var(--sidebar-width-expanded-base) - var(--sidebar-content-gap)),calc(100vw - var(--sidebar-dropup-content-width) - var(--sidebar-content-gap)));max-width:min(calc(100vw - var(--sidebar-width-expanded-base) - var(--sidebar-content-gap)),calc(100vw - var(--sidebar-dropup-content-width) - var(--sidebar-content-gap)))}}@keyframes card-motion-enter{0%{opacity:0;transform:translateY(var(--motion-card-enter-translate))}to{opacity:1;transform:translateY(0)}}@keyframes video-thumbnail-fade-in{0%{opacity:0}to{opacity:1}}@keyframes interface-fade-in{0%{opacity:0}to{opacity:1}}@keyframes fadeIn{0%{opacity:0;transform:translateY(var(--motion-fade-in-translate))}to{opacity:1;transform:translateY(0)}}.status-line{margin-top:.25rem;margin-bottom:.25rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);line-height:1.375;color:var(--text-primary)}.status-line.error{color:var(--color-critical-strong)}.status-line.\!error{color:var(--color-critical-strong)!important}.status-line.warn{color:var(--color-warning-strong)}@keyframes ztr-rotate{to{transform:rotate(1turn)}}@keyframes ztr-dash{0%{stroke-dasharray:1,150;stroke-dashoffset:0}50%{stroke-dasharray:90,150;stroke-dashoffset:-35}to{stroke-dasharray:90,150;stroke-dashoffset:-124}}@keyframes svelte-181fgen-loader-animation{0%{transform:rotate(0)}to{transform:rotate(1turn)}}@keyframes svelte-181fgen-fade-in{to{opacity:1}}.dm-app-shell{display:flex;flex-direction:column;gap:var(--space-lg);padding:var(--space-lg);background:var(--surface-base);color:var(--text-primary);min-height:100vh}.dm-app-shell__main,.dm-app-shell__sidebar{border-radius:var(--radius-lg);border:var(--border-width-hairline) solid var(--border-default);background:var(--surface-panel);box-shadow:var(--shadow-soft)}.dm-app-shell__sidebar{display:flex;flex-direction:column;gap:var(--space-md);padding:var(--space-md)}.dm-app-shell__title{font-size:var(--font-size-lg);margin:0}.dm-app-shell__main{flex:1;display:flex;flex-direction:column;gap:var(--space-md);padding:var(--space-md);background:var(--surface-raised)}.dm-conversation-list{display:flex;flex-direction:column;gap:var(--space-sm)}.dm-conversation-list__header{display:flex;justify-content:space-between;align-items:center}.dm-conversation-list__title{font-size:var(--font-size-sm);text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);margin:0}.dm-conversation-list__items{display:flex;flex-direction:column;gap:var(--space-xs);overflow-y:auto;max-height:60vh;padding-right:var(--space-xs)}.dm-conversation-list__state{font-size:var(--font-size-sm);color:var(--text-muted);padding:var(--space-md);text-align:center;background:var(--surface-sunken);border-radius:var(--radius-md)}.dm-contact-row{display:flex;align-items:center;gap:var(--space-sm);padding:var(--space-sm) var(--space-md);border-radius:var(--radius-md);border:var(--border-width-hairline) solid var(--border-subtle);background:transparent;color:inherit;text-align:left;cursor:pointer}.dm-contact-row:focus-visible,.dm-contact-row:hover{background:var(--surface-muted);border-color:var(--border-strong);outline:none}.dm-contact-row--active{background:var(--surface-sunken);border-color:var(--border-strong)}.dm-contact-row__content{display:flex;flex-direction:column;gap:var(--space-2xs);flex:1}.dm-contact-row__header{display:flex;justify-content:space-between;align-items:baseline;gap:var(--space-xs)}.dm-contact-row__name{font-weight:600}.dm-contact-row__time{font-size:var(--font-size-2xs);color:var(--text-muted)}.dm-contact-row__preview{font-size:var(--font-size-xs);color:var(--text-muted)}.dm-contact-row__badge{align-self:flex-start;padding:var(--space-3xs) var(--space-xs);border-radius:var(--radius-full);background:var(--unread-badge-background);color:var(--unread-badge-text);font-size:var(--font-size-3xs)}.dm-avatar{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--radius-full);background:var(--surface-sunken);color:var(--text-muted);font-weight:600;overflow:hidden;border:var(--border-width-hairline) solid var(--border-subtle)}.dm-avatar__image{width:100%;height:100%;-o-object-fit:cover;object-fit:cover}.dm-message-thread{display:flex;flex-direction:column;gap:var(--space-sm);flex:1;min-height:0}.dm-message-thread__header{display:flex;align-items:baseline;gap:var(--space-sm)}.dm-message-thread__title{font-size:var(--font-size-base);margin:0}.dm-message-thread__status{font-size:var(--font-size-xs);color:var(--text-muted)}.dm-message-thread__timeline{display:flex;flex-direction:column;gap:var(--space-sm);padding:var(--space-sm);border-radius:var(--radius-md);background:var(--surface-panel);border:var(--border-width-hairline) solid var(--border-subtle);overflow-y:auto;min-height:var(--sidebar-width-expanded-base)}.dm-message-thread__state{padding:var(--space-md);text-align:center;color:var(--text-muted);background:var(--surface-sunken);border-radius:var(--radius-md)}.dm-message-bubble{max-width:85%;display:flex;flex-direction:column;gap:var(--space-2xs);padding:var(--space-sm) var(--space-md);border-radius:var(--radius-lg);border:var(--border-width-hairline) solid var(--message-bubble-border);background:var(--message-bubble-background);color:var(--message-bubble-text)}.dm-message-bubble__content{font-size:var(--font-size-sm)}.dm-message-bubble__meta{display:flex;gap:var(--space-xs);font-size:var(--font-size-2xs);color:var(--message-bubble-meta)}.dm-message-bubble__status{color:var(--text-muted)}.dm-message-bubble__status[data-status=pending]{color:var(--status-warning)}.dm-message-bubble__status[data-status=sending]{color:var(--status-info)}.dm-message-bubble__status[data-status=failed]{color:var(--status-danger)}.dm-message-thread__new{align-self:center;border-radius:var(--radius-full);border:var(--border-width-hairline) solid var(--border-subtle);padding:var(--space-2xs) var(--space-sm);font-size:var(--font-size-2xs);background:var(--surface-sunken);color:var(--text-muted)}.dm-message-thread__new:focus-visible,.dm-message-thread__new:hover{background:var(--surface-overlay-panel-soft);color:var(--text-primary);outline:none}.dm-day-divider{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:var(--space-xs);color:var(--text-muted);font-size:var(--font-size-2xs)}.dm-day-divider__line{height:var(--border-width-hairline);background:var(--border-subtle)}.dm-notification-center{display:flex;flex-direction:column;gap:var(--space-sm)}.dm-notification-center__filters{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-xs)}.dm-notification-center__filter-button{border-radius:var(--radius-full);border:var(--border-width-hairline) solid var(--border-subtle);background:var(--surface-sunken);padding:var(--space-2xs) var(--space-sm);font-size:var(--font-size-2xs);font-weight:600;color:var(--text-muted);transition:color var(--motion-duration-fast) var(--motion-ease-out),background var(--motion-duration-fast) var(--motion-ease-out),border-color var(--motion-duration-fast) var(--motion-ease-out)}.dm-notification-center__filter-button:focus-visible,.dm-notification-center__filter-button:hover{outline:none;border-color:var(--border-strong);background:var(--surface-raised);color:var(--text-primary)}.dm-notification-center__filter-button--active{border-color:var(--border-strong);background:var(--surface-overlay-panel);color:var(--text-primary)}.dm-notification-center__groups{display:flex;flex-direction:column;gap:var(--space-md)}.dm-notification-center__group{display:flex;flex-direction:column;gap:var(--space-xs)}.dm-notification-center__group-title{font-size:var(--font-size-2xs);font-weight:600;text-transform:uppercase;letter-spacing:var(--tracking-wide);color:var(--text-muted)}.dm-notification-center__list{display:flex;flex-direction:column;gap:var(--space-xs)}.dm-notification-center__empty{font-size:var(--font-size-xs);color:var(--text-muted)}.dm-notification-item{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:var(--space-sm);padding:var(--space-sm);border-radius:var(--radius-md);border:var(--border-width-hairline) solid var(--border-subtle);background:var(--surface-sunken);font-size:var(--font-size-xs);text-align:left;transition:border-color var(--motion-duration-fast) var(--motion-ease-out),background var(--motion-duration-fast) var(--motion-ease-out)}.dm-notification-item:focus-visible,.dm-notification-item:hover{outline:none;border-color:var(--border-strong);background:var(--surface-raised)}.dm-notification-item__icon{display:inline-flex;align-items:center;justify-content:center;width:var(--icon-size-md);height:var(--icon-size-md);border-radius:var(--radius-full);background:var(--surface-overlay-panel-soft);color:var(--text-muted);font-size:var(--font-size-xs)}.dm-notification-item__body{display:flex;flex-direction:column;gap:var(--space-2xs);min-width:0}.dm-notification-item__title{font-weight:600;color:var(--text-primary)}.dm-notification-item__message{color:var(--text-muted)}.dm-notification-item__meta{display:flex;align-items:center;gap:var(--space-2xs);color:var(--text-muted)}.dm-notification-item__timestamp{font-variant-numeric:tabular-nums;white-space:nowrap}.dm-composer{display:flex;flex-direction:column;gap:var(--space-xs)}.dm-composer__label{font-size:var(--font-size-xs);color:var(--text-muted)}.dm-composer__input{resize:vertical;border-radius:var(--radius-md);border:var(--border-width-hairline) solid var(--border-default);background:var(--surface-base);color:var(--text-primary);padding:var(--space-sm);font-family:inherit}.dm-composer__input:focus-visible{outline:var(--size-2px) solid var(--color-accent);outline-offset:var(--size-2px)}.dm-composer__actions{justify-content:space-between}.dm-composer__actions,.dm-composer__tools{display:flex;align-items:center;gap:var(--space-sm)}.dm-composer__tools{flex-wrap:wrap}.dm-composer__attach,.dm-composer__hint,.dm-composer__privacy{font-size:var(--font-size-2xs);color:var(--text-muted)}.dm-composer__attach,.dm-composer__privacy{border-radius:var(--radius-md);border:var(--border-width-hairline) solid var(--border-default);background:var(--surface-base);padding:var(--space-2xs) var(--space-sm);cursor:pointer}.dm-composer__attach:focus-visible,.dm-composer__attach:hover,.dm-composer__privacy:focus-visible,.dm-composer__privacy:hover{background:var(--surface-overlay-panel-soft);color:var(--text-primary);outline:none}.dm-composer__send{border-radius:var(--radius-md);border:var(--border-width-hairline) solid var(--border-strong);background:var(--surface-overlay-panel);color:var(--text-primary);padding:var(--space-xs) var(--space-md);font-weight:600;cursor:pointer}.dm-composer__send:focus-visible,.dm-composer__send:hover{background:var(--surface-overlay-panel-soft);outline:none}.dm-composer__status{font-size:var(--font-size-2xs);color:var(--status-danger);min-height:var(--space-md)}@media (min-width:768px){.dm-app-shell{flex-direction:row;align-items:stretch}.dm-app-shell__sidebar{flex:0 0 var(--size-18rem);max-width:var(--grid-card-min-width)}.dm-app-shell__main,.dm-app-shell__sidebar{height:calc(100vh - var(--space-lg)*2)}.dm-conversation-list__items{max-height:none;flex:1}}@media (min-width:1024px){.dm-app-shell__sidebar{flex-basis:var(--grid-card-min-width)}.dm-message-thread__timeline{padding:var(--space-md)}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.\!visible{visibility:visible!important}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-4{bottom:1rem}.bottom-6{bottom:1.5rem}.left-4{left:1rem}.right-3{right:.75rem}.right-4{right:1rem}.right-6{right:1.5rem}.top-0{top:0}.top-3{top:.75rem}.z-10{z-index:10}.z-50{z-index:50}.z-\[140\]{z-index:140}.z-overlay-floating{z-index:var(--z-overlay-floating)}.z-overlay-toast{z-index:var(--z-overlay-toast)}.order-1{order:1}.order-2{order:2}.col-span-full{grid-column:1/-1}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-4{margin-top:1rem;margin-bottom:1rem}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.-ml-1{margin-left:-.25rem}.-ml-2{margin-left:-.5rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.mt-sm{margin-top:var(--space-sm)}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-square{aspect-ratio:1/1}.h-1{height:.25rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2xl{height:var(--space-2xl)}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-auto{height:auto}.h-full{height:100%}.max-h-\[90vh\]{max-height:90vh}.max-h-modal-body{max-height:var(--modal-body-max-height)}.max-h-modal-pane{max-height:var(--modal-pane-max-height)}.max-h-modal-sheet{max-height:var(--modal-sheet-max-height)}.max-h-modal-shell{max-height:var(--modal-shell-max-height)}.min-h-0{min-height:0}.min-h-\[200px\]{min-height:200px}.min-h-\[var\(--size-80px\)\]{min-height:var(--size-80px)}.min-h-\[var\(--size-96px\)\]{min-height:var(--size-96px)}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-20{width:5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-44{width:11rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-72{width:18rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[calc\(100\%-var\(--sidebar-mobile-gutter\)\)\]{width:calc(100% - var(--sidebar-mobile-gutter))}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-\[200px\]{max-width:200px}.max-w-docs{max-width:var(--layout-docs-max-width)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-popover-safe{max-width:var(--popover-inline-safe-max)}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow,.grow{flex-grow:1}.origin-bottom-right{transform-origin:bottom right}.rotate-90{--tw-rotate:90deg}.rotate-90,.scale-\[1\.2\]{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))}.scale-\[1\.2\]{--tw-scale-x:1.2;--tw-scale-y:1.2}.transform{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))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.cursor-wait{cursor:wait}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize-y{resize:vertical}.resize{resize:both}.list-disc{list-style-type:disc}.auto-rows-fr{grid-auto-rows:minmax(0,1fr)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-sm{gap:var(--space-sm)}.gap-xl{gap:var(--space-xl)}.gap-xs{gap:var(--space-xs)}.gap-y-6{row-gap:1.5rem}.gap-y-8{row-gap:2rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-border-translucent>:not([hidden])~:not([hidden]){border-color:var(--border-overlay)}.self-start{align-self:flex-start}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:var(--radius-full)}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l-2{border-left-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-none{border-style:none}.border-border{border-color:var(--border-default)}.border-border-strong{border-color:var(--border-strong)}.border-border-translucent{border-color:var(--border-overlay)}.border-border-translucent-bright{border-color:var(--color-border-translucent-bright)}.border-border-translucent-medium{border-color:var(--color-border-translucent-medium)}.border-overlay{border-color:var(--border-overlay)}.border-overlay-strong{border-color:var(--border-overlay-strong)}.border-status-danger-border{border-color:var(--status-danger-border)}.border-status-info-border{border-color:var(--status-info-border)}.border-status-success-border{border-color:var(--status-success-border)}.border-status-warning-border{border-color:var(--status-warning-border)}.border-surface-strong{border-color:var(--border-strong)}.border-opacity-20{--tw-border-opacity:0.2}.bg-accent-strong{background-color:var(--color-accent-strong)}.bg-overlay-muted{background-color:var(--surface-overlay-muted)}.bg-overlay-panel{background-color:var(--surface-overlay-panel)}.bg-overlay-panel-soft{background-color:var(--surface-overlay-panel-soft)}.bg-overlay-strong{background-color:var(--surface-overlay-strong)}.bg-panel{background-color:var(--surface-panel)}.bg-status-danger-surface{background-color:var(--status-danger-surface)}.bg-status-info-surface{background-color:var(--status-info-surface)}.bg-status-success-surface{background-color:var(--status-success-surface)}.bg-status-warning-surface{background-color:var(--status-warning-surface)}.bg-surface{background-color:var(--surface-base)}.bg-surface-alt{background-color:var(--surface-alt)}.bg-transparent{background-color:transparent}.bg-white{background-color:var(--color-white)}.bg-opacity-95{--tw-bg-opacity:0.95}.bg-overlay-channel{background-image:var(--surface-overlay-banner-gradient-channel)}.stroke-border-translucent{stroke:var(--border-overlay)}.stroke-current{stroke:currentColor}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-lg{padding:var(--space-lg)}.p-md{padding:var(--space-md)}.px-0{padding-left:0;padding-right:0}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-lg{padding-left:var(--space-lg);padding-right:var(--space-lg)}.px-md{padding-left:var(--space-md);padding-right:var(--space-md)}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.py-md{padding-top:var(--space-md);padding-bottom:var(--space-md)}.py-xl{padding-top:var(--space-xl);padding-bottom:var(--space-xl)}.pb-16{padding-bottom:4rem}.pb-2{padding-bottom:.5rem}.pb-6{padding-bottom:1.5rem}.pb-8{padding-bottom:2rem}.pl-2{padding-left:.5rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-1{padding-right:.25rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-family-mono)}.font-sans{font-family:var(--font-family-sans)}.text-2xl{font-size:var(--font-size-2xl);line-height:var(--line-height-relaxed)}.text-2xs{font-size:var(--font-size-2xs);line-height:var(--line-height-tight)}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-3xs{font-size:var(--font-size-3xs);line-height:var(--line-height-tight)}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-4xs{font-size:var(--font-size-4xs);line-height:var(--line-height-tight)}.text-\[0\.75rem\]{font-size:.75rem}.text-base{font-size:var(--font-size-base)}.text-base,.text-lg{line-height:var(--line-height-base)}.text-lg{font-size:var(--font-size-lg)}.text-sm{font-size:var(--font-size-sm);line-height:var(--line-height-snug)}.text-xl{font-size:var(--font-size-xl);line-height:var(--line-height-relaxed)}.text-xs{font-size:var(--font-size-xs);line-height:var(--line-height-tight)}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.ordinal{--tw-ordinal:ordinal}.ordinal,.tabular-nums{font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.tabular-nums{--tw-numeric-spacing:tabular-nums}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-extra-wide{letter-spacing:var(--tracking-extra-wide)}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-accent{color:var(--color-accent)}.text-critical{color:var(--color-critical)}.text-critical-strong{color:var(--color-critical-strong)}.text-info{color:var(--color-info)}.text-info-strong{color:var(--color-info-strong)}.text-muted{color:var(--text-muted)}.text-muted-strong{color:var(--text-muted-strong)}.text-primary{color:var(--text-primary)}.text-status-danger{color:var(--status-danger)}.text-status-danger-on{color:var(--status-danger-on)}.text-status-info{color:var(--status-info)}.text-status-info-on{color:var(--status-info-on)}.text-status-neutral{color:var(--status-neutral)}.text-status-private-on{color:var(--status-private-on)}.text-status-success{color:var(--status-success)}.text-status-success-on{color:var(--status-success-on)}.text-status-warning{color:var(--status-warning)}.text-status-warning-on{color:var(--status-warning-on)}.text-subtle{color:var(--text-subtle)}.text-text{color:var(--text-primary)}.text-text-frosted{color:var(--text-frosted-primary)}.text-text-muted{color:var(--text-muted)}.text-text-strong{color:var(--color-text-strong)}.text-warning{color:var(--status-warning)}.text-warning-strong{color:var(--status-warning-strong)}.text-white{color:var(--color-white)}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.opacity-95{opacity:.95}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color)}.shadow-inner,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.shadow-md{--tw-shadow:var(--shadow-md);--tw-shadow-colored:var(--shadow-md)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.blur{--tw-blur:blur(8px)}.blur,.blur-xl{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-xl{--tw-blur:blur(24px)}.\!filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)!important}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px)}.backdrop-blur-md,.backdrop-blur-sm{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.transition{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-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-fast{transition-duration:var(--motion-duration-fast)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@media (max-width:640px){.bv-grid-video{gap:var(--space-md);grid-template-columns:1fr}}@media (prefers-reduced-motion:reduce){:root{--motion-duration-fast:0s;--motion-duration-base:0s;--motion-duration-slow:0s;--modal-motion-duration:0s}*,:after,:before{animation-delay:0s!important;animation-duration:0s!important;animation-iteration-count:1!important;animation-timing-function:linear!important;transition-delay:0s!important;transition-duration:0s!important;transition-timing-function:linear!important}.player-modal__similar-card:before{transition:none!important}#app,#playerModal .player-modal__content,#sidebar,.btn,.btn-ghost,.card,.card[data-motion=enter],.fade-in,.icon-button,.menu__item,.modal-nav,.notification-banner,.notification-portal,.popover-panel,.popover__panel,.progress-bar-fill,.sidebar-collapse-toggle,.sidebar-collapse-toggle__icon,.sidebar-dropup-panel,.sidebar-dropup-trigger,.sidebar-dropup-trigger__chevron,.sidebar-loading-indicator,.sidebar-loading-wrapper,.sidebar-nav,.sidebar-nav-link,.status-banner .status-spinner,.status-spinner--inline,.torrent-toast,.video-modal__panel,.watch-history-card,.watch-history-card__action,.watch-history-card__creatorAvatar,.watch-history-card__creatorName,.watch-history-card__title{transition-property:none!important}.popover-panel,.popover__panel{transform:none!important}.popover-panel[data-state=open],.popover__panel[data-state=open]{opacity:1}.popover-panel[data-state=closed],.popover__panel[data-state=closed]{opacity:0}.card[data-motion=enter],.fade-in,[data-video-thumbnail][data-thumbnail-loaded=true]{opacity:1;animation:none!important}.notification-banner,.status-banner .status-spinner,.status-spinner--inline,.torrent-toast{animation:none!important}.notification-portal{transform:none!important;opacity:1!important}}@media (min-width:640px){.sm\:flex#playerModal{display:block}}@media (min-width:768px){.md\:hidden .bv-modal,.md\:hidden.profile-pane{display:none!important}.md\:flex#playerModal{display:block}}.placeholder\:text-muted::-moz-placeholder{color:var(--text-muted)}.placeholder\:text-muted::placeholder{color:var(--text-muted)}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;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))}.hover\:border-status-danger-border:hover{border-color:var(--status-danger-border)}.hover\:bg-overlay-panel:hover{background-color:var(--surface-overlay-panel)}.hover\:bg-panel-hover:hover{background-color:var(--surface-panel-hover)}.hover\:text-accent:hover{color:var(--color-accent)}.hover\:text-critical:hover{color:var(--color-critical)}.hover\:text-info-strong:hover{color:var(--color-info-strong)}.hover\:text-text:hover{color:var(--text-primary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-0:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-0:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--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)}.focus\:ring-info:focus{--tw-ring-color:var(--color-info)}.focus\:ring-status-info:focus{--tw-ring-color:var(--status-info)}.focus\:ring-status-success:focus{--tw-ring-color:var(--status-success)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus\:ring-offset-surface:focus{--tw-ring-offset-color:var(--surface-base)}.focus-visible\:bg-primary:focus-visible{background-color:var(--color-primary)}.focus-visible\:outline:focus-visible{outline-style:solid}.focus-visible\:outline-2:focus-visible{outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-info:focus-visible{outline-color:var(--color-info)}.active\:scale-95:active{--tw-scale-x:.95;--tw-scale-y:.95;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))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05;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))}.group:hover .group-hover\:text-primary{color:var(--text-primary)}@media (min-width:640px){.sm\:order-1{order:1}.sm\:order-2{order:2}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:flex{display:flex}.sm\:w-1\/3{width:33.333333%}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:20rem}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-start{align-items:flex-start}.sm\:items-end{align-items:flex-end}.sm\:items-center{align-items:center}.sm\:justify-end{justify-content:flex-end}.sm\:justify-between{justify-content:space-between}.sm\:gap-2{gap:.5rem}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px*var(--tw-space-y-reverse))}.sm\:self-end{align-self:flex-end}.sm\:p-5{padding:1.25rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-0{padding-top:0;padding-bottom:0}.sm\:pt-8{padding-top:2rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:flex{display:flex}.md\:hidden{display:none}.md\:h-64{height:16rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:items-center{align-items:center}.md\:justify-end{justify-content:flex-end}.md\:gap-md{gap:var(--space-md)}.md\:pb-12{padding-bottom:3rem}}@media (min-width:1024px){.lg\:ml-auto{margin-left:auto}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-wrap{flex-wrap:wrap}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:text-right{text-align:right}}@media (min-width:1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 463beaaa..e5b4a7df 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -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); diff --git a/css/tokens.css b/css/tokens.css index 5113e849..a9db6f89 100644 --- a/css/tokens.css +++ b/css/tokens.css @@ -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; diff --git a/debug_comments.js b/debug_comments.js deleted file mode 100644 index f8d52224..00000000 --- a/debug_comments.js +++ /dev/null @@ -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)); diff --git a/docs/auth-architecture.md b/docs/auth-architecture.md index 33ea4077..a5135dcb 100644 --- a/docs/auth-architecture.md +++ b/docs/auth-architecture.md @@ -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) diff --git a/docs/dm-privacy-model.md b/docs/dm-privacy-model.md new file mode 100644 index 00000000..40a9fdbc --- /dev/null +++ b/docs/dm-privacy-model.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. diff --git a/docs/nostr-client-overview.md b/docs/nostr-client-overview.md index f5e19a0d..f3e3ef88 100644 --- a/docs/nostr-client-overview.md +++ b/docs/nostr-client-overview.md @@ -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. diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 976ec556..c92d69da 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -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', ]` 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', , ]` with optional address pointer `['a', , ]`, and `['p', ]` when the origin author is known; inherits schema append tags | Empty content | +| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', , ]` with optional address pointer `['a', , ]`, and `['p', ]` 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`, , ?], `['K', ]`, and `['P', , ?]` plus parent metadata `['E'\|`I`, , ?, ], `['K', ]`, `['P', , ?]`; inherits schema append tags | Plain text body sanitized to valid UTF-8 | +| DM attachment (`NOTE_TYPES.DM_ATTACHMENT`) | `15` | `['p', ]`, `['x', ]`, `['url', ]`, optional `['name', ]`, `['type', ]`, `['size', ]`, optional `['k', ]`; inherits schema append tags | Empty content; attachment metadata is represented as tags | +| DM read receipt (`NOTE_TYPES.DM_READ_RECEIPT`) | `20001` | `['p', ]`, `['e', ]`, optional `['k', ]`; inherits schema append tags | Empty content; ephemeral receipt for message activity | +| DM typing indicator (`NOTE_TYPES.DM_TYPING`) | `20002` | `['p', ]`, optional `['e', ]`, `['t','typing']`, `['expiration', ]`; 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', ]`, 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. diff --git a/docs/signing-adapter.md b/docs/signing-adapter.md new file mode 100644 index 00000000..4e23e8a6 --- /dev/null +++ b/docs/signing-adapter.md @@ -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. diff --git a/examples/reproducers/2-repost-comment-visibility-bug/README.md b/examples/reproducers/2-repost-comment-visibility-bug/README.md new file mode 100644 index 00000000..f4bb3706 --- /dev/null +++ b/examples/reproducers/2-repost-comment-visibility-bug/README.md @@ -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. +``` diff --git a/examples/reproducers/2-repost-comment-visibility-bug/reproduce_issue.js b/examples/reproducers/2-repost-comment-visibility-bug/reproduce_issue.js new file mode 100644 index 00000000..9c3f458c --- /dev/null +++ b/examples/reproducers/2-repost-comment-visibility-bug/reproduce_issue.js @@ -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."); +} diff --git a/index.html b/index.html index 429985e8..6b0f8402 100644 --- a/index.html +++ b/index.html @@ -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" diff --git a/js/app.js b/js/app.js index 0f3b3c7c..271ddac0 100644 --- a/js/app.js +++ b/js/app.js @@ -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({ diff --git a/js/attachments/attachmentUtils.js b/js/attachments/attachmentUtils.js new file mode 100644 index 00000000..080c162d --- /dev/null +++ b/js/attachments/attachmentUtils.js @@ -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 }; diff --git a/js/auth/signingAdapter.js b/js/auth/signingAdapter.js new file mode 100644 index 00000000..8c07f79e --- /dev/null +++ b/js/auth/signingAdapter.js @@ -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, + }; +} diff --git a/js/channelProfile.js b/js/channelProfile.js index 496d60b4..03995ff9 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -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."); diff --git a/js/dmDecryptor.js b/js/dmDecryptor.js index b6c47298..36cb534b 100644 --- a/js/dmDecryptor.js +++ b/js/dmDecryptor.js @@ -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, }; - diff --git a/js/nostr/client.js b/js/nostr/client.js index 9a263105..b4f8b75a 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -57,6 +57,7 @@ import { buildVideoPostEvent, buildRepostEvent, buildWatchHistoryEvent, + buildDmAttachmentEvent, getNostrEventSchema, NOTE_TYPES, } from "../nostrEventSchemas.js"; @@ -78,6 +79,10 @@ import { listVideoComments as listVideoCommentsForClient, subscribeVideoComments as subscribeVideoCommentsForClient, } from "./commentEvents.js"; +import { + publishDmReadReceipt as publishDmReadReceiptForClient, + publishDmTypingIndicator as publishDmTypingIndicatorForClient, +} from "./dmSignalEvents.js"; import { logCountTimeoutCleanupFailure, logRelayCountFailure, @@ -106,7 +111,22 @@ import { resolveSimplePoolConstructor, shimLegacySimplePoolMethods, } from "./toolkit.js"; +import { encryptNip04InWorker } from "./nip04WorkerClient.js"; +import { + decryptDmInWorker, + isDmDecryptWorkerSupported, +} from "./dmDecryptWorkerClient.js"; +import { + sanitizeDecryptError, + summarizeDmEventForLog, +} from "./dmDecryptDiagnostics.js"; import { devLogger, userLogger } from "../utils/logger.js"; +import { LRUCache } from "../utils/lruCache.js"; +import { updateConversationFromMessage, writeMessages } from "../storage/dmDb.js"; +import { + DM_RELAY_WARNING_FALLBACK, + resolveDmRelaySelection, +} from "../services/dmNostrService.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS, DEFAULT_NIP07_PERMISSION_METHODS, @@ -411,6 +431,10 @@ function waitForTransaction(tx) { * - If the TTL expires, the cache is cleared on restore. */ class EventsCacheStore { + /** + * Initializes the EventsCacheStore. + * Tracks fingerprints of persisted items to avoid redundant writes. + */ constructor() { this.dbPromise = null; this.persistedEventFingerprints = new Map(); @@ -505,6 +529,12 @@ class EventsCacheStore { return meta; } + /** + * Restores the full state from IndexedDB. + * + * @returns {Promise<{version: number, savedAt: number, events: Map, tombstones: Map}|null>} + * The restored snapshot or null if cache is missing/expired. + */ async restoreSnapshot() { const db = await this.getDb(); if (!db) { @@ -567,6 +597,14 @@ class EventsCacheStore { }; } + /** + * Persists the current state to IndexedDB. + * Only writes changes (diffs against fingerprints). + * + * @param {{events: Map, tombstones: Map, savedAt: number}} payload - The state to save. + * @returns {Promise<{persisted: boolean, eventWrites: number, eventDeletes: number, tombstoneWrites: number, tombstoneDeletes: number}>} + * Stats about the persistence operation. + */ async persistSnapshot(payload) { const db = await this.getDb(); if (!db) { @@ -875,45 +913,6 @@ function cloneEventForCache(event) { const DM_DECRYPT_CACHE_LIMIT = 256; const DM_EVENT_KINDS = Object.freeze([4, 1059]); -class SimpleLruCache { - constructor(limit = 100) { - const resolvedLimit = Number.isFinite(limit) && limit > 0 ? limit : 100; - this.limit = Math.floor(resolvedLimit); - this.map = new Map(); - } - - get(key) { - if (!this.map.has(key)) { - return undefined; - } - - const value = this.map.get(key); - this.map.delete(key); - this.map.set(key, value); - return value; - } - - set(key, value) { - if (key === undefined || key === null) { - return; - } - - if (this.map.has(key)) { - this.map.delete(key); - } - this.map.set(key, value); - - while (this.map.size > this.limit) { - const oldestKey = this.map.keys().next().value; - this.map.delete(oldestKey); - } - } - - clear() { - this.map.clear(); - } -} - function buildDmFilters(actorPubkey, { since, until, limit } = {}) { const normalizedActor = normalizeActorKey(actorPubkey); const filters = []; @@ -1028,6 +1027,10 @@ export { }; export class NostrClient { + /** + * Initializes the NostrClient. + * Sets up connection pools, state maps, and default relays. + */ constructor() { this.pool = null; this.poolPromise = null; @@ -1036,16 +1039,32 @@ export class NostrClient { this.readRelays = Array.from(this.relays); this.writeRelays = Array.from(this.relays); - // Store all events so older links still work + /** + * @type {Map<string, object>} + * Maps event ID to the converted Video object. + * Stores ALL fetched versions to ensure old links resolve. + */ this.allEvents = new Map(); - // Keep a separate cache of raw events so we can republish the exact payload + /** + * @type {Map<string, object>} + * Maps event ID to the raw Nostr event object. + * Kept for signature verification and republishing (e.g. NIP-94 mirror). + */ this.rawEvents = new Map(); - // “activeMap” holds only the newest version for each root + /** + * @type {Map<string, object>} + * Maps `videoRootId` (or `pubkey:dTag`) to the latest valid Video object. + * This is the "materialized view" used by the UI. + */ this.activeMap = new Map(); - // Track the newest deletion timestamp for each active key + /** + * @type {Map<string, number>} + * Maps `activeKey` to the timestamp of its latest deletion. + * Prevents older events from reappearing after a delete. + */ this.tombstones = new Map(); this.rootCreatedAtByRoot = new Map(); @@ -1164,7 +1183,7 @@ export class NostrClient { }, }, }); - this.dmDecryptCache = new SimpleLruCache(DM_DECRYPT_CACHE_LIMIT); + this.dmDecryptCache = new LRUCache({ maxSize: DM_DECRYPT_CACHE_LIMIT }); this.dmDecryptor = null; this.dmDecryptorPromise = null; this.sessionActorCipherClosures = null; @@ -3625,6 +3644,20 @@ export class NostrClient { }); } + async publishDmReadReceipt(payload, options = {}) { + return publishDmReadReceiptForClient(this, payload, options, { + shouldRequestExtensionPermissions, + DEFAULT_NIP07_PERMISSION_METHODS, + }); + } + + async publishDmTypingIndicator(payload, options = {}) { + return publishDmTypingIndicatorForClient(this, payload, options, { + shouldRequestExtensionPermissions, + DEFAULT_NIP07_PERMISSION_METHODS, + }); + } + async fetchVideoComments(target, options = {}) { return listVideoCommentsForClient(this, target, options); } @@ -3649,6 +3682,8 @@ export class NostrClient { * 1. Restores local data (IndexedDB/localStorage) to render the UI immediately ("stale-while-revalidate"). * 2. Schedules the restoration of any stored NIP-46 remote signer session. * 3. Initializes the NostrTools `SimplePool` and connects to the configured relays. + * + * @returns {Promise<void>} Resolves when relays are connected (or at least attempted). */ async init() { devLogger.log("Connecting to relays..."); @@ -3940,6 +3975,13 @@ export class NostrClient { return DM_DECRYPT_CACHE_LIMIT; } + getDmDecryptCacheStats() { + if (!this.dmDecryptCache || typeof this.dmDecryptCache.getStats !== "function") { + return null; + } + return this.dmDecryptCache.getStats(); + } + clearDmDecryptCache() { if (this.dmDecryptCache) { this.dmDecryptCache.clear(); @@ -4054,6 +4096,44 @@ export class NostrClient { typeof sessionActor.privateKey === "string" && sessionActor.privateKey ) { + const canUseWorker = isDmDecryptWorkerSupported(); + const sessionPrivateKey = sessionActor.privateKey; + + if (canUseWorker) { + addCandidate( + "nip44", + (targetPubkey, ciphertext, options = {}) => + decryptDmInWorker({ + scheme: "nip44", + privateKey: sessionPrivateKey, + targetPubkey, + ciphertext, + event: options?.event, + }), + { + priority: -6, + source: "worker", + supportsGiftWrap: true, + }, + ); + + addCandidate( + "nip04", + (targetPubkey, ciphertext, options = {}) => + decryptDmInWorker({ + scheme: "nip04", + privateKey: sessionPrivateKey, + targetPubkey, + ciphertext, + event: options?.event, + }), + { + priority: -4, + source: "worker", + }, + ); + } + if ( !this.sessionActorCipherClosures || this.sessionActorCipherClosuresPrivateKey !== sessionActor.privateKey @@ -4101,8 +4181,8 @@ export class NostrClient { result = await decryptDM(event, context); } catch (error) { devLogger.warn("[nostr] DM decryptor threw unexpectedly.", { - error, - eventId, + error: sanitizeDecryptError(error), + event: summarizeDmEventForLog(event), }); throw error; } @@ -4124,6 +4204,11 @@ export class NostrClient { throw new Error("DM decryption helpers are unavailable."); } + const decryptLimit = + Number.isFinite(options?.decryptLimit) && options.decryptLimit > 0 + ? Math.floor(options.decryptLimit) + : null; + const relayCandidates = Array.isArray(options.relays) ? options.relays : Array.isArray(this.readRelays) && this.readRelays.length @@ -4159,8 +4244,19 @@ export class NostrClient { } } + let decryptTargets = Array.from(deduped.values()); + decryptTargets.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); + + if (decryptLimit && decryptTargets.length > decryptLimit) { + devLogger.debug("[nostr] Limiting DM decrypt workload", { + requested: decryptTargets.length, + decryptLimit, + }); + decryptTargets = decryptTargets.slice(0, decryptLimit); + } + const messages = []; - for (const event of deduped.values()) { + for (const event of decryptTargets) { try { const decrypted = await this.decryptDirectMessageEvent(event, { actorPubkey: context.actorPubkey || actorPubkeyInput, @@ -4170,8 +4266,8 @@ export class NostrClient { } } catch (error) { devLogger.warn("[nostr] Failed to decrypt DM event during list.", { - error, - id: event?.id || null, + error: sanitizeDecryptError(error), + event: summarizeDmEventForLog(event), }); } } @@ -4257,8 +4353,8 @@ export class NostrClient { devLogger.warn( "[nostr] Failed to decrypt DM event from subscription.", { - error, - id: eventId, + error: sanitizeDecryptError(error), + event: summarizeDmEventForLog(event), }, ); } @@ -4324,15 +4420,36 @@ export class NostrClient { return newPromise; } - async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null) { + async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null, options = {}) { + let resolvedOptions = options; + let resolvedActorOverride = actorPubkeyOverride; + if (actorPubkeyOverride && typeof actorPubkeyOverride === "object") { + resolvedOptions = actorPubkeyOverride; + resolvedActorOverride = null; + } + + if (!resolvedOptions || typeof resolvedOptions !== "object") { + resolvedOptions = {}; + } + const trimmedTarget = typeof targetNpub === "string" ? targetNpub.trim() : ""; const trimmedMessage = typeof message === "string" ? message.trim() : ""; + const attachments = Array.isArray(resolvedOptions.attachments) + ? resolvedOptions.attachments.filter( + (attachment) => + attachment && + typeof attachment === "object" && + (typeof attachment.url === "string" || + typeof attachment.x === "string"), + ) + : []; + const hasAttachments = attachments.length > 0; if (!trimmedTarget) { return { ok: false, error: "invalid-target" }; } - if (!trimmedMessage) { + if (!trimmedMessage && !hasAttachments) { return { ok: false, error: "empty-message" }; } @@ -4341,12 +4458,12 @@ export class NostrClient { } const activeSignerCandidate = - typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim() - ? resolveActiveSigner(actorPubkeyOverride) + typeof resolvedActorOverride === "string" && resolvedActorOverride.trim() + ? resolveActiveSigner(resolvedActorOverride) : resolveActiveSigner(this.pubkey); const baseActiveSigner = activeSignerCandidate || getActiveSigner(); const signer = - typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim() + typeof resolvedActorOverride === "string" && resolvedActorOverride.trim() ? activeSignerCandidate : baseActiveSigner ? resolveActiveSigner(baseActiveSigner.pubkey || this.pubkey) @@ -4374,8 +4491,8 @@ export class NostrClient { } let actorHex = - typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim() - ? actorPubkeyOverride.trim() + typeof resolvedActorOverride === "string" && resolvedActorOverride.trim() + ? resolvedActorOverride.trim() : ""; if (!actorHex && typeof this.pubkey === "string") { @@ -4386,67 +4503,453 @@ export class NostrClient { actorHex = signer.pubkey; } - if (!actorHex) { - return { ok: false, error: "missing-actor-pubkey" }; - } - const targetHex = decodeNpubToHex(trimmedTarget); if (!targetHex) { return { ok: false, error: "invalid-target" }; } - const encryptionCandidates = []; - const signerCapabilities = resolveSignerCapabilities(signer); - const allowNip04Fallback = signerCapabilities.nip04 === true; - if ( - signerCapabilities.nip44 && - typeof signer.nip44Encrypt === "function" - ) { - encryptionCandidates.push({ - scheme: "nip44", - encrypt: signer.nip44Encrypt, - }); + const signingAdapter = + resolvedOptions?.signingAdapter && + typeof resolvedOptions.signingAdapter === "object" + ? resolvedOptions.signingAdapter + : signer && typeof signer.signEvent === "function" + ? { + signEvent: (event) => signer.signEvent(event), + getPubkey: async () => actorHex || signer.pubkey || "", + getDisplayName: async () => "", + } + : null; + + if (!signingAdapter || typeof signingAdapter.signEvent !== "function") { + return { ok: false, error: "sign-event-unavailable" }; } - if ( - signerCapabilities.nip04 && - typeof signer.nip04Encrypt === "function" - ) { - encryptionCandidates.push({ - scheme: "nip04", - encrypt: signer.nip04Encrypt, + + if (typeof signingAdapter.getPubkey === "function") { + try { + const adapterPubkey = await signingAdapter.getPubkey(); + if (typeof adapterPubkey === "string" && adapterPubkey.trim()) { + actorHex = adapterPubkey.trim(); + } + } catch (error) { + devLogger.warn("[nostr] Failed to resolve DM pubkey from adapter.", error); + } + } + + if (!actorHex) { + return { ok: false, error: "missing-actor-pubkey" }; + } + + const signerCapabilities = resolveSignerCapabilities(signer); + const useNip17 = Boolean(resolvedOptions.useNip17); + + if (hasAttachments && !useNip17) { + return { ok: false, error: "attachments-unsupported" }; + } + + const resolveNip17RelaySelection = async (pubkey, relayHints) => { + const discoveryRelays = sanitizeRelayList( + Array.isArray(this.readRelays) && this.readRelays.length + ? this.readRelays + : Array.isArray(this.relays) && this.relays.length + ? this.relays + : RELAY_URLS, + ); + const fallbackRelays = sanitizeRelayList( + Array.isArray(this.writeRelays) && this.writeRelays.length + ? this.writeRelays + : Array.isArray(this.relays) && this.relays.length + ? this.relays + : RELAY_URLS, + ); + + return resolveDmRelaySelection({ + pubkey, + relayHints, + discoveryRelays, + fallbackRelays, + pool: this.pool, + log: { dev: devLogger, user: userLogger }, }); + }; + + if (useNip17) { + if (!signerCapabilities.nip44 || typeof signer.nip44Encrypt !== "function") { + return { ok: false, error: "nip44-unsupported" }; + } + + const recipientSelection = await resolveNip17RelaySelection( + targetHex, + resolvedOptions.recipientRelayHints, + ); + + if (!recipientSelection.relays.length) { + return { ok: false, error: "nip17-relays-unavailable" }; + } + + const senderSelection = await resolveNip17RelaySelection( + actorHex, + resolvedOptions.senderRelayHints, + ); + const senderRelayTargets = senderSelection.relays; + const relayWarning = + recipientSelection.warning === DM_RELAY_WARNING_FALLBACK || + senderSelection.warning === DM_RELAY_WARNING_FALLBACK + ? DM_RELAY_WARNING_FALLBACK + : null; + + const tools = await ensureNostrTools(); + const getPublicKey = + tools && typeof tools.getPublicKey === "function" ? tools.getPublicKey : null; + if (!getPublicKey) { + return { ok: false, error: "nip17-keygen-failed" }; + } + + const randomPastTimestamp = () => { + const now = Math.floor(Date.now() / 1000); + const offset = Math.floor(Math.random() * 172800); + return now - offset; + }; + + const generateEphemeralKeypair = () => { + let privateKey = ""; + try { + if (typeof tools.generateSecretKey === "function") { + const secret = tools.generateSecretKey(); + if (secret instanceof Uint8Array) { + privateKey = bytesToHex(secret); + } + } + if (!privateKey) { + if (typeof tools.generatePrivateKey === "function") { + privateKey = tools.generatePrivateKey(); + } else if (window?.crypto?.getRandomValues) { + const randomBytes = new Uint8Array(32); + window.crypto.getRandomValues(randomBytes); + privateKey = Array.from(randomBytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + } + } + } catch (error) { + devLogger.warn("[nostr] Failed to generate NIP-17 wrapper key.", error); + privateKey = ""; + } + + const normalizedPrivateKey = + typeof privateKey === "string" ? privateKey.trim() : ""; + if (!normalizedPrivateKey) { + return null; + } + + let pubkey = ""; + try { + pubkey = getPublicKey(normalizedPrivateKey); + } catch (error) { + let retrySuccess = false; + try { + if (HEX64_REGEX.test(normalizedPrivateKey)) { + const bytes = new Uint8Array(normalizedPrivateKey.length / 2); + for (let i = 0; i < normalizedPrivateKey.length; i += 2) { + bytes[i / 2] = parseInt( + normalizedPrivateKey.substring(i, i + 2), + 16, + ); + } + pubkey = getPublicKey(bytes); + retrySuccess = true; + } + } catch (retryError) { + // Fall back to error handling below + } + if (!retrySuccess) { + devLogger.warn( + "[nostr] Failed to derive NIP-17 wrapper pubkey.", + error, + ); + return null; + } + } + + return { privateKey: normalizedPrivateKey, pubkey }; + }; + + const rumorEvents = []; + const createdAt = Math.floor(Date.now() / 1000); + + if (trimmedMessage) { + rumorEvents.push({ + kind: 14, + created_at: createdAt, + tags: [["p", targetHex]], + content: trimmedMessage, + pubkey: actorHex, + }); + } + + if (hasAttachments) { + attachments.forEach((attachment) => { + const normalizedAttachment = { + x: + typeof attachment.x === "string" + ? attachment.x.trim().toLowerCase() + : "", + url: typeof attachment.url === "string" ? attachment.url.trim() : "", + name: + typeof attachment.name === "string" ? attachment.name.trim() : "", + type: + typeof attachment.type === "string" ? attachment.type.trim() : "", + size: Number.isFinite(attachment.size) ? Math.floor(attachment.size) : null, + key: typeof attachment.key === "string" ? attachment.key.trim() : "", + }; + + rumorEvents.push( + buildDmAttachmentEvent({ + pubkey: actorHex, + created_at: createdAt, + recipientPubkey: targetHex, + attachment: normalizedAttachment, + }), + ); + }); + } + + if (!rumorEvents.length) { + return { ok: false, error: "empty-message" }; + } + + const buildGiftWrapForRecipient = async ( + recipientPubkey, + relayHint, + rumorEvent, + ) => { + const sealPayload = { + kind: 13, + created_at: randomPastTimestamp(), + tags: [], + content: await signer.nip44Encrypt( + recipientPubkey, + JSON.stringify(rumorEvent), + ), + pubkey: actorHex, + }; + + const signedSeal = await signingAdapter.signEvent(sealPayload); + if (!signedSeal || typeof signedSeal.id !== "string") { + throw new Error("seal-signature-failed"); + } + + const wrapperKeys = generateEphemeralKeypair(); + if (!wrapperKeys) { + throw new Error("wrapper-keygen-failed"); + } + + const cipherClosures = await createPrivateKeyCipherClosures( + wrapperKeys.privateKey, + ); + if (typeof cipherClosures.nip44Encrypt !== "function") { + throw new Error("wrapper-encryption-unavailable"); + } + + const wrapCiphertext = await cipherClosures.nip44Encrypt( + recipientPubkey, + JSON.stringify(signedSeal), + ); + const wrapTags = relayHint + ? [["p", recipientPubkey, relayHint]] + : [["p", recipientPubkey]]; + + const wrapEvent = { + kind: 1059, + created_at: randomPastTimestamp(), + tags: wrapTags, + content: wrapCiphertext, + pubkey: wrapperKeys.pubkey, + }; + + return { + wrap: signEventWithPrivateKey(wrapEvent, wrapperKeys.privateKey), + seal: signedSeal, + }; + }; + + const resolveRumorPreview = (rumorEvent) => { + const content = + typeof rumorEvent?.content === "string" ? rumorEvent.content.trim() : ""; + if (content) { + return content; + } + + const tags = Array.isArray(rumorEvent?.tags) ? rumorEvent.tags : []; + const nameTag = tags.find( + (tag) => Array.isArray(tag) && tag[0] === "name" && tag[1], + ); + if (nameTag) { + return `Attachment: ${nameTag[1]}`; + } + + return "Attachment"; + }; + + const publishRumorEvent = async (rumorEvent) => { + let recipientGiftWrap; + try { + recipientGiftWrap = await buildGiftWrapForRecipient( + targetHex, + recipientSelection.relays[0] || "", + rumorEvent, + ); + } catch (error) { + devLogger.warn("[nostr] Failed to build NIP-17 recipient wrap.", error); + const errorCode = + error?.message === "wrapper-keygen-failed" + ? "nip17-keygen-failed" + : "encryption-failed"; + return { ok: false, error: errorCode, details: error }; + } + + const conversationId = `dm:${[actorHex, targetHex].sort().join(":")}`; + const preview = resolveRumorPreview(rumorEvent); + const dmRecord = { + id: recipientGiftWrap.wrap.id, + conversation_id: conversationId, + sender_pubkey: actorHex, + receiver_pubkey: targetHex, + created_at: rumorEvent.created_at, + kind: rumorEvent.kind, + content: typeof rumorEvent.content === "string" ? rumorEvent.content : "", + tags: rumorEvent.tags, + status: "pending", + seen: true, + }; + + try { + await writeMessages(dmRecord); + await updateConversationFromMessage(dmRecord, { + preview, + unseenDelta: 0, + }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist outgoing DM.", error); + } + + const recipientPublishResults = await Promise.all( + recipientSelection.relays.map((url) => + publishEventToRelay(this.pool, url, recipientGiftWrap.wrap), + ), + ); + + const recipientSuccess = recipientPublishResults.some( + (result) => result.success, + ); + if (!recipientSuccess) { + try { + await writeMessages({ ...dmRecord, status: "failed" }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist DM failure state.", error); + } + return { + ok: false, + error: "publish-failed", + details: recipientPublishResults.filter((result) => !result.success), + }; + } + + let senderGiftWrap = null; + try { + senderGiftWrap = await buildGiftWrapForRecipient( + actorHex, + senderRelayTargets[0] || "", + rumorEvent, + ); + } catch (error) { + devLogger.warn("[nostr] Failed to build NIP-17 sender copy.", error); + } + + if (senderGiftWrap && senderRelayTargets.length) { + const senderPublishResults = await Promise.all( + senderRelayTargets.map((url) => + publishEventToRelay(this.pool, url, senderGiftWrap.wrap), + ), + ); + if (!senderPublishResults.some((result) => result.success)) { + devLogger.warn("[nostr] Failed to publish sender copy of NIP-17 DM.", { + relays: senderRelayTargets, + }); + } + } + + try { + await writeMessages({ ...dmRecord, status: "published" }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist DM publish state.", error); + } + + return { ok: true }; + }; + + const failures = []; + for (const rumorEvent of rumorEvents) { + const result = await publishRumorEvent(rumorEvent); + if (!result?.ok) { + failures.push(result); + } + } + + if (failures.length) { + return { + ok: false, + error: failures[0]?.error || "publish-failed", + details: failures, + }; + } + + return relayWarning ? { ok: true, warning: relayWarning } : { ok: true }; } const encryptionErrors = []; let ciphertext = ""; - for (const candidate of encryptionCandidates) { + const normalizedActorHex = normalizeActorKey(actorHex); + const sessionActor = this.sessionActor; + const sessionPrivateKey = + sessionActor && + typeof sessionActor.pubkey === "string" && + sessionActor.pubkey.toLowerCase() === normalizedActorHex && + typeof sessionActor.privateKey === "string" + ? sessionActor.privateKey + : ""; + + if (sessionPrivateKey) { try { - const encrypted = await candidate.encrypt(targetHex, trimmedMessage); + ciphertext = await encryptNip04InWorker({ + privateKey: sessionPrivateKey, + targetPubkey: targetHex, + plaintext: trimmedMessage, + }); + } catch (error) { + encryptionErrors.push({ scheme: "nip04-worker", error }); + } + } + + if ( + !ciphertext && + signerCapabilities.nip04 && + typeof signer.nip04Encrypt === "function" + ) { + try { + const encrypted = await signer.nip04Encrypt(targetHex, trimmedMessage); if (typeof encrypted === "string" && encrypted) { ciphertext = encrypted; - break; } } catch (error) { - encryptionErrors.push({ scheme: candidate.scheme, error }); + encryptionErrors.push({ scheme: "nip04", error }); } } if (!ciphertext) { - const normalizedActorHex = normalizeActorKey(actorHex); - const sessionActor = this.sessionActor; - const sessionMatchesActor = - sessionActor && - typeof sessionActor.pubkey === "string" && - sessionActor.pubkey.toLowerCase() === normalizedActorHex && - typeof sessionActor.privateKey === "string" && - sessionActor.privateKey; - - const canFallbackWithSession = sessionMatchesActor && allowNip04Fallback; - - if (!encryptionCandidates.length && !canFallbackWithSession) { + if (!sessionPrivateKey && !signerCapabilities.nip04) { const error = new Error( - "Your signer does not support NIP-44 or NIP-04 encryption.", + "Your signer does not support NIP-04 encryption.", ); userLogger.warn("[nostr] Encryption unsupported for DM send.", error); return { @@ -4456,31 +4959,6 @@ export class NostrClient { }; } - if (canFallbackWithSession) { - try { - const tools = (await ensureNostrTools()) || getCachedNostrTools(); - if (tools?.nip04 && typeof tools.nip04.encrypt === "function") { - const encrypted = await tools.nip04.encrypt( - sessionActor.privateKey, - targetHex, - trimmedMessage, - ); - if (typeof encrypted === "string" && encrypted) { - ciphertext = encrypted; - } - } else { - encryptionErrors.push({ - scheme: "nip04", - error: new Error("nostr-tools nip04 helpers unavailable"), - }); - } - } catch (error) { - encryptionErrors.push({ scheme: "nip04", error }); - } - } - } - - if (!ciphertext) { const details = encryptionErrors.length === 1 ? encryptionErrors[0].error @@ -4501,13 +4979,103 @@ export class NostrClient { let signedEvent; try { - signedEvent = await queueSignEvent(signer, event); + signedEvent = await signingAdapter.signEvent(event); } catch (error) { return { ok: false, error: "signature-failed", details: error }; } - const relays = - Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS; + if (!signedEvent || typeof signedEvent.id !== "string") { + return { ok: false, error: "signature-failed" }; + } + + const conversationId = `dm:${[actorHex, targetHex].sort().join(":")}`; + const dmRecord = { + id: signedEvent.id, + conversation_id: conversationId, + sender_pubkey: actorHex, + receiver_pubkey: targetHex, + created_at: event.created_at, + kind: event.kind, + content: trimmedMessage, + tags: event.tags, + status: "pending", + seen: true, + }; + + try { + await writeMessages(dmRecord); + await updateConversationFromMessage(dmRecord, { + preview: trimmedMessage, + unseenDelta: 0, + }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist outgoing DM.", error); + } + + const relayListCandidates = sanitizeRelayList( + Array.isArray(this.readRelays) && this.readRelays.length + ? this.readRelays + : Array.isArray(this.relays) && this.relays.length + ? this.relays + : RELAY_URLS, + ); + + const parseRecipientRelays = (relayEvent) => { + const tags = Array.isArray(relayEvent?.tags) ? relayEvent.tags : []; + const seen = new Set(); + const candidates = []; + + tags.forEach((tag) => { + if (!Array.isArray(tag) || tag[0] !== "r") { + return; + } + const url = typeof tag[1] === "string" ? tag[1].trim() : ""; + if (!url) { + return; + } + const marker = + typeof tag[2] === "string" ? tag[2].trim().toLowerCase() : ""; + if (marker === "write") { + return; + } + if (!seen.has(url)) { + seen.add(url); + candidates.push(url); + } + }); + + return sanitizeRelayList(candidates); + }; + + let recipientRelays = []; + if (relayListCandidates.length) { + try { + await this.ensurePool(); + const events = await this.pool.list(relayListCandidates, [ + { kinds: [10002], authors: [targetHex], limit: 1 }, + ]); + const sorted = Array.isArray(events) + ? events + .filter((entry) => entry && entry.pubkey === targetHex) + .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)) + : []; + if (sorted.length) { + recipientRelays = parseRecipientRelays(sorted[0]); + } + } catch (error) { + devLogger.warn("[nostr] Failed to load recipient relay list.", error); + } + } + + const fallbackRelays = sanitizeRelayList( + Array.isArray(this.writeRelays) && this.writeRelays.length + ? this.writeRelays + : Array.isArray(this.relays) && this.relays.length + ? this.relays + : RELAY_URLS, + ); + + const relays = recipientRelays.length ? recipientRelays : fallbackRelays; const publishResults = await Promise.all( relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) @@ -4515,6 +5083,11 @@ export class NostrClient { const success = publishResults.some((result) => result.success); if (!success) { + try { + await writeMessages({ ...dmRecord, status: "failed" }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist DM failure state.", error); + } return { ok: false, error: "publish-failed", @@ -4522,6 +5095,12 @@ export class NostrClient { }; } + try { + await writeMessages({ ...dmRecord, status: "published" }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist DM publish state.", error); + } + return { ok: true }; } @@ -4548,6 +5127,11 @@ export class NostrClient { * - `url`: The direct HTTP URL (WebSeed). * - NIP-94 Mirror: Optionally publishes a Kind 1063 file header event if supported. * - NIP-71: Optionally publishes a Kind 22 (Video Wrapper) for categorization if metadata is provided. + * + * @param {object} videoPayload - The video metadata and form data. + * @param {string} pubkey - The public key of the publisher. + * @returns {Promise<import("nostr-tools").Event>} The signed and published event. + * @throws {Error} If not logged in or publishing fails. */ async publishVideo(videoPayload, pubkey) { if (!pubkey) throw new Error("Not logged in to publish video."); @@ -4908,6 +5492,12 @@ export class NostrClient { * * This version forces version=2 for the original note and uses * lowercase comparison for public keys. + * + * @param {object} originalEventStub - The original video event (must have `id`). + * @param {object} updatedData - The new metadata to apply. + * @param {string} userPubkey - The public key of the editor (must match owner). + * @returns {Promise<import("nostr-tools").Event>} The signed and published edit event. + * @throws {Error} If permission denied, ownership mismatch, or publish failure. */ async editVideo(originalEventStub, updatedData, userPubkey) { if (!userPubkey) { @@ -5143,7 +5733,13 @@ export class NostrClient { } /** - * revertVideo => old style + * Reverts a video (soft delete). + * Publishes a new version with the same `d` tag but `deleted: true`. + * The content is replaced with a placeholder. + * + * @param {object} originalEvent - The video event to revert. + * @param {string} pubkey - The public key of the owner. + * @returns {Promise<{event: object, publishResults: object[], summary: object}>} Result of the operation. */ async revertVideo(originalEvent, pubkey) { if (!pubkey) { @@ -5314,10 +5910,14 @@ export class NostrClient { } /** - * "Deleting" => Mark all content with the same videoRootId as {deleted:true} - * and blank out magnet/desc. + * Deletes all versions of a video. + * 1. Reverts every known version (soft delete) by publishing a `deleted: true` update for each `d` tag or root. + * 2. Publishes Kind 5 (NIP-09) deletion events for all event IDs and NIP-33 addresses. * - * This version now asks for confirmation before proceeding. + * @param {string} videoRootId - The root ID of the video series. + * @param {string} pubkey - The owner's public key. + * @param {{confirm?: boolean, video?: object}} [options] - Options (confirm dialog, target video hint). + * @returns {Promise<{reverts: object[], deletes: object[]}|null>} Summary of actions taken, or null if cancelled. */ async deleteAllVersions(videoRootId, pubkey, options = {}) { if (!pubkey) { @@ -5797,8 +6397,9 @@ export class NostrClient { * - Persist to local cache. * - Notify the UI (`onVideo`). * - * @param {Function} onVideo - Callback fired when new valid videos are processed. - * @param {{ since?: number, until?: number, limit?: number }} [options] + * @param {Function} onVideo - Callback fired when new valid videos are processed. Receives the `Video` object. + * @param {{ since?: number, until?: number, limit?: number }} [options] - Filter options. + * @returns {import("nostr-tools").Sub} The subscription object. Call `unsub()` to stop. */ subscribeVideos(onVideo, options = {}) { const { since, until, limit } = options; @@ -6735,6 +7336,9 @@ export class NostrClient { * - If the root event is missing locally, fetch it by ID. * - If we suspect missing history (e.g., only 1 version found), perform a relay query for the specific `d` tag. * - Merge and sort all findings to present a linear history. + * + * @param {object} video - The video object to find history for. + * @returns {Promise<object[]>} Sorted array of video objects (newest first). */ async hydrateVideoHistory(video) { if (!video || typeof video !== "object") { diff --git a/js/nostr/dmDecryptDiagnostics.js b/js/nostr/dmDecryptDiagnostics.js new file mode 100644 index 00000000..c6e5fb51 --- /dev/null +++ b/js/nostr/dmDecryptDiagnostics.js @@ -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, + }; +} diff --git a/js/nostr/dmDecryptWorker.js b/js/nostr/dmDecryptWorker.js new file mode 100644 index 00000000..73603d93 --- /dev/null +++ b/js/nostr/dmDecryptWorker.js @@ -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); + }); +} diff --git a/js/nostr/dmDecryptWorkerClient.js b/js/nostr/dmDecryptWorkerClient.js new file mode 100644 index 00000000..30b4b2a0 --- /dev/null +++ b/js/nostr/dmDecryptWorkerClient.js @@ -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, + }); + }); +} diff --git a/js/nostr/dmSignalEvents.js b/js/nostr/dmSignalEvents.js new file mode 100644 index 00000000..b138d497 --- /dev/null +++ b/js/nostr/dmSignalEvents.js @@ -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, + }; +} diff --git a/js/nostr/nip04Worker.js b/js/nostr/nip04Worker.js new file mode 100644 index 00000000..942d7169 --- /dev/null +++ b/js/nostr/nip04Worker.js @@ -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); + }); +} diff --git a/js/nostr/nip04WorkerClient.js b/js/nostr/nip04WorkerClient.js new file mode 100644 index 00000000..9d6e0c8b --- /dev/null +++ b/js/nostr/nip04WorkerClient.js @@ -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, + }); + }); +} diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 32ad9a86..0694e3db 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -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, diff --git a/js/payments/zapRequests.js b/js/payments/zapRequests.js new file mode 100644 index 00000000..56688d93 --- /dev/null +++ b/js/payments/zapRequests.js @@ -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, +}); diff --git a/js/services/attachmentService.js b/js/services/attachmentService.js new file mode 100644 index 00000000..267d60c6 --- /dev/null +++ b/js/services/attachmentService.js @@ -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, + }; +} diff --git a/js/services/dmNostrService.js b/js/services/dmNostrService.js new file mode 100644 index 00000000..deafcc2a --- /dev/null +++ b/js/services/dmNostrService.js @@ -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; diff --git a/js/services/dmNotificationManager.js b/js/services/dmNotificationManager.js new file mode 100644 index 00000000..f9c192c6 --- /dev/null +++ b/js/services/dmNotificationManager.js @@ -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; + } +} diff --git a/js/services/linkPreviewService.js b/js/services/linkPreviewService.js new file mode 100644 index 00000000..b927e300 --- /dev/null +++ b/js/services/linkPreviewService.js @@ -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, + }; + } +} diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 223dc4c7..80a4ee79 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -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) { diff --git a/js/services/relayHealthService.js b/js/services/relayHealthService.js new file mode 100644 index 00000000..87ed52fc --- /dev/null +++ b/js/services/relayHealthService.js @@ -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; diff --git a/js/state/cache.js b/js/state/cache.js index f05109bb..2a1995e6 100644 --- a/js/state/cache.js +++ b/js/state/cache.js @@ -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(); diff --git a/js/storage/dmDb.js b/js/storage/dmDb.js new file mode 100644 index 00000000..28c0e9b4 --- /dev/null +++ b/js/storage/dmDb.js @@ -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; diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 7290193e..9d21d8f3 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -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({ diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index f34761e4..e7968dfc 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -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) { diff --git a/js/ui/dm/AppShell.js b/js/ui/dm/AppShell.js index 3a804cce..08afcf05 100644 --- a/js/ui/dm/AppShell.js +++ b/js/ui/dm/AppShell.js @@ -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: "nia@zap.lol", + 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: "ops@getalby.com", + 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, }), ); diff --git a/js/ui/dm/Composer.js b/js/ui/dm/Composer.js index 05f3f54d..070dce6f 100644 --- a/js/ui/dm/Composer.js +++ b/js/ui/dm/Composer.js @@ -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(); diff --git a/js/ui/dm/ContactRow.js b/js/ui/dm/ContactRow.js index 4c2be75e..9af1740a 100644 --- a/js/ui/dm/ContactRow.js +++ b/js/ui/dm/ContactRow.js @@ -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( diff --git a/js/ui/dm/MessageThread.js b/js/ui/dm/MessageThread.js index de53b872..553af7dc 100644 --- a/js/ui/dm/MessageThread.js +++ b/js/ui/dm/MessageThread.js @@ -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; } diff --git a/js/ui/dm/ZapReceiptList.js b/js/ui/dm/ZapReceiptList.js new file mode 100644 index 00000000..f8691dfa --- /dev/null +++ b/js/ui/dm/ZapReceiptList.js @@ -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; +} diff --git a/js/ui/dm/index.js b/js/ui/dm/index.js index 9bada81d..ef5640c3 100644 --- a/js/ui/dm/index.js +++ b/js/ui/dm/index.js @@ -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"; diff --git a/js/ui/dm/zapHelpers.js b/js/ui/dm/zapHelpers.js new file mode 100644 index 00000000..3530f3a9 --- /dev/null +++ b/js/ui/dm/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; +} diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index e7fd360f..998d6149 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -15,13 +15,31 @@ import { } from "../constants.js"; import { getBreakpointLg } from "../designSystem/metrics.js"; import { getProviderMetadata } from "../services/authProviders/index.js"; +import { AppShell } from "./dm/index.js"; import { devLogger, userLogger } from "../utils/logger.js"; import { normalizeHashtag, formatHashtag, } from "../utils/hashtagNormalization.js"; import { getActiveSigner } from "../nostr/client.js"; +import { sanitizeRelayList } from "../nostr/nip46Client.js"; import { buildPublicUrl, buildR2Key } from "../r2.js"; +import { buildProfileMetadataEvent } from "../nostrEventSchemas.js"; +import { + describeAttachment, + extractAttachmentsFromMessage, + formatAttachmentSize, +} from "../attachments/attachmentUtils.js"; +import { + clearAttachmentCache, + downloadAttachment, + uploadAttachment, + getAttachmentCacheStats, +} from "../services/attachmentService.js"; +import { + getLinkPreviewSettings, + setLinkPreviewAutoFetch, +} from "../utils/linkPreviewSettings.js"; const noop = () => {}; @@ -35,6 +53,8 @@ const DEFAULT_MAX_WALLET_DEFAULT_ZAP = 100000000; const DEFAULT_SAVED_PROFILE_LABEL = "Saved profile"; const TRUSTED_MUTE_HIDE_HELPER_TEXT = "Reaching this count hides cards (with “Show anyway”); lower signals only blur thumbnails or block autoplay."; +const TYPING_INDICATOR_TTL_SECONDS = 15; +const TYPING_INDICATOR_COOLDOWN_MS = 4000; const ADD_PROFILE_CANCELLATION_CODES = new Set([ "login-cancelled", @@ -86,6 +106,15 @@ function createInternalDefaultModerationSettings() { return { ...DEFAULT_INTERNAL_MODERATION_SETTINGS }; } +const DEFAULT_INTERNAL_DM_PRIVACY_SETTINGS = Object.freeze({ + readReceiptsEnabled: false, + typingIndicatorsEnabled: false, +}); + +function createInternalDefaultDmPrivacySettings() { + return { ...DEFAULT_INTERNAL_DM_PRIVACY_SETTINGS }; +} + function ensureInternalModerationSettings(internalState) { if (!internalState || typeof internalState !== "object") { return createInternalDefaultModerationSettings(); @@ -275,6 +304,13 @@ const SERVICE_CONTRACT = [ "Fetches and caches profile metadata for a set of pubkeys so the modal can hydrate avatars/names in bulk.", fallback: () => async () => [], }, + { + key: "fetchDmRelayHints", + type: "function", + description: + "Fetches DM relay hints (kind 10050) for a given pubkey.", + fallback: () => async () => [], + }, { key: "switchProfile", type: "function", @@ -694,6 +730,133 @@ const STATE_CONTRACT = [ return internal.walletBusy; }, }, + { + key: "getDmRecipient", + type: "function", + description: + "Returns the currently selected direct message recipient pubkey.", + fallback: (internal) => () => internal.dmRecipient, + }, + { + key: "setDmRecipient", + type: "function", + description: + "Updates the selected direct message recipient pubkey and returns the stored value.", + fallback: (internal) => (pubkey) => { + if (typeof pubkey === "string") { + const trimmed = pubkey.trim(); + internal.dmRecipient = trimmed || null; + } else { + internal.dmRecipient = null; + } + return internal.dmRecipient; + }, + }, + { + key: "getDmRelayHints", + type: "function", + description: + "Returns the cached DM relay hints for a given pubkey.", + fallback: (internal) => (pubkey) => { + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!normalized || !(internal.dmRelayHints instanceof Map)) { + return []; + } + const hints = internal.dmRelayHints.get(normalized); + return Array.isArray(hints) ? hints.slice() : []; + }, + }, + { + key: "setDmRelayHints", + type: "function", + description: + "Stores DM relay hints for a given pubkey and returns the stored list.", + fallback: (internal) => (pubkey, hints = []) => { + if (!(internal.dmRelayHints instanceof Map)) { + internal.dmRelayHints = new Map(); + } + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!normalized) { + return []; + } + const stored = Array.isArray(hints) ? hints.slice() : []; + internal.dmRelayHints.set(normalized, stored); + return stored; + }, + }, + { + key: "getDmRelayPreferences", + type: "function", + description: + "Returns the cached DM relay hints for a given pubkey (publisher view).", + fallback: (internal) => (pubkey) => { + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!normalized || !(internal.dmRelayHints instanceof Map)) { + return []; + } + const hints = internal.dmRelayHints.get(normalized); + return Array.isArray(hints) ? hints.slice() : []; + }, + }, + { + key: "setDmRelayPreferences", + type: "function", + description: + "Stores DM relay hints for a given pubkey (publisher view).", + fallback: (internal) => (pubkey, hints = []) => { + if (!(internal.dmRelayHints instanceof Map)) { + internal.dmRelayHints = new Map(); + } + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!normalized) { + return []; + } + const stored = Array.isArray(hints) ? hints.slice() : []; + internal.dmRelayHints.set(normalized, stored); + return stored; + }, + }, + { + key: "getDmPrivacySettings", + type: "function", + description: + "Returns the DM privacy settings (read receipts and typing indicators) for the active profile.", + fallback: (internal) => () => { + if (!(internal.dmPrivacySettings instanceof Map)) { + internal.dmPrivacySettings = new Map(); + } + const settings = internal.dmPrivacySettings.get("default"); + return settings ? { ...settings } : createInternalDefaultDmPrivacySettings(); + }, + }, + { + key: "setDmPrivacySettings", + type: "function", + description: + "Updates the DM privacy settings (read receipts and typing indicators) for the active profile.", + fallback: (internal) => (settings = {}) => { + if (!(internal.dmPrivacySettings instanceof Map)) { + internal.dmPrivacySettings = new Map(); + } + const base = createInternalDefaultDmPrivacySettings(); + const normalized = { + readReceiptsEnabled: + typeof settings.readReceiptsEnabled === "boolean" + ? settings.readReceiptsEnabled + : base.readReceiptsEnabled, + typingIndicatorsEnabled: + typeof settings.typingIndicatorsEnabled === "boolean" + ? settings.typingIndicatorsEnabled + : base.typingIndicatorsEnabled, + }; + internal.dmPrivacySettings.set("default", normalized); + return { ...normalized }; + }, + }, ]; function buildServicesContract(services = {}, internalState) { @@ -882,6 +1045,8 @@ export class ProfileModalController { walletBusy: false, walletSettings: createInternalDefaultNwcSettings(), moderationSettings: createInternalDefaultModerationSettings(), + dmRecipient: null, + dmRelayHints: new Map(), }; this.services = buildServicesContract(services, this.internalState); @@ -1023,6 +1188,7 @@ export class ProfileModalController { onSendDm: callbacks.onSendDm || noop, onTogglePrivacy: callbacks.onTogglePrivacy || noop, onOpenRelays: callbacks.onOpenRelays || noop, + onPublishDmRelayPreferences: callbacks.onPublishDmRelayPreferences || noop, }; this.profileModal = null; @@ -1076,6 +1242,12 @@ export class ProfileModalController { this.relayInput = null; this.addRelayButton = null; this.restoreRelaysButton = null; + this.relayHealthPanel = null; + this.relayHealthList = null; + this.relayHealthStatus = null; + this.relayHealthRefreshButton = null; + this.relayHealthTelemetryToggle = null; + this.relayHealthRefreshPromise = null; this.profileRelayList = null; this.profileRelayInput = null; this.profileAddRelayBtn = null; @@ -1103,12 +1275,30 @@ export class ProfileModalController { this.profileMessagesStatus = null; this.profileMessagesReloadButton = null; this.profileMessagesPane = null; + this.profileMessagesConversation = null; + this.profileMessagesConversationEmpty = null; this.profileMessageInput = null; this.profileMessageSendButton = null; + this.profileMessageAttachmentInput = null; + this.profileMessageAttachmentButton = null; + this.profileMessageAttachmentEncrypt = null; + this.profileMessageAttachmentList = null; + this.profileMessageAttachmentClearCache = null; this.profileMessagesComposerHelper = null; this.profileMessagesSendDmButton = null; this.profileMessagesOpenRelaysButton = null; this.profileMessagesPrivacyToggle = null; + this.profileMessagesPrivacyMode = null; + this.profileMessagesRelayList = null; + this.profileMessagesRelayInput = null; + this.profileMessagesRelayAddButton = null; + this.profileMessagesRelayPublishButton = null; + this.profileMessagesRelayStatus = null; + this.profileMessagesReadReceiptsToggle = null; + this.profileMessagesTypingToggle = null; + this.dmAppShellContainer = null; + this.dmAppShell = null; + this.profileLinkPreviewAutoToggle = null; this.walletUriInput = null; this.walletDefaultZapInput = null; this.walletSaveButton = null; @@ -1178,6 +1368,11 @@ export class ProfileModalController { this.directMessagesUnsubscribes = []; this.pendingMessagesRender = null; this.messagesStatusClearTimeout = null; + this.dmPrivacyToggleTouched = false; + this.dmReadReceiptCache = new Set(); + this.dmTypingLastSentAt = 0; + this.dmAttachmentQueue = []; + this.dmAttachmentUploads = new Map(); this.profileHistoryRenderer = null; this.profileHistoryRendererConfig = null; @@ -1335,6 +1530,16 @@ export class ProfileModalController { this.addRelayButton = document.getElementById("addRelayBtn") || null; this.restoreRelaysButton = document.getElementById("restoreRelaysBtn") || null; + this.relayHealthPanel = + document.getElementById("relayHealthPanel") || null; + this.relayHealthList = + document.getElementById("relayHealthList") || null; + this.relayHealthStatus = + document.getElementById("relayHealthStatus") || null; + this.relayHealthRefreshButton = + document.getElementById("relayHealthRefreshBtn") || null; + this.relayHealthTelemetryToggle = + document.getElementById("relayHealthTelemetryOptIn") || null; this.subscriptionList = document.getElementById("subscriptionsList") || null; @@ -1364,10 +1569,24 @@ export class ProfileModalController { document.getElementById("profileMessagesStatus") || null; this.profileMessagesReloadButton = document.getElementById("profileMessagesReload") || null; + this.profileMessagesConversation = + document.getElementById("profileMessagesConversation") || null; + this.profileMessagesConversationEmpty = + document.getElementById("profileMessagesConversationEmpty") || null; this.profileMessageInput = document.getElementById("profileMessageInput") || null; this.profileMessageSendButton = document.getElementById("profileMessageSendBtn") || null; + this.profileMessageAttachmentInput = + document.getElementById("profileMessageAttachmentInput") || null; + this.profileMessageAttachmentButton = + document.getElementById("profileMessageAttachmentButton") || null; + this.profileMessageAttachmentEncrypt = + document.getElementById("profileMessageAttachmentEncrypt") || null; + this.profileMessageAttachmentList = + document.getElementById("profileMessageAttachmentList") || null; + this.profileMessageAttachmentClearCache = + document.getElementById("profileMessageAttachmentClearCache") || null; this.profileMessagesComposerHelper = document.getElementById("profileMessagesComposerHelper") || null; this.profileMessagesSendDmButton = @@ -1376,6 +1595,26 @@ export class ProfileModalController { document.getElementById("profileMessagesOpenRelays") || null; this.profileMessagesPrivacyToggle = document.getElementById("profileMessagesPrivacyToggle") || null; + this.profileMessagesPrivacyMode = + document.getElementById("profileMessagesPrivacyMode") || null; + this.profileMessagesRelayList = + document.getElementById("profileMessagesRelayList") || null; + this.profileMessagesRelayInput = + document.getElementById("profileMessagesRelayInput") || null; + this.profileMessagesRelayAddButton = + document.getElementById("profileMessagesRelayAdd") || null; + this.profileMessagesRelayPublishButton = + document.getElementById("profileMessagesRelayPublish") || null; + this.profileMessagesRelayStatus = + document.getElementById("profileMessagesRelayStatus") || null; + this.profileMessagesReadReceiptsToggle = + document.getElementById("profileMessagesReadReceiptsToggle") || null; + this.profileMessagesTypingToggle = + document.getElementById("profileMessagesTypingToggle") || null; + this.dmAppShellContainer = + document.getElementById("dmAppShellMount") || null; + this.profileLinkPreviewAutoToggle = + document.getElementById("profileLinkPreviewAutoToggle") || null; if (this.pendingMessagesRender) { const { messages, actorPubkey } = this.pendingMessagesRender; @@ -1402,6 +1641,9 @@ export class ProfileModalController { } this.setMessagesLoadingState(this.messagesLoadingState || "idle"); + this.updateMessagePrivacyModeDisplay(); + this.populateDmRelayPreferences(); + this.syncDmPrivacySettingsUi(); this.walletUriInput = document.getElementById("profileWalletUri") || null; this.walletDefaultZapInput = @@ -1685,6 +1927,9 @@ export class ProfileModalController { subscribe("directMessages:failure", (detail) => { this.handleDirectMessagesError(detail); }); + subscribe("directMessages:relayWarning", (detail) => { + this.handleDirectMessagesRelayWarning(detail); + }); this.directMessagesUnsubscribes = unsubscribes; @@ -1768,7 +2013,492 @@ export class ProfileModalController { return null; } - ensureDirectMessageSubscription(actorPubkey = null) { + resolveActiveDmRecipient() { + const candidate = + typeof this.state.getDmRecipient === "function" + ? this.state.getDmRecipient() + : null; + const normalized = this.normalizeHexPubkey(candidate); + if (normalized) { + return normalized; + } + return null; + } + + resolveActiveDmRelayOwner() { + const active = this.normalizeHexPubkey(this.getActivePubkey()); + if (active) { + return active; + } + + return this.resolveActiveDmActor(); + } + + getActiveDmRelayPreferences() { + const owner = this.resolveActiveDmRelayOwner(); + if (!owner || typeof this.state.getDmRelayPreferences !== "function") { + return []; + } + + const relays = this.state.getDmRelayPreferences(owner); + return Array.isArray(relays) ? relays.slice() : []; + } + + setActiveDmRelayPreferences(relays = []) { + const owner = this.resolveActiveDmRelayOwner(); + if (!owner || typeof this.state.setDmRelayPreferences !== "function") { + return []; + } + + return this.state.setDmRelayPreferences(owner, relays); + } + + setDmRelayPreferencesStatus(message = "") { + if (!(this.profileMessagesRelayStatus instanceof HTMLElement)) { + return; + } + + const text = typeof message === "string" ? message.trim() : ""; + this.profileMessagesRelayStatus.textContent = text; + } + + updateMessagePrivacyModeDisplay() { + if (!(this.profileMessagesPrivacyMode instanceof HTMLElement)) { + return; + } + + const isNip17 = + this.profileMessagesPrivacyToggle instanceof HTMLInputElement + ? this.profileMessagesPrivacyToggle.checked + : false; + const label = isNip17 ? "NIP-17" : "NIP-04"; + this.profileMessagesPrivacyMode.textContent = `Privacy: ${label}`; + this.profileMessagesPrivacyMode.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."; + } + + getDmPrivacySettingsSnapshot() { + const fallback = createInternalDefaultDmPrivacySettings(); + if (typeof this.state.getDmPrivacySettings !== "function") { + return { ...fallback }; + } + + const settings = this.state.getDmPrivacySettings(); + return { + readReceiptsEnabled: + typeof settings?.readReceiptsEnabled === "boolean" + ? settings.readReceiptsEnabled + : fallback.readReceiptsEnabled, + typingIndicatorsEnabled: + typeof settings?.typingIndicatorsEnabled === "boolean" + ? settings.typingIndicatorsEnabled + : fallback.typingIndicatorsEnabled, + }; + } + + persistDmPrivacySettings(partial = {}) { + if (typeof this.state.setDmPrivacySettings !== "function") { + return this.getDmPrivacySettingsSnapshot(); + } + + const resolved = + partial && typeof partial === "object" ? partial : {}; + + const current = this.getDmPrivacySettingsSnapshot(); + const merged = { + readReceiptsEnabled: + typeof resolved.readReceiptsEnabled === "boolean" + ? resolved.readReceiptsEnabled + : current.readReceiptsEnabled, + typingIndicatorsEnabled: + typeof resolved.typingIndicatorsEnabled === "boolean" + ? resolved.typingIndicatorsEnabled + : current.typingIndicatorsEnabled, + }; + + return this.state.setDmPrivacySettings(merged); + } + + syncDmPrivacySettingsUi() { + const settings = this.getDmPrivacySettingsSnapshot(); + + if (this.profileMessagesReadReceiptsToggle instanceof HTMLInputElement) { + this.profileMessagesReadReceiptsToggle.checked = + settings.readReceiptsEnabled; + } + + if (this.profileMessagesTypingToggle instanceof HTMLInputElement) { + this.profileMessagesTypingToggle.checked = + settings.typingIndicatorsEnabled; + } + } + + handleReadReceiptsToggle(enabled) { + this.persistDmPrivacySettings({ + readReceiptsEnabled: Boolean(enabled), + }); + this.syncDmPrivacySettingsUi(); + this.showStatus( + enabled + ? "Read receipts enabled for direct messages." + : "Read receipts disabled.", + ); + } + + handleTypingIndicatorsToggle(enabled) { + this.persistDmPrivacySettings({ + typingIndicatorsEnabled: Boolean(enabled), + }); + this.syncDmPrivacySettingsUi(); + this.showStatus( + enabled + ? "Typing indicators enabled for direct messages." + : "Typing indicators disabled.", + ); + } + + syncLinkPreviewSettingsUi() { + if (!(this.profileLinkPreviewAutoToggle instanceof HTMLInputElement)) { + return; + } + const settings = getLinkPreviewSettings(); + this.profileLinkPreviewAutoToggle.checked = Boolean( + settings?.autoFetchUnknownDomains, + ); + } + + handleLinkPreviewToggle(enabled) { + setLinkPreviewAutoFetch(Boolean(enabled)); + } + + async refreshDmRelayPreferences({ force = false } = {}) { + const owner = this.resolveActiveDmRelayOwner(); + if (!owner) { + this.populateDmRelayPreferences(); + return; + } + + const existing = this.getActiveDmRelayPreferences(); + if (!existing.length || force) { + if (typeof this.services.fetchDmRelayHints === "function") { + try { + const hints = await this.services.fetchDmRelayHints(owner); + if (typeof this.state.setDmRelayPreferences === "function") { + this.state.setDmRelayPreferences(owner, hints); + } + } catch (error) { + devLogger.warn( + "[profileModal] Failed to refresh DM relay hints for profile:", + error, + ); + } + } + } + + this.populateDmRelayPreferences(); + } + + populateDmRelayPreferences() { + if (!(this.profileMessagesRelayList instanceof HTMLElement)) { + return; + } + + const owner = this.resolveActiveDmRelayOwner(); + const relays = this.getActiveDmRelayPreferences(); + + this.profileMessagesRelayList.innerHTML = ""; + + if (!owner) { + const emptyState = document.createElement("li"); + emptyState.className = + "card border border-dashed border-border/60 p-4 text-center text-xs text-muted"; + emptyState.textContent = "Sign in to add DM relay hints."; + this.profileMessagesRelayList.appendChild(emptyState); + this.setDmRelayPreferencesStatus(""); + return; + } + + if (!relays.length) { + const emptyState = document.createElement("li"); + emptyState.className = + "card border border-dashed border-border/60 p-4 text-center text-xs text-muted"; + emptyState.textContent = "No DM relay hints yet."; + this.profileMessagesRelayList.appendChild(emptyState); + return; + } + + relays.forEach((url) => { + const item = document.createElement("li"); + item.className = "card flex items-center justify-between gap-3 p-3"; + + const label = document.createElement("p"); + label.className = "text-xs font-medium text-text break-all"; + label.textContent = url; + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "btn-ghost focus-ring text-xs"; + removeBtn.dataset.variant = "danger"; + removeBtn.textContent = "Remove"; + removeBtn.addEventListener("click", () => { + void this.handleRemoveDmRelayPreference(url); + }); + + item.appendChild(label); + item.appendChild(removeBtn); + this.profileMessagesRelayList.appendChild(item); + }); + } + + async handleAddDmRelayPreference() { + const input = this.profileMessagesRelayInput; + if (!(input instanceof HTMLInputElement)) { + return; + } + + const owner = this.resolveActiveDmRelayOwner(); + if (!owner) { + this.showError("Please sign in to save DM relay hints."); + return; + } + + const rawValue = typeof input.value === "string" ? input.value.trim() : ""; + const sanitized = sanitizeRelayList([rawValue]); + const relayUrl = sanitized[0]; + if (!relayUrl) { + this.showError("Enter a valid WSS relay URL."); + return; + } + + const current = this.getActiveDmRelayPreferences(); + const next = sanitizeRelayList([...current, relayUrl]); + this.setActiveDmRelayPreferences(next); + input.value = ""; + this.populateDmRelayPreferences(); + this.setDmRelayPreferencesStatus("DM relay hint added."); + } + + async handleRemoveDmRelayPreference(url) { + const owner = this.resolveActiveDmRelayOwner(); + if (!owner) { + this.showError("Please sign in to update DM relay hints."); + return; + } + + const target = typeof url === "string" ? url.trim() : ""; + if (!target) { + return; + } + + const current = this.getActiveDmRelayPreferences(); + const next = current.filter((entry) => entry !== target); + this.setActiveDmRelayPreferences(next); + this.populateDmRelayPreferences(); + this.setDmRelayPreferencesStatus("DM relay hint removed."); + } + + async handlePublishDmRelayPreferences() { + const owner = this.resolveActiveDmRelayOwner(); + if (!owner) { + this.showError("Please sign in to publish DM relay hints."); + return; + } + + const relays = this.getActiveDmRelayPreferences(); + if (!relays.length) { + this.showError("Add at least one DM relay before publishing."); + return; + } + + const callback = this.callbacks.onPublishDmRelayPreferences; + if (!callback || callback === noop) { + this.showError("DM relay publishing is unavailable right now."); + return; + } + + this.setDmRelayPreferencesStatus("Publishing DM relay hints…"); + + try { + const result = await callback({ + pubkey: owner, + relays, + controller: this, + }); + if (result?.ok) { + const acceptedCount = Array.isArray(result.accepted) + ? result.accepted.length + : 0; + const summary = acceptedCount + ? `Published to ${acceptedCount} relay${acceptedCount === 1 ? "" : "s"}.` + : "DM relay hints published."; + this.showSuccess("DM relay hints published."); + this.setDmRelayPreferencesStatus(summary); + return; + } + this.showError("Failed to publish DM relay hints."); + this.setDmRelayPreferencesStatus("DM relay hints publish failed."); + } catch (error) { + const message = + error && typeof error.message === "string" && error.message.trim() + ? error.message.trim() + : "Failed to publish DM relay hints."; + this.showError(message); + this.setDmRelayPreferencesStatus(message); + } + } + + buildDmRecipientContext(pubkey) { + const normalized = this.normalizeHexPubkey(pubkey); + if (!normalized) { + return null; + } + + const npub = + typeof this.safeEncodeNpub === "function" + ? this.safeEncodeNpub(normalized) + : null; + + const cacheEntry = + typeof this.services.getProfileCacheEntry === "function" + ? this.services.getProfileCacheEntry(normalized) + : null; + const profile = cacheEntry?.profile || null; + + const displayName = + profile?.display_name?.trim?.() || + profile?.name?.trim?.() || + (typeof this.formatShortNpub === "function" + ? this.formatShortNpub(npub) + : npub) || + npub || + "Unknown profile"; + + const relayHints = + typeof this.state.getDmRelayHints === "function" + ? this.state.getDmRelayHints(normalized) + : []; + + return { + pubkey: normalized, + npub, + profile, + displayName, + relayHints: Array.isArray(relayHints) ? relayHints.slice() : [], + }; + } + + async ensureDmRecipientData(pubkey) { + const normalized = this.normalizeHexPubkey(pubkey); + if (!normalized) { + return null; + } + + if ( + typeof this.services.batchFetchProfiles === "function" + ) { + try { + await this.services.batchFetchProfiles([normalized]); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to fetch DM recipient metadata:", + error, + ); + } + } + + if (typeof this.services.fetchDmRelayHints === "function") { + try { + const hints = await this.services.fetchDmRelayHints(normalized); + if (typeof this.state.setDmRelayHints === "function") { + this.state.setDmRelayHints(normalized, hints); + } + } catch (error) { + devLogger.warn( + "[profileModal] Failed to fetch DM relay hints:", + error, + ); + } + } + + const context = this.buildDmRecipientContext(normalized); + this.updateDmPrivacyToggleForRecipient(context); + return context; + } + + updateDmPrivacyToggleForRecipient(recipientContext, { force = false } = {}) { + if (!recipientContext) { + return; + } + + const relayHints = Array.isArray(recipientContext.relayHints) + ? recipientContext.relayHints + : []; + const hasHints = relayHints.length > 0; + + if (!this.dmPrivacyToggleTouched || force) { + this.setPrivacyToggleState(hasHints); + } + } + + setDirectMessageRecipient(pubkey, { reason = "manual" } = {}) { + const normalized = this.normalizeHexPubkey(pubkey); + const nextRecipient = normalized || null; + + if (typeof this.state.setDmRecipient === "function") { + this.state.setDmRecipient(nextRecipient); + } + + this.dmPrivacyToggleTouched = false; + this.updateMessageThreadSelection(nextRecipient); + + if (nextRecipient) { + void this.ensureDmRecipientData(nextRecipient); + this.setMessagesAnnouncement("Ready to message this recipient."); + } else if (reason === "clear") { + this.setMessagesAnnouncement("Message recipient cleared."); + } + + void this.renderDirectMessageConversation(); + return nextRecipient; + } + + updateMessageThreadSelection(activeRecipient) { + if (!(this.profileMessagesList instanceof HTMLElement)) { + return; + } + + const normalized = this.normalizeHexPubkey(activeRecipient); + const items = Array.from( + this.profileMessagesList.querySelectorAll("[data-remote-pubkey]"), + ); + + items.forEach((item) => { + if (!(item instanceof HTMLElement)) { + return; + } + const remote = this.normalizeHexPubkey(item.dataset.remotePubkey); + const isActive = normalized && remote === normalized; + item.dataset.state = isActive ? "active" : "inactive"; + }); + } + + focusMessageComposer() { + const input = this.profileMessageInput; + if (input instanceof HTMLTextAreaElement) { + input.focus(); + input.select(); + } + } + + setPrivacyToggleState(enabled) { + if (this.profileMessagesPrivacyToggle instanceof HTMLInputElement) { + this.profileMessagesPrivacyToggle.checked = Boolean(enabled); + } + this.updateMessagePrivacyModeDisplay(); + } + + async ensureDirectMessageSubscription(actorPubkey = null) { if ( !this.nostrService || typeof this.nostrService.ensureDirectMessageSubscription !== "function" @@ -1801,7 +2531,7 @@ export class ProfileModalController { let subscription = null; try { - subscription = this.nostrService.ensureDirectMessageSubscription({ + subscription = await this.nostrService.ensureDirectMessageSubscription({ actorPubkey: normalizedActor, }); } catch (error) { @@ -1844,6 +2574,12 @@ export class ProfileModalController { ? this.normalizeHexPubkey(actorPubkey) : this.resolveActiveDmActor(); + this.setDirectMessageRecipient(null, { reason: "clear" }); + this.resetAttachmentQueue({ clearInput: true }); + this.dmReadReceiptCache.clear(); + this.dmTypingLastSentAt = 0; + this.syncDmPrivacySettingsUi(); + if ( this.directMessagesSubscription && this.directMessagesSubscription.actor && @@ -1862,15 +2598,26 @@ export class ProfileModalController { this.profileMessagesList.classList.add("hidden"); this.profileMessagesList.setAttribute("hidden", ""); } + if (this.profileMessagesConversation instanceof HTMLElement) { + this.profileMessagesConversation.innerHTML = ""; + this.profileMessagesConversation.classList.add("hidden"); + this.profileMessagesConversation.setAttribute("hidden", ""); + } + if (this.profileMessagesConversationEmpty instanceof HTMLElement) { + this.profileMessagesConversationEmpty.classList.remove("hidden"); + this.profileMessagesConversationEmpty.removeAttribute("hidden"); + } if (!normalized) { this.setMessagesLoadingState("unauthenticated"); this.updateMessagesReloadState(); + this.populateDmRelayPreferences(); + this.setDmRelayPreferencesStatus(""); return; } this.setMessagesLoadingState("loading"); - this.ensureDirectMessageSubscription(normalized); + void this.ensureDirectMessageSubscription(normalized); this.updateMessagesReloadState(); if (this.getActivePane() === "messages") { @@ -1879,6 +2626,8 @@ export class ProfileModalController { reason: "identity-change", }); } + + void this.refreshDmRelayPreferences({ force: true }); } setMessagesLoadingState(state, options = {}) { @@ -1996,6 +2745,10 @@ export class ProfileModalController { const input = this.profileMessageInput; const button = this.profileMessageSendButton; const helper = this.profileMessagesComposerHelper; + const attachmentInput = this.profileMessageAttachmentInput; + const attachmentButton = this.profileMessageAttachmentButton; + const attachmentEncrypt = this.profileMessageAttachmentEncrypt; + const attachmentClearCache = this.profileMessageAttachmentClearCache; const shouldDisable = this.messagesLoadingState === "unauthenticated"; const applyDisabledState = (element) => { @@ -2013,6 +2766,10 @@ export class ProfileModalController { applyDisabledState(input); applyDisabledState(button); + applyDisabledState(attachmentInput); + applyDisabledState(attachmentButton); + applyDisabledState(attachmentEncrypt); + applyDisabledState(attachmentClearCache); if (helper instanceof HTMLElement) { if (shouldDisable) { @@ -2024,6 +2781,8 @@ export class ProfileModalController { helper.setAttribute("hidden", ""); } } + + this.updateMessagePrivacyModeDisplay(); } setMessagesAnnouncement(message) { @@ -2056,27 +2815,586 @@ export class ProfileModalController { } } - handleSendDmRequest() { + async handleSendDmRequest() { + const recipient = this.resolveActiveDmRecipient(); + if (!recipient) { + this.showError("Please select a message recipient."); + return; + } + + const context = await this.ensureDmRecipientData(recipient); + this.focusMessageComposer(); + const callback = this.callbacks.onSendDm; if (callback && callback !== noop) { callback({ actorPubkey: this.resolveActiveDmActor(), + recipient: context, controller: this, }); } } - handleOpenDmRelaysRequest() { + async handleOpenDmRelaysRequest() { + const recipient = this.resolveActiveDmRecipient(); + if (!recipient) { + this.showError("Please select a message recipient."); + return; + } + + const context = await this.ensureDmRecipientData(recipient); + const callback = this.callbacks.onOpenRelays; if (callback && callback !== noop) { - callback({ controller: this }); + callback({ controller: this, recipient: context }); } } handlePrivacyToggle(enabled) { + const recipientContext = this.buildDmRecipientContext( + this.resolveActiveDmRecipient(), + ); + const relayHints = Array.isArray(recipientContext?.relayHints) + ? recipientContext.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.dmPrivacyToggleTouched = true; + this.updateMessagePrivacyModeDisplay(); const callback = this.callbacks.onTogglePrivacy; if (callback && callback !== noop) { - callback({ controller: this, enabled: Boolean(enabled) }); + callback({ + controller: this, + enabled: Boolean(enabled), + recipient: recipientContext, + }); + } + } + + generateAttachmentId(file) { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + const name = typeof file?.name === "string" ? file.name : "attachment"; + return `${name}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + } + + resetAttachmentQueue({ clearInput = false } = {}) { + this.dmAttachmentQueue.forEach((entry) => { + if (entry?.previewUrl && typeof URL !== "undefined") { + try { + URL.revokeObjectURL(entry.previewUrl); + } catch (error) { + devLogger.warn("[profileModal] Failed to revoke attachment preview URL.", error); + } + } + }); + this.dmAttachmentQueue = []; + this.dmAttachmentUploads.clear(); + + if (clearInput && this.profileMessageAttachmentInput) { + this.profileMessageAttachmentInput.value = ""; + } + + this.renderAttachmentQueue(); + } + + handleAttachmentSelection() { + const input = this.profileMessageAttachmentInput; + if (!(input instanceof HTMLInputElement) || !input.files) { + return; + } + + const files = Array.from(input.files); + if (!files.length) { + return; + } + + files.forEach((file) => { + const previewUrl = + typeof URL !== "undefined" ? URL.createObjectURL(file) : ""; + this.dmAttachmentQueue.push({ + id: this.generateAttachmentId(file), + file, + name: file.name, + type: file.type || "application/octet-stream", + size: file.size, + previewUrl, + status: "pending", + progress: 0, + }); + }); + + input.value = ""; + this.renderAttachmentQueue(); + } + + renderAttachmentQueue() { + const list = this.profileMessageAttachmentList; + if (!(list instanceof HTMLElement)) { + return; + } + + list.innerHTML = ""; + + if (!this.dmAttachmentQueue.length) { + return; + } + + this.dmAttachmentQueue.forEach((entry) => { + const item = document.createElement("div"); + item.className = "card flex flex-col gap-2 p-3"; + item.dataset.attachmentId = entry.id; + + const header = document.createElement("div"); + header.className = "flex items-center justify-between gap-2"; + const title = document.createElement("div"); + title.className = "text-sm font-semibold text-text"; + title.textContent = entry.name || "Attachment"; + header.appendChild(title); + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "btn-ghost focus-ring inline-flex items-center"; + removeButton.dataset.size = "sm"; + removeButton.textContent = "Remove"; + removeButton.addEventListener("click", () => { + this.dmAttachmentQueue = this.dmAttachmentQueue.filter( + (queued) => queued.id !== entry.id, + ); + if (entry.previewUrl && typeof URL !== "undefined") { + URL.revokeObjectURL(entry.previewUrl); + } + this.renderAttachmentQueue(); + }); + header.appendChild(removeButton); + item.appendChild(header); + + const meta = document.createElement("div"); + meta.className = "text-xs text-muted"; + const sizeLabel = formatAttachmentSize(entry.size); + meta.textContent = sizeLabel + ? `${entry.type || "file"} · ${sizeLabel}` + : entry.type || "file"; + item.appendChild(meta); + + if (entry.previewUrl && entry.type?.startsWith("image/")) { + const img = document.createElement("img"); + img.src = entry.previewUrl; + img.alt = entry.name || "Attachment preview"; + img.className = "h-24 w-24 rounded-lg object-cover"; + item.appendChild(img); + } + + const progress = document.createElement("progress"); + progress.className = "progress"; + progress.value = entry.progress || 0; + progress.max = 1; + progress.dataset.variant = "surface"; + item.appendChild(progress); + + const status = document.createElement("div"); + status.className = "text-xs text-muted"; + status.textContent = + entry.status === "uploading" + ? "Uploading…" + : entry.status === "error" + ? entry.error || "Upload failed." + : "Ready to upload."; + item.appendChild(status); + + list.appendChild(item); + }); + } + + async uploadAttachmentQueue(actorPubkey) { + const r2Service = this.services.r2Service; + if (!r2Service) { + throw new Error("Storage service unavailable."); + } + + const encrypt = + this.profileMessageAttachmentEncrypt instanceof HTMLInputElement + ? this.profileMessageAttachmentEncrypt.checked + : false; + + const payloads = []; + + for (const entry of this.dmAttachmentQueue) { + entry.status = "uploading"; + entry.progress = 0; + this.renderAttachmentQueue(); + + try { + const payload = await uploadAttachment({ + r2Service, + pubkey: actorPubkey, + file: entry.file, + encrypt, + buildKey: buildR2Key, + buildUrl: buildPublicUrl, + onProgress: (fraction) => { + entry.progress = Number.isFinite(fraction) ? fraction : entry.progress; + this.renderAttachmentQueue(); + }, + }); + payloads.push(payload); + entry.status = "uploaded"; + entry.progress = 1; + } catch (error) { + entry.status = "error"; + entry.error = + error && typeof error.message === "string" + ? error.message + : "Attachment upload failed."; + this.renderAttachmentQueue(); + throw error; + } + } + + return payloads; + } + + async renderDirectMessageConversation() { + const container = this.profileMessagesConversation; + const emptyState = this.profileMessagesConversationEmpty; + const actor = this.resolveActiveDmActor(); + const recipient = this.resolveActiveDmRecipient(); + + if (!(container instanceof HTMLElement)) { + return; + } + + container.innerHTML = ""; + + if (!actor || !recipient || !this.directMessagesCache.length) { + container.classList.add("hidden"); + container.setAttribute("hidden", ""); + if (emptyState instanceof HTMLElement) { + emptyState.classList.remove("hidden"); + emptyState.removeAttribute("hidden"); + } + return; + } + + const messages = this.directMessagesCache + .filter((entry) => this.resolveDirectMessageRemote(entry, actor) === recipient) + .sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); + + if (!messages.length) { + container.classList.add("hidden"); + container.setAttribute("hidden", ""); + if (emptyState instanceof HTMLElement) { + emptyState.classList.remove("hidden"); + emptyState.removeAttribute("hidden"); + } + return; + } + + if (emptyState instanceof HTMLElement) { + emptyState.classList.add("hidden"); + emptyState.setAttribute("hidden", ""); + } + + void this.maybePublishReadReceipt(messages, { + recipientPubkey: recipient, + }); + + messages.forEach((message) => { + const item = document.createElement("div"); + item.className = "card flex flex-col gap-2 p-3"; + + const body = document.createElement("div"); + body.className = "text-sm text-text whitespace-pre-line"; + const text = typeof message.plaintext === "string" ? message.plaintext.trim() : ""; + body.textContent = text || "Attachment"; + item.appendChild(body); + + const attachments = extractAttachmentsFromMessage(message); + attachments.forEach((attachment) => { + const attachmentCard = document.createElement("div"); + attachmentCard.className = "flex flex-col gap-2 rounded-lg border border-border/60 p-3"; + + const title = document.createElement("div"); + title.className = "text-xs font-semibold text-text"; + title.textContent = describeAttachment(attachment); + attachmentCard.appendChild(title); + + const meta = document.createElement("div"); + meta.className = "text-3xs text-muted"; + const sizeLabel = formatAttachmentSize(attachment.size); + const typeLabel = attachment.type || "file"; + meta.textContent = sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel; + attachmentCard.appendChild(meta); + + const progress = document.createElement("progress"); + progress.className = "progress"; + progress.value = 0; + progress.max = 1; + progress.dataset.variant = "surface"; + attachmentCard.appendChild(progress); + + const status = document.createElement("div"); + status.className = "text-3xs text-muted"; + status.textContent = attachment.encrypted + ? "Decrypting attachment…" + : "Downloading attachment…"; + attachmentCard.appendChild(status); + + item.appendChild(attachmentCard); + + downloadAttachment({ + url: attachment.url, + expectedHash: attachment.x, + key: attachment.key, + mimeType: attachment.type, + onProgress: (fraction) => { + progress.value = Number.isFinite(fraction) ? fraction : progress.value; + }, + }) + .then((result) => { + progress.value = 1; + progress.classList.add("hidden"); + progress.setAttribute("hidden", ""); + status.textContent = attachment.encrypted ? "Decrypted." : "Ready."; + + if (!result?.objectUrl) { + return; + } + + if (attachment.type?.startsWith("image/")) { + const img = document.createElement("img"); + img.src = result.objectUrl; + img.alt = attachment.name || "Attachment preview"; + img.className = "h-40 w-full rounded-lg object-cover"; + attachmentCard.appendChild(img); + } else if (attachment.type?.startsWith("video/")) { + const video = document.createElement("video"); + video.src = result.objectUrl; + video.controls = true; + video.className = "w-full rounded-lg"; + attachmentCard.appendChild(video); + } else if (attachment.type?.startsWith("audio/")) { + const audio = document.createElement("audio"); + audio.src = result.objectUrl; + audio.controls = true; + audio.className = "w-full"; + attachmentCard.appendChild(audio); + } else { + const link = document.createElement("a"); + link.href = result.objectUrl; + link.textContent = "Download attachment"; + link.className = "text-xs text-accent underline-offset-2 hover:underline"; + link.download = attachment.name || "attachment"; + attachmentCard.appendChild(link); + } + }) + .catch((error) => { + status.textContent = + error && typeof error.message === "string" + ? error.message + : "Attachment download failed."; + status.classList.add("text-critical"); + }); + }); + + container.appendChild(item); + }); + + container.classList.remove("hidden"); + container.removeAttribute("hidden"); + } + + resolveDirectMessageEventId(message) { + if (!message || typeof message !== "object") { + return ""; + } + + const eventId = + typeof message.event?.id === "string" ? message.event.id.trim() : ""; + if (eventId) { + return eventId; + } + + const innerId = + typeof message.message?.id === "string" ? message.message.id.trim() : ""; + return innerId; + } + + resolveLatestDirectMessageForRecipient(recipientPubkey, actorPubkey = null) { + const normalizedRecipient = + typeof recipientPubkey === "string" + ? this.normalizeHexPubkey(recipientPubkey) + : ""; + if (!normalizedRecipient || !Array.isArray(this.directMessagesCache)) { + return null; + } + + const resolvedActor = actorPubkey + ? this.normalizeHexPubkey(actorPubkey) + : this.resolveActiveDmActor(); + + let latest = null; + let latestTimestamp = 0; + + for (const entry of this.directMessagesCache) { + if (this.resolveDirectMessageRemote(entry, resolvedActor) !== normalizedRecipient) { + continue; + } + const timestamp = Number(entry?.timestamp) || 0; + if (!latest || timestamp > latestTimestamp) { + latest = entry; + latestTimestamp = timestamp; + } + } + + return latest; + } + + resolveDirectMessageKind(message) { + if (!message || typeof message !== "object") { + return null; + } + + if (Number.isFinite(message?.event?.kind)) { + return Number(message.event.kind); + } + + if (Number.isFinite(message?.message?.kind)) { + return Number(message.message.kind); + } + + return null; + } + + async maybePublishReadReceipt(messages, { recipientPubkey } = {}) { + if (!Array.isArray(messages) || !messages.length) { + return; + } + + const settings = this.getDmPrivacySettingsSnapshot(); + if (!settings.readReceiptsEnabled) { + return; + } + + if ( + !this.services.nostrClient || + typeof this.services.nostrClient.publishDmReadReceipt !== "function" + ) { + return; + } + + const normalizedRecipient = + typeof recipientPubkey === "string" + ? this.normalizeHexPubkey(recipientPubkey) + : ""; + if (!normalizedRecipient) { + return; + } + + let latestMessage = null; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const entry = messages[index]; + if ( + entry && + typeof entry === "object" && + entry.direction === "incoming" + ) { + const eventId = this.resolveDirectMessageEventId(entry); + if (eventId) { + latestMessage = entry; + break; + } + } + } + + if (!latestMessage) { + return; + } + + const eventId = this.resolveDirectMessageEventId(latestMessage); + if (!eventId) { + return; + } + + const cacheKey = `${normalizedRecipient}:${eventId}`; + if (this.dmReadReceiptCache.has(cacheKey)) { + return; + } + + const relayHints = this.buildDmRecipientContext(normalizedRecipient)?.relayHints || []; + const messageKind = this.resolveDirectMessageKind(latestMessage); + + try { + const result = await this.services.nostrClient.publishDmReadReceipt({ + eventId, + recipientPubkey: normalizedRecipient, + messageKind, + relays: relayHints, + }); + + if (result?.ok) { + this.dmReadReceiptCache.add(cacheKey); + } + } catch (error) { + devLogger.warn("[profileModal] Failed to publish read receipt:", error); + } + } + + async maybePublishTypingIndicator() { + const settings = this.getDmPrivacySettingsSnapshot(); + if (!settings.typingIndicatorsEnabled) { + return; + } + + if ( + !this.services.nostrClient || + typeof this.services.nostrClient.publishDmTypingIndicator !== "function" + ) { + return; + } + + const input = this.profileMessageInput; + const messageText = + input instanceof HTMLTextAreaElement ? input.value.trim() : ""; + if (!messageText) { + return; + } + + const recipient = this.resolveActiveDmRecipient(); + if (!recipient) { + return; + } + + const now = Date.now(); + if (now - this.dmTypingLastSentAt < TYPING_INDICATOR_COOLDOWN_MS) { + return; + } + + this.dmTypingLastSentAt = now; + + const relayHints = this.buildDmRecipientContext(recipient)?.relayHints || []; + const latestMessage = this.resolveLatestDirectMessageForRecipient( + recipient, + this.resolveActiveDmActor(), + ); + const latestEventId = this.resolveDirectMessageEventId(latestMessage); + + try { + await this.services.nostrClient.publishDmTypingIndicator({ + recipientPubkey: recipient, + conversationEventId: latestEventId || null, + relays: relayHints, + expiresInSeconds: TYPING_INDICATOR_TTL_SECONDS, + }); + } catch (error) { + devLogger.warn("[profileModal] Failed to publish typing indicator:", error); } } @@ -2085,7 +3403,15 @@ export class ProfileModalController { case "sign-event-unavailable": return "Connect a Nostr signer to send messages."; case "encryption-unsupported": - return "Your signer does not support NIP-44 or NIP-04 encryption."; + return "Your signer does not support NIP-04 encryption."; + case "nip44-unsupported": + return "Your signer does not support NIP-44 encryption required for NIP-17."; + case "nip17-relays-missing": + return "Recipient has not shared NIP-17 relay hints yet."; + case "nip17-relays-unavailable": + return "No DM relays are available to deliver this message."; + case "nip17-keygen-failed": + return "We couldn’t create secure wrapper keys for NIP-17 delivery."; case "extension-permission-denied": return "Please grant your Nostr extension permission to send messages."; case "missing-actor-pubkey": @@ -2101,7 +3427,9 @@ export class ProfileModalController { case "invalid-target": return "Select a valid recipient before sending."; case "empty-message": - return "Please enter a message."; + return "Please enter a message or attach a file."; + case "attachments-unsupported": + return "Attachments require NIP-17 delivery. Enable the privacy toggle to send files."; default: return "Unable to send message. Please try again."; } @@ -2114,20 +3442,45 @@ export class ProfileModalController { } const message = typeof input.value === "string" ? input.value.trim() : ""; - if (!message) { - this.showError("Please enter a message."); + const hasAttachments = this.dmAttachmentQueue.length > 0; + if (!message && !hasAttachments) { + this.showError("Please enter a message or attach a file."); return; } - const targetCandidate = - this.resolveActiveDmActor() || this.directMessagesLastActor; + const targetHex = this.resolveActiveDmRecipient(); const target = - typeof targetCandidate === "string" ? targetCandidate.trim() : ""; + typeof targetHex === "string" && typeof this.safeEncodeNpub === "function" + ? this.safeEncodeNpub(targetHex) + : ""; if (!target) { this.showError("Please select a message recipient."); return; } + const recipientContext = this.buildDmRecipientContext(targetHex); + const recipientRelayHints = Array.isArray(recipientContext?.relayHints) + ? recipientContext.relayHints + : []; + const useNip17 = + this.profileMessagesPrivacyToggle instanceof HTMLInputElement + ? this.profileMessagesPrivacyToggle.checked + : false; + const senderRelayHints = this.getActiveDmRelayPreferences(); + + if (hasAttachments && !useNip17) { + this.showError( + "Attachments require NIP-17 delivery. Enable the privacy toggle to send files.", + ); + return; + } + + if (useNip17 && !recipientRelayHints.length) { + this.showStatus( + "Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.", + ); + } + if ( !this.services.nostrClient || typeof this.services.nostrClient.sendDirectMessage !== "function" @@ -2143,14 +3496,45 @@ export class ProfileModalController { } try { + let attachmentPayloads = []; + if (hasAttachments) { + try { + attachmentPayloads = await this.uploadAttachmentQueue( + this.resolveActiveDmActor(), + ); + } catch (error) { + const messageText = + error && typeof error.message === "string" + ? error.message + : "Attachment upload failed."; + this.showError(messageText); + return; + } + } + const result = await this.services.nostrClient.sendDirectMessage( target, message, + null, + useNip17 + ? { + useNip17: true, + recipientRelayHints, + senderRelayHints, + attachments: attachmentPayloads, + } + : {}, ); if (result?.ok) { input.value = ""; + this.resetAttachmentQueue({ clearInput: true }); this.showSuccess("Message sent."); + if (result?.warning === "dm-relays-fallback") { + this.showStatus( + "Privacy warning: this message used default relays because no NIP-17 relay list was found.", + ); + } void this.populateProfileMessages({ force: true, reason: "send-message" }); return; } @@ -2171,6 +3555,7 @@ export class ProfileModalController { this.directMessagesCache = []; this.directMessagesLastActor = this.resolveActiveDmActor(); this.messagesInitialLoadPending = true; + this.setDirectMessageRecipient(null, { reason: "clear" }); if (this.profileMessagesList instanceof HTMLElement) { this.profileMessagesList.innerHTML = ""; @@ -2390,6 +3775,7 @@ export class ProfileModalController { const item = document.createElement("li"); item.className = "card flex flex-col gap-3 p-4"; item.setAttribute("data-remote-pubkey", thread.remoteHex); + item.dataset.state = "inactive"; const header = document.createElement("div"); header.className = "flex items-start justify-between gap-3"; @@ -2467,6 +3853,13 @@ export class ProfileModalController { item.appendChild(meta); + item.addEventListener("click", () => { + this.setDirectMessageRecipient(thread.remoteHex, { + reason: "thread-select", + }); + this.focusMessageComposer(); + }); + return item; } @@ -2513,6 +3906,7 @@ export class ProfileModalController { if (!threads.length) { this.profileMessagesList.classList.add("hidden"); this.profileMessagesList.setAttribute("hidden", ""); + void this.renderDirectMessageConversation(); return; } @@ -2523,8 +3917,22 @@ export class ProfileModalController { } } + const activeRecipient = this.resolveActiveDmRecipient(); + const hasActiveRecipient = + activeRecipient && + threads.some((thread) => thread.remoteHex === activeRecipient); + + if (threads.length && !hasActiveRecipient) { + this.setDirectMessageRecipient(threads[0].remoteHex, { + reason: "thread-default", + }); + } else if (hasActiveRecipient) { + this.updateMessageThreadSelection(activeRecipient); + } + this.profileMessagesList.classList.remove("hidden"); this.profileMessagesList.removeAttribute("hidden"); + void this.renderDirectMessageConversation(); } async populateProfileMessages(options = {}) { @@ -2588,6 +3996,7 @@ export class ProfileModalController { try { let snapshot = await this.nostrService.loadDirectMessages({ actorPubkey: actor, + initialLoad: true, }); if (!Array.isArray(snapshot)) { snapshot = []; @@ -2631,16 +4040,62 @@ export class ProfileModalController { } } - this.ensureDirectMessageSubscription(actor); + void this.ensureDirectMessageSubscription(actor); + } + + mountDmAppShell() { + const container = + this.dmAppShellContainer instanceof HTMLElement + ? this.dmAppShellContainer + : null; + if (!container) { + return; + } + + container.innerHTML = ""; + + try { + this.dmAppShell = new AppShell({ document }); + } catch (error) { + this.dmAppShell = null; + devLogger.warn("[profileModal] Failed to mount DM app shell:", error); + return; + } + + const root = + this.dmAppShell && + typeof this.dmAppShell.getRoot === "function" + ? this.dmAppShell.getRoot() + : null; + if (!(root instanceof HTMLElement)) { + devLogger.warn("[profileModal] DM app shell root missing."); + return; + } + + container.appendChild(root); + } + + unmountDmAppShell() { + const container = + this.dmAppShellContainer instanceof HTMLElement + ? this.dmAppShellContainer + : null; + if (container) { + container.innerHTML = ""; + } + + this.dmAppShell = null; } resumeProfileMessages() { this.messagesViewActive = true; + this.mountDmAppShell(); this.updateMessagesReloadState(); } pauseProfileMessages() { this.messagesViewActive = false; + this.unmountDmAppShell(); this.updateMessagesReloadState(); } @@ -2696,6 +4151,7 @@ export class ProfileModalController { } this.directMessagesCache = []; + this.setDirectMessageRecipient(null, { reason: "clear" }); if (this.profileMessagesList instanceof HTMLElement) { this.profileMessagesList.innerHTML = ""; this.profileMessagesList.classList.add("hidden"); @@ -2732,6 +4188,16 @@ export class ProfileModalController { this.updateMessagesReloadState(); } + handleDirectMessagesRelayWarning(detail = {}) { + if (detail?.warning !== "dm-relays-fallback") { + return; + } + + this.showStatus( + "Privacy warning: direct messages are using your default relays because no NIP-17 relay list is available.", + ); + } + registerEventListeners() { if (this.closeButton instanceof HTMLElement) { this.closeButton.addEventListener("click", () => { @@ -2789,6 +4255,18 @@ export class ProfileModalController { }); } + if (this.relayHealthRefreshButton instanceof HTMLElement) { + this.relayHealthRefreshButton.addEventListener("click", () => { + void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "manual" }); + }); + } + + if (this.relayHealthTelemetryToggle instanceof HTMLInputElement) { + this.relayHealthTelemetryToggle.addEventListener("change", () => { + this.handleRelayHealthTelemetryToggle(); + }); + } + if (this.addBlockedButton instanceof HTMLElement) { this.addBlockedButton.addEventListener("click", () => { void this.handleAddBlockedCreator(); @@ -2842,13 +4320,13 @@ export class ProfileModalController { if (this.profileMessagesSendDmButton instanceof HTMLElement) { this.profileMessagesSendDmButton.addEventListener("click", () => { - this.handleSendDmRequest(); + void this.handleSendDmRequest(); }); } if (this.profileMessagesOpenRelaysButton instanceof HTMLElement) { this.profileMessagesOpenRelaysButton.addEventListener("click", () => { - this.handleOpenDmRelaysRequest(); + void this.handleOpenDmRelaysRequest(); }); } @@ -2861,12 +4339,84 @@ export class ProfileModalController { }); } + if (this.profileMessagesReadReceiptsToggle instanceof HTMLElement) { + this.profileMessagesReadReceiptsToggle.addEventListener("change", (event) => { + const toggle = event.currentTarget; + if (toggle instanceof HTMLInputElement) { + this.handleReadReceiptsToggle(toggle.checked); + } + }); + } + + if (this.profileMessagesTypingToggle instanceof HTMLElement) { + this.profileMessagesTypingToggle.addEventListener("change", (event) => { + const toggle = event.currentTarget; + if (toggle instanceof HTMLInputElement) { + this.handleTypingIndicatorsToggle(toggle.checked); + } + }); + } + + if (this.profileLinkPreviewAutoToggle instanceof HTMLElement) { + this.profileLinkPreviewAutoToggle.addEventListener("change", (event) => { + const toggle = event.currentTarget; + if (toggle instanceof HTMLInputElement) { + this.handleLinkPreviewToggle(toggle.checked); + } + }); + } + + if (this.profileMessagesRelayAddButton instanceof HTMLElement) { + this.profileMessagesRelayAddButton.addEventListener("click", () => { + void this.handleAddDmRelayPreference(); + }); + } + + if (this.profileMessagesRelayInput instanceof HTMLElement) { + this.profileMessagesRelayInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + void this.handleAddDmRelayPreference(); + } + }); + } + + if (this.profileMessagesRelayPublishButton instanceof HTMLElement) { + this.profileMessagesRelayPublishButton.addEventListener("click", () => { + void this.handlePublishDmRelayPreferences(); + }); + } + if (this.profileMessageSendButton instanceof HTMLElement) { this.profileMessageSendButton.addEventListener("click", () => { void this.handleSendProfileMessage(); }); } + if (this.profileMessageAttachmentButton instanceof HTMLElement) { + this.profileMessageAttachmentButton.addEventListener("click", () => { + if (this.profileMessageAttachmentInput instanceof HTMLInputElement) { + this.profileMessageAttachmentInput.click(); + } + }); + } + + if (this.profileMessageAttachmentInput instanceof HTMLElement) { + this.profileMessageAttachmentInput.addEventListener("change", () => { + this.handleAttachmentSelection(); + }); + } + + if (this.profileMessageAttachmentClearCache instanceof HTMLElement) { + this.profileMessageAttachmentClearCache.addEventListener("click", () => { + clearAttachmentCache(); + const stats = getAttachmentCacheStats(); + this.showStatus( + `Attachment cache cleared (${stats.size}/${stats.maxSize}).`, + ); + }); + } + if (this.profileMessageInput instanceof HTMLElement) { this.profileMessageInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { @@ -2874,6 +4424,9 @@ export class ProfileModalController { void this.handleSendProfileMessage(); } }); + this.profileMessageInput.addEventListener("input", () => { + void this.maybePublishTypingIndicator(); + }); } if (this.walletUriInput instanceof HTMLElement) { @@ -4354,9 +5907,13 @@ export class ProfileModalController { if (target === "history") { void this.populateProfileWatchHistory(); + } else if (target === "relays") { + this.populateProfileRelays(); + void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "pane-select" }); } else if (target === "messages") { this.resumeProfileMessages(); void this.populateProfileMessages({ reason: "pane-select" }); + void this.refreshDmRelayPreferences(); } else if (target === "wallet") { this.refreshWalletPaneState(); } else if (target === "storage") { @@ -4474,6 +6031,166 @@ export class ProfileModalController { }); } + updateRelayHealthStatus(message = "") { + if (!this.relayHealthStatus) { + return; + } + + const text = typeof message === "string" ? message.trim() : ""; + this.relayHealthStatus.textContent = text; + } + + renderRelayHealthSnapshot(snapshot = []) { + if (!this.relayHealthList) { + return; + } + + this.relayHealthList.innerHTML = ""; + + if (!snapshot.length) { + const emptyState = document.createElement("li"); + emptyState.className = + "card border border-dashed border-surface-strong p-4 text-center text-sm text-muted"; + emptyState.textContent = "No relays configured."; + this.relayHealthList.appendChild(emptyState); + return; + } + + snapshot.forEach((entry) => { + const item = document.createElement("li"); + item.className = "card flex flex-col gap-2 p-4"; + + const header = document.createElement("div"); + header.className = "flex items-center justify-between gap-3"; + + const urlEl = document.createElement("p"); + urlEl.className = "text-sm font-medium text-primary break-all"; + urlEl.textContent = entry.url; + + const connection = document.createElement("span"); + const connected = Boolean(entry.connected); + connection.className = `text-xs font-semibold ${ + connected ? "text-status-success" : "text-status-danger" + }`; + connection.textContent = connected ? "Connected" : "Unavailable"; + + header.appendChild(urlEl); + header.appendChild(connection); + + const details = document.createElement("div"); + details.className = "grid grid-cols-2 gap-3 text-xs text-muted sm:grid-cols-3"; + + const latency = document.createElement("div"); + latency.className = "flex items-center justify-between gap-2"; + const latencyLabel = document.createElement("span"); + latencyLabel.textContent = "Latency"; + const latencyValue = document.createElement("span"); + latencyValue.className = "text-text"; + latencyValue.textContent = Number.isFinite(entry.lastLatencyMs) + ? `${entry.lastLatencyMs} ms` + : "—"; + latency.appendChild(latencyLabel); + latency.appendChild(latencyValue); + + const errors = document.createElement("div"); + errors.className = "flex items-center justify-between gap-2"; + const errorsLabel = document.createElement("span"); + errorsLabel.textContent = "Errors"; + const errorsValue = document.createElement("span"); + errorsValue.className = "text-text"; + errorsValue.textContent = Number.isFinite(entry.errorCount) + ? `${entry.errorCount}` + : "0"; + errors.appendChild(errorsLabel); + errors.appendChild(errorsValue); + + const checks = document.createElement("div"); + checks.className = "flex items-center justify-between gap-2"; + const checksLabel = document.createElement("span"); + checksLabel.textContent = "Checked"; + const checksValue = document.createElement("span"); + checksValue.className = "text-text"; + if (Number.isFinite(entry.lastCheckedAt) && entry.lastCheckedAt > 0) { + checksValue.textContent = new Date(entry.lastCheckedAt).toLocaleTimeString(); + } else { + checksValue.textContent = "—"; + } + checks.appendChild(checksLabel); + checks.appendChild(checksValue); + + details.appendChild(latency); + details.appendChild(errors); + details.appendChild(checks); + + item.appendChild(header); + item.appendChild(details); + + this.relayHealthList.appendChild(item); + }); + } + + handleRelayHealthTelemetryToggle() { + const service = this.services?.relayHealthService; + if (!service || !(this.relayHealthTelemetryToggle instanceof HTMLInputElement)) { + return; + } + + const enabled = service.setTelemetryOptIn( + this.relayHealthTelemetryToggle.checked, + ); + this.relayHealthTelemetryToggle.checked = enabled; + this.updateRelayHealthStatus( + enabled ? "Relay health telemetry enabled." : "Relay health telemetry disabled.", + ); + } + + async refreshRelayHealthPanel({ forceRefresh = false, reason = "" } = {}) { + const service = this.services?.relayHealthService; + if (!service || !this.relayHealthList) { + return []; + } + + if (this.relayHealthTelemetryToggle instanceof HTMLInputElement) { + this.relayHealthTelemetryToggle.checked = service.getTelemetryOptIn(); + } + + const snapshot = service.getSnapshot(); + this.renderRelayHealthSnapshot(snapshot); + + if (!forceRefresh) { + return snapshot; + } + + if (this.relayHealthRefreshPromise) { + return this.relayHealthRefreshPromise; + } + + const statusMessage = reason === "manual" ? "Refreshing relay health…" : "Checking relays…"; + this.updateRelayHealthStatus(statusMessage); + + const refreshPromise = service + .refresh() + .then((latest) => { + this.renderRelayHealthSnapshot(latest); + this.updateRelayHealthStatus("Relay health updated."); + return latest; + }) + .catch((error) => { + this.updateRelayHealthStatus("Failed to refresh relay health."); + this.showError("Failed to refresh relay health."); + devLogger.warn("[profileModal] Relay health refresh failed:", error); + return []; + }) + .finally(() => { + if (this.relayHealthRefreshPromise === refreshPromise) { + this.relayHealthRefreshPromise = null; + } + }); + + this.relayHealthRefreshPromise = refreshPromise; + return refreshPromise; + } + async handleRelayOperation(meta = {}, { successMessage = "Relay preferences updated.", skipPublishIfUnchanged = true, @@ -4535,10 +6252,12 @@ export class ProfileModalController { this.showSuccess(unchangedMessage); } this.populateProfileRelays(); + void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" }); return operationContext; } this.populateProfileRelays(); + void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" }); if (operationContext.ok) { if (successMessage) { @@ -6376,14 +8095,11 @@ export class ProfileModalController { } try { - const content = JSON.stringify(profile); - const event = { - kind: 0, - created_at: Math.floor(Date.now() / 1000), + const event = buildProfileMetadataEvent({ pubkey, - tags: [], - content, - }; + created_at: Math.floor(Date.now() / 1000), + metadata: profile, + }); const result = await this.services.nostrClient.signAndPublishEvent(event); @@ -9719,6 +11435,7 @@ export class ProfileModalController { this.renderSavedProfiles(); this.refreshWalletPaneState(); this.refreshModerationSettingsUi(); + this.syncLinkPreviewSettingsUi(); const hasBlockHydrator = this.services.userBlocks && typeof this.services.userBlocks.ensureLoaded === "function"; @@ -9997,6 +11714,7 @@ export class ProfileModalController { this.refreshWalletPaneState(); this.populateHashtagPreferences(); this.handleActiveDmIdentityChanged(activePubkey); + void this.refreshDmRelayPreferences({ force: true }); postLoginPromise .then(() => { @@ -10006,6 +11724,7 @@ export class ProfileModalController { this.populateProfileRelays(); this.refreshWalletPaneState(); this.populateHashtagPreferences(); + void this.refreshDmRelayPreferences({ force: true }); }) .catch((error) => { userLogger.warn( @@ -10073,6 +11792,8 @@ export class ProfileModalController { this.clearHashtagInputs(); this.setHashtagStatus("", "muted"); this.handleActiveDmIdentityChanged(null); + this.populateDmRelayPreferences(); + this.setDmRelayPreferencesStatus(""); return true; } diff --git a/js/userBlocks.js b/js/userBlocks.js index 525cb385..59b07033 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -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" }); diff --git a/js/utils/linkPreviewSettings.js b/js/utils/linkPreviewSettings.js new file mode 100644 index 00000000..61dd6e3e --- /dev/null +++ b/js/utils/linkPreviewSettings.js @@ -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); +}; diff --git a/package.json b/package.json index f17b8aee..306969b4 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/reproduce_issue.js b/reproduce_issue.js deleted file mode 100644 index f50f17f5..00000000 --- a/reproduce_issue.js +++ /dev/null @@ -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)); diff --git a/scripts/agent/fuzz-dmDecryptor.mjs b/scripts/agent/fuzz-dmDecryptor.mjs index 6622e9fb..5fedb8d8 100644 --- a/scripts/agent/fuzz-dmDecryptor.mjs +++ b/scripts/agent/fuzz-dmDecryptor.mjs @@ -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); diff --git a/scripts/agent/fuzz-magnetUtils.mjs b/scripts/agent/fuzz-magnetUtils.mjs index 99e31f84..a6332b97 100644 --- a/scripts/agent/fuzz-magnetUtils.mjs +++ b/scripts/agent/fuzz-magnetUtils.mjs @@ -64,6 +64,7 @@ const wrapper = (input) => { }; } + // Deterministic execution: input fully defines execution path. normalizeAndAugmentMagnet(rawValue, options); }; diff --git a/scripts/agent/fuzz-nostrEventSchemas.mjs b/scripts/agent/fuzz-nostrEventSchemas.mjs index 74d6dd29..16a2bf02 100644 --- a/scripts/agent/fuzz-nostrEventSchemas.mjs +++ b/scripts/agent/fuzz-nostrEventSchemas.mjs @@ -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({}); + } } }; diff --git a/scripts/agent/interop-test.mjs b/scripts/agent/interop-test.mjs index b5cde18c..fcc2a5e7 100644 --- a/scripts/agent/interop-test.mjs +++ b/scripts/agent/interop-test.mjs @@ -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(); diff --git a/scripts/agent/load-test.mjs b/scripts/agent/load-test.mjs index abacb81c..f0d026c6 100644 --- a/scripts/agent/load-test.mjs +++ b/scripts/agent/load-test.mjs @@ -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); +}); diff --git a/scripts/agent/telemetry.mjs b/scripts/agent/telemetry.mjs new file mode 100644 index 00000000..e2084499 --- /dev/null +++ b/scripts/agent/telemetry.mjs @@ -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); +} diff --git a/scripts/agent/validate-events.mjs b/scripts/agent/validate-events.mjs new file mode 100644 index 00000000..edb38d1d --- /dev/null +++ b/scripts/agent/validate-events.mjs @@ -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); +}); diff --git a/scripts/check-design-tokens.mjs b/scripts/check-design-tokens.mjs index 9f846c90..64133f99 100755 --- a/scripts/check-design-tokens.mjs +++ b/scripts/check-design-tokens.mjs @@ -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; } diff --git a/scripts/run-targeted-tests.mjs b/scripts/run-targeted-tests.mjs new file mode 100644 index 00000000..9f059ff2 --- /dev/null +++ b/scripts/run-targeted-tests.mjs @@ -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"); diff --git a/stylelint.config.cjs b/stylelint.config.cjs index f1f51a2f..00195a4e 100644 --- a/stylelint.config.cjs +++ b/stylelint.config.cjs @@ -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'], diff --git a/tests/dm-db.test.mjs b/tests/dm-db.test.mjs new file mode 100644 index 00000000..bd726819 --- /dev/null +++ b/tests/dm-db.test.mjs @@ -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); +}); diff --git a/tests/dm-normalization.test.mjs b/tests/dm-normalization.test.mjs new file mode 100644 index 00000000..25a4bb06 --- /dev/null +++ b/tests/dm-normalization.test.mjs @@ -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"); +}); diff --git a/tests/nostr-delete-flow.test.mjs b/tests/nostr-delete-flow.test.mjs index c0848fe6..fda5a96c 100644 --- a/tests/nostr-delete-flow.test.mjs +++ b/tests/nostr-delete-flow.test.mjs @@ -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), diff --git a/tests/nostr/dm-direct-message-flow.test.mjs b/tests/nostr/dm-direct-message-flow.test.mjs new file mode 100644 index 00000000..40c99d09 --- /dev/null +++ b/tests/nostr/dm-direct-message-flow.test.mjs @@ -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(); + } +}); diff --git a/tests/nostr/sessionActor.test.mjs b/tests/nostr/sessionActor.test.mjs new file mode 100644 index 00000000..143e558c --- /dev/null +++ b/tests/nostr/sessionActor.test.mjs @@ -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); + }); +}); diff --git a/tests/services/r2Service.test.mjs b/tests/services/r2Service.test.mjs new file mode 100644 index 00000000..c92654af --- /dev/null +++ b/tests/services/r2Service.test.mjs @@ -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); + }); +}); diff --git a/tests/state/cache.test.mjs b/tests/state/cache.test.mjs new file mode 100644 index 00000000..c21a6684 --- /dev/null +++ b/tests/state/cache.test.mjs @@ -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); + }); +}); diff --git a/tests/test-helpers/setup-localstorage.mjs b/tests/test-helpers/setup-localstorage.mjs index cfda0f66..97bc95bb 100644 --- a/tests/test-helpers/setup-localstorage.mjs +++ b/tests/test-helpers/setup-localstorage.mjs @@ -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)); +} diff --git a/verification/mobile-view.png b/verification/mobile-view.png new file mode 100644 index 00000000..27e40683 Binary files /dev/null and b/verification/mobile-view.png differ diff --git a/verification/verify_fixes.js b/verification/verify_fixes.js new file mode 100644 index 00000000..81cd0ee0 --- /dev/null +++ b/verification/verify_fixes.js @@ -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(); +})(); diff --git a/verification/verify_fixes.mjs b/verification/verify_fixes.mjs new file mode 100644 index 00000000..847c7d9a --- /dev/null +++ b/verification/verify_fixes.mjs @@ -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(); +})();