Browse Source

fixed feed display

master
silberengel 8 months ago
parent
commit
ba1f0164ba
  1. 6
      src/app.css
  2. 440
      src/lib/components/LoginMenu.svelte
  3. 7
      src/lib/components/Navigation.svelte
  4. 2
      src/lib/components/RelayActions.svelte
  5. 20
      src/lib/components/cards/BlogHeader.svelte
  6. 64
      src/lib/components/publications/PublicationFeed.svelte
  7. 31
      src/lib/components/publications/PublicationHeader.svelte
  8. 3
      src/lib/components/util/CardActions.svelte
  9. 397
      src/lib/components/util/Profile.svelte
  10. 3
      src/lib/utils/nostrEventService.ts
  11. 12
      src/routes/+page.svelte

6
src/app.css

@ -159,6 +159,10 @@ @@ -159,6 +159,10 @@
@apply bg-primary-100 dark:bg-primary-800;
}
div.skeleton-leather {
@apply h-48;
}
div.textarea-leather {
@apply bg-primary-0 dark:bg-primary-1000;
}
@ -246,7 +250,7 @@ @@ -246,7 +250,7 @@
}
.ArticleBox.grid.active .ArticleBoxImage {
@apply max-h-72;
@apply max-h-40;
}
.tags span {

440
src/lib/components/LoginMenu.svelte

@ -1,440 +0,0 @@ @@ -1,440 +0,0 @@
<script lang="ts">
import { Avatar, Popover } from "flowbite-svelte";
import {
UserOutline,
ArrowRightToBracketOutline,
} from "flowbite-svelte-icons";
import {
userStore,
loginWithExtension,
loginWithAmber,
loginWithNpub,
logoutUser,
} from "$lib/stores/userStore";
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import NetworkStatus from "./NetworkStatus.svelte";
// UI state
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;
onMount(() => {
if (localStorage.getItem("alexandria/amber/fallback") === "1") {
console.log("LoginMenu: Found fallback flag on mount, showing modal");
showAmberFallback = true;
}
});
// Use reactive user state from store
let user = $derived($userStore);
// Handle user state changes with effects
$effect(() => {
const currentUser = user;
// Check for fallback flag when user state changes to signed in
if (
currentUser.signedIn &&
localStorage.getItem("alexandria/amber/fallback") === "1" &&
!showAmberFallback
) {
console.log(
"LoginMenu: 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(
"LoginMenu: Found fallback flag during periodic check, showing modal",
);
showAmberFallback = true;
}
}, 500); // Check every 500ms
} else if (!currentUser.signedIn && fallbackCheckInterval) {
clearInterval(fallbackCheckInterval);
fallbackCheckInterval = null;
}
});
// 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();
} 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);
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);
} catch (err: unknown) {
showResultMessage(
`❌ npub login failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
};
const handleLogout = () => {
localStorage.removeItem("amber/nsec");
localStorage.removeItem("alexandria/amber/fallback");
logoutUser();
};
function handleAmberReconnect() {
showAmberFallback = false;
localStorage.removeItem("alexandria/amber/fallback");
handleAmberLogin();
}
function handleAmberFallbackDismiss() {
showAmberFallback = false;
localStorage.removeItem("alexandria/amber/fallback");
}
function shortenNpub(long: string | undefined) {
if (!long) return "";
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>
<div class="relative">
{#if !user.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"
>
<Avatar
rounded
class="h-6 w-6 cursor-pointer"
src={user.profile?.picture || undefined}
alt={user.profile?.displayName || user.profile?.name || "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">
{user.profile?.displayName ||
user.profile?.name ||
(user.npub ? shortenNpub(user.npub) : "Unknown")}
</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={() => goto(`/events?id=${user.npub}`)}
type="button"
>
{user.npub ? shortenNpub(user.npub) : "Unknown"}
</button>
</li>
<li class="text-xs text-gray-500">
{#if user.loginMethod === "extension"}
Logged in with extension
{:else if user.loginMethod === "amber"}
Logged in with Amber
{:else if user.loginMethod === "npub"}
Logged in with npub
{:else}
Unknown login method
{/if}
</li>
<li class="border-t border-gray-200 pt-2 mt-2">
<div class="text-xs text-gray-500 mb-1">Network Status:</div>
<NetworkStatus />
</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}
{#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}

7
src/lib/components/Navigation.svelte

@ -7,9 +7,12 @@ @@ -7,9 +7,12 @@
NavHamburger,
NavBrand,
} from "flowbite-svelte";
import LoginMenu from "./LoginMenu.svelte";
import Profile from "./util/Profile.svelte";
import { userStore } from "$lib/stores/userStore";
let { class: className = "" } = $props();
let userState = $derived($userStore);
</script>
<Navbar class={`Navbar navbar-leather navbar-main ${className}`}>
@ -19,7 +22,7 @@ @@ -19,7 +22,7 @@
</NavBrand>
</div>
<div class="flex md:order-2">
<LoginMenu />
<Profile isNav={true} pubkey={userState.npub || undefined} />
<NavHamburger class="btn-leather" />
</div>
<NavUl class="ul-leather">

2
src/lib/components/RelayActions.svelte

@ -55,7 +55,7 @@ @@ -55,7 +55,7 @@
const relaySet = createRelaySetFromUrls([relay], ndk);
const found = await ndk
.fetchEvent({ ids: [event?.id || ""] }, undefined, relaySet)
.withTimeout(3000);
.withTimeout(2000);
relaySearchResults = {
...relaySearchResults,
[relay]: found ? "found" : "notfound",

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

@ -54,24 +54,28 @@ @@ -54,24 +54,28 @@
? 'active'
: ''}"
>
<div class="space-y-4">
<div class="space-y-4 relative">
<div class="flex flex-row justify-between my-2">
<div class="flex flex-col">
{@render userBadge(authorPubkey, author)}
<span class="text-gray-700 dark:text-gray-300">{publishedAt()}</span>
</div>
<CardActions {event} />
</div>
{#if image && active}
<div
class="ArticleBoxImage flex col justify-center"
class="ArticleBoxImage flex justify-center items-center p-2 h-40 -mt-2"
in:scale={{ start: 0.8, duration: 500, delay: 100, easing: quintOut }}
>
<Img src={image} class="rounded w-full max-h-72 object-cover" />
<Img
src={image}
class="rounded w-full h-full object-cover"
alt={title || "Publication image"}
/>
</div>
{/if}
<div class="flex flex-col flex-grow space-y-4">
<div class="flex flex-col space-y-4">
<button onclick={() => showBlog()} class="text-left">
<h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2>
</button>
@ -83,9 +87,15 @@ @@ -83,9 +87,15 @@
</div>
{/if}
</div>
{#if active}
<Interactions {rootId} {event} />
{/if}
<!-- Position CardActions at bottom-right -->
<div class="absolute bottom-2 right-2">
<CardActions {event} />
</div>
</div>
</Card>
{/if}

64
src/lib/components/publications/PublicationFeed.svelte

@ -137,9 +137,10 @@ @@ -137,9 +137,10 @@
allRelays.map((r: string) => [r, "pending"]),
);
let allEvents: NDKEvent[] = [];
const eventMap = new Map<string, NDKEvent>();
// Helper to fetch from a single relay with timeout
async function fetchFromRelay(relay: string): Promise<NDKEvent[]> {
async function fetchFromRelay(relay: string): Promise<void> {
try {
console.debug(`[PublicationFeed] Fetching from relay: ${relay}`);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
@ -156,57 +157,60 @@ @@ -156,57 +157,60 @@
},
relaySet,
)
.withTimeout(10000); // Increased timeout to 10 seconds
.withTimeout(5000); // Reduced timeout to 5 seconds for faster response
console.debug(`[PublicationFeed] Raw events from ${relay}:`, eventSet.size);
eventSet = filterValidIndexEvents(eventSet);
console.debug(`[PublicationFeed] Valid events from ${relay}:`, eventSet.size);
relayStatuses = { ...relayStatuses, [relay]: "found" };
return Array.from(eventSet);
// Add new events to the map and update the view immediately
const newEvents: NDKEvent[] = [];
for (const event of eventSet) {
const tagAddress = event.tagAddress();
if (!eventMap.has(tagAddress)) {
eventMap.set(tagAddress, event);
newEvents.push(event);
}
}
if (newEvents.length > 0) {
// Update allIndexEvents with new events
allIndexEvents = Array.from(eventMap.values());
// Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
// Update the view immediately with new events
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
console.debug(`[PublicationFeed] Updated view with ${newEvents.length} new events from ${relay}, total: ${allIndexEvents.length}`);
}
} catch (err) {
console.error(`[PublicationFeed] Error fetching from relay ${relay}:`, err);
relayStatuses = { ...relayStatuses, [relay]: "notfound" };
return [];
}
}
// Fetch from all relays in parallel, do not block on any single relay
// Fetch from all relays in parallel, return events as they arrive
console.debug(`[PublicationFeed] Starting fetch from ${allRelays.length} relays`);
const results = await Promise.allSettled(allRelays.map(fetchFromRelay));
for (const result of results) {
if (result.status === "fulfilled") {
allEvents = allEvents.concat(result.value);
}
}
// Start all relay fetches in parallel
const fetchPromises = allRelays.map(fetchFromRelay);
console.debug(`[PublicationFeed] Total events fetched:`, allEvents.length);
// Deduplicate by tagAddress
const eventMap = new Map(
allEvents.map((event) => [event.tagAddress(), event]),
);
allIndexEvents = Array.from(eventMap.values());
console.debug(`[PublicationFeed] Events after deduplication:`, allIndexEvents.length);
// Wait for all to complete (but events are shown as they arrive)
await Promise.allSettled(fetchPromises);
// Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
console.debug(`[PublicationFeed] All relays completed, final event count:`, allIndexEvents.length);
// Cache the fetched events
indexEventCache.set(allRelays, allIndexEvents);
// Initially show first page
// Final update to ensure we have the latest view
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
loading = false;
console.debug(`[PublicationFeed] Final state:`, {
totalEvents: allIndexEvents.length,
eventsInView: eventsInView.length,
endOfFeed,
loading
});
}
// Function to filter events based on search query
@ -332,7 +336,7 @@ @@ -332,7 +336,7 @@
}
function getSkeletonIds(): string[] {
const skeletonHeight = 124; // The height of the skeleton component in pixels.
const skeletonHeight = 192; // The height of the card component in pixels (h-48 = 12rem = 192px).
const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2;
const skeletonIds = [];
for (let i = 0; i < skeletonCount; i++) {

31
src/lib/components/publications/PublicationHeader.svelte

@ -1,9 +1,7 @@ @@ -1,9 +1,7 @@
<script lang="ts">
import { ndkInstance } from "$lib/ndk";
import { naddrEncode } from "$lib/utils";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { communityRelays } from "../../consts";
import { activeInboxRelays } from "$lib/ndk";
import { Card, Img } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
@ -41,19 +39,25 @@ @@ -41,19 +39,25 @@
</script>
{#if title != null && href != null}
<Card class="ArticleBox card-leather max-w-md flex flex-row space-x-2">
<Card class="ArticleBox card-leather max-w-md h-48 flex flex-row space-x-2 relative">
{#if image}
<div
class="flex col justify-center align-middle max-h-36 max-w-24 overflow-hidden"
class="flex-shrink-0 w-32 h-40 overflow-hidden rounded flex items-center justify-center p-2 -mt-2"
>
<Img src={image} class="rounded w-full h-full object-cover" />
<Img
src={image}
class="w-full h-full object-cover"
alt={title || "Publication image"}
/>
</div>
{/if}
<div class="col flex flex-row flex-grow space-x-4">
<div class="flex flex-col flex-grow space-x-2">
<div class="flex flex-col flex-grow">
<a href="/{href}" class="flex flex-col space-y-2">
<a href="/{href}" class="flex flex-col space-y-2 h-full">
<div class="flex-grow pt-2">
<h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2>
<h3 class="text-base font-normal">
<h3 class="text-base font-normal mt-2">
by
{#if authorPubkey != null}
{@render userBadge(authorPubkey, author)}
@ -61,14 +65,17 @@ @@ -61,14 +65,17 @@
{author}
{/if}
</h3>
</div>
{#if version != "1"}
<h3 class="text-base font-thin">version: {version}</h3>
<h3 class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-auto">version: {version}</h3>
{/if}
</a>
</div>
<div class="flex flex-col justify-start items-center">
<CardActions {event} />
</div>
<!-- Position CardActions at bottom-right -->
<div class="absolute bottom-2 right-2">
<CardActions {event} />
</div>
</Card>
{/if}

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

@ -8,8 +8,7 @@ @@ -8,8 +8,7 @@
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { communityRelays, secondaryRelays, FeedType } from "$lib/consts";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { activeInboxRelays } from "$lib/ndk";
import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";

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

@ -1,7 +1,13 @@ @@ -1,7 +1,13 @@
<script lang="ts">
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import NetworkStatus from "$components/NetworkStatus.svelte";
import { logoutUser, userStore } from "$lib/stores/userStore";
import {
logoutUser,
userStore,
loginWithExtension,
loginWithAmber,
loginWithNpub
} from "$lib/stores/userStore";
import { ndkInstance } from "$lib/ndk";
import {
ArrowRightToBracketOutline,
@ -12,8 +18,31 @@ @@ -12,8 +18,31 @@
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte";
let { pubkey, isNav = false } = $props<{ pubkey?: string, isNav?: boolean }>();
let { pubkey, isNav = false } = $props();
// 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 isProfileLoading = $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 instead of fetching separately
let userState = $derived($userStore);
@ -23,12 +52,73 @@ @@ -23,12 +52,73 @@
let tag = $derived(profile?.name);
let npub = $derived(userState.npub);
// Fallback to fetching profile if not available in userStore
// 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;
}
});
// Fetch profile when user signs in or when pubkey changes
$effect(() => {
const currentUser = userState;
// If user is signed in but profile is not available, fetch it
if (currentUser.signedIn && !profile && currentUser.npub) {
const ndk = get(ndkInstance);
if (!ndk) return;
isProfileLoading = true;
// Use the current user's npub to fetch profile
const user = ndk.getUser({ npub: currentUser.npub });
user.fetchProfile().then((userProfile: NDKUserProfile | null) => {
if (userProfile && !profile) {
// Only update if we don't already have profile data
profile = userProfile;
}
isProfileLoading = false;
}).catch(() => {
isProfileLoading = false;
});
}
// Fallback to fetching profile if not available in userStore and pubkey prop is provided
if (!profile && pubkey) {
const ndk = get(ndkInstance);
if (!ndk) return;
isProfileLoading = true;
const user = ndk.getUser({ pubkey: pubkey ?? undefined });
user.fetchProfile().then((userProfile: NDKUserProfile | null) => {
@ -36,11 +126,116 @@ @@ -36,11 +126,116 @@
// Only update if we don't already have profile data
profile = userProfile;
}
isProfileLoading = false;
}).catch(() => {
isProfileLoading = 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();
} 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);
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);
} 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();
profile = null;
}
@ -51,6 +246,17 @@ @@ -51,6 +246,17 @@
}
}
function handleAmberReconnect() {
showAmberFallback = false;
localStorage.removeItem("alexandria/amber/fallback");
handleAmberLogin();
}
function handleAmberFallbackDismiss() {
showAmberFallback = false;
localStorage.removeItem("alexandria/amber/fallback");
}
function shortenNpub(long: string | null | undefined) {
if (!long) return "";
return long.slice(0, 8) + "…" + long.slice(-4);
@ -58,24 +264,94 @@ @@ -58,24 +264,94 @@
</script>
<div class="relative">
{#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="profile-avatar"
id={profileAvatarId}
type="button"
aria-label="Open profile menu"
>
{#if isProfileLoading && !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="#profile-avatar"
class="popover-leather w-[180px]"
triggeredBy={`#${profileAvatarId}`}
class="popover-leather w-[220px]"
trigger="click"
>
<div class="flex flex-row justify-between space-x-4">
@ -83,6 +359,8 @@ @@ -83,6 +359,8 @@
{#if username}
<h3 class="text-lg font-bold">{username}</h3>
{#if isNav}<h4 class="text-base">@{tag}</h4>{/if}
{:else if isProfileLoading}
<h3 class="text-lg font-bold">Loading profile...</h3>
{:else}
<h3 class="text-lg font-bold">Loading...</h3>
{/if}
@ -103,6 +381,17 @@ @@ -103,6 +381,17 @@
/><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>
@ -132,4 +421,100 @@ @@ -132,4 +421,100 @@
</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}

3
src/lib/utils/nostrEventService.ts

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
import { nip19 } from "nostr-tools";
import { getEventHash, signEvent, prefixNostrAddresses } from "./nostrUtils";
import { communityRelays, secondaryRelays } from "$lib/consts";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import type { NDKEvent } from "./nostrUtils";
@ -381,7 +380,7 @@ export async function publishEvent( @@ -381,7 +380,7 @@ export async function publishEvent(
try {
// Publish with timeout
await event.publish(relaySet).withTimeout(10000);
await event.publish(relaySet).withTimeout(5000);
// For now, assume all relays were successful
// In a more sophisticated implementation, you'd track individual relay responses

12
src/routes/+page.svelte

@ -14,18 +14,6 @@ @@ -14,18 +14,6 @@
}
</script>
<Alert
rounded={false}
id="alert-experimental"
class="border-t-4 border-primary-600 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2"
>
<HammerSolid class="mr-2 h-5 w-5 text-primary-500 dark:text-primary-500" />
<span class="font-medium">
Pardon our dust! The publication view is currently using an experimental
loader, and may be unstable.
</span>
</Alert>
<main class="leather flex flex-col flex-grow-0 space-y-4 p-4">
<div
class="leather w-full flex flex-row items-center justify-center gap-4 mb-4"

Loading…
Cancel
Save