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.
645 lines
21 KiB
645 lines
21 KiB
<script lang="ts"> |
|
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; |
|
import NetworkStatus from "$components/NetworkStatus.svelte"; |
|
import { |
|
logoutUser, |
|
userStore, |
|
loginWithExtension, |
|
loginWithAmber, |
|
loginWithNpub |
|
} from "$lib/stores/userStore"; |
|
import { |
|
ArrowRightToBracketOutline, |
|
UserOutline, |
|
} from "flowbite-svelte-icons"; |
|
import { Avatar, Popover } from "flowbite-svelte"; |
|
import { get } from "svelte/store"; |
|
import { goto } from "$app/navigation"; |
|
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; |
|
import { onMount } from "svelte"; |
|
import { getUserMetadata } from "$lib/utils/nostrUtils"; |
|
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk"; |
|
|
|
const ndk = getNdkContext(); |
|
|
|
let { isNav = false } = $props<{ isNav?: boolean }>(); |
|
|
|
// UI state for login functionality |
|
let isLoadingExtension: boolean = $state(false); |
|
let isLoadingAmber: boolean = $state(false); |
|
let result: string | null = $state(null); |
|
let nostrConnectUri: string | undefined = $state(undefined); |
|
let showQrCode: boolean = $state(false); |
|
let qrCodeDataUrl: string | undefined = $state(undefined); |
|
let loginButtonRef: HTMLElement | undefined = $state(); |
|
let resultTimeout: ReturnType<typeof setTimeout> | null = null; |
|
let profileAvatarId = "profile-avatar-btn"; |
|
let showAmberFallback = $state(false); |
|
let fallbackCheckInterval: ReturnType<typeof setInterval> | null = null; |
|
let isRefreshingProfile = $state(false); |
|
|
|
onMount(() => { |
|
if (localStorage.getItem("alexandria/amber/fallback") === "1") { |
|
console.log("Profile: Found fallback flag on mount, showing modal"); |
|
showAmberFallback = true; |
|
} |
|
}); |
|
|
|
// Use profile data from userStore |
|
let userState = $derived($userStore); |
|
let profile = $derived(userState.profile); |
|
let pfp = $derived(profile?.picture); |
|
let username = $derived(profile?.name); |
|
let tag = $derived(profile?.name); |
|
let npub = $derived(userState.npub); |
|
|
|
// Debug logging |
|
$effect(() => { |
|
console.log("Profile component - userState:", userState); |
|
console.log("Profile component - profile:", profile); |
|
console.log("Profile component - pfp:", pfp); |
|
console.log("Profile component - username:", username); |
|
}); |
|
|
|
// Handle user state changes with effects |
|
$effect(() => { |
|
const currentUser = userState; |
|
|
|
// Check for fallback flag when user state changes to signed in |
|
if ( |
|
currentUser.signedIn && |
|
localStorage.getItem("alexandria/amber/fallback") === "1" && |
|
!showAmberFallback |
|
) { |
|
console.log( |
|
"Profile: User signed in and fallback flag found, showing modal", |
|
); |
|
showAmberFallback = true; |
|
} |
|
|
|
// Set up periodic check when user is signed in |
|
if (currentUser.signedIn && !fallbackCheckInterval) { |
|
fallbackCheckInterval = setInterval(() => { |
|
if ( |
|
localStorage.getItem("alexandria/amber/fallback") === "1" && |
|
!showAmberFallback |
|
) { |
|
console.log( |
|
"Profile: Found fallback flag during periodic check, showing modal", |
|
); |
|
showAmberFallback = true; |
|
} |
|
}, 500); // Check every 500ms |
|
} else if (!currentUser.signedIn && fallbackCheckInterval) { |
|
clearInterval(fallbackCheckInterval); |
|
fallbackCheckInterval = null; |
|
} |
|
}); |
|
|
|
// Auto-refresh profile when user signs in |
|
$effect(() => { |
|
const currentUser = userState; |
|
|
|
// If user is signed in and we have an npub but no profile data, refresh it |
|
if (currentUser.signedIn && currentUser.npub && !profile?.name && !isRefreshingProfile) { |
|
console.log("Profile: User signed in but no profile data, refreshing..."); |
|
refreshProfile(); |
|
} |
|
}); |
|
|
|
// Debug activeInboxRelays |
|
$effect(() => { |
|
const inboxRelays = get(activeInboxRelays); |
|
console.log("Profile component - activeInboxRelays:", inboxRelays); |
|
}); |
|
|
|
// Track if we've already refreshed the profile for this session |
|
let hasRefreshedProfile = $state(false); |
|
|
|
// Reset the refresh flag when user logs out |
|
$effect(() => { |
|
const currentUser = userState; |
|
if (!currentUser.signedIn) { |
|
hasRefreshedProfile = false; |
|
} |
|
}); |
|
|
|
// Manual trigger to refresh profile when user signs in (only once) |
|
$effect(() => { |
|
const currentUser = userState; |
|
|
|
if (currentUser.signedIn && currentUser.npub && !isRefreshingProfile && !hasRefreshedProfile) { |
|
console.log("Profile: User signed in, triggering profile refresh..."); |
|
hasRefreshedProfile = true; |
|
// Add a small delay to ensure relays are ready |
|
setTimeout(() => { |
|
refreshProfile(); |
|
}, 1000); |
|
} |
|
}); |
|
|
|
// Refresh profile when login method changes (e.g., Amber to read-only) |
|
$effect(() => { |
|
const currentUser = userState; |
|
|
|
if (currentUser.signedIn && currentUser.npub && currentUser.loginMethod && !isRefreshingProfile) { |
|
console.log("Profile: Login method detected:", currentUser.loginMethod); |
|
|
|
// If switching to read-only mode (npub), refresh profile |
|
if (currentUser.loginMethod === "npub" && !hasRefreshedProfile) { |
|
console.log("Profile: Switching to read-only mode, refreshing profile..."); |
|
hasRefreshedProfile = true; |
|
setTimeout(() => { |
|
refreshProfile(); |
|
}, 500); |
|
} |
|
} |
|
}); |
|
|
|
// Track login method changes and refresh profile when switching from Amber to npub |
|
let previousLoginMethod = $state<string | null>(null); |
|
|
|
$effect(() => { |
|
const currentUser = userState; |
|
|
|
if (currentUser.signedIn && currentUser.loginMethod !== previousLoginMethod && !isRefreshingProfile) { |
|
console.log("Profile: Login method changed from", previousLoginMethod, "to", currentUser.loginMethod); |
|
|
|
// If switching from Amber to npub (read-only), refresh profile |
|
if (previousLoginMethod === "amber" && currentUser.loginMethod === "npub" && !hasRefreshedProfile) { |
|
console.log("Profile: Switching from Amber to read-only mode, refreshing profile..."); |
|
hasRefreshedProfile = true; |
|
setTimeout(() => { |
|
refreshProfile(); |
|
}, 1000); |
|
} |
|
|
|
previousLoginMethod = currentUser.loginMethod; |
|
} |
|
}); |
|
|
|
// Function to refresh profile data |
|
async function refreshProfile() { |
|
if (!userState.signedIn || !userState.npub) return; |
|
|
|
isRefreshingProfile = true; |
|
try { |
|
console.log("Refreshing profile for npub:", userState.npub); |
|
|
|
// Check if we have relays available |
|
const inboxRelays = get(activeInboxRelays); |
|
const outboxRelays = get(activeOutboxRelays); |
|
|
|
if (inboxRelays.length === 0 && outboxRelays.length === 0) { |
|
console.log("Profile: No relays available, will retry when relays become available"); |
|
// Set up a retry mechanism when relays become available |
|
const unsubscribe = activeInboxRelays.subscribe((relays) => { |
|
if (relays.length > 0 && !isRefreshingProfile) { |
|
console.log("Profile: Relays now available, retrying profile fetch"); |
|
unsubscribe(); |
|
setTimeout(() => refreshProfile(), 1000); |
|
} |
|
}); |
|
return; |
|
} |
|
|
|
// Try using NDK's built-in profile fetching first |
|
if (ndk && userState.ndkUser) { |
|
console.log("Using NDK's built-in profile fetching"); |
|
const userProfile = await userState.ndkUser.fetchProfile(); |
|
console.log("NDK profile fetch result:", userProfile); |
|
|
|
if (userProfile) { |
|
const profileData = { |
|
name: userProfile.name, |
|
displayName: userProfile.displayName, |
|
nip05: userProfile.nip05, |
|
picture: userProfile.image, |
|
about: userProfile.bio, |
|
banner: userProfile.banner, |
|
website: userProfile.website, |
|
lud16: userProfile.lud16, |
|
}; |
|
|
|
console.log("Converted profile data:", profileData); |
|
|
|
// Update the userStore with fresh profile data |
|
userStore.update(currentState => ({ |
|
...currentState, |
|
profile: profileData |
|
})); |
|
|
|
return; |
|
} |
|
} |
|
|
|
// Fallback to getUserMetadata |
|
console.log("Falling back to getUserMetadata"); |
|
const freshProfile = await getUserMetadata(userState.npub, ndk, true); // Force fresh fetch |
|
console.log("Fresh profile data from getUserMetadata:", freshProfile); |
|
|
|
// Update the userStore with fresh profile data |
|
userStore.update(currentState => ({ |
|
...currentState, |
|
profile: freshProfile |
|
})); |
|
} catch (error) { |
|
console.error("Failed to refresh profile:", error); |
|
} finally { |
|
isRefreshingProfile = false; |
|
} |
|
} |
|
|
|
|
|
|
|
// 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); |
|
} |
|
|
|
// Login handlers |
|
const handleBrowserExtensionLogin = async () => { |
|
isLoadingExtension = true; |
|
isLoadingAmber = false; |
|
try { |
|
await loginWithExtension(ndk); |
|
} catch (err: unknown) { |
|
showResultMessage( |
|
`❌ Browser extension connection failed: ${err instanceof Error ? err.message : String(err)}`, |
|
); |
|
} finally { |
|
isLoadingExtension = false; |
|
} |
|
}; |
|
|
|
const handleAmberLogin = async () => { |
|
isLoadingAmber = true; |
|
isLoadingExtension = false; |
|
try { |
|
const ndk = new NDK(); |
|
const relay = "wss://relay.nsec.app"; |
|
const localNsec = |
|
localStorage.getItem("amber/nsec") ?? |
|
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 ?? undefined; |
|
showQrCode = true; |
|
qrCodeDataUrl = |
|
(await generateQrCode(amberSigner.nostrConnectUri)) ?? undefined; |
|
const user = await amberSigner.blockUntilReady(); |
|
await loginWithAmber(amberSigner, user, ndk); |
|
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; |
|
} |
|
}; |
|
|
|
const handleReadOnlyLogin = async () => { |
|
const inputNpub = prompt("Enter your npub (public key):"); |
|
if (inputNpub) { |
|
try { |
|
await loginWithNpub(inputNpub, ndk); |
|
} catch (err: unknown) { |
|
showResultMessage( |
|
`❌ npub login failed: ${err instanceof Error ? err.message : String(err)}`, |
|
); |
|
} |
|
} |
|
}; |
|
|
|
async function handleSignOutClick() { |
|
localStorage.removeItem("amber/nsec"); |
|
localStorage.removeItem("alexandria/amber/fallback"); |
|
logoutUser(ndk); |
|
} |
|
|
|
function handleViewProfile() { |
|
if (npub) { |
|
goto(`/events?id=${encodeURIComponent(npub)}`); |
|
} |
|
} |
|
|
|
function handleAmberReconnect() { |
|
showAmberFallback = false; |
|
localStorage.removeItem("alexandria/amber/fallback"); |
|
handleAmberLogin(); |
|
} |
|
|
|
function handleAmberFallbackDismiss() { |
|
showAmberFallback = false; |
|
localStorage.removeItem("alexandria/amber/fallback"); |
|
|
|
// Refresh profile when switching to read-only mode |
|
setTimeout(() => { |
|
console.log("Profile: Amber fallback dismissed, refreshing profile for read-only mode..."); |
|
refreshProfile(); |
|
}, 500); |
|
} |
|
|
|
function shortenNpub(long: string | null | undefined) { |
|
if (!long) return ""; |
|
return long.slice(0, 8) + "…" + long.slice(-4); |
|
} |
|
</script> |
|
|
|
<div class="relative h-fit my-auto"> |
|
{#if !userState.signedIn} |
|
<!-- 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 class="border-t border-gray-200 pt-2 mt-2"> |
|
<div class="text-xs text-gray-500 mb-1">Network Status:</div> |
|
<NetworkStatus /> |
|
</div> |
|
</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" |
|
> |
|
{#if !pfp} |
|
<div class="h-6 w-6 rounded-full bg-gray-300 animate-pulse cursor-pointer"></div> |
|
{:else} |
|
<Avatar |
|
rounded |
|
class="h-6 w-6 cursor-pointer" |
|
src={pfp} |
|
alt={username || "User"} |
|
/> |
|
{/if} |
|
</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"> |
|
{#if username} |
|
<h3 class="text-lg font-bold">{username}</h3> |
|
{#if isNav}<h4 class="text-base">@{tag}</h4>{/if} |
|
{:else if !pfp} |
|
<h3 class="text-lg font-bold">Loading profile...</h3> |
|
{:else} |
|
<h3 class="text-lg font-bold">Loading...</h3> |
|
{/if} |
|
<ul class="space-y-2 mt-2"> |
|
<li> |
|
<CopyToClipboard |
|
displayText={shortenNpub(npub) || "Loading..."} |
|
copyText={npub || ""} |
|
/> |
|
</li> |
|
<li> |
|
<button |
|
class="hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0 text-left" |
|
onclick={handleViewProfile} |
|
> |
|
<UserOutline |
|
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none" |
|
/><span class="underline">View profile</span> |
|
</button> |
|
</li> |
|
|
|
<li class="text-xs text-gray-500"> |
|
{#if userState.loginMethod === "extension"} |
|
Logged in with extension |
|
{:else if userState.loginMethod === "amber"} |
|
Logged in with Amber |
|
{:else if userState.loginMethod === "npub"} |
|
Logged in with npub |
|
{:else} |
|
Unknown login method |
|
{/if} |
|
</li> |
|
<li> |
|
<NetworkStatus /> |
|
</li> |
|
{#if isNav} |
|
<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={handleSignOutClick} |
|
> |
|
<ArrowRightToBracketOutline |
|
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none" |
|
/> Sign out |
|
</button> |
|
</li> |
|
{:else} |
|
<!-- li> |
|
<button |
|
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500' |
|
> |
|
<FileSearchOutline class='mr-1 !h-6 inline !fill-none dark:!fill-none' /> More content |
|
</button> |
|
</li --> |
|
{/if} |
|
</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} |
|
|
|
{#if showAmberFallback} |
|
<div |
|
class="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50" |
|
> |
|
<div |
|
class="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-lg border border-primary-300" |
|
> |
|
<div class="text-center"> |
|
<h2 class="text-lg font-semibold text-gray-900 mb-4"> |
|
Amber Session Restored |
|
</h2> |
|
<p class="text-sm text-gray-600 mb-4"> |
|
Your Amber wallet session could not be restored automatically, so |
|
you've been switched to read-only mode.<br /> |
|
You can still browse and read content, but you'll need to reconnect Amber |
|
to publish or comment. |
|
</p> |
|
<button |
|
class="mt-4 bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors" |
|
onclick={handleAmberReconnect} |
|
> |
|
Reconnect Amber |
|
</button> |
|
<button |
|
class="mt-2 ml-4 bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded text-sm font-medium transition-colors" |
|
onclick={handleAmberFallbackDismiss} |
|
> |
|
Continue in Read-Only Mode |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if}
|
|
|