/** * User actions service - manages pinned, bookmarked, and highlighted events * All data stored as events in IndexedDB cache and published to relays */ import { sessionManager } from './auth/session-manager.js'; import { signAndPublish } from './nostr/auth-handler.js'; import { nostrClient } from './nostr/nostr-client.js'; import { relayManager } from './nostr/relay-manager.js'; import { KIND } from '../types/kind-lookup.js'; import type { NostrEvent } from '../types/nostr.js'; /** * Get all pinned event IDs from published kind 10001 event (from cache/relays) */ export async function getPinnedEvents(): Promise> { const pinnedIds = new Set(); try { const session = sessionManager.getSession(); if (!session) return pinnedIds; // Fetch published pin list event from cache/relays const relays = relayManager.getProfileReadRelays(); const pinLists = await nostrClient.fetchEvents( [{ kinds: [KIND.PIN_LIST], authors: [session.pubkey], limit: 1 }], relays, { useCache: true, cacheResults: true } ); // Extract event IDs from published pin list if (pinLists.length > 0) { const pinList = pinLists[0]; for (const tag of pinList.tags) { if (tag[0] === 'e' && tag[1]) { pinnedIds.add(tag[1]); } } } } catch (error) { console.debug('Error fetching pinned events:', error); } return pinnedIds; } /** * Get all bookmarked event IDs from published kind 10003 event (from cache/relays) */ export async function getBookmarkedEvents(): Promise> { const bookmarkedIds = new Set(); try { const session = sessionManager.getSession(); if (!session) return bookmarkedIds; // Fetch published bookmark list event from cache/relays const relays = relayManager.getProfileReadRelays(); const bookmarkLists = await nostrClient.fetchEvents( [{ kinds: [KIND.BOOKMARKS], authors: [session.pubkey], limit: 1 }], relays, { useCache: true, cacheResults: true } ); // Extract event IDs from published bookmark list if (bookmarkLists.length > 0) { const bookmarkList = bookmarkLists[0]; for (const tag of bookmarkList.tags) { if (tag[0] === 'e' && tag[1]) { bookmarkedIds.add(tag[1]); } // Note: a-tags would need to be resolved to get event IDs // For now, we only support e-tags } } } catch (error) { console.debug('Error fetching bookmarked events:', error); } return bookmarkedIds; } /** * Get all highlighted event IDs (highlights are stored as kind 9802 events, not in a list) * This function is kept for compatibility but highlights are actually stored as events */ export async function getHighlightedEvents(): Promise> { // Highlights are stored as kind 9802 events, not in a list // This function is kept for compatibility but returns empty set // To get highlights, query for kind 9802 events by the user's pubkey return new Set(); } // Cache for pinned events to avoid repeated async calls let pinnedCache: Set | null = null; let pinnedCacheTime: number = 0; const PINNED_CACHE_TTL = 5000; // 5 seconds /** * Check if an event is pinned (uses cached result if available) */ export async function isPinned(eventId: string): Promise { const now = Date.now(); if (pinnedCache && (now - pinnedCacheTime) < PINNED_CACHE_TTL) { return pinnedCache.has(eventId); } pinnedCache = await getPinnedEvents(); pinnedCacheTime = now; return pinnedCache.has(eventId); } /** * Invalidate pin cache (call after toggling pins) */ function invalidatePinCache() { pinnedCache = null; pinnedCacheTime = 0; } // Cache for bookmarked events to avoid repeated async calls let bookmarkedCache: Set | null = null; let bookmarkedCacheTime: number = 0; const BOOKMARKED_CACHE_TTL = 5000; // 5 seconds /** * Check if an event is bookmarked (uses cached result if available) */ export async function isBookmarked(eventId: string): Promise { const now = Date.now(); if (bookmarkedCache && (now - bookmarkedCacheTime) < BOOKMARKED_CACHE_TTL) { return bookmarkedCache.has(eventId); } bookmarkedCache = await getBookmarkedEvents(); bookmarkedCacheTime = now; return bookmarkedCache.has(eventId); } /** * Invalidate bookmark cache (call after toggling bookmarks) */ function invalidateBookmarkCache() { bookmarkedCache = null; bookmarkedCacheTime = 0; } /** * Check if an event is highlighted * Highlights are stored as kind 9802 events, not in a list * This function is kept for compatibility but always returns false */ export function isHighlighted(eventId: string): boolean { // Highlights are stored as kind 9802 events, not in a list // To check if an event is highlighted, query for kind 9802 events that reference it return false; } /** * Toggle pin status of an event * Publishes kind 10001 list event (pins are stored in cache and on relays only) */ export async function togglePin(eventId: string): Promise { try { const session = sessionManager.getSession(); if (!session) { throw new Error('Not logged in'); } // Get current pins from published event const currentPins = await getPinnedEvents(); const isCurrentlyPinned = currentPins.has(eventId); // Toggle the pin if (isCurrentlyPinned) { currentPins.delete(eventId); } else { currentPins.add(eventId); } // Publish updated pin list event await publishPinList(Array.from(currentPins)); // Invalidate cache so next read gets fresh data invalidatePinCache(); return !isCurrentlyPinned; } catch (error) { console.error('Failed to toggle pin:', error); // Return current state on error const currentPins = await getPinnedEvents(); return currentPins.has(eventId); } } /** * Publish pin list event (kind 10001) */ async function publishPinList(eventIds: string[]): Promise { try { const session = sessionManager.getSession(); if (!session) return; // Deduplicate input eventIds first const deduplicatedEventIds = Array.from(new Set(eventIds)); // Fetch existing pin list to merge with new entries const relays = relayManager.getProfileReadRelays(); const existingLists = await nostrClient.fetchEvents( [{ kinds: [KIND.PIN_LIST], authors: [session.pubkey], limit: 1 }], relays, { useCache: true, cacheResults: true } ); // Collect existing tags: both 'e' and 'a' tags const existingETags = new Map(); // eventId -> full tag const existingATags: string[][] = []; // Store all a-tags const existingEventIds = new Set(); // Event IDs from e-tags only if (existingLists.length > 0) { const existingList = existingLists[0]; for (const tag of existingList.tags) { if (tag[0] === 'e' && tag[1]) { const eventId = tag[1]; existingEventIds.add(eventId); existingETags.set(eventId, tag); } else if (tag[0] === 'a' && tag[1]) { // Store a-tags separately (format: a:::) existingATags.push(tag); } } } // Check if we have any changes const newEventIds = deduplicatedEventIds.filter(id => !existingEventIds.has(id)); const removedEventIds = [...existingEventIds].filter(id => !deduplicatedEventIds.includes(id)); if (newEventIds.length === 0 && removedEventIds.length === 0 && existingLists.length > 0) { return; // No changes, cancel operation } // Build final tags: preserve all a-tags, add/update e-tags const tags: string[][] = []; // First, add all existing a-tags (they take precedence) for (const aTag of existingATags) { tags.push(aTag); } // Then, add e-tags for all event IDs in the final list // Note: We can't easily check if an a-tag represents a specific event ID without resolving it // So we'll add e-tags for all eventIds, and rely on clients to prefer a-tags when resolving const seenETags = new Set(); for (const eventId of deduplicatedEventIds) { if (!seenETags.has(eventId)) { tags.push(['e', eventId]); seenETags.add(eventId); } } // Final deduplication: if we somehow have duplicate e-tags, remove them // (This shouldn't happen, but ensures clean output) const finalTags: string[][] = []; const seenEventIds = new Set(); for (const tag of tags) { if (tag[0] === 'a') { // Always keep a-tags finalTags.push(tag); } else if (tag[0] === 'e' && tag[1]) { // For e-tags, check if we already have this event ID // If an a-tag represents this event, we'd ideally skip the e-tag, // but we can't check that without resolving a-tags // So we'll keep the e-tag and let clients handle the preference if (!seenEventIds.has(tag[1])) { finalTags.push(tag); seenEventIds.add(tag[1]); } } else { // Keep other tag types as-is finalTags.push(tag); } } // Create new list event with merged tags and new timestamp const listEvent: Omit = { kind: KIND.PIN_LIST, pubkey: session.pubkey, created_at: Math.floor(Date.now() / 1000), tags: finalTags, content: '' }; // Publish to write relays const writeRelays = relayManager.getPublishRelays(relays, true); await signAndPublish(listEvent, writeRelays); } catch (error) { console.error('Failed to publish pin list:', error); } } /** * Toggle bookmark status of an event * Publishes kind 10003 list event (bookmarks are stored in cache and on relays only) */ export async function toggleBookmark(eventId: string): Promise { try { const session = sessionManager.getSession(); if (!session) { throw new Error('Not logged in'); } // Get current bookmarks from published event const currentBookmarks = await getBookmarkedEvents(); const isCurrentlyBookmarked = currentBookmarks.has(eventId); // Toggle the bookmark if (isCurrentlyBookmarked) { currentBookmarks.delete(eventId); } else { currentBookmarks.add(eventId); } // Publish updated bookmark list event await publishBookmarkList(Array.from(currentBookmarks)); // Invalidate cache so next read gets fresh data invalidateBookmarkCache(); return !isCurrentlyBookmarked; } catch (error) { console.error('Failed to toggle bookmark:', error); // Return current state on error const currentBookmarks = await getBookmarkedEvents(); return currentBookmarks.has(eventId); } } /** * Publish bookmark list event (kind 10003) */ async function publishBookmarkList(eventIds: string[]): Promise { try { const session = sessionManager.getSession(); if (!session) return; // Deduplicate input eventIds first const deduplicatedEventIds = Array.from(new Set(eventIds)); // Fetch existing bookmark list to merge with new entries const relays = relayManager.getProfileReadRelays(); const existingLists = await nostrClient.fetchEvents( [{ kinds: [KIND.BOOKMARKS], authors: [session.pubkey], limit: 1 }], relays, { useCache: true, cacheResults: true } ); // Collect existing tags: both 'e' and 'a' tags const existingETags = new Map(); // eventId -> full tag const existingATags: string[][] = []; // Store all a-tags const existingEventIds = new Set(); // Event IDs from e-tags only if (existingLists.length > 0) { const existingList = existingLists[0]; for (const tag of existingList.tags) { if (tag[0] === 'e' && tag[1]) { const eventId = tag[1]; existingEventIds.add(eventId); existingETags.set(eventId, tag); } else if (tag[0] === 'a' && tag[1]) { // Store a-tags separately (format: a:::) existingATags.push(tag); } } } // Check if we have any changes const newEventIds = deduplicatedEventIds.filter(id => !existingEventIds.has(id)); const removedEventIds = [...existingEventIds].filter(id => !deduplicatedEventIds.includes(id)); if (newEventIds.length === 0 && removedEventIds.length === 0 && existingLists.length > 0) { return; // No changes, cancel operation } // Build final tags: preserve all a-tags, add/update e-tags const tags: string[][] = []; // First, add all existing a-tags (they take precedence) for (const aTag of existingATags) { tags.push(aTag); } // Then, add e-tags for all event IDs in the final list // Note: We can't easily check if an a-tag represents a specific event ID without resolving it // So we'll add e-tags for all eventIds, and rely on clients to prefer a-tags when resolving const seenETags = new Set(); for (const eventId of deduplicatedEventIds) { if (!seenETags.has(eventId)) { tags.push(['e', eventId]); seenETags.add(eventId); } } // Final deduplication: if we somehow have duplicate e-tags, remove them // (This shouldn't happen, but ensures clean output) const finalTags: string[][] = []; const seenEventIds = new Set(); for (const tag of tags) { if (tag[0] === 'a') { // Always keep a-tags finalTags.push(tag); } else if (tag[0] === 'e' && tag[1]) { // For e-tags, check if we already have this event ID // If an a-tag represents this event, we'd ideally skip the e-tag, // but we can't check that without resolving a-tags // So we'll keep the e-tag and let clients handle the preference if (!seenEventIds.has(tag[1])) { finalTags.push(tag); seenEventIds.add(tag[1]); } } else { // Keep other tag types as-is finalTags.push(tag); } } // Create new list event with merged tags and new timestamp const listEvent: Omit = { kind: KIND.BOOKMARKS, pubkey: session.pubkey, created_at: Math.floor(Date.now() / 1000), tags: finalTags, content: '' }; // Publish to write relays const writeRelays = relayManager.getPublishRelays(relays, true); await signAndPublish(listEvent, writeRelays); } catch (error) { console.error('Failed to publish bookmark list:', error); } } /** * Toggle highlight status of an event * Highlights are stored as kind 9802 events, not in a list * This function is kept for compatibility but does nothing */ export function toggleHighlight(eventId: string): boolean { // Highlights are stored as kind 9802 events, not in a list // To create a highlight, publish a kind 9802 event // This function is kept for compatibility but does nothing return false; } /** * Get all muted pubkeys from published kind 10000 event (from cache/relays) */ export async function getMutedPubkeys(): Promise> { const mutedPubkeys = new Set(); try { const session = sessionManager.getSession(); if (!session) return mutedPubkeys; // Fetch published mute list event from cache/relays const relays = relayManager.getProfileReadRelays(); const muteLists = await nostrClient.fetchEvents( [{ kinds: [KIND.MUTE_LIST], authors: [session.pubkey], limit: 1 }], relays, { useCache: true, cacheResults: true } ); // Extract pubkeys from published mute list if (muteLists.length > 0) { const muteList = muteLists[0]; for (const tag of muteList.tags) { if (tag[0] === 'p' && tag[1]) { mutedPubkeys.add(tag[1]); } } } } catch (error) { console.debug('Error fetching muted pubkeys:', error); } return mutedPubkeys; } // Cache for muted pubkeys to avoid repeated async calls let mutedCache: Set | null = null; let mutedCacheTime: number = 0; const MUTED_CACHE_TTL = 5000; // 5 seconds /** * Check if a pubkey is muted (uses cached result if available) */ export async function isMuted(pubkey: string): Promise { const now = Date.now(); if (mutedCache && (now - mutedCacheTime) < MUTED_CACHE_TTL) { return mutedCache.has(pubkey); } mutedCache = await getMutedPubkeys(); mutedCacheTime = now; return mutedCache.has(pubkey); } /** * Invalidate mute cache (call after toggling mutes) */ function invalidateMuteCache() { mutedCache = null; mutedCacheTime = 0; } /** * Toggle mute status of a user * Publishes kind 10000 mute list event (mutes are stored in cache and on relays only) */ export async function toggleMute(pubkey: string): Promise { try { const session = sessionManager.getSession(); if (!session) { throw new Error('Not logged in'); } // Get current mutes from published event const currentMutes = await getMutedPubkeys(); const isCurrentlyMuted = currentMutes.has(pubkey); // Toggle the mute if (isCurrentlyMuted) { currentMutes.delete(pubkey); } else { currentMutes.add(pubkey); } // Publish updated mute list event await publishMuteList(Array.from(currentMutes)); // Invalidate cache so next read gets fresh data invalidateMuteCache(); return !isCurrentlyMuted; } catch (error) { console.error('Failed to toggle mute:', error); // Return current state on error const currentMutes = await getMutedPubkeys(); return currentMutes.has(pubkey); } } /** * Publish mute list event (kind 10000) */ async function publishMuteList(pubkeys: string[]): Promise { try { const session = sessionManager.getSession(); if (!session) return; // Deduplicate pubkeys const deduplicatedPubkeys = Array.from(new Set(pubkeys)); // Fetch existing mute list to merge with new entries const relays = relayManager.getProfileReadRelays(); const existingLists = await nostrClient.fetchEvents( [{ kinds: [KIND.MUTE_LIST], authors: [session.pubkey], limit: 1 }], relays, { useCache: true, cacheResults: true } ); // Collect existing p tags const existingPubkeys = new Set(); if (existingLists.length > 0) { const existingList = existingLists[0]; for (const tag of existingList.tags) { if (tag[0] === 'p' && tag[1]) { existingPubkeys.add(tag[1]); } } } // Check if we have any changes const newPubkeys = deduplicatedPubkeys.filter(p => !existingPubkeys.has(p)); const removedPubkeys = [...existingPubkeys].filter(p => !deduplicatedPubkeys.includes(p)); if (newPubkeys.length === 0 && removedPubkeys.length === 0 && existingLists.length > 0) { return; // No changes, cancel operation } // Build final tags: all p tags for muted pubkeys const tags: string[][] = []; for (const pubkey of deduplicatedPubkeys) { tags.push(['p', pubkey]); } // Create new mute list event const listEvent: Omit = { kind: KIND.MUTE_LIST, pubkey: session.pubkey, created_at: Math.floor(Date.now() / 1000), tags: tags, content: '' }; // Publish to write relays const writeRelays = relayManager.getPublishRelays(relays, true); await signAndPublish(listEvent, writeRelays); } catch (error) { console.error('Failed to publish mute list:', error); } } /** * Get all followed pubkeys from published kind 3 event (from cache/relays) */ export async function getFollowedPubkeys(): Promise> { const followedPubkeys = new Set(); try { const session = sessionManager.getSession(); if (!session) return followedPubkeys; // Fetch published follow list event (kind 3) from cache/relays const relays = relayManager.getProfileReadRelays(); const followLists = await nostrClient.fetchEvents( [{ kinds: [KIND.CONTACTS], authors: [session.pubkey], limit: 1 }], relays, { useCache: true, cacheResults: true } ); // Extract pubkeys from published follow list if (followLists.length > 0) { const followList = followLists[0]; for (const tag of followList.tags) { if (tag[0] === 'p' && tag[1]) { followedPubkeys.add(tag[1]); } } } } catch (error) { console.debug('Error fetching followed pubkeys:', error); } return followedPubkeys; } // Cache for followed pubkeys to avoid repeated async calls let followedCache: Set | null = null; let followedCacheTime: number = 0; const FOLLOWED_CACHE_TTL = 5000; // 5 seconds /** * Check if a pubkey is followed (uses cached result if available) */ export async function isFollowed(pubkey: string): Promise { const now = Date.now(); if (followedCache && (now - followedCacheTime) < FOLLOWED_CACHE_TTL) { return followedCache.has(pubkey); } followedCache = await getFollowedPubkeys(); followedCacheTime = now; return followedCache.has(pubkey); } /** * Invalidate follow cache (call after toggling follows) */ function invalidateFollowCache() { followedCache = null; followedCacheTime = 0; } /** * Toggle follow status of a user * Publishes kind 3 follow list event (follows are stored in cache and on relays only) */ export async function toggleFollow(pubkey: string): Promise { try { const session = sessionManager.getSession(); if (!session) { throw new Error('Not logged in'); } // Get current follows from published event const currentFollows = await getFollowedPubkeys(); const isCurrentlyFollowed = currentFollows.has(pubkey); // Toggle the follow if (isCurrentlyFollowed) { currentFollows.delete(pubkey); } else { currentFollows.add(pubkey); } // Publish updated follow list event await publishFollowList(Array.from(currentFollows)); // Invalidate cache so next read gets fresh data invalidateFollowCache(); return !isCurrentlyFollowed; } catch (error) { console.error('Failed to toggle follow:', error); // Return current state on error const currentFollows = await getFollowedPubkeys(); return currentFollows.has(pubkey); } } /** * Publish follow list event (kind 3) */ async function publishFollowList(pubkeys: string[]): Promise { try { const session = sessionManager.getSession(); if (!session) return; // Deduplicate pubkeys const deduplicatedPubkeys = Array.from(new Set(pubkeys)); // Fetch existing follow list to merge with new entries const relays = relayManager.getProfileReadRelays(); const existingLists = await nostrClient.fetchEvents( [{ kinds: [KIND.CONTACTS], authors: [session.pubkey], limit: 1 }], relays, { useCache: true, cacheResults: true } ); // Collect existing p tags const existingPubkeys = new Set(); const existingPTags: string[][] = []; // Store full p tags to preserve relay hints and petnames if (existingLists.length > 0) { const existingList = existingLists[0]; for (const tag of existingList.tags) { if (tag[0] === 'p' && tag[1]) { existingPubkeys.add(tag[1]); existingPTags.push(tag); } } } // Check if we have any changes const newPubkeys = deduplicatedPubkeys.filter(p => !existingPubkeys.has(p)); const removedPubkeys = [...existingPubkeys].filter(p => !deduplicatedPubkeys.includes(p)); if (newPubkeys.length === 0 && removedPubkeys.length === 0 && existingLists.length > 0) { return; // No changes, cancel operation } // Build final tags: preserve existing p tags for pubkeys we're keeping, add new ones const tags: string[][] = []; const seenPubkeys = new Set(); // First, add existing p tags for pubkeys we're keeping for (const tag of existingPTags) { if (tag[1] && deduplicatedPubkeys.includes(tag[1])) { tags.push(tag); seenPubkeys.add(tag[1]); } } // Then, add new p tags for pubkeys we're adding (without relay hints or petnames) for (const pubkey of deduplicatedPubkeys) { if (!seenPubkeys.has(pubkey)) { tags.push(['p', pubkey]); } } // Create new follow list event const listEvent: Omit = { kind: KIND.CONTACTS, pubkey: session.pubkey, created_at: Math.floor(Date.now() / 1000), tags: tags, content: '' }; // Publish to write relays const writeRelays = relayManager.getPublishRelays(relays, true); await signAndPublish(listEvent, writeRelays); } catch (error) { console.error('Failed to publish follow list:', error); } }