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

/**
* 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);
}
}