Browse Source

bug fixes

master
Silberengel 1 month ago
parent
commit
dc6c02d67f
  1. 9
      src/app.css
  2. 70
      src/lib/components/layout/Header.svelte
  3. 107
      src/lib/components/layout/ProfileBadge.svelte
  4. 7
      src/lib/components/relay/RelayInfo.svelte
  5. 32
      src/lib/modules/feed/FeedPage.svelte
  6. 53
      src/lib/modules/feed/FeedPost.svelte
  7. 316
      src/lib/modules/profiles/ProfilePage.svelte
  8. 150
      src/routes/bookmarks/+page.svelte
  9. 38
      src/routes/settings/+page.svelte

9
src/app.css

@ -472,12 +472,13 @@ img.emoji-inline { @@ -472,12 +472,13 @@ img.emoji-inline {
}
/* Responsive images and media - max 600px, scale down on smaller screens */
img:not(.profile-picture):not([alt*="profile" i]):not([alt*="avatar" i]):not([src*="avatar" i]):not([src*="profile" i]),
img:not(.profile-picture):not([alt*="profile" i]):not([alt*="avatar" i]):not([src*="avatar" i]):not([src*="profile" i]):not(.relay-icon):not(.relay-favorite-pic),
video,
audio {
max-width: 600px;
width: 100%;
height: auto;
max-width: 600px !important;
width: 100% !important;
height: auto !important;
display: block;
}
/* Ensure media in markdown content is responsive */

70
src/lib/components/layout/Header.svelte

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
<script lang="ts">
import { sessionManager, type UserSession } from '../../services/auth/session-manager.js';
import UserPreferences from '../preferences/UserPreferences.svelte';
import ProfileBadge from '../layout/ProfileBadge.svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
@ -33,12 +32,13 @@ @@ -33,12 +32,13 @@
<header class="relative border-b border-fog-border dark:border-fog-dark-border">
<!-- Banner image -->
<div class="h-24 sm:h-32 md:h-48 overflow-hidden bg-fog-surface dark:bg-fog-dark-surface">
<div class="max-w-7xl mx-auto h-full relative">
<div class="h-24 sm:h-32 md:h-48 overflow-hidden bg-fog-surface dark:bg-fog-dark-surface w-full">
<div class="max-w-7xl mx-auto h-full relative w-full flex items-center justify-center">
<img
src="/aither.png"
alt="aitherboard banner"
class="w-full h-full object-cover object-center opacity-90 dark:opacity-70"
style="object-position: center;"
loading="eager"
/>
<!-- Overlay gradient for text readability -->
@ -64,22 +64,17 @@ @@ -64,22 +64,17 @@
<a href="/topics" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Topics</a>
<a href="/repos" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Repos</a>
<a href="/cache" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Cache</a>
<a href="/bookmarks" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Bookmarks</a>
<a href="/settings" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Settings</a>
{#if isLoggedIn && currentPubkey}
<a href="/logout" onclick={(e) => { e.preventDefault(); handleLogout(); }} class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Logout</a>
{:else}
<a href="/login" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Login</a>
{/if}
</div>
<div class="flex flex-wrap gap-1.5 sm:gap-2 md:gap-4 items-center min-w-0 flex-shrink-0 nav-links">
{#if isLoggedIn && currentPubkey}
<UserPreferences />
<ProfileBadge pubkey={currentPubkey} />
<button
onclick={handleLogout}
class="px-2 sm:px-3 py-1 rounded border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors flex-shrink-0"
title="Logout"
aria-label="Logout"
>
<span class="emoji emoji-grayscale">🚪</span>
</button>
{:else}
<a href="/login" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">Login</a>
<UserPreferences />
<ProfileBadge pubkey={currentPubkey} pictureOnly={true} />
{/if}
</div>
</div>
@ -89,44 +84,51 @@ @@ -89,44 +84,51 @@
<style>
header {
max-width: 100%;
width: 100%;
}
nav {
min-width: 0; /* Allow flex items to shrink */
/* Banner container */
header > div:first-child {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.emoji {
font-size: 1rem;
line-height: 1;
opacity: 0.7;
header > div:first-child > div {
width: 100%;
max-width: 100%;
}
.emoji-grayscale {
filter: grayscale(100%);
header > div:first-child img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center center;
}
button:hover .emoji-grayscale {
filter: grayscale(80%);
nav {
min-width: 0; /* Allow flex items to shrink */
}
/* Responsive navigation links */
.nav-links {
font-size: 0.75rem;
font-size: 0.65rem;
}
@media (min-width: 640px) {
.nav-links {
font-size: 0.875rem;
font-size: 0.75rem;
}
}
.nav-brand {
font-size: 1rem;
font-size: 0.875rem;
}
@media (min-width: 640px) {
.nav-brand {
font-size: 1.25rem;
font-size: 1rem;
}
}
@ -144,11 +146,11 @@ @@ -144,11 +146,11 @@
}
.nav-links {
font-size: 0.7rem;
font-size: 0.6rem;
}
.nav-brand {
font-size: 0.9rem;
font-size: 0.8rem;
}
}
</style>

107
src/lib/components/layout/ProfileBadge.svelte

@ -1,42 +1,51 @@ @@ -1,42 +1,51 @@
<script lang="ts">
import { getActivityStatus, getActivityMessage } from '../../services/auth/activity-tracker.js';
import { fetchProfile, fetchUserStatus } from '../../services/user-data.js';
import { fetchProfile } from '../../services/user-data.js';
import { nip19 } from 'nostr-tools';
interface Props {
pubkey: string;
inline?: boolean; // If true, show only handle/name (no picture, status, etc.)
pictureOnly?: boolean; // If true, show only the profile picture/avatar
}
let { pubkey, inline = false }: Props = $props();
let { pubkey, inline = false, pictureOnly = false }: Props = $props();
let profile = $state<{ name?: string; picture?: string; nip05?: string[] } | null>(null);
let status = $state<string | null>(null);
let activityStatus = $state<'red' | 'yellow' | 'green' | null>(null);
let activityMessage = $state<string | null>(null);
let imageError = $state(false);
let loadingProfile = $state(false);
let loadingStatus = $state(false);
let loadingActivity = $state(false);
let lastLoadedPubkey = $state<string | null>(null);
$effect(() => {
// Track inline prop to ensure effect runs when it changes
const currentInline = inline;
// Only load if pubkey changed and we haven't loaded it yet
if (pubkey && pubkey !== lastLoadedPubkey) {
imageError = false; // Reset image error when pubkey changes
// Reset state for new pubkey
profile = null;
status = null;
activityStatus = null;
activityMessage = null;
// Load immediately - no debounce
loadProfile();
// Only load status if not inline (feed view doesn't need status)
if (!inline) {
loadStatus();
// Only load activity status if not inline (feed view doesn't need it)
if (!currentInline) {
updateActivityStatus();
}
}
// If inline is true, clear activity (don't load it)
if (currentInline) {
activityStatus = null;
activityMessage = null;
} else if (pubkey && pubkey === lastLoadedPubkey && !loadingActivity) {
// If inline changed from true to false and we have a loaded pubkey, load activity now
updateActivityStatus();
}
});
async function loadProfile() {
@ -63,32 +72,16 @@ @@ -63,32 +72,16 @@
}
}
async function loadStatus() {
const currentPubkey = pubkey;
if (!currentPubkey || loadingStatus) return;
loadingStatus = true;
try {
const s = await fetchUserStatus(currentPubkey);
// Only update if pubkey hasn't changed during load
if (pubkey === currentPubkey) {
status = s;
}
} finally {
if (pubkey === currentPubkey) {
loadingStatus = false;
}
}
}
async function updateActivityStatus() {
const currentPubkey = pubkey;
if (!currentPubkey || loadingActivity || lastLoadedPubkey !== currentPubkey) return;
// Don't load activity status if inline is true (feed view)
if (!currentPubkey || loadingActivity || lastLoadedPubkey !== currentPubkey || inline) return;
loadingActivity = true;
try {
const actStatus = await getActivityStatus(currentPubkey);
const actMessage = await getActivityMessage(currentPubkey);
// Only update if pubkey hasn't changed during load
if (pubkey === currentPubkey && lastLoadedPubkey === currentPubkey) {
// Only update if pubkey hasn't changed during load and still not inline
if (pubkey === currentPubkey && lastLoadedPubkey === currentPubkey && !inline) {
activityStatus = actStatus;
activityMessage = actMessage;
}
@ -144,13 +137,14 @@ @@ -144,13 +137,14 @@
});
</script>
<a href="/profile/{pubkey}" class="profile-badge inline-flex items-start gap-2 min-w-0 max-w-full">
{#if !inline}
<a href="/profile/{pubkey}" class="profile-badge inline-flex items-start gap-2 min-w-0 max-w-full" class:picture-only={pictureOnly}>
{#if !inline || pictureOnly}
{#if profile?.picture && !imageError}
<img
src={profile.picture}
alt={profile.name || pubkey}
class="profile-picture w-6 h-6 rounded flex-shrink-0"
class:nav-picture={pictureOnly}
loading="lazy"
onerror={() => {
imageError = true;
@ -159,28 +153,28 @@ @@ -159,28 +153,28 @@
{:else}
<div
class="profile-placeholder w-6 h-6 rounded flex-shrink-0 flex items-center justify-center text-xs font-semibold"
class:nav-picture={pictureOnly}
style="background: {avatarColor}; color: white;"
title={pubkey}
title={profile?.name || pubkey}
>
{avatarInitials}
</div>
{/if}
{/if}
<div class="flex flex-col min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="truncate min-w-0">
{profile?.name || shortenedNpub}
</span>
{#if profile?.nip05 && profile.nip05.length > 0}
<span class="nip05-text text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0 whitespace-nowrap">
{profile.nip05[0]}
{#if !pictureOnly}
<div class="flex flex-col min-w-0 flex-1 max-w-full">
<div class="flex items-center gap-2 flex-wrap min-w-0">
<span class="truncate min-w-0 max-w-full">
{profile?.name || shortenedNpub}
</span>
{/if}
{#if profile?.nip05 && profile.nip05.length > 0}
<span class="nip05-text text-fog-text-light dark:text-fog-dark-text-light min-w-0 break-all">
{profile.nip05[0]}
</span>
{/if}
</div>
</div>
{#if status && status.trim()}
<span class="status-text text-sm text-fog-text-light dark:text-fog-dark-text-light">{status}</span>
{/if}
</div>
{/if}
</a>
<style>
@ -188,13 +182,29 @@ @@ -188,13 +182,29 @@
text-decoration: none;
color: inherit;
max-width: 100%;
width: 100%;
transition: opacity 0.2s;
overflow: hidden;
}
.profile-badge:hover {
text-decoration: underline;
opacity: 0.9;
}
.profile-badge.picture-only {
gap: 0;
}
.profile-badge.picture-only:hover {
text-decoration: none;
opacity: 0.8;
}
.nav-picture {
width: 2rem;
height: 2rem;
}
.profile-picture {
object-fit: cover;
@ -206,13 +216,10 @@ @@ -206,13 +216,10 @@
line-height: 1;
}
.status-text {
display: block;
font-size: 0.75em;
line-height: 1.2;
}
.nip05-text {
font-size: 0.875em;
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
}
</style>

7
src/lib/components/relay/RelayInfo.svelte

@ -483,11 +483,12 @@ @@ -483,11 +483,12 @@
}
.relay-icon {
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
object-fit: cover;
border: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
:global(.dark) .relay-icon {

32
src/lib/modules/feed/FeedPage.svelte

@ -31,6 +31,7 @@ @@ -31,6 +31,7 @@
let subscriptionId: string | null = $state(null);
let isMounted = $state(true);
let initialLoadComplete = $state(false);
let loadInProgress = $state(false);
// Load waiting room events into feed
function loadWaitingRoomEvents() {
@ -102,8 +103,9 @@ @@ -102,8 +103,9 @@
// Initial feed load
async function loadFeed() {
if (!isMounted) return;
if (!isMounted || loadInProgress) return;
loadInProgress = true;
loading = true;
relayError = null;
@ -182,6 +184,7 @@ @@ -182,6 +184,7 @@
} finally {
loading = false;
initialLoadComplete = true;
loadInProgress = false;
}
}
@ -211,18 +214,27 @@ @@ -211,18 +214,27 @@
onMount(() => {
isMounted = true;
nostrClient.initialize().then(() => {
if (isMounted) {
loadFeed().then(() => {
if (isMounted) {
setupSubscription();
}
});
}
});
loadInProgress = false;
// Use a small delay to ensure previous page cleanup completes
const initTimeout = setTimeout(() => {
if (!isMounted) return;
nostrClient.initialize().then(() => {
if (isMounted && !loadInProgress) {
loadFeed().then(() => {
if (isMounted) {
setupSubscription();
}
});
}
});
}, 50);
return () => {
clearTimeout(initTimeout);
isMounted = false;
loadInProgress = false;
if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;

53
src/lib/modules/feed/FeedPost.svelte

@ -43,15 +43,23 @@ @@ -43,15 +43,23 @@
let showReplyForm = $state(false);
// Check if card should be collapsed (only in feed view)
let isMounted = $state(true);
$effect(() => {
if (fullView) {
shouldCollapse = false;
return;
}
isMounted = true;
// Wait for content to render, then check height
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let rafId1: number | null = null;
let rafId2: number | null = null;
const checkHeight = () => {
if (!cardElement) return;
if (!isMounted || !cardElement) return;
// Measure the full card height (using scrollHeight to get full content height)
const cardHeight = cardElement.scrollHeight;
@ -59,13 +67,20 @@ @@ -59,13 +67,20 @@
};
// Check after content is rendered
const timeoutId = setTimeout(() => {
requestAnimationFrame(() => {
requestAnimationFrame(checkHeight);
timeoutId = setTimeout(() => {
if (!isMounted) return;
rafId1 = requestAnimationFrame(() => {
if (!isMounted) return;
rafId2 = requestAnimationFrame(checkHeight);
});
}, 200);
return () => clearTimeout(timeoutId);
return () => {
isMounted = false;
if (timeoutId) clearTimeout(timeoutId);
if (rafId1) cancelAnimationFrame(rafId1);
if (rafId2) cancelAnimationFrame(rafId2);
};
});
function toggleExpand() {
@ -73,13 +88,21 @@ @@ -73,13 +88,21 @@
}
$effect(() => {
let cancelled = false;
if (isLoggedIn) {
isBookmarked(post.id).then(b => {
bookmarked = b;
if (!cancelled) {
bookmarked = b;
}
});
} else {
bookmarked = false;
}
return () => {
cancelled = true;
};
});
function getRelativeTime(): string {
@ -583,9 +606,11 @@ @@ -583,9 +606,11 @@
<EventMenu event={post} showContentActions={true} />
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
<ProfileBadge pubkey={post.pubkey} />
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">{getRelativeTime()}</span>
<div class="flex items-center gap-2 flex-nowrap">
<div class="flex-shrink-1 min-w-0">
<ProfileBadge pubkey={post.pubkey} />
</div>
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0 whitespace-nowrap" style="font-size: 0.75em;">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
@ -614,9 +639,11 @@ @@ -614,9 +639,11 @@
{:else}
<!-- Feed view: plaintext only, no profile pics, media as URLs -->
<div class="post-header flex flex-col gap-2 mb-2">
<div class="flex items-center gap-2 flex-wrap">
<ProfileBadge pubkey={post.pubkey} inline={true} />
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">{getRelativeTime()}</span>
<div class="flex items-center gap-2 flex-nowrap">
<div class="flex-shrink-1 min-w-0">
<ProfileBadge pubkey={post.pubkey} inline={true} />
</div>
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0 whitespace-nowrap" style="font-size: 0.75em;">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
@ -964,6 +991,8 @@ @@ -964,6 +991,8 @@
align-items: center;
vertical-align: middle;
line-height: 1.5;
width: auto;
max-width: none;
}

316
src/lib/modules/profiles/ProfilePage.svelte

@ -3,11 +3,9 @@ @@ -3,11 +3,9 @@
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import PaymentAddresses from './PaymentAddresses.svelte';
import FeedPost from '../feed/FeedPost.svelte';
import ThreadDrawer from '../feed/ThreadDrawer.svelte';
import ProfileEventsPanel from '../../components/profile/ProfileEventsPanel.svelte';
import ProfileMenu from '../../components/profile/ProfileMenu.svelte';
import BookmarksPanel from '../../components/profile/BookmarksPanel.svelte';
import { getPinnedEvents } from '../../services/user-actions.js';
import { fetchProfile, fetchUserStatus, type ProfileData } from '../../services/user-data.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
@ -17,27 +15,15 @@ @@ -17,27 +15,15 @@
import { page } from '$app/stores';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND, getFeedKinds } from '../../types/kind-lookup.js';
import { KIND } from '../../types/kind-lookup.js';
// Progressive rendering for profile posts
const INITIAL_RENDER_LIMIT = 25;
const RENDER_INCREMENT = 25;
let visiblePostsCount = $state(INITIAL_RENDER_LIMIT);
let visibleResponsesCount = $state(INITIAL_RENDER_LIMIT);
let visibleInteractionsCount = $state(INITIAL_RENDER_LIMIT);
// Derived: visible items for each tab
const visiblePosts = $derived.by(() => posts.slice(0, visiblePostsCount));
const visibleResponses = $derived.by(() => responses.slice(0, visibleResponsesCount));
const visibleInteractions = $derived.by(() => interactionsWithMe.slice(0, visibleInteractionsCount));
let profile = $state<ProfileData | null>(null);
let userStatus = $state<string | null>(null);
let posts = $state<NostrEvent[]>([]);
let responses = $state<NostrEvent[]>([]);
let notifications = $state<NostrEvent[]>([]);
let interactionsWithMe = $state<NostrEvent[]>([]);
let loading = $state(true);
let activeTab = $state<'posts' | 'responses' | 'interactions' | 'pins'>('posts');
let activeTab = $state<'pins' | 'notifications' | 'interactions'>('pins');
let nip05Validations = $state<Record<string, boolean | null>>({}); // null = checking, true = valid, false = invalid
// Compute pubkey from route params
let profilePubkey = $derived.by(() => decodePubkey($page.params.pubkey));
@ -49,10 +35,6 @@ @@ -49,10 +35,6 @@
// Get current logged-in user's pubkey
let currentUserPubkey = $state<string | null>(sessionManager.getCurrentPubkey());
// Drawer state for viewing threads
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
// Profile events panel state
let profileEventsPanelOpen = $state(false);
@ -82,16 +64,6 @@ @@ -82,16 +64,6 @@
};
});
function openDrawer(event: NostrEvent) {
drawerEvent = event;
drawerOpen = true;
}
function closeDrawer() {
drawerOpen = false;
drawerEvent = null;
}
function openProfileEventsPanel() {
profileEventsPanelOpen = true;
}
@ -117,14 +89,17 @@ @@ -117,14 +89,17 @@
$effect(() => {
const unsubscribe = sessionManager.session.subscribe((session) => {
currentUserPubkey = session?.pubkey || null;
// Reload interactions if session changes and we're viewing another user's profile
// Reload notifications/interactions if session changes
if (profile) {
const pubkey = decodePubkey($page.params.pubkey);
if (pubkey && currentUserPubkey && currentUserPubkey !== pubkey) {
// Reload interactions tab data
loadInteractionsWithMe(pubkey, currentUserPubkey);
} else {
interactionsWithMe = [];
if (pubkey && currentUserPubkey) {
if (currentUserPubkey === pubkey) {
// Reload notifications for own profile
loadNotifications(pubkey);
} else {
// Reload interactions for other user's profile
loadInteractionsWithMe(pubkey, currentUserPubkey);
}
}
}
});
@ -134,13 +109,33 @@ @@ -134,13 +109,33 @@
async function loadPins(pubkey: string) {
if (!isMounted) return;
try {
const pinnedIds = await getPinnedEvents();
if (!isMounted || pinnedIds.size === 0) {
// Fetch the user's pin list (kind 10001)
const profileRelays = relayManager.getProfileReadRelays();
const pinLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.PIN_LIST], authors: [pubkey], limit: 1 }],
profileRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
if (!isMounted || pinLists.length === 0) {
if (isMounted) pins = [];
return;
}
const profileRelays = relayManager.getProfileReadRelays();
// Extract event IDs from pin list
const pinnedIds = new Set<string>();
for (const tag of pinLists[0].tags) {
if (tag[0] === 'e' && tag[1]) {
pinnedIds.add(tag[1]);
}
}
if (pinnedIds.size === 0) {
if (isMounted) pins = [];
return;
}
// Fetch the actual pinned events
const fetchPromise = nostrClient.fetchEvents(
[{ ids: Array.from(pinnedIds), limit: config.feedLimit }],
profileRelays,
@ -160,6 +155,53 @@ @@ -160,6 +155,53 @@
}
}
async function loadNotifications(pubkey: string) {
if (!isMounted) return;
try {
const notificationRelays = relayManager.getFeedReadRelays();
// Fetch user's posts to find replies
const userPosts = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [pubkey], limit: 100 }],
notificationRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
if (!isMounted) return;
const userPostIds = new Set(userPosts.map(p => p.id));
// Fetch notifications: replies, mentions, reactions, zaps
const notificationEvents = await nostrClient.fetchEvents(
[
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': Array.from(userPostIds).slice(0, 50), limit: 100 }, // Replies to user's posts
{ kinds: [KIND.SHORT_TEXT_NOTE], '#p': [pubkey], limit: 100 }, // Mentions
{ kinds: [KIND.REACTION], '#p': [pubkey], limit: 100 }, // Reactions
{ kinds: [KIND.ZAP_RECEIPT], '#p': [pubkey], limit: 100 } // Zaps
],
notificationRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
if (!isMounted) return;
// Deduplicate and sort by created_at descending
const seenIds = new Set<string>();
notifications = notificationEvents
.filter(e => {
if (seenIds.has(e.id)) return false;
seenIds.add(e.id);
// Exclude user's own events
return e.pubkey !== pubkey;
})
.sort((a, b) => b.created_at - a.created_at)
.slice(0, 100); // Limit to 100 most recent
} catch (error) {
console.error('Error loading notifications:', error);
notifications = [];
}
}
async function loadInteractionsWithMe(profilePubkey: string, currentUserPubkey: string) {
if (!isMounted || !currentUserPubkey || currentUserPubkey === profilePubkey) {
if (isMounted) interactionsWithMe = [];
@ -230,6 +272,7 @@ @@ -230,6 +272,7 @@
interactionsWithMe = [];
}
}
onMount(async () => {
await nostrClient.initialize();
@ -480,73 +523,31 @@ @@ -480,73 +523,31 @@
}
}
// Step 2: Load posts and responses in parallel (non-blocking, update when ready)
const profileRelays = relayManager.getProfileReadRelays();
const responseRelays = relayManager.getFeedResponseReadRelays();
// Check again before loading posts
if (abortController.signal.aborted || currentLoadPubkey !== pubkey) {
return;
}
// Load posts first (needed for response filtering)
// Fetch all feed kinds (not just kind 1)
const feedKinds = getFeedKinds();
const feedEvents = await nostrClient.fetchEvents(
[{ kinds: feedKinds, authors: [pubkey], limit: config.feedLimit }],
profileRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
// Check again after posts load
if (abortController.signal.aborted || currentLoadPubkey !== pubkey) {
return;
}
posts = feedEvents.sort((a, b) => b.created_at - a.created_at);
// Step 2: Load pins for the profile being viewed
await loadPins(pubkey);
// Reset visible counts when new data loads
visiblePostsCount = INITIAL_RENDER_LIMIT;
visibleResponsesCount = INITIAL_RENDER_LIMIT;
visibleInteractionsCount = INITIAL_RENDER_LIMIT;
// Load responses in parallel with posts (but filter after posts are loaded)
const userPostIds = new Set(posts.map(p => p.id));
const responseEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE], '#p': [pubkey], limit: config.feedLimit }], // Fetch more to account for filtering
responseRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
// Check again after responses load
if (abortController.signal.aborted || currentLoadPubkey !== pubkey) {
return;
}
// Filter responses (exclude self-replies, only include replies to user's posts)
responses = responseEvents
.filter(e => {
if (e.pubkey === pubkey) return false; // Exclude self-replies
const eTag = e.tags.find(t => t[0] === 'e');
return eTag && userPostIds.has(eTag[1]);
})
.sort((a, b) => b.created_at - a.created_at)
.slice(0, 20); // Limit to 20 after filtering
// Step 3: Load interactions in background (non-blocking)
if (currentUserPubkey && currentUserPubkey !== pubkey) {
loadInteractionsWithMe(pubkey, currentUserPubkey).catch(err => {
console.debug('Error loading interactions:', err);
});
} else {
interactionsWithMe = [];
}
// Step 4: Load pins if viewing own profile
// Step 3: Load notifications or interactions
if (isOwnProfile) {
loadPins(pubkey);
await loadNotifications(pubkey);
interactionsWithMe = [];
// Set default tab: if no pins, default to notifications
if (pins.length === 0 && notifications.length > 0) {
activeTab = 'notifications';
} else if (pins.length > 0) {
activeTab = 'pins';
}
} else {
pins = [];
notifications = [];
// Load interactions if logged in and viewing another user's profile
if (currentUserPubkey) {
await loadInteractionsWithMe(pubkey, currentUserPubkey);
} else {
interactionsWithMe = [];
}
// Set default tab to pins if available
if (pins.length > 0) {
activeTab = 'pins';
}
}
} catch (error) {
// Only update state if this load wasn't aborted
@ -581,7 +582,7 @@ @@ -581,7 +582,7 @@
{#if profile.about}
<p class="text-fog-text dark:text-fog-dark-text mb-2">{profile.about}</p>
{/if}
{#if userStatus}
{#if userStatus && userStatus.trim()}
<p class="text-fog-text-light dark:text-fog-dark-text-light italic mb-2" style="font-size: 0.875em;">
{userStatus}
</p>
@ -656,77 +657,46 @@ @@ -656,77 +657,46 @@
<div class="profile-posts">
<div class="tabs mb-4 flex gap-4 border-b border-fog-border dark:border-fog-dark-border">
<button
onclick={() => activeTab = 'posts'}
class="px-4 py-2 font-semibold {activeTab === 'posts' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}"
onclick={() => activeTab = 'pins'}
class="px-4 py-2 font-semibold {activeTab === 'pins' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}"
>
Posts ({posts.length})
Pins ({pins.length})
</button>
<button
onclick={() => activeTab = 'responses'}
class="px-4 py-2 font-semibold {activeTab === 'responses' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}"
>
Responses ({responses.length})
</button>
{#if currentUserPubkey && currentUserPubkey !== decodePubkey($page.params.pubkey)}
{#if isOwnProfile}
<button
onclick={() => activeTab = 'interactions'}
class="px-4 py-2 font-semibold {activeTab === 'interactions' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}"
onclick={() => activeTab = 'notifications'}
class="px-4 py-2 font-semibold {activeTab === 'notifications' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}"
>
Interactions with me ({interactionsWithMe.length})
Notifications ({notifications.length})
</button>
{/if}
{#if isOwnProfile}
{:else if currentUserPubkey && currentUserPubkey !== profilePubkey}
<button
onclick={() => activeTab = 'pins'}
class="px-4 py-2 font-semibold {activeTab === 'pins' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}"
onclick={() => activeTab = 'interactions'}
class="px-4 py-2 font-semibold {activeTab === 'interactions' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}"
>
Pins ({pins.length})
Interactions with me ({interactionsWithMe.length})
</button>
{/if}
</div>
{#if activeTab === 'posts'}
{#if posts.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No posts yet.</p>
{#if activeTab === 'pins'}
{#if pins.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No pinned posts yet.</p>
{:else}
<div class="posts-list">
{#each visiblePosts as post (post.id)}
<FeedPost post={post} onOpenEvent={openDrawer} />
<div class="pins-list">
{#each pins as pin (pin.id)}
<FeedPost post={pin} />
{/each}
{#if posts.length > visiblePostsCount}
<div class="load-more-visible text-center py-4">
<button
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-80"
onclick={() => {
visiblePostsCount = Math.min(visiblePostsCount + RENDER_INCREMENT, posts.length);
}}
>
Load {Math.min(RENDER_INCREMENT, posts.length - visiblePostsCount)} more posts
</button>
</div>
{/if}
</div>
{/if}
{:else if activeTab === 'responses'}
{#if responses.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No responses yet.</p>
{:else if activeTab === 'notifications'}
{#if notifications.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No notifications yet.</p>
{:else}
<div class="responses-list">
{#each visibleResponses as response (response.id)}
<FeedPost post={response} onOpenEvent={openDrawer} />
<div class="notifications-list">
{#each notifications as notification (notification.id)}
<FeedPost post={notification} />
{/each}
{#if responses.length > visibleResponsesCount}
<div class="load-more-visible text-center py-4">
<button
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-80"
onclick={() => {
visibleResponsesCount = Math.min(visibleResponsesCount + RENDER_INCREMENT, responses.length);
}}
>
Load {Math.min(RENDER_INCREMENT, responses.length - visibleResponsesCount)} more responses
</button>
</div>
{/if}
</div>
{/if}
{:else if activeTab === 'interactions'}
@ -734,30 +704,8 @@ @@ -734,30 +704,8 @@
<p class="text-fog-text-light dark:text-fog-dark-text-light">No interactions with you yet.</p>
{:else}
<div class="interactions-list">
{#each visibleInteractions as interaction (interaction.id)}
<FeedPost post={interaction} onOpenEvent={openDrawer} />
{/each}
{#if interactionsWithMe.length > visibleInteractionsCount}
<div class="load-more-visible text-center py-4">
<button
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-80"
onclick={() => {
visibleInteractionsCount = Math.min(visibleInteractionsCount + RENDER_INCREMENT, interactionsWithMe.length);
}}
>
Load {Math.min(RENDER_INCREMENT, interactionsWithMe.length - visibleInteractionsCount)} more interactions
</button>
</div>
{/if}
</div>
{/if}
{:else if activeTab === 'pins'}
{#if pins.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No pinned posts yet.</p>
{:else}
<div class="pins-list">
{#each pins as pin (pin.id)}
<FeedPost post={pin} onOpenEvent={openDrawer} />
{#each interactionsWithMe as interaction (interaction.id)}
<FeedPost post={interaction} />
{/each}
</div>
{/if}
@ -767,8 +715,6 @@ @@ -767,8 +715,6 @@
<p class="text-fog-text-light dark:text-fog-dark-text-light">Profile not found</p>
{/if}
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
{#if isOwnProfile}
<ProfileEventsPanel
isOpen={profileEventsPanelOpen}

150
src/routes/bookmarks/+page.svelte

@ -0,0 +1,150 @@ @@ -0,0 +1,150 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import FeedPost from '../../lib/modules/feed/FeedPost.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { config } from '../../lib/services/nostr/config.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { KIND } from '../../lib/types/kind-lookup.js';
let events = $state<NostrEvent[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
async function loadBookmarks() {
loading = true;
error = null;
events = [];
try {
// Fetch all kind 10003 (bookmark) events from relays
const relays = relayManager.getFeedReadRelays();
const fetchedBookmarkLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.BOOKMARKS], limit: config.feedLimit }],
relays,
{
useCache: true,
cacheResults: true,
timeout: config.standardTimeout
}
);
// Extract all event IDs from bookmark lists
const bookmarkedIds = new Set<string>();
for (const bookmarkList of fetchedBookmarkLists) {
for (const tag of bookmarkList.tags) {
if (tag[0] === 'e' && tag[1]) {
bookmarkedIds.add(tag[1]);
}
}
}
console.log(`[Bookmarks] Found ${bookmarkedIds.size} unique bookmarked event IDs from ${fetchedBookmarkLists.length} bookmark lists`);
if (bookmarkedIds.size === 0) {
loading = false;
return;
}
// Fetch the actual events - batch to avoid relay limits
const eventIds = Array.from(bookmarkedIds);
const batchSize = config.veryLargeBatchLimit; // Use config batch limit
const allFetchedEvents: NostrEvent[] = [];
console.log(`[Bookmarks] Fetching ${eventIds.length} events in batches of ${batchSize}`);
for (let i = 0; i < eventIds.length; i += batchSize) {
const batch = eventIds.slice(i, i + batchSize);
const filters = [{ ids: batch }];
console.log(`[Bookmarks] Fetching batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(eventIds.length / batchSize)} (${batch.length} events)`);
const batchEvents = await nostrClient.fetchEvents(
filters,
relays,
{
useCache: true,
cacheResults: true,
timeout: config.mediumTimeout
}
);
console.log(`[Bookmarks] Batch ${Math.floor(i / batchSize) + 1} returned ${batchEvents.length} events`);
allFetchedEvents.push(...batchEvents);
}
console.log(`[Bookmarks] Total fetched: ${allFetchedEvents.length} events`);
// Sort by created_at (newest first)
events = allFetchedEvents.sort((a, b) => b.created_at - a.created_at);
} catch (err) {
console.error('Error loading bookmarks:', err);
error = err instanceof Error ? err.message : 'Failed to load bookmarks';
} finally {
loading = false;
}
}
onMount(async () => {
await nostrClient.initialize();
await loadBookmarks();
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="bookmarks-page">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/Bookmarks</h1>
{#if loading}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading bookmarks...</p>
</div>
{:else if error}
<div class="error-state">
<p class="text-fog-text dark:text-fog-dark-text error-message">{error}</p>
</div>
{:else if events.length === 0}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No bookmarks found.</p>
</div>
{:else}
<div class="bookmarks-posts">
{#each events as event (event.id)}
<FeedPost post={event} />
{/each}
</div>
{/if}
</div>
</main>
<style>
.bookmarks-page {
max-width: 100%;
}
.loading-state,
.empty-state,
.error-state {
padding: 2rem;
text-align: center;
}
.error-message {
font-weight: 600;
color: var(--fog-accent, #64748b);
}
:global(.dark) .error-message {
color: var(--fog-dark-accent, #94a3b8);
}
.bookmarks-posts {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

38
src/routes/settings/+page.svelte

@ -16,28 +16,23 @@ @@ -16,28 +16,23 @@
let includeClientTag = $state(true);
onMount(() => {
// Load preferences from localStorage
// Read current preferences from DOM/localStorage (don't apply, just read)
const storedTextSize = localStorage.getItem('textSize') as TextSize | null;
const storedLineSpacing = localStorage.getItem('lineSpacing') as LineSpacing | null;
const storedContentWidth = localStorage.getItem('contentWidth') as ContentWidth | null;
const storedTheme = localStorage.getItem('theme');
textSize = storedTextSize || 'medium';
lineSpacing = storedLineSpacing || 'normal';
contentWidth = storedContentWidth || 'medium';
// Check theme preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
isDark = storedTheme === 'dark' || (!storedTheme && prefersDark);
// Read current theme from DOM (don't change it)
isDark = document.documentElement.classList.contains('dark');
// Load expiring events preference
expiringEvents = hasExpiringEventsEnabled();
// Load client tag preference
includeClientTag = shouldIncludeClientTag();
// Apply preferences
applyPreferences();
});
function applyPreferences() {
@ -96,8 +91,9 @@ @@ -96,8 +91,9 @@
<Header />
<main class="container mx-auto px-4 py-8 max-w-2xl">
<h1 class="font-bold mb-6 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;">Settings</h1>
<main class="container mx-auto px-4 py-8">
<div class="settings-page">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/Settings</h1>
<div class="space-y-6">
<!-- Theme Toggle -->
@ -259,9 +255,15 @@ @@ -259,9 +255,15 @@
</p>
</div>
</div>
</div>
</main>
<style>
.settings-page {
max-width: var(--content-width);
margin: 0 auto;
}
.preference-section {
margin-bottom: 0;
}
@ -296,9 +298,9 @@ @@ -296,9 +298,9 @@
}
.toggle-button.active {
background: #3b82f6;
background: var(--fog-accent, #64748b);
color: white;
border-color: #3b82f6;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .toggle-button {
@ -313,9 +315,9 @@ @@ -313,9 +315,9 @@
}
:global(.dark) .toggle-button.active {
background: #3b82f6;
background: var(--fog-dark-accent, #94a3b8);
color: white;
border-color: #3b82f6;
border-color: var(--fog-dark-accent, #94a3b8);
}
.option-button {
@ -334,9 +336,9 @@ @@ -334,9 +336,9 @@
}
.option-button.active {
background: #3b82f6;
background: var(--fog-accent, #64748b);
color: white;
border-color: #3b82f6;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .option-button {
@ -351,8 +353,8 @@ @@ -351,8 +353,8 @@
}
:global(.dark) .option-button.active {
background: #3b82f6;
background: var(--fog-dark-accent, #94a3b8);
color: white;
border-color: #3b82f6;
border-color: var(--fog-dark-accent, #94a3b8);
}
</style>

Loading…
Cancel
Save