Browse Source

fixed all logins/logouts

universal user store
master
silberengel 8 months ago
parent
commit
97036fe930
  1. 32
      src/lib/components/CommentBox.svelte
  2. 296
      src/lib/components/Login.svelte
  3. 271
      src/lib/components/LoginMenu.svelte
  4. 12
      src/lib/components/LoginModal.svelte
  5. 2
      src/lib/components/PublicationHeader.svelte
  6. 1
      src/lib/components/cards/BlogHeader.svelte
  7. 12
      src/lib/components/util/CardActions.svelte
  8. 13
      src/lib/components/util/Profile.svelte
  9. 73
      src/lib/ndk.ts
  10. 4
      src/lib/stores/relayStore.ts
  11. 298
      src/lib/stores/userStore.ts
  12. 35
      src/routes/+layout.ts
  13. 8
      src/routes/+page.svelte
  14. 11
      src/routes/contact/+page.svelte
  15. 13
      src/routes/events/+page.svelte

32
src/lib/components/CommentBox.svelte

@ -4,16 +4,13 @@
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { getEventHash, signEvent, getUserMetadata, type NostrProfile } from '$lib/utils/nostrUtils'; import { getEventHash, signEvent, getUserMetadata, type NostrProfile } from '$lib/utils/nostrUtils';
import { standardRelays, fallbackRelays } from '$lib/consts'; import { standardRelays, fallbackRelays } from '$lib/consts';
import { userRelays } from '$lib/stores/relayStore'; import { userStore } from '$lib/stores/userStore';
import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from '$lib/utils/nostrUtils';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
const props = $props<{ const props = $props<{
event: NDKEvent; event: NDKEvent;
userPubkey: string;
userRelayPreference: boolean;
}>(); }>();
let content = $state(''); let content = $state('');
@ -24,11 +21,13 @@
let showOtherRelays = $state(false); let showOtherRelays = $state(false);
let showFallbackRelays = $state(false); let showFallbackRelays = $state(false);
let userProfile = $state<NostrProfile | null>(null); let userProfile = $state<NostrProfile | null>(null);
let user = $state($userStore);
userStore.subscribe(val => user = val);
// Fetch user profile on mount // Fetch user profile on mount
onMount(async () => { onMount(async () => {
if (props.userPubkey) { if (user.signedIn && user.pubkey) {
const npub = nip19.npubEncode(props.userPubkey); const npub = nip19.npubEncode(user.pubkey);
userProfile = await getUserMetadata(npub); userProfile = await getUserMetadata(npub);
} }
}); });
@ -92,6 +91,11 @@
} }
async function handleSubmit(useOtherRelays = false, useFallbackRelays = false) { async function handleSubmit(useOtherRelays = false, useFallbackRelays = false) {
if (!user.signedIn || !user.pubkey) {
error = 'You must be signed in to comment';
return;
}
isSubmitting = true; isSubmitting = true;
error = null; error = null;
success = null; success = null;
@ -135,7 +139,7 @@
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags, tags,
content, content,
pubkey: props.userPubkey pubkey: user.pubkey
}; };
const id = getEventHash(eventToSign); const id = getEventHash(eventToSign);
@ -147,10 +151,10 @@
sig sig
}; };
// Determine which relays to use // Determine which relays to use based on user's relay preference
let relays = props.userRelayPreference ? get(userRelays) : standardRelays; let relays = user.relays.inbox.length > 0 ? user.relays.inbox : standardRelays;
if (useOtherRelays) { if (useOtherRelays) {
relays = props.userRelayPreference ? standardRelays : get(userRelays); relays = user.relays.inbox.length > 0 ? standardRelays : user.relays.inbox;
} }
if (useFallbackRelays) { if (useFallbackRelays) {
relays = fallbackRelays; relays = fallbackRelays;
@ -282,16 +286,16 @@
/> />
{/if} {/if}
<span class="text-gray-700 dark:text-gray-300"> <span class="text-gray-700 dark:text-gray-300">
{userProfile.displayName || userProfile.name || nip19.npubEncode(props.userPubkey).slice(0, 8) + '...'} {userProfile.displayName || userProfile.name || (user.pubkey ? nip19.npubEncode(user.pubkey).slice(0, 8) + '...' : 'Unknown')}
</span> </span>
</div> </div>
{/if} {/if}
<Button <Button
on:click={() => handleSubmit()} on:click={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !props.userPubkey} disabled={isSubmitting || !content.trim() || !user.signedIn}
class="w-full md:w-auto" class="w-full md:w-auto"
> >
{#if !props.userPubkey} {#if !user.signedIn}
Not Signed In Not Signed In
{:else if isSubmitting} {:else if isSubmitting}
Publishing... Publishing...
@ -301,7 +305,7 @@
</Button> </Button>
</div> </div>
{#if !props.userPubkey} {#if !user.signedIn}
<Alert color="yellow" class="mt-4"> <Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your current account. Please sign in to post comments. Your comments will be signed with your current account.
</Alert> </Alert>

296
src/lib/components/Login.svelte

@ -1,296 +0,0 @@
<script lang='ts'>
import { onMount } from 'svelte';
import NDK, { NDKEvent, NDKNip46Signer, NDKPrivateKeySigner, NDKUser } from '@nostr-dev-kit/ndk';
import { ndkInstance, ndkSignedIn, activePubkey } from '$lib/ndk';
import { get } from 'svelte/store';
// Component state
let npub: string | null = $state(null);
let signer: NDKNip46Signer | null = $state(null);
let isLoading: boolean = $state(false);
let result: string | null = $state(null);
let nostrConnectUri: string | null = $state(null);
let showQrCode: boolean = $state(false);
let qrCodeDataUrl: string | null = $state(null);
// Storage helpers
const getStoredNsec = (): string | undefined => localStorage.getItem('amber/nsec') || undefined;
const saveNsec = (nsec: string): void => localStorage.setItem('amber/nsec', nsec);
const clearStoredNsec = (): void => localStorage.removeItem('amber/nsec');
const getStoredLoginMethod = (): string | null => localStorage.getItem('amber/loginMethod');
const saveLoginMethod = (method: string): void => localStorage.setItem('amber/loginMethod', method);
const clearStoredLoginMethod = (): void => localStorage.removeItem('amber/loginMethod');
const getStoredRelay = (): string => localStorage.getItem('amber/relay') || '';
const saveRelay = (value: string): void => localStorage.setItem('amber/relay', value);
const clearStoredRelay = (): void => localStorage.removeItem('amber/relay');
// Timeout helper
async function withTimeout<T>(promise: Promise<T>, ms: number, errorMessage: string): Promise<T> {
let timeout: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(errorMessage)), ms);
});
return Promise.race([promise, timeoutPromise]).then((result) => {
clearTimeout(timeout);
return result;
});
}
// Generate QR code
const generateQrCode = async (text: string): Promise<string> => {
try {
// Use a simple QR code library - we'll use a CDN version for simplicity
const QRCode = await import('qrcode');
return await QRCode.toDataURL(text, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
});
} catch (err) {
console.error('Failed to generate QR code:', err);
return '';
}
};
// Copy to clipboard function
const copyToClipboard = async (text: string): Promise<void> => {
try {
await navigator.clipboard.writeText(text);
result = '✅ URI copied to clipboard!';
} catch (err) {
result = '❌ Failed to copy to clipboard';
}
};
// Auto-login effect
onMount(() => {
const tryAutoLogin = async () => {
const localNsec = getStoredNsec();
const loginMethod = getStoredLoginMethod();
if (!localNsec || loginMethod !== 'amber') return;
try {
const relay = getStoredRelay();
if (!relay) return;
const ndk = get(ndkInstance);
if (!ndk) return;
const amberSigner = NDKNip46Signer.nostrconnect(ndk, relay, localNsec, {
name: 'Alexandria',
perms: 'sign_event:1;sign_event:4',
});
const user = await withTimeout(amberSigner.blockUntilReady(), 10000, 'Amber timeout');
signer = amberSigner;
npub = user.npub;
// Update global state
ndk.signer = amberSigner;
ndk.activeUser = user;
ndkInstance.set(ndk);
ndkSignedIn.set(true);
activePubkey.set(user.pubkey);
} catch (err: unknown) {
clearStoredNsec();
clearStoredLoginMethod();
clearStoredRelay();
console.error('Auto-login failed:', err instanceof Error ? err.message : String(err));
}
};
tryAutoLogin();
});
// Generate Amber connection
const handleGenerateAmberConnection = async () => {
isLoading = true;
try {
const ndk = get(ndkInstance);
if (!ndk) throw new Error('NDK not initialized');
const relay = 'wss://relay.nsec.app';
const localNsec = getStoredNsec() ?? NDKPrivateKeySigner.generate().nsec;
const amberSigner = NDKNip46Signer.nostrconnect(ndk, relay, localNsec, {
name: 'Alexandria',
perms: 'sign_event:1;sign_event:4',
});
if (amberSigner.nostrConnectUri) {
nostrConnectUri = amberSigner.nostrConnectUri;
showQrCode = true;
// Generate QR code
qrCodeDataUrl = await generateQrCode(amberSigner.nostrConnectUri);
// Start waiting for connection
const user = await withTimeout(amberSigner.blockUntilReady(), 15000, 'Amber timed out');
saveLoginMethod('amber');
saveNsec(amberSigner.localSigner.nsec);
saveRelay(relay);
signer = amberSigner;
npub = user.npub;
// Update global state
ndk.signer = amberSigner;
ndk.activeUser = user;
ndkInstance.set(ndk);
ndkSignedIn.set(true);
activePubkey.set(user.pubkey);
result = `✅ Connected to Amber as ${user.npub}`;
showQrCode = false;
} else {
throw new Error('Failed to generate Nostr Connect URI');
}
} catch (err: unknown) {
result = `❌ Amber connection failed: ${err instanceof Error ? err.message : String(err)}`;
} finally {
isLoading = false;
}
};
const handleSignEvent = async () => {
if (!signer) return;
try {
const ndk = get(ndkInstance);
if (!ndk) return;
const event = new NDKEvent(ndk, { kind: 1, content: 'Hello from Alexandria!' });
const sig = await withTimeout(event.sign(signer), 10000, 'Signing timed out');
result = `✅ Signed event: ${sig}`;
} catch (err: unknown) {
result = `❌ Sign error: ${err instanceof Error ? err.message : String(err)}`;
}
};
const handleLogout = () => {
clearStoredNsec();
clearStoredLoginMethod();
clearStoredRelay();
signer = null;
npub = null;
result = null;
nostrConnectUri = null;
showQrCode = false;
qrCodeDataUrl = null;
// Update global state
ndkSignedIn.set(false);
activePubkey.set(null);
};
</script>
<div class='min-h-screen p-8 sm:p-20 flex items-center justify-center font-sans'>
<div class='w-full max-w-md flex flex-col gap-6'>
{#if !npub}
<div class='text-center mb-6'>
<h1 class='text-2xl font-bold text-gray-900 mb-2'>Welcome to Alexandria</h1>
<p class='text-gray-600'>Connect with Amber to start reading and publishing</p>
</div>
{#if !showQrCode}
<button
class='bg-purple-600 hover:bg-purple-700 text-white py-3 px-6 rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-colors'
onclick={handleGenerateAmberConnection}
disabled={isLoading}
>
{#if isLoading}
🔄 Generating QR code...
{:else}
🔗 Connect with Amber
{/if}
</button>
<div class='text-sm text-gray-500 text-center'>
<p>Click to generate a QR code for your mobile Amber app</p>
</div>
{:else}
<div class='space-y-4'>
<div class='text-center'>
<h2 class='text-lg font-semibold text-gray-900 mb-2'>Scan with Amber</h2>
<p class='text-sm text-gray-600 mb-4'>Open Amber on your phone and scan this QR code</p>
</div>
<!-- QR Code -->
{#if qrCodeDataUrl}
<div class='flex justify-center'>
<img
src={qrCodeDataUrl}
alt='Nostr Connect QR Code'
class='border-2 border-gray-300 rounded-lg'
width='256'
height='256'
/>
</div>
{/if}
<!-- Copyable URI field -->
<div class='space-y-2'>
<label for='nostr-connect-uri' class='block text-sm font-medium text-gray-700'>Or copy the URI manually:</label>
<div class='flex'>
<input
id='nostr-connect-uri'
type='text'
value={nostrConnectUri}
readonly
class='flex-1 border border-gray-300 rounded-l px-3 py-2 text-sm bg-gray-50'
placeholder='nostrconnect://...'
/>
<button
class='bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-r text-sm font-medium transition-colors'
onclick={() => copyToClipboard(nostrConnectUri || '')}
>
📋 Copy
</button>
</div>
</div>
<div class='text-xs text-gray-500 text-center'>
<p>1. Open Amber on your phone</p>
<p>2. Scan the QR code above</p>
<p>3. Approve the connection in Amber</p>
</div>
</div>
{/if}
{:else}
<div class='text-center mb-6'>
<div class='text-green-600 font-semibold text-lg mb-2'>✅ Connected to Amber</div>
<div class='text-gray-600 text-sm break-all'>{npub}</div>
</div>
<div class='space-y-3'>
<button
class='w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors'
onclick={handleSignEvent}
>
Test Sign Event
</button>
<button
class='w-full bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded transition-colors'
onclick={handleLogout}
>
🚪 Disconnect Amber
</button>
</div>
{/if}
{#if result}
<div class='text-sm bg-gray-100 p-3 rounded mt-4 break-words whitespace-pre-wrap'>
{result}
</div>
{/if}
</div>
</div>

271
src/lib/components/LoginMenu.svelte

@ -1,55 +1,24 @@
<script lang='ts'> <script lang='ts'>
import { onMount } from 'svelte';
import NDK, { NDKEvent, NDKNip46Signer, NDKPrivateKeySigner, NDKUser, NDKNip07Signer, NDKRelaySet } from '@nostr-dev-kit/ndk';
import { ndkInstance, ndkSignedIn, activePubkey, inboxRelays, outboxRelays, getPersistedLogin, persistLogin, clearLogin } from '$lib/ndk';
import { get } from 'svelte/store';
import { Avatar, Popover } from 'flowbite-svelte'; import { Avatar, Popover } from 'flowbite-svelte';
import { UserOutline, ArrowRightToBracketOutline } from 'flowbite-svelte-icons'; import { UserOutline, ArrowRightToBracketOutline } from 'flowbite-svelte-icons';
import { getUserMetadata, type NostrProfile } from '$lib/utils/nostrUtils'; import { userStore, loginWithExtension, loginWithAmber, loginWithNpub, logoutUser } from '$lib/stores/userStore';
import { get } from 'svelte/store';
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
// Component state // UI state
let npub: string | null = $state(null); let isLoadingExtension: boolean = $state(false);
let signer: NDKNip46Signer | NDKNip07Signer | null = $state(null); let isLoadingAmber: boolean = $state(false);
let isLoading: boolean = $state(false);
let result: string | null = $state(null); let result: string | null = $state(null);
let nostrConnectUri: string | null = $state(null); let nostrConnectUri: string | undefined = $state(undefined);
let showQrCode: boolean = $state(false); let showQrCode: boolean = $state(false);
let qrCodeDataUrl: string | null = $state(null); let qrCodeDataUrl: string | undefined = $state(undefined);
let loginButtonRef: HTMLElement | undefined = $state(); let loginButtonRef: HTMLElement | undefined = $state();
let profile: NostrProfile | null = $state(null);
let profilePicture: string | undefined = $state(undefined);
let profileHandle: string | undefined = $state(undefined);
let resultTimeout: ReturnType<typeof setTimeout> | null = null; let resultTimeout: ReturnType<typeof setTimeout> | null = null;
// Add a reference for the profile avatar
let profileAvatarId = 'profile-avatar-btn'; let profileAvatarId = 'profile-avatar-btn';
// Add separate loading states for each login method
let isLoadingExtension: boolean = $state(false);
let isLoadingAmber: boolean = $state(false);
// Storage helpers // Subscribe to userStore
const getStoredNsec = (): string | undefined => localStorage.getItem('amber/nsec') || undefined; let user = $state(get(userStore));
const saveNsec = (nsec: string): void => localStorage.setItem('amber/nsec', nsec); userStore.subscribe(val => user = val);
const clearStoredNsec = (): void => localStorage.removeItem('amber/nsec');
const getStoredLoginMethod = (): string | null => localStorage.getItem('amber/loginMethod');
const saveLoginMethod = (method: string): void => localStorage.setItem('amber/loginMethod', method);
const clearStoredLoginMethod = (): void => localStorage.removeItem('amber/loginMethod');
const getStoredRelay = (): string => localStorage.getItem('amber/relay') || '';
const saveRelay = (value: string): void => localStorage.setItem('amber/relay', value);
const clearStoredRelay = (): void => localStorage.removeItem('amber/relay');
// Timeout helper
async function withTimeout<T>(promise: Promise<T>, ms: number, errorMessage: string): Promise<T> {
let timeout: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(errorMessage)), ms);
});
return Promise.race([promise, timeoutPromise]).then((result) => {
clearTimeout(timeout);
return result;
});
}
// Generate QR code // Generate QR code
const generateQrCode = async (text: string): Promise<string> => { const generateQrCode = async (text: string): Promise<string> => {
@ -90,86 +59,12 @@
}, 4000); }, 4000);
} }
// Fetch and set profile info after login // Login handlers
async function fetchAndSetProfile(npubVal: string | null) {
if (!npubVal) {
profile = null;
profilePicture = undefined;
profileHandle = undefined;
return;
}
const metadata = await getUserMetadata(npubVal);
profile = metadata;
profilePicture = metadata.picture || undefined;
profileHandle = metadata.displayName || metadata.name || undefined;
}
// Auto-login effect
onMount(() => {
const tryAutoLogin = async () => {
const localNsec = getStoredNsec();
const loginMethod = getStoredLoginMethod();
if (!localNsec || loginMethod !== 'amber') return;
try {
const relay = getStoredRelay();
if (!relay) return;
const ndk = get(ndkInstance);
if (!ndk) return;
const amberSigner = NDKNip46Signer.nostrconnect(ndk, relay, localNsec, {
name: 'Alexandria',
perms: 'sign_event:1;sign_event:4',
});
const user = await withTimeout(amberSigner.blockUntilReady(), 10000, 'Amber timeout');
signer = amberSigner;
npub = user.npub;
await fetchAndSetProfile(npub);
// Update global state
ndk.signer = amberSigner;
ndk.activeUser = user;
ndkInstance.set(ndk);
ndkSignedIn.set(true);
activePubkey.set(user.pubkey);
} catch (err: unknown) {
clearStoredNsec();
clearStoredLoginMethod();
clearStoredRelay();
console.error('Auto-login failed:', err instanceof Error ? err.message : String(err));
}
};
tryAutoLogin();
});
// Browser extension login
const handleBrowserExtensionLogin = async () => { const handleBrowserExtensionLogin = async () => {
isLoadingExtension = true; isLoadingExtension = true;
isLoadingAmber = false; isLoadingAmber = false;
try { try {
const ndk = get(ndkInstance); await loginWithExtension();
if (!ndk) throw new Error('NDK not initialized');
const extensionSigner = new NDKNip07Signer();
const user = await extensionSigner.user();
signer = extensionSigner;
npub = user.npub;
await fetchAndSetProfile(npub);
// Update global state
ndk.signer = extensionSigner;
ndk.activeUser = user;
ndkInstance.set(ndk);
ndkSignedIn.set(true);
activePubkey.set(user.pubkey);
persistLogin(user);
saveLoginMethod('extension');
showResultMessage(`✅ Connected with browser extension as ${profileHandle || user.npub}`);
} catch (err: unknown) { } catch (err: unknown) {
showResultMessage(`❌ Browser extension connection failed: ${err instanceof Error ? err.message : String(err)}`); showResultMessage(`❌ Browser extension connection failed: ${err instanceof Error ? err.message : String(err)}`);
} finally { } finally {
@ -177,49 +72,23 @@
} }
}; };
// Amber login
const handleAmberLogin = async () => { const handleAmberLogin = async () => {
isLoadingAmber = true; isLoadingAmber = true;
isLoadingExtension = false; isLoadingExtension = false;
try { try {
const ndk = get(ndkInstance); const ndk = new NDK();
if (!ndk) throw new Error('NDK not initialized');
const relay = 'wss://relay.nsec.app'; const relay = 'wss://relay.nsec.app';
const localNsec = getStoredNsec() ?? NDKPrivateKeySigner.generate().nsec; const localNsec = localStorage.getItem('amber/nsec') ?? NDKPrivateKeySigner.generate().nsec;
const amberSigner = NDKNip46Signer.nostrconnect(ndk, relay, localNsec, { const amberSigner = NDKNip46Signer.nostrconnect(ndk, relay, localNsec, {
name: 'Alexandria', name: 'Alexandria',
perms: 'sign_event:1;sign_event:4', perms: 'sign_event:1;sign_event:4',
}); });
if (amberSigner.nostrConnectUri) { if (amberSigner.nostrConnectUri) {
nostrConnectUri = amberSigner.nostrConnectUri; nostrConnectUri = amberSigner.nostrConnectUri ?? undefined;
showQrCode = true; showQrCode = true;
qrCodeDataUrl = (await generateQrCode(amberSigner.nostrConnectUri)) ?? undefined;
// Generate QR code const user = await amberSigner.blockUntilReady();
qrCodeDataUrl = await generateQrCode(amberSigner.nostrConnectUri); await loginWithAmber(amberSigner, user);
// Start waiting for connection
const user = await withTimeout(amberSigner.blockUntilReady(), 15000, 'Amber timed out');
saveLoginMethod('amber');
saveNsec(amberSigner.localSigner.nsec);
saveRelay(relay);
signer = amberSigner;
npub = user.npub;
await fetchAndSetProfile(npub);
// Update global state
ndk.signer = amberSigner;
ndk.activeUser = user;
ndkInstance.set(ndk);
ndkSignedIn.set(true);
activePubkey.set(user.pubkey);
persistLogin(user);
showResultMessage(`✅ Connected to Amber as ${profileHandle || user.npub}`);
showQrCode = false; showQrCode = false;
} else { } else {
throw new Error('Failed to generate Nostr Connect URI'); throw new Error('Failed to generate Nostr Connect URI');
@ -231,93 +100,37 @@
} }
}; };
// Read-only login (npub input)
const handleReadOnlyLogin = async () => { const handleReadOnlyLogin = async () => {
const inputNpub = prompt('Enter your npub (public key):'); const inputNpub = prompt('Enter your npub (public key):');
if (inputNpub) { if (inputNpub) {
npub = inputNpub; try {
await fetchAndSetProfile(npub); await loginWithNpub(inputNpub);
// Set NDK active user and update relays for read-only mode } catch (err: unknown) {
const ndk = get(ndkInstance); showResultMessage(`❌ npub login failed: ${err instanceof Error ? err.message : String(err)}`);
if (ndk) {
const user = ndk.getUser({ npub: inputNpub });
// Fetch and set relays (read-only, no signer)
const [inboxes, outboxes] = await (async () => {
try {
// getUserPreferredRelays is not exported, so inline the logic here
const relayList = await ndk.fetchEvent(
{ kinds: [10002], authors: [user.pubkey] },
{ groupable: false, skipVerification: false, skipValidation: false },
NDKRelaySet.fromRelayUrls(['wss://relay.nsec.app'], ndk)
);
const inboxRelays = new Set();
const outboxRelays = new Set();
if (relayList == null) {
// fallback: no relays found
} else {
relayList.tags.forEach(tag => {
switch (tag[0]) {
case 'r': inboxRelays.add(tag[1]); break;
case 'w': outboxRelays.add(tag[1]); break;
default:
inboxRelays.add(tag[1]);
outboxRelays.add(tag[1]);
break;
}
});
}
return [inboxRelays, outboxRelays];
} catch {
return [new Set(), new Set()];
}
})();
inboxRelays.set(Array.from(inboxes) as string[]);
outboxRelays.set(Array.from(outboxes) as string[]);
ndk.activeUser = user;
ndk.signer = undefined;
ndkInstance.set(ndk);
ndkSignedIn.set(true);
activePubkey.set(user.pubkey);
persistLogin(user);
} }
} }
}; };
const handleLogout = () => { const handleLogout = () => {
clearStoredNsec(); logoutUser();
clearStoredLoginMethod();
clearStoredRelay();
clearLogin();
signer = null;
npub = null;
result = null;
nostrConnectUri = null;
showQrCode = false;
qrCodeDataUrl = null;
profile = null;
profilePicture = undefined;
profileHandle = undefined;
// Update global state
ndkSignedIn.set(false);
activePubkey.set(null);
// Reset NDK instance state
const ndk = get(ndkInstance);
if (ndk) {
ndk.activeUser = undefined;
ndk.signer = undefined;
ndkInstance.set(ndk);
}
}; };
function shortenNpub(long: string | undefined) { function shortenNpub(long: string | undefined) {
if (!long) return ''; if (!long) return '';
return long.slice(0, 8) + '…' + long.slice(-4); return long.slice(0, 8) + '…' + long.slice(-4);
} }
function toNullAsUndefined(val: string | null): string | undefined {
return val === null ? undefined : val;
}
function nullToUndefined(val: string | null | undefined): string | undefined {
return val === null ? undefined : val;
}
</script> </script>
<div class="relative"> <div class="relative">
{#if !npub} {#if !user.signedIn}
<!-- Login button --> <!-- Login button -->
<div class="group"> <div class="group">
<button <button
@ -384,8 +197,8 @@
<Avatar <Avatar
rounded rounded
class='h-6 w-6 cursor-pointer' class='h-6 w-6 cursor-pointer'
src={profilePicture || undefined} src={user.profile?.picture || undefined}
alt={profileHandle || 'User'} alt={user.profile?.displayName || user.profile?.name || 'User'}
/> />
</button> </button>
<Popover <Popover
@ -396,15 +209,15 @@
> >
<div class='flex flex-row justify-between space-x-4'> <div class='flex flex-row justify-between space-x-4'>
<div class='flex flex-col'> <div class='flex flex-col'>
<h3 class='text-lg font-bold'>{profileHandle || shortenNpub(npub)}</h3> <h3 class='text-lg font-bold'>{user.profile?.displayName || user.profile?.name || (user.npub ? shortenNpub(user.npub) : 'Unknown')}</h3>
<ul class="space-y-2 mt-2"> <ul class="space-y-2 mt-2">
<li> <li>
<button <button
class='text-sm text-primary-600 dark:text-primary-400 underline hover:text-primary-400 dark:hover:text-primary-500 px-0 bg-transparent border-none cursor-pointer' class='text-sm text-primary-600 dark:text-primary-400 underline hover:text-primary-400 dark:hover:text-primary-500 px-0 bg-transparent border-none cursor-pointer'
onclick={() => window.open(`./events?id=${npub}`, '_blank')} onclick={() => window.location.href = `./events?id=${user.npub}`}
type='button' type='button'
> >
{shortenNpub(npub)} {user.npub ? shortenNpub(user.npub) : 'Unknown'}
</button> </button>
</li> </li>
<li> <li>
@ -431,24 +244,22 @@
<div class="text-center"> <div class="text-center">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Scan with Amber</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">Scan with Amber</h2>
<p class="text-sm text-gray-600 mb-4">Open Amber on your phone and scan this QR code</p> <p class="text-sm text-gray-600 mb-4">Open Amber on your phone and scan this QR code</p>
<div class="flex justify-center mb-4"> <div class="flex justify-center mb-4">
<img <img
src={qrCodeDataUrl} src={qrCodeDataUrl || ''}
alt="Nostr Connect QR Code" alt="Nostr Connect QR Code"
class="border-2 border-gray-300 rounded-lg" class="border-2 border-gray-300 rounded-lg"
width="256" width="256"
height="256" height="256"
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label for="nostr-connect-uri-modal" class="block text-sm font-medium text-gray-700">Or copy the URI manually:</label> <label for="nostr-connect-uri-modal" class="block text-sm font-medium text-gray-700">Or copy the URI manually:</label>
<div class="flex"> <div class="flex">
<input <input
id="nostr-connect-uri-modal" id="nostr-connect-uri-modal"
type="text" type="text"
value={nostrConnectUri} value={nostrConnectUri || ''}
readonly readonly
class="flex-1 border border-gray-300 rounded-l px-3 py-2 text-sm bg-gray-50" class="flex-1 border border-gray-300 rounded-l px-3 py-2 text-sm bg-gray-50"
placeholder="nostrconnect://..." placeholder="nostrconnect://..."
@ -461,13 +272,11 @@
</button> </button>
</div> </div>
</div> </div>
<div class="text-xs text-gray-500 mt-4"> <div class="text-xs text-gray-500 mt-4">
<p>1. Open Amber on your phone</p> <p>1. Open Amber on your phone</p>
<p>2. Scan the QR code above</p> <p>2. Scan the QR code above</p>
<p>3. Approve the connection in Amber</p> <p>3. Approve the connection in Amber</p>
</div> </div>
<button <button
class="mt-4 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors" class="mt-4 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors"
onclick={() => showQrCode = false} onclick={() => showQrCode = false}

12
src/lib/components/LoginModal.svelte

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Button } from "flowbite-svelte"; import { Button } from "flowbite-svelte";
import { loginWithExtension, ndkSignedIn } from '$lib/ndk'; import { loginWithExtension } from '$lib/stores/userStore';
import { userStore } from '$lib/stores/userStore';
const { show = false, onClose = () => {}, onLoginSuccess = () => {} } = $props<{ const { show = false, onClose = () => {}, onLoginSuccess = () => {} } = $props<{
show?: boolean; show?: boolean;
@ -10,9 +11,11 @@
let signInFailed = $state<boolean>(false); let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>(''); let errorMessage = $state<string>('');
let user = $state($userStore);
userStore.subscribe(val => user = val);
$effect(() => { $effect(() => {
if ($ndkSignedIn && show) { if (user.signedIn && show) {
onLoginSuccess(); onLoginSuccess();
onClose(); onClose();
} }
@ -23,10 +26,7 @@
signInFailed = false; signInFailed = false;
errorMessage = ''; errorMessage = '';
const user = await loginWithExtension(); await loginWithExtension();
if (!user) {
throw new Error('The NIP-07 extension did not return a user.');
}
} catch (e: unknown) { } catch (e: unknown) {
console.error(e); console.error(e);
signInFailed = true; signInFailed = true;

2
src/lib/components/PublicationHeader.svelte

@ -28,8 +28,6 @@
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1'); let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
console.log("PublicationHeader event:", event);
</script> </script>
{#if title != null && href != null} {#if title != null && href != null}

1
src/lib/components/cards/BlogHeader.svelte

@ -6,6 +6,7 @@
import Interactions from "$components/util/Interactions.svelte"; import Interactions from "$components/util/Interactions.svelte";
import { quintOut } from "svelte/easing"; import { quintOut } from "svelte/easing";
import CardActions from "$components/util/CardActions.svelte"; import CardActions from "$components/util/CardActions.svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils";
const { rootId, event, onBlogUpdate, active = true } = $props<{ rootId: string, event: NDKEvent, onBlogUpdate?: any, active: boolean }>(); const { rootId, event, onBlogUpdate, active = true } = $props<{ rootId: string, event: NDKEvent, onBlogUpdate?: any, active: boolean }>();

12
src/lib/components/util/CardActions.svelte

@ -10,13 +10,17 @@
import { neventEncode, naddrEncode } from "$lib/utils"; import { neventEncode, naddrEncode } from "$lib/utils";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { feedType } from "$lib/stores"; import { feedType } from "$lib/stores";
import { inboxRelays, ndkSignedIn } from "$lib/ndk"; import { userStore } from "$lib/stores/userStore";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
// Component props // Component props
let { event } = $props<{ event: NDKEvent }>(); let { event } = $props<{ event: NDKEvent }>();
// Subscribe to userStore
let user = $state($userStore);
userStore.subscribe(val => user = val);
// Derive metadata from event // Derive metadata from event
let title = $derived(event.tags.find((t: string[]) => t[0] === 'title')?.[1] ?? ''); let title = $derived(event.tags.find((t: string[]) => t[0] === 'title')?.[1] ?? '');
let summary = $derived(event.tags.find((t: string[]) => t[0] === 'summary')?.[1] ?? ''); let summary = $derived(event.tags.find((t: string[]) => t[0] === 'summary')?.[1] ?? '');
@ -41,12 +45,12 @@
*/ */
let activeRelays = $derived( let activeRelays = $derived(
(() => { (() => {
const isUserFeed = $ndkSignedIn && $feedType === FeedType.UserRelays; const isUserFeed = user.signedIn && $feedType === FeedType.UserRelays;
const relays = isUserFeed ? $inboxRelays : standardRelays; const relays = isUserFeed ? user.relays.inbox : standardRelays;
console.debug("[CardActions] Selected relays:", { console.debug("[CardActions] Selected relays:", {
eventId: event.id, eventId: event.id,
isSignedIn: $ndkSignedIn, isSignedIn: user.signedIn,
feedType: $feedType, feedType: $feedType,
isUserFeed, isUserFeed,
relayCount: relays.length, relayCount: relays.length,

13
src/lib/components/util/Profile.svelte

@ -1,9 +1,11 @@
<script lang='ts'> <script lang='ts'>
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { logout, ndkInstance } from '$lib/ndk'; import { logoutUser } from '$lib/stores/userStore';
import { ndkInstance } from '$lib/ndk';
import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flowbite-svelte-icons"; import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte"; import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk"; import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { get } from 'svelte/store';
const externalProfileDestination = './events?id=' const externalProfileDestination = './events?id='
@ -16,19 +18,20 @@ let tag = $derived(profile?.name);
let npub = $state<string | undefined >(undefined); let npub = $state<string | undefined >(undefined);
$effect(() => { $effect(() => {
const user = $ndkInstance const ndk = get(ndkInstance);
.getUser({ pubkey: pubkey ?? undefined }); if (!ndk) return;
const user = ndk.getUser({ pubkey: pubkey ?? undefined });
npub = user.npub; npub = user.npub;
user.fetchProfile() user.fetchProfile()
.then(userProfile => { .then((userProfile: NDKUserProfile | null) => {
profile = userProfile; profile = userProfile;
}); });
}); });
async function handleSignOutClick() { async function handleSignOutClick() {
logout($ndkInstance.activeUser!); logoutUser();
profile = null; profile = null;
} }

73
src/lib/ndk.ts

@ -1,17 +1,11 @@
import NDK, { NDKNip07Signer, NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser } from '@nostr-dev-kit/ndk'; import NDK, { NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser } from '@nostr-dev-kit/ndk';
import { get, writable, type Writable } from 'svelte/store'; import { get, writable, type Writable } from 'svelte/store';
import { fallbackRelays, FeedType, loginStorageKey, standardRelays } from './consts'; import { fallbackRelays, FeedType, loginStorageKey, standardRelays } from './consts';
import { feedType } from './stores'; import { feedType } from './stores';
import { userStore } from './stores/userStore';
export const ndkInstance: Writable<NDK> = writable(); export const ndkInstance: Writable<NDK> = writable();
export const ndkSignedIn: Writable<boolean> = writable(false);
export const activePubkey: Writable<string | null> = writable(null);
export const inboxRelays: Writable<string[]> = writable([]);
export const outboxRelays: Writable<string[]> = writable([]);
/** /**
* Gets the user's pubkey from local storage, if it exists. * Gets the user's pubkey from local storage, if it exists.
* @returns The user's pubkey, or null if there is no logged-in user. * @returns The user's pubkey, or null if there is no logged-in user.
@ -91,9 +85,10 @@ export function clearPersistedRelays(user: NDKUser): void {
} }
export function getActiveRelays(ndk: NDK): NDKRelaySet { export function getActiveRelays(ndk: NDK): NDKRelaySet {
return get(feedType) === FeedType.UserRelays const user = get(userStore);
return get(feedType) === FeedType.UserRelays && user.signedIn
? new NDKRelaySet( ? new NDKRelaySet(
new Set(get(inboxRelays).map(relay => new NDKRelay( new Set(user.relays.inbox.map(relay => new NDKRelay(
relay, relay,
NDKRelayAuthPolicies.signIn({ ndk }), NDKRelayAuthPolicies.signIn({ ndk }),
ndk, ndk,
@ -135,68 +130,12 @@ export function initNdk(): NDK {
return ndk; return ndk;
} }
/**
* Signs in with a NIP-07 browser extension, and determines the user's preferred inbox and outbox
* relays.
* @returns The user's profile, if it is available.
* @throws If sign-in fails. This may because there is no accessible NIP-07 extension, or because
* NDK is unable to fetch the user's profile or relay lists.
*/
export async function loginWithExtension(pubkey?: string): Promise<NDKUser | null> {
try {
const ndk = get(ndkInstance);
const signer = new NDKNip07Signer();
const signerUser = await signer.user();
// TODO: Handle changing pubkeys.
if (pubkey && signerUser.pubkey !== pubkey) {
console.debug('Switching pubkeys from last login.');
}
activePubkey.set(signerUser.pubkey);
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(signerUser);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
const user = ndk.getUser({ pubkey: signerUser.pubkey });
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
inboxRelays.set(Array.from(inboxes ?? persistedInboxes).map(relay => relay.url));
outboxRelays.set(Array.from(outboxes ?? persistedOutboxes).map(relay => relay.url));
persistRelays(signerUser, inboxes, outboxes);
ndk.signer = signer;
ndk.activeUser = user;
ndkInstance.set(ndk);
ndkSignedIn.set(true);
return user;
} catch (e) {
throw new Error(`Failed to sign in with NIP-07 extension: ${e}`);
}
}
/**
* Handles logging out a user.
* @param user The user to log out.
*/
export function logout(user: NDKUser): void {
clearLogin();
clearPersistedRelays(user);
activePubkey.set(null);
ndkSignedIn.set(false);
}
/** /**
* Fetches the user's NIP-65 relay list, if one can be found, and returns the inbox and outbox * Fetches the user's NIP-65 relay list, if one can be found, and returns the inbox and outbox
* relay sets. * relay sets.
* @returns A tuple of relay sets of the form `[inboxRelays, outboxRelays]`. * @returns A tuple of relay sets of the form `[inboxRelays, outboxRelays]`.
*/ */
async function getUserPreferredRelays( export async function getUserPreferredRelays(
ndk: NDK, ndk: NDK,
user: NDKUser, user: NDKUser,
fallbacks: readonly string[] = fallbackRelays fallbacks: readonly string[] = fallbackRelays

4
src/lib/stores/relayStore.ts

@ -1,4 +0,0 @@
import { writable } from 'svelte/store';
// Initialize with empty array, will be populated from user preferences
export const userRelays = writable<string[]>([]);

298
src/lib/stores/userStore.ts

@ -0,0 +1,298 @@
import { writable, get } from 'svelte/store';
import type { NostrProfile } from '$lib/utils/nostrUtils';
import type { NDKUser, NDKSigner } from '@nostr-dev-kit/ndk';
import { NDKNip07Signer, NDKRelayAuthPolicies, NDKRelaySet, NDKRelay } from '@nostr-dev-kit/ndk';
import { getUserMetadata } from '$lib/utils/nostrUtils';
import { ndkInstance } from '$lib/ndk';
import { loginStorageKey, fallbackRelays } from '$lib/consts';
import { nip19 } from 'nostr-tools';
export interface UserState {
pubkey: string | null;
npub: string | null;
profile: NostrProfile | null;
relays: { inbox: string[]; outbox: string[] };
loginMethod: 'extension' | 'amber' | 'npub' | null;
ndkUser: NDKUser | null;
signer: NDKSigner | null;
signedIn: boolean;
}
export const userStore = writable<UserState>({
pubkey: null,
npub: null,
profile: null,
relays: { inbox: [], outbox: [] },
loginMethod: null,
ndkUser: null,
signer: null,
signedIn: false,
});
// Helper functions for relay management
function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string {
return `${loginStorageKey}/${user.pubkey}/${type}`;
}
function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKRelay>): void {
localStorage.setItem(
getRelayStorageKey(user, 'inbox'),
JSON.stringify(Array.from(inboxes).map(relay => relay.url))
);
localStorage.setItem(
getRelayStorageKey(user, 'outbox'),
JSON.stringify(Array.from(outboxes).map(relay => relay.url))
);
}
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
const inboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'inbox')) ?? '[]')
);
const outboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'outbox')) ?? '[]')
);
return [inboxes, outboxes];
}
async function getUserPreferredRelays(
ndk: any,
user: NDKUser,
fallbacks: readonly string[] = fallbackRelays
): 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 == null) {
const relayMap = await window.nostr?.getRelays?.();
Object.entries(relayMap ?? {}).forEach(([url, relayType]: [string, any]) => {
const relay = new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk);
if (relayType.read) inboxRelays.add(relay);
if (relayType.write) outboxRelays.add(relay);
});
} else {
relayList.tags.forEach((tag: string[]) => {
switch (tag[0]) {
case 'r':
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
break;
case 'w':
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
break;
default:
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
break;
}
});
}
return [inboxRelays, outboxRelays];
}
// --- Unified login/logout helpers ---
export const loginMethodStorageKey = 'alexandria/login/method';
function persistLogin(user: NDKUser, method: 'extension' | 'amber' | 'npub') {
localStorage.setItem(loginStorageKey, user.pubkey);
localStorage.setItem(loginMethodStorageKey, method);
}
function getPersistedLoginMethod(): 'extension' | 'amber' | 'npub' | null {
return (localStorage.getItem(loginMethodStorageKey) as 'extension' | 'amber' | 'npub') ?? null;
}
function clearLogin() {
localStorage.removeItem(loginStorageKey);
localStorage.removeItem(loginMethodStorageKey);
}
/**
* Login with NIP-07 browser extension
*/
export async function loginWithExtension() {
const ndk = get(ndkInstance);
if (!ndk) throw new Error('NDK not initialized');
// Only clear previous login state after successful login
const signer = new NDKNip07Signer();
const user = await signer.user();
const npub = user.npub;
const profile = await getUserMetadata(npub);
// Fetch user's preferred relays
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(user);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
persistRelays(user, inboxes, outboxes);
ndk.signer = signer;
ndk.activeUser = user;
userStore.set({
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: 'extension',
ndkUser: user,
signer,
signedIn: true,
});
clearLogin();
localStorage.removeItem('alexandria/logout/flag');
persistLogin(user, 'extension');
}
/**
* Login with Amber (NIP-46)
*/
export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) {
const ndk = get(ndkInstance);
if (!ndk) throw new Error('NDK not initialized');
// Only clear previous login state after successful login
const npub = user.npub;
const profile = await getUserMetadata(npub);
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(user);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
persistRelays(user, inboxes, outboxes);
ndk.signer = amberSigner;
ndk.activeUser = user;
userStore.set({
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: 'amber',
ndkUser: user,
signer: amberSigner,
signedIn: true,
});
clearLogin();
localStorage.removeItem('alexandria/logout/flag');
persistLogin(user, 'amber');
}
/**
* Login with npub (read-only)
*/
export async function loginWithNpub(pubkeyOrNpub: string) {
const ndk = get(ndkInstance);
if (!ndk) throw new Error('NDK not initialized');
// Only clear previous login state after successful login
let hexPubkey: string;
if (pubkeyOrNpub.startsWith('npub')) {
try {
hexPubkey = nip19.decode(pubkeyOrNpub).data as string;
} catch (e) {
console.error('Failed to decode hex pubkey from npub:', pubkeyOrNpub, e);
throw e;
}
} else {
hexPubkey = pubkeyOrNpub;
}
let npub: string;
try {
npub = nip19.npubEncode(hexPubkey);
} catch (e) {
console.error('Failed to encode npub from hex pubkey:', hexPubkey, e);
throw e;
}
const user = ndk.getUser({ npub });
const profile = await getUserMetadata(npub);
ndk.signer = undefined;
ndk.activeUser = user;
userStore.set({
pubkey: user.pubkey,
npub,
profile,
relays: { inbox: [], outbox: [] },
loginMethod: 'npub',
ndkUser: user,
signer: null,
signedIn: true,
});
clearLogin();
localStorage.removeItem('alexandria/logout/flag');
persistLogin(user, 'npub');
}
/**
* Logout and clear all user state
*/
export function logoutUser() {
console.log('Logging out user...');
const currentUser = get(userStore);
if (currentUser.ndkUser) {
// Clear persisted relays for the user
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, 'inbox'));
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, 'outbox'));
}
// Clear all possible login states from localStorage
clearLogin();
// Also clear any other potential login keys that might exist
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.includes('login') || key.includes('nostr') || key.includes('user') || key.includes('alexandria') || key === 'pubkey')) {
keysToRemove.push(key);
}
}
// Specifically target the login storage key
keysToRemove.push('alexandria/login/pubkey');
keysToRemove.push('alexandria/login/method');
keysToRemove.forEach(key => {
console.log('Removing localStorage key:', key);
localStorage.removeItem(key);
});
// Set a flag to prevent auto-login on next page load
localStorage.setItem('alexandria/logout/flag', 'true');
console.log('Cleared all login data from localStorage');
userStore.set({
pubkey: null,
npub: null,
profile: null,
relays: { inbox: [], outbox: [] },
loginMethod: null,
ndkUser: null,
signer: null,
signedIn: false,
});
const ndk = get(ndkInstance);
if (ndk) {
ndk.activeUser = undefined;
ndk.signer = undefined;
}
console.log('Logout complete');
}

35
src/routes/+layout.ts

@ -1,6 +1,8 @@
import { feedTypeStorageKey } from '$lib/consts'; import { feedTypeStorageKey } from '$lib/consts';
import { FeedType } from '$lib/consts'; import { FeedType } from '$lib/consts';
import { getPersistedLogin, initNdk, loginWithExtension, ndkInstance } from '$lib/ndk'; import { getPersistedLogin, initNdk, ndkInstance } from '$lib/ndk';
import { loginWithExtension, loginWithAmber, loginWithNpub } from '$lib/stores/userStore';
import { loginMethodStorageKey } from '$lib/stores/userStore';
import Pharos, { pharosInstance } from '$lib/parser'; import Pharos, { pharosInstance } from '$lib/parser';
import { feedType } from '$lib/stores'; import { feedType } from '$lib/stores';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
@ -16,17 +18,32 @@ export const load: LayoutLoad = () => {
ndkInstance.set(ndk); ndkInstance.set(ndk);
try { try {
// Michael J - 18 Jan 2025 - This will not work server-side, since the NIP-07 extension is only
// available in the browser, and the flags for persistent login are saved in the browser's
// local storage. If SSR is ever enabled, move this code block to run client-side.
const pubkey = getPersistedLogin(); const pubkey = getPersistedLogin();
if (pubkey) { const loginMethod = localStorage.getItem(loginMethodStorageKey);
// Michael J - 27 Jan 2025 - We don't await this call; it will run in the background and const logoutFlag = localStorage.getItem('alexandria/logout/flag');
// update Svelte stores to propagate data. console.log('Layout load - persisted pubkey:', pubkey);
loginWithExtension(pubkey); console.log('Layout load - persisted login method:', loginMethod);
console.log('Layout load - logout flag:', logoutFlag);
console.log('All localStorage keys:', Object.keys(localStorage));
if (pubkey && loginMethod && !logoutFlag) {
if (loginMethod === 'extension') {
console.log('Restoring extension login...');
loginWithExtension();
} else if (loginMethod === 'amber') {
// Amber login restoration would require more context (e.g., session, signer), so skip for now
alert('Amber login cannot be restored automatically. Please reconnect your Amber wallet.');
console.warn('Amber login cannot be restored automatically. Please reconnect your Amber wallet.');
} else if (loginMethod === 'npub') {
console.log('Restoring npub login...');
loginWithNpub(pubkey);
}
} else if (logoutFlag) {
console.log('Skipping auto-login due to logout flag');
localStorage.removeItem('alexandria/logout/flag');
} }
} catch (e) { } catch (e) {
console.warn(`Failed to login with extension: ${e}\n\nContinuing with anonymous session.`); console.warn(`Failed to restore login: ${e}\n\nContinuing with anonymous session.`);
} }
const parser = new Pharos(ndk); const parser = new Pharos(ndk);

8
src/routes/+page.svelte

@ -2,7 +2,7 @@
import { FeedType, feedTypeStorageKey, standardRelays, fallbackRelays } from '$lib/consts'; import { FeedType, feedTypeStorageKey, standardRelays, fallbackRelays } from '$lib/consts';
import { Alert, Button, Dropdown, Radio, Input } from "flowbite-svelte"; import { Alert, Button, Dropdown, Radio, Input } from "flowbite-svelte";
import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons"; import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons";
import { inboxRelays, ndkSignedIn } from '$lib/ndk'; import { userStore } from '$lib/stores/userStore';
import PublicationFeed from '$lib/components/PublicationFeed.svelte'; import PublicationFeed from '$lib/components/PublicationFeed.svelte';
import { feedType } from '$lib/stores'; import { feedType } from '$lib/stores';
@ -22,6 +22,8 @@
}; };
let searchQuery = $state(''); let searchQuery = $state('');
let user = $state($userStore);
userStore.subscribe(val => user = val);
</script> </script>
<Alert rounded={false} id="alert-experimental" class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2'> <Alert rounded={false} id="alert-experimental" class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2'>
@ -32,7 +34,7 @@
</Alert> </Alert>
<main class='leather flex flex-col flex-grow-0 space-y-4 p-4'> <main class='leather flex flex-col flex-grow-0 space-y-4 p-4'>
{#if !$ndkSignedIn} {#if !user.signedIn}
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} /> <PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else} {:else}
<div class='leather w-full flex flex-row items-center justify-center gap-4 mb-4'> <div class='leather w-full flex flex-row items-center justify-center gap-4 mb-4'>
@ -60,7 +62,7 @@
{#if $feedType === FeedType.StandardRelays} {#if $feedType === FeedType.StandardRelays}
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} /> <PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else if $feedType === FeedType.UserRelays} {:else if $feedType === FeedType.UserRelays}
<PublicationFeed relays={$inboxRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} /> <PublicationFeed relays={user.relays.inbox} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{/if} {/if}
{/if} {/if}
</main> </main>

11
src/routes/contact/+page.svelte

@ -1,6 +1,7 @@
<script lang='ts'> <script lang='ts'>
import { Heading, P, A, Button, Label, Textarea, Input, Modal } from 'flowbite-svelte'; import { Heading, P, A, Button, Label, Textarea, Input, Modal } from 'flowbite-svelte';
import { ndkSignedIn, ndkInstance } from '$lib/ndk'; import { ndkInstance } from '$lib/ndk';
import { userStore } from '$lib/stores/userStore';
import { standardRelays } from '$lib/consts'; import { standardRelays } from '$lib/consts';
import type NDK from '@nostr-dev-kit/ndk'; import type NDK from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk';
@ -44,6 +45,10 @@
content: '' content: ''
}; };
// Subscribe to userStore
let user = $state($userStore);
userStore.subscribe(val => user = val);
// Repository event address from the task // Repository event address from the task
const repoAddress = 'naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr'; const repoAddress = 'naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr';
@ -79,7 +84,7 @@
} }
// Check if user is logged in // Check if user is logged in
if (!$ndkSignedIn) { if (!user.signedIn) {
// Save form data // Save form data
savedFormData = { savedFormData = {
subject, subject,
@ -258,7 +263,7 @@
// Handle login completion // Handle login completion
$effect(() => { $effect(() => {
if ($ndkSignedIn && showLoginModal) { if (user.signedIn && showLoginModal) {
showLoginModal = false; showLoginModal = false;
// Restore saved form data // Restore saved form data

13
src/routes/events/+page.svelte

@ -7,6 +7,7 @@
import EventDetails from '$lib/components/EventDetails.svelte'; import EventDetails from '$lib/components/EventDetails.svelte';
import RelayActions from '$lib/components/RelayActions.svelte'; import RelayActions from '$lib/components/RelayActions.svelte';
import CommentBox from '$lib/components/CommentBox.svelte'; import CommentBox from '$lib/components/CommentBox.svelte';
import { userStore } from '$lib/stores/userStore';
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -22,8 +23,8 @@
lud16?: string; lud16?: string;
nip05?: string; nip05?: string;
} | null>(null); } | null>(null);
let userPubkey = $state<string | null>(null); let user = $state($userStore);
let userRelayPreference = $state(false); userStore.subscribe(val => user = val);
function handleEventFound(newEvent: NDKEvent) { function handleEventFound(newEvent: NDKEvent) {
event = newEvent; event = newEvent;
@ -43,10 +44,6 @@
if (id) { if (id) {
searchValue = id; searchValue = id;
} }
// Get user's pubkey and relay preference from localStorage
userPubkey = localStorage.getItem('userPubkey');
userRelayPreference = localStorage.getItem('useUserRelays') === 'true';
}); });
</script> </script>
@ -64,10 +61,10 @@
{#if event} {#if event}
<EventDetails {event} {profile} {searchValue} /> <EventDetails {event} {profile} {searchValue} />
<RelayActions {event} /> <RelayActions {event} />
{#if userPubkey} {#if user.signedIn}
<div class="mt-8"> <div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">Add Comment</Heading> <Heading tag="h2" class="h-leather mb-4">Add Comment</Heading>
<CommentBox event={event} userPubkey={userPubkey} userRelayPreference={userRelayPreference} /> <CommentBox {event} />
</div> </div>
{:else} {:else}
<div class="mt-8 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg"> <div class="mt-8 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">

Loading…
Cancel
Save