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.
534 lines
14 KiB
534 lines
14 KiB
import { writable, get } from 'svelte/store'; |
|
import type { NostrProfile } from '../utils/search_types.ts'; |
|
import type { NDKUser, NDKSigner } from '@nostr-dev-kit/ndk'; |
|
import NDK, { |
|
NDKNip07Signer, |
|
NDKRelayAuthPolicies, |
|
NDKRelaySet, |
|
NDKRelay, |
|
} from '@nostr-dev-kit/ndk'; |
|
import { getUserMetadata } from '../utils/nostrUtils.ts'; |
|
import { ndkInstance, activeInboxRelays, activeOutboxRelays, updateActiveRelayStores } from '../ndk.ts'; |
|
import { loginStorageKey } from '../consts.ts'; |
|
import { nip19 } from 'nostr-tools'; |
|
import { fetchCurrentUserLists } from '../utils/user_lists.ts'; |
|
import { npubCache } from '../utils/npubCache.ts'; |
|
|
|
// AI-NOTE: UserStore consolidation - This file contains all user-related state management |
|
// including authentication, profile management, relay preferences, and user lists caching. |
|
|
|
export type LoginMethod = 'extension' | 'amber' | 'npub'; |
|
|
|
export interface UserState { |
|
pubkey: string | null; |
|
npub: string | null; |
|
profile: NostrProfile | null; |
|
relays: { inbox: string[]; outbox: string[] }; |
|
loginMethod: LoginMethod | null; |
|
ndkUser: NDKUser | null; |
|
signer: NDKSigner | null; |
|
signedIn: boolean; |
|
} |
|
|
|
const initialUserState: UserState = { |
|
pubkey: null, |
|
npub: null, |
|
profile: null, |
|
relays: { inbox: [], outbox: [] }, |
|
loginMethod: null, |
|
ndkUser: null, |
|
signer: null, |
|
signedIn: false, |
|
}; |
|
|
|
export const userStore = writable<UserState>(initialUserState); |
|
|
|
// Storage keys |
|
export const loginMethodStorageKey = 'alexandria/login/method'; |
|
const LOGOUT_FLAG_KEY = 'alexandria/logout/flag'; |
|
|
|
// Performance optimization: Cache for relay storage keys |
|
const relayStorageKeyCache = new Map<string, { inbox: string; outbox: string }>(); |
|
|
|
/** |
|
* Get relay storage key for a user, with caching for performance |
|
*/ |
|
function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string { |
|
const cacheKey = user.pubkey; |
|
let cached = relayStorageKeyCache.get(cacheKey); |
|
|
|
if (!cached) { |
|
const baseKey = `${loginStorageKey}/${user.pubkey}`; |
|
cached = { |
|
inbox: `${baseKey}/inbox`, |
|
outbox: `${baseKey}/outbox`, |
|
}; |
|
relayStorageKeyCache.set(cacheKey, cached); |
|
} |
|
|
|
return type === 'inbox' ? cached.inbox : cached.outbox; |
|
} |
|
|
|
/** |
|
* Safely access localStorage (client-side only) |
|
*/ |
|
function safeLocalStorage(): Storage | null { |
|
return typeof window !== 'undefined' ? window.localStorage : null; |
|
} |
|
|
|
/** |
|
* Persist relay preferences to localStorage |
|
*/ |
|
function persistRelays( |
|
user: NDKUser, |
|
inboxes: Set<NDKRelay>, |
|
outboxes: Set<NDKRelay>, |
|
): void { |
|
const storage = safeLocalStorage(); |
|
if (!storage) return; |
|
|
|
const inboxUrls = Array.from(inboxes).map((relay) => relay.url); |
|
const outboxUrls = Array.from(outboxes).map((relay) => relay.url); |
|
|
|
storage.setItem(getRelayStorageKey(user, 'inbox'), JSON.stringify(inboxUrls)); |
|
storage.setItem(getRelayStorageKey(user, 'outbox'), JSON.stringify(outboxUrls)); |
|
} |
|
|
|
/** |
|
* Get persisted relay preferences from localStorage |
|
*/ |
|
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] { |
|
const storage = safeLocalStorage(); |
|
if (!storage) { |
|
return [new Set<string>(), new Set<string>()]; |
|
} |
|
|
|
const inboxes = new Set<string>( |
|
JSON.parse(storage.getItem(getRelayStorageKey(user, 'inbox')) ?? '[]'), |
|
); |
|
const outboxes = new Set<string>( |
|
JSON.parse(storage.getItem(getRelayStorageKey(user, 'outbox')) ?? '[]'), |
|
); |
|
|
|
return [inboxes, outboxes]; |
|
} |
|
|
|
/** |
|
* Fetch user's preferred relays from Nostr network |
|
*/ |
|
async function getUserPreferredRelays( |
|
ndk: NDK, |
|
user: NDKUser, |
|
fallbacks: readonly string[] = [...get(activeInboxRelays), ...get(activeOutboxRelays)], |
|
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> { |
|
const relayList = await ndk.fetchEvent( |
|
{ |
|
kinds: [10002], |
|
authors: [user.pubkey], |
|
}, |
|
{ |
|
groupable: false, |
|
skipVerification: false, |
|
skipValidation: false, |
|
}, |
|
NDKRelaySet.fromRelayUrls(fallbacks, ndk), |
|
); |
|
|
|
const inboxRelays = new Set<NDKRelay>(); |
|
const outboxRelays = new Set<NDKRelay>(); |
|
|
|
if (!relayList) { |
|
// Fallback to extension relays if available |
|
const relayMap = await globalThis.nostr?.getRelays?.(); |
|
if (relayMap) { |
|
Object.entries(relayMap).forEach( |
|
([url, relayType]: [string, Record<string, boolean | undefined>]) => { |
|
const relay = new NDKRelay( |
|
url, |
|
NDKRelayAuthPolicies.signIn({ ndk }), |
|
ndk, |
|
); |
|
if (relayType.read) inboxRelays.add(relay); |
|
if (relayType.write) outboxRelays.add(relay); |
|
}, |
|
); |
|
} |
|
} else { |
|
// Parse relay list from event |
|
relayList.tags.forEach((tag: string[]) => { |
|
const relay = new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk); |
|
|
|
switch (tag[0]) { |
|
case 'r': |
|
inboxRelays.add(relay); |
|
break; |
|
case 'w': |
|
outboxRelays.add(relay); |
|
break; |
|
default: |
|
// Default: add to both |
|
inboxRelays.add(relay); |
|
outboxRelays.add(relay); |
|
break; |
|
} |
|
}); |
|
} |
|
|
|
return [inboxRelays, outboxRelays]; |
|
} |
|
|
|
/** |
|
* Persist login information to localStorage |
|
*/ |
|
function persistLogin(user: NDKUser, method: LoginMethod): void { |
|
const storage = safeLocalStorage(); |
|
if (!storage) return; |
|
|
|
storage.setItem(loginStorageKey, user.pubkey); |
|
storage.setItem(loginMethodStorageKey, method); |
|
} |
|
|
|
/** |
|
* Clear login information from localStorage |
|
*/ |
|
function clearLogin(): void { |
|
const storage = safeLocalStorage(); |
|
if (!storage) return; |
|
|
|
storage.removeItem(loginStorageKey); |
|
storage.removeItem(loginMethodStorageKey); |
|
} |
|
|
|
/** |
|
* Fetch user profile with fallback |
|
*/ |
|
async function fetchUserProfile(npub: string): Promise<NostrProfile> { |
|
try { |
|
return await getUserMetadata(npub, true); |
|
} catch (error) { |
|
console.warn('Failed to fetch user metadata:', error); |
|
// Fallback profile |
|
return { |
|
name: npub.slice(0, 8) + '...' + npub.slice(-4), |
|
displayName: npub.slice(0, 8) + '...' + npub.slice(-4), |
|
}; |
|
} |
|
} |
|
|
|
/** |
|
* Fetch and cache user lists in background |
|
*/ |
|
async function fetchUserListsAndUpdateCache(userPubkey: string): Promise<void> { |
|
try { |
|
console.log('Fetching user lists and updating profile cache for:', userPubkey); |
|
|
|
const userLists = await fetchCurrentUserLists(); |
|
console.log(`Found ${userLists.length} user lists`); |
|
|
|
// Collect all unique pubkeys |
|
const allPubkeys = new Set<string>(); |
|
userLists.forEach(list => { |
|
list.pubkeys.forEach(pubkey => allPubkeys.add(pubkey)); |
|
}); |
|
|
|
console.log(`Found ${allPubkeys.size} unique pubkeys in user lists`); |
|
|
|
// Batch fetch profiles for performance |
|
const batchSize = 20; |
|
const pubkeyArray = Array.from(allPubkeys); |
|
const ndk = get(ndkInstance); |
|
|
|
if (!ndk) return; |
|
|
|
for (let i = 0; i < pubkeyArray.length; i += batchSize) { |
|
const batch = pubkeyArray.slice(i, i + batchSize); |
|
|
|
try { |
|
const events = await ndk.fetchEvents({ |
|
kinds: [0], |
|
authors: batch, |
|
}); |
|
|
|
// Cache profiles |
|
for (const event of events) { |
|
if (event.content) { |
|
try { |
|
const profileData = JSON.parse(event.content); |
|
const npub = nip19.npubEncode(event.pubkey); |
|
npubCache.set(npub, profileData); |
|
} catch (e) { |
|
console.warn('Failed to parse profile data:', e); |
|
} |
|
} |
|
} |
|
} catch (error) { |
|
console.warn('Failed to fetch batch of profiles:', error); |
|
} |
|
} |
|
|
|
console.log('User lists and profile cache update completed'); |
|
} catch (error) { |
|
console.warn('Failed to fetch user lists and update cache:', error); |
|
} |
|
} |
|
|
|
/** |
|
* Common login logic to reduce code duplication |
|
*/ |
|
async function performLogin( |
|
user: NDKUser, |
|
signer: NDKSigner | null, |
|
method: LoginMethod, |
|
): Promise<void> { |
|
const ndk = get(ndkInstance); |
|
if (!ndk) throw new Error('NDK not initialized'); |
|
|
|
const npub = user.npub; |
|
console.log(`Login with ${method} - fetching profile for npub:`, npub); |
|
|
|
// Fetch profile |
|
const profile = await fetchUserProfile(npub); |
|
console.log(`Login with ${method} - fetched profile:`, profile); |
|
|
|
// Handle relays |
|
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(user); |
|
persistedInboxes.forEach(relay => ndk.addExplicitRelay(relay)); |
|
|
|
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user); |
|
persistRelays(user, inboxes, outboxes); |
|
|
|
// Set NDK state |
|
ndk.signer = signer || undefined; |
|
ndk.activeUser = user; |
|
|
|
// Create user state |
|
const userState: UserState = { |
|
pubkey: user.pubkey, |
|
npub, |
|
profile, |
|
relays: { |
|
inbox: Array.from(inboxes || persistedInboxes).map((relay) => relay.url), |
|
outbox: Array.from(outboxes || persistedOutboxes).map((relay) => relay.url), |
|
}, |
|
loginMethod: method, |
|
ndkUser: user, |
|
signer, |
|
signedIn: true, |
|
}; |
|
|
|
console.log(`Login with ${method} - setting userStore with:`, userState); |
|
userStore.set(userState); |
|
|
|
// Update relay stores |
|
try { |
|
console.debug(`[userStore.ts] loginWith${method.charAt(0).toUpperCase() + method.slice(1)}: Updating relay stores`); |
|
await updateActiveRelayStores(ndk, true); |
|
} catch (error) { |
|
console.warn(`[userStore.ts] loginWith${method.charAt(0).toUpperCase() + method.slice(1)}: Failed to update relay stores:`, error); |
|
} |
|
|
|
// Background tasks |
|
fetchUserListsAndUpdateCache(user.pubkey).catch(error => { |
|
console.warn(`[userStore.ts] loginWith${method.charAt(0).toUpperCase() + method.slice(1)}: Failed to fetch user lists:`, error); |
|
}); |
|
|
|
// Cleanup and persist |
|
clearLogin(); |
|
const storage = safeLocalStorage(); |
|
if (storage) { |
|
storage.removeItem(LOGOUT_FLAG_KEY); |
|
} |
|
persistLogin(user, method); |
|
} |
|
|
|
/** |
|
* Login with NIP-07 browser extension |
|
*/ |
|
export async function loginWithExtension(): Promise<void> { |
|
const ndk = get(ndkInstance); |
|
if (!ndk) throw new Error('NDK not initialized'); |
|
|
|
const signer = new NDKNip07Signer(); |
|
const user = await signer.user(); |
|
|
|
await performLogin(user, signer, 'extension'); |
|
} |
|
|
|
/** |
|
* Login with Amber (NIP-46) |
|
*/ |
|
export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser): Promise<void> { |
|
await performLogin(user, amberSigner, 'amber'); |
|
} |
|
|
|
/** |
|
* Login with npub (read-only) |
|
*/ |
|
export async function loginWithNpub(pubkeyOrNpub: string): Promise<void> { |
|
const ndk = get(ndkInstance); |
|
if (!ndk) throw new Error('NDK not initialized'); |
|
|
|
// Decode pubkey |
|
let hexPubkey: string; |
|
if (pubkeyOrNpub.startsWith('npub1')) { |
|
try { |
|
const decoded = nip19.decode(pubkeyOrNpub); |
|
if (decoded.type !== 'npub') { |
|
throw new Error('Invalid npub format'); |
|
} |
|
hexPubkey = decoded.data; |
|
} catch (e) { |
|
console.error('Failed to decode npub:', pubkeyOrNpub, e); |
|
throw e; |
|
} |
|
} else { |
|
hexPubkey = pubkeyOrNpub; |
|
} |
|
|
|
// Encode npub |
|
let npub: string; |
|
try { |
|
npub = nip19.npubEncode(hexPubkey); |
|
} catch (e) { |
|
console.error('Failed to encode npub from hex pubkey:', hexPubkey, e); |
|
throw e; |
|
} |
|
|
|
console.log('Login with npub - fetching profile for npub:', npub); |
|
|
|
const user = ndk.getUser({ npub }); |
|
|
|
// Update relay stores first |
|
try { |
|
console.debug('[userStore.ts] loginWithNpub: Updating relay stores'); |
|
await updateActiveRelayStores(ndk); |
|
} catch (error) { |
|
console.warn('[userStore.ts] loginWithNpub: Failed to update relay stores:', error); |
|
} |
|
|
|
// Wait for relay stores to initialize |
|
await new Promise(resolve => setTimeout(resolve, 500)); |
|
|
|
// Fetch profile |
|
const profile = await fetchUserProfile(npub); |
|
|
|
// Set NDK state (no signer for read-only) |
|
ndk.signer = undefined; |
|
ndk.activeUser = user; |
|
|
|
// Create user state |
|
const userState: UserState = { |
|
pubkey: user.pubkey, |
|
npub, |
|
profile, |
|
relays: { inbox: [], outbox: [] }, |
|
loginMethod: 'npub', |
|
ndkUser: user, |
|
signer: null, |
|
signedIn: true, |
|
}; |
|
|
|
console.log('Login with npub - setting userStore with:', userState); |
|
userStore.set(userState); |
|
|
|
// Background tasks |
|
fetchUserListsAndUpdateCache(user.pubkey).catch(error => { |
|
console.warn('[userStore.ts] loginWithNpub: Failed to fetch user lists:', error); |
|
}); |
|
|
|
// Cleanup and persist |
|
clearLogin(); |
|
const storage = safeLocalStorage(); |
|
if (storage) { |
|
storage.removeItem(LOGOUT_FLAG_KEY); |
|
} |
|
persistLogin(user, 'npub'); |
|
} |
|
|
|
/** |
|
* Logout and clear all user state |
|
*/ |
|
export function logoutUser(): void { |
|
console.log('Logging out user...'); |
|
const currentUser = get(userStore); |
|
|
|
// Clear localStorage |
|
const storage = safeLocalStorage(); |
|
if (storage) { |
|
if (currentUser.ndkUser) { |
|
// Clear persisted relays |
|
storage.removeItem(getRelayStorageKey(currentUser.ndkUser, 'inbox')); |
|
storage.removeItem(getRelayStorageKey(currentUser.ndkUser, 'outbox')); |
|
} |
|
|
|
// Clear login data |
|
clearLogin(); |
|
|
|
// Clear any other potential login keys |
|
const keysToRemove: string[] = []; |
|
for (let i = 0; i < storage.length; i++) { |
|
const key = storage.key(i); |
|
if (key && ( |
|
key.includes('login') || |
|
key.includes('nostr') || |
|
key.includes('user') || |
|
key.includes('alexandria') || |
|
key === 'pubkey' |
|
)) { |
|
keysToRemove.push(key); |
|
} |
|
} |
|
|
|
// Clear specific keys |
|
keysToRemove.push('alexandria/login/pubkey', 'alexandria/login/method'); |
|
keysToRemove.forEach(key => { |
|
console.log('Removing localStorage key:', key); |
|
storage.removeItem(key); |
|
}); |
|
|
|
// Clear Amber-specific flags |
|
storage.removeItem('alexandria/amber/fallback'); |
|
|
|
// Set logout flag |
|
storage.setItem(LOGOUT_FLAG_KEY, 'true'); |
|
|
|
console.log('Cleared all login data from localStorage'); |
|
} |
|
|
|
// Clear cache |
|
relayStorageKeyCache.clear(); |
|
|
|
// Reset user store |
|
userStore.set(initialUserState); |
|
|
|
// Clear NDK state |
|
const ndk = get(ndkInstance); |
|
if (ndk) { |
|
ndk.activeUser = undefined; |
|
ndk.signer = undefined; |
|
} |
|
|
|
console.log('Logout complete'); |
|
} |
|
|
|
/** |
|
* Reset user store to initial state |
|
*/ |
|
export function resetUserStore(): void { |
|
userStore.set(initialUserState); |
|
relayStorageKeyCache.clear(); |
|
} |
|
|
|
/** |
|
* Get current user state |
|
*/ |
|
export function getCurrentUser(): UserState { |
|
return get(userStore); |
|
} |
|
|
|
/** |
|
* Check if user is signed in |
|
*/ |
|
export function isUserSignedIn(): boolean { |
|
return get(userStore).signedIn; |
|
}
|
|
|