mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Migrate user blocks to standard NIP-51 Mute List
- Updates `js/userBlocks.js` to treat Kind 10000 (standard Mute List) as the primary write target. - Public mutes are stored in `p` tags, and private blocks are stored in encrypted content within the same event. - Legacy `d=user-blocks` lists are still read for backward compatibility (preferring the newest event), but no longer written. - Updates `js/nostrEventSchemas.js` to mark `USER_BLOCK_LIST` as legacy and standardise `MUTE_LIST`. - Updates UI controllers (`MoreMenuController`, `ProfileModalController`) to use "Mute/Block List" terminology and delegate all mute/block actions to `userBlocks`. - Updates `ModerationService` to delegate viewer mute operations to `userBlocks` and disable direct Kind 10000 publishing to prevent overwrites. - Documentation updated in `docs/nostr-event-schemas.md`. - Fixes regressions in `more-menu-controller.test.mjs`, `moderation-service.test.mjs`, `trusted-mute-lists.test.mjs`, and `user-blocks.test.mjs`. Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
1 parent
4147211a3f
commit
8383a4f18b
10 files changed
+317
-475
No files matched your search
@@ -174,13 +174,13 @@ Video posts should treat `videoRootId` as the stable series identifier that rema
|
||||
| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', <eventId>]` or `['a', <address>]`), and a stable dedupe tag `['d', <view identifier>]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message |
|
||||
| Watch history month (`NOTE_TYPES.WATCH_HISTORY`) | `WATCH_HISTORY_KIND` (default `30079`) | Replaceable list tag `['d', `${WATCH_HISTORY_LIST_IDENTIFIER}:<YYYY-MM>`]` with optional `['month', <YYYY-MM>]` marker plus schema append tags; no chunk pointers required. | JSON payload `{ version, month: 'YYYY-MM', items: [{ id: <eventId\|address>, watched_at?: <unix seconds> }] }` |
|
||||
| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30000` | `['d', 'subscriptions']`, `['encrypted', 'nip44_v2']` (updated to the negotiated scheme at publish time) | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', <hex>], …]`) |
|
||||
| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `10000` | `['d', 'user-blocks']` | NIP-04/NIP-44 encrypted JSON `{ blockedPubkeys: string[] }` |
|
||||
| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `10000` | `['d', 'user-blocks']` | **LEGACY**. NIP-04/NIP-44 encrypted JSON `{ blockedPubkeys: string[] }`. Migrated to standard Mute List. |
|
||||
| Hashtag preferences (`NOTE_TYPES.HASHTAG_PREFERENCES`) | `30015` | `['d', 'bitvid:tag-preferences']` plus schema-appended `['encrypted','nip44_v2']` | NIP-44 encrypted JSON `{ version, interests: string[], disinterests: string[] }` |
|
||||
| Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Profile metadata (`NOTE_TYPES.PROFILE_METADATA`) | `0` | `['d', ...]` is not used | JSON payload with NIP-01 fields (`name`, `about`, `picture`, `nip05`, etc.) |
|
||||
| Mute list (`NOTE_TYPES.MUTE_LIST`) | `10000` | Repeated `['p', <pubkey>]` tags for blocked/muted users | Optional content (often encrypted) |
|
||||
| Mute list (`NOTE_TYPES.MUTE_LIST`) | `10000` | Repeated `['p', <pubkey>]` tags for public mutes | Encrypted content for private blocks. Standard NIP-51. |
|
||||
| Deletion (`NOTE_TYPES.DELETION`) | `5` | `['e', <event id>]` or `['a', <coordinate>]` | Reason for deletion |
|
||||
| Legacy Direct Message (`NOTE_TYPES.LEGACY_DM`) | `4` | `['p', <recipient pubkey>]` | NIP-04 encrypted ciphertext |
|
||||
| Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', <recipient pubkey>]` plus optional `['e', <event id>]`, `['a', <coordinate>]`, `['amount', <msats>]`, `['lnurl', <bech32>]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note |
|
||||
@@ -270,7 +270,16 @@ To prevent relay spikes, we only invoke `publish()` when the local interests or
|
||||
disinterests change—UI handlers short-circuit if a user toggles a tag back to
|
||||
its prior state. The method fans out to the configured write relays via
|
||||
`publishEventToRelays`, and `assertAnyRelayAccepted` ensures at least one relay
|
||||
acknowledges the update before treating the migration as complete.【F:js/services/hashtagPreferencesService.js†L761-L784】
|
||||
acknowledges the update before treating the migration as complete.
|
||||
|
||||
### User Mute/Block List (Kind 10000)
|
||||
|
||||
bitvid now uses the standard NIP-51 Mute List (kind 10000 without a `d` tag) to store both public mutes and private blocks.
|
||||
|
||||
* **Public Mutes**: Stored as `['p', <pubkey>]` tags. These are visible to anyone and are used for "Trusted Mute" graph analysis.
|
||||
* **Private Blocks**: Stored as NIP-04/NIP-44 encrypted JSON in the `content` field (`{ blockedPubkeys: string[] }`). These are only visible to the user.
|
||||
|
||||
The legacy `d=user-blocks` list is still read for backward compatibility but is no longer updated. New updates merge both legacy and standard lists into the standard Kind 10000 event.
|
||||
|
||||
### Operator checklist
|
||||
|
||||
|
||||
@@ -522,7 +522,7 @@ const BASE_SCHEMAS = {
|
||||
},
|
||||
[NOTE_TYPES.USER_BLOCK_LIST]: {
|
||||
type: NOTE_TYPES.USER_BLOCK_LIST,
|
||||
label: "User block list",
|
||||
label: "User block list (legacy)",
|
||||
kind: 10000,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
@@ -532,7 +532,7 @@ const BASE_SCHEMAS = {
|
||||
content: {
|
||||
format: "encrypted-tag-list",
|
||||
description:
|
||||
"NIP-04/NIP-44 encrypted JSON array of mute tags per NIP-51 (e.g., [['p', <hex>], …]).",
|
||||
"Legacy NIP-51 block list using custom d tag. Migrated to standard Mute List (kind 10000).",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.HASHTAG_PREFERENCES]: {
|
||||
@@ -604,7 +604,7 @@ const BASE_SCHEMAS = {
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description: "Optional content (often encrypted) with public p tags.",
|
||||
description: "Standard NIP-51 Mute List. Public mutes in 'p' tags, private blocks in encrypted content.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.DELETION]: {
|
||||
|
||||
@@ -1489,101 +1489,8 @@ export class ModerationService {
|
||||
}
|
||||
}
|
||||
|
||||
async publishViewerMuteList({ owner, muted }) {
|
||||
const viewer = normalizeHex(owner || this.viewerPubkey);
|
||||
if (!viewer) {
|
||||
const error = new Error("viewer-not-logged-in");
|
||||
error.code = "viewer-not-logged-in";
|
||||
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();
|
||||
|
||||
let signer = getActiveSigner();
|
||||
if (!signer && typeof this.nostrClient?.ensureActiveSignerForPubkey === "function") {
|
||||
signer = await this.nostrClient.ensureActiveSignerForPubkey(viewer);
|
||||
}
|
||||
|
||||
const canSign = typeof signer?.canSign === "function"
|
||||
? signer.canSign()
|
||||
: typeof signer?.signEvent === "function";
|
||||
if (!canSign || typeof signer?.signEvent !== "function") {
|
||||
const error = new Error("nostr-extension-missing");
|
||||
error.code = "nostr-extension-missing";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (signer?.type === "extension") {
|
||||
const permissionResult = await this.requestExtensionPermissions([
|
||||
"sign_event",
|
||||
"get_public_key",
|
||||
]);
|
||||
if (!permissionResult?.ok) {
|
||||
const error = new Error("extension-permission-denied");
|
||||
error.code = "extension-permission-denied";
|
||||
error.details = permissionResult?.error || null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const tags = [];
|
||||
if (muted instanceof Set || Array.isArray(muted)) {
|
||||
for (const value of muted) {
|
||||
const normalized = normalizeHex(value);
|
||||
if (!normalized || normalized === viewer) {
|
||||
continue;
|
||||
}
|
||||
tags.push(["p", normalized]);
|
||||
}
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: 10000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: "",
|
||||
pubkey: viewer,
|
||||
};
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
signedEvent = await signer.signEvent(event);
|
||||
} catch (error) {
|
||||
const wrapped = new Error("signature-failed");
|
||||
wrapped.code = "signature-failed";
|
||||
wrapped.details = error;
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
const relays = resolveRelayList(this.nostrClient, { write: true });
|
||||
if (!relays.length) {
|
||||
const error = new Error("no-relays-configured");
|
||||
error.code = "no-relays-configured";
|
||||
throw error;
|
||||
}
|
||||
|
||||
let results = [];
|
||||
try {
|
||||
results = await publishEventToRelays(this.nostrClient.pool, relays, signedEvent);
|
||||
assertAnyRelayAccepted(results, { context: "mute list" });
|
||||
} catch (error) {
|
||||
const wrapped = new Error("mute-list-publish-failed");
|
||||
wrapped.code = "mute-list-publish-failed";
|
||||
wrapped.details = error;
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
this.applyTrustedMuteEvent(viewer, signedEvent);
|
||||
return { ok: true, event: signedEvent, results };
|
||||
async publishViewerMuteList() {
|
||||
throw new Error("Mute list publishing is now handled by userBlocks.");
|
||||
}
|
||||
|
||||
async addAuthorToViewerMuteList(pubkey) {
|
||||
@@ -1594,29 +1501,11 @@ export class ModerationService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const target = normalizeHex(pubkey);
|
||||
if (!target) {
|
||||
const error = new Error("invalid-target");
|
||||
error.code = "invalid-target";
|
||||
throw error;
|
||||
if (this.userBlocks && typeof this.userBlocks.addMute === "function") {
|
||||
return this.userBlocks.addMute(pubkey, viewer);
|
||||
}
|
||||
|
||||
if (target === viewer) {
|
||||
const error = new Error("self");
|
||||
error.code = "self";
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.ensureViewerMuteListLoaded(viewer);
|
||||
|
||||
if (this.viewerMuteList.has(target)) {
|
||||
return { ok: true, already: true };
|
||||
}
|
||||
|
||||
const next = new Set(this.viewerMuteList);
|
||||
next.add(target);
|
||||
|
||||
return this.publishViewerMuteList({ owner: viewer, muted: next });
|
||||
throw new Error("Mute list management is delegated to userBlocks.");
|
||||
}
|
||||
|
||||
async removeAuthorFromViewerMuteList(pubkey) {
|
||||
@@ -1627,21 +1516,11 @@ export class ModerationService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const target = normalizeHex(pubkey);
|
||||
if (!target) {
|
||||
return { ok: true, already: true };
|
||||
if (this.userBlocks && typeof this.userBlocks.removeMute === "function") {
|
||||
return this.userBlocks.removeMute(pubkey, viewer);
|
||||
}
|
||||
|
||||
await this.ensureViewerMuteListLoaded(viewer);
|
||||
|
||||
if (!this.viewerMuteList.has(target)) {
|
||||
return { ok: true, already: true };
|
||||
}
|
||||
|
||||
const next = new Set(this.viewerMuteList);
|
||||
next.delete(target);
|
||||
|
||||
return this.publishViewerMuteList({ owner: viewer, muted: next });
|
||||
throw new Error("Mute list management is delegated to userBlocks.");
|
||||
}
|
||||
|
||||
isAuthorMutedByTrusted(pubkey) {
|
||||
|
||||
@@ -1225,9 +1225,7 @@ export default class MoreMenuController {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.moderationService.addAuthorToViewerMuteList(
|
||||
targetHex,
|
||||
);
|
||||
const result = await this.userBlocks.addMute(targetHex, viewerPubkey);
|
||||
if (result?.already) {
|
||||
this.callbacks.showSuccess("You already muted this creator.");
|
||||
} else {
|
||||
@@ -1251,21 +1249,18 @@ export default class MoreMenuController {
|
||||
this.refreshActiveMuteButtons({ author: targetHex });
|
||||
} catch (error) {
|
||||
switch (error?.code) {
|
||||
case "viewer-not-logged-in":
|
||||
this.callbacks.showError("Please login to manage your mute list.");
|
||||
break;
|
||||
case "invalid-target":
|
||||
case "invalid":
|
||||
this.callbacks.showError("Unable to determine the creator to mute.");
|
||||
break;
|
||||
case "self":
|
||||
this.callbacks.showError("You cannot mute yourself.");
|
||||
break;
|
||||
case "nostr-extension-missing":
|
||||
case "signer-missing":
|
||||
this.callbacks.showError(
|
||||
"A Nostr extension is required to update your mute list.",
|
||||
);
|
||||
break;
|
||||
case "extension-permission-denied":
|
||||
case "extension-encryption-permission-denied":
|
||||
this.callbacks.showError(
|
||||
"Allow your Nostr extension to sign events before muting creators.",
|
||||
);
|
||||
@@ -1293,9 +1288,7 @@ export default class MoreMenuController {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.moderationService.removeAuthorFromViewerMuteList(
|
||||
targetHex,
|
||||
);
|
||||
const result = await this.userBlocks.removeMute(targetHex, viewerPubkey);
|
||||
if (result?.already) {
|
||||
this.callbacks.showSuccess("This creator is not on your mute list.");
|
||||
} else {
|
||||
@@ -1319,15 +1312,12 @@ export default class MoreMenuController {
|
||||
this.refreshActiveMuteButtons({ author: targetHex });
|
||||
} catch (error) {
|
||||
switch (error?.code) {
|
||||
case "viewer-not-logged-in":
|
||||
this.callbacks.showError("Please login to manage your mute list.");
|
||||
break;
|
||||
case "nostr-extension-missing":
|
||||
case "signer-missing":
|
||||
this.callbacks.showError(
|
||||
"A Nostr extension is required to update your mute list.",
|
||||
);
|
||||
break;
|
||||
case "extension-permission-denied":
|
||||
case "extension-encryption-permission-denied":
|
||||
this.callbacks.showError(
|
||||
"Allow your Nostr extension to sign events before updating your mute list.",
|
||||
);
|
||||
|
||||
@@ -1899,7 +1899,7 @@ export class ProfileModalController {
|
||||
);
|
||||
ensureAriaLabel(this.profileSubscriptionsRefreshBtn, "Refresh subscriptions");
|
||||
ensureAriaLabel(this.profileFriendsRefreshBtn, "Refresh friends");
|
||||
ensureAriaLabel(this.profileBlockedRefreshBtn, "Refresh blocked creators");
|
||||
ensureAriaLabel(this.profileBlockedRefreshBtn, "Refresh muted & blocked users");
|
||||
ensureAriaLabel(this.adminModeratorsRefreshBtn, "Refresh moderators");
|
||||
ensureAriaLabel(this.adminWhitelistRefreshBtn, "Refresh whitelist");
|
||||
ensureAriaLabel(this.adminBlacklistRefreshBtn, "Refresh blacklist");
|
||||
@@ -9519,9 +9519,9 @@ export class ProfileModalController {
|
||||
? "Your Nostr extension must support NIP-04 to manage private lists."
|
||||
: mutationResult?.error?.code ===
|
||||
"extension-encryption-permission-denied"
|
||||
? "Your Nostr extension must allow encryption to update your block list."
|
||||
? "Your Nostr extension must allow encryption to update your mute/block list."
|
||||
: mutationResult?.error?.message ||
|
||||
"Failed to update your block list. Please try again.";
|
||||
"Failed to update your mute/block list. Please try again.";
|
||||
context.error = mutationResult?.error || null;
|
||||
context.reason = mutationResult?.reason || "service-error";
|
||||
if (message) {
|
||||
@@ -9541,8 +9541,8 @@ export class ProfileModalController {
|
||||
error?.code === "nip04-missing"
|
||||
? "Your Nostr extension must support NIP-04 to manage private lists."
|
||||
: error?.code === "extension-encryption-permission-denied"
|
||||
? "Your Nostr extension must allow encryption to update your block list."
|
||||
: "Failed to update your block list. Please try again.";
|
||||
? "Your Nostr extension must allow encryption to update your mute/block list."
|
||||
: "Failed to update your mute/block list. Please try again.";
|
||||
this.showError(message);
|
||||
}
|
||||
|
||||
@@ -9585,18 +9585,18 @@ export class ProfileModalController {
|
||||
});
|
||||
|
||||
if (mutationResult?.ok) {
|
||||
this.showSuccess("Creator removed from your block list.");
|
||||
this.showSuccess("Creator removed from your mute/block list.");
|
||||
} else if (mutationResult?.reason === "not-blocked") {
|
||||
this.showSuccess("Creator already removed from your block list.");
|
||||
this.showSuccess("Creator already removed from your mute/block list.");
|
||||
} else if (mutationResult?.error) {
|
||||
const message =
|
||||
mutationResult.error.code === "nip04-missing"
|
||||
? "Your Nostr extension must support NIP-04 to manage private lists."
|
||||
: mutationResult.error.code ===
|
||||
"extension-encryption-permission-denied"
|
||||
? "Your Nostr extension must allow encryption to update your block list."
|
||||
? "Your Nostr extension must allow encryption to update your mute/block list."
|
||||
: mutationResult.error.message ||
|
||||
"Failed to update your block list. Please try again.";
|
||||
"Failed to update your mute/block list. Please try again.";
|
||||
if (message) {
|
||||
this.showError(message);
|
||||
}
|
||||
@@ -9612,8 +9612,8 @@ export class ProfileModalController {
|
||||
error?.code === "nip04-missing"
|
||||
? "Your Nostr extension must support NIP-04 to manage private lists."
|
||||
: error?.code === "extension-encryption-permission-denied"
|
||||
? "Your Nostr extension must allow encryption to update your block list."
|
||||
: "Failed to update your block list. Please try again.";
|
||||
? "Your Nostr extension must allow encryption to update your mute/block list."
|
||||
: "Failed to update your mute/block list. Please try again.";
|
||||
this.showError(message);
|
||||
}
|
||||
}
|
||||
|
||||
+198
-278
@@ -677,6 +677,8 @@ function normalizeHex(pubkey) {
|
||||
class UserBlockListManager {
|
||||
constructor() {
|
||||
this.blockedPubkeys = new Set();
|
||||
this._privateBlocks = new Set();
|
||||
this._publicMutes = new Set();
|
||||
this.blockEventId = null;
|
||||
this.blockEventCreatedAt = null;
|
||||
this.lastPublishedCreatedAt = null;
|
||||
@@ -705,6 +707,8 @@ class UserBlockListManager {
|
||||
reset() {
|
||||
this.stopBlockListSubscription();
|
||||
this.blockedPubkeys.clear();
|
||||
this._privateBlocks.clear();
|
||||
this._publicMutes.clear();
|
||||
this.blockEventId = null;
|
||||
this.blockEventCreatedAt = null;
|
||||
this.lastPublishedCreatedAt = null;
|
||||
@@ -775,6 +779,14 @@ class UserBlockListManager {
|
||||
return Array.from(this.blockedPubkeys);
|
||||
}
|
||||
|
||||
getPublicMutes() {
|
||||
return Array.from(this._publicMutes);
|
||||
}
|
||||
|
||||
getPrivateBlocks() {
|
||||
return Array.from(this._privateBlocks);
|
||||
}
|
||||
|
||||
scheduleDecryptRetry(userPubkey, detail = {}) {
|
||||
if (!userPubkey) {
|
||||
return;
|
||||
@@ -922,7 +934,10 @@ class UserBlockListManager {
|
||||
const hasLocalData = !!localData;
|
||||
|
||||
if (localData) {
|
||||
this.blockedPubkeys = localData.blockedPubkeys;
|
||||
this._privateBlocks = localData.blockedPubkeys;
|
||||
this._publicMutes = new Set(); // Local cache structure currently only stores 'blockedPubkeys'
|
||||
this.blockedPubkeys = new Set(this._privateBlocks);
|
||||
|
||||
if (Number.isFinite(localData.createdAt)) {
|
||||
this.blockEventCreatedAt = localData.createdAt;
|
||||
}
|
||||
@@ -934,9 +949,10 @@ class UserBlockListManager {
|
||||
});
|
||||
}
|
||||
|
||||
const applyBlockedPubkeys = (nextValues, meta = {}) => {
|
||||
const nextSet = new Set(Array.isArray(nextValues) ? nextValues : []);
|
||||
this.blockedPubkeys = nextSet;
|
||||
const applySets = (privateSet, publicSet, meta = {}) => {
|
||||
this._privateBlocks = privateSet;
|
||||
this._publicMutes = publicSet;
|
||||
this.blockedPubkeys = new Set([...privateSet, ...publicSet]);
|
||||
|
||||
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
||||
action: "sync",
|
||||
@@ -1147,6 +1163,8 @@ class UserBlockListManager {
|
||||
) => {
|
||||
const previousState = {
|
||||
blockedPubkeys: new Set(this.blockedPubkeys),
|
||||
privateBlocks: new Set(this._privateBlocks),
|
||||
publicMutes: new Set(this._publicMutes),
|
||||
blockEventId: this.blockEventId,
|
||||
blockEventCreatedAt: this.blockEventCreatedAt,
|
||||
loaded: this.loaded,
|
||||
@@ -1166,7 +1184,7 @@ class UserBlockListManager {
|
||||
if (!this.loaded) {
|
||||
this.blockEventId = null;
|
||||
this.blockEventCreatedAt = null;
|
||||
applyBlockedPubkeys([], { source, reason: "empty-result" });
|
||||
applySets(new Set(), new Set(), { source, reason: "empty-result" });
|
||||
}
|
||||
emitStatus({ status: "applied-empty", source });
|
||||
return;
|
||||
@@ -1191,37 +1209,37 @@ class UserBlockListManager {
|
||||
if (!this.loaded) {
|
||||
this.blockEventId = null;
|
||||
this.blockEventCreatedAt = null;
|
||||
applyBlockedPubkeys([], { source, reason: "empty-result" });
|
||||
applySets(new Set(), new Set(), { source, reason: "empty-result" });
|
||||
}
|
||||
emitStatus({ status: "applied-empty", source });
|
||||
return;
|
||||
}
|
||||
|
||||
// Separate by logic (Block vs Mute)
|
||||
// Block: Kind 10000 with d=user-blocks OR Kind 30002 (legacy)
|
||||
const blockEvents = validEvents
|
||||
// Legacy Block: Kind 10000 with d=user-blocks OR Kind 30002
|
||||
const legacyBlockEvents = validEvents
|
||||
.filter((e) => isTaggedBlockListEvent(e) || e.kind === 30002)
|
||||
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
|
||||
|
||||
// Mute: Kind 10000 WITHOUT d=user-blocks
|
||||
const muteEvents = validEvents
|
||||
// Standard Mute: Kind 10000 WITHOUT d=user-blocks
|
||||
const standardMuteEvents = validEvents
|
||||
.filter((e) => e.kind === 10000 && !isTaggedBlockListEvent(e))
|
||||
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
|
||||
|
||||
const newestBlock = blockEvents[0] || null;
|
||||
const newestMute = muteEvents[0] || null;
|
||||
const newestLegacy = legacyBlockEvents[0] || null;
|
||||
const newestStandard = standardMuteEvents[0] || null;
|
||||
|
||||
const newestMuteTime = Number.isFinite(newestMute?.created_at)
|
||||
? newestMute.created_at
|
||||
const newestStandardTime = Number.isFinite(newestStandard?.created_at)
|
||||
? newestStandard.created_at
|
||||
: 0;
|
||||
const newestBlockTime = Number.isFinite(newestBlock?.created_at)
|
||||
? newestBlock.created_at
|
||||
const newestLegacyTime = Number.isFinite(newestLegacy?.created_at)
|
||||
? newestLegacy.created_at
|
||||
: 0;
|
||||
|
||||
// The "canonical" newest event for stale checks is simply the latest of either.
|
||||
const newestOverall =
|
||||
newestMuteTime >= newestBlockTime ? newestMute : newestBlock;
|
||||
const newestCreatedAt = Math.max(newestMuteTime, newestBlockTime);
|
||||
newestStandardTime >= newestLegacyTime ? newestStandard : newestLegacy;
|
||||
const newestCreatedAt = Math.max(newestStandardTime, newestLegacyTime);
|
||||
|
||||
// We accept this state.
|
||||
this.blockEventId = newestOverall?.id || null;
|
||||
@@ -1233,12 +1251,6 @@ class UserBlockListManager {
|
||||
const isContentEmpty = !ev.content || !ev.content.trim();
|
||||
|
||||
if (isContentEmpty) {
|
||||
// Handle tag-only lists (e.g. Kind 10000 with empty content but populated p tags)
|
||||
if (Array.isArray(ev.tags) && ev.tags.length) {
|
||||
return extractPubkeysFromTags(ev.tags, normalized, {
|
||||
limit: MAX_BLOCKLIST_ENTRIES,
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1275,8 +1287,6 @@ class UserBlockListManager {
|
||||
}
|
||||
|
||||
if (!decryptedText) {
|
||||
// If decryption fails completely for a present event, we might want to log it
|
||||
// but return empty for that specific event.
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1285,14 +1295,15 @@ class UserBlockListManager {
|
||||
});
|
||||
};
|
||||
|
||||
const mergedPubkeys = new Set();
|
||||
const nextPrivateBlocks = new Set();
|
||||
const nextPublicMutes = new Set();
|
||||
let decryptionError = null;
|
||||
const decryptMeta = {
|
||||
mute: { eventId: newestMute?.id || null },
|
||||
block: { eventId: newestBlock?.id || null },
|
||||
standard: { eventId: newestStandard?.id || null },
|
||||
legacy: { eventId: newestLegacy?.id || null },
|
||||
};
|
||||
const resolveSignerStatus = () => {
|
||||
const statuses = [decryptMeta.mute.signerStatus, decryptMeta.block.signerStatus]
|
||||
const statuses = [decryptMeta.standard.signerStatus, decryptMeta.legacy.signerStatus]
|
||||
.filter(Boolean);
|
||||
if (!statuses.length) {
|
||||
return "unknown";
|
||||
@@ -1308,8 +1319,8 @@ class UserBlockListManager {
|
||||
|
||||
try {
|
||||
const decryptPromise = Promise.all([
|
||||
decryptEvent(newestMute, decryptMeta.mute),
|
||||
decryptEvent(newestBlock, decryptMeta.block),
|
||||
decryptEvent(newestStandard, decryptMeta.standard),
|
||||
decryptEvent(newestLegacy, decryptMeta.legacy),
|
||||
]);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
@@ -1325,13 +1336,20 @@ class UserBlockListManager {
|
||||
),
|
||||
);
|
||||
|
||||
const [muteList, blockList] = await Promise.race([
|
||||
const [standardDecrypted, legacyDecrypted] = await Promise.race([
|
||||
decryptPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
muteList.forEach((pk) => mergedPubkeys.add(pk));
|
||||
blockList.forEach((pk) => mergedPubkeys.add(pk));
|
||||
// Only apply the newest list to avoid zombie data from stale lists
|
||||
if (newestOverall === newestStandard && newestStandard) {
|
||||
const publicTags = extractPubkeysFromTags(newestStandard.tags, normalized, { limit: MAX_BLOCKLIST_ENTRIES });
|
||||
publicTags.forEach((pk) => nextPublicMutes.add(pk));
|
||||
standardDecrypted.forEach((pk) => nextPrivateBlocks.add(pk));
|
||||
} else if (newestLegacy) {
|
||||
legacyDecrypted.forEach((pk) => nextPrivateBlocks.add(pk));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// If permission denied or critical failure
|
||||
decryptionError = error;
|
||||
@@ -1342,6 +1360,8 @@ class UserBlockListManager {
|
||||
const signerStatus =
|
||||
decryptionError.signerStatus || resolveSignerStatus();
|
||||
this.blockedPubkeys = previousState.blockedPubkeys;
|
||||
this._privateBlocks = previousState.privateBlocks;
|
||||
this._publicMutes = previousState.publicMutes;
|
||||
this.blockEventId = previousState.blockEventId;
|
||||
this.blockEventCreatedAt = previousState.blockEventCreatedAt;
|
||||
this.loaded = true;
|
||||
@@ -1352,7 +1372,7 @@ class UserBlockListManager {
|
||||
reason: "decryptor-permission",
|
||||
allowPermissionPrompt,
|
||||
mode: loadMode,
|
||||
events: [newestMute?.id, newestBlock?.id].filter(Boolean),
|
||||
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
||||
error: decryptionError,
|
||||
});
|
||||
emitStatus({ status: "settled" });
|
||||
@@ -1361,11 +1381,15 @@ class UserBlockListManager {
|
||||
if (decryptionError.code === "user-blocklist-decrypt-timeout") {
|
||||
const signerStatus =
|
||||
decryptionError.signerStatus || resolveSignerStatus();
|
||||
applyBlockedPubkeys([], {
|
||||
// If decrypt times out, we can still load public mutes from standard event tags?
|
||||
// For safety, we probably shouldn't partially load if we expect encryption.
|
||||
// But preserving existing state is safer or applying empty.
|
||||
// Current logic applies empty.
|
||||
applySets(new Set(), new Set(), {
|
||||
source,
|
||||
reason: "decrypt-timeout",
|
||||
signerStatus,
|
||||
events: [newestMute?.id, newestBlock?.id].filter(Boolean),
|
||||
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
||||
});
|
||||
this.loaded = true;
|
||||
emitStatus({
|
||||
@@ -1379,7 +1403,7 @@ class UserBlockListManager {
|
||||
"[UserBlockList] Decryption timed out; applying empty list and retrying in background.",
|
||||
{
|
||||
signerStatus,
|
||||
events: [newestMute?.id, newestBlock?.id].filter(Boolean),
|
||||
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
||||
},
|
||||
);
|
||||
this.scheduleDecryptRetry(normalized, {
|
||||
@@ -1393,6 +1417,8 @@ class UserBlockListManager {
|
||||
decryptionError,
|
||||
);
|
||||
this.blockedPubkeys = previousState.blockedPubkeys;
|
||||
this._privateBlocks = previousState.privateBlocks;
|
||||
this._publicMutes = previousState.publicMutes;
|
||||
this.blockEventId = previousState.blockEventId;
|
||||
this.blockEventCreatedAt = previousState.blockEventCreatedAt;
|
||||
this.loaded = true;
|
||||
@@ -1408,22 +1434,17 @@ class UserBlockListManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMute = Boolean(newestMute);
|
||||
const hasBlock = Boolean(newestBlock);
|
||||
const isStandardEffectiveEmpty = !newestStandard || ((!newestStandard.content || !newestStandard.content.trim()) && (!newestStandard.tags || !extractPubkeysFromTags(newestStandard.tags, normalized).length));
|
||||
const isLegacyEffectiveEmpty = !newestLegacy || ((!newestLegacy.content || !newestLegacy.content.trim()) && (!newestLegacy.tags || !extractPubkeysFromTags(newestLegacy.tags, normalized).length));
|
||||
|
||||
// Check if we should log "empty-events" reason
|
||||
// Effectively empty means: missing content AND missing valid tags
|
||||
const isMuteEffectiveEmpty = !newestMute || ((!newestMute.content || !newestMute.content.trim()) && (!newestMute.tags || !extractPubkeysFromTags(newestMute.tags, normalized).length));
|
||||
const isBlockEffectiveEmpty = !newestBlock || ((!newestBlock.content || !newestBlock.content.trim()) && (!newestBlock.tags || !extractPubkeysFromTags(newestBlock.tags, normalized).length));
|
||||
|
||||
const effectiveReason = (isMuteEffectiveEmpty && isBlockEffectiveEmpty)
|
||||
const effectiveReason = (isStandardEffectiveEmpty && isLegacyEffectiveEmpty)
|
||||
? "empty-events"
|
||||
: "applied-merge";
|
||||
|
||||
applyBlockedPubkeys(Array.from(mergedPubkeys), {
|
||||
applySets(nextPrivateBlocks, nextPublicMutes, {
|
||||
source,
|
||||
reason: effectiveReason,
|
||||
events: [newestMute?.id, newestBlock?.id].filter(Boolean),
|
||||
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
||||
});
|
||||
|
||||
if (effectiveReason === "empty-events") {
|
||||
@@ -1513,7 +1534,7 @@ class UserBlockListManager {
|
||||
return;
|
||||
}
|
||||
profileCache.setProfileData(normalizedActorHex, "blocks", {
|
||||
blockedPubkeys: Array.from(this.blockedPubkeys),
|
||||
blockedPubkeys: Array.from(this._privateBlocks), // Local cache historically stores private blocks
|
||||
createdAt: this.blockEventCreatedAt,
|
||||
version: 1,
|
||||
});
|
||||
@@ -1635,13 +1656,21 @@ class UserBlockListManager {
|
||||
return { ok: true, seeded: false, reason: "no-candidates" };
|
||||
}
|
||||
|
||||
const snapshot = new Set(this.blockedPubkeys);
|
||||
additions.forEach((hex) => this.blockedPubkeys.add(hex));
|
||||
const snapshotPrivate = new Set(this._privateBlocks);
|
||||
const snapshotPublic = new Set(this._publicMutes);
|
||||
const snapshotUnion = new Set(this.blockedPubkeys);
|
||||
|
||||
additions.forEach((hex) => {
|
||||
this._privateBlocks.add(hex);
|
||||
this.blockedPubkeys.add(hex);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.publishBlockList(actorHex);
|
||||
} catch (error) {
|
||||
this.blockedPubkeys = snapshot;
|
||||
this._privateBlocks = snapshotPrivate;
|
||||
this._publicMutes = snapshotPublic;
|
||||
this.blockedPubkeys = snapshotUnion;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1716,7 +1745,10 @@ class UserBlockListManager {
|
||||
return { ok: true, seeded: true, addedPubkeys: [] };
|
||||
}
|
||||
|
||||
additions.forEach((hex) => this.blockedPubkeys.add(hex));
|
||||
additions.forEach((hex) => {
|
||||
this._privateBlocks.add(hex);
|
||||
this.blockedPubkeys.add(hex);
|
||||
});
|
||||
|
||||
this._setSeeded(actorHex, true);
|
||||
additions.forEach((hex) => this._clearSeedRemoval(actorHex, hex));
|
||||
@@ -1787,7 +1819,10 @@ class UserBlockListManager {
|
||||
return { ok: true, seeded: state.seeded, reason: "no-candidates" };
|
||||
}
|
||||
|
||||
additions.forEach((hex) => this.blockedPubkeys.add(hex));
|
||||
additions.forEach((hex) => {
|
||||
this._privateBlocks.add(hex);
|
||||
this.blockedPubkeys.add(hex);
|
||||
});
|
||||
|
||||
this._setSeeded(actorHex, true);
|
||||
this._saveLocal(actorHex);
|
||||
@@ -1808,6 +1843,22 @@ class UserBlockListManager {
|
||||
}
|
||||
|
||||
async addBlock(targetPubkey, userPubkey) {
|
||||
return this.modifyList("block", targetPubkey, userPubkey);
|
||||
}
|
||||
|
||||
async removeBlock(targetPubkey, userPubkey) {
|
||||
return this.modifyList("unblock", targetPubkey, userPubkey);
|
||||
}
|
||||
|
||||
async addMute(targetPubkey, userPubkey) {
|
||||
return this.modifyList("mute", targetPubkey, userPubkey);
|
||||
}
|
||||
|
||||
async removeMute(targetPubkey, userPubkey) {
|
||||
return this.modifyList("unmute", targetPubkey, userPubkey);
|
||||
}
|
||||
|
||||
async modifyList(action, targetPubkey, userPubkey) {
|
||||
const actorHex = normalizeHex(userPubkey);
|
||||
if (!actorHex) {
|
||||
throw new Error("Invalid user pubkey.");
|
||||
@@ -1821,19 +1872,45 @@ class UserBlockListManager {
|
||||
}
|
||||
|
||||
if (actorHex === targetHex) {
|
||||
const err = new Error("Cannot block yourself.");
|
||||
const err = new Error("Cannot block/mute yourself.");
|
||||
err.code = "self";
|
||||
throw err;
|
||||
}
|
||||
|
||||
await this.ensureLoaded(actorHex);
|
||||
|
||||
if (this.blockedPubkeys.has(targetHex)) {
|
||||
const snapshotPrivate = new Set(this._privateBlocks);
|
||||
const snapshotPublic = new Set(this._publicMutes);
|
||||
const snapshotUnion = new Set(this.blockedPubkeys);
|
||||
|
||||
let changed = false;
|
||||
|
||||
if (action === "block") {
|
||||
if (this._privateBlocks.has(targetHex)) {
|
||||
return { ok: true, already: true };
|
||||
}
|
||||
this._privateBlocks.add(targetHex);
|
||||
changed = true;
|
||||
} else if (action === "unblock") {
|
||||
const inPrivate = this._privateBlocks.delete(targetHex);
|
||||
const inPublic = this._publicMutes.delete(targetHex);
|
||||
changed = inPrivate || inPublic;
|
||||
} else if (action === "mute") {
|
||||
if (this._publicMutes.has(targetHex)) {
|
||||
return { ok: true, already: true };
|
||||
}
|
||||
this._publicMutes.add(targetHex);
|
||||
changed = true;
|
||||
} else if (action === "unmute") {
|
||||
const inPublic = this._publicMutes.delete(targetHex);
|
||||
changed = inPublic;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { ok: true, already: true };
|
||||
}
|
||||
|
||||
const snapshot = new Set(this.blockedPubkeys);
|
||||
this.blockedPubkeys.add(targetHex);
|
||||
this.blockedPubkeys = new Set([...this._privateBlocks, ...this._publicMutes]);
|
||||
|
||||
const loggedInPubkey = normalizeHex(nostrClient?.pubkey);
|
||||
const sessionPubkey = normalizeHex(nostrClient?.sessionActor?.pubkey);
|
||||
@@ -1843,192 +1920,39 @@ class UserBlockListManager {
|
||||
if (isSessionActor) {
|
||||
this._saveLocal(actorHex);
|
||||
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
||||
action: "block",
|
||||
action,
|
||||
targetPubkey: targetHex,
|
||||
actorPubkey: actorHex,
|
||||
});
|
||||
this._clearSeedRemoval(actorHex, targetHex);
|
||||
if (action === "block" || action === "mute") {
|
||||
this._clearSeedRemoval(actorHex, targetHex);
|
||||
} else {
|
||||
this._addSeedRemoval(actorHex, targetHex);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.publishBlockList(actorHex);
|
||||
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
||||
action: "block",
|
||||
action,
|
||||
targetPubkey: targetHex,
|
||||
actorPubkey: actorHex,
|
||||
});
|
||||
this._clearSeedRemoval(actorHex, targetHex);
|
||||
if (action === "block" || action === "mute") {
|
||||
this._clearSeedRemoval(actorHex, targetHex);
|
||||
} else {
|
||||
this._addSeedRemoval(actorHex, targetHex);
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
this.blockedPubkeys = snapshot;
|
||||
this._privateBlocks = snapshotPrivate;
|
||||
this._publicMutes = snapshotPublic;
|
||||
this.blockedPubkeys = snapshotUnion;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async removeBlock(targetPubkey, userPubkey) {
|
||||
const actorHex = normalizeHex(userPubkey);
|
||||
if (!actorHex) {
|
||||
throw new Error("Invalid user pubkey.");
|
||||
}
|
||||
|
||||
const targetHex = normalizeHex(targetPubkey);
|
||||
if (!targetHex) {
|
||||
return { ok: true, already: true };
|
||||
}
|
||||
|
||||
await this.ensureLoaded(actorHex);
|
||||
|
||||
if (!this.blockedPubkeys.has(targetHex)) {
|
||||
return { ok: true, already: true };
|
||||
}
|
||||
|
||||
const snapshot = new Set(this.blockedPubkeys);
|
||||
this.blockedPubkeys.delete(targetHex);
|
||||
|
||||
const loggedInPubkey = normalizeHex(nostrClient?.pubkey);
|
||||
const sessionPubkey = normalizeHex(nostrClient?.sessionActor?.pubkey);
|
||||
const isSessionActor =
|
||||
!!actorHex && !loggedInPubkey && actorHex === sessionPubkey;
|
||||
|
||||
if (isSessionActor) {
|
||||
this._saveLocal(actorHex);
|
||||
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
||||
action: "unblock",
|
||||
targetPubkey: targetHex,
|
||||
actorPubkey: actorHex,
|
||||
});
|
||||
this._addSeedRemoval(actorHex, targetHex);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.publishBlockList(actorHex);
|
||||
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
||||
action: "unblock",
|
||||
targetPubkey: targetHex,
|
||||
actorPubkey: actorHex,
|
||||
});
|
||||
this._addSeedRemoval(actorHex, targetHex);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
this.blockedPubkeys = snapshot;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async publishMuteListSnapshot({
|
||||
signer,
|
||||
ownerPubkey,
|
||||
blockedPubkeys = [],
|
||||
createdAt,
|
||||
plaintext = "",
|
||||
onStatus,
|
||||
} = {}) {
|
||||
// nostrClient imported from nostrClientFacade
|
||||
if (isSessionActor(nostrClient)) {
|
||||
const error = new Error(
|
||||
"Publishing mute lists is not allowed for session actors."
|
||||
);
|
||||
error.code = "session-actor-publish-blocked";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = normalizeHex(ownerPubkey);
|
||||
if (!owner || !signer || typeof signer.signEvent !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sanitizedTags = sanitizeMuteTags(blockedPubkeys, owner);
|
||||
const pTags = sanitizedTags.map((tag) => tag[1]);
|
||||
|
||||
const timestamp = Number.isFinite(createdAt)
|
||||
? Math.max(0, Math.floor(createdAt))
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const payloadText = typeof plaintext === "string" ? plaintext : "";
|
||||
let encryptedContent = "";
|
||||
let encryptionTagValue = "";
|
||||
|
||||
if (payloadText) {
|
||||
const encryptionCandidates = [];
|
||||
|
||||
if (typeof signer.nip44Encrypt === "function") {
|
||||
encryptionCandidates.push({ tag: "nip44_v2", encrypt: signer.nip44Encrypt });
|
||||
}
|
||||
|
||||
if (typeof signer.nip04Encrypt === "function") {
|
||||
encryptionCandidates.push({ tag: "nip04", encrypt: signer.nip04Encrypt });
|
||||
}
|
||||
|
||||
for (const candidate of encryptionCandidates) {
|
||||
try {
|
||||
const encrypted = await candidate.encrypt(owner, payloadText);
|
||||
if (typeof encrypted === "string" && encrypted) {
|
||||
encryptedContent = encrypted;
|
||||
encryptionTagValue = candidate.tag;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore encryption failures and fall back to the next candidate.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const event = buildMuteListEvent({
|
||||
pubkey: owner,
|
||||
created_at: timestamp,
|
||||
pTags,
|
||||
content: encryptedContent,
|
||||
encrypted: !!encryptionTagValue,
|
||||
encryptionTag: encryptionTagValue,
|
||||
});
|
||||
|
||||
onStatus?.({ status: "mute-publishing" });
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
signedEvent = await signer.signEvent(event);
|
||||
} catch (error) {
|
||||
const wrapped =
|
||||
error instanceof Error ? error : new Error(String(error || "mute-signature-failed"));
|
||||
wrapped.code = wrapped.code || "mute-signature-failed";
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
nostrClient.relays,
|
||||
signedEvent,
|
||||
);
|
||||
|
||||
const summary = assertAnyRelayAccepted(publishResults, { context: "mute list" });
|
||||
|
||||
if (summary.failed.length) {
|
||||
summary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
userLogger.warn(
|
||||
`[UserBlockList] Mute list not accepted by ${url}: ${reason}`,
|
||||
relayError,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.muteEventId = signedEvent.id;
|
||||
this.muteEventCreatedAt = Number.isFinite(signedEvent?.created_at)
|
||||
? signedEvent.created_at
|
||||
: event.created_at;
|
||||
|
||||
onStatus?.({ status: "mute-published", event: signedEvent });
|
||||
|
||||
return signedEvent;
|
||||
}
|
||||
|
||||
async publishBlockList(userPubkey, options = {}) {
|
||||
if (isSessionActor(nostrClient)) {
|
||||
const error = new Error(
|
||||
@@ -2085,11 +2009,10 @@ class UserBlockListManager {
|
||||
throw new Error("Invalid user pubkey.");
|
||||
}
|
||||
|
||||
const sanitizedTags = sanitizeMuteTags(this.blockedPubkeys, normalized);
|
||||
const payload = {
|
||||
blockedPubkeys: sanitizedTags.map(([, target]) => target),
|
||||
};
|
||||
const plaintext = serializeBlockListTagMatrix(sanitizedTags, normalized, {
|
||||
// Encrypt private blocks
|
||||
const privateBlockArray = Array.from(this._privateBlocks);
|
||||
const sanitizedPrivateTags = sanitizeMuteTags(privateBlockArray, normalized);
|
||||
const plaintext = serializeBlockListTagMatrix(sanitizedPrivateTags, normalized, {
|
||||
logDiscarded: false,
|
||||
});
|
||||
|
||||
@@ -2110,41 +2033,44 @@ class UserBlockListManager {
|
||||
registerEncryptor("nip04", (value) => signer.nip04Encrypt(normalized, value));
|
||||
}
|
||||
|
||||
if (!encryptors.length) {
|
||||
const err = new Error(
|
||||
"An encryption-capable signer is required to update the block list."
|
||||
);
|
||||
err.code = "block-list-missing-encryptor";
|
||||
throw err;
|
||||
}
|
||||
|
||||
let cipherText = "";
|
||||
let encryptionScheme = "";
|
||||
const seenSchemes = new Set();
|
||||
const encryptionErrors = [];
|
||||
|
||||
for (const candidate of encryptors) {
|
||||
if (seenSchemes.has(candidate.scheme)) {
|
||||
continue;
|
||||
// Only attempt encryption if we have private blocks
|
||||
if (privateBlockArray.length > 0) {
|
||||
if (!encryptors.length) {
|
||||
const err = new Error(
|
||||
"An encryption-capable signer is required to update private blocks."
|
||||
);
|
||||
err.code = "block-list-missing-encryptor";
|
||||
throw err;
|
||||
}
|
||||
seenSchemes.add(candidate.scheme);
|
||||
try {
|
||||
const encrypted = await candidate.handler(plaintext);
|
||||
if (typeof encrypted === "string" && encrypted) {
|
||||
cipherText = encrypted;
|
||||
encryptionScheme = candidate.scheme;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
encryptionErrors.push({ scheme: candidate.scheme, error });
|
||||
}
|
||||
}
|
||||
|
||||
if (!cipherText) {
|
||||
const err = new Error("Failed to encrypt block list.");
|
||||
err.code = "block-list-encrypt-failed";
|
||||
err.cause = encryptionErrors;
|
||||
throw err;
|
||||
for (const candidate of encryptors) {
|
||||
if (seenSchemes.has(candidate.scheme)) {
|
||||
continue;
|
||||
}
|
||||
seenSchemes.add(candidate.scheme);
|
||||
try {
|
||||
const encrypted = await candidate.handler(plaintext);
|
||||
if (typeof encrypted === "string" && encrypted) {
|
||||
cipherText = encrypted;
|
||||
encryptionScheme = candidate.scheme;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
encryptionErrors.push({ scheme: candidate.scheme, error });
|
||||
}
|
||||
}
|
||||
|
||||
if (!cipherText) {
|
||||
const err = new Error("Failed to encrypt block list.");
|
||||
err.code = "block-list-encrypt-failed";
|
||||
err.cause = encryptionErrors;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const encryptionTagValue =
|
||||
@@ -2156,11 +2082,18 @@ class UserBlockListManager {
|
||||
? "nip04"
|
||||
: undefined;
|
||||
|
||||
const event = buildBlockListEvent({
|
||||
// Build event with both public mutes (p tags) and private blocks (encrypted content)
|
||||
const publicMuteArray = Array.from(this._publicMutes);
|
||||
const sanitizedPublicTags = sanitizeMuteTags(publicMuteArray, normalized);
|
||||
const pTags = sanitizedPublicTags.map((tag) => tag[1]);
|
||||
|
||||
const event = buildMuteListEvent({
|
||||
pubkey: normalized,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: cipherText,
|
||||
encryption: encryptionTagValue,
|
||||
pTags,
|
||||
encrypted: !!encryptionTagValue,
|
||||
encryptionTag: encryptionTagValue,
|
||||
});
|
||||
|
||||
const signedEvent = await signer.signEvent(event);
|
||||
@@ -2174,14 +2107,14 @@ class UserBlockListManager {
|
||||
let publishSummary;
|
||||
try {
|
||||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "block list",
|
||||
context: "mute list",
|
||||
});
|
||||
} catch (publishError) {
|
||||
if (publishError?.relayFailures?.length) {
|
||||
publishError.relayFailures.forEach(
|
||||
({ url, error: relayError, reason }) => {
|
||||
userLogger.error(
|
||||
`[UserBlockList] Block list rejected by ${url}: ${reason}`,
|
||||
`[UserBlockList] Mute list rejected by ${url}: ${reason}`,
|
||||
relayError || reason
|
||||
);
|
||||
}
|
||||
@@ -2199,7 +2132,7 @@ class UserBlockListManager {
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
userLogger.warn(
|
||||
`[UserBlockList] Block list not accepted by ${url}: ${reason}`,
|
||||
`[UserBlockList] Mute list not accepted by ${url}: ${reason}`,
|
||||
relayError
|
||||
);
|
||||
});
|
||||
@@ -2212,19 +2145,6 @@ class UserBlockListManager {
|
||||
this.lastPublishedCreatedAt = this.blockEventCreatedAt;
|
||||
onStatus?.({ status: "published", event: signedEvent });
|
||||
|
||||
try {
|
||||
await this.publishMuteListSnapshot({
|
||||
signer,
|
||||
ownerPubkey: normalized,
|
||||
blockedPubkeys: payload.blockedPubkeys,
|
||||
createdAt: event.created_at,
|
||||
plaintext,
|
||||
onStatus,
|
||||
});
|
||||
} catch (muteError) {
|
||||
userLogger.warn("[UserBlockList] Failed to publish mute list", muteError);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.loadBlocks(normalized, {
|
||||
since: this.lastPublishedCreatedAt ?? undefined,
|
||||
|
||||
@@ -394,10 +394,38 @@ test("viewer mute list publishes and updates aggregation", async (t) => {
|
||||
}),
|
||||
};
|
||||
|
||||
const mockUserBlocks = {
|
||||
async addMute(pubkey, viewer) {
|
||||
// Simulate the publish that userBlocks would do
|
||||
const event = {
|
||||
kind: 10000,
|
||||
pubkey: viewer,
|
||||
tags: [["p", pubkey]],
|
||||
content: "",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await nostrClient.pool.publish([], event);
|
||||
return { ok: true };
|
||||
},
|
||||
async removeMute(pubkey, viewer) {
|
||||
const event = {
|
||||
kind: 10000,
|
||||
pubkey: viewer,
|
||||
tags: [],
|
||||
content: "",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await nostrClient.pool.publish([], event);
|
||||
return { ok: true };
|
||||
},
|
||||
on: () => () => {},
|
||||
};
|
||||
|
||||
const service = new ModerationService({
|
||||
logger: () => {},
|
||||
nostrClient,
|
||||
userLogger: noopUserLogger,
|
||||
userBlocks: mockUserBlocks,
|
||||
requestExtensionPermissions: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
|
||||
@@ -73,6 +73,8 @@ test("trusted mute lists from seeds hide authors for anonymous viewers", async (
|
||||
|
||||
userBlocks.loadBlocks = async () => {};
|
||||
userBlocks.blockedPubkeys = new Set();
|
||||
userBlocks._privateBlocks = new Set();
|
||||
userBlocks._publicMutes = new Set();
|
||||
userBlocks.blockEventId = null;
|
||||
userBlocks.blockEventCreatedAt = null;
|
||||
userBlocks.lastPublishedCreatedAt = null;
|
||||
@@ -103,7 +105,7 @@ test("trusted mute lists from seeds hide authors for anonymous viewers", async (
|
||||
},
|
||||
});
|
||||
|
||||
await userBlocks.addBlock(mutedHex, seedHex);
|
||||
await userBlocks.addMute(mutedHex, seedHex);
|
||||
|
||||
const muteEvent = signedEvents.find(
|
||||
(event) =>
|
||||
|
||||
@@ -166,18 +166,18 @@ test('block-author updates user blocks, reloads videos, and refreshes feeds', as
|
||||
test('mute-author updates viewer mute list and refreshes feeds', async () => {
|
||||
const events = [];
|
||||
const moderationStub = {
|
||||
addAuthorToViewerMuteList: async (target) => {
|
||||
events.push(['addMute', target]);
|
||||
isAuthorMutedByViewer: () => false,
|
||||
};
|
||||
const userBlocksStub = {
|
||||
addMute: async (target, viewer) => {
|
||||
events.push(['addMute', target, viewer]);
|
||||
return { ok: true };
|
||||
},
|
||||
removeAuthorFromViewerMuteList: async () => {
|
||||
throw new Error('unreachable');
|
||||
},
|
||||
isAuthorMutedByViewer: () => false,
|
||||
};
|
||||
|
||||
const controller = new MoreMenuController({
|
||||
moderationService: moderationStub,
|
||||
userBlocks: userBlocksStub,
|
||||
callbacks: {
|
||||
getCurrentUserPubkey: () => 'viewerhex',
|
||||
getCurrentVideo: () => ({ pubkey: 'fallbackhex' }),
|
||||
@@ -199,7 +199,7 @@ test('mute-author updates viewer mute list and refreshes feeds', async () => {
|
||||
await controller.handleMoreMenuAction('mute-author', { author: targetHex });
|
||||
|
||||
assert.deepEqual(events, [
|
||||
['addMute', targetHex],
|
||||
['addMute', targetHex, 'viewerhex'],
|
||||
['success', 'Creator muted. Their videos will be downranked in your feeds.'],
|
||||
[
|
||||
'refreshAllVideoGrids',
|
||||
@@ -211,18 +211,18 @@ test('mute-author updates viewer mute list and refreshes feeds', async () => {
|
||||
test('unmute-author removes creators from viewer mute list', async () => {
|
||||
const events = [];
|
||||
const moderationStub = {
|
||||
addAuthorToViewerMuteList: async () => {
|
||||
throw new Error('unreachable');
|
||||
},
|
||||
removeAuthorFromViewerMuteList: async (target) => {
|
||||
events.push(['removeMute', target]);
|
||||
isAuthorMutedByViewer: () => true,
|
||||
};
|
||||
const userBlocksStub = {
|
||||
removeMute: async (target, viewer) => {
|
||||
events.push(['removeMute', target, viewer]);
|
||||
return { ok: true };
|
||||
},
|
||||
isAuthorMutedByViewer: () => true,
|
||||
};
|
||||
|
||||
const controller = new MoreMenuController({
|
||||
moderationService: moderationStub,
|
||||
userBlocks: userBlocksStub,
|
||||
callbacks: {
|
||||
getCurrentUserPubkey: () => 'viewerhex',
|
||||
getCurrentVideo: () => ({ pubkey: 'fallbackhex' }),
|
||||
@@ -244,7 +244,7 @@ test('unmute-author removes creators from viewer mute list', async () => {
|
||||
await controller.handleMoreMenuAction('unmute-author', { author: targetHex });
|
||||
|
||||
assert.deepEqual(events, [
|
||||
['removeMute', targetHex],
|
||||
['removeMute', targetHex, 'viewerhex'],
|
||||
['success', 'Creator removed from your mute list.'],
|
||||
[
|
||||
'refreshAllVideoGrids',
|
||||
|
||||
+33
-19
@@ -88,6 +88,8 @@ await (async () => {
|
||||
);
|
||||
|
||||
userBlocks.blockedPubkeys = new Set();
|
||||
userBlocks._privateBlocks = new Set();
|
||||
userBlocks._publicMutes = new Set();
|
||||
userBlocks.blockEventId = null;
|
||||
userBlocks.loaded = false;
|
||||
|
||||
@@ -230,6 +232,8 @@ await (async () => {
|
||||
);
|
||||
|
||||
userBlocks.blockedPubkeys = new Set([initialBlocked]);
|
||||
userBlocks._privateBlocks = new Set([initialBlocked]);
|
||||
userBlocks._publicMutes = new Set();
|
||||
userBlocks.blockEventId = "existing-event";
|
||||
userBlocks.blockEventCreatedAt = 1_000;
|
||||
userBlocks.loaded = false;
|
||||
@@ -383,6 +387,10 @@ await (async () => {
|
||||
};
|
||||
|
||||
userBlocks.blockedPubkeys = new Set();
|
||||
userBlocks._privateBlocks = new Set();
|
||||
userBlocks._publicMutes = new Set();
|
||||
userBlocks._privateBlocks = new Set();
|
||||
userBlocks._publicMutes = new Set();
|
||||
userBlocks.blockEventId = null;
|
||||
userBlocks.blockEventCreatedAt = null;
|
||||
userBlocks.lastPublishedCreatedAt = null;
|
||||
@@ -641,15 +649,15 @@ await (async () => {
|
||||
"loadBlocks should not request extension permissions when signer decryptor exists",
|
||||
);
|
||||
|
||||
const blockResult = await userBlocks.addBlock(target, actor);
|
||||
assert.equal(blockResult?.ok, true, "addBlock should succeed with direct signer");
|
||||
const blockResult = await userBlocks.addMute(target, actor);
|
||||
assert.equal(blockResult?.ok, true, "addMute should succeed with direct signer");
|
||||
assert(userBlocks.blockedPubkeys.has(target), "target should be in the block list");
|
||||
|
||||
const unblockResult = await userBlocks.removeBlock(target, actor);
|
||||
const unblockResult = await userBlocks.removeMute(target, actor);
|
||||
assert.equal(
|
||||
unblockResult?.ok,
|
||||
true,
|
||||
"removeBlock should succeed with direct signer",
|
||||
"removeMute should succeed with direct signer",
|
||||
);
|
||||
assert.equal(
|
||||
userBlocks.blockedPubkeys.has(target),
|
||||
@@ -849,6 +857,10 @@ await (async () => {
|
||||
});
|
||||
|
||||
manager.blockedPubkeys = new Set([blocked]);
|
||||
manager._privateBlocks = new Set([blocked]);
|
||||
manager._publicMutes = new Set();
|
||||
manager._privateBlocks = new Set([blocked]);
|
||||
manager._publicMutes = new Set();
|
||||
manager.publishMuteListSnapshot = async () => null;
|
||||
manager.loadBlocks = async () => {};
|
||||
|
||||
@@ -931,6 +943,8 @@ await (async () => {
|
||||
});
|
||||
|
||||
manager.blockedPubkeys = new Set([blocked]);
|
||||
manager._privateBlocks = new Set([blocked]);
|
||||
manager._privateBlocks = new Set([blocked]);
|
||||
manager.publishMuteListSnapshot = async () => null;
|
||||
manager.loadBlocks = async () => {};
|
||||
|
||||
@@ -1096,7 +1110,7 @@ await (async () => {
|
||||
},
|
||||
});
|
||||
|
||||
manager.blockedPubkeys = new Set([
|
||||
const badInputs = new Set([
|
||||
validOne,
|
||||
validTwoUpper,
|
||||
actor,
|
||||
@@ -1107,17 +1121,19 @@ await (async () => {
|
||||
["e", validOne],
|
||||
null,
|
||||
]);
|
||||
manager.blockedPubkeys = new Set(badInputs);
|
||||
manager._privateBlocks = new Set(badInputs);
|
||||
manager._publicMutes = new Set(badInputs);
|
||||
manager.loadBlocks = async () => {};
|
||||
|
||||
try {
|
||||
await manager.publishBlockList(actor);
|
||||
|
||||
assert.equal(publishCalls.length, 2, "block and mute events should publish");
|
||||
assert.equal(encryptionCalls.length, 2, "both block and mute payloads should encrypt");
|
||||
assert.equal(publishCalls.length, 1, "only one merged event should publish");
|
||||
assert.equal(encryptionCalls.length, 1, "payload should encrypt once for private content");
|
||||
|
||||
const [blockPayload, mutePayload] = encryptionCalls;
|
||||
const [blockPayload] = encryptionCalls;
|
||||
assert.equal(blockPayload.pubkey, actor, "block payload should encrypt for the actor");
|
||||
assert.equal(mutePayload.pubkey, actor, "mute payload should encrypt for the actor");
|
||||
|
||||
const matrix = JSON.parse(blockPayload.plaintext);
|
||||
assert.deepEqual(
|
||||
@@ -1128,11 +1144,6 @@ await (async () => {
|
||||
],
|
||||
"block payload should serialize only sanitized mute tags",
|
||||
);
|
||||
assert.equal(
|
||||
mutePayload.plaintext,
|
||||
blockPayload.plaintext,
|
||||
"mute payload should reuse the sanitized block payload",
|
||||
);
|
||||
|
||||
const muteEvent = signedEvents.find((event) =>
|
||||
Array.isArray(event?.tags) && event.tags.some((tag) => Array.isArray(tag) && tag[0] === "p"),
|
||||
@@ -1162,8 +1173,8 @@ await (async () => {
|
||||
|
||||
assert.equal(
|
||||
warningLines.length,
|
||||
expectedReasons.length,
|
||||
"every malformed entry should trigger a warning",
|
||||
expectedReasons.length * 2,
|
||||
"every malformed entry should trigger a warning (once for block processing, once for mute processing)",
|
||||
);
|
||||
|
||||
const actualReasons = warningLines
|
||||
@@ -1174,8 +1185,10 @@ await (async () => {
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
const uniqueActualReasons = [...new Set(actualReasons)].sort();
|
||||
|
||||
assert.deepEqual(
|
||||
actualReasons,
|
||||
uniqueActualReasons,
|
||||
expectedReasons,
|
||||
"discarded entries should log their discard reasons",
|
||||
);
|
||||
@@ -1281,6 +1294,7 @@ await (async () => {
|
||||
const UserBlockListManager = userBlocks.constructor;
|
||||
const manager = new UserBlockListManager();
|
||||
manager.blockedPubkeys.add(previouslyBlocked);
|
||||
manager._privateBlocks.add(previouslyBlocked);
|
||||
|
||||
const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays;
|
||||
const originalPool = nostrClient.pool;
|
||||
@@ -1368,8 +1382,8 @@ await (async () => {
|
||||
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 include pubkey from plain list (merged)");
|
||||
assert.ok(!blocked.includes(taggedBlocked), "Should NOT include pubkey from older tagged list (superseded)");
|
||||
assert.ok(blocked.includes(plainBlocked), "Should include pubkey from newer plain list");
|
||||
} finally {
|
||||
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
|
||||
nostrClient.relays = originalRelays;
|
||||
|
||||
Reference in new issue
Block a user