mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge branch 'unstable' into feature/video-card-marquee-4520373547615093905
This commit is contained in:
32 files changed
+769
-411
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -11,4 +11,4 @@
|
||||
--player-modal-primary-inline-padding-lg,clamp(var(--space-sm),2.75vw,var(--space-lg))
|
||||
)}}.revert-modal__layout{display:grid;gap:1.5rem}@media (min-width:1024px){.revert-modal__layout{grid-template-columns:minmax(var(--revert-modal-sidebar-min),var(--revert-modal-sidebar-width)) minmax(0,1fr)}}#playerModal.video-modal__primary{display:block}.video-modal__primary{display:flex;flex-direction:column;gap:1.5rem;min-width:0;min-height:0}#playerModal.video-modal__secondary{display:block}.video-modal__secondary{display:flex;flex-direction:column;gap:1rem;min-width:0;min-height:0;width:100%;align-self:stretch}.video-modal__similar{display:none}@media (min-width:1024px){#playerModal.video-modal__similar{display:block}.video-modal__similar{display:flex;flex-direction:column;gap:1.5rem;align-self:stretch;flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module{height:100%;display:flex;flex:1 1 auto;min-height:0;flex-direction:column}#playerModal .video-modal__similar .player-modal__module-list{flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module-scroll{height:100%;max-height:none;min-height:0}}#playerModal .player-modal__module-item{width:100%}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))!important}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media,#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))!important}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal .video-modal__similar .player-modal__module-list{gap:clamp(var(--space-2xs),.85vw,var(--space-sm))}.player-modal__similar-card{outline:2px solid transparent;outline-offset:2px}.player-modal__similar-card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.player-modal__similar-card{display:block}.player-modal__similar-card{position:relative;displayLine truncated
|
||||
--motion-video-card-backdrop-translate
|
||||
);--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.watch-history-card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.watch-history-card[data-state=disabled],.watch-history-card[data-state=private]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.watch-history-card[data-mobile-active=true] .video-card__title{color:var(--color-accent)}.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}@media (min-width:1024px){.watch-history-card:hover .marquee-host.can-marquee .marquee-static{opacity:0}.watch-history-card:hover .marquee-host.can-marquee .marquee-anim{opacity:1}.watch-history-card:hover .marquee-host.can-marquee .marquee-track{animation-play-state:running}}@media (max-width:calc(1024px - var(--breakpoint-edge-offset ))){.watch-history-card[data-mobile-active=true] .marquee-host.can-marquee .marquee-static{opacity:0}.watch-history-card[data-mobile-active=true] .marquee-host.can-marquee .marquee-anim{opacity:1}.watch-history-card[data-mobile-active=true] .marquee-host.can-marquee .marquee-track{animation-play-state:running}}.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:reduceLine truncated
|
||||
);--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.watch-history-card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.watch-history-card[data-state=disabled],.watch-history-card[data-state=private]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.watch-history-card[data-alert=nsfw-owner]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--color-black)}.watch-history-card[data-alert=nsfw-owner]:hover{--tw-border-opacity:0.6;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card img[data-thumbnail-state=blurred],.watch-history-card img[data-visual-state=blurred]{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}.watch-history-card[data-owner-is-viewer=true][data-url-health-state=offline][data-stream-health-state=unhealthy]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max));flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}.watch-history-card[data-motion=enter]{opacity:0;animation:card-motion-enter .22s ease-out forwards}#playerModal.watch-history-card{display:block}.watch-history-card{display:flex;flex-wrap:wrap;align-items:stretch;gap:1.25rem;padding:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}@media (prefers-reduced-motion:reduce){.watch-history-card,.watch-history-card[data-motion=enter]{transition-property:none}.watch-history-card[data-motion=enter]{opacity:1;animation:none}}.watch-history-card:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.watch-history-card{gap:1.5rem;padding:1.25rem}}.watch-history-card--private{border-color:var(--color-info);--tw-border-opacity:0.6}#playerModal.watch-histoLine truncated
|
||||
+30
-80
@@ -4594,9 +4594,15 @@
|
||||
}
|
||||
|
||||
#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"] {
|
||||
@@ -5755,7 +5761,7 @@
|
||||
min-height: var(--space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
@media (max-width: calc(theme("screens.md") - var(--breakpoint-edge-offset))) {
|
||||
.dm-app-shell[data-dm-view="list"] .dm-app-shell__main {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -5850,91 +5856,35 @@
|
||||
max-block-size: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.marquee-host {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
/*
|
||||
Responsive grid limit utility
|
||||
Default (Mobile): Limit to 5
|
||||
lg: Limit to 6
|
||||
xl: Limit to 8
|
||||
*/
|
||||
.channel-grid-limit > :nth-child(n+6) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
@media (min-width: theme('screens.lg')) {
|
||||
.channel-grid-limit > :nth-child(n+6) {
|
||||
display: flex !important; /* Restore visibility for item 6 */
|
||||
}
|
||||
.card:hover .marquee-host.can-marquee .marquee-anim {
|
||||
opacity: 1;
|
||||
}
|
||||
.card:hover .marquee-host.can-marquee .marquee-track {
|
||||
animation-play-state: running;
|
||||
.channel-grid-limit > :nth-child(n+7) {
|
||||
display: none !important; /* Hide 7+ */
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@media (min-width: theme('screens.xl')) {
|
||||
.channel-grid-limit > :nth-child(n+7) {
|
||||
display: flex !important; /* Restore visibility for item 7 */
|
||||
}
|
||||
|
||||
.card[data-mobile-active="true"] .video-card__title {
|
||||
color: var(--color-accent);
|
||||
.channel-grid-limit > :nth-child(n+8) {
|
||||
display: flex !important; /* Restore visibility for item 8 */
|
||||
}
|
||||
|
||||
.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;
|
||||
.channel-grid-limit > :nth-child(n+9) {
|
||||
display: none !important; /* Hide 9+ */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,12 +497,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) {
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -109,6 +109,8 @@ const DEFAULT_WSS_TRACKERS = Object.freeze([
|
||||
"wss://tracker.files.fm:7073/announce",
|
||||
"wss://tracker.btorrent.xyz",
|
||||
"wss://tracker.novage.com.ua:443/announce",
|
||||
"wss://tracker.webtorrent.dev",
|
||||
"wss://open.video.lukewarlow.uk:443/announce",
|
||||
]);
|
||||
|
||||
const SANITIZED_DEFAULT_BLUR_THRESHOLD = coerceNonNegativeInteger(
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
|
||||
@@ -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
@@ -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 } = {}) {
|
||||
|
||||
+32
-2
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1883,7 +1883,6 @@ export class ProfileModalController {
|
||||
scrollContainerSelector: "#profileModalPanes",
|
||||
errorBannerSelector: "#profileHistoryError",
|
||||
clearButtonSelector: "#profileHistoryClear",
|
||||
republishButtonSelector: "#profileHistoryRepublish",
|
||||
refreshButtonSelector: "#profileHistoryRefresh",
|
||||
privacyBannerSelector: "#profileHistoryPrivacyBanner",
|
||||
privacyMessageSelector: "#profileHistoryPrivacyMessage",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
+156
-110
@@ -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);
|
||||
|
||||
@@ -195,10 +195,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) {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 80 KiB |
+32
-34
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user