You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
790 lines
25 KiB
790 lines
25 KiB
/** |
|
* 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<Set<string>> { |
|
const pinnedIds = new Set<string>(); |
|
|
|
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<Set<string>> { |
|
const bookmarkedIds = new Set<string>(); |
|
|
|
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<Set<string>> { |
|
// 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<string>(); |
|
} |
|
|
|
// Cache for pinned events to avoid repeated async calls |
|
let pinnedCache: Set<string> | 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<boolean> { |
|
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<string> | 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<boolean> { |
|
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<boolean> { |
|
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<void> { |
|
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<string, string[]>(); // eventId -> full tag |
|
const existingATags: string[][] = []; // Store all a-tags |
|
const existingEventIds = new Set<string>(); // 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:<kind>:<pubkey>:<d-tag>) |
|
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<string>(); |
|
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<string>(); |
|
|
|
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<NostrEvent, 'sig' | 'id'> = { |
|
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<boolean> { |
|
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<void> { |
|
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<string, string[]>(); // eventId -> full tag |
|
const existingATags: string[][] = []; // Store all a-tags |
|
const existingEventIds = new Set<string>(); // 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:<kind>:<pubkey>:<d-tag>) |
|
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<string>(); |
|
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<string>(); |
|
|
|
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<NostrEvent, 'sig' | 'id'> = { |
|
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<Set<string>> { |
|
const mutedPubkeys = new Set<string>(); |
|
|
|
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<string> | 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<boolean> { |
|
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<boolean> { |
|
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<void> { |
|
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<string>(); |
|
|
|
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<NostrEvent, 'sig' | 'id'> = { |
|
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<Set<string>> { |
|
const followedPubkeys = new Set<string>(); |
|
|
|
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<string> | 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<boolean> { |
|
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<boolean> { |
|
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<void> { |
|
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<string>(); |
|
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<string>(); |
|
|
|
// 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<NostrEvent, 'sig' | 'id'> = { |
|
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); |
|
} |
|
}
|
|
|