422 lines
14 KiB
JavaScript
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');
|