Merge pull request #1188 from PR0M3TH3AN/unstable

Unstable
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-12 21:01:35 -05:00
commit 34a431dcfd
41 files changed
+2546 -836

No files matched your search

-1
View File
@@ -5,4 +5,3 @@ src/webtorrent-docs/
playwright-report/
test-results/
tests/visual/__snapshots__/
css/tailwind.generated.css
+2 -2
View File
@@ -930,7 +930,7 @@
<h4 class="text-base font-semibold text-text">Whitelist</h4>
</div>
<p class="text-sm text-muted">
Creators listed here remain visible when whitelist mode is enabled. Adding someone removes them from the blacklist.
Creators listed here remain visible when whitelist mode is enabled. Accounts can appear in both lists, and the blacklist always takes precedence.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-muted-strong" for="adminWhitelistInput">
@@ -966,7 +966,7 @@
<h4 class="text-base font-semibold text-text">Blacklist</h4>
</div>
<p class="text-sm text-muted">
Creators on the blacklist are hidden from bitvid everywhere. Adding someone removes them from the whitelist.
Creators on the blacklist are hidden from bitvid everywhere. Accounts can appear in both lists, and the blacklist always takes precedence.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-muted-strong" for="adminBlacklistInput">
+61 -33
View File
@@ -89,7 +89,7 @@
</label>
</div>
<!-- Upload Progress (Hidden by default) -->
<!-- Upload Progress -->
<div id="upload-progress-container" class="space-y-2 hidden">
<div class="flex justify-between text-xs">
<span id="upload-status-text" class="text-text font-medium">Uploading...</span>
@@ -133,45 +133,73 @@
</p>
</div>
<div class="grid gap-3 md:grid-cols-2">
<!-- Target Bucket Display -->
<div class="space-y-1">
<label for="input-r2-bucket-name" class="text-xs font-medium text-text">Target Bucket Name (Required)</label>
<div class="flex gap-2">
<input
id="input-r2-bucket-name"
type="text"
class="input text-xs bg-surface-alt text-muted cursor-text select-all"
readonly
placeholder="Log in to see your bucket name"
/>
<button
type="button"
id="btn-copy-bucket"
class="btn-ghost px-2 text-xs border border-border/30"
aria-label="Copy bucket name"
>
Copy
</button>
</div>
<p class="text-[10px] text-muted">Create a bucket with this exact name in Cloudflare R2.</p>
</div>
<!-- Step 1: Credentials -->
<div id="step-credentials" class="space-y-3">
<div class="space-y-1">
<label for="input-r2-account" class="text-xs font-medium text-text">Account ID</label>
<input id="input-r2-account" type="text" class="input text-xs" placeholder="xxxxxxxx..." />
</div>
<div class="space-y-1">
<label for="input-r2-key" class="text-xs font-medium text-text">Access Key ID</label>
<input id="input-r2-key" type="text" class="input text-xs" placeholder="AKIA..." />
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-1">
<label for="input-r2-key" class="text-xs font-medium text-text">Access Key ID</label>
<input id="input-r2-key" type="text" class="input text-xs" placeholder="AKIA..." />
</div>
<div class="space-y-1">
<label for="input-r2-secret" class="text-xs font-medium text-text">Secret Access Key</label>
<input id="input-r2-secret" type="password" class="input text-xs" />
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-next-step" class="btn btn-sm">Next</button>
</div>
</div>
<div class="space-y-1">
<label for="input-r2-secret" class="text-xs font-medium text-text">Secret Access Key</label>
<input id="input-r2-secret" type="password" class="input text-xs" />
<!-- Step 2: Verification -->
<div id="step-verification" class="hidden space-y-3 pt-2 border-t border-border/30">
<div class="space-y-1">
<label for="input-r2-domain" class="text-xs font-medium text-text">Public Bucket URL</label>
<input id="input-r2-domain" type="text" class="input text-xs" placeholder="https://pub-xxx.r2.dev" />
<p class="text-[10px] text-muted leading-tight">
Enable <strong>Public Access</strong> in the Cloudflare dashboard to get your r2.dev URL.
</p>
</div>
<!-- Error Container for Verification Failures -->
<div id="container-verification-error" class="hidden bg-critical/10 border border-critical/30 rounded p-3 text-xs text-critical space-y-2">
<p id="text-verification-error" class="font-medium">Verification Failed</p>
<a id="link-cloudflare-guide" href="#" target="_blank" class="block text-center bg-critical/10 hover:bg-critical/20 py-1.5 rounded font-medium transition-colors">
Open Cloudflare Dashboard &rarr;
</a>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-back-step" class="btn-ghost px-2 text-xs">Back</button>
<button type="button" id="btn-save-settings" class="btn btn-sm">Verify & Save</button>
</div>
</div>
<!-- R2 Advanced -->
<div class="space-y-2 pt-2">
<button type="button" id="btn-r2-advanced" class="text-xs text-muted-strong hover:text-text underline">Advanced R2 Options</button>
<div id="section-r2-advanced" class="hidden space-y-3 pt-2">
<div class="space-y-1">
<label for="input-r2-token" class="text-xs font-medium text-text">API Token (Optional)</label>
<input id="input-r2-token" type="password" class="input text-xs" placeholder="Worker Token..." />
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-1">
<label for="input-r2-zone" class="text-xs font-medium text-text">Zone ID</label>
<input id="input-r2-zone" type="text" class="input text-xs" />
</div>
<div class="space-y-1">
<label for="input-r2-domain" class="text-xs font-medium text-text">Base Domain</label>
<input id="input-r2-domain" type="text" class="input text-xs" placeholder="cdn.example.com" />
</div>
</div>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-save-settings" class="btn btn-sm">Save Credentials</button>
</div>
</div>
</div>
</div>
+7 -1
View File
@@ -175,7 +175,13 @@
<div class="video-modal__meta-info flex flex-wrap items-center gap-3">
<span id="videoTimestamp" class="modal-meta">Posted just now</span>
<span id="videoEditedTimestamp" class="modal-meta hidden">Last edited just now</span>
<span id="videoViewCount" class="modal-meta modal-view-count">– views</span>
<span id="videoViewCount" class="modal-meta modal-view-count flex items-center gap-1.5" title="Views">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span data-view-count-text>–</span>
</span>
</div>
</div>
+1 -1
View File
@@ -256,7 +256,7 @@ export const DEFAULT_AUTOPLAY_BLOCK_THRESHOLD = 1;
* enforce community standards or raise it to tolerate more reports before
* hiding.
*/
export const DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD = 1;
export const DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD = 20;
/**
* Trusted spam/report threshold that hides videos globally.
+1 -1
View File
@@ -88,7 +88,7 @@ You can manage moderation directly from the client using the existing menus rend
### **5.5 Safety & Moderation thresholds**
- Open your profile avatar → **Profile** → **Safety & Moderation** to adjust blur and autoplay thresholds. Leave a field blank to return to the defaults exported from [`config/instance-config.js`](../config/instance-config.js) (`DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`). The upstream repo ships example values of 3 trusted reports for blur, 2 for autoplay blocking, 1 trusted mute to hide authors, and 3 trusted spam reports to hide videos—tune the config file before deployment if your community needs different baselines.
- Open your profile avatar → **Profile** → **Safety & Moderation** to adjust blur and autoplay thresholds. Leave a field blank to return to the defaults exported from [`config/instance-config.js`](../config/instance-config.js) (`DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`). The upstream repo ships example values of 1 trusted report for blur, 1 for autoplay blocking, 20 trusted mutes to hide authors, and 1 trusted spam report to hide videos—tune the config file before deployment if your community needs different baselines.
- These controls update immediately and sync with the moderation overlays applied by `moreMenuController` and `VideoCard` components. Operators can edit the config file to change the baseline before deploying their own instance.
## **6. Appeals and Feedback**
+8
View File
@@ -0,0 +1,8 @@
: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;--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__primary>.watch-container:last-child,#playerModal .video-modal__secondary>.watch-container:last-child{padding-block-end:var(--player-modal-vertical-padding)}#playerModal .modal-nav .watch-container{max-width:none;margin-inline:0}#playerModal .modal-info-panel{display:flex;flex-direction:column;gap:var(--space-md-plus)}#playerModal h2#videoTitle{margin:0;font-size:clamp(var(--font-size-xl),calc(var(--font-size-modal-title-base) + .5vw),var(--font-size-xl-plus));font-weight:600;line-height:1.3;color:var(--color-text);min-width:0;overflow-wrap:break-word;word-break:break-word}@media (min-width:1024px){.video-modal__headline #videoTitle.video-modal__title{flex:1 1 0%;min-width:0;max-width:calc(100% - var(--space-3xl) - var(--space-md))}.video-modal__headline .video-modal__status{flex:0 0 auto;max-width:clamp(var(--space-xl),25%,var(--space-3xl));margin-left:auto}}#playerModal .modal-meta{font-size:var(--font-size-sm);line-height:1.4;color:var(--color-text-muted)}#playerModal .modal-view-count{font-weight:600;color:var(--color-text)}#playerModal .channel-card{align-items:center;gap:var(--space-md);padding:var(--space-md) var(--space-lg)}#playerModal .channel-card,#playerModal .player-modal__module{display:flex;border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-surface);box-shadow:var(--shadow-sm)}#playerModal .player-modal__module{flex-direction:column;gap:clamp(var(--space-2xs),1vw,var(--space-sm));width:100%;padding:clamp(var(--space-xs),1vw,var(--space-sm))}#playerModal .player-modal__module-title{margin:0;font-size:var(--font-size-lg);font-weight:600;line-height:1.3;color:var(--color-text)}#playerModal .player-modal__module-list{display:flex;flex-direction:column;gap:var(--space-sm);width:100%;margin:0;padding:0;list-style:none}#playerModal .video-modal__similar .player-modal__module{gap:clamp(var(--space-3xs),.6vw,var(--space-xs));padding:clamp(var(--space-2xs),.6vw,var(--space-xs));border-width:0;box-shadow:none;background-color:transparent}#playerModal .video-modal__similar .player-modal__module-title{padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .video-modal__similar .player-modal__module-list{gap:clamp(var(--space-3xs),.6vw,var(--space-xs));padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs));padding-bottom:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .player-modal__module-scroll{max-height:clamp(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)) scaleLine 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{outline:2px solid transparent;outline-offset:2px}.player-modal__similar-card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.player-modal__similar-card{display:block}.player-modal__similar-card{position:relative;display:flex;width:100%;min-width:0;align-items:flex-start;gap:.75rem;border-radius:var(--radius-xl);padding:.5rem;trLine truncated
+6 -2
View File
@@ -1602,6 +1602,10 @@
padding-block-start: var(--player-modal-vertical-padding);
}
#playerModal .video-modal__primary > .watch-container:last-child {
padding-block-end: var(--player-modal-vertical-padding);
}
#playerModal .video-modal__secondary > .watch-container:last-child {
padding-block-end: var(--player-modal-vertical-padding);
}
@@ -1897,7 +1901,7 @@
.video-modal__layout {
--video-modal-secondary-size: clamp(
var(--video-modal-secondary-min),
40vw,
30vw,
var(--video-modal-secondary-max)
);
grid-template-columns:
@@ -1936,7 +1940,7 @@
.video-modal__layout {
--video-modal-secondary-size: clamp(
var(--video-modal-secondary-min),
36vw,
28vw,
var(--video-modal-secondary-max)
);
}
+1 -1
View File
@@ -211,7 +211,7 @@
--popup-padding-block: 0.75rem;
--video-modal-primary-fr: 1.85fr;
--video-modal-secondary-min: 320px;
--video-modal-secondary-max: 44rem;
--video-modal-secondary-max: 35rem;
--video-modal-secondary-fr: 1fr;
--video-modal-layout-max: calc(
var(--layout-player-max-width) + var(--video-modal-secondary-max) +
+1 -1
View File
@@ -139,7 +139,7 @@ an operator might change it.
- **`DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`**
- **What it does:** Trusted mute count required to hide a creator globally.
- **Why change it:** Lower for stricter enforcement, higher for more leniency.
- **Why change it:** Lower for stricter enforcement, higher for more leniency. The upstream default is 20, allowing most content to blur before hiding.
- **`DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`**
- **What it does:** Trusted spam/report count required to hide a video
+5 -5
View File
@@ -6,7 +6,7 @@ bitvid is follow-centric. Your Home feed comes from people you follow (F1). Disc
## Core principles
- **Freedom to choose**: User picks filters. Defaults are safe but reversible.
- **Explain decisions**: Every blur/hide shows a “why” badge and a “show anyway” control.
- **Whitelist > blacklist**: Prefer showing content from trusted networks over fighting global spam.
- **Blacklist > whitelist**: Admin blacklists and moderation gates always win; whitelists only influence Discovery ranking.
- **Minimal central power**: Admin lists are opt-in; users can unsubscribe.
## Threat model (short)
@@ -50,7 +50,7 @@ Thread new moderation behaviors through the same service → stage → app → U
- Opt-in admin lists (30000 with `d=bitvid:admin:*`) can hard-hide content, but whitelist entries no longer bypass moderation gates—they only influence Discovery rankings when a viewer opts into that list.
- Trust seeds now come from the Super Admin plus every active moderator, so their reports shape anonymous/default visitor filters automatically. The `DEFAULT_TRUST_SEED_NPUBS` export only activates as an emergency fallback when those live lists cannot be loaded (toggle via `FEATURE_TRUST_SEEDS`).
> Default thresholds now come directly from [`config/instance-config.js`](../../config/instance-config.js). Look for the exports `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` to adjust the instance-wide behavior before deploying. The upstream repo currently publishes example values of 3 trusted reports for blur, 2 for autoplay blocking, 1 trusted mute to hide authors, and 3 trusted spam reports to hide videos.
> Default thresholds now come directly from [`config/instance-config.js`](../../config/instance-config.js). Look for the exports `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` to adjust the instance-wide behavior before deploying. The upstream repo currently publishes example values of 1 trusted report for blur, 1 for autoplay blocking, 20 trusted mutes to hide authors, and 1 trusted spam report to hide videos.
> Operators can still override these thresholds on a per-viewer basis in **Settings → Safety & Moderation**; leaving the UI fields blank restores the config-driven defaults. Update [`config/instance-config.js`](../../config/instance-config.js) to change what new viewers receive out of the box.
@@ -67,7 +67,7 @@ Operators can delegate hard-hide decisions to trusted curators without giving th
1. The super admin maintains a `kind 30000` list with `d=${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_SOURCES}`. Each `a` tag references a community curator’s blacklist as `30000:<curator hex pubkey>:${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}:<slug>`.
2. Every curator publishes their own `kind 30000` list using the referenced `d` tag (`${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}:<slug>`) and fills it with `p` tags for accounts they want hidden.
3. bitvid automatically fetches each referenced list, merges the entries into the global blacklist, and removes any duplicates or guard-protected accounts (super admin, editors, or whitelist members).
3. bitvid automatically fetches each referenced list, merges the entries into the global blacklist, and removes any duplicates or guard-protected accounts (super admin or editors).
To add a community list, append a new `a` tag to the super-admin source list and ask the curator to publish their companion `p` list. To remove a list, delete the `a` tag or ask the curator to clear their `p` entries; the client stops ingesting it on the next refresh.
@@ -86,8 +86,8 @@ The profile modal now exposes blur, autoplay, and hide thresholds so operators c
### Runtime flags
- `TRUSTED_MUTE_HIDE_THRESHOLD` — numeric default for the trusted mute hide control. Initialized from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the upstream config sets this to `1`, but adjust the export to match your policy.
- `TRUSTED_SPAM_HIDE_THRESHOLD` — numeric default for the trusted spam hide control. Initialized from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the upstream config example is `3`.
- `TRUSTED_MUTE_HIDE_THRESHOLD` — numeric default for the trusted mute hide control. Initialized from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the upstream config sets this to `20`, but adjust the export to match your policy.
- `TRUSTED_SPAM_HIDE_THRESHOLD` — numeric default for the trusted spam hide control. Initialized from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the upstream config example is `1`.
- `FEATURE_TRUSTED_HIDE_CONTROLS` — boolean toggle to hide/show the new trusted hide controls in the UI (default `true`).
## Files in this folder
+2 -2
View File
@@ -41,7 +41,7 @@ Expected:
* After 1 report → no blur; autoplay allowed.
* Once reports reach `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` → autoplay blocked (the upstream configuration currently sets this to 2, but rely on your `config/instance-config.js` value).
* Once reports reach `DEFAULT_BLUR_THRESHOLD` → thumbnail blurred; reason chip shown (upstream example: 3). At the spam threshold (`TRUSTED_SPAM_HIDE_THRESHOLD` sourced from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js)) the card hides entirely and the badge copy switches to "Hidden · {count} trusted spam report(s)" until the viewer overrides it.
* Once reports reach `DEFAULT_BLUR_THRESHOLD` → thumbnail blurred; reason chip shown (upstream example: 1). At the spam threshold (`TRUSTED_SPAM_HIDE_THRESHOLD` sourced from `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js)) the card hides entirely and the badge copy switches to "Hidden · {count} trusted spam report(s)" until the viewer overrides it.
**Exercises:** [`ModerationService.ingestReportEvent()`](../../js/services/moderationService.js) and [`ModerationService.getTrustedReportSummary()`](../../js/services/moderationService.js) wire through [`createModerationStage()`](../../js/feedEngine/stages.js) so [`bitvidApp.decorateVideoModeration()`](../../js/app.js) can hand the summary to [`VideoCard.refreshModerationUi()`](../../js/ui/components/VideoCard.js).
@@ -56,7 +56,7 @@ Expected:
}
```
Expected: author’s items are downranked/hidden for this viewer. When the mute threshold (`TRUSTED_MUTE_HIDE_THRESHOLD`, sourced from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); upstream example: 1) is met, cards render with "Hidden · {count} trusted mute(s)" and require a "Show anyway" override to display content plus a "Restore default moderation" control to revert.
Expected: author’s items are downranked/blurred for this viewer. When the mute threshold (`TRUSTED_MUTE_HIDE_THRESHOLD`, sourced from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); upstream example: 20) is met, cards render with "Hidden · {count} trusted mute(s)" and require a "Show anyway" override to display content plus a "Restore default moderation" control to revert.
**Exercises:** [`ModerationService.ingestTrustedMuteEvent()`](../../js/services/moderationService.js) updates [`ModerationService.isAuthorMutedByTrusted()`](../../js/services/moderationService.js) / [`getTrustedMutersForAuthor()`](../../js/services/moderationService.js), which [`createModerationStage()`](../../js/feedEngine/stages.js) propagates to the video item before [`VideoCard.refreshModerationUi()`](../../js/ui/components/VideoCard.js) renders the mute state.
+7 -7
View File
@@ -54,8 +54,8 @@ list. Blocking still follows the precedence rules above.
- A video has 3 `nudity` reports from those seed accounts.
Result:
- Thumbnail is blurred if `DEFAULT_BLUR_THRESHOLD` is 3.
- Autoplay is blocked if `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` is 2.
- Thumbnail is blurred if `DEFAULT_BLUR_THRESHOLD` is 1 (met).
- Autoplay is blocked if `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` is 1 (met).
- No hard hide unless trusted mute/spam thresholds are met.
### Example 2: Logged-in viewer with personal blocks
@@ -81,13 +81,13 @@ Result:
### Example 4: Trusted mute hide
- Viewer follows `npub_a`, `npub_b`, and `npub_c`.
- Both `npub_a` and `npub_b` have muted `npub_author_y`.
- `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` is 1.
- Viewer follows 25 accounts.
- 20 of them have muted `npub_author_y`.
- `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` is 20.
Result:
- The author is hidden (trusted mute count ≥ 1).
- The badge reads: `Hidden · 2 trusted mutes` and a “Show anyway” override is
- The author is hidden (trusted mute count ≥ 20).
- The badge reads: `Hidden · 20 trusted mutes` and a “Show anyway” override is
available.
### Example 5: Mixed nudity + spam reports
+1 -1
View File
@@ -20,7 +20,7 @@
- `hideIfTrustedMuteCount(author) >= DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`
- `hideIfTrustedSpamReports(event) >= DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`
Threshold constants are exported from [`config/instance-config.js`](../../config/instance-config.js) so operators can change the defaults without touching moderation code. Inspect the `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` exports to set your policy. The upstream repo includes example values (blur at 3, autoplay block at 2, trusted mute hide at 1, trusted spam hide at 3), but treat those as guidance rather than hard-coded requirements.
Threshold constants are exported from [`config/instance-config.js`](../../config/instance-config.js) so operators can change the defaults without touching moderation code. Inspect the `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` exports to set your policy. The upstream repo includes example values (blur at 1, autoplay block at 1, trusted mute hide at 20, trusted spam hide at 1), but treat those as guidance rather than hard-coded requirements.
### Why these numbers?
- F1-only reports resist Sybil attacks.
+55 -27
View File
@@ -117,6 +117,7 @@ class AccessControl {
this._hydratedFromCache = false;
this._whitelistListeners = new Set();
this._editorListeners = new Set();
this._blacklistListeners = new Set();
this._scheduleHydrateFromCache();
}
@@ -242,6 +243,7 @@ class AccessControl {
}
_applyState(state, options = {}) {
userLogger.info("[accessControl] _applyState called", { state, options });
const markLoaded = options.markLoaded !== false;
const editors = Array.isArray(state?.editors) ? state.editors : [];
const whitelist = Array.isArray(state?.whitelist) ? state.whitelist : [];
@@ -251,6 +253,8 @@ class AccessControl {
this.editors instanceof Set ? new Set(this.editors) : new Set();
const previousWhitelist =
this.whitelist instanceof Set ? new Set(this.whitelist) : new Set();
const previousBlacklist =
this.blacklist instanceof Set ? new Set(this.blacklist) : new Set();
this.editors = new Set(
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
@@ -261,7 +265,6 @@ class AccessControl {
const whitelistChanged = !areSetsEqual(previousWhitelist, this.whitelist);
const blacklistDedupe = dedupeNpubs(blacklist);
const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub));
const adminGuardSet = new Set([
normalizeNpub(ADMIN_SUPER_NPUB),
...Array.from(this.editors),
@@ -271,15 +274,13 @@ class AccessControl {
if (!normalized) {
return false;
}
if (whitelistSet.has(normalized)) {
return false;
}
if (adminGuardSet.has(normalized)) {
return false;
}
return true;
});
this.blacklist = new Set(sanitizedBlacklist);
const blacklistChanged = !areSetsEqual(previousBlacklist, this.blacklist);
const toolkitCandidate =
(typeof window !== "undefined" ? window?.NostrTools : null) ||
@@ -326,6 +327,9 @@ class AccessControl {
if (editorsChanged) {
this._emitEditorsChange(Array.from(this.editors));
}
if (blacklistChanged) {
this._emitBlacklistChange(Array.from(this.blacklist));
}
}
_emitWhitelistChange(whitelistValues) {
@@ -346,6 +350,24 @@ class AccessControl {
}
}
_emitBlacklistChange(blacklistValues) {
if (!this._blacklistListeners.size) {
return;
}
const snapshot = Array.isArray(blacklistValues)
? [...blacklistValues]
: this.getBlacklist();
for (const listener of Array.from(this._blacklistListeners)) {
try {
listener(snapshot);
} catch (error) {
userLogger.error("accessControl blacklist listener failed", error);
}
}
}
_emitEditorsChange(editorsValues) {
if (!this._editorListeners.size) {
return;
@@ -396,9 +418,11 @@ class AccessControl {
refresh() {
if (this._isRefreshing) {
userLogger.info("[accessControl] refresh ignored (already refreshing)");
return this._refreshPromise;
}
userLogger.info("[accessControl] refresh started");
const operation = this._performRefresh();
const tracked = operation.catch((error) => {
userLogger.error("Failed to refresh admin lists:", error);
@@ -415,8 +439,10 @@ class AccessControl {
try {
await this._refreshPromise;
userLogger.info("[accessControl] ensureReady resolved");
} catch (error) {
if (!this.hasLoaded) {
userLogger.warn("[accessControl] ensureReady retry");
await this.refresh();
await this._refreshPromise;
} else {
@@ -485,6 +511,18 @@ class AccessControl {
};
}
onBlacklistChange(listener) {
if (typeof listener !== "function") {
return () => {};
}
this._blacklistListeners.add(listener);
return () => {
this._blacklistListeners.delete(listener);
};
}
async addModerator(requestorNpub, moderatorNpub) {
if (!this.isSuperAdmin(requestorNpub)) {
return { ok: false, error: "forbidden" };
@@ -543,9 +581,7 @@ class AccessControl {
}
const nextWhitelist = dedupeNpubs([...this.getWhitelist(), normalized]);
const nextBlacklist = this.getBlacklist().filter(
(value) => value !== normalized
);
const nextBlacklist = this.getBlacklist();
try {
await persistAdminState(actorNpub, {
@@ -604,9 +640,7 @@ class AccessControl {
}
const nextBlacklist = dedupeNpubs([...this.getBlacklist(), normalized]);
const nextWhitelist = this.getWhitelist().filter(
(value) => value !== normalized
);
const nextWhitelist = this.getWhitelist();
try {
await persistAdminState(actorNpub, {
@@ -656,15 +690,9 @@ class AccessControl {
if (!normalized) {
return false;
}
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);
}
@@ -712,14 +740,14 @@ class AccessControl {
return false;
}
if (this.whitelistPubkeys.has(hex)) {
return true;
}
if (this.blacklistPubkeys.has(hex)) {
return false;
}
if (this.whitelistPubkeys.has(hex)) {
return true;
}
if (this.whitelistEnabled && !this.whitelistPubkeys.has(hex)) {
return false;
}
@@ -736,22 +764,22 @@ class AccessControl {
}
if (hex) {
if (this.whitelistPubkeys.has(hex)) {
return true;
}
if (this.blacklistPubkeys.has(hex)) {
return false;
}
}
if (this.whitelist.has(normalized)) {
return true;
if (this.whitelistPubkeys.has(hex)) {
return true;
}
}
if (this.blacklist.has(normalized)) {
return false;
}
if (this.whitelist.has(normalized)) {
return true;
}
if (this.whitelistEnabled && !this.whitelist.has(normalized)) {
return false;
}
-10
View File
@@ -193,7 +193,6 @@ function sanitizeNpubList(values) {
function sanitizeAdminState(state = {}) {
const sanitizedEditors = sanitizeNpubList(state.editors || []);
const sanitizedWhitelist = sanitizeNpubList(state.whitelist || []);
const whitelistSet = new Set(sanitizedWhitelist.map(normalizeNpub));
const adminGuardSet = new Set([
normalizeNpub(ADMIN_SUPER_NPUB),
@@ -207,9 +206,6 @@ function sanitizeAdminState(state = {}) {
if (!normalized) {
return false;
}
if (whitelistSet.has(normalized)) {
return false;
}
if (adminGuardSet.has(normalized)) {
return false;
}
@@ -848,9 +844,6 @@ async function persistNostrState(actorNpub, updates = {}) {
}
if (Array.isArray(updates.blacklist)) {
const whitelistSet = new Set(
(sanitizedUpdates.whitelist || []).map(normalizeNpub)
);
const editorGuard = new Set([
normalizeNpub(ADMIN_SUPER_NPUB),
...ADMIN_EDITORS_NPUBS.map(normalizeNpub),
@@ -867,9 +860,6 @@ async function persistNostrState(actorNpub, updates = {}) {
if (!normalized) {
return false;
}
if (whitelistSet.has(normalized)) {
return false;
}
if (editorGuard.has(normalized)) {
return false;
}
+159 -2
View File
@@ -76,6 +76,7 @@ import {
import { splitAndZap as splitAndZapDefault } from "./payments/zapSplit.js";
import {
formatAbsoluteTimestamp as formatAbsoluteTimestampUtil,
formatAbsoluteDateWithOrdinal as formatAbsoluteDateWithOrdinalUtil,
formatTimeAgo as formatTimeAgoUtil,
truncateMiddle,
formatShortNpub,
@@ -328,7 +329,24 @@ class Application {
isAuthorBlocked(pubkey) {
try {
if (userBlocks && typeof userBlocks.isBlocked === "function") {
return userBlocks.isBlocked(pubkey);
if (userBlocks.isBlocked(pubkey)) {
return true;
}
}
if (!this.isUserLoggedIn()) {
const normalized = this.normalizeHexPubkey(pubkey);
if (
normalized &&
moderationService &&
typeof moderationService.getTrustedMutersForAuthor === "function"
) {
const muters = moderationService.getTrustedMutersForAuthor(normalized);
const threshold = getTrustedMuteHideThreshold();
if (Array.isArray(muters) && muters.length >= threshold) {
return true;
}
}
}
} catch (error) {
devLogger.warn("[Application] Failed to evaluate block status:", error);
@@ -467,6 +485,112 @@ class Application {
await Promise.all([accessControlPromise, adminPanePromise]);
const syncSessionActorBlacklist = async (trigger) => {
if (this.pubkey) {
return;
}
const sessionActorPubkey = nostrClient.sessionActor?.pubkey;
if (!sessionActorPubkey) {
return;
}
const blacklist = accessControl.getBlacklist();
try {
await userBlocks.seedBaselineDelta(
sessionActorPubkey,
Array.from(blacklist || []),
);
} catch (error) {
devLogger.warn(
`[app.init()] Failed to sync session actor blacklist${
trigger ? ` (${trigger})` : ""
}:`,
error,
);
}
};
const handleSessionActorReady = async ({ pubkey, reason } = {}) => {
if (this.pubkey) {
return;
}
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
if (!normalizedPubkey) {
return;
}
const triggerLabel = reason ? `session-actor-${reason}` : "session-actor";
await syncSessionActorBlacklist(triggerLabel);
const refreshReason = "session-actor-ready";
if (typeof this.refreshVisibleModerationUi === "function") {
try {
this.refreshVisibleModerationUi({ reason: refreshReason });
} catch (error) {
devLogger.warn(
"[app.init()] Failed to refresh moderation UI after session actor:",
error,
);
}
} else {
this.refreshAllVideoGrids({
reason: refreshReason,
forceMainReload: true,
}).catch((error) => {
devLogger.warn(
"[app.init()] Failed to refresh video grids after session actor:",
error,
);
});
}
};
if (typeof nostrClient.onSessionActorChange === "function") {
nostrClient.onSessionActorChange((detail) => {
handleSessionActorReady(detail).catch((error) => {
devLogger.warn(
"[app.init()] Failed to process session actor change:",
error,
);
});
});
}
await syncSessionActorBlacklist("post-refresh");
if (typeof accessControl.onBlacklistChange === "function") {
accessControl.onBlacklistChange(() => {
syncSessionActorBlacklist("blacklist-change");
if (this.isUserLoggedIn()) {
return;
}
const refreshReason = "admin-blacklist-change";
this.refreshAllVideoGrids({
reason: refreshReason,
forceMainReload: true,
}).catch((error) => {
devLogger.warn(
"[app.init()] Failed to refresh video grids after admin blacklist change:",
error,
);
});
if (typeof this.refreshVisibleModerationUi === "function") {
try {
this.refreshVisibleModerationUi({ reason: refreshReason });
} catch (error) {
devLogger.warn(
"[app.init()] Failed to refresh moderation UI after admin blacklist change:",
error,
);
}
}
});
}
// Grab the "Subscriptions" link by its id in the sidebar
this.subscriptionsLink = document.getElementById("subscriptionsLink");
@@ -2651,6 +2775,22 @@ class Application {
* Setup general event listeners for logout, modals, etc.
*/
setupEventListeners() {
if (moderationService && typeof moderationService.on === "function") {
moderationService.on("trusted-mutes", () => {
if (!this.isUserLoggedIn()) {
this.refreshAllVideoGrids({ reason: "trusted-mutes" });
} else {
this.refreshVisibleModerationUi({ reason: "trusted-mutes" });
}
});
moderationService.on("user-blocks", () => {
this.refreshVisibleModerationUi({ reason: "user-blocks" });
});
moderationService.on("summary", () => {
this.refreshVisibleModerationUi({ reason: "moderation-summary" });
});
}
if (this.appChromeController) {
this.appChromeController.initialize();
return;
@@ -8622,7 +8762,9 @@ class Application {
(normalizedPostedAt === null || normalizedEditedAt - normalizedPostedAt >= 60);
if (shouldShowEdited) {
payload.edited = `Last edited ${this.formatTimeAgo(normalizedEditedAt)}`;
const abs = formatAbsoluteDateWithOrdinalUtil(normalizedEditedAt);
const rel = this.formatTimeAgo(normalizedEditedAt);
payload.edited = `Last edited: ${abs} (${rel})`;
}
return payload;
@@ -8707,6 +8849,21 @@ class Application {
return null;
}
// Prioritize NIP-71 published_at metadata if available
const rawNip71PublishedAt =
video?.nip71?.publishedAt ||
video?.nip71?.published_at ||
video?.nip71?.["published-at"];
const parsedNip71PublishedAt = Number(rawNip71PublishedAt);
const nip71PublishedAt = Number.isFinite(parsedNip71PublishedAt)
? Math.floor(parsedNip71PublishedAt)
: null;
if (nip71PublishedAt !== null) {
this.cacheVideoRootCreatedAt(video, nip71PublishedAt);
return nip71PublishedAt;
}
const cached = this.getKnownVideoPostedAt(video);
if (cached !== null) {
return cached;
+1 -1
View File
@@ -123,7 +123,7 @@ const SANITIZED_DEFAULT_AUTOPLAY_BLOCK_THRESHOLD = coerceNonNegativeInteger(
);
const SANITIZED_DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD = coerceNonNegativeInteger(
CONFIG_DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
1,
4,
);
const SANITIZED_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD = coerceNonNegativeInteger(
CONFIG_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
+7
View File
@@ -66,6 +66,7 @@ export function createSubscriptionAuthorsSource({ service } = {}) {
const resolvedService = resolveService(service, nostrService);
return async function subscriptionAuthorsSource(context = {}) {
context?.log?.("[subscriptions-source] START");
const runtimeAuthors = toArray(
context?.runtime?.subscriptionAuthors || context?.runtime?.authors
).map(normalizeAuthor);
@@ -88,6 +89,8 @@ export function createSubscriptionAuthorsSource({ service } = {}) {
[...runtimeAuthors, ...configAuthors, ...hookAuthors].filter(Boolean)
);
context?.log?.(`[subscriptions-source] Resolved ${authors.size} authors.`);
if (!authors.size) {
return [];
}
@@ -115,12 +118,16 @@ export function createSubscriptionAuthorsSource({ service } = {}) {
let videos = [];
try {
if (hasTargetedLookup) {
context?.log?.("[subscriptions-source] Calling getActiveVideosByAuthors...");
videos = await Promise.resolve(
resolvedService.getActiveVideosByAuthors(authorList, options)
);
context?.log?.(`[subscriptions-source] getActiveVideosByAuthors returned ${videos.length} videos.`);
if (!videos.length && typeof resolvedService.fetchVideosByAuthors === "function") {
context?.log?.("[subscriptions-source] Cache empty. Calling fetchVideosByAuthors...");
const fetched = await resolvedService.fetchVideosByAuthors(authorList, options);
context?.log?.(`[subscriptions-source] fetchVideosByAuthors returned ${fetched ? fetched.length : 0} videos.`);
if (Array.isArray(fetched) && fetched.length > 0) {
videos = fetched;
}
+50
View File
@@ -1247,6 +1247,7 @@ export class NostrClient {
this.countUnsupportedRelays = new Set();
this.nip46Client = null;
this.remoteSignerListeners = new Set();
this.sessionActorListeners = new Set();
const storedRemoteSigner = this.getStoredNip46Metadata();
this.remoteSignerStatus = {
state: storedRemoteSigner.hasSession ? "stored" : "idle",
@@ -1398,6 +1399,53 @@ export class NostrClient {
};
}
emitSessionActorChange(actor, context = {}) {
const resolvedActor =
actor && typeof actor === "object" ? actor : this.sessionActor;
const pubkey =
typeof resolvedActor?.pubkey === "string" ? resolvedActor.pubkey.trim() : "";
if (!pubkey) {
return null;
}
const reason =
typeof context.reason === "string" && context.reason.trim()
? context.reason.trim()
: "";
const createdAt = Number.isFinite(resolvedActor?.createdAt)
? resolvedActor.createdAt
: null;
const snapshot = {
pubkey,
source:
typeof resolvedActor?.source === "string" ? resolvedActor.source : "",
persisted: resolvedActor?.persisted === true,
createdAt,
reason,
};
for (const listener of Array.from(this.sessionActorListeners)) {
try {
listener(snapshot);
} catch (error) {
devLogger.warn("[nostr] Session actor listener threw:", error);
}
}
return snapshot;
}
onSessionActorChange(listener) {
if (typeof listener !== "function") {
return () => {};
}
this.sessionActorListeners.add(listener);
return () => {
this.sessionActorListeners.delete(listener);
};
}
async createNip46KeyPair(existingPrivateKey = "", existingPublicKey = "") {
let privateKey =
typeof existingPrivateKey === "string" && existingPrivateKey.trim()
@@ -3167,6 +3215,7 @@ export class NostrClient {
const restored = this.restoreSessionActorFromStorage();
if (restored) {
this.sessionActor = restored;
this.emitSessionActorChange(restored, { reason: "restored" });
return restored.pubkey;
}
}
@@ -3179,6 +3228,7 @@ export class NostrClient {
this.sessionActorCipherClosures = null;
this.sessionActorCipherClosuresPrivateKey = null;
this.persistSessionActor(minted);
this.emitSessionActorChange(minted, { reason: "minted" });
return minted.pubkey;
}
+1
View File
@@ -581,6 +581,7 @@ export function shimLegacySimplePoolMethods(pool) {
: 7000;
timer = setTimeout(() => {
devLogger.warn(`[toolkit] pool.list timed out after ${timeoutMs}ms.`);
timer = null;
try {
subscription.unsub?.();
+6 -2
View File
@@ -10,8 +10,12 @@ function sanitizeBaseDomain(domain) {
return "";
}
let value = String(domain).trim().toLowerCase();
value = value.replace(/^https?:\/\//, "");
value = value.replace(/\/.*$/, "");
// Ensure it starts with https:// if it doesn't already
if (!/^https?:\/\//.test(value)) {
value = `https://${value}`;
}
// Remove trailing slashes
value = value.replace(/\/+$/, "");
return value;
}
+161 -26
View File
@@ -446,6 +446,19 @@ function ensureNumber(value) {
return Number.isFinite(parsed) ? parsed : 0;
}
function resolveStorage() {
if (typeof localStorage !== "undefined") {
return localStorage;
}
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage;
}
if (typeof globalThis !== "undefined" && globalThis.localStorage) {
return globalThis.localStorage;
}
return null;
}
export class ModerationService {
constructor({
nostrClient: client = null,
@@ -459,6 +472,7 @@ export class ModerationService {
this.userLogger = normalizeUserLogger(userChannel);
this.viewerPubkey = "";
this.viewerIsSessionActor = false;
this.trustedContacts = new Set();
this.trustedSeedContacts = new Set();
this.viewerContacts = new Set();
@@ -520,9 +534,11 @@ export class ModerationService {
}
if (this.trustedSeedContacts instanceof Set) {
const adminSnapshot = this.getAdminListSnapshot();
const blacklistHex = adminSnapshot.blacklistHex;
for (const seed of this.trustedSeedContacts) {
const normalized = normalizeHex(seed);
if (normalized) {
if (normalized && (!blacklistHex || !blacklistHex.has(normalized))) {
merged.add(normalized);
}
}
@@ -634,19 +650,23 @@ export class ModerationService {
}
setTrustedSeeds(seeds = []) {
this.log("[moderationService] setTrustedSeeds called", { count: seeds?.length || seeds?.size || 0 });
const sanitizedSeeds = new Set();
const adminSnapshot = this.getAdminListSnapshot();
if (seeds instanceof Set || Array.isArray(seeds)) {
for (const candidate of seeds) {
const normalized = normalizeToHex(candidate);
if (normalized) {
const status = this.getAccessControlStatus(candidate, adminSnapshot);
if (normalized && !status.blacklisted) {
sanitizedSeeds.add(normalized);
}
}
} else if (seeds && typeof seeds[Symbol.iterator] === "function") {
for (const candidate of seeds) {
const normalized = normalizeToHex(candidate);
if (normalized) {
const status = this.getAccessControlStatus(candidate, adminSnapshot);
if (normalized && !status.blacklisted) {
sanitizedSeeds.add(normalized);
}
}
@@ -812,12 +832,7 @@ export class ModerationService {
}
clearTrustedMuteTracking({ previousContacts = null } = {}) {
const previous =
previousContacts instanceof Set
? new Set(previousContacts)
: this.trustedContacts instanceof Set
? new Set(this.trustedContacts)
: new Set();
void previousContacts;
for (const [pubkey, entry] of this.trustedMuteSubscriptions.entries()) {
if (entry && typeof entry.unsub === "function") {
@@ -845,7 +860,8 @@ export class ModerationService {
this.emit("trusted-mutes", { total: 0 });
this.rebuildTrustedContacts(new Set(), { previous });
const emptyPrevious = new Set();
this.rebuildTrustedContacts(new Set(), { previous: emptyPrevious });
}
isTrustedMuteOwner(pubkey) {
@@ -863,6 +879,11 @@ export class ModerationService {
const previous = previousSet instanceof Set ? new Set(previousSet) : new Set();
const next = nextSet instanceof Set ? new Set(nextSet) : new Set();
this.log("[moderationService] reconcileTrustedMuteSubscriptions", {
previous: previous.size,
next: next.size,
});
if (this.viewerPubkey) {
if (this.trustedMuteSubscriptions.has(this.viewerPubkey)) {
previous.add(this.viewerPubkey);
@@ -877,14 +898,12 @@ export class ModerationService {
}
for (const value of next) {
if (!previous.has(value)) {
this.subscribeToTrustedMuteList(value).catch((error) => {
this.log(
`(moderationService) failed to subscribe to trusted mute list for ${value}`,
error,
);
});
}
this.subscribeToTrustedMuteList(value).catch((error) => {
this.log(
`(moderationService) failed to subscribe to trusted mute list for ${value}`,
error,
);
});
}
}
@@ -923,9 +942,12 @@ export class ModerationService {
}
return;
} else if (typeof record.unsub === "function") {
this.log(`[moderationService] already subscribed to ${normalized}`);
return;
}
this.log(`[moderationService] subscribing to trusted mute list for ${normalized}`);
record.promise = (async () => {
try {
await this.ensurePool();
@@ -943,6 +965,7 @@ export class ModerationService {
const relays = resolveRelayList(this.nostrClient);
if (!relays.length) {
this.log(`[moderationService] no relays available for ${normalized}`);
return;
}
@@ -987,6 +1010,7 @@ export class ModerationService {
});
sub.on("eose", () => {});
record.unsub = typeof sub.unsub === "function" ? () => sub.unsub() : null;
this.log(`[moderationService] subscribed to trusted mute list for ${normalized}`);
} catch (error) {
this.log(
`(moderationService) failed to subscribe to trusted mute list for ${normalized}`,
@@ -1149,6 +1173,7 @@ export class ModerationService {
return;
}
this.log(`[moderationService] ingesting trusted mute event from ${owner} (${event.id})`);
this.applyTrustedMuteEvent(owner, event);
}
@@ -1164,6 +1189,85 @@ export class ModerationService {
return this.viewerMuteList.has(normalized);
}
loadLocalMutes(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return;
}
const storage = resolveStorage();
if (!storage) {
return;
}
const key = `bitvid:user-mutes:local:v1:${normalized}`;
let loaded = [];
try {
const raw = storage.getItem(key);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
loaded = parsed;
}
}
} catch (error) {
this.log(
`[moderationService] failed to load local mutes for ${normalized}`,
error,
);
}
const mutes = new Set();
for (const item of loaded) {
const target = normalizeHex(item);
if (target && target !== normalized) {
mutes.add(target);
}
}
this.replaceTrustedMuteList(normalized, mutes, {
createdAt: Date.now() / 1000,
eventId: `local-${Date.now()}`,
});
}
saveLocalMutes(pubkey, muteSet) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return;
}
const storage = resolveStorage();
if (!storage) {
return;
}
const list = [];
if (muteSet instanceof Set || Array.isArray(muteSet)) {
for (const item of muteSet) {
const target = normalizeHex(item);
if (target && target !== normalized) {
list.push(target);
}
}
}
const key = `bitvid:user-mutes:local:v1:${normalized}`;
try {
if (!list.length) {
storage.removeItem(key);
} else {
storage.setItem(key, JSON.stringify(list));
}
} catch (error) {
this.log(
`[moderationService] failed to save local mutes for ${normalized}`,
error,
);
}
}
async ensureViewerMuteListLoaded(pubkey = this.viewerPubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
@@ -1177,6 +1281,11 @@ export class ModerationService {
return;
}
if (this.viewerIsSessionActor && normalized === this.viewerPubkey) {
this.loadLocalMutes(normalized);
return;
}
if (this.viewerMutePromise) {
try {
await this.viewerMutePromise;
@@ -1210,6 +1319,15 @@ export class ModerationService {
throw error;
}
if (this.viewerIsSessionActor && viewer === this.viewerPubkey) {
this.saveLocalMutes(viewer, muted);
this.replaceTrustedMuteList(viewer, muted, {
createdAt: Date.now() / 1000,
eventId: `local-${Date.now()}`,
});
return { ok: true, event: null, results: [] };
}
await this.ensurePool();
const extension = typeof window !== "undefined" ? window.nostr : null;
@@ -1516,7 +1634,9 @@ export class ModerationService {
}
async refreshViewerFromClient() {
const clientPubkey = normalizeHex(this.nostrClient?.pubkey);
const clientPubkey = normalizeHex(
this.nostrClient?.pubkey || this.nostrClient?.sessionActor?.pubkey,
);
if (clientPubkey === this.viewerPubkey) {
if (this.contactListPromise) {
try {
@@ -1546,6 +1666,13 @@ export class ModerationService {
this.trustedContacts instanceof Set ? new Set(this.trustedContacts) : new Set();
this.viewerPubkey = normalized;
const loggedInPubkey = normalizeHex(this.nostrClient?.pubkey);
const sessionPubkey = normalizeHex(this.nostrClient?.sessionActor?.pubkey);
this.viewerIsSessionActor =
!!normalized && !loggedInPubkey && normalized === sessionPubkey;
this.clearTrustedMuteTracking({ previousContacts });
if (this.contactSubscription && typeof this.contactSubscription.unsub === "function") {
@@ -2116,7 +2243,10 @@ export class ModerationService {
throw error;
}
const reporterPubkey = normalizeHex(this.nostrClient?.pubkey);
const reporterPubkey = normalizeHex(
this.nostrClient?.pubkey || this.nostrClient?.sessionActor?.pubkey,
);
if (!reporterPubkey) {
const error = new Error("viewer-not-logged-in");
error.code = "viewer-not-logged-in";
@@ -2130,14 +2260,19 @@ export class ModerationService {
throw error;
}
const extension = typeof window !== "undefined" ? window.nostr : null;
if (!extension) {
const error = new Error("nostr-extension-missing");
error.code = "nostr-extension-missing";
const signer = await this.nostrClient.ensureActiveSignerForPubkey(
reporterPubkey,
);
if (!signer || typeof signer.signEvent !== "function") {
const error = new Error("signer-unavailable");
error.code = "signer-unavailable";
throw error;
}
if (typeof this.nostrClient?.ensureExtensionPermissions === "function") {
if (
signer.type === "extension" &&
typeof this.nostrClient?.ensureExtensionPermissions === "function"
) {
const permissionResult = await this.nostrClient.ensureExtensionPermissions([
"sign_event",
"get_public_key",
@@ -2174,7 +2309,7 @@ export class ModerationService {
let signedEvent;
try {
signedEvent = await extension.signEvent(event);
signedEvent = await signer.signEvent(event);
} catch (error) {
const wrapped = new Error("signature-failed");
wrapped.code = "signature-failed";
+40 -21
View File
@@ -157,6 +157,17 @@ function extractSnapshotPreview(message) {
return sanitizeSnapshotPreview(selected || "");
}
function getActiveKey(video) {
if (video.videoRootId) {
return `ROOT:${video.videoRootId}`;
}
const dTag = video.tags?.find((t) => Array.isArray(t) && t[0] === "d");
if (dTag && typeof dTag[1] === "string") {
return `${video.pubkey}:${dTag[1]}`;
}
return `LEGACY:${video.id}`;
}
function resolveDirectMessageRemotePubkey(message, actorPubkey = "") {
const normalizedActor = normalizeHexPubkey(actorPubkey);
@@ -1165,10 +1176,11 @@ export class NostrService {
return [];
}
this.log(`[nostrService] fetchVideosByAuthors START. Authors: ${authorList.length}`);
const requestedLimit = Number(options?.limit);
const resolvedLimit = this.nostrClient.clampVideoRequestLimit(
requestedLimit,
DEFAULT_VIDEO_REQUEST_LIMIT
requestedLimit
);
const filter = {
@@ -1183,31 +1195,38 @@ export class NostrService {
const invalidNotes = [];
try {
this.log(`[nostrService] Querying ${this.nostrClient.relays.length} relays...`);
await Promise.all(
this.nostrClient.relays.map(async (url) => {
const events = await this.nostrClient.pool.list([url], [filter]);
for (const evt of events) {
if (evt && evt.id) {
this.nostrClient.rawEvents.set(evt.id, evt);
}
const vid = convertEventToVideo(evt);
if (vid.invalid) {
invalidNotes.push({ id: vid.id, reason: vid.reason });
} else {
if (
this.nostrClient &&
typeof this.nostrClient.applyRootCreatedAt === "function"
) {
this.nostrClient.applyRootCreatedAt(vid);
try {
this.log(`[nostrService] Querying relay: ${url}`);
const events = await this.nostrClient.pool.list([url], [filter]);
this.log(`[nostrService] Relay ${url} returned ${events.length} events.`);
for (const evt of events) {
if (evt && evt.id) {
this.nostrClient.rawEvents.set(evt.id, evt);
}
const activeKey = getActiveKey(vid);
if (vid.deleted) {
this.recordTombstone(activeKey, vid.created_at);
const vid = convertEventToVideo(evt);
if (vid.invalid) {
invalidNotes.push({ id: vid.id, reason: vid.reason });
} else {
this.applyTombstoneGuard(vid);
if (
this.nostrClient &&
typeof this.nostrClient.applyRootCreatedAt === "function"
) {
this.nostrClient.applyRootCreatedAt(vid);
}
const activeKey = getActiveKey(vid);
if (vid.deleted) {
this.nostrClient.recordTombstone(activeKey, vid.created_at);
} else {
this.nostrClient.applyTombstoneGuard(vid);
}
localAll.set(evt.id, vid);
}
localAll.set(evt.id, vid);
}
} catch (relayErr) {
this.log(`[nostrService] Relay ${url} failed:`, relayErr);
}
})
);
+149 -304
View File
@@ -19,6 +19,8 @@ import {
makeR2Client,
multipartUpload,
ensureBucketCors,
ensureBucketExists,
deleteObject,
} from "../storage/r2-s3.js";
import { truncateMiddle } from "../utils/formatters.js";
import { userLogger } from "../utils/logger.js";
@@ -35,9 +37,7 @@ function createDefaultSettings() {
accountId: "",
accessKeyId: "",
secretAccessKey: "",
apiToken: "",
zoneId: "",
baseDomain: "",
baseDomain: "", // Now interpreted as the public URL base
buckets: {},
};
}
@@ -46,7 +46,6 @@ class R2Service {
constructor() {
this.listeners = new Map();
this.cloudflareSettings = null;
this.cloudflareAdvancedVisible = false;
}
on(event, handler) {
@@ -89,19 +88,6 @@ class R2Service {
this.emit("settingsChanged", { settings: this.getSettings() });
}
getCloudflareAdvancedVisibility() {
return Boolean(this.cloudflareAdvancedVisible);
}
setCloudflareAdvancedVisibility(visible) {
const isVisible = Boolean(visible);
if (this.cloudflareAdvancedVisible === isVisible) {
return;
}
this.cloudflareAdvancedVisible = isVisible;
this.emit("advancedVisibilityChange", { visible: isVisible });
}
normalizeStatusVariant(variant) {
if (STATUS_VARIANTS.has(variant)) {
return variant;
@@ -203,23 +189,9 @@ class R2Service {
accountId: settings?.accountId || "",
accessKeyId: settings?.accessKeyId || "",
secretAccessKey: settings?.secretAccessKey || "",
apiToken: settings?.apiToken || "",
zoneId: settings?.zoneId || "",
baseDomain: settings?.baseDomain || "",
};
const hasAdvancedValues = Boolean(
(data.apiToken && data.apiToken.length > 0) ||
(data.zoneId && data.zoneId.length > 0) ||
(data.baseDomain && data.baseDomain.length > 0)
);
if (hasAdvancedValues) {
this.setCloudflareAdvancedVisibility(true);
} else if (!this.cloudflareAdvancedVisible) {
this.setCloudflareAdvancedVisibility(false);
}
this.setCloudflareSettingsStatus("");
this.emit("settingsPopulated", { settings: data });
}
@@ -246,9 +218,7 @@ class R2Service {
const accountId = String(formValues.accountId || "").trim();
const accessKeyId = String(formValues.accessKeyId || "").trim();
const secretAccessKey = String(formValues.secretAccessKey || "").trim();
const apiToken = String(formValues.apiToken || "").trim();
const zoneId = String(formValues.zoneId || "").trim();
const baseDomain = sanitizeBaseDomain(formValues.baseDomain || "");
const baseDomain = sanitizeBaseDomain(formValues.baseDomain || ""); // This is the Public Bucket URL
if (!accountId || !accessKeyId || !secretAccessKey) {
if (!quiet) {
@@ -260,14 +230,23 @@ class R2Service {
return false;
}
if (!baseDomain) {
if (!quiet) {
this.setCloudflareSettingsStatus(
"Public Bucket URL is required (e.g., https://pub-xxx.r2.dev).",
"error"
);
}
return false;
}
let buckets = { ...(this.getSettings().buckets || {}) };
const previousAccount = this.getSettings().accountId || "";
const previousBaseDomain = this.getSettings().baseDomain || "";
const previousZoneId = this.getSettings().zoneId || "";
if (
previousAccount !== accountId ||
previousBaseDomain !== baseDomain ||
previousZoneId !== zoneId
previousBaseDomain !== baseDomain
) {
buckets = {};
}
@@ -276,8 +255,6 @@ class R2Service {
accountId,
accessKeyId,
secretAccessKey,
apiToken,
zoneId,
baseDomain,
buckets,
};
@@ -312,7 +289,6 @@ class R2Service {
const refreshed = await loadR2Settings();
this.setSettings(refreshed);
this.populateCloudflareSettingsInputs(refreshed);
this.setCloudflareAdvancedVisibility(false);
this.setCloudflareSettingsStatus("Settings cleared.", "success");
return true;
} catch (err) {
@@ -340,297 +316,171 @@ class R2Service {
return Array.from(origins);
}
deriveSubdomainForNpub(npub) {
try {
return deriveShortSubdomain(npub);
} catch (err) {
userLogger.warn("Failed to derive short subdomain, falling back:", err);
}
const base = String(npub || "user")
.toLowerCase()
.replace(/[^a-z0-9-]/g, "")
.replace(/^-+|[-]+$/g, "");
return base.slice(0, 32) || "user";
}
async ensureBucketConfigForNpub(npub) {
if (!npub || !this.cloudflareSettings) {
return null;
}
const accountId = (this.cloudflareSettings.accountId || "").trim();
const apiToken = (this.cloudflareSettings.apiToken || "").trim();
const zoneId = (this.cloudflareSettings.zoneId || "").trim();
const accessKeyId = (this.cloudflareSettings.accessKeyId || "").trim();
const secretAccessKey = (this.cloudflareSettings.secretAccessKey || "").trim();
const corsOrigins = this.getCorsOrigins();
const baseDomain = this.cloudflareSettings.baseDomain || "";
const corsOrigins = this.getCorsOrigins();
if (!accountId) {
throw new Error("Cloudflare account ID is missing.");
}
let entry = this.cloudflareSettings.buckets?.[npub] || null;
if (entry && entry.publicBaseUrl) {
if (apiToken) {
try {
await ensureBucket({
accountId,
bucket: entry.bucket,
token: apiToken,
});
await putCors({
accountId,
bucket: entry.bucket,
token: apiToken,
origins: corsOrigins,
});
} catch (err) {
userLogger.warn("Failed to refresh bucket configuration:", err);
}
} else if (accessKeyId && secretAccessKey && corsOrigins.length > 0) {
try {
const s3 = makeR2Client({
accountId,
accessKeyId,
secretAccessKey,
});
await ensureBucketCors({
s3,
bucket: entry.bucket,
origins: corsOrigins,
});
} catch (err) {
userLogger.warn("Failed to refresh bucket CORS via access keys:", err);
}
}
return {
entry,
usedManagedFallback: entry.domainType !== "custom",
customDomainStatus: entry.domainType === "custom" ? "active" : "skipped",
};
if (!baseDomain) {
throw new Error("Public Bucket URL is missing.");
}
if (!apiToken) {
const bucketName = entry?.bucket || sanitizeBucketName(npub);
const manualCustomDomain = baseDomain
? `https://${this.deriveSubdomainForNpub(npub)}.${baseDomain}`
: "";
// We no longer support automated bucket creation or domain management via API token.
// We assume the user has created the bucket with the correct name and configured the public domain.
const bucketName = sanitizeBucketName(npub);
let publicBaseUrl = entry?.publicBaseUrl || manualCustomDomain;
if (!publicBaseUrl) {
publicBaseUrl = `https://${bucketName}.${accountId}.r2.dev`;
}
if (!publicBaseUrl) {
throw new Error(
"No public bucket domain configured. Please add a Cloudflare API token to automate domain setup."
);
}
const manualEntry = {
bucket: bucketName,
publicBaseUrl,
domainType: publicBaseUrl.includes(".r2.dev") ? "managed" : "custom",
lastUpdated: Date.now(),
};
if (accessKeyId && secretAccessKey && corsOrigins.length > 0) {
// We attempt to ensure the bucket exists and CORS is set up using the S3 keys if possible.
if (accessKeyId && secretAccessKey) {
try {
const s3 = makeR2Client({
accountId,
accessKeyId,
secretAccessKey,
});
await ensureBucketCors({
s3,
bucket: bucketName,
origins: corsOrigins,
});
// Attempt to auto-create the bucket (requires Admin keys, but harmless if fails)
try {
await ensureBucketExists({ s3, bucket: bucketName });
} catch (createErr) {
// 403 Forbidden is expected if keys are "Object Read & Write" only.
// We proceed assuming the user might have created it manually.
userLogger.debug("Auto-creation of bucket failed (likely permission issue), proceeding...", createErr);
}
if (corsOrigins.length > 0) {
await ensureBucketCors({
s3,
bucket: bucketName,
origins: corsOrigins,
});
}
} catch (corsErr) {
userLogger.warn(
"Failed to ensure R2 CORS rules via access keys. Configure the bucket's CORS policy manually if uploads continue to fail.",
"Failed to ensure R2 bucket/CORS configuration via access keys. Ensure the bucket exists and you have permissions.",
corsErr
);
}
}
}
let savedEntry = entry;
if (
// Use the user-provided baseDomain as the public URL root.
// Clean up trailing slash if present.
const cleanBaseUrl = baseDomain.replace(/\/+$/, "");
const manualEntry = {
bucket: bucketName,
publicBaseUrl: cleanBaseUrl,
domainType: "manual",
lastUpdated: Date.now(),
};
let entry = this.cloudflareSettings.buckets?.[npub];
let savedEntry = entry;
if (
!entry ||
entry.bucket !== manualEntry.bucket ||
entry.publicBaseUrl !== manualEntry.publicBaseUrl ||
entry.domainType !== manualEntry.domainType
) {
entry.publicBaseUrl !== manualEntry.publicBaseUrl
) {
const updatedSettings = await saveR2Settings(
mergeBucketEntry(this.getSettings(), npub, manualEntry)
);
this.setSettings(updatedSettings);
savedEntry = updatedSettings.buckets?.[npub] || manualEntry;
}
return {
entry: savedEntry,
usedManagedFallback: manualEntry.domainType !== "custom",
customDomainStatus:
manualEntry.domainType === "custom" ? "manual" : "managed",
};
}
const bucketName = entry?.bucket || sanitizeBucketName(npub);
return {
entry: savedEntry,
usedManagedFallback: false,
customDomainStatus: "manual",
};
}
await ensureBucket({ accountId, bucket: bucketName, token: apiToken });
async verifyPublicAccess({ settings, npub }) {
if (!settings || !npub) {
return { success: false, error: "Missing settings or npub." };
}
const { accountId, accessKeyId, secretAccessKey, baseDomain } = settings;
if (!accountId || !accessKeyId || !secretAccessKey || !baseDomain) {
return { success: false, error: "Incomplete credentials or missing public URL." };
}
const bucketName = sanitizeBucketName(npub);
const verifyKey = `.verify-${Date.now()}-${Math.random().toString(36).substring(7)}.txt`;
const verifyContent = "bitvid-verification";
const publicUrl = buildPublicUrl(baseDomain, verifyKey);
try {
await putCors({
accountId,
bucket: bucketName,
token: apiToken,
origins: corsOrigins,
});
} catch (err) {
userLogger.warn("Failed to apply R2 CORS rules:", err);
}
// 1. Initialize S3
const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey });
let publicBaseUrl = entry?.publicBaseUrl || "";
let domainType = entry?.domainType || "managed";
let usedManagedFallback = false;
let customDomainStatus = "skipped";
if (baseDomain && zoneId) {
const domain = `${this.deriveSubdomainForNpub(npub)}.${baseDomain}`;
// 2. Ensure bucket/cors (best effort)
try {
const custom = await attachCustomDomainAndWait({
accountId,
bucket: bucketName,
token: apiToken,
zoneId,
domain,
pollInterval: 2500,
timeoutMs: 120000,
});
customDomainStatus = custom?.status || "unknown";
if (custom?.active && custom?.url) {
publicBaseUrl = custom.url;
domainType = "custom";
try {
await setManagedDomain({
accountId,
bucket: bucketName,
token: apiToken,
enabled: false,
});
} catch (disableErr) {
userLogger.warn("Failed to disable managed domain:", disableErr);
}
} else {
usedManagedFallback = true;
}
} catch (err) {
if (/already exists/i.test(err.message || "")) {
publicBaseUrl = `https://${domain}`;
domainType = "custom";
customDomainStatus = "active";
try {
await setManagedDomain({
accountId,
bucket: bucketName,
token: apiToken,
enabled: false,
});
} catch (disableErr) {
userLogger.warn("Failed to disable managed domain:", disableErr);
}
} else {
userLogger.warn("Failed to attach custom domain, falling back:", err);
usedManagedFallback = true;
customDomainStatus = "error";
await ensureBucketExists({ s3, bucket: bucketName });
const corsOrigins = this.getCorsOrigins();
if (corsOrigins.length > 0) {
await ensureBucketCors({ s3, bucket: bucketName, origins: corsOrigins });
}
} catch (setupErr) {
userLogger.warn("Bucket setup warning during verification:", setupErr);
// Continue, assuming bucket might already exist and be configured
}
}
if (!publicBaseUrl) {
const managed = await setManagedDomain({
accountId,
// 3. Upload Test File
const file = new File([verifyContent], "verify.txt", { type: "text/plain" });
await multipartUpload({
s3,
bucket: bucketName,
token: apiToken,
enabled: true,
key: verifyKey,
file,
contentType: "text/plain",
});
publicBaseUrl = managed?.url || `https://${bucketName}.${accountId}.r2.dev`;
domainType = "managed";
usedManagedFallback = true;
customDomainStatus =
customDomainStatus === "skipped" ? "managed" : customDomainStatus;
}
const mergedEntry = {
bucket: bucketName,
publicBaseUrl,
domainType,
lastUpdated: Date.now(),
};
const updatedSettings = await saveR2Settings(
mergeBucketEntry(this.getSettings(), npub, mergedEntry)
);
this.setSettings(updatedSettings);
return { entry: mergedEntry, usedManagedFallback, customDomainStatus };
// 4. Verify Public Access (Fetch)
// Wait a moment for propagation (R2 is usually instant-ish but helpful to wait)
await new Promise((r) => setTimeout(r, 500));
const response = await fetch(publicUrl, { method: "GET", cache: "no-cache" });
if (!response.ok) {
// Cleanup attempt
try { await deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {}
if (response.status === 404) {
return { success: false, error: "File not found. Check your Public Bucket URL." };
}
return { success: false, error: `Public access failed (HTTP ${response.status}). Is the bucket public?` };
}
const text = await response.text();
// Cleanup
try { await deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {}
if (text.trim() !== verifyContent) {
return { success: false, error: "Content mismatch. URL might be pointing elsewhere." };
}
return { success: true };
} catch (err) {
userLogger.error("Verification failed:", err);
return { success: false, error: err.message || "Unknown error during verification." };
}
}
async updateCloudflareBucketPreview({ hasPubkey = false, npub = "" } = {}) {
if (!this.cloudflareSettings) {
const detail = {
text: "Save your credentials to configure R2.",
title: "",
};
this.emit("bucketPreview", detail);
return detail;
}
if (!hasPubkey) {
const detail = { text: "Login to preview your R2 bucket.", title: "" };
this.emit("bucketPreview", detail);
return detail;
}
if (!npub) {
const detail = { text: "Unable to encode npub.", title: "" };
this.emit("bucketPreview", detail);
return detail;
}
const entry = this.cloudflareSettings.buckets?.[npub];
if (!entry || !entry.publicBaseUrl) {
const detail = {
text: "Bucket will be auto-created on your next upload.",
title: "",
};
this.emit("bucketPreview", detail);
return detail;
}
const sampleKey = buildR2Key(npub, { name: "sample.mp4" });
const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey);
const fullPreview = `${entry.bucket} • ${publicUrl}`;
let displayHostAndPath = truncateMiddle(publicUrl, 72);
try {
const parsed = new URL(publicUrl);
const cleanPath = parsed.pathname.replace(/^\//, "");
const truncatedPath = truncateMiddle(cleanPath || sampleKey, 32);
displayHostAndPath = `${truncateMiddle(parsed.host, 32)}/${truncatedPath}`;
} catch (err) {
// ignore URL parse issues and fall back to the raw string
}
const truncatedBucket = truncateMiddle(entry.bucket, 28);
const detail = {
text: `${truncatedBucket} • ${displayHostAndPath}`,
title: fullPreview,
};
this.emit("bucketPreview", detail);
return detail;
// No-op for now or just simplified text, since we rely on user input mostly.
return;
}
async handleCloudflareUploadSubmit({
@@ -638,6 +488,7 @@ class R2Service {
file = null,
thumbnailFile = null,
metadata = {},
infoHash = "",
settingsInput = null,
publishVideoNote,
onReset,
@@ -717,7 +568,7 @@ class R2Service {
if (!bucketEntry || !bucketEntry.publicBaseUrl) {
this.setCloudflareUploadStatus(
"Bucket is missing a public domain. Check your Cloudflare settings.",
"Bucket is missing a public URL. Check your settings.",
"error"
);
this.setCloudflareUploading(false);
@@ -726,22 +577,7 @@ class R2Service {
}
let statusMessage = `Uploading to ${bucketEntry.bucket}…`;
if (bucketResult?.usedManagedFallback) {
const baseDomain = this.cloudflareSettings?.baseDomain || "";
if (baseDomain) {
const customStatus = bucketResult?.customDomainStatus
? ` (custom domain status: ${bucketResult.customDomainStatus})`
: "";
statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone${customStatus}. Uploading…`;
} else {
statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Uploading…`;
}
}
this.setCloudflareUploadStatus(
statusMessage,
bucketResult?.usedManagedFallback ? "warning" : "info"
);
this.setCloudflareUploadStatus(statusMessage, "info");
const key = buildR2Key(npub, file);
const publicUrl = buildPublicUrl(bucketEntry.publicBaseUrl, key);
@@ -771,10 +607,7 @@ class R2Service {
}
}
this.setCloudflareUploadStatus(
statusMessage,
bucketResult?.usedManagedFallback ? "warning" : "info"
);
this.setCloudflareUploadStatus(statusMessage, "info");
await multipartUpload({
s3,
@@ -795,13 +628,25 @@ class R2Service {
);
publishOutcome = false;
} else {
// Construct the Magnet Link
// magnet:?xt=urn:btih:<infoHash>&dn=<filename>&ws=<publicUrl>
let generatedMagnet = "";
let generatedWs = "";
if (infoHash) {
const encodedDn = encodeURIComponent(file.name);
const encodedWs = encodeURIComponent(publicUrl);
generatedMagnet = `magnet:?xt=urn:btih:${infoHash}&dn=${encodedDn}&ws=${encodedWs}`;
generatedWs = publicUrl;
}
const rawVideoPayload = {
title,
url: publicUrl,
magnet: metadata?.magnet ?? "",
url: publicUrl, // Primary URL
magnet: generatedMagnet || (metadata?.magnet ?? ""),
thumbnail: metadata?.thumbnail ?? "",
description: metadata?.description ?? "",
ws: metadata?.ws ?? "",
ws: generatedWs || (metadata?.ws ?? ""),
xs: metadata?.xs ?? "",
enableComments: metadata?.enableComments,
isNsfw: metadata?.isNsfw,
+8
View File
@@ -92,6 +92,7 @@ async function waitForAccessControl({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS }
}
export async function bootstrapTrustedSeeds() {
devLogger.info("[trustBootstrap] bootstrapTrustedSeeds called");
if (
!(
FEATURE_TRUST_SEEDS &&
@@ -99,6 +100,7 @@ export async function bootstrapTrustedSeeds() {
typeof moderationService.setTrustedSeeds === "function"
)
) {
devLogger.warn("[trustBootstrap] Trust seeds feature disabled or service missing");
return;
}
@@ -113,6 +115,7 @@ export async function bootstrapTrustedSeeds() {
editors,
fallbackSeeds: DEFAULT_TRUST_SEED_NPUBS,
});
devLogger.info(`[trustBootstrap] Applying ${seeds.size} trusted seeds`, Array.from(seeds));
moderationService.setTrustedSeeds(seeds);
if (
moderationService &&
@@ -130,6 +133,7 @@ export async function bootstrapTrustedSeeds() {
attempts += 1;
try {
const result = await waitForAccessControl();
devLogger.info("[trustBootstrap] waitForAccessControl result", result);
applySeeds();
return result;
} catch (error) {
@@ -178,4 +182,8 @@ export async function bootstrapTrustedSeeds() {
if (accessControl && typeof accessControl.onEditorsChange === "function") {
accessControl.onEditorsChange(applyOnChange);
}
if (accessControl && typeof accessControl.onBlacklistChange === "function") {
accessControl.onBlacklistChange(applyOnChange);
}
}
+26 -1
View File
@@ -25,6 +25,7 @@ function assignAwsSdk(module) {
AbortMultipartUploadCommand: module.AbortMultipartUploadCommand,
HeadBucketCommand: module.HeadBucketCommand,
PutBucketCorsCommand: module.PutBucketCorsCommand,
DeleteObjectCommand: module.DeleteObjectCommand,
};
}
@@ -129,7 +130,7 @@ export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) {
});
}
async function ensureBucketExists({ s3, bucket }) {
export async function ensureBucketExists({ s3, bucket }) {
const { CreateBucketCommand, HeadBucketCommand } = requireAwsSdk();
try {
@@ -199,6 +200,30 @@ export async function ensureBucketCors({ s3, bucket, origins }) {
await s3.send(command);
}
export async function deleteObject({ s3, bucket, key }) {
if (!s3 || !bucket || !key) {
throw new Error("Missing required parameters for deleteObject");
}
const { DeleteObjectCommand } = requireAwsSdk();
try {
await s3.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: key,
})
);
} catch (error) {
// We treat 404 (NoSuchKey) as success since the object is gone.
const status = error?.$metadata?.httpStatusCode || 0;
const code = error?.name || error?.Code || "";
if (status !== 404 && code !== "NoSuchKey") {
throw error;
}
}
}
export async function multipartUpload({
s3,
bucket,
+11 -1
View File
@@ -398,17 +398,20 @@ class SubscriptionsManager {
}
async ensureLoaded(actorHex) {
devLogger.log("[SubscriptionsManager] ensureLoaded start", actorHex);
const normalizedActor = normalizeHexPubkey(actorHex) || actorHex;
if (!normalizedActor) {
return;
}
if (this.loaded && this.currentUserPubkey === normalizedActor) {
devLogger.log("[SubscriptionsManager] ensureLoaded already loaded");
return;
}
if (this.loadingPromise) {
try {
devLogger.log("[SubscriptionsManager] ensureLoaded awaiting existing promise");
await this.loadingPromise;
} catch (error) {
throw error;
@@ -431,6 +434,7 @@ class SubscriptionsManager {
)
),
]);
devLogger.log("[SubscriptionsManager] ensureLoaded success");
} catch (error) {
userLogger.warn("[SubscriptionsManager] ensureLoaded timed out or failed:", error);
} finally {
@@ -856,7 +860,9 @@ class SubscriptionsManager {
return this.lastResult;
}
container.innerHTML = getSidebarLoadingMarkup("Fetching subscriptions…");
if (!this.hasRenderedOnce) {
container.innerHTML = getSidebarLoadingMarkup("Fetching subscriptions…");
}
this.lastRunOptions = {
actorPubkey: userPubkey,
@@ -870,7 +876,9 @@ class SubscriptionsManager {
if (typeof nostrService?.awaitInitialLoad === "function") {
try {
devLogger.log("[SubscriptionsManager] awaiting nostrService initial load...");
await nostrService.awaitInitialLoad();
devLogger.log("[SubscriptionsManager] nostrService initial load done.");
} catch (error) {
devLogger.warn(
"[SubscriptionsManager] Failed to await nostrService initial load:",
@@ -906,7 +914,9 @@ class SubscriptionsManager {
try {
this.isRunningFeed = true;
devLogger.log("[SubscriptionsManager] Calling engine.run('subscriptions')...");
const result = await engine.run("subscriptions", runOptions);
devLogger.log("[SubscriptionsManager] engine.run complete. Items:", result?.items?.length);
const videos = Array.isArray(result?.items)
? result.items.map((item) => item?.video).filter(Boolean)
+48 -5
View File
@@ -438,9 +438,38 @@ export class EditModal {
this.applyVideoToForm(editContext);
this.nip71FormManager.resetSection(this.nip71SectionKey);
if (video.nip71 && typeof video.nip71 === "object") {
this.nip71FormManager.hydrateSection(this.nip71SectionKey, video.nip71);
let nip71Data = video.nip71 && typeof video.nip71 === "object" ? { ...video.nip71 } : {};
// Backfill published_at if missing, using the original root creation time
if (
!nip71Data.publishedAt &&
!nip71Data.published_at &&
!nip71Data["published-at"]
) {
const rootCreated = video.rootCreatedAt || video.created_at;
if (rootCreated) {
nip71Data.publishedAt = rootCreated;
}
}
// Format publishedAt for datetime-local input (YYYY-MM-DDThh:mm)
const rawPublishedAt =
nip71Data.publishedAt || nip71Data.published_at || nip71Data["published-at"];
if (rawPublishedAt) {
const numeric = Number(rawPublishedAt);
if (Number.isFinite(numeric)) {
const dt = new Date(numeric * 1000);
const pad = (n) => String(n).padStart(2, "0");
const formatted = `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(
dt.getDate()
)}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
nip71Data.publishedAt = formatted;
}
}
this.nip71FormManager.hydrateSection(this.nip71SectionKey, nip71Data);
const initialNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
this.originalNip71Metadata = this.cloneNip71Metadata(initialNip71);
this.originalNip71MetadataJson = JSON.stringify(this.originalNip71Metadata);
@@ -926,9 +955,23 @@ export class EditModal {
const originalIsNsfw = original.isNsfw === true;
const originalIsForKids = original.isForKids === true;
const currentNip71 = this.cloneNip71Metadata(
this.nip71FormManager.collectSection(this.nip71SectionKey)
);
const rawNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
const currentNip71 = this.cloneNip71Metadata(rawNip71);
// Convert publishedAt back to Unix timestamp (seconds)
if (currentNip71 && typeof currentNip71.publishedAt === "string") {
const dateStr = currentNip71.publishedAt;
if (dateStr) {
const dt = new Date(dateStr);
if (!isNaN(dt.getTime())) {
currentNip71.publishedAt = Math.floor(dt.getTime() / 1000);
} else {
delete currentNip71.publishedAt;
}
} else {
delete currentNip71.publishedAt;
}
}
const hasImetaSource = Array.isArray(currentNip71?.imeta)
? currentNip71.imeta.some((variant) => {
if (!variant || typeof variant !== "object") {
+150 -19
View File
@@ -2,6 +2,7 @@ import { normalizeDesignSystemContext } from "../../designSystem.js";
import { formatShortNpub } from "../../utils/formatters.js";
import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg";
export class SimilarContentCard {
@@ -66,6 +67,7 @@ export class SimilarContentCard {
this.authorNpubEl = null;
this.timeEl = null;
this.viewCountEl = null;
this.discussionCountEl = null;
this.build();
}
@@ -82,6 +84,10 @@ export class SimilarContentCard {
return this.viewCountEl;
}
getDiscussionCountElement() {
return this.discussionCountEl;
}
closeMoreMenu() {}
closeSettingsMenu() {}
@@ -469,9 +475,9 @@ export class SimilarContentCard {
titleLink.title = this.video.title || "Untitled";
titleLink.style.minWidth = "0";
// Modified order: Title -> Author -> Meta
const authorStack = this.buildAuthorStack();
const metaRow = this.buildMetaRow();
const engagement = this.buildEngagementSection();
content.appendChild(titleLink);
if (authorStack) {
@@ -480,6 +486,9 @@ export class SimilarContentCard {
if (metaRow) {
content.appendChild(metaRow);
}
if (engagement) {
content.appendChild(engagement);
}
this.contentEl = content;
this.titleEl = titleLink;
@@ -556,24 +565,6 @@ export class SimilarContentCard {
row.classList.add("player-modal__similar-card-meta");
row.style.minWidth = "0";
// Reordered: Views -> Separator -> Time
const viewEl = this.document.createElement("span");
viewEl.classList.add("player-modal__similar-card-views", "view-count-text");
viewEl.dataset.viewCount = "";
viewEl.textContent = "– views";
if (this.pointerInfo?.key) {
viewEl.dataset.viewPointer = this.pointerInfo.key;
}
row.appendChild(viewEl);
this.viewCountEl = viewEl;
const separator = this.document.createElement("span");
separator.classList.add("player-modal__similar-card-separator");
separator.setAttribute("aria-hidden", "true");
separator.textContent = "•";
row.appendChild(separator);
const timeEl = this.document.createElement("time");
timeEl.classList.add("player-modal__similar-card-timestamp");
if (this.postedAt !== null) {
@@ -592,6 +583,146 @@ export class SimilarContentCard {
return row;
}
createEyeIcon(classNames = []) {
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
classNames.forEach((className) => {
if (className) {
svg.classList.add(className);
}
});
const path = this.document.createElementNS(SVG_NAMESPACE, "path");
path.setAttribute("d", "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z");
svg.appendChild(path);
const circle = this.document.createElementNS(SVG_NAMESPACE, "circle");
circle.setAttribute("cx", "12");
circle.setAttribute("cy", "12");
circle.setAttribute("r", "3");
svg.appendChild(circle);
return svg;
}
createMessageIcon(classNames = []) {
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
classNames.forEach((className) => {
if (className) {
svg.classList.add(className);
}
});
const path = this.document.createElementNS(SVG_NAMESPACE, "path");
path.setAttribute(
"d",
"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
);
svg.appendChild(path);
return svg;
}
buildEngagementSection() {
const hasPointer = this.pointerInfo && this.pointerInfo.key;
const hasDiscussion =
this.video.enableComments !== false &&
(typeof this.video.discussionCount === "number" ||
(typeof this.video.discussionCount === "string" &&
this.video.discussionCount.trim()));
if (!hasPointer && !hasDiscussion) {
return null;
}
const container = this.document.createElement("div");
container.classList.add(
"flex",
"items-center",
"gap-4",
"text-xs",
"text-muted-strong",
"mt-1.5",
"video-card__engagement" // Reuse this class for consistent styling if global, otherwise it's just a marker
);
// Add specific class for scoping if needed, but Tailwind classes usually suffice.
// Views
if (hasPointer) {
const wrapper = this.document.createElement("div");
wrapper.classList.add("flex", "items-center", "gap-1.5");
wrapper.setAttribute("title", "Views");
const icon = this.createEyeIcon(["w-3.5", "h-3.5"]);
wrapper.appendChild(icon);
const view = this.document.createElement("span");
view.classList.add("view-count-text");
view.textContent = "–";
view.dataset.viewCount = "";
view.dataset.viewPointer = this.pointerInfo.key;
wrapper.appendChild(view);
container.appendChild(wrapper);
this.viewCountEl = view;
}
// Discussion
if (hasDiscussion) {
let initialCount = 0;
if (typeof this.video.discussionCount === "number") {
initialCount = this.video.discussionCount;
} else if (typeof this.video.discussionCount === "string") {
const parsed = Number.parseInt(this.video.discussionCount.trim(), 10);
if (Number.isFinite(parsed)) {
initialCount = parsed;
}
}
if (Number.isFinite(initialCount) && initialCount >= 0) {
const wrapper = this.document.createElement("div");
wrapper.classList.add("flex", "items-center", "gap-1.5");
wrapper.dataset.discussionCount = this.video.id;
wrapper.dataset.countState = "ready";
wrapper.setAttribute("title", "Comments");
const icon = this.createMessageIcon(["w-3.5", "h-3.5"]);
wrapper.appendChild(icon);
const displayValue = initialCount.toLocaleString();
const valueEl = this.document.createElement("span");
valueEl.textContent = displayValue;
valueEl.dataset.discussionCountValue = "";
wrapper.appendChild(valueEl);
container.appendChild(wrapper);
this.discussionCountEl = wrapper;
}
}
return container;
}
applyPointerDatasets() {
if (!this.root || !this.pointerInfo) {
return;
+169 -28
View File
@@ -7,6 +7,8 @@ import {
normalizeVideoNotePayload,
VIDEO_NOTE_ERROR_CODES,
} from "../../services/videoNotePayload.js";
import { calculateTorrentInfoHash } from "../../utils/torrentHash.js";
import { sanitizeBucketName } from "../../storage/r2-mgmt.js";
export class UploadModal {
constructor({
@@ -137,7 +139,6 @@ export class UploadModal {
external: $("#section-source-external"),
settings: $("#section-storage-settings"),
advanced: $("#section-advanced"),
r2Advanced: $("#section-r2-advanced"),
progress: $("#upload-progress-container"),
};
@@ -152,12 +153,11 @@ export class UploadModal {
magnet: $("#input-magnet"),
// Settings
r2BucketName: $("#input-r2-bucket-name"),
r2Account: $("#input-r2-account"),
r2Key: $("#input-r2-key"),
r2Secret: $("#input-r2-secret"),
r2Token: $("#input-r2-token"),
r2Zone: $("#input-r2-zone"),
r2Domain: $("#input-r2-domain"),
r2Domain: $("#input-r2-domain"), // Public URL
// Advanced (Manual or NIP71 managed)
ws: $("#input-ws"),
@@ -170,6 +170,17 @@ export class UploadModal {
progress: $("#input-progress"),
};
// Wizard Containers
this.wizard = {
step1: $("#step-credentials"),
step2: $("#step-verification"),
nextBtn: $("#btn-next-step"),
backBtn: $("#btn-back-step"),
errorContainer: $("#container-verification-error"),
errorText: $("#text-verification-error"),
guideLink: $("#link-cloudflare-guide"),
};
// Toggles/Buttons
this.toggles = {
nsfw: $("#check-nsfw"),
@@ -179,10 +190,10 @@ export class UploadModal {
advanced: $("#btn-advanced-toggle"),
storageSettings: $("#btn-storage-settings"),
r2Advanced: $("#btn-r2-advanced"),
saveSettings: $("#btn-save-settings"),
saveSettings: $("#btn-save-settings"), // Verify & Save
browseThumbnail: $("#btn-thumbnail-file"),
r2HelpLink: $("#link-r2-help"),
copyBucket: $("#btn-copy-bucket"),
};
// Status text
@@ -203,7 +214,6 @@ export class UploadModal {
// Toggles
this.setupAccordion(this.toggles.advanced, this.sourceSections.advanced);
this.setupAccordion(this.toggles.storageSettings, this.sourceSections.settings);
this.setupAccordion(this.toggles.r2Advanced, this.sourceSections.r2Advanced);
// Form Submission
this.form.addEventListener("submit", (e) => {
@@ -211,9 +221,19 @@ export class UploadModal {
this.handleSubmit();
});
// Settings Save
// Wizard: Step 1 Next
if (this.wizard.nextBtn) {
this.wizard.nextBtn.addEventListener("click", () => this.goToVerificationStep());
}
// Wizard: Step 2 Back
if (this.wizard.backBtn) {
this.wizard.backBtn.addEventListener("click", () => this.goToCredentialsStep());
}
// Settings Save (Verify & Save)
this.toggles.saveSettings.addEventListener("click", async () => {
await this.handleSaveSettings();
await this.handleVerifyAndSave();
});
// Help Link
@@ -223,6 +243,20 @@ export class UploadModal {
});
}
// Copy Bucket Name
if (this.toggles.copyBucket && this.inputs.r2BucketName) {
this.toggles.copyBucket.addEventListener("click", () => {
const val = this.inputs.r2BucketName.value;
if (val) {
navigator.clipboard.writeText(val).then(() => {
const original = this.toggles.copyBucket.textContent;
this.toggles.copyBucket.textContent = "Copied!";
setTimeout(() => this.toggles.copyBucket.textContent = original, 1500);
});
}
});
}
// Automation
this.setupDescriptionMirror();
this.setupMutuallyExclusiveCheckboxes(this.toggles.nsfw, this.toggles.kids);
@@ -270,6 +304,10 @@ export class UploadModal {
if (this.toggles.browseThumbnail) {
this.toggles.browseThumbnail.classList.remove("hidden");
}
// Populate derived bucket name
this.populateBucketName();
} else {
this.sourceSections.upload.classList.add("hidden");
this.sourceSections.external.classList.remove("hidden");
@@ -304,6 +342,15 @@ export class UploadModal {
// --- Automation Helpers ---
populateBucketName() {
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
if (pubkey && this.safeEncodeNpub && this.inputs.r2BucketName) {
const npub = this.safeEncodeNpub(pubkey);
const bucketName = sanitizeBucketName(npub);
this.inputs.r2BucketName.value = bucketName;
}
}
setupDescriptionMirror() {
const { description, summary } = this.inputs;
const { summaryUnlock } = this.toggles;
@@ -382,15 +429,13 @@ export class UploadModal {
hasValidR2Settings() {
const s = this.cloudflareSettings;
return Boolean(s?.accountId && s?.accessKeyId && s?.secretAccessKey);
return Boolean(s?.accountId && s?.accessKeyId && s?.secretAccessKey && s?.baseDomain);
}
fillSettingsForm(s) {
if (this.inputs.r2Account) this.inputs.r2Account.value = s.accountId || "";
if (this.inputs.r2Key) this.inputs.r2Key.value = s.accessKeyId || "";
if (this.inputs.r2Secret) this.inputs.r2Secret.value = s.secretAccessKey || "";
if (this.inputs.r2Token) this.inputs.r2Token.value = s.apiToken || "";
if (this.inputs.r2Zone) this.inputs.r2Zone.value = s.zoneId || "";
if (this.inputs.r2Domain) this.inputs.r2Domain.value = s.baseDomain || "";
}
@@ -399,28 +444,103 @@ export class UploadModal {
accountId: this.inputs.r2Account?.value?.trim() || "",
accessKeyId: this.inputs.r2Key?.value?.trim() || "",
secretAccessKey: this.inputs.r2Secret?.value?.trim() || "",
apiToken: this.inputs.r2Token?.value?.trim() || "",
zoneId: this.inputs.r2Zone?.value?.trim() || "",
baseDomain: this.inputs.r2Domain?.value?.trim() || "",
baseDomain: this.inputs.r2Domain?.value?.trim() || "", // Public URL
};
}
async handleSaveSettings() {
const settings = this.collectSettingsForm();
await this.r2Service.saveSettings(settings);
this.cloudflareSettings = settings;
this.updateStorageStatus(this.hasValidR2Settings());
goToVerificationStep() {
// Validate Step 1
const accountId = this.inputs.r2Account?.value?.trim();
const keyId = this.inputs.r2Key?.value?.trim();
const secret = this.inputs.r2Secret?.value?.trim();
// Visual feedback
if (!accountId || !keyId || !secret) {
alert("Please fill in Account ID, Access Key ID, and Secret Access Key.");
return;
}
// Transition
this.wizard.step1.classList.add("hidden");
this.wizard.step2.classList.remove("hidden");
// Auto-focus URL if empty
if (!this.inputs.r2Domain.value) {
this.inputs.r2Domain.focus();
}
}
goToCredentialsStep() {
this.wizard.step2.classList.add("hidden");
this.wizard.step1.classList.remove("hidden");
this.wizard.errorContainer.classList.add("hidden");
}
async handleVerifyAndSave() {
const btn = this.toggles.saveSettings;
const originalText = btn.textContent;
btn.textContent = "Saved!";
setTimeout(() => btn.textContent = originalText, 2000);
// Collapse if valid
if (this.hasValidR2Settings()) {
this.sourceSections.settings.classList.add("hidden");
this.toggles.storageSettings.setAttribute("aria-expanded", "false");
// Clear previous error
this.wizard.errorContainer.classList.add("hidden");
const settings = this.collectSettingsForm();
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
const npub = pubkey ? this.safeEncodeNpub(pubkey) : null;
if (!settings.baseDomain) {
this.wizard.errorText.textContent = "Public Bucket URL is required.";
this.wizard.errorContainer.classList.remove("hidden");
return;
}
// 1. Verify
btn.disabled = true;
btn.textContent = "Verifying...";
try {
const verification = await this.r2Service.verifyPublicAccess({ settings, npub });
if (!verification.success) {
btn.disabled = false;
btn.textContent = originalText;
this.wizard.errorText.textContent = verification.error || "Verification failed.";
this.wizard.errorContainer.classList.remove("hidden");
// Dynamic Cloudflare Link
// https://dash.cloudflare.com/?to=/:account/r2/default/buckets/:bucket/settings
if (settings.accountId && npub) {
const bucketName = this.inputs.r2BucketName?.value || "";
const cfLink = `https://dash.cloudflare.com/?to=/${settings.accountId}/r2/default/buckets/${bucketName}/settings`;
this.wizard.guideLink.href = cfLink;
} else {
this.wizard.guideLink.href = "https://dash.cloudflare.com";
}
return;
}
// 2. Save on Success
btn.textContent = "Saving...";
await this.r2Service.saveSettings(settings);
this.cloudflareSettings = settings;
this.updateStorageStatus(true);
btn.disabled = false;
btn.textContent = "Saved & Ready!";
setTimeout(() => {
btn.textContent = originalText;
// Collapse
this.sourceSections.settings.classList.add("hidden");
this.toggles.storageSettings.setAttribute("aria-expanded", "false");
// Reset wizard to start for next edit
this.goToCredentialsStep();
}, 1500);
} catch (err) {
userLogger.error("Verification crashed:", err);
btn.disabled = false;
btn.textContent = originalText;
this.wizard.errorText.textContent = "Unexpected error during verification.";
this.wizard.errorContainer.classList.remove("hidden");
}
}
@@ -456,7 +576,7 @@ export class UploadModal {
const bar = this.inputs.progress;
const txt = this.statusText.uploadPercent;
if (fraction === null || fraction < 0) {
if (fraction === null || fraction < 0 || isNaN(fraction)) {
container.classList.add("hidden");
return;
}
@@ -530,11 +650,30 @@ export class UploadModal {
const npub = this.safeEncodeNpub(pubkey);
// 1. Calculate Torrent Info Hash (Client-side)
this.isUploading = true;
this.submitButton.disabled = true;
this.updateUploadStatus("Calculating Info Hash...", "info");
this.updateProgress(0); // Show bar at 0
let infoHash = "";
try {
infoHash = await calculateTorrentInfoHash(file);
} catch (hashErr) {
userLogger.warn("Failed to calculate info hash:", hashErr);
// We can proceed without it, or fail. The plan says we need it.
// Let's warn but proceed? Or fail? The user said "Also lets not forget that we calculate a torrent hash".
// I will proceed but log it, maybe the upload handles it gracefully (missing magnet).
// But passing empty infoHash means no magnet link generated in R2Service.
}
// 2. Upload
await this.r2Service.uploadVideo({
npub,
file,
thumbnailFile,
metadata,
infoHash,
settingsInput: this.collectSettingsForm(),
publishVideoNote: this.publishVideoNote,
onReset: () => this.resetForm(),
@@ -589,6 +728,7 @@ export class UploadModal {
this.toggles.nsfw.checked = false;
this.toggles.kids.checked = false;
this.updateProgress(null);
this.populateBucketName(); // Re-populate if user logged in
// Reset thumbnail UI
if (this.inputs.thumbnail) {
@@ -607,6 +747,7 @@ export class UploadModal {
this.root.classList.remove("hidden");
this.setGlobalModalState("upload", true);
this.isVisible = true;
this.populateBucketName(); // Ensure bucket name is fresh on open
this.modalAccessibility?.activate({ triggerElement });
}
+152 -67
View File
@@ -476,9 +476,9 @@ export class VideoCard {
content.appendChild(badgesContainer);
}
const discussion = this.buildDiscussionCount();
if (discussion) {
content.appendChild(discussion);
const engagement = this.buildEngagementSection();
if (engagement) {
content.appendChild(engagement);
}
if (this.showUnsupportedTorrentBadge) {
@@ -926,29 +926,24 @@ export class VideoCard {
metadata.appendChild(timeEl);
this.timestampEl = timeEl;
if (this.pointerInfo && this.pointerInfo.key) {
const dotClassNames = ["text-muted-strong"];
if (!isCompact) {
dotClassNames.push("mx-1");
}
// (View count moved to engagement section)
const dot = this.createElement("span", {
classNames: dotClassNames,
textContent: "•"
if (
this.postedAt !== null &&
Number.isFinite(this.video?.created_at) &&
this.video.created_at > this.postedAt + 60
) {
const separator = this.createElement("span", {
classNames: ["text-muted-strong", isCompact ? "" : "mx-1"],
textContent: "•",
});
dot.setAttribute("aria-hidden", "true");
separator.setAttribute("aria-hidden", "true");
metadata.appendChild(separator);
const view = this.createElement("span", {
classNames: ["view-count-text"],
textContent: "– views"
const edited = this.createElement("span", {
textContent: "Edited",
});
view.dataset.viewCount = "";
view.dataset.viewPointer = this.pointerInfo.key;
metadata.appendChild(dot);
metadata.appendChild(view);
this.viewCountEl = view;
metadata.appendChild(edited);
}
authorMeta.appendChild(authorName);
@@ -2312,62 +2307,152 @@ export class VideoCard {
}
}
buildDiscussionCount() {
if (this.variant === "compact") {
createEyeIcon(classNames = []) {
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
classNames.forEach((className) => {
if (className) {
svg.classList.add(className);
}
});
const path = this.document.createElementNS(SVG_NAMESPACE, "path");
path.setAttribute("d", "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z");
svg.appendChild(path);
const circle = this.document.createElementNS(SVG_NAMESPACE, "circle");
circle.setAttribute("cx", "12");
circle.setAttribute("cy", "12");
circle.setAttribute("r", "3");
svg.appendChild(circle);
return svg;
}
createMessageIcon(classNames = []) {
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
classNames.forEach((className) => {
if (className) {
svg.classList.add(className);
}
});
const path = this.document.createElementNS(SVG_NAMESPACE, "path");
path.setAttribute(
"d",
"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
);
svg.appendChild(path);
return svg;
}
buildEngagementSection() {
// Check availability
const hasPointer = this.pointerInfo && this.pointerInfo.key;
const hasDiscussion =
this.variant !== "compact" &&
this.video.enableComments !== false &&
(typeof this.video.discussionCount === "number" ||
(typeof this.video.discussionCount === "string" &&
this.video.discussionCount.trim()));
if (!hasPointer && !hasDiscussion) {
return null;
}
if (this.video.enableComments === false) {
return null;
const container = this.createElement("div", {
classNames: [
"flex",
"items-center",
"gap-4",
"text-xs",
"text-muted-strong",
"mt-3",
"video-card__engagement",
],
});
// Views
if (hasPointer) {
const wrapper = this.createElement("div", {
classNames: ["flex", "items-center", "gap-1.5"],
});
wrapper.setAttribute("title", "Views");
const icon = this.createEyeIcon(["w-4", "h-4"]);
wrapper.appendChild(icon);
const view = this.createElement("span", {
classNames: ["view-count-text"],
textContent: "–",
});
view.dataset.viewCount = "";
view.dataset.viewPointer = this.pointerInfo.key;
wrapper.appendChild(view);
container.appendChild(wrapper);
this.viewCountEl = view;
}
let initialCount = null;
if (typeof this.video.discussionCount === "number") {
initialCount = this.video.discussionCount;
} else if (typeof this.video.discussionCount === "string") {
const trimmed = this.video.discussionCount.trim();
if (trimmed) {
const parsed = Number.parseInt(trimmed, 10);
// Discussion
if (hasDiscussion) {
let initialCount = 0;
if (typeof this.video.discussionCount === "number") {
initialCount = this.video.discussionCount;
} else if (typeof this.video.discussionCount === "string") {
const parsed = Number.parseInt(this.video.discussionCount.trim(), 10);
if (Number.isFinite(parsed)) {
initialCount = parsed;
}
}
if (Number.isFinite(initialCount) && initialCount >= 0) {
const wrapper = this.createElement("div", {
classNames: ["flex", "items-center", "gap-1.5"],
});
wrapper.dataset.discussionCount = this.video.id;
wrapper.dataset.countState = "ready";
wrapper.setAttribute("title", "Comments");
const icon = this.createMessageIcon(["w-4", "h-4"]);
wrapper.appendChild(icon);
const safeCount = Math.floor(initialCount);
const displayValue = this.helpers.toLocaleString
? this.helpers.toLocaleString(safeCount)
: safeCount.toLocaleString();
const valueEl = this.createElement("span", {
textContent: displayValue,
});
valueEl.dataset.discussionCountValue = "";
wrapper.appendChild(valueEl);
container.appendChild(wrapper);
this.discussionCountEl = wrapper;
}
}
if (
initialCount === null ||
!Number.isFinite(initialCount) ||
initialCount < 0
) {
return null;
}
const safeCount = Math.floor(initialCount);
const displayValue = this.helpers.toLocaleString
? this.helpers.toLocaleString(safeCount)
: safeCount.toLocaleString();
const container = this.createElement("div", {
classNames: ["flex", "items-center", "text-xs", "text-muted-strong"]
});
container.dataset.discussionCount = this.video.id;
container.dataset.countState = "ready";
const valueEl = this.createElement("span", {
textContent: displayValue
});
valueEl.dataset.discussionCountValue = "";
const labelEl = this.createElement("span", {
classNames: ["ml-1"],
textContent: "notes"
});
container.appendChild(valueEl);
container.appendChild(labelEl);
this.discussionCountEl = container;
return container;
}
+14 -6
View File
@@ -3959,8 +3959,17 @@ export class VideoModal {
}
updateViewCountLabel(text) {
if (this.videoViewCountEl) {
this.videoViewCountEl.textContent = text || "";
if (!this.videoViewCountEl) {
return;
}
const textEl = this.videoViewCountEl.querySelector(
"[data-view-count-text]"
);
if (textEl) {
textEl.textContent = text || "–";
} else {
// Fallback if span is missing
this.videoViewCountEl.textContent = text || "–";
}
}
@@ -5438,15 +5447,14 @@ export class VideoModal {
return this.formatViewCountLabel(Number(total));
}
if (status === "hydrating") {
return "Loading views…";
return "Loading…";
}
return "– views";
return "–";
}
formatViewCountLabel(total) {
const numeric = Number.isFinite(total) ? Math.max(0, Number(total)) : 0;
const label = numeric === 1 ? "view" : "views";
return `${formatViewCount(numeric)} ${label}`;
return formatViewCount(numeric);
}
syncMoreMenuData({ currentVideo, canManageBlacklist }) {
+37 -2
View File
@@ -7033,8 +7033,14 @@ export class ProfileModalController {
return;
}
const { onRemove, removeLabel = "Remove", confirmMessage, removable = true } =
options;
const {
onRemove,
removeLabel = "Remove",
confirmMessage,
removable = true,
overlapSet,
overlapLabel,
} = options;
const formatNpub =
typeof this.formatShortNpub === "function"
@@ -7076,6 +7082,8 @@ export class ProfileModalController {
"card flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between";
const normalizedNpub = typeof npub === "string" ? npub.trim() : "";
const comparableNpub =
this.normalizeNpubValue(normalizedNpub) || normalizedNpub;
const decodedHex =
normalizedNpub && normalizedNpub.startsWith("npub1")
? this.safeDecodeNpub(normalizedNpub)
@@ -7110,6 +7118,20 @@ export class ProfileModalController {
avatarSrc,
});
if (
summary &&
overlapLabel &&
overlapSet instanceof Set &&
comparableNpub &&
overlapSet.has(comparableNpub)
) {
const overlapBadge = document.createElement("span");
overlapBadge.className = "badge whitespace-nowrap";
overlapBadge.dataset.variant = "warning";
overlapBadge.textContent = overlapLabel;
summary.appendChild(overlapBadge);
}
const actions = document.createElement("div");
actions.className =
"flex flex-wrap items-center justify-end gap-2 sm:flex-none";
@@ -7175,6 +7197,15 @@ export class ProfileModalController {
const blacklist = this.normalizeAdminListEntries(
this.services.accessControl.getBlacklist(),
);
const normalizeForCompare = (value) =>
this.normalizeNpubValue(value) ||
(typeof value === "string" ? value.trim() : "");
const whitelistCompare = new Set(
whitelist.map(normalizeForCompare).filter(Boolean),
);
const blacklistCompare = new Set(
blacklist.map(normalizeForCompare).filter(Boolean),
);
this.renderAdminList(
this.adminModeratorList,
@@ -7199,6 +7230,8 @@ export class ProfileModalController {
removeLabel: "Remove",
confirmMessage: "Remove {npub} from the whitelist?",
removable: true,
overlapSet: blacklistCompare,
overlapLabel: "Also blacklisted",
},
);
@@ -7212,6 +7245,8 @@ export class ProfileModalController {
removeLabel: "Unblock",
confirmMessage: "Remove {npub} from the blacklist?",
removable: true,
overlapSet: whitelistCompare,
overlapLabel: "Also whitelisted",
},
);
}
+732 -245
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -90,3 +90,37 @@ export function formatTimeAgo(timestamp) {
}
return "just now";
}
/**
* Formats a timestamp (in seconds) to a long absolute date with ordinal day
* (e.g. "January 6th, 2026").
*/
export function formatAbsoluteDateWithOrdinal(timestamp) {
if (!Number.isFinite(timestamp)) {
return "";
}
const date = new Date(timestamp * 1000);
if (Number.isNaN(date.getTime())) {
return "";
}
// Helper for English ordinal suffixes
const getOrdinal = (n) => {
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return s[(v - 20) % 10] || s[v] || s[0];
};
try {
const month = date.toLocaleString("en-US", { month: "long" });
const day = date.getDate();
const year = date.getFullYear();
const suffix = getOrdinal(day);
return `${month} ${day}${suffix}, ${year}`;
} catch (err) {
// Fallback if locale fails
return date.toDateString();
}
}
+62
View File
@@ -0,0 +1,62 @@
import WebTorrent from "../webtorrent.min.js";
import { userLogger } from "./logger.js";
/**
* Calculates the torrent infoHash for a given file client-side.
* This operation requires reading and hashing the entire file, so it may take time for large files.
*
* @param {File} file - The file object to hash.
* @returns {Promise<string>} - Resolves with the hex encoded infoHash.
*/
export async function calculateTorrentInfoHash(file) {
if (!file) {
throw new Error("No file provided for hashing.");
}
// Create a temporary client just for hashing.
// We disable trackers and dht/lsd to keep it quiet and fast (local only).
const client = new WebTorrent({
tracker: false,
dht: false,
lsd: false,
webSeeds: false,
});
return new Promise((resolve, reject) => {
// Seed the file. This calculates the infoHash.
// We use destroyStoreOnDestroy: true to clean up memory/storage afterwards.
try {
client.seed(
file,
{
announce: [], // No trackers
destroyStoreOnDestroy: true,
name: file.name,
},
(torrent) => {
const infoHash = torrent.infoHash;
// Destroy the client and the torrent data immediately.
client.destroy((err) => {
if (err) {
userLogger.warn("Error destroying temporary hashing client:", err);
}
if (infoHash) {
resolve(infoHash);
} else {
reject(new Error("Failed to calculate info hash."));
}
});
}
);
client.on("error", (err) => {
userLogger.error("WebTorrent client error during hashing:", err);
client.destroy();
reject(err);
});
} catch (err) {
client.destroy();
reject(err);
}
});
}
+13 -2
View File
@@ -49,7 +49,7 @@ restoreCacheSnapshot();
* @property {number} total
* @property {Map<string, number>} dedupeBuckets
* @property {number} lastSyncedAt
* @property {"idle"|"hydrating"|"live"} status
* @property {"idle"|"hydrating"|"live"|"stale"} status
*/
/**
@@ -482,7 +482,8 @@ async function hydratePointer(key, listeners) {
}
}
let mutated = false;
if (Array.isArray(listResult)) {
const hasListEvents = Array.isArray(listResult) && listResult.length > 0;
if (hasListEvents) {
for (const event of listResult) {
mutated = applyEventToState(key, event) || mutated;
}
@@ -502,6 +503,13 @@ async function hydratePointer(key, listeners) {
mutated = true;
}
}
if (!hasListEvents && bestCount === null) {
const state = ensurePointerState(key);
state.status = "stale";
schedulePersist();
notifyHandlers(key);
return;
}
if (mutated) {
schedulePersist();
notifyHandlers(key);
@@ -518,8 +526,11 @@ function ensureHydration(key, listeners) {
if (!listeners.hydrationPromise) {
listeners.hydrationPromise = hydratePointer(key, listeners).finally(() => {
listeners.hydrationPromise = null;
const state = ensurePointerState(key);
if (listeners.liveUnsub) {
setPointerStatus(key, "live");
} else if (state.status === "stale") {
setPointerStatus(key, "stale");
} else {
setPointerStatus(key, "idle");
}
+160 -5
View File
@@ -1032,6 +1032,159 @@ function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}) {
return promise;
}
function resolveEntryPointer(entry) {
if (!entry) {
return null;
}
const candidate = entry.pointer || entry;
return normalizePointerInput(candidate);
}
function resolveEntryWatchedAt(entry, pointer) {
const watchedAtRaw = Number.isFinite(entry?.watchedAt)
? entry.watchedAt
: Number.isFinite(entry?.pointer?.watchedAt)
? entry.pointer.watchedAt
: Number.isFinite(entry?.timestamp)
? entry.timestamp
: Number.isFinite(pointer?.watchedAt)
? pointer.watchedAt
: 0;
return Math.max(0, Math.floor(Number(watchedAtRaw) || 0));
}
function getLatestWatchedAt(items) {
if (!Array.isArray(items) || !items.length) {
return 0;
}
let latest = 0;
for (const entry of items) {
const pointer = resolveEntryPointer(entry);
if (!pointer) {
continue;
}
const watchedAt = resolveEntryWatchedAt(entry, pointer);
if (watchedAt > latest) {
latest = watchedAt;
}
}
return latest;
}
function buildMergedEntry(entry, pointer, watchedAt) {
const normalizedPointer = pointer ? { ...pointer } : null;
if (normalizedPointer) {
normalizedPointer.watchedAt = watchedAt;
if (pointer?.session === true) {
normalizedPointer.session = true;
}
}
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
const next = { ...entry };
if (entry.pointer && typeof entry.pointer === "object") {
next.pointer = {
...(entry.pointer || {}),
...(normalizedPointer || {}),
watchedAt,
};
} else if (normalizedPointer) {
Object.assign(next, normalizedPointer);
next.watchedAt = watchedAt;
} else {
next.watchedAt = watchedAt;
}
if (normalizedPointer?.session === true) {
if (next.pointer) {
next.pointer.session = true;
} else {
next.session = true;
}
}
return next;
}
if (normalizedPointer) {
return normalizedPointer;
}
return entry;
}
function mergeWatchHistoryItems(baseItems, queueItems) {
const merged = new Map();
const ingest = (entry, pointer, watchedAt) => {
const key = pointerKey(pointer);
if (!key) {
return;
}
merged.set(key, {
entry,
watchedAt,
key,
});
};
for (const entry of Array.isArray(baseItems) ? baseItems : []) {
const pointer = resolveEntryPointer(entry);
if (!pointer) {
continue;
}
const watchedAt = resolveEntryWatchedAt(entry, pointer);
ingest(entry, pointer, watchedAt);
}
for (const entry of Array.isArray(queueItems) ? queueItems : []) {
const pointer = resolveEntryPointer(entry);
if (!pointer) {
continue;
}
const watchedAt = resolveEntryWatchedAt(entry, pointer);
const key = pointerKey(pointer);
if (!key) {
continue;
}
const existing = merged.get(key);
if (!existing || watchedAt > existing.watchedAt) {
const mergedEntry = buildMergedEntry(existing?.entry || entry, pointer, watchedAt);
merged.set(key, {
entry: mergedEntry,
watchedAt,
key,
});
}
}
const ordered = Array.from(merged.values());
ordered.sort((a, b) => {
if (a.watchedAt !== b.watchedAt) {
return b.watchedAt - a.watchedAt;
}
return a.key.localeCompare(b.key);
});
return ordered.map((entry) => entry.entry);
}
function mergeQueuedItemsIfNeeded(actorKey, items) {
const queueItems = collectQueueItems(actorKey);
if (!queueItems.length) {
return items;
}
const snapshotInflight = state.inflightSnapshots.has(actorKey);
if (!snapshotInflight) {
const latestQueue = getLatestWatchedAt(queueItems);
const latestBase = getLatestWatchedAt(items);
if (latestQueue <= latestBase) {
return items;
}
}
return mergeWatchHistoryItems(items, queueItems);
}
async function loadLatest(actorInput, options = {}) {
const actorKey = resolveActorKey(actorInput);
if (!actorKey) {
@@ -1087,7 +1240,7 @@ async function loadLatest(actorInput, options = {}) {
itemCount: cacheEntry.items.length,
}
);
return cacheEntry.items;
return mergeQueuedItemsIfNeeded(actorKey, cacheEntry.items);
}
if (cacheEntry?.promise) {
@@ -1105,13 +1258,15 @@ async function loadLatest(actorInput, options = {}) {
itemCount: cacheEntry.items.length,
}
);
return cacheEntry.items;
return mergeQueuedItemsIfNeeded(actorKey, cacheEntry.items);
}
return cacheEntry.promise;
const resolved = await cacheEntry.promise;
return mergeQueuedItemsIfNeeded(actorKey, resolved);
}
if (!allowStale || !hasCachedItems) {
return scheduleWatchHistoryRefresh(actorKey, cacheEntry);
const resolved = await scheduleWatchHistoryRefresh(actorKey, cacheEntry);
return mergeQueuedItemsIfNeeded(actorKey, resolved);
}
const refreshPromise = scheduleWatchHistoryRefresh(actorKey, cacheEntry);
@@ -1125,7 +1280,7 @@ async function loadLatest(actorInput, options = {}) {
}
);
return cacheEntry.items || [];
return mergeQueuedItemsIfNeeded(actorKey, cacheEntry.items || []);
}
async function getFingerprint(actorInput) {
+1 -1
View File
@@ -150,7 +150,7 @@ test("trusted mute lists from seeds hide authors for anonymous viewers", async (
service,
autoplayThreshold: DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
blurThreshold: DEFAULT_BLUR_THRESHOLD,
trustedMuteHideThreshold: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
trustedMuteHideThreshold: 1, // Override to ensure hiding triggers with a single mute
reportType: "nudity",
});
+197 -3
View File
@@ -13,6 +13,17 @@ if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
// Fix environment for NostrTools
if (typeof globalThis.NostrTools === "undefined") {
// Polyfill enough of NostrTools for the tests to function if actual library missing or failing bootstrap
// The test below overwrites window.NostrTools later, but we need globalThis.NostrTools to exist for js/userBlocks.js module scope checks
globalThis.NostrTools = {
nip19: {
decode: () => { throw new Error("Mock not implemented"); }
}
};
}
await (async () => {
const actor = "f".repeat(64);
const blocked = "e".repeat(64);
@@ -87,6 +98,16 @@ await (async () => {
"loadBlocks should initiate queries for all relays concurrently"
);
// Reverting to original test data:
// relay-one (fast): OLD event
// relay-two (fast): NEW event
// This ensures that even if fast relay returns old data, eventual consistency (or Promise.any randomness?) picks it up.
// Wait, Promise.any picks the FIRST resolved.
// The test resolves them in order: relay-one (old), relay-two (new).
// So 'fast' path gets 'event-old'.
// Background path (Promise.allSettled) gets BOTH.
// Background path merges and finds 'event-new'.
// So if the code correctly implements eventual consistency, userBlocks.blockEventId will settle on 'event-new'.
const responses = {
[relays[0]]: [
{
@@ -313,7 +334,7 @@ await (async () => {
const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt;
const originalLoaded = userBlocks.loaded;
const originalSeedStateCache = userBlocks.seedStateCache;
const originalNostrTools = window.NostrTools;
const originalNostrTools = globalThis.NostrTools;
const publishCalls = [];
@@ -324,7 +345,8 @@ await (async () => {
[seedTwoNpub, seedTwoHex],
]);
window.NostrTools = {
// Patch both globalThis and window to ensure userBlocks sees it
globalThis.NostrTools = {
...(originalNostrTools || {}),
nip19: {
...((originalNostrTools && originalNostrTools.nip19) || {}),
@@ -336,6 +358,7 @@ await (async () => {
},
},
};
window.NostrTools = globalThis.NostrTools;
userBlocks.ensureLoaded = async () => {
userBlocks.loaded = true;
@@ -454,7 +477,7 @@ await (async () => {
userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt;
userBlocks.loaded = originalLoaded;
userBlocks.seedStateCache = originalSeedStateCache;
window.NostrTools = originalNostrTools;
globalThis.NostrTools = originalNostrTools;
localStorage.clear();
}
})();
@@ -492,6 +515,8 @@ await (async () => {
let latestCiphertext = "cipher:[]";
let latestEventId = "event-initial";
let latestCreatedAt = 1_000;
// Capture tags for faithful mock
let latestTags = [];
const decryptPayloads = new Map([[latestCiphertext, JSON.stringify([])]]);
@@ -502,6 +527,7 @@ await (async () => {
nostrClient.pool = {
publish: (_targets, event) => {
latestCiphertext = event.content;
latestTags = event.tags; // Store tags
latestEventId = event.id || `event-${Date.now()}`;
latestCreatedAt = event.created_at || Math.floor(Date.now() / 1000);
return {
@@ -521,6 +547,7 @@ await (async () => {
created_at: latestCreatedAt,
pubkey: actor,
content: latestCiphertext,
tags: latestTags || [], // Return tags so encryption hints work
},
],
};
@@ -1044,3 +1071,170 @@ await (async () => {
}
}
})();
await (async () => {
// Test for tag-only Kind 10000 events (Primal-style)
const actor = "f".repeat(64);
const blockedHex = "a".repeat(64);
const UserBlockListManager = userBlocks.constructor;
const manager = new UserBlockListManager();
const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays;
const originalPool = nostrClient.pool;
nostrClient.relays = ["wss://tag-only.example"];
nostrClient.pool = {
list: async () => [
{
id: "event-tag-only",
kind: 10000,
created_at: 10_000,
pubkey: actor,
content: "", // Empty content
tags: [["p", blockedHex]],
},
],
};
try {
await manager.loadBlocks(actor);
assert(manager.blockedPubkeys.has(blockedHex), "should load blocked pubkeys from p-tags when content is empty");
assert.equal(manager.blockedPubkeys.size, 1, "should have exactly one blocked pubkey");
assert.equal(manager.blockEventId, "event-tag-only", "should track the correct event ID");
} finally {
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
}
})();
await (async () => {
// Test for tag-only Kind 10000 events with whitespace content
const actor = "f".repeat(64);
const blockedHex = "b".repeat(64);
const UserBlockListManager = userBlocks.constructor;
const manager = new UserBlockListManager();
const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays;
const originalPool = nostrClient.pool;
nostrClient.relays = ["wss://tag-only-whitespace.example"];
nostrClient.pool = {
list: async () => [
{
id: "event-tag-only-space",
kind: 10000,
created_at: 11_000,
pubkey: actor,
content: " ", // Whitespace content
tags: [["p", blockedHex]],
},
],
};
try {
await manager.loadBlocks(actor);
assert(manager.blockedPubkeys.has(blockedHex), "should load blocked pubkeys from p-tags when content is whitespace");
} finally {
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
}
})();
await (async () => {
// Test for empty content AND no tags -> should clear
const actor = "f".repeat(64);
const previouslyBlocked = "c".repeat(64);
const UserBlockListManager = userBlocks.constructor;
const manager = new UserBlockListManager();
manager.blockedPubkeys.add(previouslyBlocked);
const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays;
const originalPool = nostrClient.pool;
nostrClient.relays = ["wss://empty-clear.example"];
nostrClient.pool = {
list: async () => [
{
id: "event-empty",
kind: 10000,
created_at: 12_000,
pubkey: actor,
content: "",
tags: [], // No p tags
},
],
};
try {
await manager.loadBlocks(actor);
assert.equal(manager.blockedPubkeys.size, 0, "should clear block list when content and tags are empty");
} finally {
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
}
})();
await (async () => {
// Test: Prioritize 'd=user-blocks' tagged lists over newer plain lists
const actor = "a".repeat(64);
const taggedBlocked = "b".repeat(64);
const plainBlocked = "c".repeat(64);
const originalRelays = nostrClient.relays;
const originalPool = nostrClient.pool;
const originalNostr = window.nostr;
nostrClient.relays = ["wss://repro.example"];
window.nostr = {
nip04: {
decrypt: async (_pubkey, ciphertext) => {
if (ciphertext === "encrypted-tagged") {
return JSON.stringify({ blockedPubkeys: [taggedBlocked] });
}
return "{}";
}
}
};
nostrClient.pool = {
list: async () => [
{
id: "event-tagged",
kind: 10000,
created_at: 1000,
pubkey: actor,
content: "encrypted-tagged",
tags: [["d", "user-blocks"]],
},
{
id: "event-plain",
kind: 10000,
created_at: 2000, // Newer!
pubkey: actor,
content: "",
tags: [["p", plainBlocked]],
}
]
};
userBlocks.reset();
try {
await userBlocks.loadBlocks(actor);
const blocked = userBlocks.getBlockedPubkeys();
assert.ok(blocked.includes(taggedBlocked), "Should include pubkey from tagged list");
assert.ok(!blocked.includes(plainBlocked), "Should NOT include pubkey from plain list (fallback ignored)");
} finally {
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
window.nostr = originalNostr;
}
})();