Merge pull request #2059 from PR0M3TH3AN/ai/nip51-mute-list-migration-1978282445834244087

Migrate user blocks to NIP-51 Mute List
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-01 17:14:00 -05:00
commit 3da8707929
11 files changed
+355 -486

No files matched your search

+12 -3
View File
@@ -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
+3 -3
View File
@@ -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]: {
+8 -129
View File
@@ -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) {
+7 -2
View File
@@ -887,8 +887,13 @@ class SubscriptionsManager {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
const signerCapabilities = signer?.capabilities;
const signerHasNip04 =
typeof signer?.nip04Decrypt === "function" &&
(!signerCapabilities || signerCapabilities.nip04 !== false);
const signerHasNip44 =
typeof signer?.nip44Decrypt === "function" &&
(!signerCapabilities || signerCapabilities.nip44 !== false);
const nostrApi = allowPermissionPrompt
? typeof window !== "undefined"
+7 -17
View File
@@ -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.",
);
+11 -11
View File
@@ -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);
}
}
+226 -284
View File
@@ -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.privateBlocks || localData.blockedPubkeys || new Set();
this._publicMutes = localData.publicMutes || new Set();
this.blockedPubkeys = new Set([...this._privateBlocks, ...this._publicMutes]);
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") {
@@ -1446,8 +1467,8 @@ class UserBlockListManager {
? this.blockEventCreatedAt
: 0;
// Concurrent incremental fetch for both kinds
const [muteEvents, blockEvents] = await Promise.all([
// Concurrent incremental fetch for all variants
const [muteEvents, blockEvents, legacyEvents] = await Promise.all([
nostrClient.fetchListIncrementally({
kind: 10000,
pubkey: normalized,
@@ -1463,9 +1484,17 @@ class UserBlockListManager {
since: fetchSince,
timeoutMs: 12000,
}),
nostrClient.fetchListIncrementally({
kind: 30002,
pubkey: normalized,
dTag: BLOCK_LIST_IDENTIFIER,
relayUrls: relays,
since: fetchSince,
timeoutMs: 12000,
}),
]);
const combined = [...muteEvents, ...blockEvents];
const combined = [...muteEvents, ...blockEvents, ...legacyEvents];
// If combined is empty, check if we have loaded state
if (!combined.length) {
@@ -1497,10 +1526,21 @@ class UserBlockListManager {
return { blockedPubkeys: new Set(cached), createdAt: null };
}
if (cached && typeof cached === "object") {
const privateBlocks = new Set(
Array.isArray(cached.privateBlocks) ? cached.privateBlocks : [],
);
const publicMutes = new Set(
Array.isArray(cached.publicMutes) ? cached.publicMutes : [],
);
// Fallback for legacy cache format
const blockedPubkeys = new Set(
Array.isArray(cached.blockedPubkeys) ? cached.blockedPubkeys : [],
);
return {
blockedPubkeys: new Set(
Array.isArray(cached.blockedPubkeys) ? cached.blockedPubkeys : [],
),
privateBlocks,
publicMutes,
blockedPubkeys,
createdAt: Number.isFinite(cached.createdAt) ? cached.createdAt : null,
};
}
@@ -1513,9 +1553,12 @@ class UserBlockListManager {
return;
}
profileCache.setProfileData(normalizedActorHex, "blocks", {
privateBlocks: Array.from(this._privateBlocks),
publicMutes: Array.from(this._publicMutes),
// Keep blockedPubkeys for backward compatibility if needed, or just relying on new keys
blockedPubkeys: Array.from(this.blockedPubkeys),
createdAt: this.blockEventCreatedAt,
version: 1,
version: 2,
});
}
@@ -1635,13 +1678,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 +1767,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 +1841,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 +1865,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 +1894,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 +1942,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 +2031,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 +2055,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 +2104,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 +2129,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 +2154,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 +2167,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,
+28
View File
@@ -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 }),
});
+3 -1
View File
@@ -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) =>
+14 -14
View File
@@ -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',
+36 -22
View File
@@ -88,6 +88,8 @@ await (async () => {
);
userBlocks.blockedPubkeys = new Set();
userBlocks._privateBlocks = new Set();
userBlocks._publicMutes = new Set();
userBlocks.blockEventId = null;
userBlocks.loaded = false;
@@ -98,8 +100,8 @@ await (async () => {
assert.deepEqual(
calls,
[...relays, ...relays],
"loadBlocks should initiate queries for all relays concurrently (twice, for block list and mute list)"
[...relays, ...relays, ...relays],
"loadBlocks should initiate queries for all relays concurrently (thrice, for block list, mute list, and legacy kind 30002)"
);
// Reverting to original test data:
@@ -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;
@@ -250,7 +254,7 @@ await (async () => {
assert.deepEqual(
calls,
[...relays, ...relays],
[...relays, ...relays, ...relays],
"loadBlocks should query fast and background relays concurrently",
);
@@ -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;