7 changed files with 772 additions and 79 deletions
@ -0,0 +1,480 @@
@@ -0,0 +1,480 @@
|
||||
<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 { UserOutline, ArrowRightToBracketOutline } from 'flowbite-svelte-icons'; |
||||
import { getUserMetadata, type NostrProfile } from '$lib/utils/nostrUtils'; |
||||
|
||||
// Component state |
||||
let npub: string | null = $state(null); |
||||
let signer: NDKNip46Signer | NDKNip07Signer | 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); |
||||
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; |
||||
// Add a reference for the profile avatar |
||||
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 |
||||
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 { |
||||
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'; |
||||
} |
||||
}; |
||||
|
||||
// Helper to show result message near avatar and auto-dismiss |
||||
function showResultMessage(msg: string) { |
||||
result = msg; |
||||
if (resultTimeout) { |
||||
clearTimeout(resultTimeout); |
||||
} |
||||
resultTimeout = setTimeout(() => { |
||||
result = null; |
||||
}, 4000); |
||||
} |
||||
|
||||
// Fetch and set profile info after login |
||||
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 () => { |
||||
isLoadingExtension = true; |
||||
isLoadingAmber = false; |
||||
try { |
||||
const ndk = get(ndkInstance); |
||||
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) { |
||||
showResultMessage(`❌ Browser extension connection failed: ${err instanceof Error ? err.message : String(err)}`); |
||||
} finally { |
||||
isLoadingExtension = false; |
||||
} |
||||
}; |
||||
|
||||
// Amber login |
||||
const handleAmberLogin = async () => { |
||||
isLoadingAmber = true; |
||||
isLoadingExtension = false; |
||||
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; |
||||
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; |
||||
} else { |
||||
throw new Error('Failed to generate Nostr Connect URI'); |
||||
} |
||||
} catch (err: unknown) { |
||||
showResultMessage(`❌ Amber connection failed: ${err instanceof Error ? err.message : String(err)}`); |
||||
} finally { |
||||
isLoadingAmber = false; |
||||
} |
||||
}; |
||||
|
||||
// Read-only login (npub input) |
||||
const handleReadOnlyLogin = async () => { |
||||
const inputNpub = prompt('Enter your npub (public key):'); |
||||
if (inputNpub) { |
||||
npub = inputNpub; |
||||
await fetchAndSetProfile(npub); |
||||
// Set NDK active user and update relays for read-only mode |
||||
const ndk = get(ndkInstance); |
||||
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 = () => { |
||||
clearStoredNsec(); |
||||
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) { |
||||
if (!long) return ''; |
||||
return long.slice(0, 8) + '…' + long.slice(-4); |
||||
} |
||||
</script> |
||||
|
||||
<div class="relative"> |
||||
{#if !npub} |
||||
<!-- Login button --> |
||||
<div class="group"> |
||||
<button |
||||
bind:this={loginButtonRef} |
||||
id="login-avatar" |
||||
class="h-6 w-6 rounded-full bg-gray-300 flex items-center justify-center cursor-pointer hover:bg-gray-400 transition-colors" |
||||
> |
||||
<UserOutline class="h-4 w-4 text-gray-600" /> |
||||
</button> |
||||
<Popover |
||||
placement="bottom" |
||||
triggeredBy="#login-avatar" |
||||
class='popover-leather w-[200px]' |
||||
trigger='click' |
||||
> |
||||
<div class='flex flex-col space-y-2'> |
||||
<h3 class='text-lg font-bold mb-2'>Login with...</h3> |
||||
<button |
||||
class='btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50' |
||||
onclick={handleBrowserExtensionLogin} |
||||
disabled={isLoadingExtension || isLoadingAmber} |
||||
> |
||||
{#if isLoadingExtension} |
||||
🔄 Connecting... |
||||
{:else} |
||||
🌐 Browser extension |
||||
{/if} |
||||
</button> |
||||
<button |
||||
class='btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50' |
||||
onclick={handleAmberLogin} |
||||
disabled={isLoadingAmber || isLoadingExtension} |
||||
> |
||||
{#if isLoadingAmber} |
||||
🔄 Connecting... |
||||
{:else} |
||||
📱 Amber: NostrConnect |
||||
{/if} |
||||
</button> |
||||
<button |
||||
class='btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500' |
||||
onclick={handleReadOnlyLogin} |
||||
> |
||||
📖 npub (read only) |
||||
</button> |
||||
</div> |
||||
</Popover> |
||||
{#if result} |
||||
<div class="absolute right-0 top-10 z-50 bg-gray-100 p-3 rounded text-sm break-words whitespace-pre-line max-w-lg shadow-lg border border-gray-300"> |
||||
{result} |
||||
<button class="ml-2 text-gray-500 hover:text-gray-700" onclick={() => result = null}>✖</button> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{:else} |
||||
<!-- User profile --> |
||||
<div class="group"> |
||||
<button |
||||
class='h-6 w-6 rounded-full p-0 border-0 bg-transparent cursor-pointer' |
||||
id={profileAvatarId} |
||||
type='button' |
||||
aria-label='Open profile menu' |
||||
> |
||||
<Avatar |
||||
rounded |
||||
class='h-6 w-6 cursor-pointer' |
||||
src={profilePicture || undefined} |
||||
alt={profileHandle || 'User'} |
||||
/> |
||||
</button> |
||||
<Popover |
||||
placement="bottom" |
||||
triggeredBy={`#${profileAvatarId}`} |
||||
class='popover-leather w-[220px]' |
||||
trigger='click' |
||||
> |
||||
<div class='flex flex-row justify-between space-x-4'> |
||||
<div class='flex flex-col'> |
||||
<h3 class='text-lg font-bold'>{profileHandle || shortenNpub(npub)}</h3> |
||||
<ul class="space-y-2 mt-2"> |
||||
<li> |
||||
<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' |
||||
onclick={() => window.open(`./events?id=${npub}`, '_blank')} |
||||
type='button' |
||||
> |
||||
{shortenNpub(npub)} |
||||
</button> |
||||
</li> |
||||
<li> |
||||
<button |
||||
id='sign-out-button' |
||||
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500' |
||||
onclick={handleLogout} |
||||
> |
||||
<ArrowRightToBracketOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /> Sign out |
||||
</button> |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
</div> |
||||
</Popover> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if showQrCode && qrCodeDataUrl} |
||||
<!-- QR Code Modal --> |
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> |
||||
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4"> |
||||
<div class="text-center"> |
||||
<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> |
||||
|
||||
<div class="flex justify-center mb-4"> |
||||
<img |
||||
src={qrCodeDataUrl} |
||||
alt="Nostr Connect QR Code" |
||||
class="border-2 border-gray-300 rounded-lg" |
||||
width="256" |
||||
height="256" |
||||
/> |
||||
</div> |
||||
|
||||
<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> |
||||
<div class="flex"> |
||||
<input |
||||
id="nostr-connect-uri-modal" |
||||
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 mt-4"> |
||||
<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> |
||||
|
||||
<button |
||||
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} |
||||
> |
||||
Close |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
Loading…
Reference in new issue