Merge pull request #1513 from PR0M3TH3AN/unstable

Unstable
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-22 15:04:07 -05:00
commit 489295af40
80 files changed
+3626 -2111

No files matched your search

+43 -3
View File
@@ -70,13 +70,53 @@ jobs:
test-results
if-no-files-found: ignore
- name: Run unit tests
run: npm run test:unit
- name: Fail if visual regressions detected
if: steps.visual.outcome == 'failure'
run: exit 1
unit-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [20]
shard: [1, 2, 3]
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 unit tests (shard ${{ matrix.shard }}/3)
env:
UNIT_TEST_TIMEOUT_MS: 300000
run: npm run test:unit:shard${{ matrix.shard }}
dm-unit-tests:
runs-on: ubuntu-latest
strategy:
+1
View File
@@ -6,3 +6,4 @@ playwright-report/
test-results/
tests/visual/__snapshots__/
artifacts/
css/tailwind.generated.css
+2
View File
@@ -2,6 +2,8 @@
This guide tells AI agents how to keep bitvid aligned with the current product direction. Follow it whenever you touch code, content, or documentation inside this repository.
**Important:** Before starting any development or debugging, please review `KNOWN_ISSUES.md`. This file documents pre-existing test failures, environmental quirks, and architectural limitations that you should be aware of to avoid unnecessary investigation.
For any nostr related work, please review the nip documentation located in /docs/nips before begining work.
**Comment publishing guardrail:** Session actors are limited to passive telemetry (e.g., view counters). Always require the logged-in Nostr signer when sending video comments through [`js/nostr/commentEvents.js`](js/nostr/commentEvents.js). See the reminder in [`docs/nostr-event-schemas.md`](docs/nostr-event-schemas.md#event-catalogue) for context.
+1
View File
@@ -30,6 +30,7 @@ If we later introduce **dual-licensing** options (e.g., commercial licenses for
* Add tests where reasonable; keep UI accessible.
* Follow existing formatting/lint rules.
* Update docs and user-facing text when behavior changes.
* Do not commit `css/tailwind.generated.css`; it is generated at build time.
## Agent PR Conventions
+33
View File
@@ -0,0 +1,33 @@
# Known Issues
## Tests
### Unit Tests
- `npm run test:unit` consistently times out in the CI environment due to resource constraints. Targeted testing (`test:dm:unit`, `test:dm:integration`) is recommended.
### Visual Regression Tests (`test:visual`)
- **Moderation UI (`tests/visual/moderation.spec.ts`)**: The "Show anyway" button and related moderation badges fail to render in the test environment, causing `toBeVisible()` assertions to fail. The logic for creating the button appears correct (`allowOverride` is true), but the button element is not found in the DOM during the test execution. This might be due to a race condition in the test fixture's state initialization or an environment-specific rendering issue.
- **Sidebar Layout (`tests/visual/overlay-layers.spec.ts`)**: The `mobile sidebar shares desktop rail behavior` test fails. The expected margin for the app container does not match the computed margin in the test environment (diff of ~45px). Attempts to fix this by adjusting CSS margin calculations were incomplete and reverted to avoid instability.
- **Kitchen Sink (`tests/visual/kitchen-sink.spec.ts`)**: Minor pixel mismatch (width 1292 vs expected 1280) in snapshot comparison.
### E2E Tests (`test:e2e`)
- **Modals (`tests/e2e/ui-modals.spec.ts`)**:
- `application-form` and `profile-modal` fail with 404 errors for requested resources (likely related to how `http-server` serves relative paths or nested components in the test runner).
- Several modals (`upload-modal`, `edit-video-modal`, etc.) timeout waiting for close actions or visibility checks. This might be due to the `reducedMotion` application interacting poorly with visibility checks or genuine performance issues in the test runner.
- `login-modal` fails to find the `[data-nsec-error]` element (it expects it to be visible but it remains hidden).
- **Popover (`tests/e2e/popover.spec.ts`)**: Fails with `ReferenceError: getPanelWithTriggerMetrics is not defined`. This helper function seems to be missing or not imported correctly in the test file.
## CSS / Design System
- The global CSS (`css/tailwind.source.css`) was updated to fix a linter error by using `calc(theme("screens.md") - var(--breakpoint-edge-offset))` instead of raw pixels.
## Retrospective & Future Improvements
### Challenges
- **Generated Artifacts**: The build process modifies `css/tailwind.generated.css`, which creates noise in diffs and potential merge conflicts. It is recommended to ignore this file in version control or enforce a check that it matches the build output without committing it manually in every PR.
- **Test Environment Isolation**: Debugging visual test failures (like the Sidebar Layout and Moderation UI) was difficult without access to the live browser UI or artifacts. The reproduction script failed due to environment restrictions on launching browsers.
- **Test Flakiness**: E2E tests for modals are prone to timeouts, likely due to animation/transition handling or resource loading delays in the CI environment. Using `applyReducedMotion` consistently helps but doesn't solve all timing issues.
### Suggestions
- **Commit Hooks**: Implement a pre-commit hook that runs the linter and potentially the build to ensure generated files are in sync, or configure CI to fail if they are not.
- **Dockerized Testing**: Running tests in a consistent Docker container with Xvfb properly configured would help reproduce environment-specific failures locally.
- **Visual Testing improvements**: Ensure that test fixtures (like `moderation/fixtures/index.html`) correctly force UI updates after state injection to avoid race conditions.
+8 -3
View File
@@ -112,6 +112,7 @@ To run **bitvid** locally:
```bash
npm run build
```
(This generates `css/tailwind.generated.css`, which is gitignored and required for styling.)
4. Start a local server:
- Using Python:
@@ -387,7 +388,7 @@ placeholder at “—” and development builds log a warning—so mixed deploym
4. **Make Changes**:
- Ensure your code follows best practices and is well-documented.
5. **Test**:
- Run unit tests: `npm run test:unit`
- Run unit tests: `npm run test:unit` (or a shard like `npm run test:unit:shard1`)
- Validate the site functionality locally before submitting.
6. **Submit a Pull Request**:
- Explain your changes and reference any related issues.
@@ -405,8 +406,12 @@ placeholder at “—” and development builds log a warning—so mixed deploym
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.
Pair that with `npm run test:unit` for application logic changes, or a shard
(`npm run test:unit:shard1`, `test:unit:shard2`, `test:unit:shard3`) when you want to
split the suite. You can also set `UNIT_TEST_SHARD=1/3` or pass `--shard=1/3`
to `scripts/run-unit-tests.mjs` for custom shard splits, and use
`UNIT_TEST_TIMEOUT_MS=120000` to abort a single stalled test file without
blocking the entire run. `npm run test:visual` covers 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`.
+70
View File
@@ -0,0 +1,70 @@
{
"summary": {
"date": "2026-01-22T19:39:07.362Z",
"duration_sec": 14.367,
"clients": 50,
"events_published": 380,
"events_confirmed": 380,
"events_failed": 0
},
"metrics": {
"throughput": {
"avg_events_per_sec": 26.44950233173244
},
"latency_ms": {
"build": {
"avg": 0.09473684210526316,
"p95": 1
},
"sign": {
"avg": 6.257894736842105,
"p95": 10
},
"rtt": {
"avg": 30.563157894736843,
"p95": 50
}
},
"resources": {
"cpu_samples": [
{
"timestamp": 9.227,
"user": 1834418,
"system": 114421
},
{
"timestamp": 14.266,
"user": 3170422,
"system": 155074
}
],
"memory_samples": [
{
"timestamp": 9.227,
"rss": 94273536,
"heapUsed": 11273392
},
{
"timestamp": 14.266,
"rss": 94453760,
"heapUsed": 11443640
}
]
},
"errors": []
},
"hot_functions": [
{
"name": "buildEvent",
"avg_ms": 0.09473684210526316
},
{
"name": "signEvent",
"avg_ms": 6.257894736842105
}
],
"recommendations": [
"If signEvent latency > 5ms, consider offloading signing to Web Workers or using Wasm optimization.",
"If RTT is high but build/sign is low, network or relay processing is the bottleneck."
]
}
+1 -1
View File
@@ -46,7 +46,7 @@
<div id="nostrFormModalDescription" class="modal-body">
<div class="modal-embed-frame">
<iframe
src="./components/iframe_forms/iframe-application-form.html"
src="/components/iframe_forms/iframe-application-form.html"
class="h-full w-full rounded-lg border border-border/30 shadow-sm"
frameborder="0"
></iframe>
-1
View File
@@ -204,7 +204,6 @@
spellcheck="false"
autocomplete="off"
data-nsec-secret
required
></textarea>
<label class="inline-flex items-center gap-2 text-sm text-text">
<input type="checkbox" class="checkbox" data-nsec-remember />
+1 -9
View File
@@ -238,7 +238,7 @@
>
<img
id="profileModalAvatar"
src="assets/svg/default-profile.svg"
src="/assets/svg/default-profile.svg"
alt="Active profile avatar"
class="h-full w-full object-cover"
/>
@@ -1128,14 +1128,6 @@
>
Clear local cache
</button>
<button
id="profileHistoryRepublish"
type="button"
class="btn focus-ring inline-flex items-center gap-2"
data-size="sm"
>
Republish now
</button>
<button
id="profileHistoryRefresh"
type="button"
+6 -1
View File
@@ -63,7 +63,12 @@
/>
<span class="sidebar-text">Recently Added</span>
</a>
<a href="#view=explore" class="sidebar-nav-link" title="Explore">
<a
id="exploreLink"
href="#view=explore"
class="sidebar-nav-link hidden"
title="Explore"
>
<img
src="assets/svg/explore-icon.svg"
alt=""
-19
View File
@@ -304,25 +304,6 @@
</div>
</div>
<div class="video-modal__actions-group">
<button
id="copyMagnetBtn"
type="button"
class="video-modal__action-button focus-ring accent-action-button"
aria-label="Copy magnet"
>
<svg
class="video-modal__action-icon icon-image"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M17.374 20.235c2.444-2.981 6.626-8.157 6.626-8.157l-3.846-3.092s-2.857 3.523-6.571 8.097c-4.312 5.312-11.881-2.41-6.671-6.671 4.561-3.729 8.097-6.57 8.097-6.57l-3.092-3.842s-5.173 4.181-8.157 6.621c-2.662 2.175-3.76 4.749-3.76 7.24 0 5.254 4.867 10.139 10.121 10.139 2.487 0 5.064-1.095 7.253-3.765zm4.724-7.953l-1.699 2.111-1.74-1.397 1.701-2.114 1.738 1.4zm-10.386-10.385l1.4 1.738-2.113 1.701-1.397-1.74 2.11-1.699z"
/>
</svg>
<span class="video-modal__action-label">Copy magnet</span>
</button>
<div class="popover" data-modal-zap-wrapper>
<button
id="modalZapBtn"
-14
View File
@@ -1,14 +0,0 @@
:root{--surface-base:var(--color-surface);--surface-alt:var(--color-surface-alt);--surface-raised:var(--color-card);--surface-raised-private:var(--color-card-private);--surface-raised-critical:var(--color-card-critical);--surface-panel:var(--color-panel);--surface-panel-hover:var(--color-panel-hover);--surface-sunken:var(--color-surface-sunken);--surface-muted:var(--color-surface-muted);--surface-veil:var(--color-overlay-veil);--surface-inverse:var(--color-page);--surface-overlay:var(--color-overlay);--surface-overlay-muted:var(--color-overlay-muted);--surface-overlay-strong:var(--color-overlay-strong);--surface-overlay-darker:var(--color-overlay-darker);--surface-overlay-black:var(--color-overlay-black);--surface-overlay-banner-base:var(--color-page);--surface-overlay-banner:var(--surface-overlay-banner-base);--surface-overlay-banner-opaque:var(--surface-overlay-banner-base);--surface-overlay-banner-strong:color-mix(in srgb,var(--surface-overlay-banner-base) 82%,transparent);--surface-overlay-banner-bottom:color-mix(in srgb,var(--surface-overlay-banner-base) 50%,transparent);--surface-overlay-banner-channel-mid:color-mix(in srgb,var(--surface-overlay-banner-base) 24%,transparent);--surface-overlay-banner-channel-faint:color-mix(in srgb,var(--surface-overlay-banner-base) 10%,transparent);--surface-overlay-banner-soft:color-mix(in srgb,var(--surface-overlay-banner-base) 56%,transparent);--surface-overlay-banner-faint:color-mix(in srgb,var(--surface-overlay-banner-base) 18%,transparent);--surface-overlay-banner-transparent:transparent;--surface-overlay-banner-gradient-header:linear-gradient(to bottom,var(--surface-overlay-banner-opaque) 0%,var(--surface-overlay-banner-strong) 38%,var(--surface-overlay-banner-soft) 70%,var(--surface-overlay-banner-faint) 88%,var(--surface-overlay-banner-transparent) 100%);--surface-overlay-banner-gradient-channel:linear-gradient(to top,var(--surface-overlay-banner-bottom) 0%,var(--surface-overlay-banner-channel-mid) 28%,var(--surface-overlay-banner-channel-faint) 42%,var(--surface-overlay-banner-transparent) 50%);--surface-overlay-banner-gradient:var(
--surface-overlay-banner-gradient-header
);--surface-overlay-panel:var(--color-overlay-panel);--surface-overlay-panel-soft:var(--color-overlay-panel-soft);--surface-overlay-panel-tint:var(--color-overlay-panel-tint);--surface-overlay-panel-glass:var(--color-overlay-panel-glass);--surface-overlay-panel-glass-soft:var(--color-overlay-panel-glass-soft);--surface-modal-backdrop:var(--color-modal-backdrop);--surface-modal:var(--color-modal-surface);--blur-modal-backdrop:18px;--blur-modal-alert:14px;--blur-thumbnail-blur:18px;--modal-nav-backdrop-blur:6px;--app-header-centerline-offset:calc(var(--space-sm) + var(--space-2xl-plus)/2);--border-default:var(--color-border);--border-subtle:var(--color-border-subtle);--border-strong:var(--color-border-translucent-stronger);--border-overlay:var(--color-border-translucent);--border-overlay-strong:var(--color-border-translucent-strongest);--border-overlay-glow:var(--color-border-translucent-glow);--border-neutral-soft:var(--color-border-neutral-soft);--border-info-soft:var(--color-border-info-soft);--border-sidebar-strong:var(--color-border-sidebar-strong);--border-width-hairline:1px;--border-width-fine:0.5px;--text-primary:var(--color-text);--text-inverse:var(--color-text-inverse);--text-muted:var(--color-text-muted);--text-muted-strong:var(--color-muted-strong);--text-subtle:var(--color-subtle-text);--text-frosted-primary:var(--color-text-frosted);--text-frosted-strong:var(--color-text-frosted-strong);--text-frosted-soft:var(--color-text-frosted-soft);--status-info:var(--color-info);--status-info-strong:var(--color-info-strong);--status-info-surface:var(--color-info-glass);--status-info-border:var(--color-info-border);--status-info-on:var(--color-info-on);--status-success:var(--color-success);--status-success-strong:var(--color-success-strong);--status-success-surface:var(--color-success-surface);--status-success-border:var(--color-success-border);--status-success-on:var(--color-success-on);--status-warning:var(--color-warning);--status-warning-strong:var(--color-warning-strong);--status-warning-surface:var(--color-warning-surface);--status-warning-border:var(--color-warning-border);--status-warning-on:var(--color-warning-on);--status-danger:var(--color-critical);--status-danger-strong:var(--color-critical-strong);--status-danger-surface:var(--color-danger-surface);--status-danger-border:var(--color-danger-border);--status-danger-on:var(--color-danger-on);--status-private:var(--color-private);--status-private-strong:var(--color-private-strong);--status-private-surface:var(--color-private-surface);--status-private-border:var(--color-private-border);--status-private-on:var(--color-private-on);--status-neutral:var(--color-muted);--status-neutral-strong:var(--color-muted-strong);--status-neutral-surface:var(--color-neutral-surface);--status-neutral-border:var(--color-neutral-border);--status-neutral-on:var(--color-neutral-on);--message-bubble-background:var(--surface-sunken);--message-bubble-border:var(--border-subtle);--message-bubble-text:var(--text-primary);--message-bubble-meta:var(--text-muted);--message-bubble-outgoing-background:var(--surface-overlay-panel);--message-bubble-outgoing-border:var(--border-overlay);--unread-badge-background:var(--status-info-strong);--unread-badge-text:var(--text-inverse);--reply-highlight-background:var(--status-info-surface);--reply-highlight-border:var(--status-info-border);--reply-highlight-text:var(--status-info-on);--reply-highlight-icon:var(--status-info-on);--notification-banner-accent:var(--color-accent-strong);--notification-banner-accent-strong:var(--color-accent);--notification-banner-border:var(--surface-overlay-panel-tint);--notification-banner-background:var(--surface-overlay-panel);--notification-banner-text:var(--text-primary);--notification-banner-icon:var(--text-primary);--notification-banner-critical-accent:var(--status-danger-strong);--notification-banner-critical-accent-strong:var(--status-danger);--notification-banner-critical-border:var(--color-ring-critical-soft);--notification-banner-critical-background:var(--surface-raised-critical);--notification-banner-critical-text:var(--status-danger-on);--notification-banner-success-accent:var(--status-success-strong);--notification-banner-success-accent-strong:var(--status-success);--notification-banner-success-border:var(--status-success-border);--notification-banner-success-background:var(--status-success-surface);--notification-banner-success-text:var(--status-success-on);--notification-banner-info-accent:var(--status-info-strong);--notification-banner-info-accent-strong:var(--status-info);--notification-banner-info-border:var(--status-info-border);--notification-banner-info-background:var(--status-info-surface);--notification-banner-info-text:var(--status-info-on);--notification-banner-warning-accent:var(--status-warning-strong);--notification-banner-warning-accent-strong:var(--status-warning);--notification-banner-warning-border:var(--status-warning-border);--notification-banner-warning-background:var(--Line truncated
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-family-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--font-family-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{font-size:80%}@media (min-width:768px){html{font-size:100%}}@media (min-width:1024px){html{font-size:75%}}*,:after,:before{scrollbar-width:thin;scrollbar-color:var(--scrollbar-thumb) var(--scrollbar-track)}::-webkit-scrollbar{width:var(--scrollbar-width);height:var(--scrollbar-width)}::-webkit-scrollbar-track{background:var(--scrollbar-track)}::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);border-radius:var(--radius-sm)}::-webkit-scrollbar-thumb:hover{background:var(--scrollbar-thumb-hover)}body{--app-header-centerline:calc(env(safe-area-inset-top, 0) + var(--app-header-centerline-offset));font-family:var(--font-family-sans);background-color:var(--color-page);color:var(--color-text-strong);line-height:1.5;margin:0;padding:0;overflow-x:hidden}:root[data-theme-accent=override]{--color-accent:var(--color-accent-override);--color-accent-strong:var(--color-accent-strong-override);--color-accent-pressed:var(--color-accent-pressed-override)}#uiOverlay{position:fixed;inset:0;pointer-events:none;z-index:var(--z-overlay-popover)}#uiOverlay>*{pointer-events:auto}#editNip71TagsFieldset>legend,#nip71TagsFieldset>legend{margin-left:0;padding:0}#app{background-color:var(--color-page);color:inherit}body.modal-open,html.modal-open{overflow:hidden;height:100%}body.modal-open #app{overflow:hidden}body.modal-open #sidebar{pointer-events:none;opacity:0;visibility:hidden;z-index:var(--z-sidebar-overlay)}.ng-cloak,.ng-hide:not(.ng-hide-animate),.x-ng-cloak,[data-ng-cloak],[ng-cloak],[ng\:cloak],[x-ng-cloak]{display:none!important}ng\:form{display:block}.\!container{width:100%!important}.container{width:100%}@media (min-width:25rem){.\!container{max-width:25rem!important}.container{max-width:25rem}}@media (min-width:30rem){.\!container{max-width:30rem!important}.container{max-width:30rem}}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}#playerModal.ds-header{display:block}.ds-header{display:flex;align-items:center;justify-content:flex-start;padding-top:var(--space-sm);padding-bottom:var(--space-sm)}.app-shell__header{position:sticky;top:env(safe-area-inset-top,0);z-index:var(--z-app-header);padding-top:calc(var(--space-sm) + env(safe-area-inset-top, 0));padding-bottom:vaLine truncated
--motion-video-card-backdrop-translate
);--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong)}.\!card:focus-within,.\!card:hover{--video-card-lift-translate-y:var(--motion-video-card-hover-translate)!important;--video-card-backdrop-opacity:0.425!important;--video-card-backdrop-translate-y:var(
--motion-video-card-backdrop-translate
)!important;--video-card-backdrop-scale:1.12!important;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong)!important;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.card:focus-within,.card:hover{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card:focus-within,.\!card:hover{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card[data-state=critical],.card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.\!card[data-state=private],.card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card[data-state=disabled],.card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.video-card__title{transition:color var(--motion-duration-base) var(--motion-ease-in-out)}.video-card__title:focus-visible,.video-card__title:hover{color:var(--color-accent)}.form-control{outline:2px solid transparent;outline-offset:2px}.form-control:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.form-control{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.form-control::-moz-placeholder{color:var(--text-muted)}.form-control::placeholder{color:var(--text-muted)}.form-control:hover{border-color:var(--border-default)}.form-control:focus-visible{border-color:var(--color-accent-strong)}.form-control:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.form-control:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.\!input[data-state=locked],.form-control[data-state=locked],.input[data-state=locked],.select[data-state=locked],.textarea[data-state=locked],select.form-control[data-state=locked],textarea.form-control[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.form-control[data-state=locked]:focus-visible,.input[data-state=locked]:focus-visible,.select[data-state=locked]:focus-visible,.textarea[data-state=locked]:focus-visible,select.form-control[data-state=locked]:focus-visible,textarea.form-control[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--Line truncated
--player-modal-inline-padding,clamp(var(--space-md),4vw,var(--space-xl-plus))
);overflow:visible}#playerModal .watch-container.video-modal__similar{padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .video-modal__primary>.watch-container:first-child{padding-block-start:var(--player-modal-vertical-padding)}#playerModal .video-modal__primary>.watch-container:last-child,#playerModal .video-modal__secondary>.watch-container:last-child{padding-block-end:var(--player-modal-vertical-padding)}#playerModal .modal-nav .watch-container{max-width:none;margin-inline:0}#playerModal .modal-info-panel{display:flex;flex-direction:column;gap:var(--space-md-plus)}#playerModal h2#videoTitle{margin:0;font-size:clamp(var(--font-size-xl),calc(var(--font-size-modal-title-base) + .5vw),var(--font-size-xl-plus));font-weight:600;line-height:1.3;color:var(--color-text);min-width:0;overflow-wrap:break-word;word-break:break-word}@media (min-width:1024px){.video-modal__headline #videoTitle.video-modal__title{flex:1 1 0%;min-width:0;max-width:calc(100% - var(--space-3xl) - var(--space-md))}.video-modal__headline .video-modal__status{flex:0 0 auto;max-width:clamp(var(--space-xl),25%,var(--space-3xl));margin-left:auto}}#playerModal .modal-meta{font-size:var(--font-size-sm);line-height:1.4;color:var(--color-text-muted)}#playerModal .modal-view-count{font-weight:600;color:var(--color-text)}#playerModal .channel-card{align-items:center;gap:var(--space-md);padding:var(--space-md) var(--space-lg)}#playerModal .channel-card,#playerModal .player-modal__module{display:flex;border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-surface);box-shadow:var(--shadow-sm)}#playerModal .player-modal__module{flex-direction:column;gap:clamp(var(--space-2xs),1vw,var(--space-sm));width:100%;padding:clamp(var(--space-xs),1vw,var(--space-sm))}#playerModal .player-modal__module-title{margin:0;font-size:var(--font-size-lg);font-weight:600;line-height:1.3;color:var(--color-text)}#playerModal .player-modal__module-list{display:flex;flex-direction:column;gap:var(--space-sm);width:100%;margin:0;padding:0;list-style:none}#playerModal .video-modal__similar .player-modal__module{gap:clamp(var(--space-3xs),.6vw,var(--space-xs));padding:clamp(var(--space-2xs),.6vw,var(--space-xs));border-width:0;box-shadow:none;background-color:transparent}#playerModal .video-modal__similar .player-modal__module-title{padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .video-modal__similar .player-modal__module-list{gap:clamp(var(--space-3xs),.6vw,var(--space-xs));padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs));padding-bottom:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .player-modal__module-scroll{max-height:clamp(var(--player-modal-module-scroll-min-height),calc(100vh - var(--player-modal-vertical-padding)*2 - var(--space-2xl)),var(--player-modal-module-scroll-max-height));overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;scrollbar-gutter:stable;padding-inline-end:var(--space-xs)}#playerModal .video-modal__similar .player-modal__module-scroll{max-height:none;overflow:visible;padding-inline-end:0;scrollbar-gutter:auto}#playerModal .player-modal__module-scroll>li{scroll-margin-block:var(--space-xs)}@media (max-width:30rem){#playerModal .modal-nav .icon-button{width:var(--space-xl);height:var(--space-xl);min-width:var(--space-xl);min-height:var(--space-xl)}#playerModal .watch-container{padding-inline:var(--space-sm)}#playerModal .channel-card{gap:var(--space-sm);padding:var(--space-sm) var(--space-md)}}#playerModal .channel-card__avatar{flex-shrink:0;width:var(--avatar-size-lg);height:var(--avatar-size-lg);border-radius:var(--radius-full);overflow:hidden;background-color:var(--color-border-subtle)}#playerModal .channel-card__avatar img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}#playerModal .channel-card__name{margin:0;font-size:var(--font-size-lg);font-weight:600;color:var(--color-text)}#playerModal .channel-card__npub{margin:0;font-size:var(--font-size-sm);color:var(--color-text-muted)}#playerModal .description-panel{padding:var(--space-md-plus) var(--space-lg);border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-surface);box-shadow:var(--shadow-sm)}#playerModal .description-panel p{margin:0;font-size:var(--font-size-sm);line-height:1.6;color:var(--color-text)}.player-modal__content{position:relative;z-index:var(--z-modal-content);display:flex;flex:1 1 auto;width:100%;min-height:100%;flex-direction:column;overflow:visible;padding:0;margin:0;border:none;border-radius:0;background-color:var(--color-surface);box-shadow:none}@media (prefers-reduced-motion:reduce){.player-modal__content{transition-property:none}}#playerModal .modal-nav{position:sticky;top:0;z-index:var(--z-overlay-nav);--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) sLine truncated
--player-modal-primary-inline-padding-lg,clamp(var(--space-sm),2.75vw,var(--space-lg))
)}}.revert-modal__layout{display:grid;gap:1.5rem}@media (min-width:1024px){.revert-modal__layout{grid-template-columns:minmax(var(--revert-modal-sidebar-min),var(--revert-modal-sidebar-width)) minmax(0,1fr)}}#playerModal.video-modal__primary{display:block}.video-modal__primary{display:flex;flex-direction:column;gap:1.5rem;min-width:0;min-height:0}#playerModal.video-modal__secondary{display:block}.video-modal__secondary{display:flex;flex-direction:column;gap:1rem;min-width:0;min-height:0;width:100%;align-self:stretch}.video-modal__similar{display:none}@media (min-width:1024px){#playerModal.video-modal__similar{display:block}.video-modal__similar{display:flex;flex-direction:column;gap:1.5rem;align-self:stretch;flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module{height:100%;display:flex;flex:1 1 auto;min-height:0;flex-direction:column}#playerModal .video-modal__similar .player-modal__module-list{flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module-scroll{height:100%;max-height:none;min-height:0}}#playerModal .player-modal__module-item{width:100%}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))!important}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media,#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))!important}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal .video-modal__similar .player-modal__module-list{gap:clamp(var(--space-2xs),.85vw,var(--space-sm))}.player-modal__similar-card{outline:2px solid transparent;outline-offset:2px}.player-modal__similar-card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.player-modal__similar-card{display:block}.player-modal__similar-card{position:relative;displayLine truncated
--motion-video-card-backdrop-translate
);--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.watch-history-card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.watch-history-card[data-state=disabled],.watch-history-card[data-state=private]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.watch-history-card[data-alert=nsfw-owner]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--color-black)}.watch-history-card[data-alert=nsfw-owner]:hover{--tw-border-opacity:0.6;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card img[data-thumbnail-state=blurred],.watch-history-card img[data-visual-state=blurred]{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}.watch-history-card[data-owner-is-viewer=true][data-url-health-state=offline][data-stream-health-state=unhealthy]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max));flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}.watch-history-card[data-motion=enter]{opacity:0;animation:card-motion-enter .22s ease-out forwards}#playerModal.watch-history-card{display:block}.watch-history-card{display:flex;flex-wrap:wrap;align-items:stretch;gap:1.25rem;padding:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}@media (prefers-reduced-motion:reduce){.watch-history-card,.watch-history-card[data-motion=enter]{transition-property:none}.watch-history-card[data-motion=enter]{opacity:1;animation:none}}.watch-history-card:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.watch-history-card{gap:1.5rem;padding:1.25rem}}.watch-history-card--private{border-color:var(--color-info);--tw-border-opacity:0.6}#playerModal.watch-histoLine truncated
+120 -12
View File
@@ -1051,6 +1051,7 @@
}
.card {
min-width: 0;
--video-card-lift-translate-y: 0;
--video-card-backdrop-opacity: 0;
--video-card-backdrop-translate-y: var(--space-md-plus);
@@ -4593,23 +4594,26 @@
}
#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));
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(
var(--sidebar-width-expanded-base),
var(--sidebar-dropup-content-width)
margin-left: calc(
var(--sidebar-width-expanded) + var(--sidebar-content-gap)
);
width: min(
calc(100vw - var(--sidebar-width-expanded-base)),
calc(100vw - var(--sidebar-dropup-content-width))
width: calc(
100vw - var(--sidebar-width-expanded) - var(--sidebar-content-gap)
);
max-width: min(
calc(100vw - var(--sidebar-width-expanded-base)),
calc(100vw - var(--sidebar-dropup-content-width))
max-width: calc(
100vw - var(--sidebar-width-expanded) - var(--sidebar-content-gap)
);
}
@@ -5849,3 +5853,107 @@
max-block-size: none;
}
}
@layer components {
.marquee-host {
position: relative;
overflow: hidden;
white-space: nowrap;
width: 100%;
min-width: 0;
max-width: 100%;
}
.marquee-static {
@apply truncate;
opacity: 1;
transition: opacity 0.3s ease;
}
.marquee-anim {
position: absolute;
inset: 0;
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
mask-image: linear-gradient(
to right,
transparent,
black 4%,
black 96%,
transparent
);
-webkit-mask-image: linear-gradient(
to right,
transparent,
black 4%,
black 96%,
transparent
);
}
.marquee-track {
display: flex;
width: max-content;
animation: marquee-scroll linear infinite;
animation-duration: var(--marquee-duration, 10s);
animation-play-state: paused;
}
.marquee-item {
flex-shrink: 0;
padding-right: var(--space-xl); /* Gap between loop items */
}
@keyframes marquee-scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
/* Desktop Hover State */
@media (min-width: theme("screens.lg")) {
.card:hover .marquee-host.can-marquee .marquee-static {
opacity: 0;
}
.card:hover .marquee-host.can-marquee .marquee-anim {
opacity: 1;
}
.card:hover .marquee-host.can-marquee .marquee-track {
animation-play-state: running;
}
}
/* Mobile Active State */
@media (max-width: calc(theme("screens.lg") - var(--breakpoint-edge-offset))) {
.card[data-mobile-active="true"] {
--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);
@apply shadow-lg;
}
.card[data-mobile-active="true"] .video-card__title {
color: var(--color-accent);
}
.card[data-mobile-active="true"] .marquee-host.can-marquee .marquee-static {
opacity: 0;
}
.card[data-mobile-active="true"] .marquee-host.can-marquee .marquee-anim {
opacity: 1;
}
.card[data-mobile-active="true"] .marquee-host.can-marquee .marquee-track {
animation-play-state: running;
}
}
}
+15
View File
@@ -60,6 +60,7 @@
import moderationService from "/js/services/moderationService.js";
const STORAGE_KEY = "bitvid:moderation:fixture-overrides";
const READY_FLAG = "__moderationFixturesReady__";
// These fixtures mirror the upstream defaults in config/instance-config.js.
// Update the numbers alongside DEFAULT_BLUR_THRESHOLD, DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
// DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, and DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD when they change.
@@ -282,6 +283,7 @@
}
window.__MODERATION_FIXTURE_STORAGE_KEY__ = STORAGE_KEY;
window[READY_FLAG] = false;
function loadOverrides() {
try {
@@ -593,12 +595,24 @@
card.onModerationOverride = () => {
overrides = setOverride(overrides, video.id, { showAnyway: true });
refreshModeration();
try {
document.dispatchEvent(
new CustomEvent("video:moderation-override", {
detail: { video }
})
);
} catch {
/* ignore fixture event errors */
}
return true;
};
}
const root = card.getRoot();
root.dataset.testId = fixture.key;
if (fixture.enableOverride) {
root.dataset.moderationOverrideAvailable = "true";
}
return { card, root, refreshModeration };
}
@@ -688,6 +702,7 @@
}
renderFixtures();
window[READY_FLAG] = true;
</script>
</body>
</html>
+50 -53
View File
@@ -1,68 +1,65 @@
# NostrClient Overview
The `NostrClient` class in `js/nostr/client.js` is the central controller for all Nostr protocol interactions in the application. It handles connection management, event publishing, state synchronization, and caching.
`js/nostr/client.js` exports the `NostrClient` class, which serves as the central controller for all Nostr protocol interactions in the application. It acts as a bridge between the UI components, the local state (React/Redux/Listeners), and the decentralized Nostr network.
## Key Responsibilities
## Responsibilities
1. **Connection Management**: Maintains a pool of connections to Nostr relays (`SimplePool`).
2. **State Management**:
* `allEvents`: A raw map of all fetched events, useful for history.
* `activeMap`: A "materialized view" containing only the latest version of each video (deduplicated by `videoRootId` or `d` tag).
* `tombstones`: Tracks deleted events to prevent them from reappearing if an old relay broadcasts them.
3. **Event Publishing**: Handles signing and broadcasting events (Kind 30078 video posts, Kind 1 metadata, etc.).
4. **Buffering**: Implements a buffering strategy for incoming events to optimize UI rendering performance.
5. **Caching**: Uses `EventsCacheStore` (IndexedDB + localStorage) to persist state across sessions for instant load times.
6. **Signer Abstraction**: Manages different signing methods (NIP-07 extension, NIP-46 remote signer, local keys).
1. **Connection Management**:
* Maintains a persistent connection pool (`SimplePool` from `nostr-tools`) to multiple relays.
* Handles relay discovery, connection retries, and error reporting.
* Supports "Read" vs. "Write" relay splitting.
## Core Concepts
2. **Event Publishing (Write Path)**:
* Constructs spec-compliant Nostr events (Kind 1, 30078, etc.).
* Handles **signing**:
* NIP-07 (Browser Extensions like Alby/nos2x).
* NIP-46 (Remote Signers / Bunker).
* Local Private Keys (nsec).
* Broadcasts events to write relays and verifies acceptance.
* Implements **NIP-94** (File Metadata) and **NIP-71** (Video Categorization) mirroring.
### Eventual Consistency & `activeMap`
3. **Data Fetching & Subscription (Read Path)**:
* **Live Feeds**: Uses persistent subscriptions (`sub`) to stream new events.
* **Buffering**: Implements a debounced buffer strategy (`subscribeVideos`) to handle relay floods without freezing the UI.
* **Caching**: Stores events in `allEvents` (memory) and persists to IndexedDB/LocalStorage for instant load ("Stale-While-Revalidate").
Nostr is a decentralized protocol where events can arrive out of order or be duplicated across relays. The `activeMap` is critical for presenting a consistent view to the user.
4. **State Management**:
* **`allEvents`**: Map of all raw video events fetched, keyed by Event ID.
* **`activeMap`**: A "materialized view" of the latest video state. It deduplicates multiple versions of the same video (edits) based on `videoRootId` or `d` tag, ensuring the UI only shows the most recent valid version.
* **`tombstones`**: Tracks deleted events to prevent "zombie" events from reappearing during partial syncs.
* **Logic**: When a new event arrives, `activeMap` only updates if the new event has a newer `created_at` timestamp than the existing entry for that `videoRootId` (or `d` tag).
* **Deletions**: If an event is deleted (Kind 5 or explicit `deleted: true` flag), a "tombstone" is recorded. Future events with a timestamp older than the tombstone are ignored.
## Key Architectures
### Buffering Strategy (`subscribeVideos`)
### 1. The "Active Map" & Deduplication
Nostr is immutable; "editing" a video means publishing a NEW event with the same identifier (`d` tag).
The client listens to *all* versions but only keeps the latest one in `activeMap`.
To prevent the UI from freezing when thousands of events arrive during the initial connection:
```javascript
// Simplified Logic
if (incomingEvent.createdAt > currentActiveEvent.createdAt) {
activeMap.set(videoRootId, incomingEvent);
notifyUI();
}
```
1. Incoming events are pushed into an `eventBuffer`.
2. A debounced flush function processes the buffer every ~75ms.
3. Events are processed in batches: validation, deduplication, and state updates happen in bulk.
4. The UI is notified only once per batch.
### 2. Buffering Strategy
When the app loads, relays might send 1000+ events in a second. Rendering React components 1000 times is too slow.
`subscribeVideos` pushes events to a buffer and flushes them every 75ms.
### Caching (`EventsCacheStore`)
```javascript
sub.on("event", (evt) => {
buffer.push(evt);
debounce(flushBuffer, 75);
});
```
The client attempts to restore state immediately on load ("Stale-While-Revalidate"):
### 3. Tombstoning (Deletes)
Since relays are eventually consistent, a "delete" event (Kind 5) might arrive *before* the event it deletes, or *after*.
The client tracks `tombstones` (map of ID -> deletedAt timestamp). Any incoming event older than its tombstone is immediately discarded.
1. **Restore**: `restoreLocalData` reads from IndexedDB (or localStorage fallback).
2. **Display**: The UI renders cached content immediately.
3. **Fetch**: The client connects to relays and fetches new events, updating the UI as they arrive.
4. **Persist**: The `EventsCacheStore` periodically saves the current state to disk, using fingerprints to avoid unnecessary writes.
## Main Entry Points
## Common Workflows
### 1. Publishing a Video
`publishVideo(videoPayload, pubkey)`
1. Constructs the video metadata (Kind 30078).
2. Signs the event using the active signer.
3. Publishes to configured relays.
4. **Side Effects**:
* If a hosted URL is present, publishes a NIP-94 mirror event (Kind 1063).
* If NIP-71 categorization tags are present, publishes a wrapper event (Kind 22).
### 2. Loading the Feed
`subscribeVideos(onVideo, options)`
1. Checks cache for the latest timestamp.
2. Subscribes to relays with `since` filter (if cached) or full history.
3. Buffers incoming events.
4. Updates `activeMap` and calls `onVideo` for valid updates.
## When to Modify
* **New Event Kinds**: If adding support for a new NIP or event type, add handlers here.
* **Performance Tuning**: Adjust buffering timeouts or cache policies in `EventsCacheStore` if UI responsiveness issues arise.
* **Signer Logic**: If modifying how keys are handled (e.g., adding a new wallet adapter), update `signerHelpers.js` or `nip46Client.js`, but ensure `NostrClient` integrates it correctly.
* `init()`: Bootstraps the client, restores local cache, connects to relays.
* `subscribeVideos(callback)`: Main feed subscription.
* `publishVideo(payload)`: Complex flow to upload -> sign -> publish -> mirror.
* `connectRemoteSigner(uri)`: Handshake protocol for NIP-46.
@@ -0,0 +1,11 @@
{
"target": "nostrEventSchemas",
"description": "Fixed crash in buildWatchHistoryEvent when pointerTags is null",
"input": {
"action": "buildWatchHistoryEvent",
"args": [{
"pointerTags": null
}]
},
"error": "TypeError: pointerTags.forEach is not a function"
}
+1 -1
View File
@@ -379,7 +379,7 @@
<!-- Mobile Search Container -->
<div
id="mobileSearchContainer"
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"
class="fixed top-4 left-4 right-4 z-50 hidden max-w-sm mx-auto origin-top transition-all duration-200 md:hidden"
>
<form
id="mobileSearchForm"
+101 -364
View File
@@ -150,6 +150,7 @@ import {
urlHealthConstants,
} from "./state/cache.js";
import ApplicationBootstrap from "./ui/applicationBootstrap.js";
import EngagementController from "./ui/engagementController.js";
import SimilarContentController from "./ui/similarContentController.js";
import VideoModalCommentController from "./ui/videoModalCommentController.js";
import TorrentStatusController from "./ui/torrentStatusController.js";
@@ -290,6 +291,21 @@ class Application {
getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey),
});
this.engagementController = new EngagementController({
services: {
nostrClient,
},
ui: {
showError: (msg) => this.showError(msg),
showSuccess: (msg) => this.showSuccess(msg),
showStatus: (msg, opts) => this.showStatus(msg, opts),
},
state: {
getCurrentVideo: () => this.currentVideo,
getCurrentVideoPointer: () => this.currentVideoPointer,
},
});
this.initializeModerationActionController();
this.initializeSimilarContentController();
}
@@ -445,6 +461,29 @@ class Application {
this.videoModal &&
typeof this.videoModal.addEventListener === "function"
) {
this.videoModal.addEventListener("video:share-nostr", () => {
this.showStatus(
"Sharing on Nostr is not implemented yet. Coming soon!",
);
});
this.videoModal.addEventListener("video:copy-cdn", (event) => {
const video = event?.detail?.video || this.currentVideo;
const url = video?.url || "";
if (!url) {
this.showError("No CDN link available to copy.");
return;
}
navigator.clipboard
.writeText(url)
.then(() => this.showSuccess("CDN link copied to clipboard!"))
.catch(() => this.showError("Failed to copy CDN link."));
});
this.videoModal.addEventListener("video:copy-magnet", () => {
this.handleCopyMagnet();
});
this.videoModal.addEventListener("playback:switch-source", (event) => {
const detail = event?.detail || {};
const { source } = detail;
@@ -497,12 +536,13 @@ class Application {
if (source === "torrent" && hasActivePeers === false) {
userLogger.warn(
"[app] Unable to switch to torrent playback: no active peers.",
"[app] Switching to torrent playback despite 0 active peers detected.",
);
this.showError(
"Torrent playback is unavailable right now because there are no active peers.",
this.showStatus(
"Warning: No peers detected. Playback may fail or stall.",
{ autoHideMs: 5000 }
);
return;
// Proceed anyway
}
if (source === "url" && cdnUnavailable) {
@@ -4044,6 +4084,11 @@ class Application {
if (forYouLink) {
forYouLink.classList.remove("hidden");
}
const exploreLink = this.resolveExploreLink();
if (exploreLink) {
exploreLink.classList.remove("hidden");
}
}
applyLoggedOutUiState() {
@@ -4083,6 +4128,11 @@ class Application {
if (forYouLink) {
forYouLink.classList.add("hidden");
}
const exploreLink = this.resolveExploreLink();
if (exploreLink) {
exploreLink.classList.add("hidden");
}
}
syncAuthUiState() {
@@ -4154,10 +4204,29 @@ class Application {
return null;
}
resolveExploreLink() {
if (
this.exploreLink instanceof HTMLElement &&
this.exploreLink.isConnected
) {
return this.exploreLink;
}
const linkCandidate = document.getElementById("exploreLink");
if (linkCandidate instanceof HTMLElement) {
this.exploreLink = linkCandidate;
return this.exploreLink;
}
this.exploreLink = null;
return null;
}
hydrateSidebarNavigation() {
const chromeElements = this.refreshChromeElements();
this.resolveSubscriptionsLink();
this.resolveForYouLink();
this.resolveExploreLink();
if (this.appChromeController) {
if (typeof this.appChromeController.setElements === "function") {
@@ -5093,11 +5162,6 @@ class Application {
}
}
setCopyMagnetState(enabled) {
if (this.videoModal) {
this.videoModal.setCopyEnabled(enabled);
}
}
setShareButtonState(enabled) {
if (this.videoModal) {
@@ -6595,7 +6659,19 @@ class Application {
}
decorateVideoModeration(video, feedContext = {}) {
return this.moderationDecorator.decorateVideo(video, feedContext);
const decorated = this.moderationDecorator.decorateVideo(video, feedContext);
if (
video &&
video.pubkey &&
this.isAuthorBlocked(video.pubkey) &&
decorated &&
decorated.moderation
) {
decorated.moderation.viewerMuted = true;
decorated.moderation.hidden = true;
decorated.moderation.hideReason = "viewer-block";
}
return decorated;
}
initializeModerationActionController() {
@@ -7030,37 +7106,6 @@ class Application {
}
}
derivePointerFromDataset(dataset = {}, context = "") {
const type =
typeof dataset.pointerType === "string" ? dataset.pointerType.trim() : "";
const value =
typeof dataset.pointerValue === "string" ? dataset.pointerValue.trim() : "";
const relay =
typeof dataset.pointerRelay === "string" ? dataset.pointerRelay.trim() : "";
if (type && value) {
return relay ? [type, value, relay] : [type, value];
}
if (
context === "modal" &&
Array.isArray(this.currentVideoPointer) &&
this.currentVideoPointer.length >= 2
) {
return this.currentVideoPointer;
}
if (
context === "modal" &&
Array.isArray(this.currentVideo?.pointer) &&
this.currentVideo.pointer.length >= 2
) {
return this.currentVideo.pointer;
}
return null;
}
async handleMoreMenuAction(action, dataset = {}) {
if (!this.moreMenuController) {
return;
@@ -7070,332 +7115,20 @@ class Application {
}
async handleRepostAction(dataset = {}) {
const context = typeof dataset.context === "string" ? dataset.context : "";
const explicitEventId =
typeof dataset.eventId === "string" && dataset.eventId.trim()
? dataset.eventId.trim()
: "";
const fallbackEventId =
context === "modal" && this.currentVideo?.id ? this.currentVideo.id : "";
const targetEventId = explicitEventId || fallbackEventId;
if (!targetEventId) {
this.showError("No event is available to repost.");
return;
}
const pointer = this.derivePointerFromDataset(dataset, context);
let author = typeof dataset.author === "string" ? dataset.author.trim() : "";
if (!author && context === "modal" && this.currentVideo?.pubkey) {
author = this.currentVideo.pubkey;
}
const rawKindValue = (() => {
if (typeof dataset.kind === "string" && dataset.kind.trim()) {
const parsed = Number.parseInt(dataset.kind.trim(), 10);
return Number.isFinite(parsed) ? parsed : null;
}
if (Number.isFinite(dataset.kind)) {
return Number(dataset.kind);
}
if (Number.isFinite(this.currentVideo?.kind)) {
return Number(this.currentVideo.kind);
}
return null;
})();
const options = {
pointer,
pointerType: dataset.pointerType,
pointerValue: dataset.pointerValue,
pointerRelay: dataset.pointerRelay,
authorPubkey: author,
};
if (Number.isFinite(rawKindValue)) {
options.kind = Math.floor(rawKindValue);
}
try {
const result = await nostrClient.repostEvent(targetEventId, options);
if (!result?.ok) {
const code = result?.error || "repost-failed";
switch (code) {
case "invalid-event-id":
this.showError("No event is available to repost.");
break;
case "missing-actor":
this.showError(
"Cannot sign the repost right now. Please refresh and try again.",
);
break;
case "pool-unavailable":
this.showError("Cannot reach relays right now. Please try again later.");
break;
case "publish-rejected":
this.showError("No relay accepted the repost attempt.");
break;
case "signing-failed":
this.showError("Failed to sign the repost. Please try again.");
break;
default:
this.showError("Failed to repost the video. Please try again later.");
break;
}
return;
}
const acceptedCount = Array.isArray(result.summary?.accepted)
? result.summary.accepted.length
: 0;
const relayCount =
acceptedCount > 0
? acceptedCount
: Array.isArray(result.relays)
? result.relays.length
: acceptedCount;
const fragments = [];
if (relayCount > 0) {
fragments.push(
`Reposted to ${relayCount} relay${relayCount === 1 ? "" : "s"}.`,
);
} else {
fragments.push("Reposted.");
}
if (result.sessionActor) {
fragments.push("Boost as session user.");
}
this.showSuccess(fragments.join(" ").trim());
} catch (error) {
devLogger.warn("[app] Repost action failed:", error);
this.showError("Failed to repost the video. Please try again later.");
if (this.engagementController) {
return this.engagementController.handleRepostAction(dataset);
}
}
async handleMirrorAction(dataset = {}) {
const context = typeof dataset.context === "string" ? dataset.context : "";
const explicitEventId =
typeof dataset.eventId === "string" && dataset.eventId.trim()
? dataset.eventId.trim()
: "";
const fallbackEventId =
context === "modal" && this.currentVideo?.id ? this.currentVideo.id : "";
const targetEventId = explicitEventId || fallbackEventId;
if (!targetEventId) {
this.showError("No event is available to mirror.");
return;
}
const explicitUrl =
typeof dataset.url === "string" && dataset.url.trim() ? dataset.url.trim() : "";
const fallbackUrl =
context === "modal" && typeof this.currentVideo?.url === "string"
? this.currentVideo.url.trim()
: "";
const targetUrl = explicitUrl || fallbackUrl;
if (!targetUrl) {
this.showError("This video does not expose a hosted URL to mirror.");
return;
}
const rawMagnet =
typeof dataset.magnet === "string" && dataset.magnet.trim()
? dataset.magnet.trim()
: "";
const fallbackMagnet =
context === "modal"
? (this.currentVideo?.magnet || this.currentVideo?.originalMagnet || "")
: "";
const magnet = rawMagnet || fallbackMagnet;
const thumbnail =
typeof dataset.thumbnail === "string" && dataset.thumbnail.trim()
? dataset.thumbnail.trim()
: context === "modal" && typeof this.currentVideo?.thumbnail === "string"
? this.currentVideo.thumbnail.trim()
: "";
const description =
typeof dataset.description === "string" && dataset.description.trim()
? dataset.description.trim()
: context === "modal" && typeof this.currentVideo?.description === "string"
? this.currentVideo.description.trim()
: "";
const title =
typeof dataset.title === "string" && dataset.title.trim()
? dataset.title.trim()
: context === "modal" && typeof this.currentVideo?.title === "string"
? this.currentVideo.title.trim()
: "";
const datasetPrivate =
dataset.isPrivate === "true" || dataset.isPrivate === true ? true : false;
const fallbackPrivate = context === "modal" && this.currentVideo?.isPrivate === true;
const isPrivate = datasetPrivate || fallbackPrivate;
if (isPrivate) {
this.showError("Mirroring is unavailable for private videos.");
return;
}
const options = {
url: targetUrl,
magnet,
thumbnail,
description,
title,
isPrivate,
};
try {
const result = await nostrClient.mirrorVideoEvent(targetEventId, options);
if (!result?.ok) {
const code = result?.error || "mirror-failed";
switch (code) {
case "invalid-event-id":
this.showError("No event is available to mirror.");
break;
case "missing-url":
this.showError("This video does not expose a hosted URL to mirror.");
break;
case "missing-actor":
this.showError(
"Cannot sign the mirror right now. Please refresh and try again.",
);
break;
case "pool-unavailable":
this.showError("Cannot reach relays right now. Please try again later.");
break;
case "publish-rejected":
this.showError("No relay accepted the mirror attempt.");
break;
case "signing-failed":
this.showError("Failed to sign the mirror. Please try again.");
break;
default:
this.showError("Failed to mirror the video. Please try again later.");
break;
}
return;
}
const acceptedCount = Array.isArray(result.summary?.accepted)
? result.summary.accepted.length
: 0;
const relayCount =
acceptedCount > 0
? acceptedCount
: Array.isArray(result.relays)
? result.relays.length
: acceptedCount;
const fragments = [];
if (relayCount > 0) {
fragments.push(
`Mirrored to ${relayCount} relay${relayCount === 1 ? "" : "s"}.`,
);
} else {
fragments.push("Mirrored.");
}
if (result.sessionActor) {
fragments.push("Boost as session user.");
}
this.showSuccess(fragments.join(" ").trim());
} catch (error) {
devLogger.warn("[app] Mirror action failed:", error);
this.showError("Failed to mirror the video. Please try again later.");
if (this.engagementController) {
return this.engagementController.handleMirrorAction(dataset);
}
}
async handleEnsurePresenceAction(dataset = {}) {
const context = typeof dataset.context === "string" ? dataset.context : "";
const explicitEventId =
typeof dataset.eventId === "string" && dataset.eventId.trim()
? dataset.eventId.trim()
: "";
const fallbackEventId =
context === "modal" && this.currentVideo?.id ? this.currentVideo.id : "";
const targetEventId = explicitEventId || fallbackEventId;
if (!targetEventId) {
this.showError("No event is available to rebroadcast.");
return;
}
const explicitPubkey =
typeof dataset.pubkey === "string" && dataset.pubkey.trim()
? dataset.pubkey.trim()
: "";
const datasetAuthor =
typeof dataset.author === "string" && dataset.author.trim()
? dataset.author.trim()
: "";
const fallbackPubkey =
context === "modal" && typeof this.currentVideo?.pubkey === "string"
? this.currentVideo.pubkey
: datasetAuthor;
const targetPubkey = explicitPubkey || fallbackPubkey || "";
try {
const result = await nostrClient.rebroadcastEvent(targetEventId, {
pubkey: targetPubkey,
});
if (result?.throttled) {
const remainingMs = Math.max(0, Number(result?.cooldown?.remainingMs) || 0);
const remainingSeconds = Math.ceil(remainingMs / 1000);
const message =
remainingSeconds > 0
? `Rebroadcast is cooling down. Try again in ${remainingSeconds}s.`
: "Rebroadcast is cooling down. Try again soon.";
this.showStatus(message);
if (typeof window !== "undefined" && typeof window.setTimeout === "function") {
window.setTimeout(() => {
this.showStatus("");
}, 5000);
}
return;
}
if (!result?.ok) {
const code = result?.error || "rebroadcast-failed";
switch (code) {
case "event-not-found":
this.showError("Original event payload is unavailable. Reload and try again.");
break;
case "publish-rejected":
this.showError("No relay accepted the rebroadcast attempt.");
break;
case "pool-unavailable":
this.showError("Cannot reach relays right now. Please try again later.");
break;
default:
this.showError("Failed to rebroadcast. Please try again later.");
break;
}
return;
}
if (result?.alreadyPresent) {
this.showSuccess("Relays already have this revision.");
return;
}
this.showSuccess("Rebroadcast requested across relays.");
} catch (error) {
devLogger.warn("[app] Rebroadcast action failed:", error);
this.showError("Failed to rebroadcast. Please try again later.");
if (this.engagementController) {
return this.engagementController.handleEnsurePresenceAction(dataset);
}
}
@@ -8458,7 +8191,7 @@ class Application {
this.currentVideo.torrentSupported = !!magnetForPlayback;
}
this.currentMagnetUri = magnetForPlayback || null;
this.setCopyMagnetState(!!magnetForPlayback);
// this.setCopyMagnetState(!!magnetForPlayback); // Removed
const unsubscribers = [];
const subscribe = (eventName, handler) => {
@@ -8756,8 +8489,8 @@ class Application {
this.currentMagnetUri = sanitizedMagnet || null;
this.setCopyMagnetState(!!sanitizedMagnet);
this.setShareButtonState(true);
// this.setCopyMagnetState(!!sanitizedMagnet); // Removed
// this.setShareButtonState(true); // Moved to after showModalWithPoster
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
const pushUrl =
@@ -8780,6 +8513,8 @@ class Application {
await this.showModalWithPoster(this.currentVideo);
this.setShareButtonState(true);
this.commentController?.load(this.currentVideo);
const playbackOptions = {
@@ -9130,8 +8865,8 @@ class Application {
this.currentMagnetUri = sanitizedMagnet || null;
this.setCopyMagnetState(!!sanitizedMagnet);
this.setShareButtonState(false);
// this.setCopyMagnetState(!!sanitizedMagnet);
// this.setShareButtonState(false);
if (this.videoModal) {
this.videoModal.updateMetadata({
@@ -9149,6 +8884,8 @@ class Application {
await this.showModalWithPoster(this.currentVideo, hasTrigger ? { trigger } : {});
this.setShareButtonState(false);
this.commentController?.load(null);
const shareUrl = this.buildShareUrlFromEventId(this.currentVideo.id);
+9
View File
@@ -64,6 +64,7 @@ import {
openStaticModal
} from "./ui/components/staticModalAccessibility.js";
import { setModalState as setGlobalModalState } from "./state/appState.js";
import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
const getApp = () => getApplication();
@@ -3703,6 +3704,14 @@ export async function initChannelProfileView() {
return;
}
const infoTrigger = document.getElementById("channelInfoTrigger");
if (infoTrigger) {
attachFeedInfoPopover(
infoTrigger,
"Videos published by this creator."
);
}
currentChannelHex = null;
currentChannelNpub = null;
currentChannelLightningAddress = "";
+1 -2
View File
@@ -106,11 +106,10 @@ function sanitizeTrustSeedList(candidate) {
const DEFAULT_WSS_TRACKERS = Object.freeze([
"wss://tracker.openwebtorrent.com",
"wss://tracker.ghostchu-services.top:443/announce",
"wss://tracker.files.fm:7073/announce",
"wss://tracker.dler.org:443/announce",
"wss://tracker.btorrent.xyz",
"wss://tracker.novage.com.ua:443/announce",
"wss://tracker.webtorrent.dev",
]);
const SANITIZED_DEFAULT_BLUR_THRESHOLD = coerceNonNegativeInteger(
+9
View File
@@ -1,5 +1,6 @@
import { getApplication } from "./applicationContext.js";
import { devLogger } from "./utils/logger.js";
import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
const VIDEO_CARD_SELECTOR = "[data-component=\"video-card\"]";
const EMPTY_STATE_SELECTOR = "[data-for-you-empty-state]";
@@ -23,6 +24,14 @@ class ForYouView {
this.container.addEventListener("click", this.handleContainerClick);
this.observeContainer();
this.updateEmptyState();
const infoTrigger = this.document.getElementById("feedInfoTrigger");
if (infoTrigger) {
attachFeedInfoPopover(
infoTrigger,
"Most Recent Video - Disinterests Tags - Watch History + Interests Tags"
);
}
}
observeContainer() {
+1 -1
View File
@@ -212,7 +212,7 @@ function queueProbe(magnet, cacheKey, priority = 0) {
}
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
const PROBE_TIMEOUT_MS = 8000;
const PROBE_TIMEOUT_MS = 20000;
const PROBE_CONCURRENCY = 96;
const PROBE_POLL_COUNT = 3;
const PRIORITY_BASELINE = 1_000_000;
+8
View File
@@ -19,6 +19,7 @@ import {
} from "./ui/moderationUiHelpers.js";
import { buildModerationBadgeText } from "./ui/moderationCopy.js";
import { formatShortNpub } from "./utils/formatters.js";
import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
export const WATCH_HISTORY_EMPTY_COPY =
"Your watch history is empty. Watch some videos to populate this list.";
@@ -2475,6 +2476,13 @@ export function createWatchHistoryRenderer(config = {}) {
export const watchHistoryRenderer = createWatchHistoryRenderer();
export async function initHistoryView() {
const infoTrigger = document.getElementById("historyInfoTrigger");
if (infoTrigger) {
attachFeedInfoPopover(
infoTrigger,
"Chronological list of videos you have watched."
);
}
await watchHistoryRenderer.init();
}
+29
View File
@@ -55,6 +55,35 @@ export function normalizeAndAugmentMagnet(
didChange = true;
}
// Filter out known broken trackers to prevent console errors
const BROKEN_TRACKERS = [
"wss://tracker.dler.org/announce",
"wss://tracker.dler.org:443/announce",
"wss://tracker.ghostchu-services.top/announce",
"wss://tracker.ghostchu-services.top:443/announce",
];
// We need to filter 'tr' params
// Since we don't have a remove function in shared, we rebuild params if needed
const currentTrackers = params
.filter((p) => p.key === "tr")
.map((p) => p.decoded || p.value);
const hasBroken = currentTrackers.some((t) => BROKEN_TRACKERS.includes(t));
if (hasBroken) {
const validParams = params.filter((p) => {
if (p.key !== "tr") return true;
const val = p.decoded || p.value;
return !BROKEN_TRACKERS.includes(val);
});
// Replace params content
params.length = 0;
params.push(...validParams);
didChange = true;
}
const torrentHint = typeof torrentUrl === "string" && torrentUrl.trim()
? torrentUrl
: typeof xs === "string"
+36
View File
@@ -3734,6 +3734,11 @@ export class NostrClient {
}
}
/**
* Ensures the `SimplePool` (from nostr-tools) is initialized and ready.
*
* @returns {Promise<import("nostr-tools").SimplePool>} The active pool instance.
*/
async ensurePool() {
if (this.pool) {
return this.pool;
@@ -4440,6 +4445,22 @@ export class NostrClient {
return newPromise;
}
/**
* Sends a Direct Message (DM) to a target user.
*
* Supports:
* - **Legacy (NIP-04)**: Simple base64 ciphertext (deprecated but widely supported).
* - **Wrapped (NIP-17)**: Sealed rumors inside gift wraps (private, metadata-leaking free).
* - **Attachments**: Handling file attachments via NIP-17 or fallback text.
*
* @param {string} targetNpub - The recipient's npub.
* @param {string} message - The text content to send.
* @param {string|null} [actorPubkeyOverride] - Optional sender pubkey override.
* @param {object} [options] - Configuration options.
* @param {boolean} [options.useNip17=false] - Whether to use the new NIP-17 protocol.
* @param {Array} [options.attachments] - File attachments (NIP-17 only).
* @returns {Promise<{ok: boolean, error?: string, details?: any}>} Result of the operation.
*/
async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null, options = {}) {
let resolvedOptions = options;
let resolvedActorOverride = actorPubkeyOverride;
@@ -6275,6 +6296,14 @@ export class NostrClient {
};
}
/**
* Schedules a persistence task to save the current state (events, tombstones) to local storage.
*
* @param {string} [reason="unspecified"] - Debug label for why persistence was triggered.
* @param {object} [options]
* @param {boolean} [options.immediate=false] - If true, bypasses the debounce timer and saves immediately.
* @returns {Promise<boolean>} A promise resolving to whether persistence succeeded.
*/
saveLocalData(reason = "unspecified", options = {}) {
const { immediate = false } = options;
@@ -6307,6 +6336,13 @@ export class NostrClient {
return this.cachePersistInFlight;
}
/**
* Executes the actual persistence logic, writing to IndexedDB (preferred) or localStorage.
*
* @param {string} reason - Debug label.
* @returns {Promise<boolean>} True if successful.
* @private
*/
async persistLocalData(reason = "unspecified") {
const payload = this.buildCachePayload();
const startedAt = Date.now();
+19 -7
View File
@@ -1101,13 +1101,25 @@ class WatchHistoryManager {
if (attempt > WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS) {
return;
}
const exponentialDelay = WATCH_HISTORY_REPUBLISH_BASE_DELAY_MS * Math.pow(2, attempt);
const cappedDelay = Math.min(exponentialDelay, WATCH_HISTORY_REPUBLISH_MAX_DELAY_MS);
const jitter = Math.random() * cappedDelay * WATCH_HISTORY_REPUBLISH_JITTER;
const delay = Math.max(
WATCH_HISTORY_REPUBLISH_BASE_DELAY_MS,
Math.floor(cappedDelay + jitter),
);
let delay = 0;
if (attempt === 0) {
delay = 10;
} else {
const exponentialDelay =
WATCH_HISTORY_REPUBLISH_BASE_DELAY_MS * Math.pow(2, attempt);
const cappedDelay = Math.min(
exponentialDelay,
WATCH_HISTORY_REPUBLISH_MAX_DELAY_MS,
);
const jitter =
Math.random() * cappedDelay * WATCH_HISTORY_REPUBLISH_JITTER;
delay = Math.max(
WATCH_HISTORY_REPUBLISH_BASE_DELAY_MS,
Math.floor(cappedDelay + jitter),
);
}
if (onSchedule) {
try {
onSchedule({ taskId: key, attempt: attempt + 1, delay });
+7 -5
View File
@@ -1912,11 +1912,13 @@ export function buildWatchHistoryEvent(params) {
if (monthTagName && identifierValue) {
tags.push([monthTagName, identifierValue]);
}
pointerTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
if (Array.isArray(pointerTags)) {
pointerTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
appendSchemaTags(tags, schema);
+32
View File
@@ -7,6 +7,7 @@ import { escapeHTML } from "./utils/domUtils.js";
import { formatShortNpub } from "./utils/formatters.js";
import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js";
import { devLogger } from "./utils/logger.js";
import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
function getApp() {
return getApplication();
@@ -18,11 +19,34 @@ function renderProfileCards(profiles, container) {
if (!container) return;
container.innerHTML = "";
// Reset the "Show More" button and container limit state
const showMoreContainer = document.getElementById("searchChannelShowMore");
if (showMoreContainer) {
showMoreContainer.classList.add("hidden");
}
container.classList.remove("channel-grid-limit");
if (!profiles || profiles.length === 0) {
container.innerHTML = `<p class="text-sm text-muted col-span-full">No matching channels found.</p>`;
return;
}
// If we have more than 5 results (the smallest limit), we might need to show the button
// depending on screen size, but for simplicity we just enable it if > 5.
if (profiles.length > 5) {
container.classList.add("channel-grid-limit");
if (showMoreContainer) {
showMoreContainer.classList.remove("hidden");
const btn = showMoreContainer.querySelector("button");
if (btn) {
btn.onclick = () => {
container.classList.remove("channel-grid-limit");
showMoreContainer.classList.add("hidden");
};
}
}
}
const fragment = document.createDocumentFragment();
for (const profile of profiles) {
@@ -86,6 +110,14 @@ export async function initSearchView() {
titleEl.textContent = query ? `Search Results for "${query}"` : "Search Results";
}
const infoTrigger = document.getElementById("searchInfoTrigger");
if (infoTrigger) {
attachFeedInfoPopover(
infoTrigger,
"Results matching your search query."
);
}
const channelList = document.getElementById("searchChannelList");
const videoList = document.getElementById("searchVideoList");
@@ -206,6 +206,12 @@ export default class ModerationActionController {
this.clearHideBypass(target);
this.decorateVideo(target);
if (target !== video) {
this.clearViewerOverride(video);
this.clearHideBypass(video);
this.decorateVideo(video);
}
const current = this.getCurrentVideo();
if (current && current.id === video.id) {
this.clearViewerOverride(current);
+1 -1
View File
@@ -5,7 +5,7 @@ import {
ingestLocalViewEvent as defaultIngestLocalViewEvent,
} from "../viewCounter.js";
const DEFAULT_VIEW_THRESHOLD_SECONDS = 12;
const DEFAULT_VIEW_THRESHOLD_SECONDS = 5;
function normalizeString(value) {
return typeof value === "string" ? value.trim() : "";
+3 -3
View File
@@ -1,9 +1,9 @@
import { WSS_TRACKERS as DEFAULT_WSS_TRACKERS } from "./constants.js";
export const TRACKER_TIMEOUT_MS = 3000;
export const TRACKER_PER_MAGNET = 3;
export const TRACKER_TIMEOUT_MS = 6000;
export const TRACKER_PER_MAGNET = 10;
export const HEALTH_TTL_MS = 2 * 60 * 1000;
export const CONCURRENCY = 3;
export const CONCURRENCY = 6;
export const TRACKER_ERROR_COOLDOWN_MS = 60 * 1000;
export function resolveTrackerList({ magnetTrackers } = {}) {
+1
View File
@@ -657,6 +657,7 @@ export default class ApplicationBootstrap {
app.subscriptionsLink = null;
app.forYouLink = null;
app.exploreLink = null;
app.notificationPortal = doc?.getElementById("notificationPortal") || null;
app.errorContainer = doc?.getElementById("errorContainer") || null;
+44
View File
@@ -0,0 +1,44 @@
import createPopover from "../overlay/popoverEngine.js";
/**
* Attaches an informational popover to a trigger element.
*
* @param {HTMLElement} triggerElement - The element that triggers the popover.
* @param {string} text - The text content to display in the popover.
* @param {object} [options] - Optional configuration.
* @param {string} [options.placement="bottom-start"] - Popover placement.
* @returns {object|null} - The popover instance or null if failed.
*/
export function attachFeedInfoPopover(triggerElement, text, options = {}) {
if (!triggerElement || typeof text !== "string") {
return null;
}
const render = ({ document: doc, close }) => {
const panel = doc.createElement("div");
panel.className = "popover-panel p-4 max-w-xs text-sm text-text-strong bg-surface-elevated border border-surface-line rounded-lg shadow-lg z-50";
panel.textContent = text;
// Ensure the panel can receive focus if needed, but primarily it's informational.
// Making it a "tooltip" or "dialog" role might be appropriate depending on interaction.
// Since it's a click-toggle info box, standard popover behavior is fine.
// Add a simple close behavior if clicked outside (handled by popoverEngine),
// but maybe we want to close if clicked inside too?
// Usually info popovers stay open until clicked away.
return panel;
};
const popover = createPopover(triggerElement, render, {
placement: options.placement || "bottom-start",
...options,
});
triggerElement.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
popover.toggle();
});
return popover;
}
+134 -10
View File
@@ -386,6 +386,8 @@ export class VideoCard {
this.root = root;
VideoCard.observeViewport(this.root);
this.root.dataset.component = "video-card";
this.syncCardState();
this.syncMotionState();
@@ -434,6 +436,7 @@ export class VideoCard {
"p-md",
"bv-stack",
"bv-stack--tight",
"min-w-0",
];
if (isCompact) {
contentClassNames.push("flex-1");
@@ -449,17 +452,17 @@ export class VideoCard {
isCompact ? "text-base" : "text-lg",
isCompact ? "font-semibold" : "font-bold",
"text-text",
"line-clamp-2",
"cursor-pointer",
];
if (isCompact) {
titleClassNames.push("leading-snug");
}
const title = this.createElement("h3", {
classNames: titleClassNames,
textContent: this.video.title
});
const title = this.buildMarqueeStructure(
"h3",
titleClassNames,
this.video.title
);
title.dataset.videoId = this.video.id;
this.titleEl = title;
@@ -645,7 +648,7 @@ export class VideoCard {
applyIdentityToAuthorElements() {
const nameLabel = this.identity?.name || "Unknown";
if (this.authorNameEl) {
this.authorNameEl.textContent = nameLabel || "Unknown";
this.updateMarqueeText(this.authorNameEl, nameLabel || "Unknown");
}
if (this.authorPicEl) {
@@ -918,16 +921,17 @@ export class VideoCard {
const authorMeta = this.createElement("div", { classNames: ["min-w-0"] });
const authorName = this.createElement("p", {
classNames: [
const authorName = this.buildMarqueeStructure(
"p",
[
isCompact ? "text-xs" : "text-sm",
"text-muted",
"author-name",
"cursor-pointer",
"video-card__author-name",
],
textContent: "Loading name...",
});
"Loading name..."
);
if (this.video.pubkey) {
authorName.dataset.pubkey = this.video.pubkey;
}
@@ -2945,6 +2949,126 @@ export class VideoCard {
return false;
}
buildMarqueeStructure(tagName, classNames, textContent, attrs = {}) {
const host = this.createElement(tagName, {
classNames: [...classNames, "marquee-host"],
attrs,
});
// Use span instead of div to ensure valid HTML when nested in h3/p
const staticEl = this.createElement("span", {
classNames: ["marquee-static", "block"],
textContent,
});
const animEl = this.createElement("span", {
classNames: ["marquee-anim"],
attrs: { "aria-hidden": "true" },
});
const track = this.createElement("span", { classNames: ["marquee-track"] });
const item1 = this.createElement("span", {
classNames: ["marquee-item"],
textContent,
});
const item2 = this.createElement("span", {
classNames: ["marquee-item"],
textContent,
});
track.appendChild(item1);
track.appendChild(item2);
animEl.appendChild(track);
host.appendChild(staticEl);
host.appendChild(animEl);
host._marqueeRefs = { staticEl, item1, item2, track };
VideoCard.observeMarquee(host);
return host;
}
updateMarqueeText(host, text) {
if (!host) return;
if (!host._marqueeRefs) {
host.textContent = text;
return;
}
const { staticEl, item1, item2 } = host._marqueeRefs;
if (staticEl) staticEl.textContent = text;
if (item1) item1.textContent = text;
if (item2) item2.textContent = text;
// Trigger check immediately if possible, or wait for observer
VideoCard.checkMarqueeOverflow(host);
}
// Moved checkMarqueeOverflow to static to be shared by observer
static checkMarqueeOverflow(host) {
if (!host || !host._marqueeRefs) return;
const { staticEl, track, item1 } = host._marqueeRefs;
// Use item1 width (full content width) vs host width
const contentWidth = item1 ? item1.offsetWidth : staticEl.scrollWidth;
const availableWidth = host.clientWidth;
const isOverflowing = contentWidth > availableWidth;
if (isOverflowing) {
host.classList.add("can-marquee");
// Speed: 50 pixels per second
// Distance is contentWidth (one item width including padding)
const distance = contentWidth;
const duration = distance / 50;
const safeDuration = Math.max(duration, 2);
track.style.setProperty("--marquee-duration", `${safeDuration}s`);
} else {
host.classList.remove("can-marquee");
}
}
static observeMarquee(element) {
if (!VideoCard.marqueeResizeObserver) {
VideoCard.marqueeResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
VideoCard.checkMarqueeOverflow(entry.target);
}
});
}
VideoCard.marqueeResizeObserver.observe(element);
}
static observeViewport(element) {
if (!VideoCard.viewportObserver) {
VideoCard.viewportObserver = new IntersectionObserver(
(entries) => {
// Prevent active state on desktop to avoid conflicting with hover
const isDesktop =
typeof window !== "undefined" &&
window.matchMedia("(min-width: 1024px)").matches;
for (const entry of entries) {
if (isDesktop) {
if (entry.target.hasAttribute("data-mobile-active")) {
entry.target.removeAttribute("data-mobile-active");
}
continue;
}
if (entry.isIntersecting) {
entry.target.setAttribute("data-mobile-active", "true");
} else {
entry.target.removeAttribute("data-mobile-active");
}
}
},
{ rootMargin: "-35% 0px -35% 0px", threshold: 0 }
);
}
VideoCard.viewportObserver.observe(element);
}
createElement(tagName, { classNames = [], attrs = {}, textContent } = {}) {
const el = this.document.createElement(tagName);
classNames
+160 -3
View File
@@ -1,6 +1,9 @@
import { createModalAccessibility } from "./modalAccessibility.js";
import createPopover from "../overlay/popoverEngine.js";
import { createVideoMoreMenuPanel } from "./videoMenuRenderers.js";
import {
createVideoMoreMenuPanel,
createVideoShareMenuPanel,
} from "./videoMenuRenderers.js";
import { attachAmbientBackground } from "../ambientBackground.js";
import { applyDesignSystemAttributes } from "../../designSystem.js";
import { devLogger } from "../../utils/logger.js";
@@ -661,6 +664,7 @@ export class VideoModal {
this.modalZapDialogOpen = false;
this.setupModalZapPopover();
this.setupModalMorePopover();
this.setupModalSharePopover();
this.refreshActiveVideoModeration({ video: this.activeVideo });
@@ -2220,7 +2224,8 @@ export class VideoModal {
if (this.copyMagnetBtn) {
this.copyMagnetBtn.addEventListener("click", this.handleCopyRequest);
}
if (this.shareBtn) {
if (this.shareBtn && this.shareBtn.dataset.modalMenuHandler !== "true") {
this.shareBtn.dataset.modalMenuHandler = "true";
this.shareBtn.addEventListener("click", this.handleShareRequest);
}
@@ -2523,6 +2528,149 @@ export class VideoModal {
this.modalMorePopover = popover;
}
setupModalSharePopover() {
if (!this.shareBtn) {
this.modalSharePopover = null;
return;
}
const documentRef =
this.shareBtn.ownerDocument ||
this.document ||
(typeof document !== "undefined" ? document : null);
const render = ({ document: doc, close }) => {
const panel = this.buildModalShareMenuPanel({ document: doc, close });
return panel;
};
const popover = createPopover(this.shareBtn, render, {
document: documentRef,
placement: "bottom-end",
restoreFocusOnClose: true,
});
if (!popover) {
this.modalSharePopover = null;
return;
}
const originalOpen = popover.open?.bind(popover);
if (originalOpen) {
popover.open = async (...args) => {
this.refreshModalShareMenuPanel();
return originalOpen(...args);
};
}
const originalDestroy = popover.destroy?.bind(popover);
if (originalDestroy) {
popover.destroy = (...args) => {
originalDestroy(...args);
if (this.modalSharePopover === popover) {
this.modalSharePopover = null;
}
};
}
this.shareBtn.dataset.shareMenuToggleBound = "true";
this.modalSharePopover = popover;
}
buildModalShareMenuPanel({ document: doc, close }) {
const isLoggedIn =
this.commentComposerState.reason !== "login-required" &&
!this.commentComposerState.disabled;
const hasMagnet = Boolean(
(typeof this.activeVideo?.magnet === "string" &&
this.activeVideo.magnet.trim()) ||
this.activeVideo?.torrentSupported
);
const hasCdn = Boolean(
typeof this.activeVideo?.url === "string" && this.activeVideo.url.trim()
);
const panel = createVideoShareMenuPanel({
document: doc,
video: this.activeVideo,
isLoggedIn,
hasMagnet,
hasCdn,
context: "modal",
});
if (!panel) {
return null;
}
const buttons = panel.querySelectorAll("button[data-action]");
buttons.forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const dataset = {};
Object.entries(button.dataset || {}).forEach(([key, value]) => {
dataset[key] = value;
});
if (!dataset.context) {
dataset.context = "modal";
}
const action = dataset.action || "";
// Map menu actions to events
if (action === "share") {
this.dispatch("video:share", { video: this.activeVideo });
} else if (action === "share-nostr") {
this.dispatch("video:share-nostr", { video: this.activeVideo });
} else if (action === "copy-magnet") {
this.dispatch("video:copy-magnet", { video: this.activeVideo });
} else if (action === "copy-cdn") {
this.dispatch("video:copy-cdn", { video: this.activeVideo });
}
if (typeof close === "function") {
close({ reason: "action" });
}
});
});
return panel;
}
refreshModalShareMenuPanel() {
if (!this.modalSharePopover) {
return;
}
const existingPanel = this.modalSharePopover.getPanel?.();
const documentRef =
existingPanel?.ownerDocument ||
this.shareBtn?.ownerDocument ||
this.document ||
(typeof document !== "undefined" ? document : null);
if (!documentRef) {
return;
}
const nextPanel = this.buildModalShareMenuPanel({
document: documentRef,
close: (options) => this.modalSharePopover?.close(options),
});
if (!nextPanel) {
return;
}
if (existingPanel && existingPanel.parentNode) {
existingPanel.parentNode.replaceChild(nextPanel, existingPanel);
}
}
buildModalMoreMenuPanel({ document: doc, close }) {
const panel = createVideoMoreMenuPanel({
document: doc,
@@ -2675,10 +2823,19 @@ export class VideoModal {
handleShareRequest(event) {
event?.preventDefault?.();
event?.stopPropagation?.();
if (this.shareBtn?.disabled) {
return;
}
this.dispatch("video:share", { video: this.activeVideo });
if (this.modalSharePopover?.toggle) {
this.modalSharePopover.toggle();
return;
}
if (this.modalSharePopover?.isOpen?.()) {
this.modalSharePopover.close?.();
} else {
this.modalSharePopover?.open?.();
}
}
handleCreatorNavigation(event) {
+60
View File
@@ -309,6 +309,66 @@ export function createVideoMoreMenuPanel({
return panel;
}
export function createVideoShareMenuPanel({
document: documentRef = null,
video = null,
isLoggedIn = false,
hasMagnet = false,
hasCdn = false,
designSystem = null,
} = {}) {
const doc = resolveDocument(documentRef);
if (!doc) {
return null;
}
normalizeDesignSystemContext(designSystem);
const metadata = resolveVideoMetadata(video);
const { panel, list } = ensureMenuContainer(doc, ["w-48", "p-0"]);
panel.dataset.menu = "video-share";
appendMenuAction(doc, list, {
text: "Copy URL",
action: "share",
dataset: { eventId: metadata.id },
});
const nostrBtn = appendMenuAction(doc, list, {
text: "Share on Nostr",
action: "share-nostr",
dataset: { eventId: metadata.id },
});
if (!isLoggedIn) {
nostrBtn.disabled = true;
nostrBtn.classList.add("opacity-50", "cursor-not-allowed");
nostrBtn.title = "Log in to share on Nostr";
}
const magnetBtn = appendMenuAction(doc, list, {
text: "Copy Magnet",
action: "copy-magnet",
dataset: { eventId: metadata.id },
});
if (!hasMagnet) {
magnetBtn.disabled = true;
magnetBtn.classList.add("opacity-50", "cursor-not-allowed");
magnetBtn.title = "No magnet link available";
}
const cdnBtn = appendMenuAction(doc, list, {
text: "Copy CDN Link",
action: "copy-cdn",
dataset: { eventId: metadata.id },
});
if (!hasCdn) {
cdnBtn.disabled = true;
cdnBtn.classList.add("opacity-50", "cursor-not-allowed");
cdnBtn.title = "No CDN link available";
}
return panel;
}
export function createChannelProfileMenuPanel({
document: documentRef = null,
context = "channel-profile",
+32 -2
View File
@@ -4,6 +4,7 @@ import { MessageThread } from "./MessageThread.js";
import { NotificationCenter } from "./NotificationCenter.js";
import { DMPrivacySettings } from "./DMPrivacySettings.js";
import { aggregateZapTotals } from "./zapHelpers.js";
import { createZapInterface } from "./ZapInterface.js";
function createElement(doc, tag, className, text) {
const element = doc.createElement(tag);
@@ -233,6 +234,32 @@ export class AppShell {
zapConfig,
}),
);
const handleZapTrigger = () => {
const existingInterface = main.querySelector(".dm-zap-interface");
if (existingInterface) {
existingInterface.remove();
return;
}
const zapInterface = createZapInterface(doc, {
contact: activeConversation,
zapReceipts: activeZapReceipts,
onSendZap,
zapConfig
});
// Position it relative to the composer or just absolute in main
// The extracted createZapInterface uses 'absolute bottom-16 right-4' which positions it nicely
// relative to the container if the container is relative.
// main usually has relative positioning or we can append to root.
// AppShell root has absolute positioning context maybe?
// If main is the container, we append there.
main.appendChild(zapInterface);
};
const conversationZapTotalSats = totalsByConversation.get(activeConversation.id) || 0;
const profileZapTotalSats = totalsByProfile.get(activeConversation.pubkey) || 0;
main.appendChild(
Composer({
document: doc,
@@ -242,8 +269,11 @@ export class AppShell {
zapRecipient: activeConversation,
zapConfig,
onSend: onSendMessage,
// Zap button moved to MessageThread, so onSendZap here might be redundant if triggered from thread
// But Composer still handles text message sending.
onZap: handleZapTrigger,
zapStats: {
conversationTotal: conversationZapTotalSats,
profileTotal: profileZapTotalSats
}
}),
);
main.appendChild(
+63 -1
View File
@@ -9,6 +9,8 @@ function createElement(doc, tag, className, text) {
return element;
}
import { formatZapAmount } from "./zapHelpers.js";
export function Composer({
document: doc,
placeholder = "Write a message…",
@@ -18,6 +20,8 @@ export function Composer({
zapRecipient = null,
zapConfig = null,
onSend,
onZap,
zapStats = null,
} = {}) {
if (!doc) {
throw new Error("Composer requires a document reference.");
@@ -105,7 +109,65 @@ export function Composer({
button.setAttribute("aria-label", "Send message");
actions.appendChild(tools);
actions.appendChild(button);
// Zap Button
// We use dm-composer__send as a base for layout/size compatibility, but tweak styles.
// Using accent-action-button to apply accent color on hover, and px-2 for icon squareness.
const zapBtn = createElement(doc, "button", "dm-composer__send accent-action-button px-2");
zapBtn.type = "button";
zapBtn.setAttribute("aria-label", "Zap");
zapBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`;
if (typeof onZap === "function") {
zapBtn.addEventListener("click", (e) => {
e.preventDefault();
onZap();
});
const buttonGroup = createElement(doc, "div", "flex items-center gap-2");
// Zap Stats Pill
if (zapStats && typeof zapStats === "object") {
// Reuse class from MessageThread for consistent styling
const zapSummary = createElement(doc, "div", "dm-message-thread__zap-summary");
// Zaps Label
const labelSpan = createElement(doc, "span", "dm-message-thread__zap-label", "Zaps");
zapSummary.appendChild(labelSpan);
// Conversation Total
// We need formatZapAmount. Since we can't import dynamically easily here (without changing module type or build),
// we assume formatZapAmount is available or we pass formatted strings.
// BUT, looking at file structure, Composer is a module. I should import it at the top.
// I will add the import in a separate block.
// Assuming formatZapAmount is imported.
const conversationTotal = createElement(
doc,
"span",
"dm-message-thread__zap-total",
formatZapAmount(zapStats.conversationTotal || 0)
);
zapSummary.appendChild(conversationTotal);
// Profile Total
const profileTotal = createElement(
doc,
"span",
"dm-message-thread__zap-profile",
`Profile: ${formatZapAmount(zapStats.profileTotal || 0)}`
);
zapSummary.appendChild(profileTotal);
buttonGroup.appendChild(zapSummary);
}
buttonGroup.appendChild(zapBtn);
buttonGroup.appendChild(button);
actions.appendChild(buttonGroup);
} else {
actions.appendChild(button);
}
const status = createElement(doc, "div", "dm-composer__status");
if (state === "error") {
-146
View File
@@ -1,9 +1,5 @@
import { DayDivider } from "./DayDivider.js";
import { MessageBubble } from "./MessageBubble.js";
import { ZapReceiptList } from "./ZapReceiptList.js";
import { formatZapAmount } from "./zapHelpers.js";
import { createAndPublishZapRequest, resolveZapRecipient } from "../../payments/zapRequests.js";
import { userLogger, devLogger } from "../../utils/logger.js";
function createElement(doc, tag, className, text) {
const element = doc.createElement(tag);
@@ -79,43 +75,6 @@ export function MessageThread({
privacyBadge.title = privacyHint;
header.appendChild(privacyBadge);
const zapSummary = createElement(doc, "div", "dm-message-thread__zap-summary");
zapSummary.appendChild(
createElement(doc, "span", "dm-message-thread__zap-label", "Zaps"),
);
zapSummary.appendChild(
createElement(
doc,
"span",
"dm-message-thread__zap-total",
formatZapAmount(conversationZapTotalSats),
),
);
zapSummary.appendChild(
createElement(
doc,
"span",
"dm-message-thread__zap-profile",
`Profile: ${formatZapAmount(profileZapTotalSats)}`,
),
);
header.appendChild(zapSummary);
// Zap Button
const zapBtn = createElement(doc, "button", "btn-ghost btn-icon");
zapBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`;
zapBtn.ariaLabel = "Zap";
zapBtn.addEventListener("click", () => {
const existingInterface = root.querySelector(".dm-zap-interface");
if (existingInterface) {
existingInterface.remove();
return;
}
const zapInterface = createZapInterface(doc, { contact, zapReceipts, onSendZap, zapConfig });
root.appendChild(zapInterface);
});
header.appendChild(zapBtn);
if (typeof onMarkRead === "function") {
const actions = createElement(doc, "div", "ml-auto flex items-center gap-2");
const markReadButton = createElement(doc, "button", "btn-ghost", "Mark read");
@@ -188,108 +147,3 @@ export function MessageThread({
root.appendChild(timeline);
return root;
}
function createZapInterface(doc, { contact, zapReceipts, onSendZap, zapConfig }) {
const container = createElement(doc, "div", "dm-zap-interface card p-4 absolute bottom-16 right-4 z-20 w-80 shadow-xl border border-border bg-surface");
const closeBtn = createElement(doc, "button", "absolute top-2 right-2 btn-ghost btn-xs", "✕");
closeBtn.addEventListener("click", () => container.remove());
container.appendChild(closeBtn);
const title = createElement(doc, "h4", "text-sm font-bold mb-2", "Send Zap");
container.appendChild(title);
// Zap Form
const form = createElement(doc, "form", "space-y-2 mb-4");
const amountInput = createElement(doc, "input", "input input-sm w-full");
amountInput.type = "number";
amountInput.placeholder = "Amount (sats)";
amountInput.min = "1";
const noteInput = createElement(doc, "input", "input input-sm w-full");
noteInput.type = "text";
noteInput.placeholder = "Comment (optional)";
const sendBtn = createElement(doc, "button", "btn btn-sm btn-primary w-full", "Zap ⚡");
const statusDiv = createElement(doc, "div", "text-xs text-center mt-1");
sendBtn.addEventListener("click", async (e) => {
e.preventDefault();
const amount = parseInt(amountInput.value);
const comment = noteInput.value;
if (!amount || amount <= 0) {
statusDiv.textContent = "Invalid amount";
statusDiv.className = "text-error text-xs text-center mt-1";
return;
}
statusDiv.textContent = "Sending...";
statusDiv.className = "text-muted text-xs text-center mt-1";
sendBtn.disabled = true;
try {
if (typeof onSendZap === "function") {
// Use provided callback if available (e.g. from controller)
if (!contact.lightningAddress) {
throw new Error("No lightning address for this contact");
}
const resolver = zapConfig?.resolveRecipient || resolveZapRecipient;
const resolved = await resolver(contact.lightningAddress, { fetcher: zapConfig?.fetcher });
if (!resolved) throw new Error("Could not resolve lightning address");
const payload = {
amountSats: amount,
comment,
recipient: contact,
resolved
};
await onSendZap(payload);
statusDiv.textContent = "Zap sent!";
statusDiv.className = "text-success text-xs text-center mt-1";
amountInput.value = "";
noteInput.value = "";
} else if (zapConfig?.signer) {
await createAndPublishZapRequest({
address: contact.lightningAddress,
recipientPubkey: contact.pubkey,
relays: contact.relayHints || zapConfig?.relays,
amountSats: amount,
comment,
signer: zapConfig.signer,
pool: zapConfig.pool,
fetcher: zapConfig.fetcher,
});
statusDiv.textContent = "Zap sent!";
statusDiv.className = "text-success text-xs text-center mt-1";
amountInput.value = "";
noteInput.value = "";
} else {
throw new Error("No signer available");
}
} catch (err) {
console.error(err);
statusDiv.textContent = "Failed to send zap";
statusDiv.className = "text-error text-xs text-center mt-1";
} finally {
sendBtn.disabled = false;
}
});
form.appendChild(amountInput);
form.appendChild(noteInput);
form.appendChild(sendBtn);
form.appendChild(statusDiv);
container.appendChild(form);
// Receipts List
container.appendChild(
ZapReceiptList({
document: doc,
receipts: zapReceipts,
emptyLabel: "No recent zaps.",
}),
);
return container;
}
+118
View File
@@ -0,0 +1,118 @@
import { ZapReceiptList } from "./ZapReceiptList.js";
import { createAndPublishZapRequest, resolveZapRecipient } from "../../payments/zapRequests.js";
function createElement(doc, tag, className, text) {
const element = doc.createElement(tag);
if (className) {
element.className = className;
}
if (typeof text === "string") {
element.textContent = text;
}
return element;
}
export function createZapInterface(doc, { contact, zapReceipts, onSendZap, zapConfig }) {
const container = createElement(doc, "div", "dm-zap-interface card p-4 absolute bottom-16 right-4 z-20 w-80 shadow-xl border border-border bg-surface");
const closeBtn = createElement(doc, "button", "absolute top-2 right-2 btn-ghost btn-xs", "✕");
closeBtn.addEventListener("click", () => container.remove());
container.appendChild(closeBtn);
const title = createElement(doc, "h4", "text-sm font-bold mb-2", "Send Zap");
container.appendChild(title);
// Zap Form
const form = createElement(doc, "form", "space-y-2 mb-4");
const amountInput = createElement(doc, "input", "input input-sm w-full");
amountInput.type = "number";
amountInput.placeholder = "Amount (sats)";
amountInput.min = "1";
const noteInput = createElement(doc, "input", "input input-sm w-full");
noteInput.type = "text";
noteInput.placeholder = "Comment (optional)";
const sendBtn = createElement(doc, "button", "btn btn-sm btn-primary w-full", "Zap ⚡");
const statusDiv = createElement(doc, "div", "text-xs text-center mt-1");
sendBtn.addEventListener("click", async (e) => {
e.preventDefault();
const amount = parseInt(amountInput.value);
const comment = noteInput.value;
if (!amount || amount <= 0) {
statusDiv.textContent = "Invalid amount";
statusDiv.className = "text-error text-xs text-center mt-1";
return;
}
statusDiv.textContent = "Sending...";
statusDiv.className = "text-muted text-xs text-center mt-1";
sendBtn.disabled = true;
try {
if (typeof onSendZap === "function") {
// Use provided callback if available (e.g. from controller)
if (!contact.lightningAddress) {
throw new Error("No lightning address for this contact");
}
const resolver = zapConfig?.resolveRecipient || resolveZapRecipient;
const resolved = await resolver(contact.lightningAddress, { fetcher: zapConfig?.fetcher });
if (!resolved) throw new Error("Could not resolve lightning address");
const payload = {
amountSats: amount,
comment,
recipient: contact,
resolved
};
await onSendZap(payload);
statusDiv.textContent = "Zap sent!";
statusDiv.className = "text-success text-xs text-center mt-1";
amountInput.value = "";
noteInput.value = "";
} else if (zapConfig?.signer) {
await createAndPublishZapRequest({
address: contact.lightningAddress,
recipientPubkey: contact.pubkey,
relays: contact.relayHints || zapConfig?.relays,
amountSats: amount,
comment,
signer: zapConfig.signer,
pool: zapConfig.pool,
fetcher: zapConfig.fetcher,
});
statusDiv.textContent = "Zap sent!";
statusDiv.className = "text-success text-xs text-center mt-1";
amountInput.value = "";
noteInput.value = "";
} else {
throw new Error("No signer available");
}
} catch (err) {
console.error(err);
statusDiv.textContent = "Failed to send zap";
statusDiv.className = "text-error text-xs text-center mt-1";
} finally {
sendBtn.disabled = false;
}
});
form.appendChild(amountInput);
form.appendChild(noteInput);
form.appendChild(sendBtn);
form.appendChild(statusDiv);
container.appendChild(form);
// Receipts List
container.appendChild(
ZapReceiptList({
document: doc,
receipts: zapReceipts,
emptyLabel: "No recent zaps.",
}),
);
return container;
}
+426
View File
@@ -0,0 +1,426 @@
import { devLogger } from "../utils/logger.js";
export default class EngagementController {
constructor({ services = {}, ui = {}, state = {} } = {}) {
this.services = {
nostrClient: services.nostrClient,
};
this.ui = {
showError: ui.showError || (() => {}),
showSuccess: ui.showSuccess || (() => {}),
showStatus: ui.showStatus || (() => {}),
};
this.state = {
getCurrentVideo: state.getCurrentVideo || (() => null),
getCurrentVideoPointer: state.getCurrentVideoPointer || (() => null),
};
}
derivePointerFromDataset(dataset = {}, context = "") {
const type =
typeof dataset.pointerType === "string" ? dataset.pointerType.trim() : "";
const value =
typeof dataset.pointerValue === "string"
? dataset.pointerValue.trim()
: "";
const relay =
typeof dataset.pointerRelay === "string"
? dataset.pointerRelay.trim()
: "";
if (type && value) {
return relay ? [type, value, relay] : [type, value];
}
const currentVideoPointer = this.state.getCurrentVideoPointer();
if (
context === "modal" &&
Array.isArray(currentVideoPointer) &&
currentVideoPointer.length >= 2
) {
return currentVideoPointer;
}
const currentVideo = this.state.getCurrentVideo();
if (
context === "modal" &&
Array.isArray(currentVideo?.pointer) &&
currentVideo.pointer.length >= 2
) {
return currentVideo.pointer;
}
return null;
}
async handleRepostAction(dataset = {}) {
const context = typeof dataset.context === "string" ? dataset.context : "";
const explicitEventId =
typeof dataset.eventId === "string" && dataset.eventId.trim()
? dataset.eventId.trim()
: "";
const currentVideo = this.state.getCurrentVideo();
const fallbackEventId =
context === "modal" && currentVideo?.id ? currentVideo.id : "";
const targetEventId = explicitEventId || fallbackEventId;
if (!targetEventId) {
this.ui.showError("No event is available to repost.");
return;
}
const pointer = this.derivePointerFromDataset(dataset, context);
let author =
typeof dataset.author === "string" ? dataset.author.trim() : "";
if (!author && context === "modal" && currentVideo?.pubkey) {
author = currentVideo.pubkey;
}
const rawKindValue = (() => {
if (typeof dataset.kind === "string" && dataset.kind.trim()) {
const parsed = Number.parseInt(dataset.kind.trim(), 10);
return Number.isFinite(parsed) ? parsed : null;
}
if (Number.isFinite(dataset.kind)) {
return Number(dataset.kind);
}
if (Number.isFinite(currentVideo?.kind)) {
return Number(currentVideo.kind);
}
return null;
})();
const options = {
pointer,
pointerType: dataset.pointerType,
pointerValue: dataset.pointerValue,
pointerRelay: dataset.pointerRelay,
authorPubkey: author,
};
if (Number.isFinite(rawKindValue)) {
options.kind = Math.floor(rawKindValue);
}
try {
const result = await this.services.nostrClient.repostEvent(
targetEventId,
options
);
if (!result?.ok) {
const code = result?.error || "repost-failed";
switch (code) {
case "invalid-event-id":
this.ui.showError("No event is available to repost.");
break;
case "missing-actor":
this.ui.showError(
"Cannot sign the repost right now. Please refresh and try again."
);
break;
case "pool-unavailable":
this.ui.showError(
"Cannot reach relays right now. Please try again later."
);
break;
case "publish-rejected":
this.ui.showError("No relay accepted the repost attempt.");
break;
case "signing-failed":
this.ui.showError("Failed to sign the repost. Please try again.");
break;
default:
this.ui.showError(
"Failed to repost the video. Please try again later."
);
break;
}
return;
}
const acceptedCount = Array.isArray(result.summary?.accepted)
? result.summary.accepted.length
: 0;
const relayCount =
acceptedCount > 0
? acceptedCount
: Array.isArray(result.relays)
? result.relays.length
: acceptedCount;
const fragments = [];
if (relayCount > 0) {
fragments.push(
`Reposted to ${relayCount} relay${relayCount === 1 ? "" : "s"}.`
);
} else {
fragments.push("Reposted.");
}
if (result.sessionActor) {
fragments.push("Boost as session user.");
}
this.ui.showSuccess(fragments.join(" ").trim());
} catch (error) {
devLogger.warn("[EngagementController] Repost action failed:", error);
this.ui.showError("Failed to repost the video. Please try again later.");
}
}
async handleMirrorAction(dataset = {}) {
const context = typeof dataset.context === "string" ? dataset.context : "";
const explicitEventId =
typeof dataset.eventId === "string" && dataset.eventId.trim()
? dataset.eventId.trim()
: "";
const currentVideo = this.state.getCurrentVideo();
const fallbackEventId =
context === "modal" && currentVideo?.id ? currentVideo.id : "";
const targetEventId = explicitEventId || fallbackEventId;
if (!targetEventId) {
this.ui.showError("No event is available to mirror.");
return;
}
const explicitUrl =
typeof dataset.url === "string" && dataset.url.trim()
? dataset.url.trim()
: "";
const fallbackUrl =
context === "modal" && typeof currentVideo?.url === "string"
? currentVideo.url.trim()
: "";
const targetUrl = explicitUrl || fallbackUrl;
if (!targetUrl) {
this.ui.showError("This video does not expose a hosted URL to mirror.");
return;
}
const rawMagnet =
typeof dataset.magnet === "string" && dataset.magnet.trim()
? dataset.magnet.trim()
: "";
const fallbackMagnet =
context === "modal"
? currentVideo?.magnet || currentVideo?.originalMagnet || ""
: "";
const magnet = rawMagnet || fallbackMagnet;
const thumbnail =
typeof dataset.thumbnail === "string" && dataset.thumbnail.trim()
? dataset.thumbnail.trim()
: context === "modal" && typeof currentVideo?.thumbnail === "string"
? currentVideo.thumbnail.trim()
: "";
const description =
typeof dataset.description === "string" && dataset.description.trim()
? dataset.description.trim()
: context === "modal" && typeof currentVideo?.description === "string"
? currentVideo.description.trim()
: "";
const title =
typeof dataset.title === "string" && dataset.title.trim()
? dataset.title.trim()
: context === "modal" && typeof currentVideo?.title === "string"
? currentVideo.title.trim()
: "";
const datasetPrivate =
dataset.isPrivate === "true" || dataset.isPrivate === true ? true : false;
const fallbackPrivate =
context === "modal" && currentVideo?.isPrivate === true;
const isPrivate = datasetPrivate || fallbackPrivate;
if (isPrivate) {
this.ui.showError("Mirroring is unavailable for private videos.");
return;
}
const options = {
url: targetUrl,
magnet,
thumbnail,
description,
title,
isPrivate,
};
try {
const result = await this.services.nostrClient.mirrorVideoEvent(
targetEventId,
options
);
if (!result?.ok) {
const code = result?.error || "mirror-failed";
switch (code) {
case "invalid-event-id":
this.ui.showError("No event is available to mirror.");
break;
case "missing-url":
this.ui.showError(
"This video does not expose a hosted URL to mirror."
);
break;
case "missing-actor":
this.ui.showError(
"Cannot sign the mirror right now. Please refresh and try again."
);
break;
case "pool-unavailable":
this.ui.showError(
"Cannot reach relays right now. Please try again later."
);
break;
case "publish-rejected":
this.ui.showError("No relay accepted the mirror attempt.");
break;
case "signing-failed":
this.ui.showError("Failed to sign the mirror. Please try again.");
break;
default:
this.ui.showError(
"Failed to mirror the video. Please try again later."
);
break;
}
return;
}
const acceptedCount = Array.isArray(result.summary?.accepted)
? result.summary.accepted.length
: 0;
const relayCount =
acceptedCount > 0
? acceptedCount
: Array.isArray(result.relays)
? result.relays.length
: acceptedCount;
const fragments = [];
if (relayCount > 0) {
fragments.push(
`Mirrored to ${relayCount} relay${relayCount === 1 ? "" : "s"}.`
);
} else {
fragments.push("Mirrored.");
}
if (result.sessionActor) {
fragments.push("Boost as session user.");
}
this.ui.showSuccess(fragments.join(" ").trim());
} catch (error) {
devLogger.warn("[EngagementController] Mirror action failed:", error);
this.ui.showError("Failed to mirror the video. Please try again later.");
}
}
async handleEnsurePresenceAction(dataset = {}) {
const context = typeof dataset.context === "string" ? dataset.context : "";
const explicitEventId =
typeof dataset.eventId === "string" && dataset.eventId.trim()
? dataset.eventId.trim()
: "";
const currentVideo = this.state.getCurrentVideo();
const fallbackEventId =
context === "modal" && currentVideo?.id ? currentVideo.id : "";
const targetEventId = explicitEventId || fallbackEventId;
if (!targetEventId) {
this.ui.showError("No event is available to rebroadcast.");
return;
}
const explicitPubkey =
typeof dataset.pubkey === "string" && dataset.pubkey.trim()
? dataset.pubkey.trim()
: "";
const datasetAuthor =
typeof dataset.author === "string" && dataset.author.trim()
? dataset.author.trim()
: "";
const fallbackPubkey =
context === "modal" && typeof currentVideo?.pubkey === "string"
? currentVideo.pubkey
: datasetAuthor;
const targetPubkey = explicitPubkey || fallbackPubkey || "";
try {
const result = await this.services.nostrClient.rebroadcastEvent(
targetEventId,
{
pubkey: targetPubkey,
}
);
if (result?.throttled) {
const remainingMs = Math.max(
0,
Number(result?.cooldown?.remainingMs) || 0
);
const remainingSeconds = Math.ceil(remainingMs / 1000);
const message =
remainingSeconds > 0
? `Rebroadcast is cooling down. Try again in ${remainingSeconds}s.`
: "Rebroadcast is cooling down. Try again soon.";
this.ui.showStatus(message);
if (
typeof window !== "undefined" &&
typeof window.setTimeout === "function"
) {
window.setTimeout(() => {
this.ui.showStatus("");
}, 5000);
}
return;
}
if (!result?.ok) {
const code = result?.error || "rebroadcast-failed";
switch (code) {
case "event-not-found":
this.ui.showError(
"Original event payload is unavailable. Reload and try again."
);
break;
case "publish-rejected":
this.ui.showError("No relay accepted the rebroadcast attempt.");
break;
case "pool-unavailable":
this.ui.showError(
"Cannot reach relays right now. Please try again later."
);
break;
default:
this.ui.showError(
"Failed to rebroadcast. Please try again later."
);
break;
}
return;
}
if (result?.alreadyPresent) {
this.ui.showSuccess("Relays already have this revision.");
return;
}
this.ui.showSuccess("Rebroadcast requested across relays.");
} catch (error) {
devLogger.warn("[EngagementController] Rebroadcast action failed:", error);
this.ui.showError("Failed to rebroadcast. Please try again later.");
}
}
}
+4 -4
View File
@@ -937,14 +937,14 @@ export default class LoginModalController {
passphraseFields.hidden = !shouldShow;
if (passphraseInput instanceof HTMLInputElement) {
passphraseInput.disabled = !shouldShow;
passphraseInput.required = shouldShow;
passphraseInput.required = false;
if (!shouldShow) {
passphraseInput.value = "";
}
}
if (passphraseConfirm instanceof HTMLInputElement) {
passphraseConfirm.disabled = !shouldShow;
passphraseConfirm.required = shouldShow;
passphraseConfirm.required = false;
if (!shouldShow) {
passphraseConfirm.value = "";
}
@@ -959,14 +959,14 @@ export default class LoginModalController {
}
if (unlockPassphrase instanceof HTMLInputElement) {
unlockPassphrase.disabled = !usingUnlock;
unlockPassphrase.required = usingUnlock;
unlockPassphrase.required = false;
if (!usingUnlock) {
unlockPassphrase.value = "";
}
}
if (secretField instanceof HTMLTextAreaElement) {
secretField.disabled = usingUnlock;
secretField.required = !usingUnlock;
secretField.required = false;
if (usingUnlock) {
secretField.value = "";
}
-1
View File
@@ -1883,7 +1883,6 @@ export class ProfileModalController {
scrollContainerSelector: "#profileModalPanes",
errorBannerSelector: "#profileHistoryError",
clearButtonSelector: "#profileHistoryClear",
republishButtonSelector: "#profileHistoryRepublish",
refreshButtonSelector: "#profileHistoryRefresh",
privacyBannerSelector: "#profileHistoryPrivacyBanner",
privacyMessageSelector: "#profileHistoryPrivacyMessage",
+24
View File
@@ -6,6 +6,7 @@ import { getApplication } from "./applicationContext.js";
import { ASSET_VERSION } from "../config/asset-version.js";
import { applyDesignSystemAttributes } from "./designSystem.js";
import { devLogger, userLogger } from "./utils/logger.js";
import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
@@ -92,6 +93,14 @@ export const viewInitRegistry = {
if (refreshApp && typeof refreshApp.forceRefreshAllProfiles === "function") {
refreshApp.forceRefreshAllProfiles();
}
const infoTrigger = document.getElementById("recentInfoTrigger");
if (infoTrigger) {
attachFeedInfoPopover(
infoTrigger,
"Global chronological feed from all relays."
);
}
},
"for-you": () => {
initForYouView();
@@ -110,6 +119,13 @@ export const viewInitRegistry = {
},
explore: () => {
devLogger.log("Explore view loaded.");
const infoTrigger = document.getElementById("exploreInfoTrigger");
if (infoTrigger) {
attachFeedInfoPopover(
infoTrigger,
"Coming soon."
);
}
},
history: async () => {
try {
@@ -130,6 +146,14 @@ export const viewInitRegistry = {
subscriptions: async () => {
devLogger.log("Subscriptions view loaded.");
const infoTrigger = document.getElementById("subscriptionsInfoTrigger");
if (infoTrigger) {
attachFeedInfoPopover(
infoTrigger,
"Latest videos from channels you follow."
);
}
const app = getApplication();
if (!app?.pubkey) {
const container = document.getElementById("subscriptionsVideoList");
+4 -1
View File
@@ -615,12 +615,15 @@ function scheduleRepublishForQueue(actorKey) {
actorKey,
queue,
keysToClear,
snapshotStartTime
snapshotStartTime,
);
await updateFingerprintCache(
actorKey,
publishResult.items || latestItems,
);
if (queue.items.size > 0) {
scheduleRepublishForQueue(actorKey);
}
} else if (!publishResult?.retryable) {
// If not retryable, we clear the scheduled flag to allow new attempts later
queue.republishScheduled = false;
+3
View File
@@ -24,6 +24,9 @@
"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",
"test:unit:shard1": "cross-env UNIT_TEST_SHARD=1/3 node scripts/run-unit-tests.mjs",
"test:unit:shard2": "cross-env UNIT_TEST_SHARD=2/3 node scripts/run-unit-tests.mjs",
"test:unit:shard3": "cross-env UNIT_TEST_SHARD=3/3 node scripts/run-unit-tests.mjs",
"telemetry:aggregate": "node scripts/telemetry-aggregator.mjs"
},
"devDependencies": {
-462
View File
@@ -1,462 +0,0 @@
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /css/tailwind.generated.css?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/lists.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/nostrToolsBootstrap.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/accessControl.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/config.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/webtorrent.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/viewManager.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/bootstrap.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /assets/svg/default-profile.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/index.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/disclaimer.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/utils/logger.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/config.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/adminListStore.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/nostr/toolkit.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/webtorrent.min.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /config/instance-config.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/utils/safeDecode.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/constants.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/app.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/services/nostrService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/viewManager.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/services/r2Service.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/services/hashtagPreferencesService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /assets/svg/filter.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /assets/svg/search.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/channelProfile.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/subscriptions.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/designSystem.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /config/asset-version.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/applicationContext.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/ui/components/staticModalAccessibility.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /config/validate-config.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/services/authService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/bufferPolyfill.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/themeController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/accessControl.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/services/authProviders/index.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/nostrClientFacade.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/userBlocks.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/relayManager.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/nostrToolsBootstrap.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/bootstrap.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/nostrClientRegistry.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/nostrEventSchemas.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/nostrPublish.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/nostr/index.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/services/moderationService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/directMessagesStore.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/state/appState.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/r2.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/storage/r2-s3.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:15] "GET /js/storage/r2-mgmt.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/services/storageService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/formatters.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/services/videoNotePayload.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostrViewEventsFacade.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/webtorrent.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/magnetUtils.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/playbackUtils.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/gridHealth.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/urlHealthObserver.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/cardSourceVisibility.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/videoTags.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/profileMedia.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/lists.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/feedEngine/index.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/watchHistoryService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/r2-quick.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/videoMenuRenderers.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/overlay/popoverEngine.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/watchHistoryDebug.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/sidebarLoading.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/viewCounter.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/payments/zapSplit.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/reactionCounter.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/domUtils.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/tagPreferenceMenu.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/videoPointer.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/loginModalController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/pointer.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/videoDeduper.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/magnetValidators.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/serviceWorkerFallbackMessages.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/profileBatchFetcher.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/videoTimestamps.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/state/cache.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/applicationBootstrap.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/videoModalCommentController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/services/moderationActionController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/services/trustBootstrap.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/views/VideoListView.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/modalAccessibility.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/VideoCard.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/feedEngine/stages.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/moderationUiHelpers.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/moderationCopy.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/payments/zapSharedState.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/payments/zapNotifications.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/payments/platformAddress.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/payments/nwcClient.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/payments/lnurl.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/defaultClient.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/services/authProviders/nip07.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/services/authProviders/nip46.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/nip46Client.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/services/authProviders/nsec.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/services/authProviders/generate.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/client.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/nip07Permissions.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/nip71.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/watchHistory.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/watchHistoryBindings.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/viewEvents.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/reactionEvents.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/commentEvents.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /vendor/crypto-helpers.bundle.min.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/viewEventBindings.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/magnet.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/magnetShared.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/magnets.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/feedEngine/engine.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/dom/cardObserver.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/feedEngine/sorters.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/feedEngine/sources.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/designSystem/metrics.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/feedEngine/watchHistoryFeed.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /vendor/floating-ui.dom.bundle.min.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/publishHelpers.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/overlay/overlayRoot.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/payments/zapReceiptValidator.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/payments/platformFee.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/hex.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/qrcode.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/historyView.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/mediaLoader.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/watchHistoryController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/services/playbackService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/services/watchHistoryTelemetry.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/services/discussionCountService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/services/commentThreadService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/services/nwcSettingsService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/profileIdentityController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/videoListViewController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/storage.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/state/profileSettingsStore.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/profileModalController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/moreMenuController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/ModalManager.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/appChromeController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/payments/zapMessages.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/tagPillList.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/feedEngine/utils.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/videoEventUtils.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/maxListenerDiagnostics.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/sessionActor.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/designSystem/dynamicStyles.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostrWatchHistoryFacade.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nwcSettings.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/nostr/countDiagnostics.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/initEditModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/initUploadModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/RevertModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/VideoModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/initDeleteModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/EventDetailsModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/zapController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/EditModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/UploadModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/DeleteModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/nip71FormManager.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/ambientBackground.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/video-modal/utils/commentAvatar.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/video-modal/similarContentController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/video-modal/commentsController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/video-modal/moderationController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/video-modal/reactionsController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/ui/components/SimilarContentCard.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /js/utils/torrentHash.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/login-modal.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/content-appeals-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/bug-fix-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/general-feedback-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/application-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/feature-request-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/video-modal.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/upload-modal.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/edit-video-modal.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/profile-modal.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/iframe_forms/iframe-content-appeals-form.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/iframe_forms/iframe-bug-fix-form.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/iframe_forms/iframe-feedback-form.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/iframe_forms/iframe-request-form.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/iframe_forms/iframe-application-form.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/sidebar.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /components/iframe_forms/theme-sync.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:16] "GET /css/tailwind.generated.css HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /views/most-recent-videos.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/recent-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/ellipsis.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/explore-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/about-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/history-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/subscriptions-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/guidelines-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/getting-started-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/upload-content-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/github-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/roadmap-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/blog-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/nostr-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/beacon-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/beta-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/links-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/ipns-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /assets/svg/dns-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /js/sidebar.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /js/index.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /components/login-modal.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /components/general-feedback-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /components/bug-fix-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /components/application-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /components/feature-request-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /components/disclaimer.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /components/content-appeals-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /views/most-recent-videos.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /js/disclaimer.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:28:17] "GET /components/sidebar.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /index.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/config.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostrToolsBootstrap.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/accessControl.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/lists.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/webtorrent.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /css/tailwind.generated.css?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/viewManager.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/bootstrap.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/disclaimer.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/index.js?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/logger.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/config.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /config/instance-config.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/webtorrent.min.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/toolkit.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/adminListStore.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/constants.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/safeDecode.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/channelProfile.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/subscriptions.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/designSystem.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/app.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /config/asset-version.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/applicationContext.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/nostrService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/r2Service.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/viewManager.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/hashtagPreferencesService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/staticModalAccessibility.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /config/validate-config.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/bufferPolyfill.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/themeController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/authService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/authProviders/index.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/accessControl.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostrClientFacade.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/userBlocks.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/relayManager.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /assets/svg/default-profile.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/bootstrap.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /assets/svg/filter.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /assets/svg/search.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostrToolsBootstrap.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostrEventSchemas.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostrViewEventsFacade.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostrClientRegistry.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostrPublish.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/sidebarLoading.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/index.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/views/VideoListView.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/moderationService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/urlHealthObserver.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/gridHealth.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/overlay/popoverEngine.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/VideoCard.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/domUtils.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/formatters.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/videoMenuRenderers.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/profileMedia.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/feedEngine/stages.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/moderationCopy.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/payments/zapSharedState.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/moderationUiHelpers.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/payments/zapNotifications.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/payments/zapSplit.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/payments/lnurl.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/payments/platformAddress.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/state/appState.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/payments/nwcClient.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/r2.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/directMessagesStore.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/storage/r2-mgmt.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/storage/r2-s3.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/videoNotePayload.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/storageService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/modalAccessibility.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/webtorrent.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/playbackUtils.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/cardSourceVisibility.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/magnetUtils.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/videoTags.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/lists.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/r2-quick.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/watchHistoryService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/feedEngine/index.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/watchHistoryDebug.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/viewCounter.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/reactionCounter.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/tagPreferenceMenu.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/pointer.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/videoPointer.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/loginModalController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/magnetValidators.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/videoDeduper.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/serviceWorkerFallbackMessages.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/profileBatchFetcher.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/videoTimestamps.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/state/cache.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/applicationBootstrap.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/videoModalCommentController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/moderationActionController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/trustBootstrap.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/defaultClient.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/authProviders/nip46.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/viewEventBindings.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/authProviders/nsec.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/authProviders/nip07.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/authProviders/generate.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/viewEvents.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/client.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/nip07Permissions.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/nip71.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/watchHistory.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/watchHistoryBindings.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/reactionEvents.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/nip46Client.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/tagPillList.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/commentEvents.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/dom/cardObserver.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/designSystem/metrics.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/overlay/overlayRoot.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /vendor/floating-ui.dom.bundle.min.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/magnets.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/feedEngine/utils.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/payments/zapReceiptValidator.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/payments/zapMessages.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/publishHelpers.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/payments/platformFee.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/magnet.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/magnetShared.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /vendor/crypto-helpers.bundle.min.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/feedEngine/sources.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/feedEngine/sorters.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/feedEngine/engine.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/feedEngine/watchHistoryFeed.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/hex.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/qrcode.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/storage.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/state/profileSettingsStore.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/mediaLoader.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/historyView.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/watchHistoryController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/playbackService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/watchHistoryTelemetry.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/discussionCountService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/commentThreadService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/services/nwcSettingsService.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/profileIdentityController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/videoListViewController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/moreMenuController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/ModalManager.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/profileModalController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/appChromeController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/countDiagnostics.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/videoEventUtils.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/maxListenerDiagnostics.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostr/sessionActor.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nostrWatchHistoryFacade.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/VideoModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/nwcSettings.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/designSystem/dynamicStyles.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/initUploadModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/RevertModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/initEditModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/initDeleteModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/zapController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/EventDetailsModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/ambientBackground.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/video-modal/commentsController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/video-modal/moderationController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/video-modal/similarContentController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/video-modal/utils/commentAvatar.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/video-modal/reactionsController.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/SimilarContentCard.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/EditModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/DeleteModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/UploadModal.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/ui/components/nip71FormManager.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /js/utils/torrentHash.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /components/login-modal.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /components/application-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /components/content-appeals-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /components/feature-request-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /components/general-feedback-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /components/bug-fix-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /components/upload-modal.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /components/video-modal.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /components/edit-video-modal.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:45] "GET /components/profile-modal.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/iframe_forms/iframe-application-form.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/iframe_forms/iframe-content-appeals-form.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/iframe_forms/iframe-request-form.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/iframe_forms/iframe-feedback-form.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/iframe_forms/iframe-bug-fix-form.html HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/sidebar.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /css/tailwind.generated.css HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/iframe_forms/theme-sync.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/png/favicon-32x32.png HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /views/most-recent-videos.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/explore-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/recent-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/history-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/subscriptions-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/ellipsis.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/about-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/guidelines-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/upload-content-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/nostr-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/getting-started-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/roadmap-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/blog-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/beacon-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/github-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/beta-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/ipns-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/dns-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /assets/svg/links-icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /js/sidebar.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /js/index.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/general-feedback-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/application-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/feature-request-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/content-appeals-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/login-modal.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/bug-fix-form.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/disclaimer.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /views/most-recent-videos.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /components/sidebar.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /js/disclaimer.js HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:46] "GET /views/most-recent-videos.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:47] "GET /components/disclaimer.html?v=2024.11.09 HTTP/1.1" 200 -
127.0.0.1 - - [16/Jan/2026 13:29:47] "GET /views/most-recent-videos.html?v=2024.11.09 HTTP/1.1" 200 -
+11 -12
View File
@@ -1,30 +1,29 @@
from playwright.sync_api import sync_playwright
import sys
import time
import sys
def run():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
browser = p.chromium.launch()
page = browser.new_page()
# Subscribe to console messages
print("--- Console Messages ---")
page.on("console", lambda msg: print(f"console:{msg.type}: {msg.text}"))
# Subscribe to uncaught exceptions
print("--- Page Errors ---")
page.on("pageerror", lambda exc: print(f"pageerror: {exc}"))
# Subscribe to failed network requests
print("--- Failed Requests ---")
page.on("requestfailed", lambda req: print(f"requestfailed: {req.url} {req.failure}"))
try:
# Navigate to localhost
page.goto("http://localhost:8000", wait_until="networkidle")
# Wait a bit for async operations
time.sleep(2)
page.goto("http://localhost:8000")
# Wait for potential initialization errors
time.sleep(5)
except Exception as e:
print(f"Error navigating: {e}")
finally:
browser.close()
print(f"Navigation failed: {e}")
browser.close()
if __name__ == "__main__":
run()
+54 -125
View File
@@ -1,134 +1,63 @@
import { decryptDM } from '../../js/dmDecryptor.js';
import { getRandomFuzzInput, randomHex, randomString } from './fuzz-utils.mjs';
import fs from 'fs';
import path from 'path';
import { decryptDM } from "../../js/dmDecryptor.js";
import { runFuzzer, randomString, randomInt, randomJSON, randomHex, randomBoolean, randomItem } from "./fuzz-shared.mjs";
const REPRODUCERS_DIR = 'examples/reproducers/dmDecryptor';
fs.mkdirSync(REPRODUCERS_DIR, { recursive: true });
async function fuzzDmDecryptor(iteration) {
const schemes = ["nip04", "nip44", "nip44_v2", randomString(5)];
const failures = [];
const mockDecryptor = (scheme) => ({
scheme,
decrypt: async (pubkey, ciphertext, options) => {
// Simulate random failures
if (Math.random() < 0.1) throw new Error("Decryption failed");
function recordFailure(target, input, error) {
const id = Date.now() + Math.random().toString(36).substring(7);
const filename = path.join(REPRODUCERS_DIR, `${target}-${id}.json`);
// Simulate random return values
const type = randomInt(0, 3);
if (type === 0) return randomString(20);
if (type === 1) return JSON.stringify(randomJSON(2, 2)); // Return JSON string
if (type === 2) return ""; // Empty string
if (type === 3) return ciphertext; // Echo
},
source: "mock-fuzzer",
priority: randomInt(0, 10),
supportsGiftWrap: scheme.startsWith("nip44")
});
// Handle circular input for logging
let safeInput = input;
try {
JSON.stringify(input);
} catch {
safeInput = "[Circular or non-serializable input]";
}
const decryptors = [];
const numDecryptors = randomInt(0, 3);
for (let i = 0; i < numDecryptors; i++) {
decryptors.push(mockDecryptor(randomItem(schemes)));
}
const report = {
target,
input: safeInput,
error: {
message: error.message,
stack: error.stack
}
};
const event = {
kind: randomItem([4, 1059, randomInt(0, 20000)]),
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
tags: randomJSON(2, 3), // Tags can be anything in fuzzing
content: randomString(100)
};
fs.writeFileSync(filename, JSON.stringify(report, null, 2));
failures.push(report);
console.error(`[FAIL] ${target}: ${error.message} (Report saved to ${filename})`);
// Sometimes provide malformed event
if (Math.random() < 0.05) {
event.kind = "string";
}
if (Math.random() < 0.05) {
event.tags = "string";
}
const context = {
actorPubkey: randomHex(64),
decryptors
};
const input = {
event,
context
};
await decryptDM(event, context);
return input;
}
function runFuzz(name, fn, inputGenerator, iterations = 1000) {
console.log(`Fuzzing ${name} for ${iterations} iterations...`);
for (let i = 0; i < iterations; i++) {
const input = inputGenerator();
try {
fn(input);
} catch (error) {
recordFailure(name, input, error);
}
}
}
// 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 = {
scheme: 'nip44',
decrypt: async () => { throw new Error("Hard fail"); }
}
const wrapper = async (input) => {
// input is fuzzy junk.
// We construct a call to decryptDM(event, context)
// 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)) {
const kinds = [4, 1059, input.kind];
event = {
...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
};
}
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);
};
runFuzz('decryptDM', wrapper, getRandomFuzzInput);
// Write summary
fs.writeFileSync('artifacts/fuzz-report-dmDecryptor.json', JSON.stringify(failures, null, 2));
if (failures.length > 0) {
console.log(`\nFuzzing finished with ${failures.length} failures.`);
process.exit(1);
} else {
console.log("\nFuzzing finished with 0 failures.");
process.exit(0);
}
runFuzzer("dmDecryptor", fuzzDmDecryptor, 5000);
+38 -72
View File
@@ -1,81 +1,47 @@
import { normalizeAndAugmentMagnet } from '../../js/magnetUtils.js';
import { getRandomFuzzInput } from './fuzz-utils.mjs';
import fs from 'fs';
import path from 'path';
import { normalizeAndAugmentMagnet } from "../../js/magnetUtils.js";
import { runFuzzer, randomString, randomInt, randomJSON, randomHex, randomBoolean, randomItem } from "./fuzz-shared.mjs";
const REPRODUCERS_DIR = 'examples/reproducers/magnetUtils';
fs.mkdirSync(REPRODUCERS_DIR, { recursive: true });
async function fuzzMagnetUtils(iteration) {
const baseMagnets = [
"magnet:?xt=urn:btih:" + randomHex(40),
"magnet:?xt=urn:btih:" + randomHex(40).toUpperCase(),
"magnet:?xt=urn:btih:" + randomString(40), // Invalid hex
"magnet:?xt=urn:btih:" + randomHex(40) + "&dn=" + randomString(10),
"magnet:?xt=urn:btih:" + randomHex(40) + "&tr=" + encodeURIComponent("wss://" + randomString(10) + ".com"),
randomString(100),
""
];
const failures = [];
const rawValue = Math.random() < 0.8 ? randomItem(baseMagnets) : randomString(100);
function recordFailure(target, input, error) {
const id = Date.now() + Math.random().toString(36).substring(7);
const filename = path.join(REPRODUCERS_DIR, `${target}-${id}.json`);
const options = {
webSeed: Math.random() < 0.5 ? randomString(20) : [randomString(20), randomString(20)],
torrentUrl: Math.random() < 0.5 ? "https://" + randomString(10) + ".com/file.torrent" : randomString(20),
xs: Math.random() < 0.5 ? "https://" + randomString(10) + ".com/file.torrent" : randomString(20),
extraTrackers: Math.random() < 0.5 ? ["wss://" + randomString(10) + ".com"] : randomJSON(1, 2),
appProtocol: randomItem(["http:", "https:", "magnet:", randomString(5)])
};
// Handle circular input for logging
let safeInput = input;
try {
JSON.stringify(input);
} catch {
safeInput = "[Circular or non-serializable input]";
}
// Malformed options
if (Math.random() < 0.1) {
options.webSeed = 123;
}
if (Math.random() < 0.1) {
options.extraTrackers = "string";
}
const report = {
target,
input: safeInput,
error: {
message: error.message,
stack: error.stack
}
};
const input = {
rawValue,
options
};
fs.writeFileSync(filename, JSON.stringify(report, null, 2));
failures.push(report);
console.error(`[FAIL] ${target}: ${error.message} (Report saved to ${filename})`);
// Mock logger to avoid spamming console
options.logger = () => {};
normalizeAndAugmentMagnet(rawValue, options);
return input;
}
function runFuzz(name, fn, inputGenerator, iterations = 1000) {
console.log(`Fuzzing ${name} for ${iterations} iterations...`);
for (let i = 0; i < iterations; i++) {
const input = inputGenerator();
try {
fn(input);
} catch (error) {
recordFailure(name, input, error);
}
}
}
const wrapper = (input) => {
// input is fuzzy junk.
// normalizeAndAugmentMagnet(rawValue, options)
let rawValue = input;
let options = {};
if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
rawValue = input.rawValue || getRandomFuzzInput();
options = input.options || {
webSeed: getRandomFuzzInput(),
torrentUrl: getRandomFuzzInput(),
xs: getRandomFuzzInput(),
extraTrackers: getRandomFuzzInput()
};
}
// Deterministic execution: input fully defines execution path.
normalizeAndAugmentMagnet(rawValue, options);
};
runFuzz('normalizeAndAugmentMagnet', wrapper, getRandomFuzzInput);
// Write summary
fs.writeFileSync('artifacts/fuzz-report-magnetUtils.json', JSON.stringify(failures, null, 2));
if (failures.length > 0) {
console.log(`\nFuzzing finished with ${failures.length} failures.`);
process.exit(1);
} else {
console.log("\nFuzzing finished with 0 failures.");
process.exit(0);
}
runFuzzer("magnetUtils", fuzzMagnetUtils, 10000); // Fast, so more iterations
+313 -237
View File
@@ -1,246 +1,322 @@
import {
buildVideoPostEvent,
buildVideoMirrorEvent,
buildRepostEvent,
buildRelayListEvent,
buildViewEvent,
buildReactionEvent,
buildCommentEvent,
buildWatchHistoryEvent,
buildSubscriptionListEvent,
buildBlockListEvent,
buildHashtagPreferenceEvent,
buildAdminListEvent,
sanitizeAdditionalTags,
getNostrEventSchema,
setNostrEventSchemaOverrides,
NOTE_TYPES
} from '../../js/nostrEventSchemas.js';
import { getRandomFuzzInput, randomString, randomHex } from './fuzz-utils.mjs';
import fs from 'fs';
import path from 'path';
import * as schemas from "../../js/nostrEventSchemas.js";
import { runFuzzer, randomString, randomInt, randomJSON, randomHex, randomBoolean, randomItem } from "./fuzz-shared.mjs";
const REPRODUCERS_DIR = 'examples/reproducers/nostrEventSchemas';
fs.mkdirSync(REPRODUCERS_DIR, { recursive: true });
async function fuzzNostrEventSchemas(iteration) {
// Randomly select an action
const actions = [
"sanitizeAdditionalTags",
"buildVideoPostEvent",
"buildVideoMirrorEvent",
"buildRepostEvent",
"buildRelayListEvent",
"buildDmRelayListEvent",
"buildProfileMetadataEvent",
"buildMuteListEvent",
"buildDeletionEvent",
"buildLegacyDirectMessageEvent",
"buildDmAttachmentEvent",
"buildDmReadReceiptEvent",
"buildDmTypingIndicatorEvent",
"buildViewEvent",
"buildZapRequestEvent",
"buildReactionEvent",
"buildCommentEvent",
"buildWatchHistoryEvent",
"buildSubscriptionListEvent",
"buildBlockListEvent",
"buildHashtagPreferenceEvent",
"buildAdminListEvent"
];
const failures = [];
const action = actions[iteration % actions.length];
// Introduce randomness within the cycle
// const action = randomItem(actions);
function recordFailure(target, input, error) {
const id = Date.now() + Math.random().toString(36).substring(7);
const filename = path.join(REPRODUCERS_DIR, `${target}-${id}.json`);
let input = { action };
// Handle circular input for logging
let safeInput = input;
try {
JSON.stringify(input);
} catch {
safeInput = "[Circular or non-serializable input]";
try {
switch (action) {
case "sanitizeAdditionalTags": {
input.args = [randomJSON(2, 5)];
schemas.sanitizeAdditionalTags(input.args[0]);
break;
}
case "buildVideoPostEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
dTagValue: randomString(10),
content: randomJSON(2, 3),
additionalTags: randomJSON(2, 3)
}];
schemas.buildVideoPostEvent(input.args[0]);
break;
}
case "buildVideoMirrorEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
tags: randomJSON(2, 3),
content: randomString(100)
}];
schemas.buildVideoMirrorEvent(input.args[0]);
break;
}
case "buildRepostEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
eventId: randomHex(64),
eventRelay: randomString(20),
address: randomString(20),
addressRelay: randomString(20),
authorPubkey: randomHex(64),
additionalTags: randomJSON(2, 3),
repostKind: randomInt(0, 10000),
targetKind: randomInt(0, 10000),
targetEvent: randomJSON(2, 3),
serializedEvent: randomString(50)
}];
schemas.buildRepostEvent(input.args[0]);
break;
}
case "buildRelayListEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
relays: randomJSON(2, 3),
additionalTags: randomJSON(2, 3)
}];
schemas.buildRelayListEvent(input.args[0]);
break;
}
case "buildDmRelayListEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
relays: randomJSON(1, 5),
additionalTags: randomJSON(2, 3)
}];
schemas.buildDmRelayListEvent(input.args[0]);
break;
}
case "buildProfileMetadataEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
metadata: randomJSON(2, 3),
content: randomString(50),
additionalTags: randomJSON(2, 3)
}];
schemas.buildProfileMetadataEvent(input.args[0]);
break;
}
case "buildMuteListEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
pTags: [randomHex(64), randomHex(64)],
content: randomString(50),
encrypted: randomBoolean(),
encryptionTag: randomString(10),
additionalTags: randomJSON(2, 3)
}];
schemas.buildMuteListEvent(input.args[0]);
break;
}
case "buildDeletionEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
eventIds: [randomHex(64)],
addresses: [randomString(20)],
reason: randomString(50),
additionalTags: randomJSON(2, 3)
}];
schemas.buildDeletionEvent(input.args[0]);
break;
}
case "buildLegacyDirectMessageEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
recipientPubkey: randomHex(64),
ciphertext: randomString(100),
additionalTags: randomJSON(2, 3)
}];
schemas.buildLegacyDirectMessageEvent(input.args[0]);
break;
}
case "buildDmAttachmentEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
recipientPubkey: randomHex(64),
attachment: randomJSON(1, 3),
additionalTags: randomJSON(2, 3)
}];
schemas.buildDmAttachmentEvent(input.args[0]);
break;
}
case "buildDmReadReceiptEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
recipientPubkey: randomHex(64),
eventId: randomHex(64),
messageKind: randomInt(0, 10000),
additionalTags: randomJSON(2, 3)
}];
schemas.buildDmReadReceiptEvent(input.args[0]);
break;
}
case "buildDmTypingIndicatorEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
recipientPubkey: randomHex(64),
eventId: randomHex(64),
expiresAt: randomInt(1000000000, 2000000000),
additionalTags: randomJSON(2, 3)
}];
schemas.buildDmTypingIndicatorEvent(input.args[0]);
break;
}
case "buildViewEvent": {
// High fuzz target for ensureValidUtf8Content
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
pointerValue: randomString(50),
pointerTag: [randomString(5), randomString(20)],
pointerTags: randomJSON(2, 3),
dedupeTag: randomString(20),
includeSessionTag: randomBoolean(),
additionalTags: randomJSON(2, 3),
content: randomString(200) + "\uD800" + randomString(10) // Surrogate pair
}];
schemas.buildViewEvent(input.args[0]);
break;
}
case "buildZapRequestEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
recipientPubkey: randomHex(64),
relays: [randomString(20)],
amountSats: randomInt(0, 1000000),
lnurl: randomString(50),
eventId: randomHex(64),
coordinate: randomString(50),
additionalTags: randomJSON(2, 3),
content: randomString(50)
}];
schemas.buildZapRequestEvent(input.args[0]);
break;
}
case "buildReactionEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
pointerValue: randomString(20),
pointerTag: [randomString(5), randomString(20)],
pointerTags: randomJSON(2, 3),
targetPointer: randomJSON(1, 3),
targetAuthorPubkey: randomHex(64),
additionalTags: randomJSON(2, 3),
content: randomString(10)
}];
schemas.buildReactionEvent(input.args[0]);
break;
}
case "buildCommentEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
videoEventId: randomHex(64),
videoEventRelay: randomString(20),
videoDefinitionAddress: randomString(50),
videoDefinitionRelay: randomString(20),
rootIdentifier: randomString(50),
rootIdentifierRelay: randomString(20),
parentCommentId: randomHex(64),
parentCommentRelay: randomString(20),
threadParticipantPubkey: randomHex(64),
threadParticipantRelay: randomString(20),
rootKind: randomInt(0, 10000),
rootAuthorPubkey: randomHex(64),
rootAuthorRelay: randomString(20),
parentKind: randomInt(0, 10000),
parentAuthorPubkey: randomHex(64),
parentAuthorRelay: randomString(20),
parentIdentifier: randomString(50),
parentIdentifierRelay: randomString(20),
additionalTags: randomJSON(2, 3),
content: randomString(100)
}];
schemas.buildCommentEvent(input.args[0]);
break;
}
case "buildWatchHistoryEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
monthIdentifier: randomString(10),
pointerTags: randomJSON(2, 3),
content: randomJSON(2, 3)
}];
schemas.buildWatchHistoryEvent(input.args[0]);
break;
}
case "buildSubscriptionListEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
content: randomString(50),
encryption: randomString(10)
}];
schemas.buildSubscriptionListEvent(input.args[0]);
break;
}
case "buildBlockListEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
content: randomString(50),
encryption: randomString(10)
}];
schemas.buildBlockListEvent(input.args[0]);
break;
}
case "buildHashtagPreferenceEvent": {
input.args = [{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
content: randomString(50)
}];
schemas.buildHashtagPreferenceEvent(input.args[0]);
break;
}
case "buildAdminListEvent": {
input.args = [
randomItem(["moderation", "editors", "whitelist", "blacklist", randomString(5)]),
{
pubkey: randomHex(64),
created_at: randomInt(1000000000, 2000000000),
hexPubkeys: [randomHex(64), randomHex(64)]
}
];
schemas.buildAdminListEvent(input.args[0], input.args[1]);
break;
}
}
const report = {
target,
input: safeInput,
error: {
message: error.message,
stack: error.stack
}
};
fs.writeFileSync(filename, JSON.stringify(report, null, 2));
failures.push(report);
console.error(`[FAIL] ${target}: ${error.message} (Report saved to ${filename})`);
return input;
} catch (error) {
throw error;
}
}
function runFuzz(name, fn, inputGenerator, iterations = 1000) {
console.log(`Fuzzing ${name} for ${iterations} iterations...`);
for (let i = 0; i < iterations; i++) {
const input = inputGenerator();
try {
fn(input);
} catch (error) {
recordFailure(name, input, error);
}
}
}
// Wrap functions that take multiple arguments or objects
const wrappers = {
buildVideoPostEvent: (input) => {
if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
buildVideoPostEvent({
pubkey: input.pubkey || getRandomFuzzInput(),
created_at: input.created_at || getRandomFuzzInput(),
dTagValue: input.dTagValue || getRandomFuzzInput(),
content: input.content || getRandomFuzzInput(),
additionalTags: input.additionalTags || getRandomFuzzInput()
});
} else {
buildVideoPostEvent(input);
}
},
buildVideoMirrorEvent: (input) => {
if (typeof input === 'object' && input !== null) {
buildVideoMirrorEvent({
pubkey: input.pubkey || getRandomFuzzInput(),
created_at: input.created_at || getRandomFuzzInput(),
tags: input.tags || getRandomFuzzInput(),
content: input.content || getRandomFuzzInput()
});
} else {
buildVideoMirrorEvent(input);
}
},
buildRepostEvent: (input) => {
if (typeof input === 'object' && input !== null) {
buildRepostEvent({
...input,
pubkey: input.pubkey || getRandomFuzzInput(),
created_at: input.created_at || getRandomFuzzInput()
});
} else {
buildRepostEvent(input);
}
},
buildRelayListEvent: (input) => {
if (typeof input === 'object' && input !== null) {
buildRelayListEvent({
pubkey: input.pubkey || getRandomFuzzInput(),
created_at: input.created_at || getRandomFuzzInput(),
relays: input.relays || getRandomFuzzInput(),
additionalTags: input.additionalTags || getRandomFuzzInput()
});
} else {
buildRelayListEvent(input);
}
},
buildViewEvent: (input) => {
if (typeof input === 'object' && input !== null) {
buildViewEvent({
...input,
pubkey: input.pubkey || getRandomFuzzInput(),
created_at: input.created_at || getRandomFuzzInput()
});
} else {
buildViewEvent(input);
}
},
buildReactionEvent: (input) => {
if (typeof input === 'object' && input !== null) {
buildReactionEvent({
...input,
pubkey: input.pubkey || getRandomFuzzInput(),
created_at: input.created_at || getRandomFuzzInput()
});
} else {
buildReactionEvent(input);
}
},
buildCommentEvent: (input) => {
if (typeof input === 'object' && input !== null) {
buildCommentEvent({
...input,
pubkey: input.pubkey || getRandomFuzzInput(),
created_at: input.created_at || getRandomFuzzInput()
});
} else {
buildCommentEvent(input);
}
},
buildWatchHistoryEvent: (input) => {
if (typeof input === 'object' && input !== null) {
buildWatchHistoryEvent({
...input,
pubkey: input.pubkey || getRandomFuzzInput(),
created_at: input.created_at || getRandomFuzzInput()
});
} else {
buildWatchHistoryEvent(input);
}
},
buildSubscriptionListEvent: (input) => {
if (typeof input === 'object' && input !== null) {
buildSubscriptionListEvent({
...input,
pubkey: input.pubkey || getRandomFuzzInput(),
created_at: input.created_at || getRandomFuzzInput()
});
} else {
buildSubscriptionListEvent(input);
}
},
buildBlockListEvent: (input) => {
if (typeof input === 'object' && input !== null) {
buildBlockListEvent({
...input,
pubkey: input.pubkey || getRandomFuzzInput(),
created_at: input.created_at || getRandomFuzzInput()
});
} else {
buildBlockListEvent(input);
}
},
buildHashtagPreferenceEvent: (input) => {
if (typeof input === 'object' && input !== null) {
buildHashtagPreferenceEvent({
...input,
pubkey: input.pubkey || getRandomFuzzInput(),
created_at: input.created_at || getRandomFuzzInput()
});
} else {
buildHashtagPreferenceEvent(input);
}
},
buildAdminListEvent: (input) => {
// expects listKey and params
const key = getRandomFuzzInput();
if (typeof input === 'object' && input !== null) {
buildAdminListEvent(key, {
...input,
pubkey: input.pubkey || getRandomFuzzInput(),
created_at: input.created_at || getRandomFuzzInput()
});
} else {
buildAdminListEvent(key, 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({});
}
}
};
// Main execution
Object.entries(wrappers).forEach(([name, fn]) => {
runFuzz(name, fn, getRandomFuzzInput);
});
// Write summary
fs.writeFileSync('artifacts/fuzz-report-nostrEventSchemas.json', JSON.stringify(failures, null, 2));
if (failures.length > 0) {
console.log(`\nFuzzing finished with ${failures.length} failures.`);
process.exit(1);
} else {
console.log("\nFuzzing finished with 0 failures.");
process.exit(0);
}
// 5000 iterations to get good coverage
runFuzzer("nostrEventSchemas", fuzzNostrEventSchemas, 5000);
+137
View File
@@ -0,0 +1,137 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPRODUCERS_DIR = path.resolve(__dirname, "../../examples/reproducers");
const ARTIFACTS_DIR = path.resolve(__dirname, "../../artifacts");
// Ensure directories exist
if (!fs.existsSync(REPRODUCERS_DIR)) {
fs.mkdirSync(REPRODUCERS_DIR, { recursive: true });
}
if (!fs.existsSync(ARTIFACTS_DIR)) {
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
}
export function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export function randomString(length) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+{}|:<>?~`-=[]\\;',./";
let result = "";
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function randomHex(length) {
const chars = "0123456789abcdef";
let result = "";
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function randomBoolean() {
return Math.random() < 0.5;
}
export function randomItem(array) {
return array[Math.floor(Math.random() * array.length)];
}
export function randomJSON(depth = 3, maxFields = 5) {
if (depth <= 0) {
const type = randomInt(0, 3);
if (type === 0) return randomString(10);
if (type === 1) return randomInt(-1000, 1000);
if (type === 2) return randomBoolean();
return null;
}
const type = randomInt(0, 5);
if (type === 0) {
// Array
const arr = [];
const len = randomInt(0, maxFields);
for (let i = 0; i < len; i++) {
arr.push(randomJSON(depth - 1, maxFields));
}
return arr;
} else if (type === 1) {
// Object
const obj = {};
const len = randomInt(0, maxFields);
for (let i = 0; i < len; i++) {
obj[randomString(5)] = randomJSON(depth - 1, maxFields);
}
return obj;
} else {
// Scalar
return randomJSON(0);
}
}
export function saveReproducer(target, input, error) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `crash-${target}-${timestamp}.json`;
const filepath = path.join(REPRODUCERS_DIR, filename);
const report = {
target,
timestamp: new Date().toISOString(),
error: {
message: error.message,
stack: error.stack,
name: error.name
},
input
};
fs.writeFileSync(filepath, JSON.stringify(report, null, 2));
console.error(`[FUZZ] Saved reproducer to ${filepath}`);
return filename;
}
export async function runFuzzer(name, fuzzFn, iterations = 1000) {
console.log(`[FUZZ] Starting fuzzer for ${name} with ${iterations} iterations...`);
const crashes = [];
const start = Date.now();
for (let i = 0; i < iterations; i++) {
if (i % 100 === 0) process.stdout.write(".");
let currentInput = null;
try {
const input = await fuzzFn(i);
currentInput = input;
} catch (error) {
process.stdout.write("E");
const filename = saveReproducer(name, currentInput, error);
crashes.push({
iteration: i,
error: error.message,
reproducer: filename
});
}
}
console.log("\n");
const duration = (Date.now() - start) / 1000;
console.log(`[FUZZ] Finished ${name} in ${duration.toFixed(2)}s. Crashes: ${crashes.length}`);
const reportPath = path.join(ARTIFACTS_DIR, `fuzz-report-${name}.json`);
const report = {
target: name,
iterations,
duration,
crashes
};
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(`[FUZZ] Saved report to ${reportPath}`);
}
+244 -270
View File
@@ -1,127 +1,103 @@
// scripts/agent/load-test.mjs
import './setup-test-env.js';
import WebSocket from 'ws';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
import { spawn, exec } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildVideoPostEvent, buildViewEvent } from '../../js/nostrEventSchemas.js';
import { buildNip71VideoEvent } from '../../js/nostr/nip71.js';
import { WebSocket } from 'ws';
import * as NostrTools from 'nostr-tools';
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import {
buildVideoPostEvent,
buildViewEvent
} from '../../js/nostrEventSchemas.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '../../');
const ARTIFACTS_DIR = path.join(REPO_ROOT, 'artifacts');
const ROOT_DIR = path.resolve(__dirname, '../..');
const ARTIFACTS_DIR = path.join(ROOT_DIR, 'artifacts');
// Ensure artifacts dir exists
if (!fs.existsSync(ARTIFACTS_DIR)) {
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
fs.mkdirSync(ARTIFACTS_DIR);
}
// Configuration defaults
const CONFIG = {
clients: 100, // Default N
duration: 60, // Seconds
rate: 5, // Events per second per client (approx)
relayUrl: 'ws://localhost:8008',
relayScript: 'scripts/agent/simple-relay.mjs',
force: false
// --- Configuration ---
const args = process.argv.slice(2);
function getArg(name, defaultValue) {
const idx = args.indexOf(name);
return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultValue;
}
const CLIENT_COUNT = parseInt(getArg('-n', '1000'), 10);
const DURATION_SEC = parseInt(getArg('-d', '600'), 10);
const RELAY_PORT = parseInt(process.env.PORT || '8009', 10); // Default to 8009 for load test
const RELAY_URL = `ws://localhost:${RELAY_PORT}`;
// --- Metrics ---
const metrics = {
startTime: Date.now(),
clientsConnected: 0,
eventsPublished: 0,
eventsConfirmed: 0,
eventsFailed: 0,
latencies: {
build: [],
sign: [],
rtt: []
},
resources: {
cpu: [],
memory: []
},
errors: []
};
// Parse args
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--clients')) {
if (arg.includes('=')) CONFIG.clients = parseInt(arg.split('=')[1]);
else if (args[i+1]) CONFIG.clients = parseInt(args[++i]);
}
else if (arg.startsWith('--duration')) {
if (arg.includes('=')) CONFIG.duration = parseInt(arg.split('=')[1]);
else if (args[i+1]) CONFIG.duration = parseInt(args[++i]);
}
else if (arg.startsWith('--rate')) {
if (arg.includes('=')) CONFIG.rate = parseFloat(arg.split('=')[1]);
else if (args[i+1]) CONFIG.rate = parseFloat(args[++i]);
}
else if (arg.startsWith('--relay')) {
if (arg.includes('=')) CONFIG.relayUrl = arg.split('=')[1];
else if (args[i+1]) CONFIG.relayUrl = args[++i];
}
else if (arg === '--force') {
CONFIG.force = true;
}
// --- Helpers ---
function now() { return Date.now(); }
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function randomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
// --- Relay Process ---
let relayProcess;
async function startRelay() {
console.log(`[LoadTest] Starting relay on port ${RELAY_PORT}...`);
relayProcess = spawn('node', ['scripts/agent/simple-relay.mjs'], {
cwd: ROOT_DIR,
env: { ...process.env, PORT: RELAY_PORT.toString() },
stdio: 'ignore' // We don't want relay logs spamming output
});
// Give it time to start
await sleep(2000);
console.log(`[LoadTest] Relay started.`);
}
console.log('Load Test Configuration:', CONFIG);
// Helper: Is IP Private?
function isPrivateIP(ip) {
const parts = ip.split('.').map(Number);
if (parts.length !== 4) return false; // Basic check, ipv4 only
// 10.0.0.0 - 10.255.255.255
if (parts[0] === 10) return true;
// 172.16.0.0 - 172.31.255.255
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
// 192.168.0.0 - 192.168.255.255
if (parts[0] === 192 && parts[1] === 168) return true;
// 127.0.0.0 - 127.255.255.255
if (parts[0] === 127) return true;
return false;
}
// Guardrail: Public Relays
function isSafeRelay(url) {
try {
const u = new URL(url);
const hostname = u.hostname;
// Localhost whitelist
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true;
// Private IP whitelist
if (isPrivateIP(hostname)) return true;
return false;
} catch (e) {
return false;
}
}
if (!isSafeRelay(CONFIG.relayUrl) && !CONFIG.force) {
console.error(`FATAL: Relay "${CONFIG.relayUrl}" is not on the whitelist (localhost or private IP).`);
console.error('Use --force to override this safety check ONLY if you are testing against a dedicated private relay.');
process.exit(1);
}
// Client Class
// --- Client Simulation ---
class LoadClient {
constructor(id, relayUrl) {
constructor(id) {
this.id = id;
this.relayUrl = relayUrl;
this.sk = generateSecretKey();
this.pk = getPublicKey(this.sk);
this.sk = NostrTools.generateSecretKey();
this.pk = NostrTools.getPublicKey(this.sk);
this.ws = null;
this.connected = false;
this.queue = [];
this.pending = new Map(); // id -> { start, resolve, reject }
this.pending = new Map(); // id -> startTime
}
connect() {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.relayUrl);
return new Promise((resolve) => {
this.ws = new WebSocket(RELAY_URL);
this.ws.on('open', () => {
this.connected = true;
metrics.clientsConnected++;
resolve();
});
this.ws.on('message', (data) => this.handleMessage(data));
this.ws.on('error', (err) => {
if (!this.connected) reject(err);
else console.error(`Client ${this.id} error:`, err.message);
// metrics.errors.push(`Client ${this.id} error: ${err.message}`);
});
this.ws.on('close', () => {
if (this.connected) metrics.clientsConnected--;
this.connected = false;
});
this.ws.on('message', (data) => this.handleMessage(data));
});
}
@@ -129,212 +105,210 @@ class LoadClient {
try {
const msg = JSON.parse(data);
if (msg[0] === 'OK') {
const [, eventId, success, message] = msg;
const pending = this.pending.get(eventId);
if (pending) {
if (success) pending.resolve(performance.now() - pending.start);
else pending.reject(new Error(message));
const eventId = msg[1];
const success = msg[2];
const reason = msg[3];
if (this.pending.has(eventId)) {
const start = this.pending.get(eventId);
const rtt = now() - start;
metrics.latencies.rtt.push(rtt);
this.pending.delete(eventId);
if (success) {
metrics.eventsConfirmed++;
} else {
metrics.eventsFailed++;
metrics.errors.push(`OK false: ${reason}`);
}
}
}
} catch (e) {
console.error(`Client ${this.id} parse error:`, e);
// ignore malformed
}
}
async publish(eventTemplate) {
if (!this.connected) throw new Error('Not connected');
async publishAction() {
if (!this.connected) return;
const event = finalizeEvent(eventTemplate, this.sk);
try {
const isVideo = Math.random() < 0.1; // 10% Video Post
return new Promise((resolve, reject) => {
this.pending.set(event.id, {
start: performance.now(),
resolve,
reject
});
try {
this.ws.send(JSON.stringify(['EVENT', event]));
} catch(e) {
this.pending.delete(event.id);
reject(e);
// 1. Build
const t0 = now();
let eventTemplate;
if (isVideo) {
eventTemplate = buildVideoPostEvent({
pubkey: this.pk,
created_at: Math.floor(now() / 1000),
dTagValue: `load-${this.id}-${now()}`,
content: {
version: 3,
title: `Load Test Video ${this.id}`,
videoRootId: `root-${this.id}-${now()}`
}
});
} else {
eventTemplate = buildViewEvent({
pubkey: this.pk,
created_at: Math.floor(now() / 1000),
pointerValue: `video-ref-${randomInt(1, 1000)}`,
pointerTag: ['e', 'hex-id'],
dedupeTag: `view:${this.id}:${now()}`
});
}
});
const t1 = now();
metrics.latencies.build.push(t1 - t0);
// 2. Sign
// Manually sign to measure time
const t2 = now();
if (NostrTools.finalizeEvent) {
const signed = NostrTools.finalizeEvent(eventTemplate, this.sk);
eventTemplate.id = signed.id;
eventTemplate.sig = signed.sig;
} else {
// Fallback for older nostr-tools or different bundle structure
eventTemplate.id = NostrTools.getEventHash(eventTemplate);
eventTemplate.sig = NostrTools.getSignature(eventTemplate, this.sk);
}
const t3 = now();
metrics.latencies.sign.push(t3 - t2);
// 3. Publish
const t4 = now();
this.pending.set(eventTemplate.id, t4);
this.ws.send(JSON.stringify(['EVENT', eventTemplate]));
metrics.eventsPublished++;
} catch (e) {
metrics.errors.push(`Client ${this.id} action failed: ${e.message}`);
}
}
close() {
if (this.ws) this.ws.close();
if (this.ws) this.ws.close();
}
}
// Stats
const stats = {
totalSent: 0,
totalReceived: 0,
errors: 0,
latencies: [],
relayMetrics: [] // { timestamp, cpu, memory }
};
async function getRelayMetrics(pid) {
if (!pid) return null;
return new Promise(resolve => {
exec(`ps -p ${pid} -o %cpu,%mem`, (err, stdout) => {
if (err) return resolve(null);
const lines = stdout.trim().split('\n');
if (lines.length < 2) return resolve(null);
const [cpu, mem] = lines[1].trim().split(/\s+/);
resolve({ cpu: parseFloat(cpu), mem: parseFloat(mem) });
});
});
}
// --- Main Loop ---
async function run() {
// Start Relay
let relayProc;
let relayPid;
console.log(`[LoadTest] Clients: ${CLIENT_COUNT}, Duration: ${DURATION_SEC}s`);
if (CONFIG.relayUrl.includes('localhost') || CONFIG.relayUrl.includes('127.0.0.1')) {
console.log('Starting local relay...');
relayProc = spawn('node', [CONFIG.relayScript], { cwd: REPO_ROOT, stdio: 'inherit' });
relayPid = relayProc.pid;
// Wait for relay
await new Promise(r => setTimeout(r, 2000));
} else {
console.log('Using external relay. Cannot monitor CPU/Mem directly.');
}
await startRelay();
// Create Clients
console.log(`Creating ${CONFIG.clients} clients...`);
const clients = [];
for (let i = 0; i < CONFIG.clients; i++) {
clients.push(new LoadClient(i, CONFIG.relayUrl));
console.log(`[LoadTest] Connecting ${CLIENT_COUNT} clients...`);
// Batch connect to avoid overwhelming
for (let i = 0; i < CLIENT_COUNT; i++) {
const client = new LoadClient(i);
clients.push(client);
client.connect(); // don't await individually to speed up, but maybe batch await
if (i % 50 === 0) await sleep(100);
}
// Connect Clients
console.log('Connecting clients...');
const connectStart = performance.now();
await Promise.all(clients.map(c => c.connect().catch(e => {
console.error(`Client ${c.id} failed to connect:`, e.message);
stats.errors++;
})));
console.log(`Connected in ${(performance.now() - connectStart).toFixed(2)}ms`);
// Wait for all to settle
await sleep(2000);
console.log(`[LoadTest] Connected: ${metrics.clientsConnected}`);
// Load Loop
console.log('Starting load generation...');
const endTime = Date.now() + CONFIG.duration * 1000;
const runningClients = clients.filter(c => c.connected);
// Loop
const endTime = now() + (DURATION_SEC * 1000);
const monitorInterval = setInterval(() => {
const elapsed = (now() - metrics.startTime) / 1000;
const cpuUsage = process.cpuUsage();
const memUsage = process.memoryUsage();
// Monitor Loop
const monitorInterval = setInterval(async () => {
if (relayPid) {
const metrics = await getRelayMetrics(relayPid);
if (metrics) {
stats.relayMetrics.push({ timestamp: Date.now(), ...metrics });
}
// Store metrics (CPU is since start, so we might want diff, but raw is fine for trend)
// Actually cpuUsage returns {user, system} in microseconds.
// memoryUsage returns {rss, heapTotal, heapUsed, external}.
metrics.resources.cpu.push({ timestamp: elapsed, ...cpuUsage });
metrics.resources.memory.push({ timestamp: elapsed, rss: memUsage.rss, heapUsed: memUsage.heapUsed });
const memMB = (memUsage.rss / 1024 / 1024).toFixed(1);
console.log(`[Stats] T+${elapsed.toFixed(0)}s | Pub: ${metrics.eventsPublished} | Conf: ${metrics.eventsConfirmed} | Fail: ${metrics.eventsFailed} | RTT avg: ${avg(metrics.latencies.rtt).toFixed(1)}ms | Sign avg: ${avg(metrics.latencies.sign).toFixed(2)}ms | Mem: ${memMB}MB`);
}, 5000);
while (now() < endTime) {
// Pick random clients to act
const batchSize = Math.max(1, Math.floor(CLIENT_COUNT / 10)); // 10% of clients act per tick
for (let k = 0; k < batchSize; k++) {
const client = clients[randomInt(0, clients.length - 1)];
client.publishAction();
}
}, 1000);
// Traffic Loop
const trafficPromises = runningClients.map(async (client) => {
while (Date.now() < endTime) {
const type = Math.random() > 0.8 ? 'VIDEO' : 'VIEW'; // 20% video, 80% view
let template;
try {
if (type === 'VIDEO') {
// Simulate multi-part (Video + NIP-71)
// For load test, we just publish the main video event.
// Properly linking NIP-71 requires more state, but we can publish Kind 30078.
const videoRootId = `load-${client.id}-${Date.now()}`;
template = buildVideoPostEvent({
pubkey: client.pk,
created_at: Math.floor(Date.now() / 1000),
dTagValue: videoRootId,
content: {
title: `Load Test Video ${Date.now()}`,
videoRootId: videoRootId,
version: 3
}
});
} else {
// View Event
template = buildViewEvent({
pubkey: client.pk,
created_at: Math.floor(Date.now() / 1000),
pointerValue: `30078:${client.pk}:test`,
pointerTag: ['a', `30078:${client.pk}:test`]
});
}
stats.totalSent++;
const latency = await client.publish(template);
stats.totalReceived++;
stats.latencies.push(latency);
} catch (e) {
stats.errors++;
// Backoff slightly on error
await new Promise(r => setTimeout(r, 100));
}
// Rate limiting
const delay = 1000 / CONFIG.rate + (Math.random() * 20 - 10);
await new Promise(r => setTimeout(r, Math.max(10, delay)));
}
});
await Promise.all(trafficPromises);
// Throttle loop
await sleep(100);
}
clearInterval(monitorInterval);
// Cleanup
console.log('[LoadTest] Stopping...');
clients.forEach(c => c.close());
if (relayProc) relayProc.kill();
if (relayProcess) relayProcess.kill();
// Report
console.log('Load test completed. Generating report...');
stats.latencies.sort((a, b) => a - b);
const p50 = stats.latencies[Math.floor(stats.latencies.length * 0.5)] || 0;
const p95 = stats.latencies[Math.floor(stats.latencies.length * 0.95)] || 0;
const p99 = stats.latencies[Math.floor(stats.latencies.length * 0.99)] || 0;
const avg = stats.latencies.reduce((a, b) => a + b, 0) / (stats.latencies.length || 1);
const report = {
config: CONFIG,
timestamp: new Date().toISOString(),
metrics: {
totalRequests: stats.totalSent,
successfulRequests: stats.totalReceived,
errors: stats.errors,
throughput: stats.totalReceived / CONFIG.duration,
latency: {
avg,
p50,
p95,
p99,
min: stats.latencies[0] || 0,
max: stats.latencies[stats.latencies.length - 1] || 0
},
relayResources: stats.relayMetrics
},
bottlenecks: [],
remediation: []
};
// Simple analysis
if (p99 > 200) {
report.bottlenecks.push('High P99 Latency (>200ms)');
report.remediation.push('Investigate relay event processing loop or event size overhead.');
}
if (stats.errors > 0) {
report.bottlenecks.push(`Error rate ${(stats.errors/stats.totalSent*100).toFixed(2)}%`);
report.remediation.push('Check relay logs for validation errors or connection limits.');
}
const reportPath = path.join(ARTIFACTS_DIR, `load-report-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
const reportPath = path.join(ARTIFACTS_DIR, `load-report-${new Date().toISOString().slice(0,10).replace(/-/g,'')}.json`);
const report = generateReport();
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(`Report saved to ${reportPath}`);
console.log(`[LoadTest] Report written to ${reportPath}`);
}
function avg(arr) {
if (arr.length === 0) return 0;
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
function p95(arr) {
if (arr.length === 0) return 0;
arr.sort((a, b) => a - b);
return arr[Math.floor(arr.length * 0.95)];
}
function generateReport() {
const duration = (now() - metrics.startTime) / 1000;
return {
summary: {
date: new Date().toISOString(),
duration_sec: duration,
clients: CLIENT_COUNT,
events_published: metrics.eventsPublished,
events_confirmed: metrics.eventsConfirmed,
events_failed: metrics.eventsFailed
},
metrics: {
throughput: {
avg_events_per_sec: metrics.eventsPublished / duration
},
latency_ms: {
build: {
avg: avg(metrics.latencies.build),
p95: p95(metrics.latencies.build)
},
sign: {
avg: avg(metrics.latencies.sign),
p95: p95(metrics.latencies.sign)
},
rtt: {
avg: avg(metrics.latencies.rtt),
p95: p95(metrics.latencies.rtt)
}
},
resources: {
cpu_samples: metrics.resources.cpu,
memory_samples: metrics.resources.memory
},
errors: metrics.errors.slice(0, 100) // Top 100
},
hot_functions: [
{ name: "buildEvent", avg_ms: avg(metrics.latencies.build) },
{ name: "signEvent", avg_ms: avg(metrics.latencies.sign) }
],
recommendations: [
"If signEvent latency > 5ms, consider offloading signing to Web Workers or using Wasm optimization.",
"If RTT is high but build/sign is low, network or relay processing is the bottleneck."
]
};
}
run().catch(console.error);
+35 -1
View File
@@ -55,6 +55,26 @@ async function reviewContext(contextName, options) {
report.push(`**Date:** ${new Date().toISOString()}`);
report.push('');
// Release Channel Guidance
if (options.baseBranch) {
report.push(`## ℹ️ Release Channel Guidance`);
if (options.baseBranch === 'main') {
report.push('**Target: Main (Production)**');
report.push('- Commits must be atomic.');
report.push('- No destructive migrations.');
report.push('- Feature flags must default to `false`.');
report.push('- Ensure UX and magnet safety guarantees are preserved.');
} else if (options.baseBranch === 'unstable') {
report.push('**Target: Unstable (Experimentation)**');
report.push('- Gate risky behavior behind feature flags (`js/constants.js`).');
report.push('- Document toggle/rollback plans in PR description.');
} else {
report.push(`Target: ${options.baseBranch}`);
report.push('Please refer to `AGENTS.md` for release channel guidance.');
}
report.push('');
}
let failed = false;
try {
@@ -258,7 +278,7 @@ async function main() {
}
log('Fetching open PRs...');
try {
const prsJson = spawnSync('gh', ['pr', 'list', '--json', 'number,headRefName,url'], { encoding: 'utf-8' });
const prsJson = spawnSync('gh', ['pr', 'list', '--json', 'number,headRefName,baseRefName,url'], { encoding: 'utf-8' });
if (prsJson.status !== 0) throw new Error(prsJson.stderr);
const prs = JSON.parse(prsJson.stdout);
log(`Found ${prs.length} open PRs.`);
@@ -267,6 +287,8 @@ async function main() {
log(`Checking out PR #${pr.number} (${pr.headRefName})...`);
try {
spawnSync('gh', ['pr', 'checkout', pr.number]);
// Pass baseBranch from PR details
options.baseBranch = pr.baseRefName;
const report = await reviewContext(`PR #${pr.number}`, options);
const tempFile = `pr_review_${pr.number}.md`;
fs.writeFileSync(tempFile, report);
@@ -288,8 +310,16 @@ async function main() {
}
log(`Checking out PR #${options.pr}...`);
try {
// Fetch base branch first
const viewRes = spawnSync('gh', ['pr', 'view', options.pr, '--json', 'baseRefName'], { encoding: 'utf-8' });
if (viewRes.status === 0) {
const prDetails = JSON.parse(viewRes.stdout);
options.baseBranch = prDetails.baseRefName;
}
const res = spawnSync('gh', ['pr', 'checkout', options.pr], { encoding: 'utf-8' });
if (res.status !== 0) throw new Error(res.stderr);
const report = await reviewContext(`PR #${options.pr}`, options);
console.log(report);
fs.writeFileSync(LOG_FILE, report);
@@ -312,6 +342,10 @@ async function main() {
}
const currentBranch = spawnSync('git', ['branch', '--show-current'], { encoding: 'utf-8' }).stdout.trim();
// Logic for local run: can we guess the target?
// Usually local run is 'against' something.
// But we can just skip or default.
const report = await reviewContext(currentBranch, options);
console.log('\n--- REPORT ---\n');
+1 -1
View File
@@ -1,6 +1,6 @@
import { WebSocketServer } from 'ws';
const PORT = 8008;
const PORT = process.env.PORT || 8008;
const wss = new WebSocketServer({ port: PORT });
// Simple in-memory store for REQ matching
+156 -110
View File
@@ -22,37 +22,55 @@ function log(message) {
fs.appendFileSync(LOG_FILE, line + '\n');
}
async function startServer() {
log('Starting local server (python3)...');
const serverProcess = spawn('python3', ['-m', 'http.server', '8000'], {
async function startProcess(command, args, name) {
log(`Starting ${name}...`);
const proc = spawn(command, args, {
cwd: ROOT_DIR,
stdio: 'ignore'
stdio: 'pipe', // Capture output
env: process.env
});
proc.stdout.on('data', (data) => {
log(`[${name}] ${data.toString().trim()}`);
});
proc.stderr.on('data', (data) => {
log(`[${name} ERR] ${data.toString().trim()}`);
});
proc.on('error', (err) => {
log(`[${name}] Failed to start: ${err.message}`);
});
// Give it a moment to start
await new Promise(resolve => setTimeout(resolve, 2000));
return serverProcess;
return proc;
}
async function run() {
let serverProcess;
let relayProcess;
let browser;
let exitCode = 0;
try {
serverProcess = await startServer();
// 1. Start Relay
relayProcess = await startProcess('node', ['scripts/agent/simple-relay.mjs'], 'LocalRelay');
// 2. Start Web Server
serverProcess = await startProcess('python3', ['-m', 'http.server', '8000'], 'WebServer');
log('Launching browser...');
browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
page.on('console', msg => {
const type = msg.type();
const text = msg.text();
// Filter out some noise if needed, but keeping it is safer for debugging
if (msg.type() === 'error') {
log(`BROWSER ERROR: ${text}`);
} else {
// log(`BROWSER: ${text}`); // Optional verbose logging
if (text.startsWith('[SmokeTest]')) {
log(text);
} else if (type === 'error') {
log(`BROWSER ERROR: ${text}`);
}
});
@@ -60,158 +78,189 @@ async function run() {
log(`PAGE ERROR: ${err.message}`);
});
log('Navigating to app...');
await page.goto('http://localhost:8000');
log('Navigating to http://127.0.0.1:8000 ...');
await page.goto('http://127.0.0.1:8000');
// Wait for app to load (checking for #app or similar)
await page.waitForSelector('#app', { timeout: 10000 });
log('App loaded.');
// Wait for App and NostrTools
log('Waiting for app and NostrTools...');
await page.waitForFunction(() => window.NostrTools && document.querySelector('#app'), { timeout: 15000 });
log('App loaded and NostrTools available.');
// Inject test logic
log('Running in-page smoke test...');
log('Running in-page smoke test logic...');
const result = await page.evaluate(async () => {
const logs = [];
function pageLog(m) { logs.push(m); console.log('[SmokeTest] ' + m); }
function pageLog(m) {
const msg = `[SmokeTest] ${m}`;
console.log(msg);
logs.push(msg);
}
try {
// 1. Import Client
pageLog('Importing nostrClient...');
const RELAY_URL = 'ws://localhost:8008';
pageLog(`Test Relay: ${RELAY_URL}`);
// 1. Import Modules
const { nostrClient } = await import('./js/nostrClientFacade.js');
const { NostrTools } = window; // Assumed to be loaded via script tag in index.html
const { decryptDM } = await import('./js/dmDecryptor.js');
const NostrTools = window.NostrTools;
if (!nostrClient) throw new Error('nostrClient not found');
if (!NostrTools) throw new Error('NostrTools not found on window');
if (!nostrClient) throw new Error('nostrClient import failed');
if (!decryptDM) throw new Error('decryptDM import failed');
// Override relays to a specific subset to avoid spamming all defaults if local relay is not available
// Ideally this would be a local relay, but we use a public one for the smoke test as fallback.
const TEST_RELAY = 'wss://relay.damus.io';
pageLog(`Configuring nostrClient to use ${TEST_RELAY}...`);
// If nostrClient is already initialized, we might need to reconnect or just update properties
nostrClient.relays = [TEST_RELAY];
nostrClient.writeRelays = [TEST_RELAY];
nostrClient.readRelays = [TEST_RELAY];
// 2. Configure Client
pageLog('Configuring nostrClient...');
nostrClient.relays = [RELAY_URL];
nostrClient.writeRelays = [RELAY_URL];
nostrClient.readRelays = [RELAY_URL];
// Force connection logic if needed, but adding to relays list usually triggers connection attempt in client
// Check pool if available
if (nostrClient.pool) {
// Ensure connection to our test relay
await nostrClient.pool.ensureRelay(TEST_RELAY);
await nostrClient.pool.ensureRelay(RELAY_URL);
}
// Wait a bit for connection
// Wait for connection
pageLog('Waiting for relay connection...');
await new Promise(resolve => setTimeout(resolve, 3000));
await new Promise(r => setTimeout(r, 1000));
// 2. Generate Identity
pageLog('Generating ephemeral identity...');
// 3. Generate Identity & Login
pageLog('Generating ephemeral keys...');
const bytesToHex = (bytes) => {
// Helper since NostrTools.bytesToHex might be missing depending on version/bundle
function toHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
};
let privateKey;
if (NostrTools.generateSecretKey) {
const secret = NostrTools.generateSecretKey();
if (secret instanceof Uint8Array) {
privateKey = bytesToHex(secret);
} else {
privateKey = secret;
}
} else {
privateKey = NostrTools.generatePrivateKey();
}
const pubkey = NostrTools.getPublicKey(privateKey);
pageLog(`Generated pubkey: ${pubkey}`);
const skBytes = NostrTools.generateSecretKey();
const sk = toHex(skBytes);
const pk = NostrTools.getPublicKey(skBytes);
pageLog(`Generated Pubkey: ${pk}`);
// 3. Login
pageLog('Logging in...');
await nostrClient.registerPrivateKeySigner({ privateKey, pubkey });
await nostrClient.registerPrivateKeySigner({ privateKey: sk, pubkey: pk });
pageLog('Logged in.');
// 4. Publish Video
pageLog('Publishing video...');
pageLog('Publishing VIDEO_POST...');
const videoRootId = `smoke-${Date.now()}`;
const videoPayload = {
legacyFormData: { // extractVideoPublishPayload expects this structure or direct fields
legacyFormData: {
title: `Smoke Test Video ${Date.now()}`,
description: 'Automated smoke test artifact',
url: 'https://example.com/video.mp4',
magnet: 'magnet:?xt=urn:btih:c9e15763f722f23e98ce29a8c0c4000000000000&dn=test', // Valid-ish magnet
description: 'Smoke test artifact',
url: 'https://example.com/smoke.mp4',
magnet: 'magnet:?xt=urn:btih:c9e15763f722f23e98ce29a8c0c4000000000000&dn=smoke',
mimeType: 'video/mp4',
isForKids: false,
isNsfw: false,
isPrivate: true,
isPrivate: true
},
fileSha256: '0000000000000000000000000000000000000000000000000000000000000000'
};
// Note: publishVideo takes (videoPayload, pubkey)
const event = await nostrClient.publishVideo(videoPayload, pubkey);
pageLog(`Video published! ID: ${event.id}`);
const pubEvent = await nostrClient.publishVideo(videoPayload, pk);
if (!pubEvent || !pubEvent.id) throw new Error('Publish failed, no event returned');
pageLog(`Video published: ${pubEvent.id}`);
// 5. Verify Read Back
pageLog('Verifying read back...');
// We can use getEventById
const fetched = await nostrClient.getEventById(event.id);
if (!fetched) throw new Error('Failed to fetch published video');
if (fetched.id !== event.id) throw new Error('Fetched ID mismatch');
pageLog('Read back successful.');
// 5. Verify Video Readback
pageLog('Verifying video readback...');
await new Promise(r => setTimeout(r, 500)); // Propagate
const fetched = await nostrClient.getEventById(pubEvent.id);
if (!fetched || fetched.id !== pubEvent.id) throw new Error('Failed to fetch video event');
pageLog('Video verified.');
// 6. DM Flow
pageLog('Testing DM flow (Self-DM)...');
// We'll send a DM to ourselves
const npub = NostrTools.nip19.npubEncode(pubkey);
const message = `Smoke Test DM ${Date.now()}`;
// 6. DM Flow (Self-DM)
pageLog('Testing DM flow (Self-Send)...');
const message = `Smoke DM ${Date.now()}`;
const npub = NostrTools.nip19.npubEncode(pk);
const dmResult = await nostrClient.sendDirectMessage(npub, message);
if (!dmResult.ok) throw new Error(`DM Send failed: ${dmResult.error}`);
pageLog('DM Sent.');
const dmRes = await nostrClient.sendDirectMessage(npub, message);
if (!dmRes.ok) throw new Error(`DM Send failed: ${dmRes.error}`);
pageLog('DM sent.');
// Decrypt
// We need to fetch it. listDirectMessages
pageLog('Fetching and decrypting DMs...');
// Allow some time for propagation to relay and back
await new Promise(resolve => setTimeout(resolve, 2000));
// 7. Fetch & Decrypt
pageLog('Fetching DM...');
await new Promise(r => setTimeout(r, 500));
const dms = await nostrClient.listDirectMessages(pubkey, { limit: 10 });
// We use listDirectMessages which handles fetching and decrypting usually,
// BUT the prompt asks to "verify decryption via js/dmDecryptor.js".
// `nostrClient.listDirectMessages` might use `dmDecryptor` internally or have its own logic.
// Let's manually fetch the raw event and decrypt it to be sure we are testing `dmDecryptor.js`.
const match = dms.find(r => r.content === message || r.plaintext === message);
const filter = {
kinds: [4, 1059],
'#p': [pk],
limit: 10
};
const events = await nostrClient.pool.list([RELAY_URL], [filter]);
const targetEvent = events.find(e => e.pubkey === pk); // Sent by self
if (!match) {
pageLog('DM not found in list. Fetched count: ' + dms.length);
console.log('DMs:', JSON.stringify(dms));
throw new Error('DM verification failed');
if (!targetEvent) throw new Error('DM event not found on relay');
pageLog(`Found DM event kind ${targetEvent.kind}`);
// Setup Decrypt Context
const decryptors = [];
// NIP-44
if (NostrTools.nip44) {
decryptors.push({
scheme: 'nip44',
decrypt: (pk, ct) => NostrTools.nip44.decrypt(sk, pk, ct),
priority: 1,
supportsGiftWrap: true
});
}
// NIP-04
if (NostrTools.nip04) {
decryptors.push({
scheme: 'nip04',
decrypt: async (pk, ct) => NostrTools.nip04.decrypt(sk, pk, ct),
priority: 0
});
}
pageLog('DM Verified.');
const context = {
actorPubkey: pk,
decryptors
};
pageLog('Decrypting...');
const decrypted = await decryptDM(targetEvent, context);
if (!decrypted.ok) {
console.error('Decryption errors:', decrypted.errors);
throw new Error('Decryption failed');
}
if (decrypted.plaintext !== message) {
throw new Error(`Message mismatch: Got "${decrypted.plaintext}", expected "${message}"`);
}
pageLog('Decryption verified!');
return { success: true, logs };
} catch (e) {
pageLog(`ERROR: ${e.message}`);
pageLog(`FATAL: ${e.message}`);
return { success: false, error: e.message, stack: e.stack, logs };
}
});
if (result.success) {
log('Smoke Test Passed!');
result.logs.forEach(l => log(`[PAGE] ${l}`));
log('SMOKE TEST PASSED');
const summary = {
timestamp: new Date().toISOString(),
status: 'success',
steps: ['login', 'publish', 'dm_send', 'dm_decrypt'],
logs: result.logs
};
fs.writeFileSync(path.join(ARTIFACTS_DIR, `smoke-summary-${DATE_STR}.json`), JSON.stringify(summary, null, 2));
} else {
log('Smoke Test Failed!');
result.logs.forEach(l => log(`[PAGE] ${l}`));
log('SMOKE TEST FAILED');
log(`Error: ${result.error}`);
log(`Stack: ${result.stack}`);
const screenshotPath = path.join(ARTIFACTS_DIR, `failure-${DATE_STR}.png`);
const screenshotPath = path.join(ARTIFACTS_DIR, `smoke-fail-${DATE_STR}.png`);
await page.screenshot({ path: screenshotPath });
log(`Screenshot saved to ${screenshotPath}`);
log(`Screenshot saved: ${screenshotPath}`);
const summary = {
timestamp: new Date().toISOString(),
@@ -220,20 +269,17 @@ async function run() {
logs: result.logs
};
fs.writeFileSync(path.join(ARTIFACTS_DIR, `smoke-summary-${DATE_STR}.json`), JSON.stringify(summary, null, 2));
exitCode = 1;
}
} catch (err) {
log(`CRITICAL ERROR: ${err.message}`);
log(err.stack);
log(`SCRIPT ERROR: ${err.message}`);
console.error(err);
exitCode = 1;
} finally {
if (browser) await browser.close();
if (serverProcess) {
serverProcess.kill();
log('Server stopped.');
}
if (serverProcess) serverProcess.kill();
if (relayProcess) relayProcess.kill();
}
process.exit(exitCode);
+7
View File
@@ -39,6 +39,13 @@ const VALUE_ALLOWLIST = new Map([
(value) => value === "767.98px",
],
],
[
"js/ui/components/VideoCard.js",
[
// Desktop breakpoint used in media query match
(value) => value === "1024px",
],
],
]);
const MARKUP_EXTENSIONS = new Set([".html", ".md", ".mdx", ".markdown", ".njk", ".nunjucks"]);
+115 -26
View File
@@ -7,6 +7,17 @@ const rootDir = path.dirname(fileURLToPath(new URL("../package.json", import.met
const testsDir = path.join(rootDir, "tests");
const testFiles = [];
const setupImport = new URL("../tests/test-helpers/setup-localstorage.mjs", import.meta.url);
const shardArg = process.argv.find((arg) => arg.startsWith("--shard="));
const shardValue = shardArg ? shardArg.split("=")[1] : process.env.UNIT_TEST_SHARD;
const timeoutMs = process.env.UNIT_TEST_TIMEOUT_MS
? Number(process.env.UNIT_TEST_TIMEOUT_MS)
: null;
if (timeoutMs !== null && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
throw new Error(
"UNIT_TEST_TIMEOUT_MS must be a positive number representing milliseconds.",
);
}
async function collectTests(dir) {
const entries = await readdir(dir, { withFileTypes: true });
@@ -40,42 +51,120 @@ async function collectTests(dir) {
}
}
await collectTests(testsDir);
function parseShard(value) {
if (!value) {
return null;
}
testFiles.sort((a, b) => a.localeCompare(b));
const match = /^(\d+)\/(\d+)$/.exec(value.trim());
if (!match) {
throw new Error("Shard format must be <index>/<total>, e.g. 1/3.");
}
if (testFiles.length === 0) {
console.log("No unit tests found.");
process.exit(0);
const index = Number(match[1]);
const total = Number(match[2]);
if (!Number.isInteger(index) || !Number.isInteger(total) || total < 1) {
throw new Error("Shard values must be positive integers.");
}
if (index < 1 || index > total) {
throw new Error("Shard index must be between 1 and total (inclusive).");
}
return { index, total };
}
for (const testFile of testFiles) {
const relativePath = path.relative(rootDir, testFile);
console.log(`\n→ Running ${relativePath}`);
async function run() {
await collectTests(testsDir);
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ["--import", setupImport.href, testFile], {
stdio: "inherit",
});
testFiles.sort((a, b) => a.localeCompare(b));
child.on("close", (code, signal) => {
if (signal) {
reject(new Error(`${relativePath} exited with signal ${signal}`));
return;
if (testFiles.length === 0) {
console.log("No unit tests found.");
return;
}
const shard = parseShard(shardValue);
const selectedTests = shard
? testFiles.filter((_, index) => index % shard.total === shard.index - 1)
: testFiles;
if (shard) {
console.log(
`Using shard ${shard.index}/${shard.total} (${selectedTests.length} of ${testFiles.length} files).`,
);
}
if (selectedTests.length === 0) {
console.log("No unit tests found for this shard.");
return;
}
for (const testFile of selectedTests) {
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",
});
let finished = false;
let timeoutId = null;
const finalize = (error) => {
if (finished) {
return;
}
finished = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
if (error) {
reject(error);
return;
}
resolve();
};
if (timeoutMs !== null) {
timeoutId = setTimeout(() => {
child.kill("SIGKILL");
finalize(
new Error(`${relativePath} timed out after ${timeoutMs}ms and was aborted.`),
);
}, timeoutMs);
}
if (code !== 0) {
reject(new Error(`${relativePath} failed with exit code ${code}`));
return;
}
child.on("close", (code, signal) => {
if (signal) {
finalize(new Error(`${relativePath} exited with signal ${signal}`));
return;
}
resolve();
});
if (code !== 0) {
finalize(new Error(`${relativePath} failed with exit code ${code}`));
return;
}
child.on("error", (error) => {
reject(error);
finalize();
});
child.on("error", (error) => {
finalize(error);
});
});
});
}
console.log("\n✔ All unit tests passed");
}
console.log("\n✔ All unit tests passed");
try {
await run();
} catch (error) {
console.error(`\n✖ ${error.message}`);
process.exit(1);
}
@@ -18,6 +18,7 @@ const VIEW_HTML = `<!DOCTYPE html>
</div>
<a id="subscriptionsLink" class="hidden"></a>
<a id="forYouLink" class="hidden"></a>
<a id="exploreLink" class="hidden"></a>
</body>
</html>`;
@@ -46,6 +47,7 @@ test("hydrateSidebarNavigation reveals chrome controls for authenticated viewers
app.userStatus = window.document.getElementById("userStatus");
app.userPubKey = window.document.getElementById("userPubKey");
app.subscriptionsLink = null;
app.exploreLink = null;
app.isUserLoggedIn = () => true;
app.log = () => {};
app.showError = () => {};
@@ -60,6 +62,7 @@ test("hydrateSidebarNavigation reveals chrome controls for authenticated viewers
const logoutButton = window.document.getElementById("logoutButton");
const subscriptionsLink = window.document.getElementById("subscriptionsLink");
const forYouLink = window.document.getElementById("forYouLink");
const exploreLink = window.document.getElementById("exploreLink");
const closeLoginButton = window.document.getElementById("closeLoginModal");
assert.equal(app.uploadButton, uploadButton);
@@ -77,6 +80,7 @@ test("hydrateSidebarNavigation reveals chrome controls for authenticated viewers
assert.equal(logoutButton.classList.contains("hidden"), false);
assert.equal(subscriptionsLink.classList.contains("hidden"), false);
assert.equal(forYouLink.classList.contains("hidden"), false);
assert.equal(exploreLink.classList.contains("hidden"), false);
assert.ok(app.appChromeController.receivedElements);
assert.equal(
@@ -123,6 +127,7 @@ test("hydrateSidebarNavigation hides chrome controls for logged-out viewers", ()
app.userStatus = window.document.getElementById("userStatus");
app.userPubKey = window.document.getElementById("userPubKey");
app.subscriptionsLink = null;
app.exploreLink = null;
app.isUserLoggedIn = () => false;
app.log = () => {};
app.showError = () => {};
@@ -137,6 +142,7 @@ test("hydrateSidebarNavigation hides chrome controls for logged-out viewers", ()
const logoutButton = window.document.getElementById("logoutButton");
const subscriptionsLink = window.document.getElementById("subscriptionsLink");
const forYouLink = window.document.getElementById("forYouLink");
const exploreLink = window.document.getElementById("exploreLink");
const closeLoginButton = window.document.getElementById("closeLoginModal");
assert.equal(app.uploadButton, uploadButton);
@@ -154,6 +160,7 @@ test("hydrateSidebarNavigation hides chrome controls for logged-out viewers", ()
assert.equal(logoutButton.classList.contains("hidden"), true);
assert.equal(subscriptionsLink.classList.contains("hidden"), true);
assert.equal(forYouLink.classList.contains("hidden"), true);
assert.equal(exploreLink.classList.contains("hidden"), true);
assert.ok(app.appChromeController.receivedElements);
assert.equal(
+6 -2
View File
@@ -184,8 +184,12 @@ test("handleModerationBlock requests a block, clears overrides, and refreshes hi
const originalIsBlocked = userBlocks.isBlocked;
const ensureLoadedMock = mock.fn(async () => {});
const addBlockMock = mock.fn(async () => ({ ok: true }));
const isBlockedMock = mock.fn(() => false);
let blockedState = false;
const addBlockMock = mock.fn(async () => {
blockedState = true;
return { ok: true };
});
const isBlockedMock = mock.fn(() => blockedState);
userBlocks.ensureLoaded = ensureLoadedMock;
userBlocks.addBlock = addBlockMock;
+71 -70
View File
@@ -2,16 +2,48 @@ import { expect, test } from "@playwright/test";
import type { Locator } from "@playwright/test";
import { applyReducedMotion, failOnConsoleErrors } from "./helpers/uiTestUtils";
test.describe("popover layout scenarios", () => {
test.beforeEach(async ({ page }) => {
await applyReducedMotion(page);
failOnConsoleErrors(page);
await page.goto("/docs/popover-scenarios.html", { waitUntil: "networkidle" });
});
async function getPanelMetrics(locator: Locator) {
return locator.evaluate((node) => {
const rect = node.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const tokenValue = window
.getComputedStyle(document.documentElement)
.getPropertyValue("--popover-inline-safe-max")
.trim();
const popoverMaxWidth = window
.getComputedStyle(node)
.getPropertyValue("--popover-max-width")
.trim();
async function getPanelMetrics(locator: Locator) {
return locator.evaluate((node) => {
return {
rect: {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
},
viewport: { width: viewportWidth, height: viewportHeight },
placement: node.dataset.popoverPlacement || "",
state: node.dataset.popoverState || "",
popoverMaxWidth,
tokenMaxWidth: tokenValue,
};
});
}
async function getPanelWithTriggerMetrics(panel: Locator, trigger: Locator) {
const triggerHandle = await trigger.elementHandle();
if (!triggerHandle) {
throw new Error("Trigger element is not attached");
}
try {
return await panel.evaluate((node, triggerElement) => {
const rect = node.getBoundingClientRect();
const triggerRect = triggerElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const tokenValue = window
@@ -32,75 +64,43 @@ test.describe("popover layout scenarios", () => {
width: rect.width,
height: rect.height,
},
triggerRect: {
top: triggerRect.top,
left: triggerRect.left,
right: triggerRect.right,
bottom: triggerRect.bottom,
width: triggerRect.width,
height: triggerRect.height,
},
viewport: { width: viewportWidth, height: viewportHeight },
placement: node.dataset.popoverPlacement || "",
state: node.dataset.popoverState || "",
popoverMaxWidth,
tokenMaxWidth: tokenValue,
};
});
}, triggerHandle);
} finally {
await triggerHandle.dispose();
}
}
async function getPanelWithTriggerMetrics(panel: Locator, trigger: Locator) {
const triggerHandle = await trigger.elementHandle();
if (!triggerHandle) {
throw new Error("Trigger element is not attached");
}
function assertWithinViewport(metrics: {
rect: { top: number; left: number; right: number; bottom: number };
viewport: { width: number; height: number };
}) {
const tolerance = 0.5;
expect(metrics.rect.left).toBeGreaterThanOrEqual(-tolerance);
expect(metrics.rect.top).toBeGreaterThanOrEqual(-tolerance);
expect(metrics.rect.right).toBeLessThanOrEqual(metrics.viewport.width + tolerance);
expect(metrics.rect.bottom).toBeLessThanOrEqual(metrics.viewport.height + tolerance);
}
try {
return await panel.evaluate((node, triggerElement) => {
const rect = node.getBoundingClientRect();
const triggerRect = triggerElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const tokenValue = window
.getComputedStyle(document.documentElement)
.getPropertyValue("--popover-inline-safe-max")
.trim();
const popoverMaxWidth = window
.getComputedStyle(node)
.getPropertyValue("--popover-max-width")
.trim();
return {
rect: {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
},
triggerRect: {
top: triggerRect.top,
left: triggerRect.left,
right: triggerRect.right,
bottom: triggerRect.bottom,
width: triggerRect.width,
height: triggerRect.height,
},
viewport: { width: viewportWidth, height: viewportHeight },
placement: node.dataset.popoverPlacement || "",
state: node.dataset.popoverState || "",
popoverMaxWidth,
tokenMaxWidth: tokenValue,
};
}, triggerHandle);
} finally {
await triggerHandle.dispose();
}
}
function assertWithinViewport(metrics: {
rect: { top: number; left: number; right: number; bottom: number };
viewport: { width: number; height: number };
}) {
const tolerance = 0.5;
expect(metrics.rect.left).toBeGreaterThanOrEqual(-tolerance);
expect(metrics.rect.top).toBeGreaterThanOrEqual(-tolerance);
expect(metrics.rect.right).toBeLessThanOrEqual(metrics.viewport.width + tolerance);
expect(metrics.rect.bottom).toBeLessThanOrEqual(metrics.viewport.height + tolerance);
}
test.describe("popover layout scenarios", () => {
test.beforeEach(async ({ page }) => {
await applyReducedMotion(page);
failOnConsoleErrors(page);
await page.goto("/docs/popover-scenarios.html", { waitUntil: "networkidle" });
});
test("keeps the bottom-right grid menu inside the viewport", async ({ page }) => {
await page.locator('[data-test-trigger="grid-bottom-right"]').click();
@@ -198,7 +198,8 @@ test.describe("popover demo alignments", () => {
assertWithinViewport(metrics);
expect(metrics.state).toBe("open");
expect(metrics.placement).toBe("bottom-end");
expect(metrics.inlineMaxWidth).toBe(metrics.tokenMaxWidth);
expect(metrics.popoverMaxWidth).toBe(metrics.tokenMaxWidth);
expect(metrics).toHaveProperty("popoverMaxWidth");
const tolerance = 1.5;
expect(Math.abs(metrics.rect.right - metrics.triggerRect.right)).toBeLessThanOrEqual(tolerance);
@@ -220,7 +221,7 @@ test.describe("popover demo alignments", () => {
assertWithinViewport(metrics);
expect(metrics.state).toBe("open");
expect(metrics.placement).toBe("bottom-end");
expect(metrics.inlineMaxWidth).toBe(metrics.tokenMaxWidth);
expect(metrics.popoverMaxWidth).toBe(metrics.tokenMaxWidth);
const tolerance = 1.5;
expect(Math.abs(metrics.rect.right - metrics.triggerRect.right)).toBeLessThanOrEqual(tolerance);
+20 -4
View File
@@ -6,6 +6,18 @@ import {
} from "./helpers/uiTestUtils";
test.describe("login modal flows", () => {
test("preflight check for asset availability", async ({ request }) => {
const assets = [
"/assets/svg/default-profile.svg",
"/components/iframe_forms/iframe-application-form.html",
];
for (const asset of assets) {
const response = await request.get(asset);
expect(response.status(), `Asset ${asset} should be available`).toBe(200);
}
});
test.beforeEach(async ({ page }) => {
await applyReducedMotion(page);
failOnConsoleErrors(page);
@@ -195,10 +207,14 @@ test.describe("component modal pages", () => {
if (closeSelector) {
await page.locator(closeSelector).first().click();
} else {
expect(
false,
`Modal page ${pageName} is missing a close/cancel control.`,
).toBeTruthy();
// lockdown-modal is an exception to the rule as it is designed to be inescapable.
if (pageName !== "lockdown-modal") {
expect(
false,
`Modal page ${pageName} is missing a close/cancel control.`,
).toBeTruthy();
}
await page.evaluate(() => {
const element = document.querySelector(".bv-modal");
if (!element) {
+274
View File
@@ -0,0 +1,274 @@
import test, { beforeEach, afterEach } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFile } from 'node:fs/promises';
import { JSDOM } from 'jsdom';
import LoginModalController from '../js/ui/loginModalController.js';
const loginModalHtml = await readFile(
new URL('../components/login-modal.html', import.meta.url),
'utf8',
);
let dom;
let container;
let controller;
async function waitForAnimationFrame(window, cycles = 1) {
for (let i = 0; i < cycles; i += 1) {
await new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
}
}
function createMatchMediaList(query, initialMatches = false) {
let matches = Boolean(initialMatches);
const listeners = new Set();
const notify = () => {
const event = { matches, media: query, type: 'change' };
listeners.forEach((listener) => {
try {
listener(event);
} catch {}
});
};
return {
get matches() {
return matches;
},
set matches(value) {
matches = Boolean(value);
},
media: query,
addEventListener(type, listener) {
if (type === 'change' && typeof listener === 'function') {
listeners.add(listener);
}
},
removeEventListener(type, listener) {
if (type === 'change' && typeof listener === 'function') {
listeners.delete(listener);
}
},
addListener(listener) {
if (typeof listener === 'function') {
listeners.add(listener);
}
},
removeListener(listener) {
if (typeof listener === 'function') {
listeners.delete(listener);
}
},
dispatchEvent(event) {
if (!event || event.type !== 'change') {
return true;
}
listeners.forEach((listener) => {
try {
listener(event);
} catch {}
});
return true;
},
setMatches(value) {
matches = Boolean(value);
notify();
},
};
}
beforeEach(() => {
dom = new JSDOM(
'<!DOCTYPE html><html><body><div id="modalMount"></div></body></html>',
{
url: 'https://example.com',
pretendToBeVisual: true,
},
);
global.window = dom.window;
global.document = dom.window.document;
global.HTMLElement = dom.window.HTMLElement;
global.HTMLButtonElement = dom.window.HTMLButtonElement;
global.HTMLInputElement = dom.window.HTMLInputElement;
global.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
global.HTMLFormElement = dom.window.HTMLFormElement;
global.HTMLTemplateElement = dom.window.HTMLTemplateElement;
global.Node = dom.window.Node;
global.Element = dom.window.Element;
global.MutationObserver = dom.window.MutationObserver;
window.requestAnimationFrame = (callback) => callback(0);
global.requestAnimationFrame = window.requestAnimationFrame;
window.__matchMediaMocks = [];
window.matchMedia = (query) => {
const mql = createMatchMediaList(query, false);
window.__matchMediaMocks.push(mql);
return mql;
};
global.matchMedia = window.matchMedia;
container = document.getElementById('modalMount');
container.innerHTML = loginModalHtml;
});
afterEach(() => {
if (controller) {
controller.destroy();
controller = null;
}
delete global.window;
delete global.document;
delete global.HTMLElement;
delete global.HTMLButtonElement;
delete global.HTMLInputElement;
delete global.HTMLTextAreaElement;
delete global.HTMLFormElement;
delete global.HTMLTemplateElement;
delete global.Node;
delete global.Element;
delete global.MutationObserver;
delete global.requestAnimationFrame;
delete global.matchMedia;
if (dom) {
dom.window.close();
dom = null;
}
});
function createController(options = {}) {
const modalElement = container.querySelector('#loginModal');
return new LoginModalController({
modalElement,
providers: options.providers || [],
services: options.services || {},
callbacks: options.callbacks || {},
});
}
test('LoginModalController shows custom error for empty nsec input', async (t) => {
const providers = [{ id: 'nsec', label: 'Secret Key', login: async () => {} }];
const authService = { requestLogin: async () => {} };
controller = createController({ providers, services: { authService } });
// Simulate clicking the nsec provider button
// This requires us to inspect internal state or simulate events
// But since `promptForNsecOptions` is async and returns a promise, we can call it if we can access the instance.
// However, `handleProviderSelection` is what we usually trigger.
// Let's use internal methods to render providers first
controller.initialize();
const providerButton = container.querySelector('[data-provider-id="nsec"]');
assert.ok(providerButton, 'nsec provider button should be present');
// Trigger click on provider
const clickEvent = new dom.window.Event('click', { bubbles: true });
providerButton.dispatchEvent(clickEvent);
// Wait for the nsec form to appear
await waitForAnimationFrame(window, 5);
const form = container.querySelector('[data-nsec-form]');
assert.ok(form, 'nsec form should be present');
const secretInput = form.querySelector('[data-nsec-secret]');
assert.ok(secretInput, 'secret input should be present');
assert.ok(!secretInput.hasAttribute('required'), 'secret input should NOT have required attribute');
assert.equal(secretInput.required, false, 'secret input property required should be false');
const submitButton = form.querySelector('[data-nsec-submit]');
assert.ok(submitButton, 'submit button should be present');
const errorNode = form.querySelector('[data-nsec-error]');
assert.ok(errorNode, 'error node should be present');
assert.ok(errorNode.classList.contains('hidden'), 'error node should be initially hidden');
// Submit with empty form
const submitEvent = new dom.window.Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
// Wait for validation
await waitForAnimationFrame(window, 2);
assert.ok(!errorNode.classList.contains('hidden'), 'error node should be visible after empty submit');
assert.equal(errorNode.textContent, 'Paste an nsec, hex key, or mnemonic to continue.');
});
test('LoginModalController does not set required attribute when toggling modes', async (t) => {
const providers = [{ id: 'nsec', label: 'Secret Key', login: async () => {} }];
const storedMetadata = { hasEncryptedKey: true };
const nostrClient = {
getStoredSessionActorMetadata: () => storedMetadata
};
const authService = { requestLogin: async () => {} };
controller = createController({
providers,
services: { nostrClient, authService }
});
controller.initialize();
const providerButton = container.querySelector('[data-provider-id="nsec"]');
providerButton.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
await waitForAnimationFrame(window, 5);
const form = container.querySelector('[data-nsec-form]');
const unlockRadio = form.querySelector('[data-nsec-mode="unlock"]');
const importRadio = form.querySelector('[data-nsec-mode="import"]');
const secretInput = form.querySelector('[data-nsec-secret]');
const unlockPassphrase = form.querySelector('[data-nsec-unlock-passphrase]');
// Initially should be in unlock mode because hasEncryptedKey is true
assert.ok(unlockRadio.checked);
assert.equal(secretInput.disabled, true);
assert.equal(secretInput.required, false);
// Unlock passphrase should be enabled but NOT required (because we want custom validation)
assert.equal(unlockPassphrase.disabled, false);
assert.equal(unlockPassphrase.required, false);
// Switch to import mode
importRadio.click();
unlockRadio.checked = false;
importRadio.checked = true;
importRadio.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
await waitForAnimationFrame(window, 2);
// Secret input should be enabled and NOT required
assert.equal(secretInput.disabled, false);
assert.equal(secretInput.required, false);
// Unlock passphrase should be disabled
assert.equal(unlockPassphrase.disabled, true);
assert.equal(unlockPassphrase.required, false);
});
test('LoginModalController shows NIP-46 handshake panel', async (t) => {
const providers = [{ id: 'nip46', label: 'Remote Signer', login: async () => {} }];
const authService = { requestLogin: async () => {} };
controller = createController({ providers, services: { authService } });
controller.initialize();
const providerButton = container.querySelector('[data-provider-id="nip46"]');
assert.ok(providerButton, 'nip46 provider button should be present');
// Trigger click on provider
const clickEvent = new dom.window.Event('click', { bubbles: true });
providerButton.dispatchEvent(clickEvent);
// Wait for the nip46 form to appear
await waitForAnimationFrame(window, 5);
const form = container.querySelector('[data-nip46-form]');
assert.ok(form, 'nip46 form should be present');
const handshakePanel = form.querySelector('[data-nip46-handshake-panel]');
assert.ok(handshakePanel, 'handshake panel should be present');
assert.ok(!handshakePanel.classList.contains('hidden'), 'handshake panel should not be hidden');
});
+25
View File
@@ -198,3 +198,28 @@ test("helpers trim fragments from non-magnet values", () => {
"Dropping the fragment should be reflected in didChange"
);
});
test("normalizeAndAugmentMagnet filters out known broken trackers", () => {
const infoHash = "abcdef0123456789abcdef0123456789abcdef01";
const brokenTrackers = [
"wss://tracker.dler.org/announce",
"wss://tracker.ghostchu-services.top/announce"
];
const rawMagnet =
`magnet:?xt=urn:btih:${infoHash}` +
`&tr=${brokenTrackers[0]}` +
`&tr=${brokenTrackers[1]}`;
const result = normalizeMagnetObject(rawMagnet);
const trackers = getParamValues(result.magnet, "tr");
for (const broken of brokenTrackers) {
assert.ok(
!trackers.includes(broken),
`Expected broken tracker ${broken} to be removed`
);
}
assertDefaultTrackersPresent(result.magnet);
});
+22
View File
@@ -47,3 +47,25 @@ if (typeof globalThis.window.localStorage === "undefined") {
if (typeof globalThis.structuredClone === "undefined") {
globalThis.structuredClone = (val) => JSON.parse(JSON.stringify(val));
}
if (typeof globalThis.IntersectionObserver === "undefined") {
globalThis.IntersectionObserver = class IntersectionObserver {
constructor(callback) {
this.callback = callback;
}
observe(element) {}
unobserve(element) {}
disconnect() {}
};
}
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class ResizeObserver {
constructor(callback) {
this.callback = callback;
}
observe(element) {}
unobserve(element) {}
disconnect() {}
};
}
+149
View File
@@ -0,0 +1,149 @@
import test from "node:test";
import assert from "node:assert/strict";
import EngagementController from "../../js/ui/engagementController.js";
test("EngagementController", async (t) => {
let controller;
let mockNostrClient;
let mockShowError;
let mockShowSuccess;
let mockShowStatus;
let mockGetCurrentVideo;
let mockGetCurrentVideoPointer;
t.beforeEach(() => {
mockNostrClient = {
repostEvent: t.mock.fn(),
mirrorVideoEvent: t.mock.fn(),
rebroadcastEvent: t.mock.fn(),
};
mockShowError = t.mock.fn();
mockShowSuccess = t.mock.fn();
mockShowStatus = t.mock.fn();
mockGetCurrentVideo = t.mock.fn();
mockGetCurrentVideoPointer = t.mock.fn();
controller = new EngagementController({
services: { nostrClient: mockNostrClient },
ui: {
showError: mockShowError,
showSuccess: mockShowSuccess,
showStatus: mockShowStatus,
},
state: {
getCurrentVideo: mockGetCurrentVideo,
getCurrentVideoPointer: mockGetCurrentVideoPointer
},
});
});
await t.test("handleRepostAction - should show error if no event ID is available", async () => {
await controller.handleRepostAction({});
assert.equal(mockShowError.mock.calls.length, 1);
assert.deepEqual(mockShowError.mock.calls[0].arguments, ["No event is available to repost."]);
assert.equal(mockNostrClient.repostEvent.mock.calls.length, 0);
});
await t.test("handleRepostAction - should call repostEvent with correct parameters", async () => {
const eventId = "test-event-id";
mockNostrClient.repostEvent.mock.mockImplementation(async () => ({ ok: true, summary: { accepted: [] } }));
await controller.handleRepostAction({ eventId, kind: "30078" });
assert.equal(mockNostrClient.repostEvent.mock.calls.length, 1);
const args = mockNostrClient.repostEvent.mock.calls[0].arguments;
assert.equal(args[0], eventId);
assert.equal(args[1].kind, 30078);
assert.equal(mockShowSuccess.mock.calls.length, 1);
assert.deepEqual(mockShowSuccess.mock.calls[0].arguments, ["Reposted."]);
});
await t.test("handleRepostAction - should handle failure from repostEvent", async () => {
const eventId = "test-event-id";
mockNostrClient.repostEvent.mock.mockImplementation(async () => ({ ok: false, error: "signing-failed" }));
await controller.handleRepostAction({ eventId });
assert.equal(mockShowError.mock.calls.length, 1);
assert.deepEqual(mockShowError.mock.calls[0].arguments, ["Failed to sign the repost. Please try again."]);
});
await t.test("handleRepostAction - should use currentVideoPointer when in modal context", async () => {
const eventId = "test-event-id";
const videoPointer = ["nevent", "test-nevent", "wss://relay.example.com"];
mockNostrClient.repostEvent.mock.mockImplementation(async () => ({ ok: true, summary: { accepted: [] } }));
// In node:test, mocking return values is done via implementation if it's a mock function
mockGetCurrentVideoPointer.mock.mockImplementation(() => videoPointer);
await controller.handleRepostAction({ eventId, context: "modal" });
assert.equal(mockNostrClient.repostEvent.mock.calls.length, 1);
const args = mockNostrClient.repostEvent.mock.calls[0].arguments;
assert.deepEqual(args[1].pointer, videoPointer);
});
await t.test("handleMirrorAction - should show error if video has no URL", async () => {
const eventId = "test-event-id";
await controller.handleMirrorAction({ eventId });
assert.equal(mockShowError.mock.calls.length, 1);
assert.deepEqual(mockShowError.mock.calls[0].arguments, ["This video does not expose a hosted URL to mirror."]);
});
await t.test("handleMirrorAction - should call mirrorVideoEvent when URL is provided", async () => {
const eventId = "test-event-id";
const url = "https://example.com/video.mp4";
mockNostrClient.mirrorVideoEvent.mock.mockImplementation(async () => ({ ok: true, summary: { accepted: [] } }));
await controller.handleMirrorAction({ eventId, url });
assert.equal(mockNostrClient.mirrorVideoEvent.mock.calls.length, 1);
const args = mockNostrClient.mirrorVideoEvent.mock.calls[0].arguments;
assert.equal(args[0], eventId);
assert.equal(args[1].url, url);
assert.equal(mockShowSuccess.mock.calls.length, 1);
assert.deepEqual(mockShowSuccess.mock.calls[0].arguments, ["Mirrored."]);
});
await t.test("handleMirrorAction - should prevent mirroring private videos", async () => {
const eventId = "test-event-id";
const url = "https://example.com/video.mp4";
await controller.handleMirrorAction({ eventId, url, isPrivate: true });
assert.equal(mockShowError.mock.calls.length, 1);
assert.deepEqual(mockShowError.mock.calls[0].arguments, ["Mirroring is unavailable for private videos."]);
assert.equal(mockNostrClient.mirrorVideoEvent.mock.calls.length, 0);
});
await t.test("handleEnsurePresenceAction - should show error if no event ID", async () => {
await controller.handleEnsurePresenceAction({});
assert.equal(mockShowError.mock.calls.length, 1);
assert.deepEqual(mockShowError.mock.calls[0].arguments, ["No event is available to rebroadcast."]);
});
await t.test("handleEnsurePresenceAction - should handle throttled response", async () => {
const eventId = "test-event-id";
mockNostrClient.rebroadcastEvent.mock.mockImplementation(async () => ({ throttled: true, cooldown: { remainingMs: 5000 } }));
global.window = { setTimeout: t.mock.fn() };
await controller.handleEnsurePresenceAction({ eventId });
assert.equal(mockShowStatus.mock.calls.length, 1);
assert.match(mockShowStatus.mock.calls[0].arguments[0], /Rebroadcast is cooling down/);
delete global.window;
});
await t.test("handleEnsurePresenceAction - should show success on successful rebroadcast", async () => {
const eventId = "test-event-id";
mockNostrClient.rebroadcastEvent.mock.mockImplementation(async () => ({ ok: true }));
await controller.handleEnsurePresenceAction({ eventId });
assert.equal(mockShowSuccess.mock.calls.length, 1);
assert.deepEqual(mockShowSuccess.mock.calls[0].arguments, ["Rebroadcast requested across relays."]);
});
});
+4 -4
View File
@@ -1,6 +1,6 @@
{
"contrast": "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 truncated
"default": "iVBORw0KGgoAAAANSUhEUgAABQAAAAXhCAIAAABKnqCHAAAQAElEQVR4nOzdCWCT9f0/8G/yJHma5mrSlJbeJ7S0FMrRChTBogg6wWMec6ibbLo5/U2dOuf51zmnbnNsTjedOq85EQ/AAwRBkHIXCqWFQu+mLS1NjyRN0+dJnuT/PEmP9D642uT9Woc5njvP93m+n+fzfb6PRBM6jQAAAAAAAAD4OgkBAAAAAAAA8AMIgAEAAAAAAMAvIAAGAAAAAAAAv4AAGAAAAAAAAPwCAmAAAAAAAADwCwiAAQAAAAAAwC8gAAYAAAAAAAC/gAAYAAAAAAAA/AICYAAAAAAAAPALCIABAAAAAADALyAABgAAAAAAAL+AABgAAAAAhicNniUNnikO0FO0ThwQzP9RdDC5eDimydkh/HFMs7PDaG/K5/8IAMCQRJrQaQQAAAAAoB+RRCUNmUNPmk+HXiKWqcn45mTNTMNepmGPvfGAi2snAAD9IAAGAAAAgH4oRWDSqsDY68WSADLROB0d7RWftpd+SLg2AgDgBQEwAAAAAPRwiQP4uDcw8VZKpiITGcea+RiYj4RFLpYAALghAAYAAAAAgcvlolTxQXOekygjia9wtNW05j3BWcpFIhEBAL9HBShDCAAAAAD4MT705f8NiFweNPcPVICO+BCxTB0QuczZYXSYS/i3CIMB/BwCYAAAAAC/JkS/Iql6xiPK5DtFYh98RAi/UgGTF1LyUObMfuLiEAMD+DMEwAAAAAB+ypP4FQeEai95iQ6bT3yaVJNEh8xlzhx0OYSesRAGA/gnBMAAAAAA/sgT/RIxrZ33F2lQMvEDlDxEqs/oMHzD54EJYmAAv4QAGAAAAMDveKJfFxFpZj1Bh8whfoMKCKYUkczpHZ63iIEB/A0CYAAAAAD/0pn7JUSZfLcidiXxM1J1HP+vvemI5y1iYAC/ggAYAADgIuiOQADOlREGct37Hj15sXr6r4lfkukzHG1VnKXC8xYxMID/8MGO/gAAAMY5PgJZctnCq5YvSUyI0+m0BOAsNDe3lJRWfPn11h07dw8byHVHvyKJQjXjEXLeyMIC3npMmRXoeP1Ppr+cHI+Xe1TpD7Fn9rscVuLeLIiBAfwEAmAAAIALiq9q/+b+XxAR+Xjd52VlFS2trQTgLGiDghIT4xYtzE6fnvL3f7w5wkAuMHEVJVWSUZFQt9yreTZN3PXe1Vht/+pb6yt7OEv/ge3OcoNDL+Fq28dpYwd+9fmNYC1+nQCAPxFpQqcRAAAAuCD46PfB+39ham19570PCcA5tfqnq+SBir+98u/BYuCe9C+t0+esFUtoMirdAbDdWWN0qfWUWsp/yv3vH61PH5mQTfqdDsa47WYX2+x5iyQwgD9ABhgAAOACcbd8zubr2Ih+4Xx46z8fPPjAvYsunb/z+z39Yznv286VU3466ujXi7nQetvrTG2g9IXHNNfrqYVpkuB6ao3Q4Jn7/gAXMVUaaWceftPx4//rbAL9iol+62llltT9bZosQeo8srvtz+XSh24ImKlxndjd/tuPOoptJHmR6rVb6EipexZGx/4D7X/ZyJY7iH6uasPddIjJ/lW5KDNNIqpluVhZqIm5/3nL101ENVXx8cPyBBPz2+ctnzeNaj0IvxEUST9uK3qlexMhBgbweWICAP6Dnnrf5oMNhgLPX+7Tc9TDj6Oa//ym7lGqNtyZOvYq0zBCVrx4rGtGDYWv3ZwgG3YUetqdX5d0jWLY9NKiYOIT6IQbPyvsXq/t/1p6Xtbrwszl4jqX6ziW4jOAq5Zfnpu7lwCcH/ze9YOrLh96GHHgZHn0D8hZU+klEYHCC8be/Rl1aaaU1DuO1LuYAcbgv5UJ8a1UPHOx+oM75TOF0cUpCwIfyqD4Iz4dKKbbHfvzma2FDkYvueIq5WMLqJ4zgUZ6dZqottxeVm/fZnC/ncp/K0pJlSYQ0niSOWAiYyCPXkEFhhMA8BvIAAPAWNGxy1bflBPpqZxYCtd98F7+KK+9XwSyxBvXfP1ytqbzrfHz1Tf+YovXYvNBzscfPDGrK8ov/WD5ypcOm92v1XN+v+HtuxI7v+k4vOaqm94uYs5qdnTkgideXXNX9+wIU/jmIz954bszg08xJOPGu26c6pkgU7P73be+Kx1mGXyOeuq199x515XpUSH6SRph03V0mM2NTWdqaktK877buH3TvkozGb8SE+LKyioIwPlRWlqWlJTQ/3Pv9K80KEUkpshZUGeovvuXqvNNu/2jgw4L6Zzgia3mVWvtFncnWHf2W4ojn5pW7xY9LuSN+TRy202v22fcrXkxTRw/lVLv4U7sMC/b5HLfTiy6/KdBry2gUqZSIbu5roMc99nrpqeOOFlCkpfLboiSZmVKIwsdC6fys3buOmCvdZAx4JPAkqBkrr2ucxGRBAbwdQiAAWCs1BHLV626JcrzhtldtnltfpO/xWJnY7Do18CQwbPsskmzlt1129wAzzsDvX3dd6WNxH/QkZf9/s2X7ujdDiEgQB0QpZ4UFZc2L/u621a+ceOqJ/dZyHil02nR6xXs2Pbl4iXnIAfbX3NLa/Bw/YrTYZeSsyTcA+y0tDvLy9nPdjK76l2yMM8XjiOFjsGLH3eknLPYJWYhY+w6UWivsTvj3Wlb2t3sWR0f8PhV9IwoSWRg16JqxHxp7zyzmBzfG4Tol1eezxxZLs2KpxdOFWVFi4iR+bZ87Dch8xuEqdtOAMA/IAAGgKExhh3r3iDhnoCDOV5whpwvTOl3H71v6UyWmgtKGlniuwaIft/vjH6FN43Faz/4uLSziW3dnjJf3hSjoJ5618t9o9+hYUsCkD4PnRZJZZOyyNnpvAd4oIyrecg0rPsI17Uw9t7fqaX33aa4Wk9q8tufOsCpFygeSut9p57dxXaNwhrZz046szIkd1wuDpGSmkLmyFk0/JBNyuQ3C3F1Th1JYADfhgAYAIbGGra8/eQWcgGYj2/+w2ObiR8YOPp9pjP6FZiPrX3h2FoCvYRcsur2eT3Rr8lQXFhWd+aMhWiCJ4XoJ0XGJU3qFxtjSwL0JtPPoqQKMh6J3AXYZWl30fGy66YO2U+Nw7lrN1uTERCZQPFvduU7jGTsKKmS3yxs434CAH4AATCAX6NDpt9+z+pbFqenJuqJqbYoP2/T+2+9u8X7FkrV/Oc//vy2CM8bz42vpSErXt/83HJNrykt+H/rqv9f55uOvS/e+G7yq/9aGe15b8p9eOX973kn30LmvPTx23d03k/L7P5/q3701kn1ihe3v7p8Utco/7fy/rVeo/BB4+333HjdPPeinqkoOn7w81ffXjue7/UcxPDRr7vrpv9teHJB5xb23DnMznr0rU2/Su41raiVHx1e2fWm9t1Vqx7Z2XWDMa1KXbTilivnpqbGJUXGTdKQDpPRUFNcuHf3+g82bi4buImiOuGyO+658dp56YlR6oAOc/Xx3W/88aV/7+t/a7cs6pJlt9+YM7tz4ozJUFFYdvLQxg3vbczzXhHhnuoNHzzRlbMtefPOq/5qybnnztsXp6elRmgIc6a0YNNrLz237uQIfklV0rz06K43HUVv3XLT3w6bey9VRs61K6aeMTNDbsmmc71gsqilj/zv1ZuSOhumkzM71tzxq7cPT8CdE3xSr/QvIVLteH3+pZl9ZxOTeRudwud+T7PflzsThoyBjeXMrtMBP5pMyGn7V+Vn+xAmiTrROwBGEhjAhyEABvBfdFTO7z++8brErkhMEzF7Mf932WV/vP8nr+WdZe29+cjm7wwr7/DcIaxJXz4vfG1ZZXdcEjJt2WVdvUkRU8H6HRVD3zxMJ6x4/ePnuoJjQibFzeb/Fi9Y8GbeeeuU+vyYtOCJXz3pHf2W9It+z5562rInXn7ijtRevRQHaPRJmuyk1LnRjXnfvXay/ww18375zss3Lei+rhGgjp61/Lk39eS2+/+d7xUw07HXPv3S325LDvD6SBOVvID/W7zyrns2Pfngc+/lDxxg0yELfv/xqlt62jDTkxLn3vHyPyeRO+9eVznsNqDpnu5gGXOdue8IrCF/8yv5Y2lEcDYLFnLJL19/uSf6Ne09u+iXnnrv2nd+E2kx8KtH06Tm6Pt//et7++oGXgY6buXTj957id6889Vfv7C9Zvi9SLX4+XceMj9z6wvFk5bePI/Z9vHOflMOWfDMX24ufeGhj5nl/1n79Cym9gxDAmhy5vj2f7zw6uYRtCGnp9324V/S3/v54xtqOgemI5e/+Jfs7x57ZgOaoI8DYlpHxszBfbSm+aOBvmHrO277v47BP+m47Zfdrx3PP2l8vuvNt/9pnvKfztfGnZacnQMdPQ5aFhzs97nZ8e1J7keTqRP5zAkbOUvigLPYLAAwoeAxSAD+K/rKVT3Rbw/1ggeefPjsnydkLFj/TXdXt+rZV86N6pmVKu3Kud2pPFP+hj01Q1aL1dPv+uMjPdFvj4jlP1uZFkAmDv3yp5/rFf1+/OSPznX0S0cu+/2rL/WJfkeybDk/84p+u2nmPvzgsp7dhA6+7NE+0W8vAYnL//TqI8siB36EVfTK1bcMcAevfvkDv7wsctinXjFnapq6a9CaeY+88+YTty+KHduziM7Vgqkzfvz3V1fP7tpufPT7k7PP/TKWvX+9+4qcay7N+dFvd+jv/feff3OJauC5T8u5PcP4l5tvuOGZkUS/3lSJV1578zx9v3WWpS69eXbjpu88kaq5+B/33nBpzjWZyx9aS5av+ceji0PIGDA1B9Yfj7v9xuRz8mP5uSkpo8vf9kn/krMMgMcNVTz9m1XK+zIo0m7/7KDj7Hu9o+RhfT7pv+kAwDcgAAbwb6biz1998f9+t+bdHbU9HwbE3fyrZYmDp1aZxt1/+tWv73jwre1ePWKVfPz7u1f/+g7330/+yAd1lsJvthd2favJuGx+dyARwieE47q+Me5ZlzdkBCiLWrzqrnleNeczBR+9+nt+mT/aezb3fLine truncated
"light": "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 truncated
"video-modal-mobile": "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 truncated
"contrast": "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 truncated
"default": "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 truncated
"light": "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 truncated
"video-modal-mobile": "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 truncated
}
+5 -5
View File
@@ -45,7 +45,7 @@ test.describe("design system kitchen sink", () => {
await page.addStyleTag({
content:
"*, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; animation-delay: 0s !important; caret-color: transparent !important; }"
"*, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; animation-delay: 0s !important; caret-color: transparent !important; } ::-webkit-scrollbar { display: none; } body { -ms-overflow-style: none; scrollbar-width: none; }"
});
await page.evaluate((value) => {
@@ -131,7 +131,7 @@ test.describe("video modal mobile regression", () => {
await page.addStyleTag({
content:
"*, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; animation-delay: 0s !important; caret-color: transparent !important; }"
"*, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; animation-delay: 0s !important; caret-color: transparent !important; } ::-webkit-scrollbar { display: none; } body { -ms-overflow-style: none; scrollbar-width: none; }"
});
await page.evaluate(() => {
@@ -193,15 +193,15 @@ test.describe("video modal mobile regression", () => {
const diffRatio = diffPixelCount / (actual.width * actual.height);
if (diffPixelCount > 0) {
await testInfo.attach(`${variant.baselineKey}-actual.png`, {
await testInfo.attach(`${VIDEO_MODAL_BASELINE_KEY}-actual.png`, {
body: screenshotBuffer,
contentType: "image/png"
});
await testInfo.attach(`${variant.baselineKey}-baseline.png`, {
await testInfo.attach(`${VIDEO_MODAL_BASELINE_KEY}-baseline.png`, {
body: baselineBuffer,
contentType: "image/png"
});
await testInfo.attach(`${variant.baselineKey}-diff.png`, {
await testInfo.attach(`${VIDEO_MODAL_BASELINE_KEY}-diff.png`, {
body: PNG.sync.write(diff),
contentType: "image/png"
});
+25
View File
@@ -5,6 +5,10 @@ const STORAGE_KEY = "bitvid:moderation:fixture-overrides";
const SENTINEL_KEY = "__moderationFixturesInit__";
const RESTORE_BUTTON_LABEL = "Restore default moderation";
async function waitForFixtureReady(page) {
await page.waitForFunction(() => window.__moderationFixturesReady__ === true);
}
function setupStorageReset(page) {
return page.addInitScript(({ storageKey, sentinelKey }) => {
try {
@@ -38,6 +42,7 @@ test.afterEach(async ({ page }) => {
test.describe("moderation fixtures", () => {
test("blur fixture masks thumbnails and blocks autoplay", async ({ page }) => {
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
await waitForFixtureReady(page);
const blurCard = page.locator('[data-test-id="blur-threshold"]');
const thumbnail = blurCard.locator('img[data-video-thumbnail]');
@@ -53,6 +58,7 @@ test.describe("moderation fixtures", () => {
test("autoplay fixture disables preview without blurring", async ({ page }) => {
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
await waitForFixtureReady(page);
const autoplayCard = page.locator('[data-test-id="autoplay-threshold"]');
const thumbnail = autoplayCard.locator('img[data-video-thumbnail]');
@@ -64,9 +70,15 @@ test.describe("moderation fixtures", () => {
test("show anyway override persists across reloads", async ({ page }) => {
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
await waitForFixtureReady(page);
const overrideCard = page.locator('[data-test-id="show-anyway"]');
const thumbnail = overrideCard.locator('img[data-video-thumbnail]');
await expect(overrideCard).toHaveAttribute(
"data-moderation-override-available",
"true"
);
const showAnywayButton = overrideCard.getByRole("button", { name: "Show anyway" });
const restoreButtonQuery = overrideCard.getByRole("button", {
name: RESTORE_BUTTON_LABEL,
@@ -92,6 +104,7 @@ test.describe("moderation fixtures", () => {
await expect(overrideCard.getByRole("button", { name: "Show anyway" })).toHaveCount(0);
await page.reload({ waitUntil: "networkidle" });
await waitForFixtureReady(page);
const reloadedCard = page.locator('[data-test-id="show-anyway"]');
const reloadedThumbnail = reloadedCard.locator('img[data-video-thumbnail]');
@@ -128,9 +141,15 @@ test.describe("moderation fixtures", () => {
test("trusted report hide fixture supports show anyway override", async ({ page }) => {
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
await waitForFixtureReady(page);
const hideCard = page.locator('[data-test-id="trusted-report-hide"]');
const badge = hideCard.locator('[data-moderation-badge="true"]');
await expect(hideCard).toHaveAttribute(
"data-moderation-override-available",
"true"
);
const showAnywayButton = hideCard.getByRole("button", { name: "Show anyway" });
const restoreButtonQuery = hideCard.getByRole("button", {
name: RESTORE_BUTTON_LABEL,
@@ -167,10 +186,16 @@ test.describe("moderation fixtures", () => {
page,
}) => {
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
await waitForFixtureReady(page);
const muteCard = page.locator('[data-test-id="trusted-mute-hide"]');
const badge = muteCard.locator('[data-moderation-badge="true"]');
const thumbnail = muteCard.locator('img[data-video-thumbnail]');
await expect(muteCard).toHaveAttribute(
"data-moderation-override-available",
"true"
);
const showAnywayButton = muteCard.getByRole("button", { name: "Show anyway" });
const restoreButtonQuery = muteCard.getByRole("button", {
name: RESTORE_BUTTON_LABEL,
+1 -1
View File
@@ -1 +1 @@
var ye=Object.freeze([]);var k=Object.freeze({light:Object.freeze({accent:"#fe0032",accentStrong:"#540011",accentPressed:"#a90021"}),dark:Object.freeze({accent:"#fe0032",accentStrong:"#540011",accentPressed:"#a90021"})});var be=Object.freeze(["watch-history:v2:index"]);var ge=1440*60*1e3;var _e=300*1e3;var F=!!!0;typeof window<"u"&&(window.__BITVID_DEV_MODE__=F);var P=()=>{};function Ae(){return typeof console>"u"||console===null?null:console}function Ce(){let e=Ae();if(!e)return{log:P,info:P,warn:P,error:P};let t=typeof e.log=="function"?e.log.bind(e):P;return{log:typeof e.log=="function"?e.log.bind(e):t,info:typeof e.info=="function"?e.info.bind(e):t,warn:typeof e.warn=="function"?e.warn.bind(e):t,error:typeof e.error=="function"?e.error.bind(e):t}}var S=Ce(),M="[bitvid]";function ee(e){if(e.length===0)return{args:e,force:!1};let t=e[e.length-1];if(t&&typeof t=="object"&&!Array.isArray(t)&&Object.prototype.hasOwnProperty.call(t,"force")){let r=[...e],o=r.pop();return{args:r,force:!!o.force}}return{args:e,force:!1}}var L=Object.freeze({log:(...e)=>{F&&S.log(...e)},info:(...e)=>{F&&S.info(...e)},warn:(...e)=>{F&&S.warn(...e)},error:(...e)=>{F&&S.error(...e)}}),E=Object.freeze({log:(...e)=>{let{args:t,force:r}=ee(e);if(!r&&t.length===0)return;if(r){S.log(M,...t);return}let[o,...n]=t;if(typeof o=="string"&&n.length===0){S.log(`${M} ${o}`);return}o!==void 0&&S.log(M,o)},info:(...e)=>{let{args:t,force:r}=ee(e);if(!r&&t.length===0)return;if(r){S.info(M,...t);return}let[o,...n]=t;if(typeof o=="string"&&n.length===0){S.info(`${M} ${o}`);return}o!==void 0&&S.info(M,o)},warn:(...e)=>{S.warn(M,...e)},error:(...e)=>{S.error(M,...e)}}),te=Object.freeze({dev:L,user:E});function we(e){if(!e||typeof e!="object")return!1;let t=["dev","user"],r=["log","info","warn","error"];return t.every(o=>{let n=e[o];return n&&typeof n=="object"&&r.every(l=>typeof n[l]=="function")})}typeof window<"u"&&(we(window.bitvidLogger)||(window.bitvidLogger=te));Object.freeze(te);var G="bitvid:theme",N="dark",Le=new Set(["light","dark"]),re={dark:"#0f172a",light:"#f8fafc"},ne=["[data-theme-toggle]",'[data-action="toggle-theme"]',".js-theme-toggle","#themeToggle"],xe=Object.freeze({accent:"--color-accent",accentStrong:"--color-accent-strong",accentPressed:"--color-accent-pressed"}),oe=new WeakSet,V=new Set,R=null,ae=!1,I=()=>typeof window<"u"&&typeof document<"u",q=e=>{if(typeof e!="string")return null;let t=e.trim().toLowerCase();return t==="default"||t===""?N:Le.has(t)?t:null},Ie=()=>{if(!I())return null;try{let e=window.localStorage.getItem(G);return q(e)}catch(e){return E.warn("Unable to read stored theme preference:",e),null}},ve=e=>{if(I())try{window.localStorage.setItem(G,e)}catch(t){E.warn("Unable to persist theme preference:",t)}},K=()=>{if(!I())return null;let{documentElement:e}=document;return e instanceof HTMLElement?e:null},Ne=e=>typeof e=="string"?e.trim():"",Oe=(e,t)=>{if(!e)return;let r=k&&typeof k=="object"&&k[t]&&typeof k[t]=="object"?k[t]:null;Object.entries(xe).forEach(([o,n])=>{let l=r?r[o]:null,a=Ne(l);a?e.style.setProperty(n,a):e.style.removeProperty(n)})},Be=e=>{if(!I())return;let t=document.querySelector('meta[name="theme-color"]');if(!t)return;let r=K();if(!r)return;let o="";try{typeof window.getComputedStyle=="function"&&(o=getComputedStyle(r).getPropertyValue("--color-page").trim())}catch(a){E.warn("Unable to compute theme color token:",a)}let n=re[e]||re[N],l=o||n;l&&t.setAttribute("content",l)},se=(e,t)=>{if(!(e instanceof HTMLElement))return;e.dataset.themeState=t,e.setAttribute("aria-pressed",t==="dark"?"true":"false");let r=t==="dark"?"Switch to light theme":"Switch to dark theme";e.setAttribute("aria-label",r),e.setAttribute("title",r);let o=e.querySelector("[data-theme-toggle-label]")||e.querySelector(".sr-only");o&&(o.textContent=r);let n=e.querySelector("[data-theme-toggle-icon]")||e.querySelector('[aria-hidden="true"]');if(n){let l=typeof SVGElement<"u"?SVGElement:null;if(l&&n instanceof l||n instanceof Element&&!!n.querySelector("svg"))n instanceof Element&&n.setAttribute("data-theme-icon-state",t);else{let p=e.dataset.iconLight||"\u2600\uFE0F",d=e.dataset.iconDark||"\u{1F319}";n.textContent=t==="dark"?p:d}}},Me=()=>{if(!I()){V.clear();return}V.forEach(e=>{(!(e instanceof HTMLElement)||!document.contains(e))&&V.delete(e)})},He=e=>{Me(),V.forEach(t=>{se(t,e)})},Re=e=>{!(e instanceof HTMLElement)||oe.has(e)||(e.addEventListener("click",t=>{t.preventDefault(),ke(R==="dark"?"light":"dark")}),e.dataset.themeToggleBound="true",oe.add(e))},De=(e=document)=>{if(!I())return[];let t=new Set,r=e&&typeof e.querySelectorAll=="function"?e:document;return ne.forEach(o=>{try{r.querySelectorAll(o).forEach(n=>{n instanceof HTMLElement&&t.add(n)})}catch(n){E.warn(`Invalid selector for theme toggle: ${o}`,n)}}),e instanceof HTMLElement&&ne.forEach(o=>{e.matches(o)&&t.add(e)}),Array.from(t)},X=(e,{persist:t=!0}={})=>{let r=q(e)||N;R=r;let o=K();return o&&(o.dataset.theme=r,Oe(o,r)),t&&ve(r),Be(r),He(r),r},ke=e=>I()?X(e,{persist:Line truncated
import{IS_DEV_MODE as Le,IS_VERBOSE_DEV_MODE as Ce,IS_LOCKDOWN_MODE as Ae,LOCKDOWN_TITLE as bt,LOCKDOWN_MESSAGE as Et,ADMIN_SUPER_NPUB as Tt,ADMIN_DM_IMAGE_URL as gt,BITVID_WEBSITE_URL as _t,BLOG_URL as St,COMMUNITY_URL as wt,NOSTR_URL as Lt,GITHUB_URL as Ct,BETA_URL as At,DNS_URL as xt,TIP_JAR_URL as vt,MAX_WALLET_DEFAULT_ZAP as It,PLATFORM_FEE_PERCENT as Ot,PLATFORM_LUD16_OVERRIDE as Nt,DEFAULT_RELAY_URLS_OVERRIDE as Bt,ADMIN_WHITELIST_MODE_STORAGE_KEY as Mt,DEFAULT_WHITELIST_MODE_ENABLED as Dt,ALLOW_NSFW_CONTENT as Rt,DEFAULT_TRUST_SEED_NPUBS as Ht,DEFAULT_BLUR_THRESHOLD as Ut,DEFAULT_AUTOPLAY_BLOCK_THRESHOLD as kt,DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD as Ft,DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD as Pt,WATCH_HISTORY_KIND as Vt,WATCH_HISTORY_LIST_IDENTIFIER as Wt,WATCH_HISTORY_MAX_ITEMS as qt,WATCH_HISTORY_BATCH_RESOLVE as jt,WATCH_HISTORY_BATCH_PAGE_SIZE as $t,WATCH_HISTORY_PAYLOAD_MAX_BYTES as Yt,WATCH_HISTORY_FETCH_EVENT_LIMIT as Gt,WATCH_HISTORY_CACHE_TTL_MS as zt,VIEW_COUNT_DEDUPE_WINDOW_SECONDS as Kt,VIEW_COUNT_BACKFILL_MAX_DAYS as Xt,VIEW_COUNT_CACHE_TTL_MS as Zt,ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS as Jt}from"../config/instance-config.js";var H=!!Le;var xe=!!Ce;var ve=!!Ae;typeof window<"u"&&(window.__BITVID_DEV_MODE__=H,window.__BITVID_VERBOSE_DEV_MODE__=xe,window.__BITVID_LOCKDOWN_MODE__=ve);var k=()=>{};function Ie(){return typeof console>"u"||console===null?null:console}function Oe(){let e=Ie();if(!e)return{log:k,info:k,debug:k,warn:k,error:k};let t=typeof e.log=="function"?e.log.bind(e):k;return{log:typeof e.log=="function"?e.log.bind(e):t,info:typeof e.info=="function"?e.info.bind(e):t,debug:typeof e.debug=="function"?e.debug.bind(e):t,warn:typeof e.warn=="function"?e.warn.bind(e):t,error:typeof e.error=="function"?e.error.bind(e):t}}var _=Oe(),M="[bitvid]";function ee(e){if(e.length===0)return{args:e,force:!1};let t=e[e.length-1];if(t&&typeof t=="object"&&!Array.isArray(t)&&Object.prototype.hasOwnProperty.call(t,"force")){let r=[...e],o=r.pop();return{args:r,force:!!o.force}}return{args:e,force:!1}}var S=Object.freeze({log:(...e)=>{H&&_.log(...e)},info:(...e)=>{H&&_.info(...e)},debug:(...e)=>{H&&_.debug(...e)},warn:(...e)=>{H&&_.warn(...e)},error:(...e)=>{H&&_.error(...e)}}),y=Object.freeze({log:(...e)=>{let{args:t,force:r}=ee(e);if(!r&&t.length===0)return;if(r){_.log(M,...t);return}let[o,...n]=t;if(typeof o=="string"&&n.length===0){_.log(`${M} ${o}`);return}o!==void 0&&_.log(M,o)},info:(...e)=>{let{args:t,force:r}=ee(e);if(!r&&t.length===0)return;if(r){_.info(M,...t);return}let[o,...n]=t;if(typeof o=="string"&&n.length===0){_.info(`${M} ${o}`);return}o!==void 0&&_.info(M,o)},warn:(...e)=>{_.warn(M,...e)},error:(...e)=>{_.error(M,...e)}}),te=Object.freeze({dev:S,user:y});function Ne(e){return!e||typeof e!="object"?!1:Object.entries({dev:["log","info","debug","warn","error"],user:["log","info","warn","error"]}).every(([r,o])=>{let n=e[r];return!n||typeof n!="object"?!1:o.every(c=>typeof n[c]=="function")})}typeof window<"u"&&(Ne(window.bitvidLogger)||(window.bitvidLogger=te));Object.freeze(te);import{THEME_ACCENT_OVERRIDES as re}from"../config/instance-config.js";var K="bitvid:theme",v="dark",Be=new Set(["light","dark"]),ne={dark:"#0f172a",light:"#f8fafc"},oe=["[data-theme-toggle]",'[data-action="toggle-theme"]',".js-theme-toggle","#themeToggle"],ie=Object.freeze({accent:"--color-accent",accentStrong:"--color-accent-strong",accentPressed:"--color-accent-pressed"}),ae=new WeakSet,W=new Set,D=null,se=!1,F=null,I=()=>typeof window<"u"&&typeof document<"u",q=e=>{if(typeof e!="string")return null;let t=e.trim().toLowerCase();return t==="default"||t===""?v:Be.has(t)?t:null},Me=()=>{if(!I())return null;try{let e=window.localStorage.getItem(K);return q(e)}catch(e){return y.warn("Unable to read stored theme preference:",e),null}},De=e=>{if(I())try{window.localStorage.setItem(K,e)}catch(t){y.warn("Unable to persist theme preference:",t)}},G=()=>{if(!I())return null;let{documentElement:e}=document;return e instanceof HTMLElement?e:null},Re=e=>typeof e=="string"?e.trim():"",He=e=>{if(!e||typeof e!="object")return null;let t=Object.entries(e).reduce((r,[o,n])=>{if(!n||typeof n!="object")return r;let c=Object.entries(n).reduce((a,[u,d])=>(Object.prototype.hasOwnProperty.call(ie,u)&&(a[u]=d),a),{});return Object.keys(c).length>0&&(r[o]=c),r},{});return Object.keys(t).length>0?t:null},ce=e=>{F=He(e)};ce(typeof re<"u"?re:null);var le=(e,t)=>{if(!e)return;let r=F&&typeof F=="object"&&F[t]&&typeof F[t]=="object"?F[t]:null,o=!1;Object.entries(ie).forEach(([n,c])=>{let a=r?r[n]:null,u=Re(a);u?(e.style.setProperty(c,u),o=!0):e.style.removeProperty(c)}),o?e.dataset.themeAccent="override":e.removeAttribute("data-theme-accent")},Ue=e=>{if(!I())return;let t=document.querySelector('meta[name="theme-color"]');if(!t)return;let r=G();if(!r)return;let o="";try{typeof window.getComputedStyle=="function"&&(o=getComputedStyle(r).getPropertyValue("--color-page").trim())}catch(a){y.warn("Unable to compute theme color tokenLine truncated
Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 80 KiB

+32 -34
View File
@@ -1,53 +1,51 @@
from playwright.sync_api import sync_playwright
def verify_profile_modal():
def verify_profile_messages():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={'width': 1280, 'height': 720})
page = context.new_page()
# Navigate to the app
page = browser.new_page()
page.goto("http://localhost:8000")
# Wait for the page to load
page.wait_for_load_state("networkidle")
# 1. Close disclaimer modal if present
# The logs say #disclaimerModal intercepts events.
try:
page.wait_for_selector("#closeDisclaimerModal", timeout=5000)
page.click("#closeDisclaimerModal")
except:
print("Disclaimer modal not found or not visible")
# Make the profile modal visible
page.evaluate("""() => {
# Also could be #disclaimerAgreeBtn
try:
page.wait_for_selector("#disclaimerAgreeBtn", timeout=2000)
page.click("#disclaimerAgreeBtn")
except:
pass
# 2. Open Profile Modal via JS
page.evaluate("""
const modal = document.getElementById('profileModal');
if (modal) {
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
// Ensure profile modal is on top if multiple modals
modal.style.zIndex = '9999';
}
}""")
""")
# Force the Messages pane to be visible
page.evaluate("""() => {
const pane = document.getElementById('profilePaneMessages');
if (pane) {
pane.classList.remove('hidden');
pane.removeAttribute('hidden');
// Ensure other panes are hidden if needed, but not strictly necessary for content check
}
}""")
# 3. Click Messages tab
# Wait for animation/visibility
page.wait_for_timeout(500)
page.click("#profileNavMessages")
# Wait for the pane to become active/visible
page.wait_for_selector("#profilePaneMessages:not(.hidden)")
# 4. Wait for AppShell
page.wait_for_selector("#dmAppShellMount")
page.wait_for_timeout(2000)
# Take a screenshot
# 5. Take screenshot
page.screenshot(path="verification_profile_messages.png")
content = page.content()
if "Direct message privacy" in content:
print("FAILURE: 'Direct message privacy' text found in the page source.")
else:
print("SUCCESS: 'Direct message privacy' text NOT found.")
if "Read receipts" in content and "Direct message privacy" in content:
print("FAILURE: 'Read receipts' text found.")
browser.close()
if __name__ == "__main__":
verify_profile_modal()
verify_profile_messages()
+25 -1
View File
@@ -148,7 +148,31 @@
<hr class="my-6" />
<h3 class="mb-4 text-lg font-bold text-text-strong">Videos by This User</h3>
<div class="flex items-center gap-2 mb-4">
<h3 class="text-lg font-bold text-text-strong">Videos by This User</h3>
<button
type="button"
id="channelInfoTrigger"
class="text-muted hover:text-text focus:outline-none transition-colors"
aria-label="How is this feed sorted?"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</button>
</div>
<div id="channelVideoList" class="bv-grid-video">
<!-- User’s videos will go here -->
</div>
+25 -1
View File
@@ -1,5 +1,29 @@
<!-- views/explore.html -->
<section data-ds="new">
<h2 class="mb-4 text-xl font-semibold text-text">Explore</h2>
<div class="flex items-center gap-2 mb-4">
<h2 class="text-xl font-semibold text-text">Explore</h2>
<button
type="button"
id="exploreInfoTrigger"
class="text-muted hover:text-text focus:outline-none transition-colors"
aria-label="How is this feed sorted?"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</button>
</div>
<p class="text-muted-strong">Coming Soon...</p>
</section>
+25 -1
View File
@@ -1,6 +1,30 @@
<!-- views/for-you.html -->
<section data-ds="new">
<h2 class="mb-4 text-xl font-semibold text-text">For You</h2>
<div class="flex items-center gap-2 mb-4">
<h2 class="text-xl font-semibold text-text">For You</h2>
<button
type="button"
id="feedInfoTrigger"
class="text-muted hover:text-text focus:outline-none transition-colors"
aria-label="How is this feed sorted?"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</button>
</div>
<div id="videoList" class="bv-grid-video">
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
+25 -1
View File
@@ -5,7 +5,31 @@
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div>
<h2 class="text-xl font-semibold text-text">Watch History</h2>
<div class="flex items-center gap-2">
<h2 class="text-xl font-semibold text-text">Watch History</h2>
<button
type="button"
id="historyInfoTrigger"
class="text-muted hover:text-text focus:outline-none transition-colors"
aria-label="How is this feed sorted?"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</button>
</div>
<p class="text-sm text-muted">
Review the videos you recently played, grouped by day.
</p>
+25 -1
View File
@@ -1,7 +1,31 @@
<!-- views/most-recent-videos.html -->
<section data-ds="new">
<!-- You can have a heading if you want -->
<h2 class="mb-4 text-xl font-semibold text-text">Recently Added Videos</h2>
<div class="flex items-center gap-2 mb-4">
<h2 class="text-xl font-semibold text-text">Recently Added Videos</h2>
<button
type="button"
id="recentInfoTrigger"
class="text-muted hover:text-text focus:outline-none transition-colors"
aria-label="How is this feed sorted?"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</button>
</div>
<!-- Video List -->
<div id="recentVideoTags" class="mb-4" hidden></div>
+26 -1
View File
@@ -1,11 +1,33 @@
<div class="fade-in bv-stack">
<header class="mb-4">
<header class="flex items-center gap-2 mb-4">
<h1
id="searchTitle"
class="text-2xl font-bold tracking-tight text-text-strong"
>
Search Results
</h1>
<button
type="button"
id="searchInfoTrigger"
class="text-muted hover:text-text focus:outline-none transition-colors"
aria-label="How is this feed sorted?"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</button>
</header>
<section class="bv-stack">
@@ -18,6 +40,9 @@
>
<!-- Channel cards injected here -->
</div>
<div id="searchChannelShowMore" class="hidden flex justify-center mt-2">
<button class="btn-ghost" data-size="sm">Show More</button>
</div>
</section>
<section class="bv-stack mt-8">
+25 -1
View File
@@ -1,6 +1,30 @@
<!-- views/subscriptions.html -->
<section data-ds="new">
<h2 class="mb-4 text-xl font-semibold text-text">Subscriptions</h2>
<div class="flex items-center gap-2 mb-4">
<h2 class="text-xl font-semibold text-text">Subscriptions</h2>
<button
type="button"
id="subscriptionsInfoTrigger"
class="text-muted hover:text-text focus:outline-none transition-colors"
aria-label="How is this feed sorted?"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</button>
</div>
<div id="subscriptionsVideoList" class="bv-grid-video">
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
<div class="sidebar-loading-indicator">