Files
client/www/js/mute-list.mjs
T
2026-04-17 16:52:51 -04:00

422 lines
14 KiB
JavaScript

/**
* Mute List Module (NIP-51 kind:10000)
*
* Singleton module for managing the user's mute list.
* Supports public and NIP-44 encrypted private entries.
*
* Usage:
* import { configureMuteList, loadMuteList, isEventMuted, addMute, removeMute, getMuteList } from './mute-list.mjs';
* configureMuteList({ ndkFetchEvents, publishEvent, nip44Encrypt, nip44Decrypt, getPubkey });
* await loadMuteList();
*/
// ── Internal state ──────────────────────────────────────────────────────────
let ndkFetchEventsFn = null;
let queryCacheFn = null;
let publishEventFn = null;
let nip44EncryptFn = null; // (recipientPubkey, plaintext) => ciphertext
let nip44DecryptFn = null; // (senderPubkey, ciphertext) => plaintext
let getPubkeyFn = null;
/** Raw kind:10000 event from relay */
let muteListEvent = null;
/** Public mute sets */
const publicMutes = { p: new Set(), t: new Set(), word: new Set(), e: new Set() };
/** Private (encrypted) mute sets */
const privateMutes = { p: new Set(), t: new Set(), word: new Set(), e: new Set() };
/** Change listeners */
const changeListeners = new Set();
let loaded = false;
// ── Configuration ───────────────────────────────────────────────────────────
/**
* Inject dependencies from the page's NDK init.
*/
export function configureMuteList({ ndkFetchEvents, queryCache, publishEvent, nip44Encrypt, nip44Decrypt, getPubkey }) {
if (typeof ndkFetchEvents === 'function') ndkFetchEventsFn = ndkFetchEvents;
if (typeof queryCache === 'function') queryCacheFn = queryCache;
if (typeof publishEvent === 'function') publishEventFn = publishEvent;
if (typeof nip44Encrypt === 'function') nip44EncryptFn = nip44Encrypt;
if (typeof nip44Decrypt === 'function') nip44DecryptFn = nip44Decrypt;
if (typeof getPubkey === 'function') getPubkeyFn = getPubkey;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function clearSets(obj) {
for (const key of Object.keys(obj)) obj[key].clear();
}
function parseTags(tags, target) {
if (!Array.isArray(tags)) return;
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) continue;
const name = tag[0];
const value = tag[1];
if (name === 'p' && value) target.p.add(value);
else if (name === 't' && value) target.t.add(value.toLowerCase());
else if (name === 'word' && value) target.word.add(value.toLowerCase());
else if (name === 'e' && value) target.e.add(value);
}
}
function isNip04Content(content) {
return typeof content === 'string' && content.includes('?iv=');
}
// ── Load ────────────────────────────────────────────────────────────────────
/**
* Fetch and parse the user's kind:10000 mute list using cache-first, then relay hydration.
*/
export async function loadMuteList() {
if (!getPubkeyFn || (!ndkFetchEventsFn && !queryCacheFn)) {
console.warn('[mute-list] Not configured, skipping load');
return;
}
const pubkey = await getPubkeyFn();
if (!pubkey) {
console.warn('[mute-list] No pubkey available');
return;
}
clearSets(publicMutes);
clearSets(privateMutes);
muteListEvent = null;
const filter = { kinds: [10000], authors: [pubkey], limit: 1 };
let hadCacheEvent = false;
const applyEvents = async (events, sourceLabel) => {
if (!Array.isArray(events) || events.length === 0) {
return false;
}
const sorted = events.slice().sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
muteListEvent = sorted[0];
clearSets(publicMutes);
clearSets(privateMutes);
parseTags(muteListEvent.tags, publicMutes);
console.log(`[mute-list] Public mutes loaded from ${sourceLabel}:`, {
p: publicMutes.p.size,
t: publicMutes.t.size,
word: publicMutes.word.size,
e: publicMutes.e.size
});
const content = (muteListEvent.content || '').trim();
if (content.length > 0 && typeof nip44DecryptFn === 'function') {
try {
let plaintext;
if (isNip04Content(content)) {
plaintext = await nip44DecryptFn(pubkey, content);
} else {
plaintext = await nip44DecryptFn(pubkey, content);
}
if (typeof plaintext === 'object' && plaintext?.plaintext) {
plaintext = plaintext.plaintext;
}
if (plaintext) {
const privateTags = JSON.parse(plaintext);
parseTags(privateTags, privateMutes);
console.log(`[mute-list] Private mutes loaded from ${sourceLabel}:`, {
p: privateMutes.p.size,
t: privateMutes.t.size,
word: privateMutes.word.size,
e: privateMutes.e.size
});
}
} catch (err) {
console.warn('[mute-list] Failed to decrypt private mute entries:', err?.message || err);
}
}
loaded = true;
notifyListeners();
return true;
};
if (queryCacheFn) {
try {
const cached = await queryCacheFn(filter);
hadCacheEvent = await applyEvents(cached, 'cache');
if (!hadCacheEvent) {
console.log('[mute-list] No mute list found in cache for user');
}
} catch (err) {
console.warn('[mute-list] Cache query failed:', err?.message || err);
}
}
if (!ndkFetchEventsFn) {
loaded = true;
if (!hadCacheEvent) notifyListeners();
return;
}
const hydrateRelays = async () => {
try {
const relayEvents = await ndkFetchEventsFn(filter);
const hadRelayEvent = await applyEvents(relayEvents, 'relays');
if (!hadRelayEvent && !hadCacheEvent) {
console.log('[mute-list] No mute list found for user');
loaded = true;
notifyListeners();
}
} catch (err) {
if (hadCacheEvent) {
console.warn('[mute-list] Relay hydration failed; keeping cache result:', err?.message || err);
} else {
console.error('[mute-list] Failed to load mute list:', err);
loaded = true;
notifyListeners();
}
}
};
if (hadCacheEvent) {
void hydrateRelays();
return;
}
await hydrateRelays();
}
// ── Query ───────────────────────────────────────────────────────────────────
/** Check if a pubkey is muted (public or private). */
export function isMutedPubkey(pubkey) {
if (!pubkey) return false;
return publicMutes.p.has(pubkey) || privateMutes.p.has(pubkey);
}
/** Check if a hashtag is muted. */
export function isMutedHashtag(tag) {
if (!tag) return false;
const lower = tag.toLowerCase();
return publicMutes.t.has(lower) || privateMutes.t.has(lower);
}
/** Check if content contains a muted word. */
export function isMutedWord(content) {
if (!content) return false;
const lower = content.toLowerCase();
for (const word of publicMutes.word) {
if (lower.includes(word)) return true;
}
for (const word of privateMutes.word) {
if (lower.includes(word)) return true;
}
return false;
}
/** Check if a thread (event id) is muted. */
export function isMutedThread(eventId) {
if (!eventId) return false;
return publicMutes.e.has(eventId) || privateMutes.e.has(eventId);
}
/**
* Combined check: is this event muted by any criteria?
* Checks pubkey, hashtags in t-tags, muted words in content, and thread id.
*/
export function isEventMuted(event) {
if (!event) return false;
// Check pubkey
if (isMutedPubkey(event.pubkey)) return true;
// Check thread (event id itself)
if (isMutedThread(event.id)) return true;
// Check hashtags
if (Array.isArray(event.tags)) {
for (const tag of event.tags) {
if (tag[0] === 't' && tag[1] && isMutedHashtag(tag[1])) return true;
}
}
// Check muted words in content
if (isMutedWord(event.content)) return true;
return false;
}
/** Whether the mute list has been loaded at least once. */
export function isMuteListLoaded() {
return loaded;
}
// ── Mutate ──────────────────────────────────────────────────────────────────
/**
* Add a mute entry and publish the updated list.
* @param {'p'|'t'|'word'|'e'} type - Tag type
* @param {string} value - The value to mute
* @param {boolean} isPrivate - Whether to encrypt this entry
*/
export async function addMute(type, value, isPrivate = false) {
if (!type || !value) return;
if (!['p', 't', 'word', 'e'].includes(type)) {
console.warn('[mute-list] Invalid mute type:', type);
return;
}
const normalizedValue = (type === 't' || type === 'word') ? value.toLowerCase() : value;
const target = isPrivate ? privateMutes : publicMutes;
if (target[type].has(normalizedValue)) {
console.log('[mute-list] Already muted:', type, normalizedValue);
return;
}
target[type].add(normalizedValue);
await publishMuteList();
notifyListeners();
}
/**
* Remove a mute entry and publish the updated list.
* Searches both public and private sets.
* @param {'p'|'t'|'word'|'e'} type - Tag type
* @param {string} value - The value to unmute
*/
export async function removeMute(type, value) {
if (!type || !value) return;
const normalizedValue = (type === 't' || type === 'word') ? value.toLowerCase() : value;
let removed = false;
if (publicMutes[type]?.has(normalizedValue)) {
publicMutes[type].delete(normalizedValue);
removed = true;
}
if (privateMutes[type]?.has(normalizedValue)) {
privateMutes[type].delete(normalizedValue);
removed = true;
}
if (removed) {
await publishMuteList();
notifyListeners();
}
}
// ── Publish ─────────────────────────────────────────────────────────────────
async function publishMuteList() {
if (!publishEventFn || !getPubkeyFn) {
throw new Error('[mute-list] Not configured for publishing');
}
const pubkey = await getPubkeyFn();
// Build public tags
const tags = [];
for (const pk of publicMutes.p) tags.push(['p', pk]);
for (const t of publicMutes.t) tags.push(['t', t]);
for (const w of publicMutes.word) tags.push(['word', w]);
for (const e of publicMutes.e) tags.push(['e', e]);
// Build private tags and encrypt
let content = '';
const privateTagsArray = [];
for (const pk of privateMutes.p) privateTagsArray.push(['p', pk]);
for (const t of privateMutes.t) privateTagsArray.push(['t', t]);
for (const w of privateMutes.word) privateTagsArray.push(['word', w]);
for (const e of privateMutes.e) privateTagsArray.push(['e', e]);
if (privateTagsArray.length > 0 && nip44EncryptFn) {
try {
const plaintext = JSON.stringify(privateTagsArray);
const encrypted = await nip44EncryptFn(pubkey, plaintext);
content = typeof encrypted === 'object' ? (encrypted.ciphertext || '') : (encrypted || '');
} catch (err) {
console.error('[mute-list] Failed to encrypt private mute entries:', err);
// Publish without private entries rather than losing public ones
}
}
const event = {
kind: 10000,
tags,
content,
created_at: Math.floor(Date.now() / 1000)
};
console.log('[mute-list] Publishing mute list:', {
publicTags: tags.length,
privateTags: privateTagsArray.length
});
await publishEventFn(event);
console.log('[mute-list] Mute list published');
}
// ── Read ────────────────────────────────────────────────────────────────────
/**
* Get the full parsed mute list for UI display.
* Returns { public: { p, t, word, e }, private: { p, t, word, e } }
* where each value is an array of strings.
*/
export function getMuteList() {
return {
public: {
p: Array.from(publicMutes.p),
t: Array.from(publicMutes.t),
word: Array.from(publicMutes.word),
e: Array.from(publicMutes.e)
},
private: {
p: Array.from(privateMutes.p),
t: Array.from(privateMutes.t),
word: Array.from(privateMutes.word),
e: Array.from(privateMutes.e)
}
};
}
/**
* Get total count of all muted items.
*/
export function getMuteCount() {
let count = 0;
for (const set of Object.values(publicMutes)) count += set.size;
for (const set of Object.values(privateMutes)) count += set.size;
return count;
}
// ── Events ──────────────────────────────────────────────────────────────────
/**
* Register a callback for mute list changes.
* @param {Function} callback
* @returns {Function} unsubscribe
*/
export function onMuteListChanged(callback) {
if (typeof callback !== 'function') return () => {};
changeListeners.add(callback);
return () => changeListeners.delete(callback);
}
function notifyListeners() {
const list = getMuteList();
for (const cb of changeListeners) {
try { cb(list); } catch (err) { console.warn('[mute-list] Listener error:', err); }
}
}
console.log('[mute-list] Module loaded');