mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
update
This commit is contained in:
1 parent
281a6511f1
commit
42edebcf6d
45 files changed
+8446
-1010
No files matched your search
@@ -109,6 +109,24 @@
|
||||
>
|
||||
Hashtags
|
||||
</button>
|
||||
<button
|
||||
id="profileNavSubscriptions"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneSubscriptions"
|
||||
aria-selected="false"
|
||||
>
|
||||
Subscriptions
|
||||
</button>
|
||||
<button
|
||||
id="profileNavFriends"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneFriends"
|
||||
aria-selected="false"
|
||||
>
|
||||
Friends
|
||||
</button>
|
||||
<button
|
||||
id="profileNavBlocked"
|
||||
type="button"
|
||||
@@ -118,6 +136,15 @@
|
||||
>
|
||||
Blocked
|
||||
</button>
|
||||
<button
|
||||
id="profileNavMessages"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
|
||||
aria-controls="profilePaneMessages"
|
||||
aria-selected="false"
|
||||
>
|
||||
Messages
|
||||
</button>
|
||||
<button
|
||||
id="profileNavHistory"
|
||||
type="button"
|
||||
@@ -566,6 +593,58 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneSubscriptions"
|
||||
class="profile-pane hidden space-y-4"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavSubscriptions"
|
||||
>
|
||||
<p class="text-sm text-muted">
|
||||
These are the creators you’ve subscribed to directly in bitvid.
|
||||
</p>
|
||||
<div
|
||||
id="subscriptionsEmpty"
|
||||
class="card border border-dashed border-border/60 p-6 text-center text-sm text-muted"
|
||||
>
|
||||
You haven’t subscribed to any creators yet.
|
||||
</div>
|
||||
<ul id="subscriptionsList" class="grid-stack" data-orientation="vertical"></ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneFriends"
|
||||
class="profile-pane hidden space-y-4"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavFriends"
|
||||
>
|
||||
<p class="text-sm text-muted">
|
||||
Add friends to spotlight creators you want to keep up with across bitvid.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label class="flex-1 text-sm text-muted-strong" for="friendsInput">
|
||||
<span class="mb-2 block font-medium">Creator npub</span>
|
||||
<input
|
||||
id="friendsInput"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
placeholder="npub1..."
|
||||
/>
|
||||
</label>
|
||||
<button id="addFriendBtn" type="button" class="btn focus-ring">
|
||||
Add friend
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="friendsEmpty"
|
||||
class="card border border-dashed border-border/60 p-6 text-center text-sm text-muted"
|
||||
>
|
||||
You haven’t added any friends yet.
|
||||
</div>
|
||||
<ul id="friendsList" class="grid-stack" data-orientation="vertical"></ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="profilePaneBlocked"
|
||||
class="profile-pane hidden space-y-4"
|
||||
@@ -604,6 +683,71 @@
|
||||
</div>
|
||||
<ul id="blockedList" class="grid-stack" data-orientation="vertical"></ul>
|
||||
</section>
|
||||
<section
|
||||
id="profilePaneMessages"
|
||||
class="profile-pane hidden space-y-4"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavMessages"
|
||||
>
|
||||
<p class="text-sm text-muted">
|
||||
View encrypted conversations sent with NIP-04 or NIP-44. bitvid keeps message data on this device only.
|
||||
</p>
|
||||
<div
|
||||
id="profileMessagesStatus"
|
||||
class="sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div
|
||||
id="profileMessagesLoading"
|
||||
class="hidden py-6"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="sidebar-loading-wrapper">
|
||||
<div class="sidebar-loading-indicator">
|
||||
<span
|
||||
class="status-spinner status-spinner--inline"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="sidebar-loading-text"
|
||||
>Fetching direct messages from relays…</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="profileMessagesError"
|
||||
class="card hidden border border-critical/40 bg-critical/10 px-4 py-3 text-sm text-critical"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
></div>
|
||||
<div
|
||||
id="profileMessagesEmpty"
|
||||
class="card hidden border border-dashed border-border/60 p-6 text-center text-sm text-muted"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
No direct messages yet.
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
id="profileMessagesReload"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring inline-flex items-center gap-2"
|
||||
data-size="sm"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<ul
|
||||
id="profileMessagesList"
|
||||
class="grid-stack hidden"
|
||||
data-orientation="vertical"
|
||||
aria-live="polite"
|
||||
aria-label="Direct message threads"
|
||||
></ul>
|
||||
</section>
|
||||
<section
|
||||
id="profilePaneHistory"
|
||||
class="profile-pane hidden space-y-4"
|
||||
|
||||
+1
-3
@@ -6,6 +6,7 @@
|
||||
- **Watch History view** – A dedicated watch history page ships with privacy messaging, pagination, relay sync, and local cache controls.
|
||||
- **Zap controls** – Channel profiles manage zap visibility, login gating, retry flows, and receipt rendering via the Lightning controller on the page.
|
||||
- **Source visibility filtering** – Lists now hide cards whose hosted URLs are offline unless their WebTorrent fallback is healthy, reusing the shared guard in `js/utils/cardSourceVisibility.js`.
|
||||
- **Video modal comments** – The video modal now surfaces in-context comment threads so viewers can follow discussions without leaving the player.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
@@ -23,10 +24,8 @@
|
||||
- Update note spec v3 transition to include new fields that have been added like "has previous" or "videoRootID" or enentID if null.
|
||||
- Change "explore" view to "kids" view and add flag to all notes to see if they are for kids.
|
||||
- Add "seed" lists.
|
||||
- Fix issue where page refreshes when pulling up a video in the video modal.
|
||||
- Fix sidebar media query settings on medium-sized screens. (tablet/laptops)
|
||||
- Fix various "text wrap" issues causing scroll left and right on profile and modal pages.
|
||||
- Add comments to video modal pages.
|
||||
|
||||
## Feature Additions
|
||||
|
||||
@@ -38,7 +37,6 @@
|
||||
- Improve event spec for migration to new versions. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/02_bitvid_Enhanced_Migration_of_Note_Spec_Logic.md)
|
||||
- Migrate event spec to v3 and add support for Audio/Podcast content. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/03_bitvid_Enhanced_Nostr_Video_%26_Audio_Note_Specification_Version%203.md)
|
||||
- Add Block List, Subscription List, Playlist, and Reporting Specification. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/05_bitvid_Enhanced_Block_Subscription_%26_Reporting_Specification.md)
|
||||
- Add comments to the video modal. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/06_bitvid_Enhanced_Video_Comment_System_Specification.md)
|
||||
- Implement an "Adult Content" flag for note submissions.
|
||||
- Enable custom hashtags in the submission spec and form. (Use with future search system)
|
||||
- Allow multiple video resolutions with a selector in the video player. (v3 event spec needed)
|
||||
|
||||
@@ -94,7 +94,12 @@ async handleSubmit(e) {
|
||||
magnet: document.getElementById("magnet")?.value.trim() || "",
|
||||
thumbnail: document.getElementById("thumbnail")?.value.trim() || "",
|
||||
description: document.getElementById("description")?.value.trim() || "",
|
||||
mode: isDevMode ? "dev" : "live",
|
||||
mode:
|
||||
(document.getElementById("mode")?.value || "")
|
||||
.trim()
|
||||
.toLowerCase() === "dev"
|
||||
? "dev"
|
||||
: "live",
|
||||
isPrivate: this.isPrivateCheckbox.checked
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
: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);--scrollbar-width:0.5rem;--scrollbar-track:var(--color-white-soft);--scrollbar-thumb:var(--color-white-softer);--scrollbar-thumb-hover:var(--color-white-softest);--z-app-header:40;--z-overlay-nav:60;--z-overlay-floating:70;--z-overlay-popover:140;--z-overlay-toast:180;--z-sidebar-overlay:10;--z-sidebar:20;--z-modal-root:100;--z-modal-overlay:110;--z-modal-content:120;--z-modal-player:130;--z-modal-top-root:140;--z-modal-top-overlay:150;--z-modal-top-content:160;--overlay-backdrop-color:var(--color-overlay-backdrop);--overlay-backdrop-blur:12px;--sidebar-width-expanded-base:16rem;--sidebar-dropup-content-width:0px;--sidebar-width-expanded:max(var(--sidebar-width-expanded-base),var(--sidebar-dropup-content-width));--sidebar-width-collapsed:4rem;--sidebar-width:var(--sidebar-width-expanded);--sidebar-control-size:2.75rem;--sidebar-content-gap:1rem;--space-sidebar-shell-inline:var(--space-lg);--space-sidebar-shell-block-start:var(--space-sm);--space-sidebar-shell-block-end:1.75rem;--space-sidebar-scroll-gap:1.75rem;--space-sidebar-panel-gap:1.35rem;--space-sidebar-panel-padding-block:1.35rem;--space-sidebar-panel-padding-inline:1.1rem;--space-sidebar-footer-gap:0.85rem;--space-sidebar-trigger-padding-block:0.78rem;--space-sidebar-popover-offset:0.85rem;--space-sidebar-popover-padding-block:0.85rem;--space-sidebar-popover-padding-inline:0.65rem;--space-sidebar-popover-gap:0.45rem;--space-sidebar-dropup-scroll-reserve:calc(var(--scrollbar-thin-width) + var(--space-sm) + var(--space-2xs));--sidebar-dropup-max-height:18rem;--space-sidebar-nav-padding-block:0.7rem;--space-sidebar-nav-padding-inline-start:var(--space-xs);--space-sidebar-nav-padding-inline-end:calc(var(--space-lg) + var(--space-2xs));--space-sidebar-subnav-padding-block:0.55rem;--space-sidebar-nav-translate:0.35rem;--grid-card-min-width:20rem;--popover-backdrop-blur:12px;--overlay-panel-radius:var(--radius-lg);--overlay-panel-shadow:var(--shadow-lg);--overlay-panel-padding-inline:var(--popup-padding-inline);--overlay-panel-padding-block:var(--popup-padding-block);--toast-translate:-1.5rem;--sidebar-mobile-gutter:3rem;--popup-padding-Line truncated
|
||||
/*! tailwindcss v3.4.17 | 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}#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:var(--space-sm);background-color:transparent;background-image:var(--surface-overlay-banner-gradient-header);background-repeat:no-repeat;background-size:100% 100%}.ds-metric-probe{position:absolute;tLine truncated
|
||||
);--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);--scrollbar-width:0.5rem;--scrollbar-track:var(--color-white-soft);--scrollbar-thumb:var(--color-white-softer);--scrollbar-thumb-hover:var(--color-white-softest);--z-app-header:40;--z-overlay-nav:60;--z-overlay-floating:70;--z-overlay-popover:140;--z-overlay-toast:180;--z-sidebar-overlay:10;--z-sidebar:20;--z-modal-root:100;--z-modal-overlay:110;--z-modal-content:120;--z-modal-player:130;--z-modal-top-root:140;--z-modal-top-overlay:150;--z-modal-top-content:160;--z-modal-peak-root:165;--z-modal-peak-overlay:175;--z-modal-peak-content:185;--overlay-backdrop-color:var(--color-overlay-backdrop);--overlay-backdrop-blur:12px;--sidebar-width-expanded-base:16rem;--sidebar-dropup-content-width:0px;--sidebar-width-expanded:max(var(--sidebar-width-expanded-base),var(--sidebar-dropup-content-width));--sidebar-width-collapsed:4rem;--sidebar-width:var(--sidebar-width-expanded);--sidebar-control-size:2.75rem;--sidebar-content-gap:1rem;--space-sidebar-shell-inline:var(--space-lg);--space-sidebar-shell-block-start:var(--space-sm);--space-sidebar-shell-block-end:1.75rem;--space-sidebar-scroll-gap:1.75rem;--space-sidebar-panel-gap:1.35rem;--space-sidebar-panel-padding-block:1.35rem;--space-sidebar-panel-padding-inline:1.1rem;--space-sidebar-footer-gap:0.85rem;--space-sidebar-trigger-padding-block:0.78rem;--space-sidebar-popover-offset:0.85rem;--space-sidebar-popover-padding-block:0.85rem;--space-sidebar-popover-padding-inline:0.65rem;--space-sidebar-popover-gap:0.45rem;--space-sidebar-dropup-scroll-reserve:calc(var(--scrollbar-thin-width) + var(--space-sm) + var(--space-2xs));--sidebar-dropup-max-height:18rem;--space-sidebar-nav-padding-block:0.7rem;--space-sidebar-nav-padding-inline-start:var(--space-xs);--space-sidebar-nav-padding-inline-end:calc(var(--space-lg) + var(--space-2xs));--space-sidebar-subnav-padding-block:0.55rem;--space-sidebar-nav-translate:0.35rem;--grid-card-min-width:20rem;--popover-backdrop-blur:12px;--overlay-panel-radius:var(--radius-lg);--overlay-panel-shadow:var(--shadow-lg);--overlay-panel-padding-inline:var(--popup-padding-inline);--overlay-panel-padding-block:var(--popup-padding-Line truncated
|
||||
/*! tailwindcss v3.4.17 | 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}#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:var(--space-sm);background-color:transparent;background-image:var(--surface-overlay-banner-gradient-header);background-repeat:no-repeat;background-size:100% 100%}.ds-metric-probe{position:absolute;tLine 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__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)}#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(14rem,calc(100vh - var(--player-modal-vertical-padding)*2 - var(--space-2xl)),28rem);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)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;background-color:var(--surface-overlay-panel);background-image:linear-gradient(180deg,color-mix(in srgb,var(--surface-overlay-panel) 94%,transparent) 0,color-mix(in srgb,var(--surface-overlay-panel) 88%,transparent) 100%);border-bottom:var(--border-width-hairline) solid var(--border-oveLine 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(5.75rem,18vw,8rem)}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media{width:clamp(5.75rem,18vw,8rem)!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{--player-modal-similar-thumb:clamp(11rem,calc(var(--video-modal-secondary-min, 320px)*0.62),17rem);--player-modal-similar-gap:clamp(var(--space-4xs),0.65vw,var(--space-2xs));--player-modal-similar-padding:clamp(var(--space-4xs),0.7vw,var(--space-sm));position:relative;display:grid;grid-template-columns:var(--player-modal-similar-thumb) minmax(0,1fr);align-items:stretch;gap:var(--player-modal-similar-gap);padding:var(--player-modal-similar-padding);width:100%;min-width:0;transform:translate3d(0,var(--video-card-lift-translate-y,0),0)}.player-modal__similar-card:before{content:"";position:absolute;inset:0;z-index:0;opacity:var(--video-card-backdrop-opacity,0);background-image:var(
|
||||
--similar-card-thumb-url,var(--video-card-thumb-url,none)
|
||||
);background-position:50%;background-repeat:no-repeat;background-size:cover;transform:translate3d(0,var(--video-card-backdrop-translate-y,0),0) scale(var(--video-card-backdrop-scale,1));filter:blur(var(--video-card-backdrop-blur,0));transition:opacity var(--motion-duration-base) var(--motion-ease-in-out),transform var(--motion-duration-base) var(--motion-ease-in-out),filter var(--motion-duration-base) var(--motion-ease-in-out);pointer-events:none;will-change:opacity,transform,filter}.player-modal__similar-card>*{position:relative;z-index:1}.player-modal__similar-card:focus-within,.player-modal__similar-card:hover{--video-card-backdrop-opacity:0.425;--video-card-backdrop-translate-y:0.15rem;--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:24px}.player-modal__similar-card-media{display:block;position:relative;inline-size:var(--player-modal-similar-thumb);block-size:100%;max-inline-size:clamp(calc(var(--space-2xl-plus)*1.1),28vw,calc(var(--space-2xl-plus)*2.1));border-radius:var(--radius-lg);overflow:hidden;isolation:isolate}.player-modal__similar-card-media img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover;border-radius:inherit}.player-modal__similar-card-content{display:flex;flex-direction:column;gap:clamp(var(--space-4xs),.6vw,var(--space-2xs));min-width:0}.player-modal__similar-card-title{display:-webkit-box;margin:0;font-size:clamp(var(--font-size-sm),1.05vw,var(--font-size-base));font-weight:600;line-height:1.35;color:var(--color-text);text-decoration:none;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;transition:color var(--motion-duration-base) var(--motion-ease-in-out)}.player-modal__similar-card-title:focus-visible,.player-modal__similar-card-title:hover{color:var(--color-accent)}.player-modal__similar-card-author{display:flex;align-items:center;-moz-column-gap:clamp(var(--space-5xs),.6vw,var(--space-3xs));column-gap:clamp(var(--space-5xs),.6vw,var(--space-3xs));font-size:var(--font-size-xs);color:var(--color-text-muted);min-width:0}.player-modal__similar-card-avatar{flex:0 0 auto;display:block;inline-size:clamp(2.5rem,.8vw + 2.1rem,3rem);block-size:clamp(2.5rem,.8vw + 2.1rem,3rem);border-radius:var(--radius-full);overflow:hidden;background-color:var(--color-surface-alt);box-shadow:0 0 0 1px color-mix(in srgb,var(--color-border-strong) 65%,transparent)}.player-modal__similar-card-avatar-img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}.player-modal__similar-card-author-meta{display:flex;flex-direction:column;align-items:flex-start;gap:clamp(var(--space-6xs),.35vw,var(--space-5xs));min-width:0}.player-modal__similar-card-author-name{font-weight:500;color:var(--color-text);min-width:0}.player-modal__similar-card-author-npub{display:inline-flex;padding-inline:var(--space-6xs);border-radius:var(--radius-full);font-family:var(--font-family-mono);line-height:1.2;background-color:color-mix(in srgb,var(--color-surface-alt) 80%,transparent)}.player-modal__similar-card-author-npub,.player-modal__similar-card-meta{align-items:center;font-size:var(--font-size-2xs,.75rem);color:var(--color-muted-strong)}.player-modal__similar-card-meta{display:flex;flex-wrap:wrap;gap:clamp(var(--space-5xs),.5vw,var(--space-4xs));min-width:0}.player-modal__similar-card-timestamp{white-space:nowrap}.player-modal__similar-card-separator{color:inherit;opacity:.8}.player-modal__similar-card-views{white-space:nowrap}.video-modal__stage{position:relative;isolation:isolate;overflow:visible;--video-modal-ambient-bleed:clamp(var(--space-2xl),12vw,var(--space-5xl));--video-modal-ambient-scale:1.38;--video-modal-ambient-blur:clamp(128px,20vw,256px);--video-modal-ambient-opacity:1}.video-modal__video{position:relative;z-index:1;border-radius:var(--radius-xl);padding-block:clamp(var(--space-xs),2.5vw,var(--space-md-plus));background-color:transparent;box-shadow:0 0 0 1px color-mix(in srgb,var(--color-border-strong) 8%,transparent) inset;overflow:visible}.video-modal__ambient{position:absolute;inset:calc(var(--video-modal-ambient-bleed)*-1);border-radius:calc(var(--radius-3xl) + var(--video-modal-ambient-bleed));pointer-events:none;z-index:0;display:block;overflow:visible}.video-modal__ambient:before{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--color-overlay-strong) 18%,transparent) 0,color-mix(in srgb,var(--color-overlay) 34%,transparent) 62%,transparent 100%);opacity:.9;mix-blend-mode:screen;pointer-events:none}.video-modal__ambient-canvas{position:relative;width:100%;height:100%;display:block;transform:scale(var(--video-modal-ambient-scale));filter:blur(var(--video-modal-ambient-blur)) saturate(2) brightness(1.24);opacity:var(--video-modal-ambient-opacity)}@media (min-width:1024px){.video-modal__video{position:sticky;top:calc(var(--space-2xl) + var(--space-xl))}}#playerModal .video-modal__video video{position:relative;z-index:1;background-color:var(--color-black);border-Line truncated
|
||||
);background-position:50%;background-repeat:no-repeat;background-size:cover;transform:translate3d(0,var(--video-card-backdrop-translate-y,0),0) scale(var(--video-card-backdrop-scale,1));filter:blur(var(--video-card-backdrop-blur,0));transition:opacity var(--motion-duration-base) var(--motion-ease-in-out),transform var(--motion-duration-base) var(--motion-ease-in-out),filter var(--motion-duration-base) var(--motion-ease-in-out);pointer-events:none;will-change:opacity,transform,filter}.player-modal__similar-card>*{position:relative;z-index:1}.player-modal__similar-card:focus-within,.player-modal__similar-card:hover{--video-card-backdrop-opacity:0.425;--video-card-backdrop-translate-y:0.15rem;--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:24px}.player-modal__similar-card-media{display:block;position:relative;inline-size:var(--player-modal-similar-thumb);block-size:100%;max-inline-size:clamp(calc(var(--space-2xl-plus)*1.1),28vw,calc(var(--space-2xl-plus)*2.1));border-radius:var(--radius-lg);overflow:hidden;isolation:isolate}.player-modal__similar-card-media img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover;border-radius:inherit}.player-modal__similar-card-content{display:flex;flex-direction:column;gap:clamp(var(--space-4xs),.6vw,var(--space-2xs));min-width:0}.player-modal__similar-card-title{display:-webkit-box;margin:0;font-size:clamp(var(--font-size-sm),1.05vw,var(--font-size-base));font-weight:600;line-height:1.35;color:var(--color-text);text-decoration:none;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;transition:color var(--motion-duration-base) var(--motion-ease-in-out)}.player-modal__similar-card-title:focus-visible,.player-modal__similar-card-title:hover{color:var(--color-accent)}.player-modal__similar-card-author{display:flex;align-items:center;-moz-column-gap:clamp(var(--space-5xs),.6vw,var(--space-3xs));column-gap:clamp(var(--space-5xs),.6vw,var(--space-3xs));font-size:var(--font-size-xs);color:var(--color-text-muted);min-width:0}.player-modal__similar-card-avatar{flex:0 0 auto;display:block;inline-size:clamp(2.5rem,.8vw + 2.1rem,3rem);block-size:clamp(2.5rem,.8vw + 2.1rem,3rem);border-radius:var(--radius-full);overflow:hidden;background-color:var(--color-surface-alt);box-shadow:0 0 0 1px color-mix(in srgb,var(--color-border-strong) 65%,transparent)}.player-modal__similar-card-avatar-img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}.player-modal__similar-card-author-meta{display:flex;flex-direction:column;align-items:flex-start;gap:clamp(var(--space-6xs),.35vw,var(--space-5xs));min-width:0}.player-modal__similar-card-author-name{font-weight:500;color:var(--color-text);min-width:0}.player-modal__similar-card-author-npub{display:inline-flex;padding-inline:var(--space-6xs);border-radius:var(--radius-full);font-family:var(--font-family-mono);line-height:1.2;background-color:color-mix(in srgb,var(--color-surface-alt) 80%,transparent)}.player-modal__similar-card-author-npub,.player-modal__similar-card-meta{align-items:center;font-size:var(--font-size-2xs,.75rem);color:var(--color-muted-strong)}.player-modal__similar-card-meta{display:flex;flex-wrap:wrap;gap:clamp(var(--space-5xs),.5vw,var(--space-4xs));min-width:0}.player-modal__similar-card-timestamp{white-space:nowrap}.player-modal__similar-card-separator{color:inherit;opacity:.8}.player-modal__similar-card-views{white-space:nowrap}.video-modal__stage{position:relative;isolation:isolate;overflow:visible;--video-modal-ambient-bleed:clamp(var(--space-2xl),12vw,var(--space-5xl));--video-modal-ambient-scale:1.38;--video-modal-ambient-blur:clamp(128px,20vw,256px);--video-modal-ambient-opacity:1}.video-modal__video{position:relative;z-index:1;border-radius:var(--radius-xl);padding-block:clamp(var(--space-xs),2.5vw,var(--space-md-plus));background-color:transparent;box-shadow:0 0 0 1px color-mix(in srgb,var(--color-border-strong) 8%,transparent) inset;overflow:visible}.video-modal__ambient{position:absolute;inset:calc(var(--video-modal-ambient-bleed)*-1);border-radius:calc(var(--radius-3xl) + var(--video-modal-ambient-bleed));pointer-events:none;z-index:0;display:block;overflow:visible}.video-modal__ambient:before{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--color-overlay-strong) 18%,transparent) 0,color-mix(in srgb,var(--color-overlay) 34%,transparent) 62%,transparent 100%);opacity:.9;mix-blend-mode:screen;pointer-events:none}.video-modal__ambient-canvas{position:relative;width:100%;height:100%;display:block;transform:scale(var(--video-modal-ambient-scale));filter:blur(var(--video-modal-ambient-blur)) saturate(2) brightness(1.24);opacity:var(--video-modal-ambient-opacity)}@media (min-width:1024px){.video-modal__video{position:sticky;top:calc(var(--space-2xl) + var(--space-xl))}}#playerModal .video-modal__video video{position:relative;z-index:1;background-color:var(--color-black);border-Line truncated
|
||||
@@ -3604,15 +3604,15 @@
|
||||
}
|
||||
|
||||
#modalContainer > #loginModal {
|
||||
z-index: var(--z-modal-top-root);
|
||||
z-index: var(--z-modal-peak-root);
|
||||
}
|
||||
|
||||
#modalContainer > #loginModal > .bv-modal-backdrop {
|
||||
z-index: var(--z-modal-top-overlay);
|
||||
z-index: var(--z-modal-peak-overlay);
|
||||
}
|
||||
|
||||
#modalContainer > #loginModal .bv-modal__panel {
|
||||
z-index: var(--z-modal-top-content);
|
||||
z-index: var(--z-modal-peak-content);
|
||||
}
|
||||
|
||||
#modalContainer > #playerModal {
|
||||
|
||||
@@ -160,6 +160,9 @@
|
||||
--z-modal-top-root: 140;
|
||||
--z-modal-top-overlay: 150;
|
||||
--z-modal-top-content: 160;
|
||||
--z-modal-peak-root: 165;
|
||||
--z-modal-peak-overlay: 175;
|
||||
--z-modal-peak-content: 185;
|
||||
--overlay-backdrop-color: var(--color-overlay-backdrop);
|
||||
--overlay-backdrop-blur: 12px;
|
||||
--sidebar-width-expanded-base: 16rem;
|
||||
|
||||
@@ -152,6 +152,16 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
|
||||
| Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
|
||||
### Direct messages
|
||||
|
||||
bitvid now consumes both legacy direct messages (kind `4`) and modern gift-wrap envelopes (kind `1059`). The `js/dmDecryptor.js` helper unwraps these events by:
|
||||
|
||||
* checking `['encrypted']` hints on kind `4` events to prioritize `nip44` ciphertext before falling back to `nip04`
|
||||
* unwrapping kind `1059` gift wraps with nested `nip44` decrypt operations until the inner rumor payload is available
|
||||
* returning a normalized payload that carries sender metadata, recipient relay hints, the decrypted plaintext, and derived timestamps for sorting
|
||||
|
||||
`NostrClient` exposes `listDirectMessages()` and `subscribeDirectMessages()` APIs that hydrate decryptors lazily (preferring extension-provided helpers) and cache results in an LRU keyed by event id. `NostrService` mirrors the normalized messages via `getDirectMessages()` and emits updates as new events arrive so the UI can render private conversations without reimplementing the unwrap logic.
|
||||
|
||||
The `isPrivate` flag in Content Schema v3 marks cards that should stay off shared or public grids. Clients should suppress these events for everyone except the owner, even though the payload stays in clear text for compatibility.
|
||||
|
||||
If you introduce a new Nostr feature, add its schema to
|
||||
|
||||
+205
-6
@@ -13,12 +13,45 @@ import {
|
||||
persistAdminState,
|
||||
readCachedAdminState,
|
||||
} from "./adminListStore.js";
|
||||
import { ensureNostrTools } from "./nostr/toolkit.js";
|
||||
import { userLogger } from "./utils/logger.js";
|
||||
|
||||
const HEX_KEY_PATTERN = /^[0-9a-f]{64}$/i;
|
||||
|
||||
function normalizeNpub(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function normalizeHexKey(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
return HEX_KEY_PATTERN.test(trimmed) ? trimmed : "";
|
||||
}
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
if (!bytes || typeof bytes !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof bytes.length !== "number") {
|
||||
return "";
|
||||
}
|
||||
|
||||
let hex = "";
|
||||
for (const byte of Array.from(bytes)) {
|
||||
const normalized = Number(byte);
|
||||
if (!Number.isInteger(normalized) || normalized < 0 || normalized > 255) {
|
||||
return "";
|
||||
}
|
||||
hex += normalized.toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
return normalizeHexKey(hex);
|
||||
}
|
||||
|
||||
function dedupeNpubs(values) {
|
||||
const normalized = Array.isArray(values) ? values.map(normalizeNpub) : [];
|
||||
return Array.from(
|
||||
@@ -73,7 +106,9 @@ class AccessControl {
|
||||
constructor() {
|
||||
this.editors = new Set();
|
||||
this.whitelist = new Set();
|
||||
this.whitelistPubkeys = new Set();
|
||||
this.blacklist = new Set();
|
||||
this.blacklistPubkeys = new Set();
|
||||
this.whitelistEnabled = getWhitelistMode();
|
||||
this.hasLoaded = false;
|
||||
this.lastError = null;
|
||||
@@ -112,6 +147,85 @@ class AccessControl {
|
||||
}
|
||||
}
|
||||
|
||||
_rebuildHexSets(
|
||||
whitelistValues = [],
|
||||
blacklistValues = [],
|
||||
toolkitCandidate = null
|
||||
) {
|
||||
const whitelistHex = new Set();
|
||||
const blacklistHex = new Set();
|
||||
|
||||
const decodeSources = [];
|
||||
const addDecodeSource = (candidate) => {
|
||||
if (
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
candidate.nip19 &&
|
||||
typeof candidate.nip19.decode === "function" &&
|
||||
!decodeSources.includes(candidate.nip19)
|
||||
) {
|
||||
decodeSources.push(candidate.nip19);
|
||||
}
|
||||
};
|
||||
|
||||
addDecodeSource(toolkitCandidate);
|
||||
|
||||
const scopeTools =
|
||||
(typeof window !== "undefined" ? window?.NostrTools : null) ||
|
||||
(typeof globalThis !== "undefined" ? globalThis?.NostrTools : null) ||
|
||||
null;
|
||||
addDecodeSource(scopeTools);
|
||||
|
||||
const decodeNpubToHex = (npub) => {
|
||||
const normalized = normalizeNpub(npub);
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (const nip19 of decodeSources) {
|
||||
try {
|
||||
const decoded = nip19.decode(normalized);
|
||||
if (!decoded || decoded.type !== "npub") {
|
||||
continue;
|
||||
}
|
||||
const data = decoded.data;
|
||||
if (typeof data === "string") {
|
||||
const normalizedHex = normalizeHexKey(data);
|
||||
if (normalizedHex) {
|
||||
return normalizedHex;
|
||||
}
|
||||
} else {
|
||||
const converted = bytesToHex(data);
|
||||
if (converted) {
|
||||
return converted;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
for (const entry of whitelistValues) {
|
||||
const decodedHex = decodeNpubToHex(entry);
|
||||
if (decodedHex) {
|
||||
whitelistHex.add(decodedHex);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of blacklistValues) {
|
||||
const decodedHex = decodeNpubToHex(entry);
|
||||
if (decodedHex) {
|
||||
blacklistHex.add(decodedHex);
|
||||
}
|
||||
}
|
||||
|
||||
this.whitelistPubkeys = whitelistHex;
|
||||
this.blacklistPubkeys = blacklistHex;
|
||||
}
|
||||
|
||||
_applyState(state, options = {}) {
|
||||
const markLoaded = options.markLoaded !== false;
|
||||
const editors = Array.isArray(state?.editors) ? state.editors : [];
|
||||
@@ -152,6 +266,37 @@ class AccessControl {
|
||||
});
|
||||
this.blacklist = new Set(sanitizedBlacklist);
|
||||
|
||||
const toolkitCandidate =
|
||||
(typeof window !== "undefined" ? window?.NostrTools : null) ||
|
||||
(typeof globalThis !== "undefined" ? globalThis?.NostrTools : null);
|
||||
this._rebuildHexSets(normalizedWhitelist, sanitizedBlacklist, toolkitCandidate);
|
||||
|
||||
try {
|
||||
const ensured = ensureNostrTools();
|
||||
if (ensured && typeof ensured.then === "function") {
|
||||
ensured
|
||||
.then((tools) => {
|
||||
if (!tools) {
|
||||
return;
|
||||
}
|
||||
this._rebuildHexSets(
|
||||
Array.from(this.whitelist),
|
||||
Array.from(this.blacklist),
|
||||
tools
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (ensured) {
|
||||
this._rebuildHexSets(
|
||||
Array.from(this.whitelist),
|
||||
Array.from(this.blacklist),
|
||||
ensured
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore toolkit errors; we'll attempt to hydrate again on the next refresh
|
||||
}
|
||||
|
||||
this.whitelistEnabled = getWhitelistMode();
|
||||
this.lastError = null;
|
||||
|
||||
@@ -220,7 +365,9 @@ class AccessControl {
|
||||
if (!this.hasLoaded && !this._hydratedFromCache) {
|
||||
this.editors.clear();
|
||||
this.whitelist.clear();
|
||||
this.whitelistPubkeys.clear();
|
||||
this.blacklist.clear();
|
||||
this.blacklistPubkeys.clear();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -497,29 +644,72 @@ class AccessControl {
|
||||
if (this.whitelist.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedHexCandidate = normalizeHexKey(normalized);
|
||||
if (normalizedHexCandidate) {
|
||||
if (this.whitelistPubkeys.has(normalizedHexCandidate)) {
|
||||
return false;
|
||||
}
|
||||
return this.blacklistPubkeys.has(normalizedHexCandidate);
|
||||
}
|
||||
|
||||
return this.blacklist.has(normalized);
|
||||
}
|
||||
|
||||
canAccess(candidate) {
|
||||
let npub = "";
|
||||
let hex = "";
|
||||
|
||||
const considerHexCandidate = (value) => {
|
||||
const normalizedHex = normalizeHexKey(value);
|
||||
if (!normalizedHex) {
|
||||
return false;
|
||||
}
|
||||
hex = normalizedHex;
|
||||
return true;
|
||||
};
|
||||
|
||||
if (typeof candidate === "string") {
|
||||
npub = candidate;
|
||||
if (!considerHexCandidate(candidate)) {
|
||||
npub = candidate;
|
||||
}
|
||||
} else if (candidate && typeof candidate === "object") {
|
||||
if (typeof candidate.npub === "string") {
|
||||
npub = candidate.npub;
|
||||
} else if (typeof candidate.pubkey === "string") {
|
||||
try {
|
||||
npub = window.NostrTools.nip19.npubEncode(candidate.pubkey);
|
||||
} catch (error) {
|
||||
npub = candidate.pubkey;
|
||||
if (!considerHexCandidate(candidate.pubkey)) {
|
||||
try {
|
||||
npub = window.NostrTools.nip19.npubEncode(candidate.pubkey);
|
||||
} catch (error) {
|
||||
npub = candidate.pubkey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeNpub(npub);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
if (!hex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isLockdownActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.whitelistPubkeys.has(hex)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.blacklistPubkeys.has(hex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.whitelistEnabled && !this.whitelistPubkeys.has(hex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isAdminEditor(normalized)) {
|
||||
@@ -530,6 +720,15 @@ class AccessControl {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hex) {
|
||||
if (this.whitelistPubkeys.has(hex)) {
|
||||
return true;
|
||||
}
|
||||
if (this.blacklistPubkeys.has(hex)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.whitelist.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
+15
-4
@@ -3448,7 +3448,7 @@ export async function initChannelProfileView() {
|
||||
// 3) Load subscription state when logged in, but always render the toggle UI
|
||||
if (app?.pubkey) {
|
||||
subscriptionsTask = subscriptions
|
||||
.loadSubscriptions(app.pubkey)
|
||||
.ensureLoaded(app.pubkey)
|
||||
.catch((error) => {
|
||||
userLogger.error("Failed to load subscriptions for channel view:", error);
|
||||
})
|
||||
@@ -3833,6 +3833,14 @@ function renderSubscribeButton(channelHex) {
|
||||
renderSubscribeButton(channelHex);
|
||||
} catch (err) {
|
||||
userLogger.error("Failed to update subscription:", err);
|
||||
const permissionErrorCodes = new Set([
|
||||
"extension-permission-denied",
|
||||
"nip04-missing",
|
||||
]);
|
||||
const message = permissionErrorCodes.has(err?.code)
|
||||
? "Your Nostr extension must support NIP-04 to manage private lists."
|
||||
: "Failed to update your subscriptions. Please try again.";
|
||||
currentApp?.showError?.(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4428,21 +4436,24 @@ export async function renderChannelVideosFromList({
|
||||
videoCard.onEdit = ({ video: editVideo, index: editIndex }) => {
|
||||
app?.handleEditVideo?.({
|
||||
eventId: editVideo?.id || "",
|
||||
index: Number.isFinite(editIndex) ? editIndex : null
|
||||
index: Number.isFinite(editIndex) ? editIndex : null,
|
||||
video: editVideo || null
|
||||
});
|
||||
};
|
||||
|
||||
videoCard.onRevert = ({ video: revertVideo, index: revertIndex }) => {
|
||||
app?.handleRevertVideo?.({
|
||||
eventId: revertVideo?.id || "",
|
||||
index: Number.isFinite(revertIndex) ? revertIndex : null
|
||||
index: Number.isFinite(revertIndex) ? revertIndex : null,
|
||||
video: revertVideo || null
|
||||
});
|
||||
};
|
||||
|
||||
videoCard.onDelete = ({ video: deleteVideo, index: deleteIndex }) => {
|
||||
app?.handleFullDeleteVideo?.({
|
||||
eventId: deleteVideo?.id || "",
|
||||
index: Number.isFinite(deleteIndex) ? deleteIndex : null
|
||||
index: Number.isFinite(deleteIndex) ? deleteIndex : null,
|
||||
video: deleteVideo || null
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { userLogger } from "./utils/logger.js";
|
||||
|
||||
const DB_NAME = "bitvidSettings";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "kv";
|
||||
const KEY_PREFIX = "dmSnapshot:";
|
||||
const LOCALSTORAGE_PREFIX = "bitvid:dmSnapshot:";
|
||||
const PREVIEW_MAX_LENGTH = 160;
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
function isIndexedDbAvailable() {
|
||||
try {
|
||||
return typeof indexedDB !== "undefined";
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function openSettingsDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isIndexedDbAvailable()) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error("Failed to open settings DB"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1") && typeof window !== "undefined") {
|
||||
try {
|
||||
const decoded = window?.NostrTools?.nip19?.decode?.(trimmed);
|
||||
if (decoded?.type === "npub" && typeof decoded.data === "string") {
|
||||
const hex = decoded.data.trim();
|
||||
if (HEX64_REGEX.test(hex)) {
|
||||
return hex.toLowerCase();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore decode errors and fall through to return empty string.
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveStorageKeys(pubkey) {
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid pubkey for DM snapshot storage.");
|
||||
}
|
||||
|
||||
return {
|
||||
normalized,
|
||||
dbKey: `${KEY_PREFIX}${normalized}`,
|
||||
fallbackKey: `${LOCALSTORAGE_PREFIX}${normalized}`,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeTimestamp(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rounded = Math.floor(numeric);
|
||||
return Number.isFinite(rounded) && rounded > 0 ? rounded : 0;
|
||||
}
|
||||
|
||||
function sanitizePreview(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
let preview = value.replace(/\s+/g, " ").trim();
|
||||
if (!preview) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (preview.length > PREVIEW_MAX_LENGTH) {
|
||||
preview = `${preview.slice(0, PREVIEW_MAX_LENGTH).trimEnd()}\u2026`;
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
function normalizeSnapshotEntry(entry) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const remotePubkey = normalizePubkey(
|
||||
entry.remotePubkey || entry.remote || entry.pubkey,
|
||||
);
|
||||
if (!remotePubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latestTimestamp = sanitizeTimestamp(
|
||||
entry.latestTimestamp ?? entry.timestamp ?? entry.lastTimestamp,
|
||||
);
|
||||
|
||||
const preview = sanitizePreview(
|
||||
entry.preview ?? entry.plaintext ?? entry.text ?? "",
|
||||
);
|
||||
|
||||
return {
|
||||
remotePubkey,
|
||||
latestTimestamp,
|
||||
preview,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSnapshotList(raw) {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
|
||||
for (const entry of raw) {
|
||||
const normalized = normalizeSnapshotEntry(entry);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(normalized.remotePubkey)) {
|
||||
const existingIndex = results.findIndex(
|
||||
(candidate) => candidate.remotePubkey === normalized.remotePubkey,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const existing = results[existingIndex];
|
||||
if (normalized.latestTimestamp > existing.latestTimestamp) {
|
||||
results[existingIndex] = normalized;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(normalized.remotePubkey);
|
||||
results.push(normalized);
|
||||
}
|
||||
|
||||
results.sort(
|
||||
(a, b) => (b.latestTimestamp || 0) - (a.latestTimestamp || 0),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function loadDirectMessageSnapshot(pubkey) {
|
||||
let keys;
|
||||
try {
|
||||
keys = resolveStorageKeys(pubkey);
|
||||
} catch (error) {
|
||||
userLogger.warn("[directMessagesStore] Invalid pubkey for load", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const { normalized, dbKey, fallbackKey } = keys;
|
||||
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (db) {
|
||||
const stored = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(dbKey);
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () =>
|
||||
reject(request.error || new Error("Failed to load snapshot"));
|
||||
});
|
||||
|
||||
return normalizeSnapshotList(stored);
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[directMessagesStore] Failed to read IndexedDB snapshot",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
const raw = localStorage.getItem(fallbackKey);
|
||||
if (raw) {
|
||||
return normalizeSnapshotList(JSON.parse(raw));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[directMessagesStore] Failed to read fallback snapshot",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// If we reached here, ensure we still return a normalized empty list for
|
||||
// callers that rely on consistent return values.
|
||||
return normalizeSnapshotList([]);
|
||||
}
|
||||
|
||||
export async function saveDirectMessageSnapshot(pubkey, snapshot) {
|
||||
let keys;
|
||||
try {
|
||||
keys = resolveStorageKeys(pubkey);
|
||||
} catch (error) {
|
||||
userLogger.warn("[directMessagesStore] Invalid pubkey for save", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedSnapshot = normalizeSnapshotList(snapshot);
|
||||
const { dbKey, fallbackKey } = keys;
|
||||
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (db) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.put(normalizedSnapshot, dbKey);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () =>
|
||||
reject(request.error || new Error("Failed to write snapshot"));
|
||||
});
|
||||
return normalizedSnapshot;
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[directMessagesStore] Failed to write IndexedDB snapshot",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(fallbackKey, JSON.stringify(normalizedSnapshot));
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[directMessagesStore] Failed to write fallback snapshot",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return normalizedSnapshot;
|
||||
}
|
||||
|
||||
export async function clearDirectMessageSnapshot(pubkey) {
|
||||
let keys;
|
||||
try {
|
||||
keys = resolveStorageKeys(pubkey);
|
||||
} catch (error) {
|
||||
userLogger.warn("[directMessagesStore] Invalid pubkey for clear", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { dbKey, fallbackKey } = keys;
|
||||
let cleared = false;
|
||||
|
||||
try {
|
||||
const db = await openSettingsDb();
|
||||
if (db) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.delete(dbKey);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () =>
|
||||
reject(request.error || new Error("Failed to clear snapshot"));
|
||||
});
|
||||
cleared = true;
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[directMessagesStore] Failed to clear IndexedDB snapshot",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(fallbackKey);
|
||||
cleared = true;
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[directMessagesStore] Failed to clear fallback snapshot",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return cleared;
|
||||
}
|
||||
|
||||
export function buildDirectMessageSnapshotPayload(entries) {
|
||||
return normalizeSnapshotList(entries);
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
// js/dmDecryptor.js
|
||||
|
||||
import { normalizeActorKey } from "./nostr/watchHistory.js";
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/;
|
||||
|
||||
const SUPPORTED_KINDS = new Set([4, 1059]);
|
||||
|
||||
const GIFT_WRAP_KIND = 1059;
|
||||
|
||||
function cloneEvent(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cloned = { ...event };
|
||||
if (Array.isArray(event.tags)) {
|
||||
cloned.tags = event.tags.map((tag) =>
|
||||
Array.isArray(tag) ? [...tag] : tag,
|
||||
);
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function normalizeHex(candidate) {
|
||||
if (typeof candidate !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = candidate.trim().toLowerCase();
|
||||
if (!HEX64_REGEX.test(trimmed)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeScheme(scheme) {
|
||||
if (typeof scheme !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalized = scheme.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (normalized === "nip44_v2" || normalized === "nip44-v2") {
|
||||
return "nip44_v2";
|
||||
}
|
||||
if (normalized === "nip44" || normalized === "nip-44") {
|
||||
return "nip44";
|
||||
}
|
||||
if (normalized === "nip04" || normalized === "nip-04") {
|
||||
return "nip04";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseEncryptionHints(rawTags) {
|
||||
const tags = Array.isArray(rawTags) ? rawTags : [];
|
||||
const algorithms = [];
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
if (tag[0] !== "encrypted") {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let index = 1; index < tag.length; index += 1) {
|
||||
const value = typeof tag[index] === "string" ? tag[index].trim() : "";
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = value.split(/\s+/).filter(Boolean);
|
||||
for (const part of parts) {
|
||||
const normalized = normalizeScheme(part);
|
||||
if (normalized && !algorithms.includes(normalized)) {
|
||||
algorithms.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { algorithms };
|
||||
}
|
||||
|
||||
function collectRelayHints(tag) {
|
||||
const hints = [];
|
||||
if (!Array.isArray(tag)) {
|
||||
return hints;
|
||||
}
|
||||
|
||||
for (let index = 2; index < tag.length; index += 1) {
|
||||
const candidate = typeof tag[index] === "string" ? tag[index].trim() : "";
|
||||
if (candidate) {
|
||||
hints.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
|
||||
function collectRecipients(rawTags) {
|
||||
const tags = Array.isArray(rawTags) ? rawTags : [];
|
||||
const recipients = [];
|
||||
const seen = new Map();
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag[0] !== "p") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pubkey = normalizeHex(tag[1]);
|
||||
if (!pubkey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hints = collectRelayHints(tag);
|
||||
|
||||
if (seen.has(pubkey)) {
|
||||
const existing = seen.get(pubkey);
|
||||
for (const hint of hints) {
|
||||
if (!existing.relayHints.includes(hint)) {
|
||||
existing.relayHints.push(hint);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const recipient = {
|
||||
pubkey,
|
||||
relayHints: hints,
|
||||
role: "recipient",
|
||||
};
|
||||
seen.set(pubkey, recipient);
|
||||
recipients.push(recipient);
|
||||
}
|
||||
|
||||
return recipients;
|
||||
}
|
||||
|
||||
function ensureDecryptCandidates(rawCandidates) {
|
||||
if (!Array.isArray(rawCandidates)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawCandidates
|
||||
.map((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decrypt = candidate.decrypt;
|
||||
if (typeof decrypt !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheme = normalizeScheme(candidate.scheme) || "";
|
||||
const supportsGiftWrap = candidate.supportsGiftWrap === true || scheme.startsWith("nip44");
|
||||
const priority = Number.isFinite(candidate.priority) ? candidate.priority : 0;
|
||||
|
||||
return {
|
||||
scheme: scheme || "",
|
||||
decrypt,
|
||||
priority,
|
||||
supportsGiftWrap,
|
||||
source: candidate.source || "",
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) {
|
||||
if (!candidates.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const algorithms = Array.isArray(hints?.algorithms) ? hints.algorithms : [];
|
||||
const algorithmRanks = new Map();
|
||||
algorithms.forEach((algorithm, index) => {
|
||||
if (!algorithmRanks.has(algorithm)) {
|
||||
algorithmRanks.set(algorithm, index);
|
||||
}
|
||||
});
|
||||
|
||||
const baseSchemeRank = (scheme) => {
|
||||
if (scheme === "nip44_v2" || scheme === "nip44") {
|
||||
return 0;
|
||||
}
|
||||
if (scheme === "nip04") {
|
||||
return 5;
|
||||
}
|
||||
return 10;
|
||||
};
|
||||
|
||||
const desiredSchemeRank = (scheme) => {
|
||||
if (algorithmRanks.has(scheme)) {
|
||||
return algorithmRanks.get(scheme);
|
||||
}
|
||||
if (scheme === "nip44" && algorithmRanks.has("nip44_v2")) {
|
||||
return algorithmRanks.get("nip44_v2");
|
||||
}
|
||||
if (scheme === "nip44_v2" && algorithmRanks.has("nip44")) {
|
||||
return algorithmRanks.get("nip44");
|
||||
}
|
||||
return baseSchemeRank(scheme);
|
||||
};
|
||||
|
||||
return [...candidates].sort((a, b) => {
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority;
|
||||
}
|
||||
|
||||
const aRank = desiredSchemeRank(a.scheme);
|
||||
const bRank = desiredSchemeRank(b.scheme);
|
||||
if (aRank !== bRank) {
|
||||
return aRank - bRank;
|
||||
}
|
||||
|
||||
if (preferGiftWrap) {
|
||||
const aWrapScore = a.supportsGiftWrap ? 0 : 1;
|
||||
const bWrapScore = b.supportsGiftWrap ? 0 : 1;
|
||||
if (aWrapScore !== bWrapScore) {
|
||||
return aWrapScore - bWrapScore;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function deriveDirection(actorPubkey, senderPubkey, recipients) {
|
||||
const normalizedActor = normalizeHex(actorPubkey);
|
||||
if (!normalizedActor) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (normalizedActor === normalizeHex(senderPubkey)) {
|
||||
return "outgoing";
|
||||
}
|
||||
|
||||
if (Array.isArray(recipients)) {
|
||||
for (const recipient of recipients) {
|
||||
if (normalizeHex(recipient?.pubkey) === normalizedActor) {
|
||||
return "incoming";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function buildDecryptResult({
|
||||
ok,
|
||||
event,
|
||||
message,
|
||||
plaintext,
|
||||
recipients,
|
||||
senderPubkey,
|
||||
actorPubkey,
|
||||
decryptor,
|
||||
scheme,
|
||||
envelope = null,
|
||||
errors = [],
|
||||
}) {
|
||||
const timestampCandidates = [];
|
||||
if (message && Number.isFinite(message.created_at)) {
|
||||
timestampCandidates.push(message.created_at);
|
||||
}
|
||||
if (event && Number.isFinite(event.created_at)) {
|
||||
timestampCandidates.push(event.created_at);
|
||||
}
|
||||
const timestamp = timestampCandidates.length
|
||||
? Math.max(...timestampCandidates)
|
||||
: Date.now() / 1000;
|
||||
|
||||
return {
|
||||
ok,
|
||||
event: cloneEvent(event),
|
||||
message: message ? cloneEvent(message) : null,
|
||||
plaintext: typeof plaintext === "string" ? plaintext : null,
|
||||
recipients: Array.isArray(recipients) ? recipients : [],
|
||||
sender: senderPubkey
|
||||
? {
|
||||
pubkey: senderPubkey,
|
||||
relayHints: [],
|
||||
role: "sender",
|
||||
}
|
||||
: null,
|
||||
actorPubkey: normalizeHex(actorPubkey),
|
||||
decryptor: decryptor
|
||||
? {
|
||||
scheme: normalizeScheme(decryptor.scheme) || scheme || "",
|
||||
source: decryptor.source || "",
|
||||
}
|
||||
: { scheme: scheme || "", source: "" },
|
||||
scheme: normalizeScheme(scheme || decryptor?.scheme || ""),
|
||||
envelope,
|
||||
direction: deriveDirection(actorPubkey, senderPubkey, recipients),
|
||||
timestamp: Number.isFinite(timestamp) ? timestamp : Date.now() / 1000,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEventJson(serialized, stage) {
|
||||
if (typeof serialized !== "string" || !serialized.trim()) {
|
||||
const error = new Error("Decrypted payload was empty.");
|
||||
error.code = "empty-payload";
|
||||
error.stage = stage;
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(serialized);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
const error = new Error("Decrypted payload was not a valid JSON event.");
|
||||
error.code = "invalid-json";
|
||||
error.stage = stage;
|
||||
throw error;
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
const failure = new Error("Failed to parse decrypted payload as JSON.");
|
||||
failure.code = "json-parse-failed";
|
||||
failure.stage = stage;
|
||||
failure.cause = error;
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptGiftWrap(event, decryptors, actorPubkey) {
|
||||
const ciphertext = typeof event?.content === "string" ? event.content : "";
|
||||
const wrapPubkey = normalizeHex(event?.pubkey);
|
||||
|
||||
if (!ciphertext || !wrapPubkey) {
|
||||
return buildDecryptResult({
|
||||
ok: false,
|
||||
event,
|
||||
actorPubkey,
|
||||
errors: [
|
||||
{
|
||||
stage: "wrap",
|
||||
error: new Error("Gift wrap event is missing ciphertext or pubkey."),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const decryptor of decryptors) {
|
||||
try {
|
||||
const sealSerialized = await decryptor.decrypt(wrapPubkey, ciphertext, {
|
||||
event,
|
||||
stage: "wrap",
|
||||
});
|
||||
const seal = parseEventJson(sealSerialized, "wrap");
|
||||
|
||||
const sealCiphertext = typeof seal?.content === "string" ? seal.content : "";
|
||||
const sealPubkey = normalizeHex(seal?.pubkey);
|
||||
if (!sealCiphertext || !sealPubkey) {
|
||||
const error = new Error(
|
||||
"Gift wrap seal is missing ciphertext or pubkey.",
|
||||
);
|
||||
error.code = "invalid-seal";
|
||||
error.stage = "seal";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rumorSerialized = await decryptor.decrypt(sealPubkey, sealCiphertext, {
|
||||
event: seal,
|
||||
stage: "seal",
|
||||
});
|
||||
const rumor = parseEventJson(rumorSerialized, "rumor");
|
||||
|
||||
const plaintext = typeof rumor?.content === "string" ? rumor.content : "";
|
||||
const senderPubkey = normalizeHex(rumor?.pubkey) || sealPubkey;
|
||||
const recipients = collectRecipients(rumor?.tags);
|
||||
|
||||
return buildDecryptResult({
|
||||
ok: true,
|
||||
event,
|
||||
message: rumor,
|
||||
plaintext,
|
||||
recipients,
|
||||
senderPubkey,
|
||||
actorPubkey,
|
||||
decryptor,
|
||||
scheme: decryptor.scheme || "nip44",
|
||||
envelope: {
|
||||
wrap: cloneEvent(event),
|
||||
seal: cloneEvent(seal),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
scheme: decryptor.scheme || "",
|
||||
source: decryptor.source || "",
|
||||
stage: error?.stage || "wrap",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return buildDecryptResult({
|
||||
ok: false,
|
||||
event,
|
||||
actorPubkey,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
async function decryptLegacyDm(event, decryptors, actorPubkey) {
|
||||
const ciphertext = typeof event?.content === "string" ? event.content : "";
|
||||
const senderPubkey = normalizeHex(event?.pubkey);
|
||||
const hints = parseEncryptionHints(event?.tags);
|
||||
const recipients = collectRecipients(event?.tags);
|
||||
const normalizedActor = normalizeHex(actorPubkey);
|
||||
|
||||
if (!ciphertext || !senderPubkey) {
|
||||
return buildDecryptResult({
|
||||
ok: false,
|
||||
event,
|
||||
actorPubkey,
|
||||
errors: [
|
||||
{
|
||||
stage: "content",
|
||||
error: new Error("Direct message is missing ciphertext or pubkey."),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const ordered = orderDecryptors(decryptors, hints);
|
||||
const errors = [];
|
||||
|
||||
const remoteCandidates = [];
|
||||
const seenRemotes = new Set();
|
||||
const registerRemote = (candidate) => {
|
||||
const normalized = normalizeHex(candidate);
|
||||
if (!normalized || seenRemotes.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seenRemotes.add(normalized);
|
||||
remoteCandidates.push(normalized);
|
||||
};
|
||||
|
||||
if (normalizedActor && normalizedActor === senderPubkey) {
|
||||
for (const recipient of recipients) {
|
||||
registerRemote(recipient?.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
registerRemote(senderPubkey);
|
||||
|
||||
for (const recipient of recipients) {
|
||||
registerRemote(recipient?.pubkey);
|
||||
}
|
||||
|
||||
for (const decryptor of ordered) {
|
||||
for (const remotePubkey of remoteCandidates) {
|
||||
try {
|
||||
const plaintext = await decryptor.decrypt(remotePubkey, ciphertext, {
|
||||
event,
|
||||
stage: "content",
|
||||
remotePubkey,
|
||||
});
|
||||
|
||||
if (typeof plaintext === "string") {
|
||||
return buildDecryptResult({
|
||||
ok: true,
|
||||
event,
|
||||
message: {
|
||||
...cloneEvent(event),
|
||||
content: plaintext,
|
||||
},
|
||||
plaintext,
|
||||
recipients,
|
||||
senderPubkey,
|
||||
actorPubkey,
|
||||
decryptor,
|
||||
scheme: decryptor.scheme || hints.algorithms?.[0] || "",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
scheme: decryptor.scheme || "",
|
||||
source: decryptor.source || "",
|
||||
stage: "content",
|
||||
remotePubkey,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildDecryptResult({
|
||||
ok: false,
|
||||
event,
|
||||
actorPubkey,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
export async function decryptDM(event, context = {}) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return {
|
||||
ok: false,
|
||||
event: null,
|
||||
actorPubkey: normalizeHex(context?.actorPubkey),
|
||||
errors: [
|
||||
{
|
||||
stage: "input",
|
||||
error: new Error("A valid event is required for DM decryption."),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const kind = Number.isFinite(event.kind) ? event.kind : null;
|
||||
if (!SUPPORTED_KINDS.has(kind)) {
|
||||
return {
|
||||
ok: false,
|
||||
event: cloneEvent(event),
|
||||
actorPubkey: normalizeHex(context?.actorPubkey),
|
||||
errors: [
|
||||
{
|
||||
stage: "input",
|
||||
error: new Error("Unsupported DM event kind."),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const actorPubkey = normalizeActorKey(context?.actorPubkey);
|
||||
const decryptors = ensureDecryptCandidates(context?.decryptors);
|
||||
|
||||
if (!decryptors.length) {
|
||||
return {
|
||||
ok: false,
|
||||
event: cloneEvent(event),
|
||||
actorPubkey,
|
||||
errors: [
|
||||
{
|
||||
stage: "decryptor",
|
||||
error: new Error("No decryptors are available for DM payloads."),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === GIFT_WRAP_KIND) {
|
||||
const giftWrapDecryptors = orderDecryptors(decryptors, null, {
|
||||
preferGiftWrap: true,
|
||||
}).filter((candidate) => candidate.supportsGiftWrap);
|
||||
|
||||
if (!giftWrapDecryptors.length) {
|
||||
return {
|
||||
ok: false,
|
||||
event: cloneEvent(event),
|
||||
actorPubkey,
|
||||
errors: [
|
||||
{
|
||||
stage: "decryptor",
|
||||
error: new Error("Gift wrap events require a NIP-44 decryptor."),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return decryptGiftWrap(event, giftWrapDecryptors, actorPubkey);
|
||||
}
|
||||
|
||||
return decryptLegacyDm(event, decryptors, actorPubkey);
|
||||
}
|
||||
|
||||
export const __testUtils = {
|
||||
cloneEvent,
|
||||
normalizeHex,
|
||||
parseEncryptionHints,
|
||||
collectRecipients,
|
||||
orderDecryptors,
|
||||
};
|
||||
|
||||
+24
-3
@@ -768,6 +768,7 @@ export function createModerationStage({
|
||||
let trustedMuted = false;
|
||||
let trustedMuters = [];
|
||||
let trustedMuteCount = 0;
|
||||
let viewerMuted = false;
|
||||
if (videoId) {
|
||||
try {
|
||||
if (typeof resolvedService.getTrustedReportSummary === "function") {
|
||||
@@ -827,10 +828,14 @@ export function createModerationStage({
|
||||
});
|
||||
}
|
||||
}
|
||||
if (typeof resolvedService.isAuthorMutedByViewer === "function") {
|
||||
viewerMuted = resolvedService.isAuthorMutedByViewer(authorHex) === true;
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(`[${stageName}] Failed to resolve trusted mute info`, error);
|
||||
context?.log?.(`[${stageName}] Failed to resolve trusted/viewer mute info`, error);
|
||||
trustedMuted = false;
|
||||
trustedMuters = [];
|
||||
viewerMuted = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,7 +855,7 @@ export function createModerationStage({
|
||||
}
|
||||
|
||||
const blockAutoplay =
|
||||
trustedCount >= normalizedAutoplayThreshold || trustedMuted;
|
||||
trustedCount >= normalizedAutoplayThreshold || trustedMuted || viewerMuted;
|
||||
const blurFromReports = trustedCount >= normalizedBlurThreshold;
|
||||
let blurThumbnail = blurFromReports;
|
||||
let blurReason = blurThumbnail ? "trusted-report" : "";
|
||||
@@ -878,6 +883,7 @@ export function createModerationStage({
|
||||
metadataModeration.trustedMuted = trustedMuted;
|
||||
metadataModeration.trustedMuters = trustedMuters.slice();
|
||||
metadataModeration.trustedMuteCount = trustedMuteCount;
|
||||
metadataModeration.viewerMuted = viewerMuted;
|
||||
|
||||
if (!video.moderation || typeof video.moderation !== "object") {
|
||||
video.moderation = {};
|
||||
@@ -900,6 +906,7 @@ export function createModerationStage({
|
||||
delete video.moderation.trustedMuteCount;
|
||||
}
|
||||
}
|
||||
video.moderation.viewerMuted = viewerMuted;
|
||||
if (Array.isArray(trustedReporters) && trustedReporters.length) {
|
||||
video.moderation.trustedReporters = trustedReporters.slice();
|
||||
} else if (video.moderation.trustedReporters) {
|
||||
@@ -980,16 +987,20 @@ export function createModerationStage({
|
||||
}
|
||||
}
|
||||
|
||||
if (!blurThumbnail && (trustedMuted || hideTriggered)) {
|
||||
if (!blurThumbnail && (viewerMuted || trustedMuted || hideTriggered)) {
|
||||
blurThumbnail = true;
|
||||
if (hideTriggered) {
|
||||
blurReason = hideReason || "trusted-hide";
|
||||
} else if (viewerMuted) {
|
||||
blurReason = "viewer-mute";
|
||||
} else if (trustedMuted) {
|
||||
blurReason = "trusted-mute";
|
||||
}
|
||||
} else if (blurThumbnail) {
|
||||
if (hideTriggered) {
|
||||
blurReason = hideReason || "trusted-hide";
|
||||
} else if (viewerMuted && !blurFromReports) {
|
||||
blurReason = "viewer-mute";
|
||||
} else if (trustedMuted && !blurFromReports) {
|
||||
blurReason = "trusted-mute";
|
||||
} else if (!blurReason && blurFromReports) {
|
||||
@@ -1048,6 +1059,16 @@ export function createModerationStage({
|
||||
});
|
||||
}
|
||||
|
||||
if (viewerMuted) {
|
||||
context?.addWhy?.({
|
||||
stage: stageName,
|
||||
type: "moderation",
|
||||
reason: "viewer-mute",
|
||||
videoId: videoId || null,
|
||||
pubkey: authorHex || authorPubkey || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (hideTriggered) {
|
||||
const hideDetail = {
|
||||
stage: stageName,
|
||||
|
||||
+77
@@ -380,6 +380,83 @@ function startApplication() {
|
||||
return startupPromise;
|
||||
}
|
||||
|
||||
function wasBackForwardNavigation(event) {
|
||||
if (event?.persisted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined" || !window.performance) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof window.performance.getEntriesByType === "function") {
|
||||
try {
|
||||
const navigationEntries = window.performance.getEntriesByType("navigation");
|
||||
if (Array.isArray(navigationEntries) && navigationEntries.length) {
|
||||
const latestEntry = navigationEntries[navigationEntries.length - 1];
|
||||
if (latestEntry && latestEntry.type === "back_forward") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[pageshow] Failed to inspect navigation entries:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const navigation = window.performance.navigation;
|
||||
if (!navigation || typeof navigation.type !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const backForwardType =
|
||||
typeof navigation.TYPE_BACK_FORWARD === "number"
|
||||
? navigation.TYPE_BACK_FORWARD
|
||||
: 2;
|
||||
|
||||
return navigation.type === backForwardType;
|
||||
}
|
||||
|
||||
function handlePageShow(event) {
|
||||
if (!wasBackForwardNavigation(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!application) {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(applicationReadyPromise)
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (!application) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
application.refreshVisibleModerationUi?.({ reason: "pageshow" });
|
||||
} catch (error) {
|
||||
devLogger.warn("[pageshow] Failed to refresh moderation UI after resume:", error);
|
||||
}
|
||||
|
||||
if (typeof application.refreshAllVideoGrids !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return application
|
||||
.refreshAllVideoGrids({ reason: "pageshow", forceMainReload: true })
|
||||
.catch((error) => {
|
||||
devLogger.warn("[pageshow] Failed to refresh video grids after resume:", error);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
devLogger.warn("[pageshow] Failed to process resume refresh:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
||||
window.addEventListener("pageshow", handlePageShow);
|
||||
}
|
||||
|
||||
const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in";
|
||||
const VIDEO_THUMBNAIL_FADE_IN_ANIMATION = "video-thumbnail-fade-in";
|
||||
|
||||
|
||||
+537
-10
@@ -79,6 +79,7 @@ import {
|
||||
nostrToolsBootstrapFailure,
|
||||
readToolkitFromScope,
|
||||
resolveSimplePoolConstructor,
|
||||
shimLegacySimplePoolMethods,
|
||||
} from "./toolkit.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import {
|
||||
@@ -602,6 +603,91 @@ function cloneEventForCache(event) {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
const DM_DECRYPT_CACHE_LIMIT = 256;
|
||||
const DM_EVENT_KINDS = Object.freeze([4, 1059]);
|
||||
|
||||
class SimpleLruCache {
|
||||
constructor(limit = 100) {
|
||||
const resolvedLimit = Number.isFinite(limit) && limit > 0 ? limit : 100;
|
||||
this.limit = Math.floor(resolvedLimit);
|
||||
this.map = new Map();
|
||||
}
|
||||
|
||||
get(key) {
|
||||
if (!this.map.has(key)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = this.map.get(key);
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
if (key === undefined || key === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.map.has(key)) {
|
||||
this.map.delete(key);
|
||||
}
|
||||
this.map.set(key, value);
|
||||
|
||||
while (this.map.size > this.limit) {
|
||||
const oldestKey = this.map.keys().next().value;
|
||||
this.map.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.map.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function buildDmFilters(actorPubkey, { since, until, limit } = {}) {
|
||||
const normalizedActor = normalizeActorKey(actorPubkey);
|
||||
const filters = [];
|
||||
|
||||
const normalizedLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : undefined;
|
||||
const normalizedSince = Number.isFinite(since) ? Math.floor(since) : undefined;
|
||||
const normalizedUntil = Number.isFinite(until) ? Math.floor(until) : undefined;
|
||||
|
||||
const baseFilterPayload = (kind) => {
|
||||
const payload = { kinds: [kind] };
|
||||
if (normalizedLimit !== undefined) {
|
||||
payload.limit = normalizedLimit;
|
||||
}
|
||||
if (normalizedSince !== undefined) {
|
||||
payload.since = normalizedSince;
|
||||
}
|
||||
if (normalizedUntil !== undefined) {
|
||||
payload.until = normalizedUntil;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
if (normalizedActor) {
|
||||
const authorFilter = baseFilterPayload(4);
|
||||
authorFilter.authors = [normalizedActor];
|
||||
filters.push(authorFilter);
|
||||
|
||||
const directFilter = baseFilterPayload(4);
|
||||
directFilter["#p"] = [normalizedActor];
|
||||
filters.push(directFilter);
|
||||
|
||||
const giftWrapFilter = baseFilterPayload(1059);
|
||||
giftWrapFilter["#p"] = [normalizedActor];
|
||||
filters.push(giftWrapFilter);
|
||||
} else {
|
||||
const fallbackFilter = baseFilterPayload(DM_EVENT_KINDS[0]);
|
||||
fallbackFilter.kinds = DM_EVENT_KINDS.slice();
|
||||
filters.push(fallbackFilter);
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
|
||||
const EXTENSION_MIME_MAP = {
|
||||
mp4: "video/mp4",
|
||||
@@ -795,6 +881,11 @@ export class NostrClient {
|
||||
},
|
||||
},
|
||||
});
|
||||
this.dmDecryptCache = new SimpleLruCache(DM_DECRYPT_CACHE_LIMIT);
|
||||
this.dmDecryptor = null;
|
||||
this.dmDecryptorPromise = null;
|
||||
this.sessionActorCipherClosures = null;
|
||||
this.sessionActorCipherClosuresPrivateKey = null;
|
||||
this.countUnsupportedRelays = new Set();
|
||||
this.nip46Client = null;
|
||||
this.remoteSignerListeners = new Set();
|
||||
@@ -2057,6 +2148,62 @@ export class NostrClient {
|
||||
};
|
||||
}
|
||||
|
||||
async ensureActiveSignerForPubkey(pubkey) {
|
||||
const normalizedPubkey =
|
||||
typeof pubkey === "string" && pubkey.trim()
|
||||
? pubkey.trim().toLowerCase()
|
||||
: "";
|
||||
|
||||
const existingSigner = resolveActiveSigner(normalizedPubkey);
|
||||
if (existingSigner && typeof existingSigner.signEvent === "function") {
|
||||
return existingSigner;
|
||||
}
|
||||
|
||||
const extension =
|
||||
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
|
||||
if (!extension) {
|
||||
return existingSigner;
|
||||
}
|
||||
|
||||
let extensionPubkey = normalizedPubkey;
|
||||
|
||||
if (typeof extension.getPublicKey === "function") {
|
||||
try {
|
||||
const retrieved = await runNip07WithRetry(
|
||||
() => extension.getPublicKey(),
|
||||
{ label: "extension.getPublicKey" },
|
||||
);
|
||||
if (typeof retrieved === "string" && retrieved.trim()) {
|
||||
extensionPubkey = retrieved.trim().toLowerCase();
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[nostr] Failed to hydrate active signer from extension pubkey:",
|
||||
error,
|
||||
);
|
||||
return existingSigner;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedPubkey && extensionPubkey !== normalizedPubkey) {
|
||||
return existingSigner;
|
||||
}
|
||||
|
||||
if (typeof extension.signEvent !== "function") {
|
||||
return existingSigner;
|
||||
}
|
||||
|
||||
setActiveSigner({
|
||||
type: "extension",
|
||||
pubkey: extensionPubkey || normalizedPubkey,
|
||||
signEvent: extension.signEvent.bind(extension),
|
||||
nip04: extension.nip04,
|
||||
nip44: extension.nip44,
|
||||
});
|
||||
|
||||
return resolveActiveSigner(normalizedPubkey || extensionPubkey);
|
||||
}
|
||||
|
||||
applyRootCreatedAt(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return null;
|
||||
@@ -2416,6 +2563,8 @@ export class NostrClient {
|
||||
const cipherClosures = await createPrivateKeyCipherClosures(
|
||||
normalizedPrivateKey,
|
||||
);
|
||||
this.sessionActorCipherClosures = cipherClosures || null;
|
||||
this.sessionActorCipherClosuresPrivateKey = normalizedPrivateKey;
|
||||
|
||||
setActiveSigner({
|
||||
type: "nsec",
|
||||
@@ -2532,6 +2681,8 @@ export class NostrClient {
|
||||
const cipherClosures = await createPrivateKeyCipherClosures(
|
||||
normalizedPrivateKey,
|
||||
);
|
||||
this.sessionActorCipherClosures = cipherClosures || null;
|
||||
this.sessionActorCipherClosuresPrivateKey = normalizedPrivateKey;
|
||||
|
||||
setActiveSigner({
|
||||
type: "nsec",
|
||||
@@ -2575,6 +2726,8 @@ export class NostrClient {
|
||||
|
||||
if (forceRenew) {
|
||||
this.sessionActor = null;
|
||||
this.sessionActorCipherClosures = null;
|
||||
this.sessionActorCipherClosuresPrivateKey = null;
|
||||
this.clearStoredSessionActor();
|
||||
} else if (
|
||||
this.sessionActor &&
|
||||
@@ -2597,6 +2750,8 @@ export class NostrClient {
|
||||
const minted = this.mintSessionActor();
|
||||
if (minted) {
|
||||
this.sessionActor = minted;
|
||||
this.sessionActorCipherClosures = null;
|
||||
this.sessionActorCipherClosuresPrivateKey = null;
|
||||
this.persistSessionActor(minted);
|
||||
return minted.pubkey;
|
||||
}
|
||||
@@ -2969,6 +3124,7 @@ export class NostrClient {
|
||||
|
||||
const creation = Promise.resolve().then(() => {
|
||||
const instance = new SimplePool();
|
||||
shimLegacySimplePoolMethods(instance);
|
||||
this.pool = instance;
|
||||
return instance;
|
||||
});
|
||||
@@ -3149,6 +3305,11 @@ export class NostrClient {
|
||||
clearActiveSigner();
|
||||
const previousSessionActor = this.sessionActor;
|
||||
this.sessionActor = null;
|
||||
this.sessionActorCipherClosures = null;
|
||||
this.sessionActorCipherClosuresPrivateKey = null;
|
||||
this.clearDmDecryptCache();
|
||||
this.dmDecryptor = null;
|
||||
this.dmDecryptorPromise = null;
|
||||
|
||||
if (this.nip46Client) {
|
||||
Promise.resolve()
|
||||
@@ -3182,6 +3343,357 @@ export class NostrClient {
|
||||
devLogger.log("User logged out.");
|
||||
}
|
||||
|
||||
getDmDecryptCacheLimit() {
|
||||
return DM_DECRYPT_CACHE_LIMIT;
|
||||
}
|
||||
|
||||
clearDmDecryptCache() {
|
||||
if (this.dmDecryptCache) {
|
||||
this.dmDecryptCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async ensureDmDecryptor() {
|
||||
if (this.dmDecryptor) {
|
||||
return this.dmDecryptor;
|
||||
}
|
||||
|
||||
if (!this.dmDecryptorPromise) {
|
||||
this.dmDecryptorPromise = import("../dmDecryptor.js")
|
||||
.then((module) => {
|
||||
if (!module || typeof module.decryptDM !== "function") {
|
||||
throw new Error("DM decryptor module is unavailable.");
|
||||
}
|
||||
this.dmDecryptor = module.decryptDM;
|
||||
return this.dmDecryptor;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.dmDecryptorPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return this.dmDecryptorPromise;
|
||||
}
|
||||
|
||||
async buildDmDecryptContext(actorPubkeyInput = null) {
|
||||
let normalizedActor = normalizeActorKey(actorPubkeyInput);
|
||||
if (!normalizedActor && typeof this.pubkey === "string" && this.pubkey) {
|
||||
normalizedActor = normalizeActorKey(this.pubkey);
|
||||
}
|
||||
if (
|
||||
!normalizedActor &&
|
||||
this.sessionActor &&
|
||||
typeof this.sessionActor.pubkey === "string"
|
||||
) {
|
||||
normalizedActor = normalizeActorKey(this.sessionActor.pubkey);
|
||||
}
|
||||
|
||||
const decryptors = [];
|
||||
const seen = new Set();
|
||||
|
||||
const addCandidate = (scheme, decrypt, options = {}) => {
|
||||
if (typeof decrypt !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedScheme = typeof scheme === "string" ? scheme : "";
|
||||
const key = `${normalizedScheme}:${options.source || ""}`;
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
decryptors.push({
|
||||
scheme: normalizedScheme,
|
||||
decrypt,
|
||||
priority: Number.isFinite(options.priority) ? options.priority : 0,
|
||||
source: options.source || "",
|
||||
supportsGiftWrap: options.supportsGiftWrap === true,
|
||||
});
|
||||
seen.add(key);
|
||||
};
|
||||
|
||||
let activeSigner = null;
|
||||
if (normalizedActor) {
|
||||
activeSigner = resolveActiveSigner(normalizedActor);
|
||||
}
|
||||
if (!activeSigner && this.pubkey) {
|
||||
activeSigner = resolveActiveSigner(this.pubkey);
|
||||
}
|
||||
if (!activeSigner) {
|
||||
activeSigner = getActiveSigner();
|
||||
}
|
||||
|
||||
if (activeSigner) {
|
||||
if (typeof activeSigner.nip44Decrypt === "function") {
|
||||
addCandidate(
|
||||
"nip44",
|
||||
activeSigner.nip44Decrypt.bind(activeSigner),
|
||||
{
|
||||
priority: -20,
|
||||
source: activeSigner.type || "signer",
|
||||
supportsGiftWrap: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (typeof activeSigner.nip04Decrypt === "function") {
|
||||
addCandidate(
|
||||
"nip04",
|
||||
activeSigner.nip04Decrypt.bind(activeSigner),
|
||||
{
|
||||
priority: -10,
|
||||
source: activeSigner.type || "signer",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sessionActor = this.sessionActor;
|
||||
if (
|
||||
sessionActor &&
|
||||
typeof sessionActor.privateKey === "string" &&
|
||||
sessionActor.privateKey
|
||||
) {
|
||||
if (
|
||||
!this.sessionActorCipherClosures ||
|
||||
this.sessionActorCipherClosuresPrivateKey !== sessionActor.privateKey
|
||||
) {
|
||||
const closures = await createPrivateKeyCipherClosures(
|
||||
sessionActor.privateKey,
|
||||
);
|
||||
this.sessionActorCipherClosures = closures || null;
|
||||
this.sessionActorCipherClosuresPrivateKey = sessionActor.privateKey;
|
||||
}
|
||||
|
||||
const closures = this.sessionActorCipherClosures || {};
|
||||
if (typeof closures.nip44Decrypt === "function") {
|
||||
addCandidate("nip44", closures.nip44Decrypt, {
|
||||
priority: -5,
|
||||
source: "session-actor",
|
||||
supportsGiftWrap: true,
|
||||
});
|
||||
}
|
||||
if (typeof closures.nip04Decrypt === "function") {
|
||||
addCandidate("nip04", closures.nip04Decrypt, {
|
||||
priority: 0,
|
||||
source: "session-actor",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { actorPubkey: normalizedActor, decryptors };
|
||||
}
|
||||
|
||||
async decryptDirectMessageEvent(event, { actorPubkey } = {}) {
|
||||
const eventId = typeof event?.id === "string" ? event.id : "";
|
||||
if (eventId) {
|
||||
const cached = this.dmDecryptCache.get(eventId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const decryptDM = await this.ensureDmDecryptor();
|
||||
const context = await this.buildDmDecryptContext(actorPubkey);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await decryptDM(event, context);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] DM decryptor threw unexpectedly.", {
|
||||
error,
|
||||
eventId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result?.ok && eventId) {
|
||||
this.dmDecryptCache.set(eventId, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async listDirectMessages(actorPubkeyInput = null, options = {}) {
|
||||
if (!this.pool) {
|
||||
await this.ensurePool();
|
||||
}
|
||||
|
||||
const context = await this.buildDmDecryptContext(actorPubkeyInput);
|
||||
if (!context.decryptors.length) {
|
||||
throw new Error("DM decryption helpers are unavailable.");
|
||||
}
|
||||
|
||||
const relayCandidates = Array.isArray(options.relays)
|
||||
? options.relays
|
||||
: Array.isArray(this.readRelays) && this.readRelays.length
|
||||
? this.readRelays
|
||||
: this.relays;
|
||||
const relays = sanitizeRelayList(relayCandidates);
|
||||
const relaysToUse = relays.length ? relays : Array.from(DEFAULT_RELAY_URLS);
|
||||
|
||||
const filters = buildDmFilters(
|
||||
context.actorPubkey || actorPubkeyInput,
|
||||
options,
|
||||
);
|
||||
|
||||
let events = [];
|
||||
try {
|
||||
events = await this.pool.list(relaysToUse, filters);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to list DM events.", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const deduped = new Map();
|
||||
for (const event of Array.isArray(events) ? events : []) {
|
||||
if (!event || typeof event !== "object") {
|
||||
continue;
|
||||
}
|
||||
const key =
|
||||
typeof event.id === "string" && event.id
|
||||
? event.id
|
||||
: `${event.kind || ""}:${event.pubkey || ""}:${event.created_at || ""}`;
|
||||
if (!deduped.has(key)) {
|
||||
deduped.set(key, event);
|
||||
}
|
||||
}
|
||||
|
||||
const messages = [];
|
||||
for (const event of deduped.values()) {
|
||||
try {
|
||||
const decrypted = await this.decryptDirectMessageEvent(event, {
|
||||
actorPubkey: context.actorPubkey || actorPubkeyInput,
|
||||
});
|
||||
if (decrypted?.ok) {
|
||||
messages.push(decrypted);
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to decrypt DM event during list.", {
|
||||
error,
|
||||
id: event?.id || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
messages.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0));
|
||||
return messages;
|
||||
}
|
||||
|
||||
subscribeDirectMessages(actorPubkeyInput = null, options = {}) {
|
||||
if (!this.pool) {
|
||||
throw new Error("nostr pool is not initialized");
|
||||
}
|
||||
|
||||
const relayCandidates = Array.isArray(options.relays)
|
||||
? options.relays
|
||||
: Array.isArray(this.readRelays) && this.readRelays.length
|
||||
? this.readRelays
|
||||
: this.relays;
|
||||
const relays = sanitizeRelayList(relayCandidates);
|
||||
const relaysToUse = relays.length ? relays : Array.from(DEFAULT_RELAY_URLS);
|
||||
|
||||
const filters = buildDmFilters(actorPubkeyInput, options);
|
||||
const subscription = this.pool.sub(relaysToUse, filters);
|
||||
const seenIds = new Set();
|
||||
|
||||
const contextPromise = this.buildDmDecryptContext(actorPubkeyInput);
|
||||
|
||||
subscription.on("event", (event) => {
|
||||
if (!event || typeof event !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = typeof event.id === "string" ? event.id : null;
|
||||
if (eventId) {
|
||||
if (seenIds.has(eventId)) {
|
||||
return;
|
||||
}
|
||||
seenIds.add(eventId);
|
||||
}
|
||||
|
||||
if (typeof options.onEvent === "function") {
|
||||
try {
|
||||
options.onEvent(event);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] DM onEvent handler threw.", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.skipDecrypt === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextPromise
|
||||
.then((context) => {
|
||||
if (!context.decryptors.length) {
|
||||
if (typeof options.onFailure === "function") {
|
||||
options.onFailure(
|
||||
{ ok: false, event },
|
||||
{ event, reason: "no-decryptors" },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return this.decryptDirectMessageEvent(event, {
|
||||
actorPubkey: context.actorPubkey || actorPubkeyInput,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result?.ok) {
|
||||
if (typeof options.onMessage === "function") {
|
||||
options.onMessage(result, { event });
|
||||
}
|
||||
} else if (typeof options.onFailure === "function") {
|
||||
options.onFailure(result, { event });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (typeof options.onError === "function") {
|
||||
options.onError(error, { event });
|
||||
} else {
|
||||
devLogger.warn(
|
||||
"[nostr] Failed to decrypt DM event from subscription.",
|
||||
{
|
||||
error,
|
||||
id: eventId,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (typeof options.onError === "function") {
|
||||
options.onError(error, { event });
|
||||
} else {
|
||||
devLogger.warn("[nostr] Failed to prepare DM decrypt context.", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (typeof options.onEose === "function") {
|
||||
subscription.on("eose", () => {
|
||||
try {
|
||||
options.onEose();
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] DM onEose handler threw.", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const originalUnsub =
|
||||
typeof subscription.unsub === "function"
|
||||
? subscription.unsub.bind(subscription)
|
||||
: () => {};
|
||||
|
||||
subscription.unsub = () => {
|
||||
seenIds.clear();
|
||||
return originalUnsub();
|
||||
};
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null) {
|
||||
const trimmedTarget = typeof targetNpub === "string" ? targetNpub.trim() : "";
|
||||
const trimmedMessage = typeof message === "string" ? message.trim() : "";
|
||||
@@ -3378,6 +3890,15 @@ export class NostrClient {
|
||||
async publishVideo(videoPayload, pubkey) {
|
||||
if (!pubkey) throw new Error("Not logged in to publish video.");
|
||||
|
||||
const normalizedPubkey =
|
||||
typeof pubkey === "string" ? pubkey.trim() : "";
|
||||
|
||||
if (!normalizedPubkey) {
|
||||
throw new Error("Not logged in to publish video.");
|
||||
}
|
||||
|
||||
const userPubkeyLower = normalizedPubkey.toLowerCase();
|
||||
|
||||
const { videoData, nip71Metadata } = extractVideoPublishPayload(videoPayload);
|
||||
const nip71EditedFlag =
|
||||
videoPayload && typeof videoPayload === "object"
|
||||
@@ -3389,7 +3910,7 @@ export class NostrClient {
|
||||
// controllers and revert helpers so all paths stay in lockstep.
|
||||
devLogger.log("Publishing new video with data:", videoData);
|
||||
if (nip71Metadata) {
|
||||
devLogger.log("Including NIP-71 metadata:", nip71Metadata);
|
||||
devLogger.log("Including NIP-71 metadata:", nip71Metadata);
|
||||
}
|
||||
|
||||
const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : "";
|
||||
@@ -3455,7 +3976,7 @@ export class NostrClient {
|
||||
);
|
||||
|
||||
const event = buildVideoPostEvent({
|
||||
pubkey,
|
||||
pubkey: normalizedPubkey,
|
||||
created_at: createdAt,
|
||||
dTagValue,
|
||||
content: contentObject,
|
||||
@@ -3463,7 +3984,7 @@ export class NostrClient {
|
||||
});
|
||||
|
||||
devLogger.log("Publish event with brand-new root:", videoRootId);
|
||||
devLogger.log("Event content:", event.content);
|
||||
devLogger.log("Event content:", event.content);
|
||||
|
||||
try {
|
||||
const { signedEvent } = await this.signAndPublishEvent(event, {
|
||||
@@ -3496,7 +4017,7 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
const mirrorEvent = buildVideoMirrorEvent({
|
||||
pubkey,
|
||||
pubkey: normalizedPubkey,
|
||||
created_at: createdAt,
|
||||
tags: mirrorTags,
|
||||
content: altText,
|
||||
@@ -3536,10 +4057,10 @@ export class NostrClient {
|
||||
description: contentObject.description,
|
||||
url: contentObject.url,
|
||||
magnet: contentObject.magnet,
|
||||
thumbnail: contentObject.thumbnail,
|
||||
mode: contentObject.mode,
|
||||
isPrivate: wantPrivate,
|
||||
isNsfw: contentObject.isNsfw,
|
||||
thumbnail: contentObject.thumbnail,
|
||||
mode: contentObject.mode,
|
||||
isPrivate: wantPrivate,
|
||||
isNsfw: contentObject.isNsfw,
|
||||
isForKids: contentObject.isForKids,
|
||||
};
|
||||
|
||||
@@ -3559,8 +4080,8 @@ export class NostrClient {
|
||||
},
|
||||
userPubkeyLower,
|
||||
{
|
||||
videoRootId: oldRootId,
|
||||
dTag: newD,
|
||||
videoRootId,
|
||||
dTag: dTagValue,
|
||||
eventId: signedEvent.id,
|
||||
}
|
||||
);
|
||||
@@ -3800,6 +4321,8 @@ export class NostrClient {
|
||||
devLogger.log("Creating edited event with root ID:", oldRootId);
|
||||
devLogger.log("Event content:", event.content);
|
||||
|
||||
await this.ensureActiveSignerForPubkey(userPubkeyLower);
|
||||
|
||||
const signer = resolveActiveSigner(userPubkeyLower);
|
||||
if (!signer || typeof signer.signEvent !== "function") {
|
||||
const error = new Error(
|
||||
@@ -3969,6 +4492,8 @@ export class NostrClient {
|
||||
content: JSON.stringify(contentObject),
|
||||
};
|
||||
|
||||
await this.ensureActiveSignerForPubkey(pubkey);
|
||||
|
||||
const signer = resolveActiveSigner(pubkey);
|
||||
if (!signer || typeof signer.signEvent !== "function") {
|
||||
const error = new Error(
|
||||
@@ -4265,6 +4790,8 @@ export class NostrClient {
|
||||
|
||||
const deleteSummaries = [];
|
||||
if (identifierRecords.length) {
|
||||
await this.ensureActiveSignerForPubkey(pubkey);
|
||||
|
||||
const signer = resolveActiveSigner(pubkey);
|
||||
if (!signer || typeof signer.signEvent !== "function") {
|
||||
const error = new Error(
|
||||
|
||||
+358
-5
@@ -25,6 +25,65 @@ const globalScope =
|
||||
? globalThis
|
||||
: null;
|
||||
|
||||
const SIMPLE_POOL_SHIM_KEY = Symbol.for("__bitvidSimplePoolShimApplied__");
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function normalizeRelayList(relays) {
|
||||
if (!Array.isArray(relays)) {
|
||||
if (typeof relays === "string" && relays.trim()) {
|
||||
return [relays.trim()];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return relays
|
||||
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
|
||||
.filter((relay) => relay);
|
||||
}
|
||||
|
||||
function normalizeFilterList(filters) {
|
||||
if (!filters) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(filters)) {
|
||||
if (typeof filters === "object" && filters !== null) {
|
||||
return [filters];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return filters.filter((candidate) => candidate && typeof candidate === "object");
|
||||
}
|
||||
|
||||
function safeInvoke(handler, args, contextLabel) {
|
||||
if (typeof handler !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handler(...args);
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
`[nostr] Legacy SimplePool ${contextLabel} handler threw:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createLegacySubscriptionContext(relays, filters) {
|
||||
const relayList = normalizeRelayList(relays);
|
||||
const filterList = normalizeFilterList(filters);
|
||||
|
||||
const requests = [];
|
||||
if (relayList.length && filterList.length) {
|
||||
relayList.forEach((url) => {
|
||||
filterList.forEach((filter) => {
|
||||
requests.push({ url, filter });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { relayList, filterList, requests };
|
||||
}
|
||||
|
||||
const nostrToolsReadySource =
|
||||
globalScope &&
|
||||
globalScope.nostrToolsReady &&
|
||||
@@ -32,6 +91,48 @@ const nostrToolsReadySource =
|
||||
? globalScope.nostrToolsReady
|
||||
: nostrToolsReady;
|
||||
|
||||
function ensureCanonicalCrypto(candidate, scope = globalScope) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const canonical =
|
||||
scope && typeof scope === "object"
|
||||
? scope.__BITVID_CANONICAL_NOSTR_TOOLS__ || null
|
||||
: null;
|
||||
|
||||
if (!canonical || typeof canonical !== "object") {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const needsNip04 = !candidate.nip04 && canonical.nip04;
|
||||
const needsNip44 = !candidate.nip44 && canonical.nip44;
|
||||
|
||||
if (!needsNip04 && !needsNip44) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (Object.isFrozen(candidate)) {
|
||||
const augmented = { ...candidate };
|
||||
if (needsNip04) {
|
||||
augmented.nip04 = canonical.nip04;
|
||||
}
|
||||
if (needsNip44) {
|
||||
augmented.nip44 = canonical.nip44;
|
||||
}
|
||||
return Object.freeze(augmented);
|
||||
}
|
||||
|
||||
if (needsNip04) {
|
||||
candidate.nip04 = canonical.nip04;
|
||||
}
|
||||
if (needsNip44) {
|
||||
candidate.nip44 = canonical.nip44;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function normalizeToolkitCandidate(candidate) {
|
||||
if (
|
||||
candidate &&
|
||||
@@ -39,7 +140,7 @@ export function normalizeToolkitCandidate(candidate) {
|
||||
candidate.ok !== false &&
|
||||
typeof candidate.then !== "function"
|
||||
) {
|
||||
return candidate;
|
||||
return ensureCanonicalCrypto(candidate);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -168,10 +269,14 @@ function isSimplePoolConstructor(candidate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
typeof prototype.sub === "function" &&
|
||||
typeof prototype.close === "function"
|
||||
);
|
||||
const hasSubscribeMethod =
|
||||
typeof prototype.sub === "function" ||
|
||||
typeof prototype.subscribe === "function" ||
|
||||
typeof prototype.subscribeMany === "function";
|
||||
|
||||
const hasCloseMethod = typeof prototype.close === "function";
|
||||
|
||||
return hasSubscribeMethod && hasCloseMethod;
|
||||
}
|
||||
|
||||
function unwrapSimplePool(candidate) {
|
||||
@@ -232,3 +337,251 @@ export function resolveSimplePoolConstructor(tools, scope = globalScope) {
|
||||
}
|
||||
|
||||
export { isSimplePoolConstructor, unwrapSimplePool };
|
||||
|
||||
export function shimLegacySimplePoolMethods(pool) {
|
||||
if (!pool || typeof pool !== "object" || pool[SIMPLE_POOL_SHIM_KEY]) {
|
||||
return pool || null;
|
||||
}
|
||||
|
||||
const subscribeMap =
|
||||
typeof pool.subscribeMap === "function" ? pool.subscribeMap.bind(pool) : null;
|
||||
const subscribeMany =
|
||||
typeof pool.subscribeMany === "function" ? pool.subscribeMany.bind(pool) : null;
|
||||
const subscribeSingle =
|
||||
typeof pool.subscribe === "function" ? pool.subscribe.bind(pool) : null;
|
||||
|
||||
const canSubscribe = subscribeMap || subscribeMany || subscribeSingle;
|
||||
|
||||
if (canSubscribe && typeof pool.sub !== "function") {
|
||||
pool.sub = function legacySub(relays, filters, opts = {}) {
|
||||
const { relayList, filterList, requests } =
|
||||
createLegacySubscriptionContext(relays, filters);
|
||||
|
||||
const listeners = {
|
||||
event: new Set(),
|
||||
eose: new Set(),
|
||||
close: new Set(),
|
||||
};
|
||||
|
||||
if (!requests.length) {
|
||||
const stub = {
|
||||
sub() {
|
||||
return stub;
|
||||
},
|
||||
on() {
|
||||
return stub;
|
||||
},
|
||||
off() {
|
||||
return stub;
|
||||
},
|
||||
unsub: noop,
|
||||
};
|
||||
|
||||
queueMicrotask(() => {
|
||||
listeners.eose.forEach((listener) => safeInvoke(listener, [], "eose listener"));
|
||||
listeners.close.forEach((listener) =>
|
||||
safeInvoke(listener, [["no-filters"]], "close listener")
|
||||
);
|
||||
});
|
||||
|
||||
return stub;
|
||||
}
|
||||
|
||||
const seenIds = new Set();
|
||||
|
||||
const subscribeParams = { ...opts };
|
||||
const originalOnevent = subscribeParams.onevent;
|
||||
const originalOneose = subscribeParams.oneose;
|
||||
const originalOnclose = subscribeParams.onclose;
|
||||
const originalAlreadyHaveEvent = subscribeParams.alreadyHaveEvent;
|
||||
delete subscribeParams.onevent;
|
||||
delete subscribeParams.oneose;
|
||||
delete subscribeParams.onclose;
|
||||
delete subscribeParams.alreadyHaveEvent;
|
||||
|
||||
subscribeParams.alreadyHaveEvent = (id) => {
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
if (seenIds.has(id)) {
|
||||
return true;
|
||||
}
|
||||
let skip = false;
|
||||
if (typeof originalAlreadyHaveEvent === "function") {
|
||||
try {
|
||||
skip = originalAlreadyHaveEvent(id) === true;
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[nostr] Legacy SimplePool alreadyHaveEvent hook threw:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
if (skip) {
|
||||
return true;
|
||||
}
|
||||
seenIds.add(id);
|
||||
return false;
|
||||
};
|
||||
|
||||
let closed = false;
|
||||
|
||||
const invokeEventListeners = (event) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (event && typeof event === "object" && event.id) {
|
||||
seenIds.add(event.id);
|
||||
}
|
||||
safeInvoke(originalOnevent, [event], "options.onevent");
|
||||
listeners.event.forEach((listener) =>
|
||||
safeInvoke(listener, [event], "event listener")
|
||||
);
|
||||
};
|
||||
|
||||
const invokeEoseListeners = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
safeInvoke(originalOneose, [], "options.oneose");
|
||||
listeners.eose.forEach((listener) => safeInvoke(listener, [], "eose listener"));
|
||||
};
|
||||
|
||||
const invokeCloseListeners = (reasons) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
safeInvoke(originalOnclose, [reasons], "options.onclose");
|
||||
listeners.close.forEach((listener) =>
|
||||
safeInvoke(listener, [reasons], "close listener")
|
||||
);
|
||||
};
|
||||
|
||||
subscribeParams.onevent = (event) => {
|
||||
invokeEventListeners(event);
|
||||
};
|
||||
|
||||
subscribeParams.oneose = () => {
|
||||
invokeEoseListeners();
|
||||
};
|
||||
|
||||
subscribeParams.onclose = (reasons) => {
|
||||
invokeCloseListeners(reasons);
|
||||
};
|
||||
|
||||
let closer;
|
||||
|
||||
try {
|
||||
if (subscribeMap) {
|
||||
closer = subscribeMap(requests, subscribeParams);
|
||||
} else if (subscribeMany) {
|
||||
// subscribeMany only accepts a single filter, so reuse subscribeMap-style batching
|
||||
// by issuing one subscription per filter.
|
||||
const subs = filterList.map((filter) =>
|
||||
subscribeMany(relayList, filter, subscribeParams)
|
||||
);
|
||||
closer = {
|
||||
close: (reason) => {
|
||||
subs.forEach((sub) => {
|
||||
try {
|
||||
sub?.close?.(reason);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to close SimplePool subscription.", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
} else if (subscribeSingle && filterList.length) {
|
||||
closer = subscribeSingle(relayList, filterList[0], subscribeParams);
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to open SimplePool subscription.", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const legacySub = {
|
||||
sub() {
|
||||
devLogger.warn("[nostr] legacy sub.sub() is unsupported with the new SimplePool API.");
|
||||
return legacySub;
|
||||
},
|
||||
on(type, handler) {
|
||||
if (typeof handler !== "function") {
|
||||
return legacySub;
|
||||
}
|
||||
if (type === "event") {
|
||||
listeners.event.add(handler);
|
||||
} else if (type === "eose") {
|
||||
listeners.eose.add(handler);
|
||||
} else if (type === "close" || type === "closed") {
|
||||
listeners.close.add(handler);
|
||||
}
|
||||
return legacySub;
|
||||
},
|
||||
off(type, handler) {
|
||||
if (typeof handler !== "function") {
|
||||
return legacySub;
|
||||
}
|
||||
if (type === "event") {
|
||||
listeners.event.delete(handler);
|
||||
} else if (type === "eose") {
|
||||
listeners.eose.delete(handler);
|
||||
} else if (type === "close" || type === "closed") {
|
||||
listeners.close.delete(handler);
|
||||
}
|
||||
return legacySub;
|
||||
},
|
||||
unsub() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
try {
|
||||
closer?.close?.("closed by caller");
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to close SimplePool subscription.", error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return legacySub;
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof pool.list !== "function") {
|
||||
pool.list = async function legacyList(relays, filters, opts = {}) {
|
||||
const events = [];
|
||||
let subscription;
|
||||
try {
|
||||
subscription = this.sub(relays, filters, opts);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!subscription || typeof subscription.on !== "function") {
|
||||
resolve(events);
|
||||
return;
|
||||
}
|
||||
|
||||
subscription.on("event", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
subscription.on("eose", () => {
|
||||
try {
|
||||
subscription.unsub?.();
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to unsubscribe after list EOSE.", error);
|
||||
}
|
||||
resolve(events);
|
||||
});
|
||||
subscription.on("close", () => {
|
||||
resolve(events);
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
pool[SIMPLE_POOL_SHIM_KEY] = true;
|
||||
return pool;
|
||||
}
|
||||
+117
-15
@@ -56,6 +56,40 @@ const hasWorkingNip04 = (candidate) =>
|
||||
typeof candidate.decrypt === "function"
|
||||
);
|
||||
|
||||
const hasWorkingNip44 = (candidate) =>
|
||||
!!(
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
((
|
||||
typeof candidate.encrypt === "function" &&
|
||||
typeof candidate.decrypt === "function"
|
||||
) ||
|
||||
(candidate.v2 &&
|
||||
typeof candidate.v2 === "object" &&
|
||||
typeof candidate.v2.encrypt === "function" &&
|
||||
typeof candidate.v2.decrypt === "function"))
|
||||
);
|
||||
|
||||
const selectNip44Candidate = (moduleLike) => {
|
||||
if (!moduleLike || typeof moduleLike !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasWorkingNip44(moduleLike.nip44)) {
|
||||
return moduleLike.nip44;
|
||||
}
|
||||
|
||||
if (hasWorkingNip44(moduleLike.default)) {
|
||||
return moduleLike.default;
|
||||
}
|
||||
|
||||
if (hasWorkingNip44(moduleLike)) {
|
||||
return moduleLike;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const toSerializableError = (error) => {
|
||||
if (!error) {
|
||||
return null;
|
||||
@@ -260,6 +294,11 @@ export function bootstrapNostrTools() {
|
||||
NODE_IMPORT_TIMEOUT,
|
||||
"node-nip04"
|
||||
),
|
||||
withTimeout(
|
||||
() => import("nostr-tools/nip44"),
|
||||
NODE_IMPORT_TIMEOUT,
|
||||
"node-nip44"
|
||||
),
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -277,6 +316,14 @@ export function bootstrapNostrTools() {
|
||||
REMOTE_IMPORT_TIMEOUT,
|
||||
"esm-nip04"
|
||||
),
|
||||
withTimeout(
|
||||
() =>
|
||||
import(
|
||||
"https://esm.sh/[email protected]?target=es2022&exports=nip44"
|
||||
),
|
||||
REMOTE_IMPORT_TIMEOUT,
|
||||
"esm-nip44"
|
||||
),
|
||||
];
|
||||
|
||||
const localEntrypoints = discoverLocalEntrypoints(scope);
|
||||
@@ -296,6 +343,7 @@ export function bootstrapNostrTools() {
|
||||
|
||||
const resolvedModules = [];
|
||||
let resolvedNip04 = null;
|
||||
let resolvedNip44 = null;
|
||||
|
||||
dynamicResults.forEach((settledResult) => {
|
||||
if (settledResult.status !== "fulfilled") {
|
||||
@@ -316,12 +364,25 @@ export function bootstrapNostrTools() {
|
||||
resolvedNip04 = result.value.nip04;
|
||||
return;
|
||||
}
|
||||
if (!resolvedNip44 && result.label === "esm-nip44") {
|
||||
const nip44Candidate = selectNip44Candidate(result.value);
|
||||
if (nip44Candidate) {
|
||||
resolvedNip44 = nip44Candidate;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const extracted = extractToolsFromModule(result.value);
|
||||
if (extracted && Object.keys(extracted).length > 0) {
|
||||
resolvedModules.push({ source: result.label, tools: extracted });
|
||||
if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) {
|
||||
resolvedNip04 = extracted.nip04;
|
||||
}
|
||||
if (!resolvedNip44) {
|
||||
const nip44Candidate = selectNip44Candidate(extracted);
|
||||
if (nip44Candidate) {
|
||||
resolvedNip44 = nip44Candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -349,6 +410,8 @@ export function bootstrapNostrTools() {
|
||||
}
|
||||
});
|
||||
|
||||
const needsAdditionalCrypto = !resolvedNip04 || !resolvedNip44;
|
||||
|
||||
const allDynamicTimedOut =
|
||||
attempts.length > 0 &&
|
||||
attempts.every(
|
||||
@@ -361,7 +424,8 @@ export function bootstrapNostrTools() {
|
||||
);
|
||||
|
||||
const shouldAttemptScriptFallback =
|
||||
!!scope.document && (resolvedModules.length === 0 || allDynamicTimedOut);
|
||||
!!scope.document &&
|
||||
(resolvedModules.length === 0 || allDynamicTimedOut || needsAdditionalCrypto);
|
||||
|
||||
if (shouldAttemptScriptFallback) {
|
||||
const scriptSources = [
|
||||
@@ -398,6 +462,12 @@ export function bootstrapNostrTools() {
|
||||
if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) {
|
||||
resolvedNip04 = extracted.nip04;
|
||||
}
|
||||
if (!resolvedNip44) {
|
||||
const nip44Candidate = selectNip44Candidate(extracted);
|
||||
if (nip44Candidate) {
|
||||
resolvedNip44 = nip44Candidate;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -409,9 +479,15 @@ export function bootstrapNostrTools() {
|
||||
if (!resolvedNip04 && hasWorkingNip04(existingGlobalTools?.nip04)) {
|
||||
resolvedNip04 = existingGlobalTools.nip04;
|
||||
}
|
||||
if (!resolvedNip44) {
|
||||
const nip44Candidate = selectNip44Candidate(existingGlobalTools);
|
||||
if (nip44Candidate) {
|
||||
resolvedNip44 = nip44Candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedModules.length === 0 && !resolvedNip04) {
|
||||
if (resolvedModules.length === 0 && !resolvedNip04 && !resolvedNip44) {
|
||||
const failure = {
|
||||
ok: false,
|
||||
reason: "Failed to resolve any nostr-tools helpers.",
|
||||
@@ -454,6 +530,15 @@ export function bootstrapNostrTools() {
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedNip44) {
|
||||
canonicalTools.nip44 = resolvedNip44;
|
||||
devLogger.info("[bitvid] Initialized nostr nip44 helpers.");
|
||||
} else {
|
||||
userLogger.warn(
|
||||
"[bitvid] NIP-44 helpers unavailable after bootstrap attempts."
|
||||
);
|
||||
}
|
||||
|
||||
const frozenCanonical = Object.freeze({ ...canonicalTools });
|
||||
|
||||
try {
|
||||
@@ -476,6 +561,9 @@ export function bootstrapNostrTools() {
|
||||
if (!merged.nip04 && frozenCanonical.nip04) {
|
||||
merged.nip04 = frozenCanonical.nip04;
|
||||
}
|
||||
if (!merged.nip44 && frozenCanonical.nip44) {
|
||||
merged.nip44 = frozenCanonical.nip44;
|
||||
}
|
||||
if (
|
||||
typeof merged.generatePrivateKey !== "function" &&
|
||||
typeof merged.generateSecretKey === "function"
|
||||
@@ -496,19 +584,33 @@ export function bootstrapNostrTools() {
|
||||
return activeTools;
|
||||
};
|
||||
|
||||
try {
|
||||
Object.defineProperty(scope, "NostrTools", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return activeTools;
|
||||
},
|
||||
set(value) {
|
||||
applyActive(value);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn("[bitvid] Failed to install NostrTools guard.", error);
|
||||
const existingDescriptor =
|
||||
scope && typeof scope === "object"
|
||||
? Object.getOwnPropertyDescriptor(scope, "NostrTools")
|
||||
: null;
|
||||
|
||||
const canInstallGuard =
|
||||
!existingDescriptor || existingDescriptor.configurable === true;
|
||||
|
||||
if (canInstallGuard) {
|
||||
try {
|
||||
Object.defineProperty(scope, "NostrTools", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return activeTools;
|
||||
},
|
||||
set(value) {
|
||||
applyActive(value);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn("[bitvid] Failed to install NostrTools guard.", error);
|
||||
}
|
||||
} else {
|
||||
devLogger.info(
|
||||
"[bitvid] Skipping NostrTools guard; existing property is non-configurable."
|
||||
);
|
||||
}
|
||||
|
||||
applyActive(existingGlobalTools);
|
||||
|
||||
@@ -567,6 +567,72 @@ export class ModerationService {
|
||||
this.reconcileTrustedMuteSubscriptions(previousContacts, seededNext);
|
||||
}
|
||||
|
||||
getViewerContactsSnapshot({ includeViewer = false } = {}) {
|
||||
const contacts =
|
||||
this.viewerContacts instanceof Set ? this.viewerContacts : new Set();
|
||||
const viewer = normalizeHex(this.viewerPubkey);
|
||||
const list = [];
|
||||
|
||||
for (const value of contacts) {
|
||||
const normalized = normalizeHex(value);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (!includeViewer && viewer && normalized === viewer) {
|
||||
continue;
|
||||
}
|
||||
list.push({
|
||||
pubkey: normalized,
|
||||
npub: encodeToNpub(normalized) || "",
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
async ensureViewerContactsLoaded(pubkey = null) {
|
||||
const target = normalizeHex(pubkey);
|
||||
const viewer = normalizeHex(this.viewerPubkey);
|
||||
|
||||
if (target && target !== viewer) {
|
||||
try {
|
||||
await this.setViewerPubkey(target);
|
||||
} catch (error) {
|
||||
this.log(
|
||||
"[moderationService] failed to set viewer pubkey while ensuring contacts",
|
||||
error,
|
||||
);
|
||||
}
|
||||
return this.getViewerContactsSnapshot();
|
||||
}
|
||||
|
||||
if (this.contactListPromise) {
|
||||
try {
|
||||
await this.contactListPromise;
|
||||
} catch (error) {
|
||||
this.log(
|
||||
"[moderationService] contact hydration failed while ensuring contacts",
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else if (viewer) {
|
||||
const hasContacts =
|
||||
this.viewerContacts instanceof Set && this.viewerContacts.size > 0;
|
||||
if (!hasContacts) {
|
||||
try {
|
||||
await this.fetchTrustedContacts(viewer);
|
||||
} catch (error) {
|
||||
this.log(
|
||||
"[moderationService] failed to fetch viewer contacts while ensuring contacts",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.getViewerContactsSnapshot();
|
||||
}
|
||||
|
||||
setTrustedSeeds(seeds = []) {
|
||||
const sanitizedSeeds = new Set();
|
||||
|
||||
|
||||
+622
-2
@@ -4,6 +4,11 @@ import { accessControl } from "../accessControl.js";
|
||||
import { ALLOW_NSFW_CONTENT } from "../config.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import moderationService from "./moderationService.js";
|
||||
import {
|
||||
loadDirectMessageSnapshot,
|
||||
saveDirectMessageSnapshot,
|
||||
clearDirectMessageSnapshot,
|
||||
} from "../directMessagesStore.js";
|
||||
import {
|
||||
getVideosMap as getStoredVideosMap,
|
||||
setVideosMap as setStoredVideosMap,
|
||||
@@ -99,6 +104,218 @@ function normalizeUntil(timestamp) {
|
||||
return seconds > 0 ? seconds : 0;
|
||||
}
|
||||
|
||||
function sanitizeSnapshotTimestamp(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const floored = Math.floor(numeric);
|
||||
if (!Number.isFinite(floored) || floored <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return floored;
|
||||
}
|
||||
|
||||
const SNAPSHOT_PREVIEW_LIMIT = 160;
|
||||
|
||||
function sanitizeSnapshotPreview(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
let preview = value.replace(/\s+/g, " ").trim();
|
||||
if (!preview) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (preview.length > SNAPSHOT_PREVIEW_LIMIT) {
|
||||
preview = `${preview.slice(0, SNAPSHOT_PREVIEW_LIMIT).trimEnd()}\u2026`;
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
function extractSnapshotPreview(message) {
|
||||
if (!message || typeof message !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
if (typeof message.plaintext === "string") {
|
||||
candidates.push(message.plaintext);
|
||||
}
|
||||
if (typeof message.preview === "string") {
|
||||
candidates.push(message.preview);
|
||||
}
|
||||
if (message.message && typeof message.message.content === "string") {
|
||||
candidates.push(message.message.content);
|
||||
}
|
||||
|
||||
const selected = candidates.find((candidate) => candidate && candidate.trim());
|
||||
return sanitizeSnapshotPreview(selected || "");
|
||||
}
|
||||
|
||||
function resolveDirectMessageRemotePubkey(message, actorPubkey = "") {
|
||||
const normalizedActor = normalizeHexPubkey(actorPubkey);
|
||||
|
||||
const directRemote =
|
||||
typeof message?.remotePubkey === "string"
|
||||
? normalizeHexPubkey(message.remotePubkey)
|
||||
: "";
|
||||
if (directRemote && directRemote !== normalizedActor) {
|
||||
return directRemote;
|
||||
}
|
||||
|
||||
const snapshotRemote =
|
||||
typeof message?.snapshot?.remotePubkey === "string"
|
||||
? normalizeHexPubkey(message.snapshot.remotePubkey)
|
||||
: "";
|
||||
if (snapshotRemote && snapshotRemote !== normalizedActor) {
|
||||
return snapshotRemote;
|
||||
}
|
||||
|
||||
const direction =
|
||||
typeof message?.direction === "string"
|
||||
? message.direction.toLowerCase()
|
||||
: "";
|
||||
|
||||
const senderHex =
|
||||
typeof message?.sender?.pubkey === "string"
|
||||
? normalizeHexPubkey(message.sender.pubkey)
|
||||
: "";
|
||||
|
||||
if (direction === "incoming" && senderHex && senderHex !== normalizedActor) {
|
||||
return senderHex;
|
||||
}
|
||||
|
||||
if (Array.isArray(message?.recipients)) {
|
||||
for (const recipient of message.recipients) {
|
||||
const candidate =
|
||||
recipient && typeof recipient.pubkey === "string"
|
||||
? normalizeHexPubkey(recipient.pubkey)
|
||||
: "";
|
||||
if (candidate && candidate !== normalizedActor) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === "outgoing" && senderHex && senderHex !== normalizedActor) {
|
||||
return senderHex;
|
||||
}
|
||||
|
||||
const messagePubkey =
|
||||
typeof message?.message?.pubkey === "string"
|
||||
? normalizeHexPubkey(message.message.pubkey)
|
||||
: "";
|
||||
if (messagePubkey && messagePubkey !== normalizedActor) {
|
||||
return messagePubkey;
|
||||
}
|
||||
|
||||
const eventPubkey =
|
||||
typeof message?.event?.pubkey === "string"
|
||||
? normalizeHexPubkey(message.event.pubkey)
|
||||
: "";
|
||||
if (eventPubkey && eventPubkey !== normalizedActor) {
|
||||
return eventPubkey;
|
||||
}
|
||||
|
||||
if (senderHex && senderHex !== normalizedActor) {
|
||||
return senderHex;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildSnapshotFromMessages(messages, actorPubkey = "") {
|
||||
const normalizedActor = normalizeHexPubkey(actorPubkey);
|
||||
const threadMap = new Map();
|
||||
|
||||
for (const message of Array.isArray(messages) ? messages : []) {
|
||||
if (!message || message.ok !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const remote = resolveDirectMessageRemotePubkey(message, normalizedActor);
|
||||
if (!remote) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = sanitizeSnapshotTimestamp(message.timestamp);
|
||||
const preview = extractSnapshotPreview(message);
|
||||
|
||||
const existing = threadMap.get(remote);
|
||||
if (!existing || timestamp > existing.latestTimestamp) {
|
||||
threadMap.set(remote, {
|
||||
remotePubkey: remote,
|
||||
latestTimestamp: timestamp,
|
||||
preview,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(threadMap.values()).sort(
|
||||
(a, b) => (b.latestTimestamp || 0) - (a.latestTimestamp || 0),
|
||||
);
|
||||
}
|
||||
|
||||
function hydrateMessagesFromSnapshot(snapshot, actorPubkey = "") {
|
||||
const normalizedActor = normalizeHexPubkey(actorPubkey);
|
||||
const hydrated = [];
|
||||
|
||||
const entries = Array.isArray(snapshot) ? snapshot : [];
|
||||
entries.sort(
|
||||
(a, b) => (b?.latestTimestamp || 0) - (a?.latestTimestamp || 0),
|
||||
);
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const remote = normalizeHexPubkey(entry?.remotePubkey || entry?.remote);
|
||||
if (!remote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = sanitizeSnapshotTimestamp(entry?.latestTimestamp);
|
||||
const preview = sanitizeSnapshotPreview(entry?.preview || "");
|
||||
|
||||
hydrated.push({
|
||||
ok: true,
|
||||
timestamp,
|
||||
plaintext: preview || null,
|
||||
preview: preview || "",
|
||||
actorPubkey: normalizedActor,
|
||||
remotePubkey: remote,
|
||||
sender: remote
|
||||
? {
|
||||
pubkey: remote,
|
||||
relayHints: [],
|
||||
role: "snapshot",
|
||||
}
|
||||
: null,
|
||||
recipients: [],
|
||||
direction: "unknown",
|
||||
scheme: "",
|
||||
decryptor: { scheme: "", source: "snapshot" },
|
||||
event: {
|
||||
id: `dm-snapshot:${normalizedActor || "actor"}:${remote}:${timestamp}:${index}`,
|
||||
},
|
||||
message: preview
|
||||
? {
|
||||
content: preview,
|
||||
}
|
||||
: null,
|
||||
snapshot: {
|
||||
remotePubkey: remote,
|
||||
timestamp,
|
||||
preview,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
export class NostrService {
|
||||
constructor({ logger } = {}) {
|
||||
this.nostrClient = nostrClient;
|
||||
@@ -118,6 +335,11 @@ export class NostrService {
|
||||
this.initialLoadPromise = null;
|
||||
this.initialLoadResolved = false;
|
||||
this.initialLoadResolve = null;
|
||||
this.dmMessages = [];
|
||||
this.dmMessageIndex = new Map();
|
||||
this.dmSubscription = null;
|
||||
this.dmActorPubkey = null;
|
||||
this.dmHydratedFromSnapshot = false;
|
||||
try {
|
||||
if (this.moderationService && typeof this.moderationService.setNostrClient === "function") {
|
||||
this.moderationService.setNostrClient(this.nostrClient);
|
||||
@@ -180,6 +402,400 @@ export class NostrService {
|
||||
return this.ensureInitialLoadDeferred();
|
||||
}
|
||||
|
||||
resolveActiveDmActor() {
|
||||
if (
|
||||
this.nostrClient &&
|
||||
typeof this.nostrClient.pubkey === "string" &&
|
||||
this.nostrClient.pubkey
|
||||
) {
|
||||
return this.nostrClient.pubkey;
|
||||
}
|
||||
|
||||
if (
|
||||
this.nostrClient?.sessionActor &&
|
||||
typeof this.nostrClient.sessionActor.pubkey === "string"
|
||||
) {
|
||||
return this.nostrClient.sessionActor.pubkey;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
getDirectMessages() {
|
||||
return Array.isArray(this.dmMessages) ? [...this.dmMessages] : [];
|
||||
}
|
||||
|
||||
clearDirectMessages({ actorPubkey = null, emit = true } = {}) {
|
||||
this.dmMessages = [];
|
||||
this.dmMessageIndex = new Map();
|
||||
this.dmHydratedFromSnapshot = false;
|
||||
const activeActor = normalizeHexPubkey(
|
||||
typeof actorPubkey === "string" && actorPubkey
|
||||
? actorPubkey
|
||||
: this.dmActorPubkey || this.resolveActiveDmActor(),
|
||||
);
|
||||
if (activeActor) {
|
||||
clearDirectMessageSnapshot(activeActor).catch((error) => {
|
||||
userLogger.warn("[nostrService] Failed to clear DM snapshot", error);
|
||||
});
|
||||
}
|
||||
this.dmActorPubkey = null;
|
||||
if (typeof this.nostrClient?.clearDmDecryptCache === "function") {
|
||||
try {
|
||||
this.nostrClient.clearDmDecryptCache();
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostrService] Failed to clear DM decrypt cache", error);
|
||||
}
|
||||
}
|
||||
if (emit) {
|
||||
this.emit("directMessages:cleared", {});
|
||||
this.emit("directMessages:updated", { messages: [] });
|
||||
}
|
||||
}
|
||||
|
||||
applyDirectMessage(message, { reason = "update", event = null } = {}) {
|
||||
if (!message || message.ok !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = message?.event?.id;
|
||||
if (typeof eventId !== "string" || !eventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
...message,
|
||||
timestamp:
|
||||
Number.isFinite(message?.timestamp)
|
||||
? message.timestamp
|
||||
: Number.isFinite(message?.message?.created_at)
|
||||
? message.message.created_at
|
||||
: Number.isFinite(message?.event?.created_at)
|
||||
? message.event.created_at
|
||||
: Date.now() / 1000,
|
||||
};
|
||||
|
||||
this.dmMessageIndex.set(eventId, normalized);
|
||||
|
||||
const actor = normalizeHexPubkey(
|
||||
normalized?.actorPubkey || this.dmActorPubkey || this.resolveActiveDmActor(),
|
||||
);
|
||||
const remote = resolveDirectMessageRemotePubkey(normalized, actor);
|
||||
|
||||
if (remote) {
|
||||
this.dmMessages = this.dmMessages.filter(
|
||||
(entry) =>
|
||||
!entry?.snapshot || entry.snapshot.remotePubkey !== remote,
|
||||
);
|
||||
}
|
||||
|
||||
const existingIndex = this.dmMessages.findIndex(
|
||||
(entry) => entry?.event?.id === eventId,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
this.dmMessages[existingIndex] = normalized;
|
||||
} else {
|
||||
this.dmMessages.push(normalized);
|
||||
}
|
||||
|
||||
this.dmMessages.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0));
|
||||
if (actor) {
|
||||
this.dmActorPubkey = actor;
|
||||
}
|
||||
this.dmHydratedFromSnapshot = false;
|
||||
|
||||
this.emit("directMessages:message", { message: normalized, reason, event });
|
||||
this.emit("directMessages:updated", {
|
||||
messages: this.getDirectMessages(),
|
||||
reason,
|
||||
});
|
||||
|
||||
if (actor) {
|
||||
this.persistDirectMessageSnapshot(actor).catch((error) => {
|
||||
userLogger.warn(
|
||||
"[nostrService] Failed to persist DM snapshot after update",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async hydrateDirectMessagesFromStore({
|
||||
actorPubkey = null,
|
||||
emit = false,
|
||||
force = false,
|
||||
} = {}) {
|
||||
const activeActor = normalizeHexPubkey(
|
||||
typeof actorPubkey === "string" && actorPubkey
|
||||
? actorPubkey
|
||||
: this.resolveActiveDmActor(),
|
||||
);
|
||||
|
||||
if (!activeActor) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let stored = [];
|
||||
try {
|
||||
stored = await loadDirectMessageSnapshot(activeActor);
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostrService] Failed to load DM snapshot", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const hydrated = hydrateMessagesFromSnapshot(stored, activeActor);
|
||||
|
||||
const shouldReplace =
|
||||
force ||
|
||||
!Array.isArray(this.dmMessages) ||
|
||||
!this.dmMessages.length ||
|
||||
this.dmHydratedFromSnapshot;
|
||||
|
||||
this.dmActorPubkey = activeActor;
|
||||
|
||||
if (!shouldReplace) {
|
||||
return this.getDirectMessages();
|
||||
}
|
||||
|
||||
this.dmMessages = hydrated;
|
||||
this.dmMessageIndex = new Map();
|
||||
this.dmHydratedFromSnapshot = hydrated.length > 0;
|
||||
|
||||
const snapshot = this.getDirectMessages();
|
||||
|
||||
if (emit) {
|
||||
this.emit("directMessages:hydrated", {
|
||||
messages: snapshot,
|
||||
actorPubkey: activeActor,
|
||||
});
|
||||
this.emit("directMessages:updated", {
|
||||
messages: snapshot,
|
||||
reason: "snapshot",
|
||||
});
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async loadDirectMessages({ actorPubkey, relays, ...options } = {}) {
|
||||
const activeActor = actorPubkey || this.resolveActiveDmActor();
|
||||
const normalizedActor = normalizeHexPubkey(activeActor);
|
||||
if (!normalizedActor) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const forceHydration =
|
||||
this.dmActorPubkey && this.dmActorPubkey !== normalizedActor;
|
||||
|
||||
await this.hydrateDirectMessagesFromStore({
|
||||
actorPubkey: normalizedActor,
|
||||
emit: true,
|
||||
force: forceHydration,
|
||||
});
|
||||
|
||||
this.dmActorPubkey = normalizedActor;
|
||||
|
||||
let messages = [];
|
||||
try {
|
||||
messages = await this.nostrClient.listDirectMessages(normalizedActor, {
|
||||
relays,
|
||||
...options,
|
||||
});
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostrService] Failed to load direct messages", error);
|
||||
return this.getDirectMessages();
|
||||
}
|
||||
|
||||
const collected = [];
|
||||
const index = new Map();
|
||||
|
||||
for (const message of Array.isArray(messages) ? messages : []) {
|
||||
if (!message || message.ok !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventId = message?.event?.id;
|
||||
if (typeof eventId !== "string" || !eventId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
...message,
|
||||
timestamp:
|
||||
Number.isFinite(message?.timestamp)
|
||||
? message.timestamp
|
||||
: Number.isFinite(message?.message?.created_at)
|
||||
? message.message.created_at
|
||||
: Number.isFinite(message?.event?.created_at)
|
||||
? message.event.created_at
|
||||
: Date.now() / 1000,
|
||||
};
|
||||
|
||||
index.set(eventId, normalized);
|
||||
collected.push(normalized);
|
||||
}
|
||||
|
||||
collected.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0));
|
||||
this.dmMessageIndex = index;
|
||||
this.dmMessages = collected;
|
||||
|
||||
const snapshot = this.getDirectMessages();
|
||||
|
||||
this.dmHydratedFromSnapshot = false;
|
||||
|
||||
this.emit("directMessages:loaded", {
|
||||
messages: snapshot,
|
||||
actorPubkey: normalizedActor,
|
||||
});
|
||||
this.emit("directMessages:updated", {
|
||||
messages: snapshot,
|
||||
reason: "load",
|
||||
});
|
||||
|
||||
try {
|
||||
await this.persistDirectMessageSnapshot(normalizedActor, snapshot);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostrService] Failed to persist DM snapshot after load",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) {
|
||||
if (this.dmSubscription) {
|
||||
return this.dmSubscription;
|
||||
}
|
||||
|
||||
const activeActor = actorPubkey || this.resolveActiveDmActor();
|
||||
const normalizedActor = normalizeHexPubkey(activeActor);
|
||||
if (!normalizedActor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = this.nostrClient.subscribeDirectMessages(
|
||||
normalizedActor,
|
||||
{
|
||||
relays,
|
||||
onEvent: handlers.onEvent,
|
||||
onMessage: (message, context = {}) => {
|
||||
this.applyDirectMessage(message, {
|
||||
reason: "subscription",
|
||||
event: context.event || null,
|
||||
});
|
||||
if (typeof handlers.onMessage === "function") {
|
||||
try {
|
||||
handlers.onMessage(message, context);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostrService] DM onMessage handler threw",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure: (payload, context) => {
|
||||
this.emit("directMessages:failure", { failure: payload, context });
|
||||
if (typeof handlers.onFailure === "function") {
|
||||
try {
|
||||
handlers.onFailure(payload, context);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostrService] DM onFailure handler threw",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error, context) => {
|
||||
this.emit("directMessages:error", { error, context });
|
||||
if (typeof handlers.onError === "function") {
|
||||
try {
|
||||
handlers.onError(error, context);
|
||||
} catch (handlerError) {
|
||||
userLogger.warn(
|
||||
"[nostrService] DM onError handler threw",
|
||||
handlerError,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
userLogger.warn(
|
||||
"[nostrService] Direct message subscription error",
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
onEose: () => {
|
||||
this.emit("directMessages:eose", { actorPubkey: normalizedActor });
|
||||
if (typeof handlers.onEose === "function") {
|
||||
try {
|
||||
handlers.onEose();
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostrService] DM onEose handler threw",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.dmSubscription = subscription;
|
||||
this.dmActorPubkey = normalizedActor;
|
||||
this.emit("directMessages:subscribed", { subscription });
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostrService] Failed to subscribe to direct messages",
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
stopDirectMessageSubscription() {
|
||||
if (this.dmSubscription && typeof this.dmSubscription.unsub === "function") {
|
||||
try {
|
||||
this.dmSubscription.unsub();
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostrService] Failed to unsubscribe from direct messages",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.dmSubscription = null;
|
||||
}
|
||||
|
||||
async persistDirectMessageSnapshot(actorPubkey = null, messages = null) {
|
||||
const normalizedActor = normalizeHexPubkey(
|
||||
typeof actorPubkey === "string" && actorPubkey
|
||||
? actorPubkey
|
||||
: this.dmActorPubkey || this.resolveActiveDmActor(),
|
||||
);
|
||||
|
||||
if (!normalizedActor) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceMessages = Array.isArray(messages) ? messages : this.dmMessages;
|
||||
const snapshotPayload = buildSnapshotFromMessages(
|
||||
sourceMessages,
|
||||
normalizedActor,
|
||||
);
|
||||
|
||||
if (!snapshotPayload.length) {
|
||||
await clearDirectMessageSnapshot(normalizedActor);
|
||||
return snapshotPayload;
|
||||
}
|
||||
|
||||
return saveDirectMessageSnapshot(normalizedActor, snapshotPayload);
|
||||
}
|
||||
|
||||
getModerationService() {
|
||||
if (!this.moderationService) {
|
||||
return null;
|
||||
@@ -328,7 +944,11 @@ export class NostrService {
|
||||
|
||||
const viewerIsAuthor = this.isViewerVideoAuthor(video);
|
||||
|
||||
if (ALLOW_NSFW_CONTENT !== true && video.isNsfw === true && !viewerIsAuthor) {
|
||||
if (viewerIsAuthor) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ALLOW_NSFW_CONTENT !== true && video.isNsfw === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -346,7 +966,7 @@ export class NostrService {
|
||||
}
|
||||
}
|
||||
|
||||
if (video.isPrivate === true && !viewerIsAuthor) {
|
||||
if (video.isPrivate === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+33
-24
@@ -22,6 +22,11 @@ import {
|
||||
} from "../storage/r2-s3.js";
|
||||
import { truncateMiddle } from "../utils/formatters.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import {
|
||||
getVideoNoteErrorMessage,
|
||||
normalizeVideoNotePayload,
|
||||
VIDEO_NOTE_ERROR_CODES,
|
||||
} from "./videoNotePayload.js";
|
||||
|
||||
const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]);
|
||||
|
||||
@@ -654,9 +659,17 @@ class R2Service {
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = String(metadata.title || "").trim();
|
||||
const rawTitleCandidate =
|
||||
metadata && typeof metadata === "object" ? metadata.title : metadata;
|
||||
const title = typeof rawTitleCandidate === "string"
|
||||
? rawTitleCandidate.trim()
|
||||
: String(rawTitleCandidate ?? "").trim();
|
||||
|
||||
if (!title) {
|
||||
this.setCloudflareUploadStatus("Title is required.", "error");
|
||||
this.setCloudflareUploadStatus(
|
||||
getVideoNoteErrorMessage(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE),
|
||||
"error",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -668,18 +681,6 @@ class R2Service {
|
||||
return false;
|
||||
}
|
||||
|
||||
const description = String(metadata.description || "").trim();
|
||||
const thumbnail = String(metadata.thumbnail || "").trim();
|
||||
const magnet = String(metadata.magnet || "").trim();
|
||||
const ws = String(metadata.ws || "").trim();
|
||||
const xs = String(metadata.xs || "").trim();
|
||||
const enableComments = metadata.enableComments !== false;
|
||||
const isNsfw = metadata.isNsfw === true;
|
||||
const isForKids = metadata.isForKids === true && !isNsfw;
|
||||
|
||||
metadata.isNsfw = isNsfw;
|
||||
metadata.isForKids = isForKids;
|
||||
|
||||
const accountId = (this.cloudflareSettings?.accountId || "").trim();
|
||||
const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim();
|
||||
const secretAccessKey = (this.cloudflareSettings?.secretAccessKey || "").trim();
|
||||
@@ -766,17 +767,17 @@ class R2Service {
|
||||
);
|
||||
publishOutcome = false;
|
||||
} else {
|
||||
const payload = {
|
||||
const rawVideoPayload = {
|
||||
title,
|
||||
url: publicUrl,
|
||||
magnet,
|
||||
thumbnail,
|
||||
description,
|
||||
ws,
|
||||
xs,
|
||||
enableComments,
|
||||
isNsfw,
|
||||
isForKids,
|
||||
magnet: metadata?.magnet ?? "",
|
||||
thumbnail: metadata?.thumbnail ?? "",
|
||||
description: metadata?.description ?? "",
|
||||
ws: metadata?.ws ?? "",
|
||||
xs: metadata?.xs ?? "",
|
||||
enableComments: metadata?.enableComments,
|
||||
isNsfw: metadata?.isNsfw,
|
||||
isForKids: metadata?.isForKids,
|
||||
};
|
||||
|
||||
const mergedNip71 = this.buildNip71MetadataForUpload(metadata?.nip71, {
|
||||
@@ -784,7 +785,15 @@ class R2Service {
|
||||
file,
|
||||
});
|
||||
if (mergedNip71 && Object.keys(mergedNip71).length) {
|
||||
payload.nip71 = mergedNip71;
|
||||
rawVideoPayload.nip71 = mergedNip71;
|
||||
}
|
||||
|
||||
const { payload, errors } = normalizeVideoNotePayload(rawVideoPayload);
|
||||
|
||||
if (errors.length) {
|
||||
const message = getVideoNoteErrorMessage(errors[0]);
|
||||
this.setCloudflareUploadStatus(message, "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
const published = await publishVideoNote(payload, {
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import { extractMagnetHints, normalizeAndAugmentMagnet } from "../magnet.js";
|
||||
|
||||
export const VIDEO_NOTE_ERROR_CODES = {
|
||||
MISSING_TITLE: "missing_title",
|
||||
MISSING_SOURCE: "missing_source",
|
||||
INVALID_URL_PROTOCOL: "invalid_url_protocol",
|
||||
};
|
||||
|
||||
export const VIDEO_NOTE_ERROR_MESSAGES = {
|
||||
[VIDEO_NOTE_ERROR_CODES.MISSING_TITLE]: "Title is required.",
|
||||
[VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE]:
|
||||
"Provide a hosted URL, magnet link, or an imeta variant before publishing.",
|
||||
[VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL]:
|
||||
"Hosted video URLs must use HTTPS.",
|
||||
};
|
||||
|
||||
export function getVideoNoteErrorMessage(code) {
|
||||
return VIDEO_NOTE_ERROR_MESSAGES[code] || "Unable to submit video. Please try again.";
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value).trim() : "";
|
||||
}
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function parseNumberOrNull(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseUnixSeconds(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const normalized = value > 1e12 ? value / 1000 : value;
|
||||
return Math.floor(normalized);
|
||||
}
|
||||
const trimmed = normalizeString(value);
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(trimmed);
|
||||
if (Number.isFinite(numeric)) {
|
||||
const normalized = numeric > 1e12 ? numeric / 1000 : numeric;
|
||||
return Math.floor(normalized);
|
||||
}
|
||||
const timestamp = Date.parse(trimmed);
|
||||
if (!Number.isNaN(timestamp)) {
|
||||
return Math.floor(timestamp / 1000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeStringArray(input) {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
return input
|
||||
.map((entry) => normalizeString(entry))
|
||||
.filter((entry) => Boolean(entry));
|
||||
}
|
||||
|
||||
function normalizeBooleanFlag(value, defaultValue = false) {
|
||||
if (value === true) {
|
||||
return true;
|
||||
}
|
||||
if (value === false) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(defaultValue);
|
||||
}
|
||||
|
||||
function normalizeImetaVariant(variant) {
|
||||
if (!variant || typeof variant !== "object") {
|
||||
return null;
|
||||
}
|
||||
const normalized = {};
|
||||
const m = normalizeString(variant.m);
|
||||
if (m) normalized.m = m;
|
||||
const dim = normalizeString(variant.dim);
|
||||
if (dim) normalized.dim = dim;
|
||||
const url = normalizeString(variant.url);
|
||||
if (url) normalized.url = url;
|
||||
const x = normalizeString(variant.x);
|
||||
if (x) normalized.x = x;
|
||||
const image = normalizeStringArray(variant.image);
|
||||
if (image.length) normalized.image = image;
|
||||
const fallback = normalizeStringArray(variant.fallback);
|
||||
if (fallback.length) normalized.fallback = fallback;
|
||||
const service = normalizeStringArray(variant.service);
|
||||
if (service.length) normalized.service = service;
|
||||
if (variant.autoGenerated === true) {
|
||||
normalized.autoGenerated = true;
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeSegments(segments) {
|
||||
if (!Array.isArray(segments)) {
|
||||
return [];
|
||||
}
|
||||
return segments
|
||||
.map((segment) => {
|
||||
if (!segment || typeof segment !== "object") {
|
||||
return null;
|
||||
}
|
||||
const startRaw = segment.start;
|
||||
const endRaw = segment.end;
|
||||
const start = parseNumberOrNull(startRaw);
|
||||
const end = parseNumberOrNull(endRaw);
|
||||
const normalizedSegment = {};
|
||||
if (start !== null) {
|
||||
normalizedSegment.start = start;
|
||||
} else {
|
||||
const startString = normalizeString(startRaw);
|
||||
if (startString) {
|
||||
normalizedSegment.start = startString;
|
||||
}
|
||||
}
|
||||
if (end !== null) {
|
||||
normalizedSegment.end = end;
|
||||
} else {
|
||||
const endString = normalizeString(endRaw);
|
||||
if (endString) {
|
||||
normalizedSegment.end = endString;
|
||||
}
|
||||
}
|
||||
const title = normalizeString(segment.title);
|
||||
if (title) {
|
||||
normalizedSegment.title = title;
|
||||
}
|
||||
const thumbnail = normalizeString(segment.thumbnail);
|
||||
if (thumbnail) {
|
||||
normalizedSegment.thumbnail = thumbnail;
|
||||
}
|
||||
return Object.keys(normalizedSegment).length ? normalizedSegment : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeTextTracks(tracks) {
|
||||
if (!Array.isArray(tracks)) {
|
||||
return [];
|
||||
}
|
||||
return tracks
|
||||
.map((track) => {
|
||||
if (!track || typeof track !== "object") {
|
||||
return null;
|
||||
}
|
||||
const normalizedTrack = {};
|
||||
const url = normalizeString(track.url);
|
||||
if (url) normalizedTrack.url = url;
|
||||
const type = normalizeString(track.type);
|
||||
if (type) normalizedTrack.type = type;
|
||||
const language = normalizeString(track.language);
|
||||
if (language) normalizedTrack.language = language;
|
||||
return Object.keys(normalizedTrack).length ? normalizedTrack : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeParticipants(participants) {
|
||||
if (!Array.isArray(participants)) {
|
||||
return [];
|
||||
}
|
||||
return participants
|
||||
.map((participant) => {
|
||||
if (!participant || typeof participant !== "object") {
|
||||
return null;
|
||||
}
|
||||
const normalizedParticipant = {};
|
||||
const pubkey = normalizeString(participant.pubkey);
|
||||
if (pubkey) normalizedParticipant.pubkey = pubkey;
|
||||
const relay = normalizeString(participant.relay);
|
||||
if (relay) normalizedParticipant.relay = relay;
|
||||
return Object.keys(normalizedParticipant).length
|
||||
? normalizedParticipant
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeNip71Metadata(rawMetadata) {
|
||||
if (!rawMetadata || typeof rawMetadata !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = {};
|
||||
|
||||
if (rawMetadata.kind !== undefined) {
|
||||
const kindNumber = parseNumberOrNull(rawMetadata.kind);
|
||||
if (kindNumber !== null) {
|
||||
normalized.kind = kindNumber;
|
||||
} else {
|
||||
const kindString = normalizeString(rawMetadata.kind);
|
||||
if (kindString) {
|
||||
normalized.kind = kindString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary = normalizeString(rawMetadata.summary);
|
||||
if (summary) {
|
||||
normalized.summary = summary;
|
||||
}
|
||||
|
||||
const publishedAt = parseUnixSeconds(rawMetadata.publishedAt);
|
||||
if (publishedAt !== null) {
|
||||
normalized.publishedAt = publishedAt;
|
||||
}
|
||||
|
||||
const alt = normalizeString(rawMetadata.alt);
|
||||
if (alt) {
|
||||
normalized.alt = alt;
|
||||
}
|
||||
|
||||
const duration = parseNumberOrNull(rawMetadata.duration);
|
||||
if (duration !== null) {
|
||||
normalized.duration = duration;
|
||||
}
|
||||
|
||||
const contentWarning = normalizeString(rawMetadata.contentWarning);
|
||||
if (contentWarning) {
|
||||
normalized.contentWarning = contentWarning;
|
||||
}
|
||||
|
||||
const imeta = Array.isArray(rawMetadata.imeta)
|
||||
? rawMetadata.imeta
|
||||
.map((variant) => normalizeImetaVariant(variant))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
if (imeta.length) {
|
||||
normalized.imeta = imeta;
|
||||
}
|
||||
|
||||
const textTracks = normalizeTextTracks(rawMetadata.textTracks);
|
||||
if (textTracks.length) {
|
||||
normalized.textTracks = textTracks;
|
||||
}
|
||||
|
||||
const segments = normalizeSegments(rawMetadata.segments);
|
||||
if (segments.length) {
|
||||
normalized.segments = segments;
|
||||
}
|
||||
|
||||
const hashtags = normalizeStringArray(rawMetadata.hashtags);
|
||||
if (hashtags.length) {
|
||||
normalized.hashtags = hashtags;
|
||||
}
|
||||
|
||||
const participants = normalizeParticipants(rawMetadata.participants);
|
||||
if (participants.length) {
|
||||
normalized.participants = participants;
|
||||
}
|
||||
|
||||
const references = normalizeStringArray(rawMetadata.references);
|
||||
if (references.length) {
|
||||
normalized.references = references;
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length ? normalized : null;
|
||||
}
|
||||
|
||||
export function normalizeVideoNotePayload(input) {
|
||||
const rawPayload = input && typeof input === "object" ? input : {};
|
||||
const legacyPayload =
|
||||
rawPayload.legacyFormData && typeof rawPayload.legacyFormData === "object"
|
||||
? rawPayload.legacyFormData
|
||||
: rawPayload;
|
||||
|
||||
const rawNip71 =
|
||||
(rawPayload.nip71 && typeof rawPayload.nip71 === "object"
|
||||
? rawPayload.nip71
|
||||
: null) ||
|
||||
(legacyPayload.nip71 && typeof legacyPayload.nip71 === "object"
|
||||
? legacyPayload.nip71
|
||||
: null);
|
||||
|
||||
const title = normalizeString(legacyPayload?.title || "");
|
||||
const url = normalizeString(legacyPayload?.url || "");
|
||||
const magnet = normalizeString(legacyPayload?.magnet || "");
|
||||
const thumbnail = normalizeString(legacyPayload?.thumbnail || "");
|
||||
const description = normalizeString(legacyPayload?.description || "");
|
||||
const ws = normalizeString(legacyPayload?.ws || "");
|
||||
const xs = normalizeString(legacyPayload?.xs || "");
|
||||
const rawMode = normalizeString(legacyPayload?.mode || "");
|
||||
const normalizedMode = rawMode && rawMode.toLowerCase() === "dev" ? "dev" : "live";
|
||||
const enableComments = normalizeBooleanFlag(legacyPayload?.enableComments, true);
|
||||
const rawIsNsfw = normalizeBooleanFlag(legacyPayload?.isNsfw, false);
|
||||
const rawIsForKids = normalizeBooleanFlag(legacyPayload?.isForKids, false);
|
||||
const isNsfw = rawIsNsfw;
|
||||
const isForKids = rawIsNsfw ? false : rawIsForKids;
|
||||
const isPrivate = normalizeBooleanFlag(legacyPayload?.isPrivate, false);
|
||||
|
||||
const legacyFormData = {
|
||||
version: 3,
|
||||
title,
|
||||
url,
|
||||
magnet,
|
||||
thumbnail,
|
||||
description,
|
||||
mode: normalizedMode,
|
||||
enableComments,
|
||||
isNsfw,
|
||||
isForKids,
|
||||
};
|
||||
|
||||
if (isPrivate) {
|
||||
legacyFormData.isPrivate = true;
|
||||
}
|
||||
|
||||
const normalizedNip71 = normalizeNip71Metadata(rawNip71);
|
||||
|
||||
const hasLegacySource = Boolean(legacyFormData.url || legacyFormData.magnet);
|
||||
const hasImetaVariant = Boolean(
|
||||
normalizedNip71?.imeta?.some((variant) =>
|
||||
Boolean(
|
||||
(variant.url && variant.url.length) ||
|
||||
(variant.m && variant.m.length) ||
|
||||
(variant.x && variant.x.length) ||
|
||||
(variant.dim && variant.dim.length) ||
|
||||
(Array.isArray(variant.image) && variant.image.length > 0) ||
|
||||
(Array.isArray(variant.fallback) && variant.fallback.length > 0) ||
|
||||
(Array.isArray(variant.service) && variant.service.length > 0),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (!legacyFormData.title) {
|
||||
errors.push(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE);
|
||||
}
|
||||
|
||||
if (!hasLegacySource && !hasImetaVariant) {
|
||||
errors.push(VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE);
|
||||
}
|
||||
|
||||
if (legacyFormData.url && !/^https:\/\//i.test(legacyFormData.url)) {
|
||||
errors.push(VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL);
|
||||
}
|
||||
|
||||
if (legacyFormData.magnet) {
|
||||
const normalizedMagnet = normalizeAndAugmentMagnet(legacyFormData.magnet, {
|
||||
ws,
|
||||
xs,
|
||||
});
|
||||
legacyFormData.magnet = normalizedMagnet;
|
||||
const hints = extractMagnetHints(normalizedMagnet);
|
||||
legacyFormData.ws = hints.ws || "";
|
||||
legacyFormData.xs = hints.xs || "";
|
||||
} else {
|
||||
legacyFormData.ws = "";
|
||||
legacyFormData.xs = "";
|
||||
}
|
||||
|
||||
const publishPayload = {
|
||||
legacyFormData,
|
||||
};
|
||||
|
||||
if (normalizedNip71) {
|
||||
publishPayload.nip71 = normalizedNip71;
|
||||
}
|
||||
|
||||
return {
|
||||
payload: publishPayload,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
+45
-12
@@ -132,6 +132,7 @@ class SubscriptionsManager {
|
||||
this.subscribedPubkeys = new Set();
|
||||
this.subsEventId = null;
|
||||
this.loaded = false;
|
||||
this.loadingPromise = null;
|
||||
this.subscriptionListView = null;
|
||||
this.lastRunOptions = null;
|
||||
this.lastResult = null;
|
||||
@@ -297,6 +298,35 @@ class SubscriptionsManager {
|
||||
}
|
||||
}
|
||||
|
||||
async ensureLoaded(actorHex) {
|
||||
if (this.loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedActor = normalizeHexPubkey(actorHex) || actorHex;
|
||||
if (!normalizedActor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.loadingPromise) {
|
||||
try {
|
||||
await this.loadingPromise;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = this.loadSubscriptions(normalizedActor);
|
||||
this.loadingPromise = loader;
|
||||
|
||||
try {
|
||||
await loader;
|
||||
} finally {
|
||||
this.loadingPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
isSubscribed(channelHex) {
|
||||
const normalized = normalizeHexPubkey(channelHex);
|
||||
if (!normalized) {
|
||||
@@ -409,6 +439,7 @@ class SubscriptionsManager {
|
||||
if (!userPubkey) {
|
||||
throw new Error("No user pubkey => cannot addChannel.");
|
||||
}
|
||||
await this.ensureLoaded(userPubkey);
|
||||
if (!normalizedChannel) {
|
||||
devLogger.warn("Attempted to subscribe to invalid pubkey", channelHex);
|
||||
return;
|
||||
@@ -432,6 +463,7 @@ class SubscriptionsManager {
|
||||
if (!userPubkey) {
|
||||
throw new Error("No user pubkey => cannot removeChannel.");
|
||||
}
|
||||
await this.ensureLoaded(userPubkey);
|
||||
if (!normalizedChannel) {
|
||||
devLogger.warn("Attempted to remove invalid pubkey from subscriptions", channelHex);
|
||||
return;
|
||||
@@ -627,15 +659,13 @@ class SubscriptionsManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.loaded) {
|
||||
try {
|
||||
await this.loadSubscriptions(userPubkey);
|
||||
} catch (error) {
|
||||
userLogger.error(
|
||||
"[SubscriptionsManager] Failed to load subscriptions while rendering feed:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.ensureLoaded(userPubkey);
|
||||
} catch (error) {
|
||||
userLogger.error(
|
||||
"[SubscriptionsManager] Failed to load subscriptions while rendering feed:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
const channelHexes = this.getSubscribedAuthors();
|
||||
@@ -1222,7 +1252,8 @@ class SubscriptionsManager {
|
||||
}
|
||||
app?.handleEditVideo?.({
|
||||
eventId: video.id,
|
||||
index: Number.isFinite(index) ? index : null
|
||||
index: Number.isFinite(index) ? index : null,
|
||||
video
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1232,7 +1263,8 @@ class SubscriptionsManager {
|
||||
}
|
||||
app?.handleRevertVideo?.({
|
||||
eventId: video.id,
|
||||
index: Number.isFinite(index) ? index : null
|
||||
index: Number.isFinite(index) ? index : null,
|
||||
video
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1242,7 +1274,8 @@ class SubscriptionsManager {
|
||||
}
|
||||
app?.handleFullDeleteVideo?.({
|
||||
eventId: video.id,
|
||||
index: Number.isFinite(index) ? index : null
|
||||
index: Number.isFinite(index) ? index : null,
|
||||
video
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { devLogger as defaultLogger } from "../utils/logger.js";
|
||||
import {
|
||||
prepareStaticModal,
|
||||
openStaticModal,
|
||||
closeStaticModal,
|
||||
} from "./components/staticModalAccessibility.js";
|
||||
|
||||
const DEFAULT_LOGOUT_ERROR_MESSAGE = "Failed to logout. Please try again.";
|
||||
|
||||
export default class AppChromeController {
|
||||
constructor({
|
||||
elements = {},
|
||||
callbacks = {},
|
||||
utilities = {},
|
||||
environment = {},
|
||||
logger,
|
||||
} = {}) {
|
||||
this.elements = {
|
||||
logoutButton: elements.logoutButton || null,
|
||||
profileButton: elements.profileButton || null,
|
||||
uploadButton: elements.uploadButton || null,
|
||||
loginButton: elements.loginButton || null,
|
||||
closeLoginModalButton: elements.closeLoginModalButton || null,
|
||||
};
|
||||
|
||||
this.callbacks = {
|
||||
requestLogout: callbacks.requestLogout || null,
|
||||
showError: callbacks.showError || null,
|
||||
showProfileModal: callbacks.showProfileModal || null,
|
||||
openUploadModal: callbacks.openUploadModal || null,
|
||||
onLoginModalOpened: callbacks.onLoginModalOpened || null,
|
||||
onLoginModalClosed: callbacks.onLoginModalClosed || null,
|
||||
flushWatchHistory: callbacks.flushWatchHistory || null,
|
||||
cleanup: callbacks.cleanup || null,
|
||||
hideModal: callbacks.hideModal || null,
|
||||
};
|
||||
|
||||
this.utilities = {
|
||||
prepareLoginModal:
|
||||
typeof utilities.prepareLoginModal === "function"
|
||||
? utilities.prepareLoginModal
|
||||
: () => prepareStaticModal({ id: "loginModal" }) || document.getElementById("loginModal"),
|
||||
prepareApplicationModal:
|
||||
typeof utilities.prepareApplicationModal === "function"
|
||||
? utilities.prepareApplicationModal
|
||||
: () => prepareStaticModal({ id: "nostrFormModal" }) || document.getElementById("nostrFormModal"),
|
||||
openModal:
|
||||
typeof utilities.openModal === "function"
|
||||
? utilities.openModal
|
||||
: (modal, options) => openStaticModal(modal, options),
|
||||
closeModal:
|
||||
typeof utilities.closeModal === "function"
|
||||
? utilities.closeModal
|
||||
: (modalId) => closeStaticModal(modalId),
|
||||
};
|
||||
|
||||
this.document = environment.document || (typeof document !== "undefined" ? document : null);
|
||||
this.window = environment.window || (typeof window !== "undefined" ? window : null);
|
||||
this.logger = logger || defaultLogger;
|
||||
|
||||
this.isInitialized = false;
|
||||
|
||||
this.handleBeforeUnload = this.handleBeforeUnload.bind(this);
|
||||
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
|
||||
this.handlePopstate = this.handlePopstate.bind(this);
|
||||
this.handleDocumentClick = this.handleDocumentClick.bind(this);
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bindLogoutButton();
|
||||
this.bindProfileButton();
|
||||
this.bindUploadButton();
|
||||
this.bindLoginButton();
|
||||
this.bindCloseLoginButton();
|
||||
this.bindGlobalEvents();
|
||||
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
getTriggerFromEvent(event) {
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
return event.currentTarget || event.target || null;
|
||||
}
|
||||
|
||||
bindLogoutButton() {
|
||||
const { logoutButton } = this.elements;
|
||||
if (!logoutButton || typeof logoutButton.addEventListener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
logoutButton.addEventListener("click", async () => {
|
||||
try {
|
||||
if (typeof this.callbacks.requestLogout === "function") {
|
||||
await this.callbacks.requestLogout();
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.logger?.error) {
|
||||
this.logger.error("Logout failed:", error);
|
||||
}
|
||||
if (typeof this.callbacks.showError === "function") {
|
||||
this.callbacks.showError(DEFAULT_LOGOUT_ERROR_MESSAGE);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindProfileButton() {
|
||||
const { profileButton } = this.elements;
|
||||
if (!profileButton || typeof profileButton.addEventListener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
profileButton.addEventListener("click", () => {
|
||||
if (typeof this.callbacks.showProfileModal !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => this.callbacks.showProfileModal())
|
||||
.catch((error) => {
|
||||
if (this.logger?.error) {
|
||||
this.logger.error("Failed to open profile modal:", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
bindUploadButton() {
|
||||
const { uploadButton } = this.elements;
|
||||
if (!uploadButton || typeof uploadButton.addEventListener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadButton.addEventListener("click", (event) => {
|
||||
if (typeof this.callbacks.openUploadModal !== "function") {
|
||||
return;
|
||||
}
|
||||
const triggerElement = this.getTriggerFromEvent(event);
|
||||
try {
|
||||
this.callbacks.openUploadModal({ triggerElement });
|
||||
} catch (error) {
|
||||
if (this.logger?.error) {
|
||||
this.logger.error("Failed to open upload modal:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindLoginButton() {
|
||||
const { loginButton } = this.elements;
|
||||
if (!loginButton || typeof loginButton.addEventListener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
loginButton.addEventListener("click", (event) => {
|
||||
if (this.logger?.log) {
|
||||
this.logger.log("Login button clicked!");
|
||||
}
|
||||
|
||||
const triggerElement = this.getTriggerFromEvent(event);
|
||||
const loginModal =
|
||||
(typeof this.utilities.prepareLoginModal === "function"
|
||||
? this.utilities.prepareLoginModal()
|
||||
: null) || null;
|
||||
|
||||
if (
|
||||
loginModal &&
|
||||
typeof this.utilities.openModal === "function" &&
|
||||
this.utilities.openModal(loginModal, { triggerElement })
|
||||
) {
|
||||
if (typeof this.callbacks.onLoginModalOpened === "function") {
|
||||
this.callbacks.onLoginModalOpened();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindCloseLoginButton() {
|
||||
const { closeLoginModalButton } = this.elements;
|
||||
if (
|
||||
!closeLoginModalButton ||
|
||||
typeof closeLoginModalButton.addEventListener !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeLoginModalButton.addEventListener("click", () => {
|
||||
if (this.logger?.log) {
|
||||
this.logger.log("[app.js] closeLoginModal button clicked!");
|
||||
}
|
||||
|
||||
if (typeof this.utilities.closeModal === "function") {
|
||||
const wasClosed = this.utilities.closeModal("loginModal");
|
||||
if (wasClosed && typeof this.callbacks.onLoginModalClosed === "function") {
|
||||
this.callbacks.onLoginModalClosed();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindGlobalEvents() {
|
||||
if (this.window && typeof this.window.addEventListener === "function") {
|
||||
this.window.addEventListener("beforeunload", this.handleBeforeUnload);
|
||||
this.window.addEventListener("popstate", this.handlePopstate);
|
||||
}
|
||||
|
||||
if (this.document && typeof this.document.addEventListener === "function") {
|
||||
this.document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
this.document.addEventListener("click", this.handleDocumentClick);
|
||||
}
|
||||
}
|
||||
|
||||
handleBeforeUnload() {
|
||||
if (typeof this.callbacks.flushWatchHistory === "function") {
|
||||
Promise.resolve()
|
||||
.then(() => this.callbacks.flushWatchHistory("session-end", "beforeunload"))
|
||||
.catch((error) => {
|
||||
if (this.logger?.warn) {
|
||||
this.logger.warn("[beforeunload] Watch history flush failed:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof this.callbacks.cleanup === "function") {
|
||||
Promise.resolve()
|
||||
.then(() => this.callbacks.cleanup())
|
||||
.catch((error) => {
|
||||
if (this.logger?.error) {
|
||||
this.logger.error("Cleanup before unload failed:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleVisibilityChange() {
|
||||
if (!this.document || this.document.visibilityState !== "hidden") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.callbacks.flushWatchHistory !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => this.callbacks.flushWatchHistory("session-end", "visibilitychange"))
|
||||
.catch((error) => {
|
||||
if (this.logger?.warn) {
|
||||
this.logger.warn("[visibilitychange] Watch history flush failed:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handlePopstate() {
|
||||
if (this.logger?.log) {
|
||||
this.logger.log("[popstate] user navigated back/forward; cleaning modal...");
|
||||
}
|
||||
|
||||
if (typeof this.callbacks.hideModal !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.callbacks.hideModal();
|
||||
} catch (error) {
|
||||
if (this.logger?.warn) {
|
||||
this.logger.warn("[popstate] Failed to hide modal:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleDocumentClick(event) {
|
||||
if (!event?.target || event.target.id !== "openApplicationModal") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.utilities.closeModal === "function") {
|
||||
const closed = this.utilities.closeModal("loginModal");
|
||||
if (closed && typeof this.callbacks.onLoginModalClosed === "function") {
|
||||
this.callbacks.onLoginModalClosed();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof this.utilities.prepareApplicationModal !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const applicationModal = this.utilities.prepareApplicationModal();
|
||||
if (
|
||||
applicationModal &&
|
||||
typeof this.utilities.openModal === "function"
|
||||
) {
|
||||
this.utilities.openModal(applicationModal, {
|
||||
triggerElement: event.target,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,10 @@ export class EditModal {
|
||||
typeof setGlobalModalState === "function" ? setGlobalModalState : () => {};
|
||||
this.escapeHtml = typeof escapeHtml === "function" ? escapeHtml : (value) => `${value ?? ""}`;
|
||||
this.showError = typeof showError === "function" ? showError : () => {};
|
||||
this.getMode = typeof getMode === "function" ? getMode : () => "live";
|
||||
this.getMode =
|
||||
typeof getMode === "function"
|
||||
? getMode
|
||||
: () => (this.activeVideo?.mode ?? "live");
|
||||
this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
|
||||
this.container = container || null;
|
||||
|
||||
@@ -312,6 +315,37 @@ export class EditModal {
|
||||
return { isNsfw, isForKids };
|
||||
}
|
||||
|
||||
normalizeMode(value) {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "dev") {
|
||||
return "dev";
|
||||
}
|
||||
}
|
||||
return "live";
|
||||
}
|
||||
|
||||
resolveModeForSubmit() {
|
||||
let candidate = null;
|
||||
if (typeof this.getMode === "function") {
|
||||
try {
|
||||
candidate = this.getMode({ video: this.activeVideo });
|
||||
} catch (_error) {
|
||||
candidate = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return this.normalizeMode(candidate);
|
||||
}
|
||||
|
||||
if (this.activeVideo && typeof this.activeVideo.mode === "string") {
|
||||
return this.normalizeMode(this.activeVideo.mode);
|
||||
}
|
||||
|
||||
return "live";
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (!this.root) {
|
||||
this.activeVideo = null;
|
||||
@@ -963,7 +997,7 @@ export class EditModal {
|
||||
url: finalUrl,
|
||||
thumbnail: finalThumbnail,
|
||||
description: finalDescription,
|
||||
mode: this.getMode(),
|
||||
mode: this.resolveModeForSubmit(),
|
||||
ws: finalWs,
|
||||
xs: finalXs,
|
||||
wsEdited: !shouldUseOriginalWs,
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import { createModalAccessibility } from "./modalAccessibility.js";
|
||||
import { Nip71FormManager } from "./nip71FormManager.js";
|
||||
import { userLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
getVideoNoteErrorMessage,
|
||||
normalizeVideoNotePayload,
|
||||
VIDEO_NOTE_ERROR_CODES,
|
||||
} from "../../services/videoNotePayload.js";
|
||||
|
||||
export class UploadModal {
|
||||
constructor({
|
||||
@@ -717,14 +722,15 @@ export class UploadModal {
|
||||
isNsfw: this.readCheckboxValue(this.customFormInputs.isNsfw, false),
|
||||
isForKids: this.readCheckboxValue(this.customFormInputs.isForKids, false)
|
||||
});
|
||||
const payload = {
|
||||
title: this.customFormInputs.title?.value?.trim() || "",
|
||||
url: this.customFormInputs.url?.value?.trim() || "",
|
||||
magnet: this.customFormInputs.magnet?.value?.trim() || "",
|
||||
ws: this.customFormInputs.ws?.value?.trim() || "",
|
||||
xs: this.customFormInputs.xs?.value?.trim() || "",
|
||||
thumbnail: this.customFormInputs.thumbnail?.value?.trim() || "",
|
||||
description: this.customFormInputs.description?.value?.trim() || "",
|
||||
|
||||
const rawPayload = {
|
||||
title: this.customFormInputs.title?.value ?? "",
|
||||
url: this.customFormInputs.url?.value ?? "",
|
||||
magnet: this.customFormInputs.magnet?.value ?? "",
|
||||
ws: this.customFormInputs.ws?.value ?? "",
|
||||
xs: this.customFormInputs.xs?.value ?? "",
|
||||
thumbnail: this.customFormInputs.thumbnail?.value ?? "",
|
||||
description: this.customFormInputs.description?.value ?? "",
|
||||
enableComments: this.readCheckboxValue(
|
||||
this.customFormInputs.enableComments,
|
||||
true
|
||||
@@ -733,7 +739,7 @@ export class UploadModal {
|
||||
};
|
||||
|
||||
if (this.customFormInputs.isPrivate) {
|
||||
payload.isPrivate = this.readCheckboxValue(
|
||||
rawPayload.isPrivate = this.readCheckboxValue(
|
||||
this.customFormInputs.isPrivate,
|
||||
false
|
||||
);
|
||||
@@ -741,7 +747,15 @@ export class UploadModal {
|
||||
|
||||
const nip71Metadata = this.nip71FormManager.collectSection("custom");
|
||||
if (nip71Metadata) {
|
||||
payload.nip71 = nip71Metadata;
|
||||
rawPayload.nip71 = nip71Metadata;
|
||||
}
|
||||
|
||||
const { payload, errors } = normalizeVideoNotePayload(rawPayload);
|
||||
|
||||
if (errors.length) {
|
||||
const message = getVideoNoteErrorMessage(errors[0]);
|
||||
this.showError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit("upload:submit", { payload });
|
||||
@@ -1028,12 +1042,12 @@ export class UploadModal {
|
||||
isForKids: this.readCheckboxValue(this.cloudflareIsForKidsInput, false)
|
||||
});
|
||||
const metadata = {
|
||||
title: this.cloudflareTitleInput?.value?.trim() || "",
|
||||
description: this.cloudflareDescriptionInput?.value?.trim() || "",
|
||||
thumbnail: this.cloudflareThumbnailInput?.value?.trim() || "",
|
||||
magnet: this.cloudflareMagnetInput?.value?.trim() || "",
|
||||
ws: this.cloudflareWsInput?.value?.trim() || "",
|
||||
xs: this.cloudflareXsInput?.value?.trim() || "",
|
||||
title: this.cloudflareTitleInput?.value ?? "",
|
||||
description: this.cloudflareDescriptionInput?.value ?? "",
|
||||
thumbnail: this.cloudflareThumbnailInput?.value ?? "",
|
||||
magnet: this.cloudflareMagnetInput?.value ?? "",
|
||||
ws: this.cloudflareWsInput?.value ?? "",
|
||||
xs: this.cloudflareXsInput?.value ?? "",
|
||||
enableComments: this.readCheckboxValue(
|
||||
this.cloudflareEnableCommentsInput,
|
||||
true
|
||||
@@ -1041,6 +1055,16 @@ export class UploadModal {
|
||||
...audienceFlags
|
||||
};
|
||||
|
||||
metadata.title = metadata.title?.trim() || "";
|
||||
|
||||
if (!metadata.title) {
|
||||
this.setCloudflareUploadStatus(
|
||||
getVideoNoteErrorMessage(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE),
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const nip71Metadata = this.nip71FormManager.collectSection("cloudflare");
|
||||
if (nip71Metadata) {
|
||||
const imetaList = Array.isArray(nip71Metadata.imeta)
|
||||
|
||||
+54
-249
@@ -4,6 +4,15 @@ import { createVideoMoreMenuPanel } from "./videoMenuRenderers.js";
|
||||
import { attachAmbientBackground } from "../ambientBackground.js";
|
||||
import { applyDesignSystemAttributes } from "../../designSystem.js";
|
||||
import { devLogger } from "../../utils/logger.js";
|
||||
import { CommentsController } from "./video-modal/commentsController.js";
|
||||
import { ReactionsController } from "./video-modal/reactionsController.js";
|
||||
import { SimilarContentController } from "./video-modal/similarContentController.js";
|
||||
import { ModerationController } from "./video-modal/moderationController.js";
|
||||
import {
|
||||
normalizeCommentAvatarKey as normalizeCommentAvatarKeyUtil,
|
||||
resolveCommentAvatarAsset as resolveCommentAvatarAssetUtil,
|
||||
registerCommentAvatarFailure as registerCommentAvatarFailureUtil,
|
||||
} from "./video-modal/utils/commentAvatar.js";
|
||||
import {
|
||||
renderTagPillStrip,
|
||||
applyTagPreferenceState,
|
||||
@@ -188,6 +197,11 @@ export class VideoModal {
|
||||
canManageBlacklist: false,
|
||||
};
|
||||
|
||||
this.commentsController = new CommentsController({ modal: this });
|
||||
this.reactionsController = new ReactionsController({ modal: this });
|
||||
this.similarContentController = new SimilarContentController({ modal: this });
|
||||
this.moderationController = new ModerationController({ modal: this });
|
||||
|
||||
this.commentsRoot = null;
|
||||
this.commentsContainer = null;
|
||||
this.commentsCountLabel = null;
|
||||
@@ -479,6 +493,11 @@ export class VideoModal {
|
||||
);
|
||||
}
|
||||
|
||||
this.commentsController.destroy();
|
||||
this.reactionsController.destroy();
|
||||
this.similarContentController.destroy();
|
||||
this.moderationController.destroy();
|
||||
|
||||
this.clearSimilarContent({ preservePending: true });
|
||||
|
||||
this.playerModal = playerModal;
|
||||
@@ -486,7 +505,6 @@ export class VideoModal {
|
||||
this.modalBackdrop = playerModal.querySelector("[data-dismiss]") || null;
|
||||
this.scrollRegion = this.modalPanel || playerModal;
|
||||
|
||||
this.teardownSimilarContentMediaQuery();
|
||||
const nextVideoTagsRoot = playerModal.querySelector("#videoTags") || null;
|
||||
if (this.videoTagsRoot && this.videoTagsRoot !== nextVideoTagsRoot) {
|
||||
this.cleanupVideoTags(this.videoTagsRoot);
|
||||
@@ -496,26 +514,8 @@ export class VideoModal {
|
||||
this.cleanupVideoTags(this.videoTagsRoot);
|
||||
}
|
||||
|
||||
const similarHeading =
|
||||
playerModal.querySelector("#playerModalSimilarContentHeading") || null;
|
||||
const similarList =
|
||||
playerModal.querySelector("#playerModalSimilarContentList") || null;
|
||||
const similarSection =
|
||||
similarList?.closest(
|
||||
"[aria-labelledby='playerModalSimilarContentHeading']"
|
||||
) || similarHeading?.closest(
|
||||
"[aria-labelledby='playerModalSimilarContentHeading']"
|
||||
) || null;
|
||||
const similarContainer =
|
||||
similarList?.closest(".watch-container") ||
|
||||
similarHeading?.closest(".watch-container") ||
|
||||
similarSection?.closest(".watch-container") ||
|
||||
similarSection ||
|
||||
null;
|
||||
|
||||
this.similarContentHeading = similarHeading;
|
||||
this.similarContentList = similarList;
|
||||
this.similarContentContainer = similarContainer;
|
||||
const { container: similarContainer } =
|
||||
this.similarContentController.initialize({ playerModal }) || {};
|
||||
|
||||
const statsContainerCandidate =
|
||||
playerModal.querySelector("[data-video-stats-container]") ||
|
||||
@@ -540,65 +540,12 @@ export class VideoModal {
|
||||
statsContainer: statsContainerCandidate,
|
||||
});
|
||||
|
||||
this.setupSimilarContentMediaQuery();
|
||||
|
||||
if (similarList && similarList.children.length) {
|
||||
this.toggleSimilarContentVisibility(true);
|
||||
} else {
|
||||
this.toggleSimilarContentVisibility(false);
|
||||
}
|
||||
|
||||
const previousTags = Array.isArray(this.videoTagsData)
|
||||
? [...this.videoTagsData]
|
||||
: [];
|
||||
this.videoTagsData = null;
|
||||
|
||||
this.videoStage = playerModal.querySelector(".video-modal__video") || null;
|
||||
this.moderationOverlay =
|
||||
this.videoStage?.querySelector("[data-moderation-overlay]") || null;
|
||||
this.moderationBadge =
|
||||
this.moderationOverlay?.querySelector("[data-moderation-badge='true']") || null;
|
||||
this.moderationBadgeText =
|
||||
this.moderationOverlay?.querySelector("[data-moderation-text]") || null;
|
||||
if (this.moderationPrimaryButton) {
|
||||
if (this.moderationPrimaryMode === "override") {
|
||||
this.moderationPrimaryButton.removeEventListener(
|
||||
"click",
|
||||
this.handleModerationOverrideClick,
|
||||
);
|
||||
} else if (this.moderationPrimaryMode === "hide") {
|
||||
this.moderationPrimaryButton.removeEventListener(
|
||||
"click",
|
||||
this.handleModerationHideClick,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.moderationActionsContainer =
|
||||
this.moderationOverlay?.querySelector("[data-moderation-actions]") ||
|
||||
this.moderationBadge?.querySelector(".moderation-badge__actions") ||
|
||||
null;
|
||||
const overrideButton =
|
||||
this.moderationActionsContainer?.querySelector(
|
||||
"[data-moderation-action='override']",
|
||||
) || null;
|
||||
if (overrideButton) {
|
||||
overrideButton.addEventListener(
|
||||
"click",
|
||||
this.handleModerationOverrideClick,
|
||||
);
|
||||
this.moderationPrimaryButton = overrideButton;
|
||||
this.moderationPrimaryMode = "override";
|
||||
} else {
|
||||
this.moderationPrimaryButton = null;
|
||||
this.moderationPrimaryMode = "";
|
||||
}
|
||||
if (this.moderationBlockButton) {
|
||||
this.moderationBlockButton.removeEventListener(
|
||||
"click",
|
||||
this.handleModerationBlockClick,
|
||||
);
|
||||
}
|
||||
this.moderationBlockButton = null;
|
||||
this.moderationController.initialize({ playerModal });
|
||||
|
||||
this.modalVideo = playerModal.querySelector("#modalVideo") || null;
|
||||
this.modalStatus = playerModal.querySelector("#modalStatus") || null;
|
||||
@@ -635,22 +582,7 @@ export class VideoModal {
|
||||
this.shareBtn = playerModal.querySelector("#shareBtn") || null;
|
||||
this.modalZapBtn = playerModal.querySelector("#modalZapBtn") || null;
|
||||
this.modalMoreBtn = playerModal.querySelector("#modalMoreBtn") || null;
|
||||
this.reactionButtons["+"] =
|
||||
playerModal.querySelector("#modalLikeBtn") || null;
|
||||
this.reactionButtons["-"] =
|
||||
playerModal.querySelector("#modalDislikeBtn") || null;
|
||||
this.reactionMeter =
|
||||
playerModal.querySelector("[data-reaction-meter]") || null;
|
||||
this.reactionMeterFill =
|
||||
playerModal.querySelector("[data-reaction-meter-fill]") || null;
|
||||
this.reactionMeterLabel =
|
||||
playerModal.querySelector("[data-reaction-meter-label]") || null;
|
||||
this.reactionMeterAssistive =
|
||||
playerModal.querySelector("[data-reaction-meter-sr]") || null;
|
||||
this.reactionCountLabels["+"] =
|
||||
playerModal.querySelector("[data-reaction-like-count]") || null;
|
||||
this.reactionCountLabels["-"] =
|
||||
playerModal.querySelector("[data-reaction-dislike-count]") || null;
|
||||
this.reactionsController.initialize({ playerModal });
|
||||
|
||||
this.renderVideoTags(previousTags);
|
||||
|
||||
@@ -688,90 +620,7 @@ export class VideoModal {
|
||||
|
||||
this.refreshActiveVideoModeration({ video: this.activeVideo });
|
||||
|
||||
this.detachCommentEventHandlers();
|
||||
|
||||
const commentsRoot =
|
||||
playerModal.querySelector("[data-comments-root]") || null;
|
||||
this.commentsRoot = commentsRoot;
|
||||
this.commentsContainer = commentsRoot;
|
||||
this.commentsCountLabel =
|
||||
commentsRoot?.querySelector("[data-comments-count]") || null;
|
||||
this.commentsEmptyState =
|
||||
commentsRoot?.querySelector("[data-comments-empty]") || null;
|
||||
this.commentsList =
|
||||
commentsRoot?.querySelector("[data-comments-list]") || null;
|
||||
this.commentsLoadMoreButton =
|
||||
commentsRoot?.querySelector("[data-comments-load-more]") || null;
|
||||
this.commentsDisabledPlaceholder =
|
||||
playerModal.querySelector("[data-comments-disabled-placeholder]") ||
|
||||
commentsRoot?.querySelector("[data-comments-disabled]") ||
|
||||
null;
|
||||
this.commentsComposer =
|
||||
commentsRoot?.querySelector("[data-comments-composer]") || null;
|
||||
this.commentsInput =
|
||||
this.commentsComposer?.querySelector("[data-comments-input]") || null;
|
||||
this.commentsSubmitButton =
|
||||
this.commentsComposer?.querySelector("[data-comments-submit]") || null;
|
||||
this.commentsStatusMessage =
|
||||
this.commentsComposer?.querySelector("[data-comments-status]") ||
|
||||
commentsRoot?.querySelector("[data-comments-status]") ||
|
||||
null;
|
||||
this.commentsCharCount =
|
||||
this.commentsComposer?.querySelector("[data-comments-char-count]") ||
|
||||
null;
|
||||
this.commentComposerHint =
|
||||
this.commentsComposer?.querySelector("#commentComposerHelp") ||
|
||||
this.commentsComposer?.querySelector(".comment-composer__hint") ||
|
||||
this.commentComposerHint ||
|
||||
null;
|
||||
|
||||
if (this.commentComposerHint) {
|
||||
const hintText = this.commentComposerHint.textContent || "";
|
||||
if (hintText) {
|
||||
this.commentComposerDefaultHint = hintText;
|
||||
}
|
||||
}
|
||||
if (this.commentsCharCount) {
|
||||
const countText = this.commentsCharCount.textContent || "";
|
||||
if (countText) {
|
||||
this.commentComposerDefaultCountText = countText;
|
||||
}
|
||||
}
|
||||
if (this.commentsStatusMessage) {
|
||||
const statusText = this.commentsStatusMessage.textContent || "";
|
||||
if (statusText) {
|
||||
this.commentStatusDefaultText = statusText;
|
||||
}
|
||||
}
|
||||
if (this.commentsInput) {
|
||||
const parsedMax = Number(this.commentsInput.maxLength);
|
||||
if (Number.isFinite(parsedMax) && parsedMax > 0) {
|
||||
this.commentComposerMaxLength = parsedMax;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.commentsDisabledPlaceholder) {
|
||||
const disabledText = this.commentsDisabledPlaceholder.textContent || "";
|
||||
if (disabledText) {
|
||||
this.commentsDisabledPlaceholderDefaultText = disabledText;
|
||||
}
|
||||
this.commentsDisabledPlaceholder.setAttribute("hidden", "");
|
||||
}
|
||||
if (this.commentsComposer) {
|
||||
this.commentsComposer.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
this.clearComments();
|
||||
this.resetCommentComposer();
|
||||
this.attachCommentEventHandlers();
|
||||
|
||||
Object.values(this.reactionButtons).forEach((button) => {
|
||||
if (button) {
|
||||
button.addEventListener("click", this.handleReactionClick);
|
||||
}
|
||||
});
|
||||
this.syncReactionButtons();
|
||||
this.updateReactionMeterDisplay();
|
||||
this.commentsController.initialize({ playerModal });
|
||||
|
||||
const closeButton = playerModal.querySelector("#closeModal");
|
||||
if (closeButton) {
|
||||
@@ -1773,80 +1622,25 @@ export class VideoModal {
|
||||
}
|
||||
|
||||
resolveCommentAvatarAsset(pubkey, sanitizedPicture) {
|
||||
const normalizedPubkey = this.normalizeCommentAvatarKey(pubkey);
|
||||
const normalizedSource =
|
||||
typeof sanitizedPicture === "string" && sanitizedPicture
|
||||
? sanitizedPicture
|
||||
: "";
|
||||
|
||||
if (normalizedSource && this.commentAvatarFailures.has(normalizedSource)) {
|
||||
return { url: this.DEFAULT_PROFILE_AVATAR, source: "" };
|
||||
}
|
||||
|
||||
if (normalizedPubkey && this.commentAvatarCache.has(normalizedPubkey)) {
|
||||
const cached = this.commentAvatarCache.get(normalizedPubkey);
|
||||
if (cached) {
|
||||
const cachedSource =
|
||||
typeof cached.source === "string" ? cached.source : "";
|
||||
const cachedUrl =
|
||||
typeof cached.url === "string" && cached.url
|
||||
? cached.url
|
||||
: this.DEFAULT_PROFILE_AVATAR;
|
||||
|
||||
if (!normalizedSource) {
|
||||
return { url: cachedUrl, source: cachedSource };
|
||||
}
|
||||
|
||||
if (cachedSource === normalizedSource) {
|
||||
return { url: cachedUrl, source: cachedSource };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedUrl = normalizedSource || this.DEFAULT_PROFILE_AVATAR;
|
||||
if (normalizedPubkey) {
|
||||
this.commentAvatarCache.set(normalizedPubkey, {
|
||||
url: resolvedUrl,
|
||||
source: normalizedSource,
|
||||
});
|
||||
}
|
||||
|
||||
return { url: resolvedUrl, source: normalizedSource };
|
||||
return resolveCommentAvatarAssetUtil({
|
||||
cache: this.commentAvatarCache,
|
||||
failures: this.commentAvatarFailures,
|
||||
defaultAvatar: this.DEFAULT_PROFILE_AVATAR,
|
||||
pubkey,
|
||||
sanitizedPicture,
|
||||
});
|
||||
}
|
||||
|
||||
normalizeCommentAvatarKey(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
return normalizeCommentAvatarKeyUtil(value);
|
||||
}
|
||||
|
||||
registerCommentAvatarFailure(sourceUrl) {
|
||||
if (typeof sourceUrl !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = sourceUrl.trim();
|
||||
if (!trimmed || trimmed === this.DEFAULT_PROFILE_AVATAR) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.commentAvatarFailures.has(trimmed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.commentAvatarFailures.add(trimmed);
|
||||
|
||||
this.commentAvatarCache.forEach((entry, key) => {
|
||||
if (entry && entry.source === trimmed) {
|
||||
this.commentAvatarCache.set(key, {
|
||||
url: this.DEFAULT_PROFILE_AVATAR,
|
||||
source: "",
|
||||
});
|
||||
}
|
||||
registerCommentAvatarFailureUtil({
|
||||
cache: this.commentAvatarCache,
|
||||
failures: this.commentAvatarFailures,
|
||||
defaultAvatar: this.DEFAULT_PROFILE_AVATAR,
|
||||
sourceUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2136,8 +1930,10 @@ export class VideoModal {
|
||||
|
||||
destroy() {
|
||||
this.close();
|
||||
this.detachCommentEventHandlers();
|
||||
this.removeCommentRetryButton();
|
||||
this.commentsController.destroy();
|
||||
this.reactionsController.destroy();
|
||||
this.similarContentController.destroy();
|
||||
this.moderationController.destroy();
|
||||
this.invokeCommentTeardown();
|
||||
|
||||
if (this.document) {
|
||||
@@ -3989,11 +3785,11 @@ export class VideoModal {
|
||||
const badge = this.moderationBadge;
|
||||
const textEl = this.moderationBadgeText;
|
||||
|
||||
const shouldShow = Boolean(context?.shouldShow);
|
||||
const showModeration = Boolean(context?.shouldShow);
|
||||
const shouldBlur = Boolean(context?.activeBlur && !context?.overrideActive);
|
||||
const hiddenActive = Boolean(context?.activeHidden && !context?.overrideActive);
|
||||
|
||||
if (!shouldShow) {
|
||||
if (!showModeration) {
|
||||
if (stage && stage.dataset.visualState === "blurred") {
|
||||
delete stage.dataset.visualState;
|
||||
}
|
||||
@@ -4031,7 +3827,11 @@ export class VideoModal {
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
overlay.removeAttribute("hidden");
|
||||
if (showModeration && !context?.overrideActive) {
|
||||
overlay.removeAttribute("hidden");
|
||||
} else {
|
||||
overlay.setAttribute("hidden", "");
|
||||
}
|
||||
overlay.dataset.overlayState = context?.overrideActive ? "override" : "active";
|
||||
}
|
||||
|
||||
@@ -4129,7 +3929,12 @@ export class VideoModal {
|
||||
const actions = this.ensureModerationActionsContainer();
|
||||
let actionsAttached = false;
|
||||
|
||||
if (context?.allowOverride) {
|
||||
const allowOverride =
|
||||
context?.overrideActive === true ||
|
||||
context?.allowOverride === true ||
|
||||
(shouldBlur && !context?.overrideActive && context?.blurReason !== "viewer-mute");
|
||||
|
||||
if (allowOverride) {
|
||||
const mode = context.overrideActive ? "hide" : "override";
|
||||
const primaryButton = this.ensureModerationPrimaryButton(mode);
|
||||
if (primaryButton) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
export class CommentsController {
|
||||
constructor({ modal } = {}) {
|
||||
this.modal = modal;
|
||||
this.root = null;
|
||||
}
|
||||
|
||||
initialize({ playerModal } = {}) {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = playerModal?.querySelector("[data-comments-root]") || null;
|
||||
this.root = root;
|
||||
this.modal.commentsRoot = root;
|
||||
this.modal.commentsContainer = root;
|
||||
this.modal.commentsCountLabel =
|
||||
root?.querySelector("[data-comments-count]") || null;
|
||||
this.modal.commentsEmptyState =
|
||||
root?.querySelector("[data-comments-empty]") || null;
|
||||
this.modal.commentsList =
|
||||
root?.querySelector("[data-comments-list]") || null;
|
||||
this.modal.commentsLoadMoreButton =
|
||||
root?.querySelector("[data-comments-load-more]") || null;
|
||||
this.modal.commentsDisabledPlaceholder =
|
||||
playerModal?.querySelector("[data-comments-disabled-placeholder]") ||
|
||||
root?.querySelector("[data-comments-disabled]") ||
|
||||
null;
|
||||
this.modal.commentsComposer =
|
||||
root?.querySelector("[data-comments-composer]") || null;
|
||||
this.modal.commentsInput =
|
||||
this.modal.commentsComposer?.querySelector("[data-comments-input]") || null;
|
||||
this.modal.commentsSubmitButton =
|
||||
this.modal.commentsComposer?.querySelector("[data-comments-submit]") || null;
|
||||
this.modal.commentsStatusMessage =
|
||||
this.modal.commentsComposer?.querySelector("[data-comments-status]") ||
|
||||
root?.querySelector("[data-comments-status]") ||
|
||||
null;
|
||||
this.modal.commentsCharCount =
|
||||
this.modal.commentsComposer?.querySelector("[data-comments-char-count]") ||
|
||||
null;
|
||||
this.modal.commentComposerHint =
|
||||
this.modal.commentsComposer?.querySelector("#commentComposerHelp") ||
|
||||
this.modal.commentsComposer?.querySelector(".comment-composer__hint") ||
|
||||
this.modal.commentComposerHint ||
|
||||
null;
|
||||
|
||||
if (this.modal.commentComposerHint) {
|
||||
const hintText = this.modal.commentComposerHint.textContent || "";
|
||||
if (hintText) {
|
||||
this.modal.commentComposerDefaultHint = hintText;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.modal.commentsCharCount) {
|
||||
const countText = this.modal.commentsCharCount.textContent || "";
|
||||
if (countText) {
|
||||
this.modal.commentComposerDefaultCountText = countText;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.modal.commentsStatusMessage) {
|
||||
const statusText = this.modal.commentsStatusMessage.textContent || "";
|
||||
if (statusText) {
|
||||
this.modal.commentStatusDefaultText = statusText;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.modal.commentsInput) {
|
||||
const parsedMax = Number(this.modal.commentsInput.maxLength);
|
||||
if (Number.isFinite(parsedMax) && parsedMax > 0) {
|
||||
this.modal.commentComposerMaxLength = parsedMax;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.modal.commentsDisabledPlaceholder) {
|
||||
const disabledText =
|
||||
this.modal.commentsDisabledPlaceholder.textContent || "";
|
||||
if (disabledText) {
|
||||
this.modal.commentsDisabledPlaceholderDefaultText = disabledText;
|
||||
}
|
||||
this.modal.commentsDisabledPlaceholder.setAttribute("hidden", "");
|
||||
}
|
||||
|
||||
if (this.modal.commentsComposer) {
|
||||
this.modal.commentsComposer.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
this.modal.clearComments();
|
||||
this.modal.resetCommentComposer();
|
||||
this.modal.attachCommentEventHandlers();
|
||||
}
|
||||
|
||||
update(action = {}) {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
const { type } = action;
|
||||
switch (type) {
|
||||
case "set-visibility":
|
||||
this.modal.setCommentsVisibility(action.visible);
|
||||
break;
|
||||
case "render":
|
||||
this.modal.renderComments(action.snapshot);
|
||||
break;
|
||||
case "set-composer-state":
|
||||
this.modal.setCommentComposerState(action.state);
|
||||
break;
|
||||
case "set-thread-context":
|
||||
this.modal.setCommentThreadContext(action.context);
|
||||
break;
|
||||
case "clear":
|
||||
this.modal.clearComments();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
this.modal.detachCommentEventHandlers();
|
||||
this.modal.removeCommentRetryButton();
|
||||
this.modal.commentsRoot = null;
|
||||
this.modal.commentsContainer = null;
|
||||
this.modal.commentsCountLabel = null;
|
||||
this.modal.commentsEmptyState = null;
|
||||
this.modal.commentsList = null;
|
||||
this.modal.commentsLoadMoreButton = null;
|
||||
this.modal.commentsDisabledPlaceholder = null;
|
||||
this.modal.commentsComposer = null;
|
||||
this.modal.commentsInput = null;
|
||||
this.modal.commentsSubmitButton = null;
|
||||
this.modal.commentsStatusMessage = null;
|
||||
this.modal.commentsCharCount = null;
|
||||
this.modal.commentComposerHint = null;
|
||||
this.root = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
export class ModerationController {
|
||||
constructor({ modal } = {}) {
|
||||
this.modal = modal;
|
||||
}
|
||||
|
||||
initialize({ playerModal } = {}) {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modal.videoStage =
|
||||
playerModal?.querySelector(".video-modal__video") || null;
|
||||
this.modal.moderationOverlay =
|
||||
this.modal.videoStage?.querySelector("[data-moderation-overlay]") || null;
|
||||
this.modal.moderationBadge =
|
||||
this.modal.moderationOverlay?.querySelector(
|
||||
"[data-moderation-badge='true']",
|
||||
) || null;
|
||||
this.modal.moderationBadgeText =
|
||||
this.modal.moderationOverlay?.querySelector("[data-moderation-text]") ||
|
||||
null;
|
||||
|
||||
this.teardownPrimaryButton();
|
||||
this.modal.moderationActionsContainer =
|
||||
this.modal.moderationOverlay?.querySelector("[data-moderation-actions]") ||
|
||||
this.modal.moderationBadge?.querySelector(".moderation-badge__actions") ||
|
||||
null;
|
||||
|
||||
const overrideButton =
|
||||
this.modal.moderationActionsContainer?.querySelector(
|
||||
"[data-moderation-action='override']",
|
||||
) || null;
|
||||
if (overrideButton) {
|
||||
overrideButton.addEventListener(
|
||||
"click",
|
||||
this.modal.handleModerationOverrideClick,
|
||||
);
|
||||
this.modal.moderationPrimaryButton = overrideButton;
|
||||
this.modal.moderationPrimaryMode = "override";
|
||||
} else {
|
||||
this.modal.moderationPrimaryButton = null;
|
||||
this.modal.moderationPrimaryMode = "";
|
||||
}
|
||||
|
||||
this.teardownBlockButton();
|
||||
const blockButton =
|
||||
this.modal.moderationActionsContainer?.querySelector(
|
||||
"[data-moderation-action='block']",
|
||||
) || null;
|
||||
if (blockButton) {
|
||||
blockButton.addEventListener(
|
||||
"click",
|
||||
this.modal.handleModerationBlockClick,
|
||||
);
|
||||
this.modal.moderationBlockButton = blockButton;
|
||||
} else {
|
||||
this.modal.moderationBlockButton = null;
|
||||
}
|
||||
}
|
||||
|
||||
update(action = {}) {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
const { type } = action;
|
||||
switch (type) {
|
||||
case "set-context":
|
||||
this.modal.refreshActiveVideoModeration?.(action.context);
|
||||
break;
|
||||
case "set-overlay":
|
||||
this.modal.applyModerationOverlay?.(action.overlay);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
this.teardownPrimaryButton();
|
||||
this.teardownBlockButton();
|
||||
this.modal.moderationOverlay = null;
|
||||
this.modal.moderationBadge = null;
|
||||
this.modal.moderationBadgeText = null;
|
||||
this.modal.moderationActionsContainer = null;
|
||||
this.modal.moderationPrimaryButton = null;
|
||||
this.modal.moderationPrimaryMode = "";
|
||||
this.modal.moderationBlockButton = null;
|
||||
}
|
||||
|
||||
teardownPrimaryButton() {
|
||||
if (!this.modal?.moderationPrimaryButton) {
|
||||
return;
|
||||
}
|
||||
const button = this.modal.moderationPrimaryButton;
|
||||
if (this.modal.moderationPrimaryMode === "override") {
|
||||
button.removeEventListener(
|
||||
"click",
|
||||
this.modal.handleModerationOverrideClick,
|
||||
);
|
||||
} else if (this.modal.moderationPrimaryMode === "hide") {
|
||||
button.removeEventListener("click", this.modal.handleModerationHideClick);
|
||||
}
|
||||
}
|
||||
|
||||
teardownBlockButton() {
|
||||
if (!this.modal?.moderationBlockButton) {
|
||||
return;
|
||||
}
|
||||
this.modal.moderationBlockButton.removeEventListener(
|
||||
"click",
|
||||
this.modal.handleModerationBlockClick,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
export class ReactionsController {
|
||||
constructor({ modal } = {}) {
|
||||
this.modal = modal;
|
||||
this.boundHandler = (event) => {
|
||||
this.modal?.handleReactionClick?.(event);
|
||||
};
|
||||
this.buttons = [];
|
||||
}
|
||||
|
||||
initialize({ playerModal } = {}) {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const likeButton = playerModal?.querySelector("#modalLikeBtn") || null;
|
||||
const dislikeButton = playerModal?.querySelector("#modalDislikeBtn") || null;
|
||||
this.modal.reactionButtons = { "+": likeButton, "-": dislikeButton };
|
||||
this.modal.reactionMeter =
|
||||
playerModal?.querySelector("[data-reaction-meter]") || null;
|
||||
this.modal.reactionMeterFill =
|
||||
playerModal?.querySelector("[data-reaction-meter-fill]") || null;
|
||||
this.modal.reactionMeterLabel =
|
||||
playerModal?.querySelector("[data-reaction-meter-label]") || null;
|
||||
this.modal.reactionMeterAssistive =
|
||||
playerModal?.querySelector("[data-reaction-meter-sr]") || null;
|
||||
this.modal.reactionCountLabels = {
|
||||
"+": playerModal?.querySelector("[data-reaction-like-count]") || null,
|
||||
"-": playerModal?.querySelector("[data-reaction-dislike-count]") || null,
|
||||
};
|
||||
|
||||
this.buttons = [likeButton, dislikeButton].filter(Boolean);
|
||||
this.buttons.forEach((button) => {
|
||||
button.removeEventListener("click", this.boundHandler);
|
||||
button.addEventListener("click", this.boundHandler);
|
||||
});
|
||||
|
||||
this.modal.syncReactionButtons?.();
|
||||
this.modal.updateReactionMeterDisplay?.();
|
||||
}
|
||||
|
||||
update(action = {}) {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
const { type } = action;
|
||||
switch (type) {
|
||||
case "set-summary":
|
||||
this.modal.updateReactionSummary?.(action.summary);
|
||||
break;
|
||||
case "set-user-reaction":
|
||||
this.modal.setUserReaction?.(action.reaction);
|
||||
break;
|
||||
case "reset":
|
||||
this.modal.resetReactions?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.buttons.forEach((button) => {
|
||||
button.removeEventListener("click", this.boundHandler);
|
||||
});
|
||||
this.buttons = [];
|
||||
if (this.modal) {
|
||||
this.modal.reactionButtons = { "+": null, "-": null };
|
||||
this.modal.reactionMeter = null;
|
||||
this.modal.reactionMeterFill = null;
|
||||
this.modal.reactionMeterLabel = null;
|
||||
this.modal.reactionMeterAssistive = null;
|
||||
this.modal.reactionCountLabels = { "+": null, "-": null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
export class SimilarContentController {
|
||||
constructor({ modal } = {}) {
|
||||
this.modal = modal;
|
||||
}
|
||||
|
||||
initialize({ playerModal } = {}) {
|
||||
if (!this.modal) {
|
||||
return { container: null, heading: null, list: null };
|
||||
}
|
||||
|
||||
this.modal.teardownSimilarContentMediaQuery?.();
|
||||
|
||||
const heading =
|
||||
playerModal?.querySelector("#playerModalSimilarContentHeading") || null;
|
||||
const list =
|
||||
playerModal?.querySelector("#playerModalSimilarContentList") || null;
|
||||
const section =
|
||||
list?.closest("[aria-labelledby='playerModalSimilarContentHeading']") ||
|
||||
heading?.closest("[aria-labelledby='playerModalSimilarContentHeading']") ||
|
||||
null;
|
||||
const container =
|
||||
list?.closest(".watch-container") ||
|
||||
heading?.closest(".watch-container") ||
|
||||
section?.closest(".watch-container") ||
|
||||
section ||
|
||||
null;
|
||||
|
||||
this.modal.similarContentHeading = heading;
|
||||
this.modal.similarContentList = list;
|
||||
this.modal.similarContentContainer = container;
|
||||
|
||||
this.modal.setupSimilarContentMediaQuery?.();
|
||||
|
||||
if (list && list.children.length) {
|
||||
this.modal.toggleSimilarContentVisibility?.(true);
|
||||
} else {
|
||||
this.modal.toggleSimilarContentVisibility?.(false);
|
||||
}
|
||||
|
||||
return { container, heading, list };
|
||||
}
|
||||
|
||||
update(action = {}) {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
const { type } = action;
|
||||
switch (type) {
|
||||
case "set-items":
|
||||
this.modal.setSimilarContent?.(action.items, action.options);
|
||||
break;
|
||||
case "set-visibility":
|
||||
this.modal.toggleSimilarContentVisibility?.(action.visible);
|
||||
break;
|
||||
case "clear":
|
||||
this.modal.clearSimilarContent?.(action.options);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
this.modal.teardownSimilarContentMediaQuery?.();
|
||||
this.modal.similarContentHeading = null;
|
||||
this.modal.similarContentList = null;
|
||||
this.modal.similarContentContainer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
export function normalizeCommentAvatarKey(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveCommentAvatarAsset({
|
||||
cache,
|
||||
failures,
|
||||
defaultAvatar,
|
||||
pubkey,
|
||||
sanitizedPicture,
|
||||
}) {
|
||||
const normalizedPubkey = normalizeCommentAvatarKey(pubkey);
|
||||
const normalizedSource =
|
||||
typeof sanitizedPicture === "string" && sanitizedPicture
|
||||
? sanitizedPicture
|
||||
: "";
|
||||
|
||||
if (normalizedSource && failures?.has?.(normalizedSource)) {
|
||||
return { url: defaultAvatar, source: "" };
|
||||
}
|
||||
|
||||
if (normalizedPubkey && cache?.has?.(normalizedPubkey)) {
|
||||
const cached = cache.get(normalizedPubkey);
|
||||
if (cached) {
|
||||
const cachedSource =
|
||||
typeof cached.source === "string" ? cached.source : "";
|
||||
const cachedUrl =
|
||||
typeof cached.url === "string" && cached.url
|
||||
? cached.url
|
||||
: defaultAvatar;
|
||||
|
||||
if (!normalizedSource || cachedSource === normalizedSource) {
|
||||
return { url: cachedUrl, source: cachedSource };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedUrl = normalizedSource || defaultAvatar;
|
||||
if (normalizedPubkey && cache) {
|
||||
cache.set(normalizedPubkey, { url: resolvedUrl, source: normalizedSource });
|
||||
}
|
||||
|
||||
return { url: resolvedUrl, source: normalizedSource };
|
||||
}
|
||||
|
||||
export function registerCommentAvatarFailure({
|
||||
cache,
|
||||
failures,
|
||||
defaultAvatar,
|
||||
sourceUrl,
|
||||
}) {
|
||||
if (typeof sourceUrl !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = sourceUrl.trim();
|
||||
if (!trimmed || trimmed === defaultAvatar) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!failures?.has?.(trimmed)) {
|
||||
failures?.add?.(trimmed);
|
||||
}
|
||||
|
||||
if (!cache?.forEach) {
|
||||
return;
|
||||
}
|
||||
|
||||
cache.forEach((entry, key) => {
|
||||
if (entry && entry.source === trimmed) {
|
||||
cache.set(key, { url: defaultAvatar, source: "" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { devLogger as defaultLogger } from "../utils/logger.js";
|
||||
|
||||
export default class ProfileIdentityController {
|
||||
constructor({ callbacks = {}, environment = {}, logger } = {}) {
|
||||
this.safeEncodeNpub =
|
||||
typeof callbacks.safeEncodeNpub === "function"
|
||||
? callbacks.safeEncodeNpub
|
||||
: () => "";
|
||||
this.formatShortNpub =
|
||||
typeof callbacks.formatShortNpub === "function"
|
||||
? callbacks.formatShortNpub
|
||||
: (value) => value || "";
|
||||
this.document = environment.document || (typeof document !== "undefined" ? document : null);
|
||||
this.logger = logger || defaultLogger;
|
||||
}
|
||||
|
||||
updateProfileIdentity({ pubkey, profile } = {}) {
|
||||
if (!this.document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPubkey =
|
||||
typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : "";
|
||||
if (!normalizedPubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedProfile = profile && typeof profile === "object" ? profile : {};
|
||||
|
||||
const pictureUrl =
|
||||
typeof normalizedProfile.picture === "string" ? normalizedProfile.picture : "";
|
||||
|
||||
const resolveProfileName = () => {
|
||||
const candidates = [
|
||||
normalizedProfile.name,
|
||||
normalizedProfile.display_name,
|
||||
normalizedProfile.displayName,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const resolvedName = resolveProfileName();
|
||||
|
||||
const explicitNpub =
|
||||
typeof normalizedProfile.npub === "string"
|
||||
? normalizedProfile.npub.trim()
|
||||
: "";
|
||||
|
||||
const encodedPubkeyNpub = this.safeEncodeNpub(normalizedPubkey);
|
||||
const resolvedNpub = explicitNpub || encodedPubkeyNpub || "";
|
||||
const shortNpubLabel = resolvedNpub
|
||||
? this.formatShortNpub(resolvedNpub) || resolvedNpub
|
||||
: "";
|
||||
|
||||
const picEls = this.document.querySelectorAll(
|
||||
`.author-pic[data-pubkey="${normalizedPubkey}"]`
|
||||
);
|
||||
picEls.forEach((el) => {
|
||||
if (el) {
|
||||
el.src = pictureUrl;
|
||||
}
|
||||
});
|
||||
|
||||
const nameLabel = resolvedName || shortNpubLabel || resolvedNpub || "";
|
||||
const nameEls = this.document.querySelectorAll(
|
||||
`.author-name[data-pubkey="${normalizedPubkey}"]`
|
||||
);
|
||||
nameEls.forEach((el) => {
|
||||
if (el) {
|
||||
el.textContent = nameLabel;
|
||||
}
|
||||
});
|
||||
|
||||
const npubSelectors = new Set();
|
||||
if (resolvedNpub) {
|
||||
npubSelectors.add(`.author-npub[data-npub="${resolvedNpub}"]`);
|
||||
}
|
||||
npubSelectors.add(`.author-npub[data-pubkey="${normalizedPubkey}"]`);
|
||||
|
||||
const npubElements = new Set();
|
||||
npubSelectors.forEach((selector) => {
|
||||
this.document.querySelectorAll(selector).forEach((el) => {
|
||||
if (el) {
|
||||
npubElements.add(el);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const npubEls = Array.from(npubElements);
|
||||
|
||||
npubEls.forEach((el) => {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displayNpub = resolvedNpub ? shortNpubLabel || resolvedNpub : "";
|
||||
const hasDisplayNpub = Boolean(displayNpub);
|
||||
|
||||
if (hasDisplayNpub) {
|
||||
el.textContent = displayNpub;
|
||||
el.setAttribute("aria-hidden", "false");
|
||||
} else {
|
||||
el.textContent = "";
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
if (resolvedNpub) {
|
||||
el.setAttribute("title", resolvedNpub);
|
||||
if (el.dataset) {
|
||||
el.dataset.npub = resolvedNpub;
|
||||
}
|
||||
} else {
|
||||
el.removeAttribute("title");
|
||||
if (el.dataset && "npub" in el.dataset) {
|
||||
delete el.dataset.npub;
|
||||
}
|
||||
}
|
||||
|
||||
if (el.dataset) {
|
||||
el.dataset.pubkey = normalizedPubkey;
|
||||
}
|
||||
});
|
||||
|
||||
if (!nameEls.length && !npubEls.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cardInstances = new Set();
|
||||
const collectCardInstance = (el) => {
|
||||
if (!el || typeof el.closest !== "function") {
|
||||
return;
|
||||
}
|
||||
const cardRoot = el.closest('[data-component="similar-content-card"]');
|
||||
if (!cardRoot) {
|
||||
return;
|
||||
}
|
||||
const instance = cardRoot.__bitvidSimilarContentCard;
|
||||
if (instance && typeof instance.updateIdentity === "function") {
|
||||
cardInstances.add(instance);
|
||||
}
|
||||
};
|
||||
|
||||
nameEls.forEach(collectCardInstance);
|
||||
npubEls.forEach(collectCardInstance);
|
||||
|
||||
if (!cardInstances.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const identityPayload = {
|
||||
name: resolvedName,
|
||||
npub: resolvedNpub,
|
||||
shortNpub: shortNpubLabel,
|
||||
pubkey: normalizedPubkey,
|
||||
};
|
||||
|
||||
cardInstances.forEach((card) => {
|
||||
try {
|
||||
card.updateIdentity(identityPayload);
|
||||
} catch (error) {
|
||||
if (this.logger?.warn) {
|
||||
this.logger.warn(
|
||||
"[ProfileIdentityController] Failed to update similar content card identity",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+1983
-7
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,95 @@
|
||||
import { devLogger as defaultLogger } from "../utils/logger.js";
|
||||
|
||||
export default class VideoListViewController {
|
||||
constructor({ getSidebarLoadingMarkup, environment = {}, logger } = {}) {
|
||||
this.getSidebarLoadingMarkup =
|
||||
typeof getSidebarLoadingMarkup === "function"
|
||||
? getSidebarLoadingMarkup
|
||||
: () => "";
|
||||
|
||||
this.document = environment.document || (typeof document !== "undefined" ? document : null);
|
||||
this.logger = logger || defaultLogger;
|
||||
}
|
||||
|
||||
isElement(value) {
|
||||
return (
|
||||
typeof HTMLElement !== "undefined" && value instanceof HTMLElement
|
||||
);
|
||||
}
|
||||
|
||||
mount({ container = null, view, currentVideoList = null } = {}) {
|
||||
if (!view) {
|
||||
return { videoList: currentVideoList || null, popularTags: null };
|
||||
}
|
||||
|
||||
const target = container || this.getElementById("videoList");
|
||||
const popularTags = this.getElementById("recentVideoTags");
|
||||
|
||||
if (typeof view.setPopularTagsContainer === "function") {
|
||||
view.setPopularTagsContainer(popularTags || null);
|
||||
}
|
||||
|
||||
view.mount(target || null);
|
||||
|
||||
return {
|
||||
videoList: target || null,
|
||||
popularTags: popularTags || null,
|
||||
};
|
||||
}
|
||||
|
||||
reinitialize({
|
||||
view,
|
||||
reason,
|
||||
postLoginResult,
|
||||
currentVideoList = null,
|
||||
} = {}) {
|
||||
if (!view) {
|
||||
return {
|
||||
videoList: currentVideoList || null,
|
||||
popularTags: null,
|
||||
};
|
||||
}
|
||||
|
||||
const container =
|
||||
this.isElement(currentVideoList) && currentVideoList
|
||||
? currentVideoList
|
||||
: this.getElementById("videoList");
|
||||
|
||||
try {
|
||||
view.destroy();
|
||||
} catch (error) {
|
||||
if (this.logger?.warn) {
|
||||
this.logger.warn(
|
||||
"[VideoListViewController] Failed to destroy VideoListView during reinitialization:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const messageContext =
|
||||
reason === "login" && postLoginResult?.blocksLoaded !== false
|
||||
? "Applying your filters…"
|
||||
: "Refreshing videos…";
|
||||
|
||||
const tagsRoot = this.getElementById("recentVideoTags");
|
||||
if (typeof view.setPopularTagsContainer === "function") {
|
||||
view.setPopularTagsContainer(this.isElement(tagsRoot) ? tagsRoot : null);
|
||||
}
|
||||
|
||||
if (this.isElement(container)) {
|
||||
container.innerHTML = this.getSidebarLoadingMarkup(messageContext);
|
||||
}
|
||||
|
||||
return {
|
||||
videoList: this.isElement(container) ? container : null,
|
||||
popularTags: this.isElement(tagsRoot) ? tagsRoot : null,
|
||||
};
|
||||
}
|
||||
|
||||
getElementById(id) {
|
||||
if (!this.document || typeof this.document.getElementById !== "function") {
|
||||
return null;
|
||||
}
|
||||
return this.document.getElementById(id) || null;
|
||||
}
|
||||
}
|
||||
@@ -901,6 +901,55 @@ function clearQueue(actorKey) {
|
||||
notifyQueueChange(actorKey);
|
||||
}
|
||||
|
||||
function pruneQueueAfterSnapshot(actorKey, queue, keysToClear, snapshotStart) {
|
||||
if (!actorKey || !queue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keySet = new Set(keysToClear || []);
|
||||
const normalizedStart = Number(snapshotStart);
|
||||
const cutoff = Number.isFinite(normalizedStart) ? normalizedStart : Date.now();
|
||||
|
||||
let mutated = false;
|
||||
|
||||
if (keySet.size > 0) {
|
||||
for (const key of keySet) {
|
||||
const entry = queue.items.get(key);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const updatedAt = Number(entry?.updatedAt);
|
||||
const addedAt = Number(entry?.addedAt);
|
||||
const referenceTime = Number.isFinite(updatedAt)
|
||||
? updatedAt
|
||||
: Number.isFinite(addedAt)
|
||||
? addedAt
|
||||
: 0;
|
||||
if (referenceTime > cutoff) {
|
||||
continue;
|
||||
}
|
||||
queue.items.delete(key);
|
||||
queue.throttle.delete(key);
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (queue.pendingSnapshotId) {
|
||||
queue.pendingSnapshotId = null;
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if (queue.republishScheduled) {
|
||||
queue.republishScheduled = false;
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if (mutated) {
|
||||
persistQueueState();
|
||||
notifyQueueChange(actorKey);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWatchedAt(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
if (!Number.isFinite(candidate)) {
|
||||
@@ -1252,6 +1301,14 @@ async function snapshot(items, options = {}) {
|
||||
}
|
||||
|
||||
const queue = ensureQueue(actorKey);
|
||||
const queuePayloadKeys =
|
||||
!items && queue && payloadItems.length
|
||||
? new Set(
|
||||
payloadItems
|
||||
.map((pointer) => pointerKey(pointer))
|
||||
.filter((keyValue) => typeof keyValue === "string" && keyValue)
|
||||
)
|
||||
: new Set();
|
||||
|
||||
if (!items) {
|
||||
const cachedItems = getCachedSnapshotItems(actorKey);
|
||||
@@ -1271,6 +1328,7 @@ async function snapshot(items, options = {}) {
|
||||
);
|
||||
|
||||
const run = (async () => {
|
||||
const snapshotStartTime = Date.now();
|
||||
emit("snapshot-start", { actor: actorKey, reason, items: payloadItems });
|
||||
const publishResult = await nostrClient.publishWatchHistorySnapshot(
|
||||
payloadItems,
|
||||
@@ -1308,7 +1366,7 @@ async function snapshot(items, options = {}) {
|
||||
}
|
||||
|
||||
if (!items) {
|
||||
clearQueue(actorKey);
|
||||
pruneQueueAfterSnapshot(actorKey, queue, queuePayloadKeys, snapshotStartTime);
|
||||
} else if (queue) {
|
||||
queue.pendingSnapshotId = null;
|
||||
queue.republishScheduled = false;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { decryptDM } from "../js/dmDecryptor.js";
|
||||
|
||||
test("decryptDM handles kind 4 events with nip04 ciphertext", async () => {
|
||||
const nostrTools = await import("nostr-tools");
|
||||
const { generateSecretKey, getPublicKey, nip04 } = nostrTools;
|
||||
|
||||
const senderSecret = generateSecretKey();
|
||||
const recipientSecret = generateSecretKey();
|
||||
|
||||
const senderPubkey = getPublicKey(senderSecret);
|
||||
const recipientPubkey = getPublicKey(recipientSecret);
|
||||
|
||||
const message = "Hello from kind 4";
|
||||
const ciphertext = await nip04.encrypt(senderSecret, recipientPubkey, message);
|
||||
|
||||
const event = {
|
||||
id: "kind4-event",
|
||||
kind: 4,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: senderPubkey,
|
||||
tags: [["p", recipientPubkey]],
|
||||
content: ciphertext,
|
||||
};
|
||||
|
||||
const context = {
|
||||
actorPubkey: recipientPubkey,
|
||||
decryptors: [
|
||||
{
|
||||
scheme: "nip04",
|
||||
priority: 0,
|
||||
decrypt: (remotePubkey, cipher) =>
|
||||
nip04.decrypt(recipientSecret, remotePubkey, cipher),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await decryptDM(event, context);
|
||||
|
||||
assert.equal(result.ok, true, "decryptDM should succeed for kind 4 event");
|
||||
assert.equal(result.plaintext, message, "plaintext should match original message");
|
||||
assert.equal(result.scheme, "nip04", "scheme should resolve to nip04");
|
||||
assert.equal(result.sender.pubkey, senderPubkey, "sender pubkey should match event");
|
||||
assert.equal(result.recipients.length, 1, "recipient metadata should be populated");
|
||||
assert.equal(result.recipients[0].pubkey, recipientPubkey);
|
||||
assert.equal(result.direction, "incoming", "direction should be incoming for recipient");
|
||||
});
|
||||
|
||||
test("decryptDM prefers recipient pubkeys when actor is the sender", async () => {
|
||||
const nostrTools = await import("nostr-tools");
|
||||
const { generateSecretKey, getPublicKey, nip04 } = nostrTools;
|
||||
|
||||
const senderSecret = generateSecretKey();
|
||||
const recipientSecret = generateSecretKey();
|
||||
|
||||
const senderPubkey = getPublicKey(senderSecret);
|
||||
const recipientPubkey = getPublicKey(recipientSecret);
|
||||
|
||||
const message = "Hello from the author";
|
||||
const ciphertext = await nip04.encrypt(senderSecret, recipientPubkey, message);
|
||||
|
||||
const event = {
|
||||
id: "outgoing-kind4",
|
||||
kind: 4,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: senderPubkey,
|
||||
tags: [["p", recipientPubkey]],
|
||||
content: ciphertext,
|
||||
};
|
||||
|
||||
const context = {
|
||||
actorPubkey: senderPubkey,
|
||||
decryptors: [
|
||||
{
|
||||
scheme: "nip04",
|
||||
priority: 0,
|
||||
decrypt: (remotePubkey, cipher) =>
|
||||
nip04.decrypt(senderSecret, remotePubkey, cipher),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await decryptDM(event, context);
|
||||
|
||||
assert.equal(result.ok, true, "decryptDM should succeed for outgoing event");
|
||||
assert.equal(result.plaintext, message, "plaintext should match outgoing message");
|
||||
assert.equal(result.recipients.length, 1, "recipient metadata should remain intact");
|
||||
assert.equal(result.recipients[0].pubkey, recipientPubkey);
|
||||
assert.equal(result.direction, "outgoing", "direction should be outgoing for the author");
|
||||
});
|
||||
|
||||
test("decryptDM unwraps kind 1059 gift wraps with nip44", async () => {
|
||||
const nostrTools = await import("nostr-tools");
|
||||
const { generateSecretKey, getPublicKey, nip44, nip59 } = nostrTools;
|
||||
|
||||
const senderSecret = generateSecretKey();
|
||||
const recipientSecret = generateSecretKey();
|
||||
|
||||
const senderPubkey = getPublicKey(senderSecret);
|
||||
const recipientPubkey = getPublicKey(recipientSecret);
|
||||
|
||||
const baseEvent = {
|
||||
kind: 14,
|
||||
content: "Gift wrapped hello",
|
||||
tags: [["p", recipientPubkey]],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
const wrap = nip59.wrapEvent(baseEvent, senderSecret, recipientPubkey);
|
||||
|
||||
const nip44Module = nip44.v2 ?? nip44;
|
||||
const getConversationKey =
|
||||
typeof nip44Module?.getConversationKey === "function"
|
||||
? nip44Module.getConversationKey.bind(nip44Module)
|
||||
: typeof nip44Module?.utils?.getConversationKey === "function"
|
||||
? nip44Module.utils.getConversationKey.bind(nip44Module.utils)
|
||||
: null;
|
||||
const decryptFn =
|
||||
typeof nip44Module?.decrypt === "function"
|
||||
? nip44Module.decrypt.bind(nip44Module)
|
||||
: typeof nip44?.v2?.decrypt === "function"
|
||||
? nip44.v2.decrypt.bind(nip44.v2)
|
||||
: null;
|
||||
|
||||
assert.ok(getConversationKey && decryptFn, "nip44 helpers should be available for tests");
|
||||
|
||||
const context = {
|
||||
actorPubkey: recipientPubkey,
|
||||
decryptors: [
|
||||
{
|
||||
scheme: "nip44_v2",
|
||||
priority: 0,
|
||||
supportsGiftWrap: true,
|
||||
decrypt: (remotePubkey, cipher) => {
|
||||
const conversationKey = getConversationKey(recipientSecret, remotePubkey);
|
||||
return decryptFn(cipher, conversationKey);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await decryptDM(wrap, context);
|
||||
|
||||
assert.equal(result.ok, true, "decryptDM should succeed for gift wrap event");
|
||||
assert.equal(
|
||||
result.plaintext,
|
||||
baseEvent.content,
|
||||
"plaintext should match unwrap payload",
|
||||
);
|
||||
assert.equal(result.sender.pubkey, senderPubkey, "sender should match rumor pubkey");
|
||||
assert.equal(result.scheme, "nip44_v2", "scheme should report nip44 variant");
|
||||
assert.equal(result.direction, "incoming", "direction should be incoming for recipient");
|
||||
assert.ok(result.envelope && result.envelope.seal, "seal metadata should be provided");
|
||||
assert.equal(
|
||||
result.recipients[0].pubkey,
|
||||
recipientPubkey,
|
||||
"recipient metadata should reflect unwrap target",
|
||||
);
|
||||
});
|
||||
|
||||
test("decryptDM returns failure when decryptors are unavailable", async () => {
|
||||
const event = { id: "unhandled", kind: 4, content: "", pubkey: "" };
|
||||
|
||||
const result = await decryptDM(event, { decryptors: [] });
|
||||
|
||||
assert.equal(result.ok, false, "decryptDM should report failure");
|
||||
assert.ok(Array.isArray(result.errors), "failure payload should include errors");
|
||||
});
|
||||
@@ -496,6 +496,76 @@ test("moderation stage propagates whitelist, muters, and threshold updates", asy
|
||||
assert.equal(updatedMuted.video.moderation.blurReason, undefined);
|
||||
});
|
||||
|
||||
test("moderation stage blurs viewer-muted authors", async () => {
|
||||
const mutedHex = "f".repeat(64);
|
||||
|
||||
const service = {
|
||||
async refreshViewerFromClient() {},
|
||||
async setActiveEventIds() {},
|
||||
getAdminListSnapshot() {
|
||||
return { whitelist: new Set(), whitelistHex: new Set(), blacklist: new Set(), blacklistHex: new Set() };
|
||||
},
|
||||
getAccessControlStatus(identifier) {
|
||||
return { hex: identifier, whitelisted: false, blacklisted: false };
|
||||
},
|
||||
getTrustedReportSummary() {
|
||||
return null;
|
||||
},
|
||||
trustedReportCount() {
|
||||
return 0;
|
||||
},
|
||||
getTrustedReporters() {
|
||||
return [];
|
||||
},
|
||||
isAuthorMutedByTrusted() {
|
||||
return false;
|
||||
},
|
||||
isAuthorMutedByViewer(pubkey) {
|
||||
return pubkey === mutedHex;
|
||||
},
|
||||
};
|
||||
|
||||
const stage = createModerationStage({
|
||||
service,
|
||||
autoplayThreshold: Number.POSITIVE_INFINITY,
|
||||
blurThreshold: Number.POSITIVE_INFINITY,
|
||||
trustedMuteHideThreshold: Number.POSITIVE_INFINITY,
|
||||
trustedReportHideThreshold: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
const items = [{ video: { id: "viewer-muted", pubkey: mutedHex }, metadata: {} }];
|
||||
|
||||
const reasons = [];
|
||||
const context = {
|
||||
addWhy(detail) {
|
||||
reasons.push(detail);
|
||||
return detail;
|
||||
},
|
||||
log() {},
|
||||
};
|
||||
|
||||
const result = await stage(items, context);
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
const entry = result[0];
|
||||
|
||||
assert.equal(entry.video.moderation.viewerMuted, true);
|
||||
assert.equal(entry.metadata.moderation.viewerMuted, true);
|
||||
assert.equal(entry.video.moderation.blockAutoplay, true);
|
||||
assert.equal(entry.video.moderation.blurThumbnail, true);
|
||||
assert.equal(entry.video.moderation.blurReason, "viewer-mute");
|
||||
assert.equal(entry.metadata.moderation.blurReason, "viewer-mute");
|
||||
assert.equal(entry.video.moderation.hidden, false);
|
||||
assert.equal(entry.metadata.moderation.hidden, false);
|
||||
assert.equal(entry.video.moderation.hideReason, undefined);
|
||||
assert.equal(entry.metadata.moderation.hideReason, undefined);
|
||||
assert.equal(entry.metadata.moderation.hideCounts, undefined);
|
||||
|
||||
const reasonSet = new Set(reasons.map((detail) => detail.reason));
|
||||
assert(reasonSet.has("blur"));
|
||||
assert(reasonSet.has("viewer-mute"));
|
||||
});
|
||||
|
||||
test("moderation stage clears cached reporters and muters after service signals", async () => {
|
||||
const reporterHex = "d".repeat(64);
|
||||
const muterHex = "e".repeat(64);
|
||||
|
||||
@@ -271,6 +271,58 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test("VideoCard blurs viewer-muted creators", async (t) => {
|
||||
const { document } = setupDom(t);
|
||||
withMockedNostrTools(t);
|
||||
|
||||
const app = await createModerationAppHarness();
|
||||
const videoId = "d".repeat(64);
|
||||
|
||||
const video = {
|
||||
id: videoId,
|
||||
title: "Muted Creator Clip",
|
||||
pubkey: "e".repeat(64),
|
||||
moderation: {
|
||||
viewerMuted: true,
|
||||
blockAutoplay: true,
|
||||
blurThumbnail: true,
|
||||
blurReason: "viewer-mute",
|
||||
trustedCount: 0,
|
||||
trustedMuted: false,
|
||||
trustedReporters: [],
|
||||
},
|
||||
};
|
||||
|
||||
app.videosMap.set(video.id, video);
|
||||
app.currentVideo = video;
|
||||
app.decorateVideoModeration(video);
|
||||
|
||||
const card = new VideoCard({
|
||||
document,
|
||||
video,
|
||||
formatters: {
|
||||
formatTimeAgo: () => "moments ago",
|
||||
},
|
||||
helpers: {
|
||||
isMagnetSupported: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
document.body.appendChild(card.getRoot());
|
||||
|
||||
assert.equal(card.getRoot().dataset.autoplayPolicy, "blocked");
|
||||
assert.equal(card.thumbnailEl.dataset.thumbnailState, "blurred");
|
||||
|
||||
const context = normalizeVideoModerationContext(card.video?.moderation);
|
||||
assert.equal(context.blurReason, "viewer-mute");
|
||||
assert.equal(context.activeBlur, true);
|
||||
assert.equal(context.activeBlockAutoplay, true);
|
||||
assert.equal(context.trustedCount, 0);
|
||||
assert.equal(card.getRoot().dataset.moderationReportCount, undefined);
|
||||
assert.ok(card.moderationBadgeEl);
|
||||
assert.equal(card.moderationBadgeEl.dataset.moderationState, "blocked");
|
||||
});
|
||||
|
||||
test("VideoCard blurs thumbnails when trusted mute triggers without reports", async (t) => {
|
||||
const { document } = setupDom(t);
|
||||
withMockedNostrTools(t);
|
||||
|
||||
@@ -51,6 +51,8 @@ try {
|
||||
const { nostrClient, setActiveSigner, clearActiveSigner } = await import("../js/nostr.js");
|
||||
const { subscriptions } = await import("../js/subscriptions.js");
|
||||
const { userBlocks } = await import("../js/userBlocks.js");
|
||||
const { Application } = await import("../js/app.js");
|
||||
const { isDevMode } = await import("../js/config.js");
|
||||
|
||||
setActiveSigner({
|
||||
type: "session",
|
||||
@@ -283,9 +285,170 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
async function testPublishVideoNoteDefaultsToLiveModeInDev() {
|
||||
assert.equal(isDevMode, true, "Test expects isDevMode to be true");
|
||||
|
||||
let capturedPayload = null;
|
||||
let capturedPubkey = null;
|
||||
|
||||
const fakeApp = {
|
||||
pubkey: "f".repeat(64),
|
||||
nostrService: {
|
||||
publishVideoNote: async (payload, pubkey) => {
|
||||
capturedPayload = payload;
|
||||
capturedPubkey = pubkey;
|
||||
},
|
||||
},
|
||||
showError(message) {
|
||||
throw new Error(`showError invoked: ${message}`);
|
||||
},
|
||||
showSuccess() {},
|
||||
showStatus() {},
|
||||
loadVideos: async () => {},
|
||||
uploadModal: null,
|
||||
};
|
||||
|
||||
const publishResult = await Application.prototype.publishVideoNote.call(
|
||||
fakeApp,
|
||||
{
|
||||
legacyFormData: {
|
||||
title: "Dev Mode Default",
|
||||
url: "https://cdn.example/dev.mp4",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(publishResult, true, "publishVideoNote should resolve truthy");
|
||||
assert.equal(
|
||||
capturedPubkey,
|
||||
fakeApp.pubkey,
|
||||
"publishVideoNote should forward the viewer pubkey",
|
||||
);
|
||||
assert.equal(
|
||||
capturedPayload?.legacyFormData?.mode,
|
||||
"live",
|
||||
"publishVideoNote should default mode to live even in dev mode",
|
||||
);
|
||||
}
|
||||
|
||||
async function testPublishVideoNoteRespectsProvidedMode() {
|
||||
let capturedPayload = null;
|
||||
|
||||
const fakeApp = {
|
||||
pubkey: "f".repeat(64),
|
||||
nostrService: {
|
||||
publishVideoNote: async (payload) => {
|
||||
capturedPayload = payload;
|
||||
},
|
||||
},
|
||||
showError(message) {
|
||||
throw new Error(`showError invoked: ${message}`);
|
||||
},
|
||||
showSuccess() {},
|
||||
showStatus() {},
|
||||
loadVideos: async () => {},
|
||||
uploadModal: null,
|
||||
};
|
||||
|
||||
await Application.prototype.publishVideoNote.call(
|
||||
fakeApp,
|
||||
{
|
||||
legacyFormData: {
|
||||
title: "Dev Mode Override",
|
||||
url: "https://cdn.example/dev.mp4",
|
||||
mode: "dev",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
capturedPayload?.legacyFormData?.mode,
|
||||
"dev",
|
||||
"publishVideoNote should respect an explicit dev mode value",
|
||||
);
|
||||
}
|
||||
|
||||
async function testPublishVideoNormalizesPubkeyForNip71Metadata() {
|
||||
const uppercasePubkey = "AB".repeat(32);
|
||||
const originalSignAndPublish = nostrClient.signAndPublishEvent;
|
||||
const originalPublishNip71 = nostrClient.publishNip71Video;
|
||||
|
||||
const publishedEvents = [];
|
||||
const nip71Calls = [];
|
||||
|
||||
nostrClient.signAndPublishEvent = async (event, options = {}) => {
|
||||
publishedEvents.push({ event, options });
|
||||
const contextLabel =
|
||||
typeof options?.context === "string" && options.context
|
||||
? options.context
|
||||
: "event";
|
||||
return {
|
||||
signedEvent: {
|
||||
...event,
|
||||
id: event?.id || `signed-${contextLabel}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
nostrClient.publishNip71Video = async (payload, pubkey, pointerOptions) => {
|
||||
nip71Calls.push({ payload, pubkey, pointerOptions });
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await nostrClient.publishVideo(
|
||||
{
|
||||
legacyFormData: {
|
||||
title: "Metadata Video",
|
||||
description: "Video with metadata",
|
||||
url: "https://cdn.example/video.mp4",
|
||||
mode: "live",
|
||||
},
|
||||
nip71: {
|
||||
title: "Metadata Video",
|
||||
imeta: [{ url: "https://cdn.example/video.mp4", m: "video/mp4" }],
|
||||
},
|
||||
},
|
||||
uppercasePubkey,
|
||||
);
|
||||
|
||||
assert.equal(typeof result?.id, "string", "publishVideo should resolve with a signed event");
|
||||
assert.ok(
|
||||
publishedEvents.some((entry) => entry.options?.context === "video note"),
|
||||
"publishVideo should sign the primary video note",
|
||||
);
|
||||
assert.equal(
|
||||
nip71Calls.length,
|
||||
1,
|
||||
"publishVideo should invoke publishNip71Video when metadata is provided",
|
||||
);
|
||||
assert.equal(
|
||||
nip71Calls[0]?.pubkey,
|
||||
uppercasePubkey.toLowerCase(),
|
||||
"publishVideo should normalize the pubkey passed to publishNip71Video",
|
||||
);
|
||||
assert.ok(
|
||||
typeof nip71Calls[0]?.pointerOptions?.videoRootId === "string" &&
|
||||
nip71Calls[0]?.pointerOptions?.videoRootId.length > 0,
|
||||
"publishVideo should supply pointer metadata for the new video",
|
||||
);
|
||||
assert.ok(
|
||||
typeof nip71Calls[0]?.pointerOptions?.dTag === "string" &&
|
||||
nip71Calls[0]?.pointerOptions?.dTag.length > 0,
|
||||
"publishVideo should provide the new d tag as a pointer",
|
||||
);
|
||||
} finally {
|
||||
nostrClient.signAndPublishEvent = originalSignAndPublish;
|
||||
nostrClient.publishNip71Video = originalPublishNip71;
|
||||
}
|
||||
}
|
||||
|
||||
await testPublishVideoRejectsWhenAllRelaysFail();
|
||||
await testPublishSubscriptionListRejectsWhenAllRelaysFail();
|
||||
await testPublishBlockListRejectsWhenAllRelaysFail();
|
||||
await testPublishVideoNoteDefaultsToLiveModeInDev();
|
||||
await testPublishVideoNoteRespectsProvidedMode();
|
||||
await testPublishVideoNormalizesPubkeyForNip71Metadata();
|
||||
|
||||
console.log("nostr publish rejection tests passed");
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import { AccessControl } from "../js/accessControl.js";
|
||||
import { NostrService } from "../js/services/nostrService.js";
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
}
|
||||
|
||||
const whitelistHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const whitelistNpub = "npub1whitelistcandidate";
|
||||
const blacklistHex = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
const blacklistNpub = "npub1blacklistcandidate";
|
||||
|
||||
const toolkit = {
|
||||
nip19: {
|
||||
decode(value) {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("invalid value");
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === whitelistNpub) {
|
||||
return { type: "npub", data: whitelistHex };
|
||||
}
|
||||
if (trimmed === blacklistNpub) {
|
||||
return { type: "npub", data: blacklistHex };
|
||||
}
|
||||
throw new Error("unsupported npub");
|
||||
},
|
||||
npubEncode(value) {
|
||||
if (value === whitelistHex) {
|
||||
return whitelistNpub;
|
||||
}
|
||||
if (value === blacklistHex) {
|
||||
return blacklistNpub;
|
||||
}
|
||||
throw new Error("unsupported hex");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const originalWindowNostrTools = globalThis.window.NostrTools;
|
||||
const originalGlobalNostrTools = globalThis.NostrTools;
|
||||
|
||||
globalThis.window.NostrTools = toolkit;
|
||||
globalThis.NostrTools = toolkit;
|
||||
|
||||
test.after(() => {
|
||||
if (originalWindowNostrTools === undefined) {
|
||||
delete globalThis.window.NostrTools;
|
||||
} else {
|
||||
globalThis.window.NostrTools = originalWindowNostrTools;
|
||||
}
|
||||
|
||||
if (originalGlobalNostrTools === undefined) {
|
||||
delete globalThis.NostrTools;
|
||||
} else {
|
||||
globalThis.NostrTools = originalGlobalNostrTools;
|
||||
}
|
||||
});
|
||||
|
||||
function createServiceWithAccessControl() {
|
||||
const service = new NostrService();
|
||||
const accessControl = new AccessControl();
|
||||
accessControl._hydrateFromCache = () => {};
|
||||
accessControl._scheduleHydrateFromCache = () => {};
|
||||
accessControl.whitelist = new Set([whitelistNpub]);
|
||||
accessControl.whitelistPubkeys = new Set([whitelistHex]);
|
||||
accessControl.blacklist = new Set([blacklistNpub]);
|
||||
accessControl.blacklistPubkeys = new Set([blacklistHex]);
|
||||
accessControl.whitelistEnabled = true;
|
||||
service.accessControl = accessControl;
|
||||
return service;
|
||||
}
|
||||
|
||||
test("shouldIncludeVideo returns true for whitelist author when npubEncode throws", () => {
|
||||
const service = createServiceWithAccessControl();
|
||||
const originalEncode = globalThis.window.NostrTools.nip19.npubEncode;
|
||||
globalThis.window.NostrTools.nip19.npubEncode = () => {
|
||||
throw new Error("encode failure");
|
||||
};
|
||||
|
||||
try {
|
||||
const video = {
|
||||
id: "video-whitelist",
|
||||
kind: 30078,
|
||||
pubkey: whitelistHex,
|
||||
};
|
||||
|
||||
assert.equal(service.shouldIncludeVideo(video), true);
|
||||
} finally {
|
||||
globalThis.window.NostrTools.nip19.npubEncode = originalEncode;
|
||||
}
|
||||
});
|
||||
|
||||
test("shouldIncludeVideo rejects blacklisted authors provided as npub", () => {
|
||||
const service = createServiceWithAccessControl();
|
||||
const video = {
|
||||
id: "video-blacklist-npub",
|
||||
kind: 30078,
|
||||
pubkey: blacklistHex,
|
||||
npub: blacklistNpub,
|
||||
};
|
||||
|
||||
assert.equal(service.shouldIncludeVideo(video), false);
|
||||
});
|
||||
|
||||
test("shouldIncludeVideo rejects blacklisted authors provided as hex when npubEncode throws", () => {
|
||||
const service = createServiceWithAccessControl();
|
||||
const originalEncode = globalThis.window.NostrTools.nip19.npubEncode;
|
||||
globalThis.window.NostrTools.nip19.npubEncode = () => {
|
||||
throw new Error("encode failure");
|
||||
};
|
||||
|
||||
try {
|
||||
const video = {
|
||||
id: "video-blacklist-hex",
|
||||
kind: 30078,
|
||||
pubkey: blacklistHex,
|
||||
};
|
||||
|
||||
assert.equal(service.shouldIncludeVideo(video), false);
|
||||
} finally {
|
||||
globalThis.window.NostrTools.nip19.npubEncode = originalEncode;
|
||||
}
|
||||
});
|
||||
|
||||
test("shouldIncludeVideo always returns true for the viewer's own video", () => {
|
||||
const service = createServiceWithAccessControl();
|
||||
const video = {
|
||||
id: "viewer-owned-video",
|
||||
kind: 30078,
|
||||
pubkey: whitelistHex,
|
||||
};
|
||||
|
||||
service.nostrClient = { pubkey: whitelistHex };
|
||||
|
||||
const blacklistedEventIds = new Set([video.id]);
|
||||
assert.equal(
|
||||
service.shouldIncludeVideo(video, { blacklistedEventIds }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldIncludeVideo allows access when access control would deny the author", () => {
|
||||
const service = new NostrService();
|
||||
service.accessControl = { canAccess: () => false };
|
||||
service.nostrClient = { pubkey: whitelistHex };
|
||||
|
||||
const video = {
|
||||
id: "access-control-denied-video",
|
||||
kind: 30078,
|
||||
pubkey: whitelistHex,
|
||||
};
|
||||
|
||||
assert.equal(service.shouldIncludeVideo(video), true);
|
||||
});
|
||||
@@ -29,6 +29,7 @@ const {
|
||||
getCachedNostrTools,
|
||||
ensureNostrTools,
|
||||
resolveSimplePoolConstructor,
|
||||
shimLegacySimplePoolMethods,
|
||||
} = toolkitModule;
|
||||
|
||||
const baselineToolkit = getCachedNostrTools() || bootstrapToolkit;
|
||||
@@ -165,3 +166,89 @@ test("resolveSimplePoolConstructor handles supported permutations", () => {
|
||||
"returns null when no candidates are available"
|
||||
);
|
||||
});
|
||||
|
||||
test("shimLegacySimplePoolMethods adds legacy sub/list wrappers", async () => {
|
||||
let capturedRequests = null;
|
||||
let capturedParams = null;
|
||||
const closeReasons = [];
|
||||
|
||||
const mockPool = {
|
||||
subscribeMap(requests, params) {
|
||||
capturedRequests = requests;
|
||||
capturedParams = params;
|
||||
return {
|
||||
close(reason) {
|
||||
closeReasons.push(reason);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
shimLegacySimplePoolMethods(mockPool);
|
||||
|
||||
assert.strictEqual(typeof mockPool.sub, "function");
|
||||
assert.strictEqual(typeof mockPool.list, "function");
|
||||
|
||||
const relays = ["wss://relay.one", "wss://relay.two"];
|
||||
const filters = [{ kinds: [1] }, { authors: ["abc"] }];
|
||||
|
||||
const sub = mockPool.sub(relays, filters, {
|
||||
alreadyHaveEvent(id) {
|
||||
return id === "skip";
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(Array.isArray(capturedRequests));
|
||||
assert.strictEqual(capturedRequests.length, relays.length * filters.length);
|
||||
assert.ok(capturedRequests.every((entry) => entry && entry.url && entry.filter));
|
||||
assert.ok(capturedParams);
|
||||
assert.strictEqual(typeof capturedParams.onevent, "function");
|
||||
assert.strictEqual(typeof capturedParams.oneose, "function");
|
||||
assert.strictEqual(typeof capturedParams.onclose, "function");
|
||||
assert.strictEqual(typeof capturedParams.alreadyHaveEvent, "function");
|
||||
|
||||
const receivedEvents = [];
|
||||
const eosePromise = new Promise((resolve) => sub.on("eose", resolve));
|
||||
const closePromise = new Promise((resolve) => sub.on("close", resolve));
|
||||
sub.on("event", (event) => {
|
||||
receivedEvents.push(event.id);
|
||||
});
|
||||
|
||||
assert.strictEqual(capturedParams.alreadyHaveEvent("skip"), true);
|
||||
assert.strictEqual(capturedParams.alreadyHaveEvent("id-1"), false);
|
||||
assert.strictEqual(capturedParams.alreadyHaveEvent("id-1"), true);
|
||||
|
||||
capturedParams.onevent({ id: "id-1" });
|
||||
capturedParams.onevent({ id: "id-2" });
|
||||
capturedParams.oneose();
|
||||
capturedParams.onclose(["done"]);
|
||||
|
||||
await eosePromise;
|
||||
const closePayload = await closePromise;
|
||||
|
||||
assert.deepStrictEqual(receivedEvents, ["id-1", "id-2"]);
|
||||
assert.deepStrictEqual(closePayload, ["done"]);
|
||||
|
||||
sub.unsub();
|
||||
|
||||
closeReasons.length = 0;
|
||||
const immediateUnsub = mockPool.sub(["wss://relay.extra"], [{ kinds: [2] }]);
|
||||
immediateUnsub.unsub();
|
||||
assert.ok(closeReasons.includes("closed by caller"));
|
||||
|
||||
capturedParams = null;
|
||||
capturedRequests = null;
|
||||
closeReasons.length = 0;
|
||||
|
||||
const listPromise = mockPool.list(["wss://relay.example"], [{ kinds: [1] }]);
|
||||
assert.ok(capturedParams);
|
||||
capturedParams.onevent({ id: "evt-1" });
|
||||
capturedParams.onevent({ id: "evt-2" });
|
||||
capturedParams.oneose();
|
||||
|
||||
const listed = await listPromise;
|
||||
assert.deepStrictEqual(
|
||||
listed.map((event) => event.id),
|
||||
["evt-1", "evt-2"]
|
||||
);
|
||||
});
|
||||
@@ -540,6 +540,28 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test("ensureLoaded memoizes concurrent loads", async () => {
|
||||
const SubscriptionsManager = subscriptions.constructor;
|
||||
const manager = new SubscriptionsManager();
|
||||
|
||||
let loadCount = 0;
|
||||
manager.loadSubscriptions = async () => {
|
||||
loadCount += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
manager.loaded = true;
|
||||
};
|
||||
|
||||
const actorHex = "f".repeat(64);
|
||||
|
||||
await Promise.all([
|
||||
manager.ensureLoaded(actorHex),
|
||||
manager.ensureLoaded(actorHex),
|
||||
]);
|
||||
|
||||
assert.equal(loadCount, 1, "loadSubscriptions should only run once");
|
||||
assert.equal(manager.loaded, true, "manager should be marked as loaded");
|
||||
});
|
||||
|
||||
test(
|
||||
"publishSubscriptionList succeeds with direct signer without requesting extension permissions",
|
||||
async () => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { setupModal } from "./video-modal-accessibility.test.mjs";
|
||||
|
||||
test("CommentsController lifecycle manages comment references", async (t) => {
|
||||
const { modal, cleanup } = await setupModal();
|
||||
t.after(cleanup);
|
||||
|
||||
assert.ok(modal.commentsRoot, "comments should be initialized after hydrate");
|
||||
|
||||
modal.commentsController.destroy();
|
||||
assert.equal(modal.commentsRoot, null, "destroy should clear comment root");
|
||||
|
||||
modal.commentsController.initialize({ playerModal: modal.getRoot() });
|
||||
assert.ok(modal.commentsRoot, "initialize should restore comment root");
|
||||
});
|
||||
|
||||
test("ReactionsController delegates reaction updates", async (t) => {
|
||||
const { modal, cleanup } = await setupModal();
|
||||
t.after(cleanup);
|
||||
|
||||
modal.reactionsController.update({ type: "set-user-reaction", reaction: "+" });
|
||||
assert.equal(
|
||||
modal.reactionState.userReaction,
|
||||
"+",
|
||||
"user reaction should be updated via controller",
|
||||
);
|
||||
|
||||
modal.reactionsController.destroy();
|
||||
assert.equal(
|
||||
modal.reactionButtons["+"],
|
||||
null,
|
||||
"destroy should clear reaction button references",
|
||||
);
|
||||
});
|
||||
|
||||
test("SimilarContentController toggles section visibility", async (t) => {
|
||||
const { modal, cleanup } = await setupModal();
|
||||
t.after(cleanup);
|
||||
|
||||
const container = modal.similarContentContainer;
|
||||
assert.ok(container, "similar content container should exist");
|
||||
|
||||
modal.similarContentController.update({ type: "set-visibility", visible: false });
|
||||
assert.equal(
|
||||
container.hasAttribute("hidden"),
|
||||
true,
|
||||
"controller should hide similar content when requested",
|
||||
);
|
||||
|
||||
modal.similarContentController.update({ type: "set-visibility", visible: true });
|
||||
assert.equal(
|
||||
container.hasAttribute("hidden"),
|
||||
false,
|
||||
"controller should reveal similar content",
|
||||
);
|
||||
});
|
||||
|
||||
test("ModerationController reset clears moderation overlay references", async (t) => {
|
||||
const { modal, cleanup } = await setupModal();
|
||||
t.after(cleanup);
|
||||
|
||||
assert.ok(modal.moderationOverlay, "moderation overlay should be present");
|
||||
|
||||
modal.moderationController.destroy();
|
||||
assert.equal(
|
||||
modal.moderationOverlay,
|
||||
null,
|
||||
"destroy should clear moderation overlay references",
|
||||
);
|
||||
|
||||
modal.moderationController.initialize({ playerModal: modal.getRoot() });
|
||||
assert.ok(
|
||||
modal.moderationOverlay,
|
||||
"initialize should restore moderation overlay references",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
getVideoNoteErrorMessage,
|
||||
normalizeVideoNotePayload,
|
||||
VIDEO_NOTE_ERROR_CODES,
|
||||
} from "../js/services/videoNotePayload.js";
|
||||
|
||||
test("normalizes minimal payload with hosted URL", () => {
|
||||
const { payload, errors } = normalizeVideoNotePayload({
|
||||
title: " Example Title ",
|
||||
url: " https://cdn.example.com/video.mp4 ",
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(payload, "Expected payload result");
|
||||
assert.equal(payload.legacyFormData.version, 3);
|
||||
assert.equal(payload.legacyFormData.title, "Example Title");
|
||||
assert.equal(
|
||||
payload.legacyFormData.url,
|
||||
"https://cdn.example.com/video.mp4",
|
||||
);
|
||||
assert.equal(payload.legacyFormData.magnet, "");
|
||||
assert.equal(payload.legacyFormData.ws, "");
|
||||
assert.equal(payload.legacyFormData.xs, "");
|
||||
assert.equal(payload.legacyFormData.mode, "live");
|
||||
assert.equal(payload.legacyFormData.enableComments, true);
|
||||
assert.equal(payload.legacyFormData.isNsfw, false);
|
||||
assert.equal(payload.legacyFormData.isForKids, false);
|
||||
});
|
||||
|
||||
test("normalizes mode and boolean flags", () => {
|
||||
const { payload, errors } = normalizeVideoNotePayload({
|
||||
title: "Flag Demo",
|
||||
url: "https://videos.example.com/watch.mp4",
|
||||
mode: "DEV",
|
||||
enableComments: false,
|
||||
isNsfw: true,
|
||||
isForKids: true,
|
||||
isPrivate: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
assert.equal(payload.legacyFormData.mode, "dev");
|
||||
assert.equal(payload.legacyFormData.enableComments, false);
|
||||
assert.equal(payload.legacyFormData.isNsfw, true);
|
||||
assert.equal(payload.legacyFormData.isForKids, false);
|
||||
assert.equal(payload.legacyFormData.isPrivate, true);
|
||||
});
|
||||
|
||||
test("augments magnets with ws/xs hints", () => {
|
||||
const { payload, errors } = normalizeVideoNotePayload({
|
||||
title: "Magnet Upload",
|
||||
magnet: "magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01",
|
||||
ws: "https://cdn.example.com/video.mp4",
|
||||
xs: "https://cdn.example.com/video.torrent",
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
const { magnet, ws, xs } = payload.legacyFormData;
|
||||
assert.ok(magnet.startsWith("magnet:?xt=urn:btih:"));
|
||||
assert.ok(
|
||||
magnet.includes("ws=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4") ||
|
||||
magnet.includes("ws=https://cdn.example.com/video.mp4"),
|
||||
"Normalized magnet should include ws hint",
|
||||
);
|
||||
assert.ok(
|
||||
magnet.includes("xs=https://cdn.example.com/video.torrent") ||
|
||||
magnet.includes("xs=https%3A%2F%2Fcdn.example.com%2Fvideo.torrent"),
|
||||
"Normalized magnet should include xs hint",
|
||||
);
|
||||
assert.equal(ws, "https://cdn.example.com/video.mp4");
|
||||
assert.equal(xs, "https://cdn.example.com/video.torrent");
|
||||
});
|
||||
|
||||
test("normalizes nip71 metadata collections", () => {
|
||||
const { payload, errors } = normalizeVideoNotePayload({
|
||||
title: "NIP-71 Rich Metadata",
|
||||
nip71: {
|
||||
imeta: [
|
||||
{
|
||||
url: " https://cdn.example.com/hls/playlist.m3u8 ",
|
||||
service: [" wss://relay.example.com "],
|
||||
},
|
||||
],
|
||||
segments: [
|
||||
{
|
||||
start: " 00:30 ",
|
||||
end: 90000,
|
||||
title: " Intro ",
|
||||
thumbnail: " https://cdn.example.com/thumb.jpg ",
|
||||
},
|
||||
{
|
||||
start: "bad",
|
||||
end: null,
|
||||
title: "",
|
||||
},
|
||||
],
|
||||
textTracks: [
|
||||
{ url: " https://cdn.example.com/captions.vtt ", type: " text/vtt ", language: " en " },
|
||||
null,
|
||||
],
|
||||
participants: [
|
||||
{ pubkey: " npub123 ", relay: " wss://relay.example.com " },
|
||||
{},
|
||||
],
|
||||
hashtags: [" bitvid ", ""],
|
||||
references: [" nostr:note1abc "],
|
||||
duration: "900",
|
||||
publishedAt: "2023-01-01T00:00:00Z",
|
||||
summary: " Summary copy ",
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
const nip71 = payload.nip71;
|
||||
assert.ok(nip71, "Expected nip71 payload");
|
||||
assert.equal(nip71.summary, "Summary copy");
|
||||
assert.equal(nip71.duration, 900);
|
||||
assert.equal(nip71.publishedAt, 1672531200);
|
||||
assert.deepEqual(nip71.hashtags, ["bitvid"]);
|
||||
assert.deepEqual(nip71.references, ["nostr:note1abc"]);
|
||||
assert.equal(nip71.segments.length, 2);
|
||||
assert.deepEqual(nip71.segments[0], {
|
||||
start: "00:30",
|
||||
end: 90000,
|
||||
title: "Intro",
|
||||
thumbnail: "https://cdn.example.com/thumb.jpg",
|
||||
});
|
||||
assert.deepEqual(nip71.segments[1], { start: "bad" });
|
||||
assert.deepEqual(nip71.textTracks, [
|
||||
{
|
||||
url: "https://cdn.example.com/captions.vtt",
|
||||
type: "text/vtt",
|
||||
language: "en",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(nip71.participants, [
|
||||
{
|
||||
pubkey: "npub123",
|
||||
relay: "wss://relay.example.com",
|
||||
},
|
||||
]);
|
||||
assert.equal(nip71.imeta[0].url, "https://cdn.example.com/hls/playlist.m3u8");
|
||||
assert.deepEqual(nip71.imeta[0].service, ["wss://relay.example.com"]);
|
||||
});
|
||||
|
||||
test("reports validation errors for missing fields", () => {
|
||||
const { errors } = normalizeVideoNotePayload({
|
||||
title: " ",
|
||||
url: "",
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, [
|
||||
VIDEO_NOTE_ERROR_CODES.MISSING_TITLE,
|
||||
VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE,
|
||||
]);
|
||||
assert.equal(
|
||||
getVideoNoteErrorMessage(errors[0]),
|
||||
"Title is required.",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects insecure hosted URLs", () => {
|
||||
const { errors } = normalizeVideoNotePayload({
|
||||
title: "Sample",
|
||||
url: "http://example.com/video.mp4",
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, [VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL]);
|
||||
});
|
||||
|
||||
test("allows publishing with only imeta variants", () => {
|
||||
const { payload, errors } = normalizeVideoNotePayload({
|
||||
title: "Imeta Only",
|
||||
nip71: {
|
||||
imeta: [
|
||||
{
|
||||
url: "https://cdn.example.com/video.m3u8",
|
||||
image: ["https://cdn.example.com/poster.jpg"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(payload.nip71);
|
||||
assert.equal(payload.legacyFormData.url, "");
|
||||
});
|
||||
@@ -1325,6 +1325,118 @@ async function testWatchHistoryPartialRelayRetry() {
|
||||
}
|
||||
}
|
||||
|
||||
async function testWatchHistorySnapshotRetainsNewQueueEntriesDuringPublish() {
|
||||
console.log("Running watch history snapshot inflight queue retention test...");
|
||||
|
||||
const actor = "npub-snapshot-inflight";
|
||||
const originalPublishSnapshot = nostrClient.publishWatchHistorySnapshot;
|
||||
const originalRecordView = nostrClient.recordVideoView;
|
||||
const originalEnsure = nostrClient.ensureSessionActor;
|
||||
const originalSession = nostrClient.sessionActor;
|
||||
const originalPub = nostrClient.pubkey;
|
||||
|
||||
try {
|
||||
localStorage.clear();
|
||||
watchHistoryService.resetProgress();
|
||||
nostrClient.pubkey = actor;
|
||||
nostrClient.sessionActor = { pubkey: actor, privateKey: "snapshot-priv" };
|
||||
nostrClient.ensureSessionActor = async () => actor;
|
||||
nostrClient.watchHistoryLastCreatedAt = 0;
|
||||
|
||||
let createdAt = 1_700_200_000;
|
||||
nostrClient.recordVideoView = async (_pointer, options = {}) => ({
|
||||
ok: true,
|
||||
event: {
|
||||
id: `view-${options.created_at || createdAt}`,
|
||||
pubkey: actor,
|
||||
created_at: options.created_at || createdAt,
|
||||
},
|
||||
});
|
||||
|
||||
await watchHistoryService.publishView(
|
||||
{ type: "e", value: "inflight-initial" },
|
||||
createdAt,
|
||||
{ actor },
|
||||
);
|
||||
|
||||
const queuedBefore = watchHistoryService.getQueuedPointers(actor);
|
||||
assert.equal(
|
||||
queuedBefore.length,
|
||||
1,
|
||||
"queue should contain the initial pointer before snapshot",
|
||||
);
|
||||
|
||||
let publishCalled = false;
|
||||
let publishItems = [];
|
||||
let releasePublish;
|
||||
const publishGate = new Promise((resolve) => {
|
||||
releasePublish = resolve;
|
||||
});
|
||||
|
||||
nostrClient.publishWatchHistorySnapshot = async (items, options = {}) => {
|
||||
publishCalled = true;
|
||||
publishItems = Array.isArray(items) ? items.map((item) => ({ ...item })) : [];
|
||||
await publishGate;
|
||||
return { ok: true, items, snapshotId: "inflight-snapshot" };
|
||||
};
|
||||
|
||||
const snapshotPromise = watchHistoryService.snapshot(null, {
|
||||
actor,
|
||||
reason: "inflight-test",
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(
|
||||
publishCalled,
|
||||
true,
|
||||
"snapshot should invoke publish before resolving",
|
||||
);
|
||||
|
||||
createdAt += 60;
|
||||
await watchHistoryService.publishView(
|
||||
{ type: "e", value: "inflight-new" },
|
||||
createdAt,
|
||||
{ actor },
|
||||
);
|
||||
|
||||
const queuedDuring = watchHistoryService.getQueuedPointers(actor);
|
||||
assert.equal(
|
||||
queuedDuring.some((entry) => entry?.value === "inflight-new"),
|
||||
true,
|
||||
"queue should include the new pointer while snapshot is pending",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
publishItems.some((entry) => entry?.value === "inflight-new"),
|
||||
false,
|
||||
"inflight publish should not include pointers queued after the snapshot started",
|
||||
);
|
||||
|
||||
releasePublish();
|
||||
const snapshotResult = await snapshotPromise;
|
||||
assert.ok(snapshotResult?.ok, "snapshot should resolve successfully");
|
||||
|
||||
const queuedAfter = watchHistoryService.getQueuedPointers(actor);
|
||||
assert.equal(
|
||||
queuedAfter.length,
|
||||
1,
|
||||
"queue should retain only the pointer added during the inflight snapshot",
|
||||
);
|
||||
assert.equal(
|
||||
queuedAfter[0]?.value,
|
||||
"inflight-new",
|
||||
"new pointer should remain queued for the next snapshot",
|
||||
);
|
||||
} finally {
|
||||
nostrClient.publishWatchHistorySnapshot = originalPublishSnapshot;
|
||||
nostrClient.recordVideoView = originalRecordView;
|
||||
nostrClient.ensureSessionActor = originalEnsure;
|
||||
nostrClient.sessionActor = originalSession;
|
||||
nostrClient.pubkey = originalPub;
|
||||
watchHistoryService.resetProgress();
|
||||
}
|
||||
}
|
||||
|
||||
async function testResolveWatchHistoryBatchingWindow() {
|
||||
const actor = "b".repeat(64);
|
||||
const originalEnsure = nostrClient.ensureSessionActor;
|
||||
@@ -2345,6 +2457,7 @@ await testEnsureExtensionPermissionCaching();
|
||||
await testFetchWatchHistoryExtensionDecryptsHexAndNpub();
|
||||
await testPublishSnapshotFailureRetry();
|
||||
await testWatchHistoryPartialRelayRetry();
|
||||
await testWatchHistorySnapshotRetainsNewQueueEntriesDuringPublish();
|
||||
await testResolveWatchHistoryBatchingWindow();
|
||||
await testWatchHistoryServiceIntegration();
|
||||
await testWatchHistorySnapshotMergesQueuedWithCachedItems();
|
||||
|
||||
Reference in new issue
Block a user