Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
ce93fac889
  1. 4
      public/healthz.json
  2. 46
      src/lib/components/EventMenu.svelte
  3. 219
      src/lib/components/profile/BookmarksPanel.svelte
  4. 458
      src/lib/components/profile/ProfileMenu.svelte
  5. 32
      src/lib/components/write/CreateEventForm.svelte
  6. 265
      src/lib/modules/feed/FeedPage.svelte
  7. 25
      src/lib/modules/feed/FeedPost.svelte
  8. 122
      src/lib/modules/feed/HighlightCard.svelte
  9. 15
      src/lib/modules/profiles/PaymentAddresses.svelte
  10. 111
      src/lib/modules/profiles/ProfilePage.svelte
  11. 13
      src/lib/services/cache/deletion-tracker.ts
  12. 17
      src/lib/services/cache/event-cache.ts
  13. 569
      src/lib/services/user-actions.ts
  14. 8
      src/lib/types/kind-lookup.ts

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.0",
"buildTime": "2026-02-04T14:48:13.641Z",
"buildTime": "2026-02-04T16:28:03.763Z",
"gitCommit": "unknown",
"timestamp": 1770216493641
"timestamp": 1770222483764
}

46
src/lib/components/EventMenu.svelte

@ -55,12 +55,21 @@ @@ -55,12 +55,21 @@
let pinnedState = $state(false);
let bookmarkedState = $state(false);
let highlightedState = $state(false);
let stateUpdateTrigger = $state(0); // Trigger to force state updates
// Update state when event changes
// Update state when event changes or when trigger changes
$effect(() => {
pinnedState = isPinned(event.id);
bookmarkedState = isBookmarked(event.id);
highlightedState = isHighlighted(event.id);
// Access trigger to make effect reactive to it
void stateUpdateTrigger;
// Update pin and bookmark state asynchronously
isPinned(event.id).then(pinned => {
pinnedState = pinned;
});
isBookmarked(event.id).then(bookmarked => {
bookmarkedState = bookmarked;
});
});
function toggleMenu() {
@ -231,12 +240,17 @@ @@ -231,12 +240,17 @@
}
async function pinNote() {
pinnedState = await togglePin(event.id);
await togglePin(event.id);
// Force state update
stateUpdateTrigger++;
closeMenu();
}
async function bookmarkNote() {
bookmarkedState = await toggleBookmark(event.id);
await toggleBookmark(event.id);
// Force state update by re-checking bookmark status
const newBookmarked = await isBookmarked(event.id);
bookmarkedState = newBookmarked;
closeMenu();
}
@ -244,21 +258,25 @@ @@ -244,21 +258,25 @@
// Extract content and e/a tags for highlight
const content = event.content || '';
// Find e-tag or a-tag (prefer a-tag if available)
let referenceTag: string[] | null = null;
const aTag = event.tags.find(tag => tag[0] === 'a');
const eTag = event.tags.find(tag => tag[0] === 'e');
// Collect all relevant tags: e-tag with the event's ID, a-tag (if available), and p-tag with the event's pubkey
const tagsToInclude: string[][] = [];
// Always add e-tag with the event ID of the event being highlighted
tagsToInclude.push(['e', event.id]);
// Also check for a-tag in the original event (for parameterized replaceable events)
const aTag = event.tags.find(tag => tag[0] === 'a');
if (aTag) {
referenceTag = aTag;
} else if (eTag) {
referenceTag = eTag;
tagsToInclude.push(aTag);
}
// Add p-tag with the pubkey of the event being highlighted
tagsToInclude.push(['p', event.pubkey]);
// Store highlight data in sessionStorage
const highlightData = {
content,
tags: referenceTag ? [referenceTag] : []
tags: tagsToInclude
};
sessionStorage.setItem('aitherboard_highlightData', JSON.stringify(highlightData));
@ -446,6 +464,7 @@ @@ -446,6 +464,7 @@
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s, color 0.2s;
filter: grayscale(100%);
}
.menu-button:hover {
@ -466,6 +485,7 @@ @@ -466,6 +485,7 @@
user-select: none;
transform: rotate(90deg);
display: inline-block;
filter: grayscale(100%);
}
.menu-dropdown {

219
src/lib/components/profile/BookmarksPanel.svelte

@ -0,0 +1,219 @@ @@ -0,0 +1,219 @@
<script lang="ts">
import { getBookmarkedEvents } from '../../services/user-actions.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import FeedPost from '../../modules/feed/FeedPost.svelte';
import ThreadDrawer from '../../modules/feed/ThreadDrawer.svelte';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
interface Props {
isOpen: boolean;
onClose: () => void;
}
let { isOpen, onClose }: Props = $props();
let bookmarkedEvents = $state<NostrEvent[]>([]);
let loading = $state(true);
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
function openDrawer(event: NostrEvent) {
drawerEvent = event;
drawerOpen = true;
}
function closeDrawer() {
drawerOpen = false;
drawerEvent = null;
}
$effect(() => {
if (isOpen) {
loadBookmarks();
}
});
async function loadBookmarks() {
loading = true;
try {
// Get bookmarks from published kind 10003 event (from cache/relays)
const allBookmarkedIds = await getBookmarkedEvents();
console.log('[BookmarksPanel] Total bookmarked IDs to fetch:', allBookmarkedIds.size, Array.from(allBookmarkedIds));
if (allBookmarkedIds.size === 0) {
bookmarkedEvents = [];
loading = false;
return;
}
const profileRelays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ ids: Array.from(allBookmarkedIds), limit: 100 }],
profileRelays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
console.log('[BookmarksPanel] Fetched', events.length, 'events out of', allBookmarkedIds.size, 'bookmarked IDs');
// Log which IDs were not found
const foundIds = new Set(events.map(e => e.id));
const missingIds = Array.from(allBookmarkedIds).filter(id => !foundIds.has(id));
if (missingIds.length > 0) {
console.warn('[BookmarksPanel] Could not find events for these bookmarked IDs:', missingIds);
}
// Sort by created_at descending
bookmarkedEvents = events.sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.error('Error loading bookmarks:', error);
bookmarkedEvents = [];
} finally {
loading = false;
}
}
</script>
{#if isOpen}
<div
class="bookmarks-panel-overlay"
onclick={(e) => {
// Only close if clicking directly on the overlay, not on the panel
if (e.target === e.currentTarget) {
onClose();
}
}}
onkeydown={(e) => {
if (e.key === 'Escape') {
onClose();
}
}}
role="dialog"
aria-modal="true"
aria-label="Bookmarks panel"
tabindex="-1"
>
<div
class="bookmarks-panel"
>
<div class="bookmarks-panel-header">
<h2 class="bookmarks-panel-title">Bookmarks</h2>
<button class="bookmarks-panel-close" onclick={onClose} aria-label="Close bookmarks">
</button>
</div>
<div class="bookmarks-panel-content">
{#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading bookmarks...</p>
{:else if bookmarkedEvents.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No bookmarks yet.</p>
{:else}
<div class="bookmarks-list">
{#each bookmarkedEvents as event (event.id)}
<FeedPost post={event} onOpenEvent={openDrawer} />
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
<style>
.bookmarks-panel-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: stretch;
}
.bookmarks-panel {
width: 400px;
max-width: 90vw;
background: var(--fog-post, #ffffff);
border-right: 1px solid var(--fog-border, #e5e7eb);
display: flex;
flex-direction: column;
overflow: hidden;
}
:global(.dark) .bookmarks-panel {
background: var(--fog-dark-post, #1f2937);
border-right-color: var(--fog-dark-border, #374151);
}
.bookmarks-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .bookmarks-panel-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.bookmarks-panel-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .bookmarks-panel-title {
color: var(--fog-dark-text, #f9fafb);
}
.bookmarks-panel-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--fog-text-light, #6b7280);
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: background 0.2s;
}
:global(.dark) .bookmarks-panel-close {
color: var(--fog-dark-text-light, #9ca3af);
}
.bookmarks-panel-close:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .bookmarks-panel-close:hover {
background: var(--fog-dark-highlight, #374151);
}
.bookmarks-panel-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.bookmarks-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

458
src/lib/components/profile/ProfileMenu.svelte

@ -0,0 +1,458 @@ @@ -0,0 +1,458 @@
<script lang="ts">
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import { sessionManager } from '../../services/auth/session-manager.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { getProfile } from '../../services/cache/profile-cache.js';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
import { toggleMute, toggleFollow, isMuted, isFollowed } from '../../services/user-actions.js';
interface Props {
pubkey: string;
onOpenBookmarks?: () => void;
}
let { pubkey, onOpenBookmarks }: Props = $props();
let menuOpen = $state(false);
let menuButtonElement: HTMLButtonElement | null = $state(null);
let menuDropdownElement: HTMLDivElement | null = $state(null);
let menuPosition = $state({ top: 0, right: 0 });
let copied = $state<string | null>(null);
let profileEvent = $state<NostrEvent | null>(null);
let muting = $state(false);
let following = $state(false);
let isLoggedIn = $derived(sessionManager.isLoggedIn());
let currentUserPubkey = $derived(sessionManager.getCurrentPubkey());
let isOwnProfile = $derived(isLoggedIn && currentUserPubkey === pubkey);
let muted = $state(false);
let followed = $state(false);
// Update mute and follow state
$effect(() => {
if (pubkey) {
isMuted(pubkey).then(m => {
muted = m;
});
isFollowed(pubkey).then(f => {
followed = f;
});
}
});
// Load kind 0 event for this profile
$effect(() => {
if (pubkey) {
loadProfileEvent();
}
});
async function loadProfileEvent() {
try {
// Try cache first
const cached = await getProfile(pubkey);
if (cached) {
profileEvent = cached.event;
return;
}
// Fetch from relays if not in cache
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
profileEvent = events[0];
}
} catch (error) {
console.error('Error loading profile event:', error);
}
}
function toggleMenu() {
if (menuOpen) {
closeMenu();
} else {
openMenu();
}
}
function openMenu() {
menuOpen = true;
requestAnimationFrame(() => {
positionMenu();
});
}
function closeMenu() {
menuOpen = false;
}
function positionMenu() {
if (!menuButtonElement || !menuDropdownElement) return;
const buttonRect = menuButtonElement.getBoundingClientRect();
const top = buttonRect.bottom + 4;
const right = window.innerWidth - buttonRect.right;
menuPosition = { top, right };
requestAnimationFrame(() => {
if (!menuDropdownElement) return;
const dropdownRect = menuDropdownElement.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// Adjust if menu goes off bottom of screen
if (top + dropdownRect.height > viewportHeight) {
menuPosition.top = buttonRect.top - dropdownRect.height - 4;
}
// Adjust if menu goes off right side of screen
if (right - dropdownRect.width < 0) {
menuPosition.right = window.innerWidth - buttonRect.left;
}
});
}
// Close menu when clicking outside
$effect(() => {
if (!menuOpen) return;
function handleClickOutside(event: MouseEvent) {
if (
menuButtonElement &&
menuDropdownElement &&
!menuButtonElement.contains(event.target as Node) &&
!menuDropdownElement.contains(event.target as Node)
) {
closeMenu();
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeMenu();
}
}
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
});
async function copyUserId() {
try {
const npub = nip19.npubEncode(pubkey);
await navigator.clipboard.writeText(npub);
copied = 'userId';
setTimeout(() => {
copied = null;
}, 2000);
closeMenu();
} catch (error) {
console.error('Failed to copy npub:', error);
}
}
async function copyEventId() {
try {
if (!profileEvent) {
await loadProfileEvent();
}
if (profileEvent) {
const nevent = nip19.neventEncode({
id: profileEvent.id,
author: profileEvent.pubkey,
relays: []
});
await navigator.clipboard.writeText(nevent);
copied = 'eventId';
setTimeout(() => {
copied = null;
}, 2000);
}
closeMenu();
} catch (error) {
console.error('Failed to copy nevent:', error);
}
}
function shareWithAitherboard() {
const url = `${window.location.origin}/profile/${pubkey}`;
navigator.clipboard.writeText(url).then(() => {
copied = 'share';
setTimeout(() => {
copied = null;
}, 2000);
}).catch(error => {
console.error('Failed to copy share URL:', error);
});
closeMenu();
}
function seeBookmarks() {
if (onOpenBookmarks) {
onOpenBookmarks();
}
closeMenu();
}
async function handleMute() {
if (muting || isOwnProfile) return;
muting = true;
try {
await toggleMute(pubkey);
// Update state
const newMuted = await isMuted(pubkey);
muted = newMuted;
} catch (error) {
console.error('Failed to toggle mute:', error);
} finally {
muting = false;
closeMenu();
}
}
async function handleFollow() {
if (following || isOwnProfile) return;
following = true;
try {
await toggleFollow(pubkey);
// Update state
const newFollowed = await isFollowed(pubkey);
followed = newFollowed;
} catch (error) {
console.error('Failed to toggle follow:', error);
} finally {
following = false;
closeMenu();
}
}
</script>
<div class="profile-menu-container">
<button
bind:this={menuButtonElement}
class="menu-button"
onclick={toggleMenu}
aria-label="Profile menu"
aria-expanded={menuOpen}
aria-haspopup="true"
>
<span class="menu-icon"></span>
</button>
{#if menuOpen}
<div
bind:this={menuDropdownElement}
class="menu-dropdown"
style="top: {menuPosition.top}px; right: {menuPosition.right}px;"
role="menu"
>
<button class="menu-item" onclick={copyUserId} role="menuitem">
<span class="menu-item-icon">📋</span>
<span class="menu-item-text">Copy user ID (npub)</span>
{#if copied === 'userId'}
<span class="menu-item-check"></span>
{/if}
</button>
<button class="menu-item" onclick={copyEventId} role="menuitem" disabled={!profileEvent}>
<span class="menu-item-icon">📋</span>
<span class="menu-item-text">Copy event ID (nevent)</span>
{#if copied === 'eventId'}
<span class="menu-item-check"></span>
{/if}
</button>
<div class="menu-divider"></div>
<button class="menu-item" onclick={shareWithAitherboard} role="menuitem">
<span class="menu-item-icon">🔗</span>
<span class="menu-item-text">Share with aitherboard</span>
{#if copied === 'share'}
<span class="menu-item-check"></span>
{/if}
</button>
{#if isLoggedIn && !isOwnProfile}
<div class="menu-divider"></div>
<button
class="menu-item"
onclick={handleMute}
role="menuitem"
disabled={muting}
>
<span class="menu-item-icon">{muted ? '🔇' : '🔊'}</span>
<span class="menu-item-text">{muted ? 'Unmute this user' : 'Mute this user'}</span>
{#if muted}
<span class="menu-item-check"></span>
{/if}
</button>
<button
class="menu-item"
onclick={handleFollow}
role="menuitem"
disabled={following}
>
<span class="menu-item-icon">{followed ? '👤' : '➕'}</span>
<span class="menu-item-text">{followed ? 'Unfollow this user' : 'Follow this user'}</span>
{#if followed}
<span class="menu-item-check"></span>
{/if}
</button>
{/if}
{#if isOwnProfile && onOpenBookmarks}
<div class="menu-divider"></div>
<button class="menu-item" onclick={seeBookmarks} role="menuitem">
<span class="menu-item-icon">🔖</span>
<span class="menu-item-text">See bookmarks</span>
</button>
{/if}
</div>
{/if}
</div>
<style>
.profile-menu-container {
position: relative;
display: inline-block;
}
.menu-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.5rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
min-width: 2rem;
min-height: 2rem;
filter: grayscale(100%);
}
:global(.dark) .menu-button {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.menu-button:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .menu-button:hover {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #94a3b8);
}
.menu-icon {
font-size: 1.25rem;
line-height: 1;
color: var(--fog-text, #1f2937);
filter: grayscale(100%);
}
:global(.dark) .menu-icon {
color: var(--fog-dark-text, #f9fafb);
}
.menu-dropdown {
position: fixed;
z-index: 1000;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 180px;
padding: 0.25rem;
}
:global(.dark) .menu-dropdown {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.menu-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
background: none;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
text-align: left;
transition: background 0.2s;
}
:global(.dark) .menu-item {
color: var(--fog-dark-text, #f9fafb);
}
.menu-item:hover:not(:disabled) {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .menu-item:hover:not(:disabled) {
background: var(--fog-dark-highlight, #374151);
}
.menu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-item-icon {
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
filter: grayscale(100%);
}
.menu-item-text {
flex: 1;
}
.menu-item-check {
color: var(--fog-accent, #64748b);
font-weight: 600;
flex-shrink: 0;
}
:global(.dark) .menu-item-check {
color: var(--fog-dark-accent, #94a3b8);
}
.menu-divider {
height: 1px;
background: var(--fog-border, #e5e7eb);
margin: 0.25rem 0;
}
:global(.dark) .menu-divider {
background: var(--fog-dark-border, #374151);
}
</style>

32
src/lib/components/write/CreateEventForm.svelte

@ -119,13 +119,21 @@ @@ -119,13 +119,21 @@
}
});
// Sync content and tags when initial props change (only if form is empty)
// Track if we've already applied initial props to prevent re-applying after clear
let initialPropsApplied = $state(false);
let formCleared = $state(false); // Track if form was explicitly cleared
// Sync content and tags when initial props change (only if form is empty and not yet applied)
$effect(() => {
if (initialPropsApplied || formCleared) return; // Don't re-apply after they've been used or after clear
if (propInitialContent !== null && propInitialContent !== undefined && content === '') {
content = propInitialContent;
initialPropsApplied = true;
}
if (propInitialTags !== null && propInitialTags !== undefined && propInitialTags.length > 0 && tags.length === 0) {
tags = [...propInitialTags];
initialPropsApplied = true;
}
});
@ -756,14 +764,34 @@ @@ -756,14 +764,34 @@
function clearForm() {
if (confirm('Are you sure you want to clear the form? This will delete all unsaved content.')) {
try {
// Mark form as cleared to prevent initial props from re-applying
formCleared = true;
// Clear state synchronously
content = '';
tags = [];
uploadedFiles = [];
customKindId = '';
// Clear draft from localStorage
selectedKind = 1; // Reset to default kind
// Reset the initial props applied flag
initialPropsApplied = false;
// Clear draft from localStorage after clearing state
// This prevents the save effect from running with old data
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
// Reset formCleared flag after a brief delay to allow effects to settle
setTimeout(() => {
formCleared = false;
}, 100);
} catch (error) {
console.error('Error clearing form:', error);
alert('Failed to clear form. Please try again.');
}
}
}

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

@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
import ThreadDrawer from './ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { onMount, tick } from 'svelte';
import { KIND } from '../../types/kind-lookup.js';
import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js';
interface Props {
singleRelay?: string; // If provided, use only this relay and disable cache
@ -19,6 +19,8 @@ @@ -19,6 +19,8 @@
let allPosts = $state<NostrEvent[]>([]); // Store all posts before filtering
let highlights = $state<NostrEvent[]>([]); // Store highlight events (kind 9802)
let allHighlights = $state<NostrEvent[]>([]); // Store all highlights before filtering
let otherFeedEvents = $state<NostrEvent[]>([]); // Store other feed kinds (not kind 1 or 9802)
let allOtherFeedEvents = $state<NostrEvent[]>([]); // Store all other feed events before filtering
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(true);
@ -136,9 +138,11 @@ @@ -136,9 +138,11 @@
selectedListId = listId;
if (!listId) {
// No filter selected - show all posts
// No filter selected - show all posts, highlights, and other feed events
listFilterIds = new Set();
posts = [...allPosts];
highlights = [...allHighlights];
otherFeedEvents = [...allOtherFeedEvents];
return;
}
@ -150,6 +154,8 @@ @@ -150,6 +154,8 @@
if (!list) {
listFilterIds = new Set();
posts = [...allPosts];
highlights = [...allHighlights];
otherFeedEvents = [...allOtherFeedEvents];
return;
}
@ -179,22 +185,28 @@ @@ -179,22 +185,28 @@
listFilterIds = ids;
// Filter posts
// Filter posts, highlights, and other feed events
if (kind === KIND.CONTACTS || kind === KIND.FOLOW_SET) {
// Filter by author pubkey
posts = allPosts.filter(post => ids.has(post.pubkey));
highlights = allHighlights.filter(highlight => ids.has(highlight.pubkey));
otherFeedEvents = allOtherFeedEvents.filter(event => ids.has(event.pubkey));
} else {
// Filter by event ID
posts = allPosts.filter(post => ids.has(post.id));
highlights = allHighlights.filter(highlight => ids.has(highlight.id));
otherFeedEvents = allOtherFeedEvents.filter((event: NostrEvent) => ids.has(event.id));
}
}
// Apply filter when allPosts changes
// Apply filter when allPosts, allHighlights, or allOtherFeedEvents changes
$effect(() => {
if (selectedListId) {
handleListFilterChange(selectedListId);
} else {
posts = [...allPosts];
highlights = [...allHighlights];
otherFeedEvents = [...allOtherFeedEvents];
}
});
@ -261,10 +273,8 @@ @@ -261,10 +273,8 @@
}
const relays = relayManager.getFeedReadRelays();
const filters = [
{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 20 },
{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 20 }
];
const feedKinds = getFeedKinds();
const filters = feedKinds.map(kind => ({ kinds: [kind], limit: 20 }));
// Subscribe to new kind 1 events and kind 9802 highlights
subscriptionId = nostrClient.subscribe(
@ -367,56 +377,109 @@ @@ -367,56 +377,109 @@
// Use single relay if provided, otherwise use normal relay list
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
// For single relay mode, ensure the relay is connected first
// Load both kind 1 posts and kind 9802 highlights
const filters = [
{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 20 },
{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 20 }
];
// For single relay mode, load from cache first for immediate display
// Then query the relay in background to get fresh data
let events: NostrEvent[] = [];
if (singleRelay) {
console.log(`[FeedPage] Single relay mode: ensuring ${singleRelay} is connected...`);
// Step 1: Load from cache immediately (fast, shows something right away)
const cachedEvents = await nostrClient.fetchEvents(
filters,
relays,
{
useCache: true, // Use cache for fast initial load
cacheResults: false, // Don't cache again
timeout: 2000 // Short timeout for cache
}
);
// Show cached data immediately if available
if (cachedEvents.length > 0) {
events = cachedEvents;
console.log(`[FeedPage] Loaded ${cachedEvents.length} cached events from ${singleRelay}`);
// Process cached events immediately so they show up
// (will be processed below)
}
// Step 2: Ensure relay is connected and query for fresh data
// If we have cached data, do this in background. Otherwise, wait for it.
const queryPromise = (async () => {
try {
console.log(`[FeedPage] Single relay mode: ensuring ${singleRelay} is connected...`);
// Force connection to the relay
await nostrClient.addRelay(singleRelay);
// Give it a moment to establish connection
await new Promise(resolve => setTimeout(resolve, 500));
} catch (error) {
console.warn(`[FeedPage] Failed to connect to relay ${singleRelay}:`, error);
await new Promise(resolve => setTimeout(resolve, 1000));
// Query relay for fresh data
const freshEvents = await nostrClient.fetchEvents(
filters,
relays,
{
useCache: false, // Force query relay
cacheResults: true, // Cache the results
timeout: 15000
}
);
console.log(`[FeedPage] Fresh query returned ${freshEvents.length} events from ${singleRelay}`);
// Update with fresh data
if (freshEvents.length > 0) {
const existingIds = new Set([...posts.map(p => p.id), ...highlights.map(h => h.id)]);
const trulyNew = freshEvents.filter(e => !existingIds.has(e.id));
if (trulyNew.length > 0 || freshEvents.length !== events.length) {
handleUpdate(freshEvents);
}
}
// For single relay mode, disable cache completely
const useCache = !singleRelay;
const cacheResults = !singleRelay;
return freshEvents;
} catch (error) {
console.warn(`[FeedPage] Failed to query relay ${singleRelay}:`, error);
// If query fails but we have cached data, that's okay - keep showing cached data
return [];
}
})();
// Load initial feed - use cache for fast initial load (unless single relay mode)
// Load both kind 1 posts and kind 9802 highlights
const filters = [
{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 20 },
{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 20 }
];
const events = await nostrClient.fetchEvents(
// If we don't have cached data, wait for the relay query
if (events.length === 0) {
const freshEvents = await queryPromise;
if (freshEvents.length > 0) {
events = freshEvents;
}
} else {
// If we have cached data, query in background (don't await)
queryPromise.catch(() => {
// Already logged error above
});
}
} else {
// Normal mode: use cache first, then query relays
events = await nostrClient.fetchEvents(
filters,
relays,
{
useCache, // Disable cache for single relay mode
cacheResults, // Don't cache results for single relay mode
// Don't use onUpdate here - subscriptions handle updates
timeout: 15000 // Longer timeout for single relay mode
useCache: true, // Use cache for fast initial load
cacheResults: true, // Cache results
timeout: 15000
}
);
console.log(`[FeedPage] Loaded ${events.length} events from ${singleRelay ? 'single relay' : 'relays'}`);
// Separate posts and highlights
const postsList = events.filter(e => e.kind === KIND.SHORT_TEXT_NOTE);
const highlightsList = events.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE);
console.log(`[FeedPage] Loaded ${events.length} events from relays`);
// Also immediately query relays to ensure we get fresh data in background
// For single relay mode, also do a background query to ensure we get results
if (!singleRelay || events.length === 0) {
nostrClient.fetchEvents(
filters,
relays,
{
useCache: false, // Force query relays
cacheResults: !singleRelay, // Don't cache for single relay mode
// Don't use onUpdate - let subscriptions handle it
cacheResults: true, // Cache results
timeout: 15000
}
).then((newEvents) => {
@ -434,6 +497,16 @@ @@ -434,6 +497,16 @@
});
}
// Separate events by kind - we'll handle all showInFeed kinds
const postsList = events.filter(e => e.kind === KIND.SHORT_TEXT_NOTE);
const highlightsList = events.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE);
// Store other feed kinds separately for now (we'll create cards for them)
const newOtherFeedEvents = events.filter((e: NostrEvent) =>
e.kind !== KIND.SHORT_TEXT_NOTE &&
e.kind !== KIND.HIGHLIGHTED_ARTICLE &&
getKindInfo(e.kind).showInFeed === true
);
// Sort by created_at descending and deduplicate
const uniquePostsMap = new Map<string, NostrEvent>();
for (const event of postsList) {
@ -454,16 +527,33 @@ @@ -454,16 +527,33 @@
const uniqueHighlights = Array.from(uniqueHighlightsMap.values());
const sortedHighlights = uniqueHighlights.sort((a, b) => b.created_at - a.created_at);
allHighlights = sortedHighlights;
highlights = [...allHighlights];
// Always set posts, even if empty
// Store other feed events
const uniqueOtherMap = new Map<string, NostrEvent>();
for (const event of newOtherFeedEvents) {
if (!uniqueOtherMap.has(event.id)) {
uniqueOtherMap.set(event.id, event);
}
}
const uniqueOther = Array.from(uniqueOtherMap.values());
const sortedOther = uniqueOther.sort((a, b) => b.created_at - a.created_at);
allOtherFeedEvents = sortedOther;
// Always set posts, highlights, and other feed events immediately, even if empty
// This ensures cached data shows up right away
// Apply filter if one is selected
if (selectedListId) {
handleListFilterChange(selectedListId);
} else {
posts = [...allPosts];
highlights = [...allHighlights];
otherFeedEvents = [...allOtherFeedEvents];
}
// Set loading to false immediately after showing cached data
// This allows the UI to render while fresh data loads in background
loading = false;
console.log(`[FeedPage] Loaded ${sortedPosts.length} posts and ${sortedHighlights.length} highlights`);
if (sortedPosts.length > 0 || sortedHighlights.length > 0) {
@ -493,10 +583,6 @@ @@ -493,10 +583,6 @@
// Use single relay if provided, otherwise use normal relay list
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
// For single relay mode, disable cache completely
const useCache = !singleRelay;
const cacheResults = !singleRelay;
const filters = [
{
kinds: [KIND.SHORT_TEXT_NOTE],
@ -510,30 +596,80 @@ @@ -510,30 +596,80 @@
}
];
const events = await nostrClient.fetchEvents(
// For single relay mode, try cache first, then query relay
let events: NostrEvent[] = [];
if (singleRelay) {
// Try cache first
const cachedEvents = await nostrClient.fetchEvents(
filters,
relays,
{
useCache: true,
cacheResults: false,
timeout: 2000
}
);
if (cachedEvents.length > 0) {
events = cachedEvents;
}
// Query relay in background for fresh data
nostrClient.fetchEvents(
filters,
relays,
{
useCache: false,
cacheResults: true,
timeout: 10000
}
).then((freshEvents) => {
if (freshEvents.length > 0) {
const existingIds = new Set([...allPosts.map(p => p.id), ...allHighlights.map(h => h.id)]);
const uniqueNewPosts = freshEvents.filter(e => e.kind === KIND.SHORT_TEXT_NOTE && !existingIds.has(e.id));
const uniqueNewHighlights = freshEvents.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE && !existingIds.has(e.id));
if (uniqueNewPosts.length > 0 || uniqueNewHighlights.length > 0) {
handleUpdate(freshEvents);
}
}
}).catch(error => {
console.warn('[FeedPage] Background query error:', error);
});
} else {
events = await nostrClient.fetchEvents(
filters,
relays,
{
useCache, // Disable cache for single relay mode
cacheResults, // Don't cache results for single relay mode
useCache: true,
cacheResults: true,
timeout: 10000
}
);
}
if (events.length === 0) {
hasMore = false;
return;
}
// Separate posts and highlights
// Separate events by kind
const newPosts = events.filter(e => e.kind === KIND.SHORT_TEXT_NOTE);
const newHighlights = events.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE);
const newOtherFeedEvents = events.filter((e: NostrEvent) =>
e.kind !== KIND.SHORT_TEXT_NOTE &&
e.kind !== KIND.HIGHLIGHTED_ARTICLE &&
getKindInfo(e.kind).showInFeed === true
);
// Filter out duplicates
const existingPostIds = new Set(allPosts.map(p => p.id));
const existingHighlightIds = new Set(allHighlights.map(h => h.id));
const existingOtherIds = new Set(allOtherFeedEvents.map((e: NostrEvent) => e.id));
const uniqueNewPosts = newPosts.filter(e => !existingPostIds.has(e.id));
const uniqueNewHighlights = newHighlights.filter(e => !existingHighlightIds.has(e.id));
const uniqueNewOther = newOtherFeedEvents.filter((e: NostrEvent) => !existingOtherIds.has(e.id));
if (uniqueNewPosts.length > 0 || uniqueNewHighlights.length > 0) {
if (uniqueNewPosts.length > 0) {
@ -546,7 +682,12 @@ @@ -546,7 +682,12 @@
if (uniqueNewHighlights.length > 0) {
const sorted = uniqueNewHighlights.sort((a, b) => b.created_at - a.created_at);
allHighlights = [...allHighlights, ...sorted];
highlights = [...allHighlights];
}
if (uniqueNewOther.length > 0) {
const sorted = uniqueNewOther.sort((a, b) => b.created_at - a.created_at);
allOtherFeedEvents = [...allOtherFeedEvents, ...sorted];
otherFeedEvents = [...allOtherFeedEvents];
}
// Apply filter if one is selected
@ -554,6 +695,8 @@ @@ -554,6 +695,8 @@
handleListFilterChange(selectedListId);
} else {
posts = [...allPosts];
highlights = [...allHighlights];
otherFeedEvents = [...allOtherFeedEvents];
}
const allNewTimestamps = [...uniqueNewPosts.map(e => e.created_at), ...uniqueNewHighlights.map(e => e.created_at)];
@ -626,9 +769,14 @@ @@ -626,9 +769,14 @@
console.log(`[FeedPage] Processing ${newEvents.length} new events, existing: ${allPosts.length}`);
// Separate posts and highlights
// Separate events by kind
const newPosts = newEvents.filter(e => e.kind === KIND.SHORT_TEXT_NOTE);
const newHighlights = newEvents.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE);
const newOtherFeedEvents = newEvents.filter((e: NostrEvent) =>
e.kind !== KIND.SHORT_TEXT_NOTE &&
e.kind !== KIND.HIGHLIGHTED_ARTICLE &&
getKindInfo(e.kind).showInFeed === true
);
// Merge and sort posts, then deduplicate by ID
if (newPosts.length > 0) {
@ -663,7 +811,24 @@ @@ -663,7 +811,24 @@
// Only update if we actually have new events to prevent loops
if (sortedHighlights.length > allHighlights.length || sortedHighlights.some((e, i) => e.id !== allHighlights[i]?.id)) {
allHighlights = sortedHighlights;
highlights = [...allHighlights];
}
}
// Merge and sort other feed events, then deduplicate by ID
if (newOtherFeedEvents.length > 0) {
const mergedOther = [...allOtherFeedEvents, ...newOtherFeedEvents];
const uniqueOtherMap = new Map<string, NostrEvent>();
for (const event of mergedOther) {
if (event && event.id && !uniqueOtherMap.has(event.id)) {
uniqueOtherMap.set(event.id, event);
}
}
const uniqueOther = Array.from(uniqueOtherMap.values());
const sortedOther = uniqueOther.sort((a, b) => b.created_at - a.created_at);
// Only update if we actually have new events to prevent loops
if (sortedOther.length > allOtherFeedEvents.length || sortedOther.some((e, i) => e.id !== allOtherFeedEvents[i]?.id)) {
allOtherFeedEvents = sortedOther;
}
}
@ -672,6 +837,8 @@ @@ -672,6 +837,8 @@
handleListFilterChange(selectedListId);
} else {
posts = [...allPosts];
highlights = [...allHighlights];
otherFeedEvents = [...allOtherFeedEvents];
}
console.debug(`[FeedPage] Updated: ${allPosts.length} posts, ${allHighlights.length} highlights`);

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

@ -12,6 +12,7 @@ @@ -12,6 +12,7 @@
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo, KIND } from '../../types/kind-lookup.js';
import { stripMarkdown } from '../../services/text-utils.js';
import { isBookmarked } from '../../services/user-actions.js';
interface Props {
post: NostrEvent;
@ -34,6 +35,15 @@ @@ -34,6 +35,15 @@
let needsExpansion = $state(false);
let zapCount = $state(0);
// Check if this event is bookmarked (async, so we use state)
let bookmarked = $state(false);
$effect(() => {
isBookmarked(post.id).then(b => {
bookmarked = b;
});
});
// Calculate votes as derived values to avoid infinite loops
// Deduplicate by pubkey - each user should only count once per vote type
let upvotes = $derived.by(() => {
@ -446,7 +456,8 @@ @@ -446,7 +456,8 @@
{/each}
{/if}
{/if}
<div class="ml-auto">
<div class="ml-auto flex items-center gap-2">
<span class="bookmark-indicator" class:bookmarked={bookmarked} title={bookmarked ? "Bookmarked" : "Not bookmarked"}>🔖</span>
<EventMenu event={post} showContentActions={true} />
</div>
</div>
@ -663,4 +674,16 @@ @@ -663,4 +674,16 @@
vertical-align: middle;
}
.bookmark-indicator {
display: inline-block;
font-size: 1rem;
line-height: 1;
filter: grayscale(100%);
transition: filter 0.2s;
}
.bookmark-indicator.bookmarked {
filter: grayscale(0%);
}
</style>

122
src/lib/modules/feed/HighlightCard.svelte

@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo, KIND } from '../../types/kind-lookup.js';
import { getHighlightsForEvent } from '../../services/nostr/highlight-service.js';
import { isBookmarked } from '../../services/user-actions.js';
interface Props {
highlight: NostrEvent; // The highlight event (kind 9802)
@ -19,6 +20,15 @@ @@ -19,6 +20,15 @@
let sourceEvent = $state<NostrEvent | null>(null);
let loadingSource = $state(false);
// Check if this event is bookmarked (async, so we use state)
let bookmarked = $state(false);
$effect(() => {
isBookmarked(highlight.id).then(b => {
bookmarked = b;
});
});
// Extract source event ID from e-tag or a-tag
function getSourceEventId(): string | null {
const eTag = highlight.tags.find(t => t[0] === 'e' && t[1]);
@ -37,6 +47,86 @@ @@ -37,6 +47,86 @@
return null;
}
// Extract context tag value
function getContext(): string | null {
const contextTag = highlight.tags.find(t => t[0] === 'context' && t[1]);
return contextTag?.[1] || null;
}
// Normalize text for matching (remove extra whitespace, normalize line breaks)
function normalizeText(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
// Escape HTML to prevent XSS
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Find content within context and highlight it
function getHighlightedContext(): string {
const context = getContext();
const content = highlight.content?.trim();
// If no context or no content, just return the content
if (!context || !content) {
return escapeHtml(content || '');
}
// Escape the context for safety
const escapedContext = escapeHtml(context);
const escapedContent = escapeHtml(content);
// Normalize whitespace for matching (but preserve original for display)
const normalizeForMatch = (text: string) => text.replace(/\s+/g, ' ').trim();
const normalizedContext = normalizeForMatch(context);
const normalizedContent = normalizeForMatch(content);
// Try to find the normalized content within the normalized context
const normalizedIndex = normalizedContext.toLowerCase().indexOf(normalizedContent.toLowerCase());
if (normalizedIndex === -1) {
// Content not found in context, just return context
return escapedContext;
}
// Find the actual position in the original context
// We need to map from normalized position back to original position
// This is approximate - we'll search for the content in the original context
// using a flexible regex that handles whitespace variations
// Create a regex pattern from the content that allows flexible whitespace
const contentWords = content.trim().split(/\s+/);
const flexiblePattern = contentWords.map(word =>
word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
).join('\\s+');
const regex = new RegExp(`(${flexiblePattern})`, 'i');
const match = context.match(regex);
if (!match) {
// Fallback: just return context without highlighting
return escapedContext;
}
// Found match, highlight it
const matchIndex = match.index!;
const matchText = match[1];
const before = escapeHtml(context.substring(0, matchIndex));
const highlighted = '<mark class="highlight-text">' + escapeHtml(matchText) + '</mark>';
const after = escapeHtml(context.substring(matchIndex + matchText.length));
return before + highlighted + after;
}
// Check if we should show context with highlight
let shouldShowContext = $derived(getContext() !== null && highlight.content?.trim() !== '');
onMount(async () => {
await loadSourceEvent();
});
@ -147,14 +237,19 @@ @@ -147,14 +237,19 @@
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span>
{/if}
<div class="ml-auto">
<div class="ml-auto flex items-center gap-2">
<span class="bookmark-indicator" class:bookmarked={bookmarked} title={bookmarked ? "Bookmarked" : "Not bookmarked"}>🔖</span>
<EventMenu event={highlight} showContentActions={true} />
</div>
</div>
</div>
<div class="highlight-content">
{#if shouldShowContext}
{@html getHighlightedContext()}
{:else}
<MarkdownRenderer content={highlight.content} event={highlight} />
{/if}
</div>
{#if sourceEvent}
@ -299,4 +394,29 @@ @@ -299,4 +394,29 @@
font-size: 0.625rem;
opacity: 0.8;
}
:global(.highlight-content mark.highlight-text) {
background-color: #fef3c7;
color: #92400e;
padding: 0.125rem 0.25rem;
border-radius: 0.125rem;
font-weight: 500;
}
:global(.dark .highlight-content mark.highlight-text) {
background-color: #78350f;
color: #fef3c7;
}
.bookmark-indicator {
display: inline-block;
font-size: 1rem;
line-height: 1;
filter: grayscale(100%);
transition: filter 0.2s;
}
.bookmark-indicator.bookmarked {
filter: grayscale(0%);
}
</style>

15
src/lib/modules/profiles/PaymentAddresses.svelte

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
import { fetchProfile } from '../../services/user-data.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
interface Props {
pubkey: string;
@ -11,6 +12,7 @@ @@ -11,6 +12,7 @@
let { pubkey }: Props = $props();
let paymentAddresses = $state<Array<{ type: string; address: string }>>([]);
let paymentEvent = $state<NostrEvent | null>(null);
let loading = $state(true);
const recognizedTypes = ['bitcoin', 'lightning', 'ethereum', 'nano', 'monero', 'cashme', 'revolut', 'venmo'];
@ -37,8 +39,8 @@ @@ -37,8 +39,8 @@
// Extract from kind 10133
if (paymentEvents.length > 0) {
const event = paymentEvents[0];
for (const tag of event.tags) {
paymentEvent = paymentEvents[0];
for (const tag of paymentEvent.tags) {
if (tag[0] === 'payto' && tag[1] && tag[2]) {
const key = `${tag[1]}:${tag[2]}`;
if (!seen.has(key)) {
@ -86,9 +88,16 @@ @@ -86,9 +88,16 @@
<div class="payment-addresses">
{#if loading}
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">Loading payment addresses...</span>
{:else if paymentAddresses.length > 0}
{:else if paymentAddresses.length > 0 || paymentEvent}
<div class="addresses-list">
<h3 class="text-lg font-semibold mb-2">Payment Addresses</h3>
{#if paymentEvent}
<div class="payment-event-info mb-2">
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
From kind {KIND.PAYMENT_ADDRESSES} event
</span>
</div>
{/if}
{#each paymentAddresses as { type, address }}
<div class="address-item flex items-center gap-2 mb-2">
<span class="text-sm font-medium">{getTypeLabel(type)}:</span>

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

@ -5,6 +5,9 @@ @@ -5,6 +5,9 @@
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';
@ -21,8 +24,10 @@ @@ -21,8 +24,10 @@
let responses = $state<NostrEvent[]>([]);
let interactionsWithMe = $state<NostrEvent[]>([]);
let loading = $state(true);
let activeTab = $state<'posts' | 'responses' | 'interactions'>('posts');
let activeTab = $state<'posts' | 'responses' | 'interactions' | 'pins'>('posts');
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));
// Cache for NIP-05 validation results (nip05+pubkey -> result)
// This prevents re-validating the same NIP-05 address repeatedly
@ -38,6 +43,12 @@ @@ -38,6 +43,12 @@
// Profile events panel state
let profileEventsPanelOpen = $state(false);
// Bookmarks panel state
let bookmarksPanelOpen = $state(false);
// Pins state
let pins = $state<NostrEvent[]>([]);
function openDrawer(event: NostrEvent) {
drawerEvent = event;
drawerOpen = true;
@ -56,6 +67,14 @@ @@ -56,6 +67,14 @@
profileEventsPanelOpen = false;
}
function openBookmarksPanel() {
bookmarksPanelOpen = true;
}
function closeBookmarksPanel() {
bookmarksPanelOpen = false;
}
const isOwnProfile = $derived.by(() => {
const pubkey = decodePubkey($page.params.pubkey);
return currentUserPubkey && pubkey && currentUserPubkey === pubkey;
@ -79,6 +98,29 @@ @@ -79,6 +98,29 @@
return unsubscribe;
});
async function loadPins(pubkey: string) {
try {
const pinnedIds = await getPinnedEvents();
if (pinnedIds.size === 0) {
pins = [];
return;
}
const profileRelays = relayManager.getProfileReadRelays();
const pinnedEvents = await nostrClient.fetchEvents(
[{ ids: Array.from(pinnedIds), limit: 100 }],
profileRelays,
{ useCache: true, cacheResults: true, timeout: 5000 }
);
// Sort by created_at descending
pins = pinnedEvents.sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.error('Error loading pins:', error);
pins = [];
}
}
async function loadInteractionsWithMe(profilePubkey: string, currentUserPubkey: string) {
if (!currentUserPubkey || currentUserPubkey === profilePubkey) {
interactionsWithMe = [];
@ -399,6 +441,13 @@ @@ -399,6 +441,13 @@
} else {
interactionsWithMe = [];
}
// Step 4: Load pins if viewing own profile
if (isOwnProfile) {
loadPins(pubkey);
} else {
pins = [];
}
} catch (error) {
console.error('Error loading profile:', error);
loading = false;
@ -471,6 +520,16 @@ @@ -471,6 +520,16 @@
{/each}
</div>
{/if}
{#if profilePubkey}
<div class="profile-npub-section mb-2">
<div class="npub-display">
<code class="npub-text">{nip19.npubEncode(profilePubkey)}</code>
<ProfileMenu pubkey={profilePubkey} onOpenBookmarks={isOwnProfile ? openBookmarksPanel : undefined} />
</div>
</div>
{/if}
<PaymentAddresses pubkey={decodePubkey($page.params.pubkey) || ''} />
{#if isOwnProfile}
@ -507,6 +566,14 @@ @@ -507,6 +566,14 @@
Interactions with me ({interactionsWithMe.length})
</button>
{/if}
{#if isOwnProfile}
<button
onclick={() => activeTab = 'pins'}
class="px-4 py-2 font-semibold {activeTab === 'pins' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}"
>
Pins ({pins.length})
</button>
{/if}
</div>
{#if activeTab === 'posts'}
@ -539,6 +606,16 @@ @@ -539,6 +606,16 @@
{/each}
</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}
</div>
{/if}
{/if}
</div>
{:else}
@ -553,6 +630,10 @@ @@ -553,6 +630,10 @@
pubkey={decodePubkey($page.params.pubkey) || ''}
onClose={closeProfileEventsPanel}
/>
<BookmarksPanel
isOpen={bookmarksPanelOpen}
onClose={closeBookmarksPanel}
/>
{/if}
</div>
@ -679,4 +760,32 @@ @@ -679,4 +760,32 @@
.adjust-profile-button:hover {
opacity: 0.9;
}
.profile-npub-section {
margin-top: 0.5rem;
}
.npub-display {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.npub-text {
font-family: monospace;
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
word-break: break-all;
padding: 0.25rem 0.5rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
border: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .npub-text {
color: var(--fog-dark-text-light, #9ca3af);
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
</style>

13
src/lib/services/cache/deletion-tracker.ts vendored

@ -18,9 +18,14 @@ async function getDeletedEventIdsFromCache(): Promise<Set<string>> { @@ -18,9 +18,14 @@ async function getDeletedEventIdsFromCache(): Promise<Set<string>> {
const tx = db.transaction('events', 'readonly');
const index = tx.store.index('kind');
// Get all kind 5 (deletion request) events
for await (const cursor of index.iterate(KIND.EVENT_DELETION)) {
const deletionEvent = cursor.value as CachedEvent;
// Use getAll() instead of iterate to avoid transaction timing issues
const deletionEvents = await index.getAll(KIND.EVENT_DELETION);
// Wait for transaction to complete
await tx.done;
// Process deletion events
for (const deletionEvent of deletionEvents) {
// Extract event IDs from 'e' tags
if (deletionEvent.tags) {
for (const tag of deletionEvent.tags) {
@ -30,8 +35,6 @@ async function getDeletedEventIdsFromCache(): Promise<Set<string>> { @@ -30,8 +35,6 @@ async function getDeletedEventIdsFromCache(): Promise<Set<string>> {
}
}
}
await tx.done;
} catch (error) {
console.debug('Error getting deleted event IDs from cache:', error);
}

17
src/lib/services/cache/event-cache.ts vendored

@ -39,7 +39,7 @@ export async function cacheEvents(events: NostrEvent[]): Promise<void> { @@ -39,7 +39,7 @@ export async function cacheEvents(events: NostrEvent[]): Promise<void> {
try {
if (events.length === 0) return;
// Check which events are marked as deleted
// Check which events are marked as deleted (complete this transaction first)
const eventIds = events.map(e => e.id);
const deletedIds = await getDeletedEventIds(eventIds);
@ -48,15 +48,22 @@ export async function cacheEvents(events: NostrEvent[]): Promise<void> { @@ -48,15 +48,22 @@ export async function cacheEvents(events: NostrEvent[]): Promise<void> {
if (eventsToCache.length === 0) return;
// Create a new transaction for writing (after the read transaction is complete)
const db = await getDB();
const tx = db.transaction('events', 'readwrite');
for (const event of eventsToCache) {
const cached: CachedEvent = {
// Prepare all cached events first
const cachedEvents: CachedEvent[] = eventsToCache.map(event => ({
...event,
cached_at: Date.now()
};
await tx.store.put(cached);
}));
// Put all events in a single batch
for (const cached of cachedEvents) {
tx.store.put(cached);
}
// Wait for transaction to complete
await tx.done;
} catch (error) {
console.debug('Error caching events:', error);

569
src/lib/services/user-actions.ts

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
/**
* User actions service - manages pinned, bookmarked, and highlighted events
* Stores in localStorage for persistence and publishes list events
* All data stored as events in IndexedDB cache and published to relays
*/
import { sessionManager } from './auth/session-manager.js';
@ -10,103 +10,186 @@ import { relayManager } from './nostr/relay-manager.js'; @@ -10,103 +10,186 @@ import { relayManager } from './nostr/relay-manager.js';
import { KIND } from '../types/kind-lookup.js';
import type { NostrEvent } from '../types/nostr.js';
const STORAGE_KEY_PINNED = 'aitherboard_pinned_events';
const STORAGE_KEY_BOOKMARKED = 'aitherboard_bookmarked_events';
const STORAGE_KEY_HIGHLIGHTED = 'aitherboard_highlighted_events';
/**
* Get all pinned event IDs
* Get all pinned event IDs from published kind 10001 event (from cache/relays)
*/
export function getPinnedEvents(): Set<string> {
if (typeof window === 'undefined') return new Set();
export async function getPinnedEvents(): Promise<Set<string>> {
const pinnedIds = new Set<string>();
try {
const stored = localStorage.getItem(STORAGE_KEY_PINNED);
if (!stored) return new Set();
const ids = JSON.parse(stored) as string[];
return new Set(ids);
} catch {
return new Set();
const session = sessionManager.getSession();
if (!session) return pinnedIds;
// Fetch published pin list event from cache/relays
const relays = relayManager.getProfileReadRelays();
const pinLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.PIN_LIST], authors: [session.pubkey], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
// Extract event IDs from published pin list
if (pinLists.length > 0) {
const pinList = pinLists[0];
for (const tag of pinList.tags) {
if (tag[0] === 'e' && tag[1]) {
pinnedIds.add(tag[1]);
}
}
}
} catch (error) {
console.debug('Error fetching pinned events:', error);
}
return pinnedIds;
}
/**
* Get all bookmarked event IDs
* Get all bookmarked event IDs from published kind 10003 event (from cache/relays)
*/
export function getBookmarkedEvents(): Set<string> {
if (typeof window === 'undefined') return new Set();
export async function getBookmarkedEvents(): Promise<Set<string>> {
const bookmarkedIds = new Set<string>();
try {
const stored = localStorage.getItem(STORAGE_KEY_BOOKMARKED);
if (!stored) return new Set();
const ids = JSON.parse(stored) as string[];
return new Set(ids);
} catch {
return new Set();
const session = sessionManager.getSession();
if (!session) return bookmarkedIds;
// Fetch published bookmark list event from cache/relays
const relays = relayManager.getProfileReadRelays();
const bookmarkLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.BOOKMARKS], authors: [session.pubkey], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
// Extract event IDs from published bookmark list
if (bookmarkLists.length > 0) {
const bookmarkList = bookmarkLists[0];
for (const tag of bookmarkList.tags) {
if (tag[0] === 'e' && tag[1]) {
bookmarkedIds.add(tag[1]);
}
// Note: a-tags would need to be resolved to get event IDs
// For now, we only support e-tags
}
}
} catch (error) {
console.debug('Error fetching bookmarked events:', error);
}
return bookmarkedIds;
}
/**
* Get all highlighted event IDs
* Get all highlighted event IDs (highlights are stored as kind 9802 events, not in a list)
* This function is kept for compatibility but highlights are actually stored as events
*/
export function getHighlightedEvents(): Set<string> {
if (typeof window === 'undefined') return new Set();
try {
const stored = localStorage.getItem(STORAGE_KEY_HIGHLIGHTED);
if (!stored) return new Set();
const ids = JSON.parse(stored) as string[];
return new Set(ids);
} catch {
return new Set();
export async function getHighlightedEvents(): Promise<Set<string>> {
// Highlights are stored as kind 9802 events, not in a list
// This function is kept for compatibility but returns empty set
// To get highlights, query for kind 9802 events by the user's pubkey
return new Set<string>();
}
// Cache for pinned events to avoid repeated async calls
let pinnedCache: Set<string> | null = null;
let pinnedCacheTime: number = 0;
const PINNED_CACHE_TTL = 5000; // 5 seconds
/**
* Check if an event is pinned (uses cached result if available)
*/
export async function isPinned(eventId: string): Promise<boolean> {
const now = Date.now();
if (pinnedCache && (now - pinnedCacheTime) < PINNED_CACHE_TTL) {
return pinnedCache.has(eventId);
}
pinnedCache = await getPinnedEvents();
pinnedCacheTime = now;
return pinnedCache.has(eventId);
}
/**
* Check if an event is pinned
* Invalidate pin cache (call after toggling pins)
*/
export function isPinned(eventId: string): boolean {
return getPinnedEvents().has(eventId);
function invalidatePinCache() {
pinnedCache = null;
pinnedCacheTime = 0;
}
// Cache for bookmarked events to avoid repeated async calls
let bookmarkedCache: Set<string> | null = null;
let bookmarkedCacheTime: number = 0;
const BOOKMARKED_CACHE_TTL = 5000; // 5 seconds
/**
* Check if an event is bookmarked (uses cached result if available)
*/
export async function isBookmarked(eventId: string): Promise<boolean> {
const now = Date.now();
if (bookmarkedCache && (now - bookmarkedCacheTime) < BOOKMARKED_CACHE_TTL) {
return bookmarkedCache.has(eventId);
}
bookmarkedCache = await getBookmarkedEvents();
bookmarkedCacheTime = now;
return bookmarkedCache.has(eventId);
}
/**
* Check if an event is bookmarked
* Invalidate bookmark cache (call after toggling bookmarks)
*/
export function isBookmarked(eventId: string): boolean {
return getBookmarkedEvents().has(eventId);
function invalidateBookmarkCache() {
bookmarkedCache = null;
bookmarkedCacheTime = 0;
}
/**
* Check if an event is highlighted
* Highlights are stored as kind 9802 events, not in a list
* This function is kept for compatibility but always returns false
*/
export function isHighlighted(eventId: string): boolean {
return getHighlightedEvents().has(eventId);
// Highlights are stored as kind 9802 events, not in a list
// To check if an event is highlighted, query for kind 9802 events that reference it
return false;
}
/**
* Toggle pin status of an event
* Updates localStorage and publishes kind 10001 list event
* Publishes kind 10001 list event (pins are stored in cache and on relays only)
*/
export async function togglePin(eventId: string): Promise<boolean> {
const pinned = getPinnedEvents();
const isCurrentlyPinned = pinned.has(eventId);
try {
const session = sessionManager.getSession();
if (!session) {
throw new Error('Not logged in');
}
// Get current pins from published event
const currentPins = await getPinnedEvents();
const isCurrentlyPinned = currentPins.has(eventId);
// Toggle the pin
if (isCurrentlyPinned) {
pinned.delete(eventId);
currentPins.delete(eventId);
} else {
pinned.add(eventId);
currentPins.add(eventId);
}
try {
localStorage.setItem(STORAGE_KEY_PINNED, JSON.stringify(Array.from(pinned)));
// Publish updated pin list event
await publishPinList(Array.from(currentPins));
// Publish list event if user is logged in
const session = sessionManager.getSession();
if (session) {
await publishPinList(Array.from(pinned));
}
// Invalidate cache so next read gets fresh data
invalidatePinCache();
return !isCurrentlyPinned;
} catch (error) {
console.error('Failed to save pinned events:', error);
return isCurrentlyPinned;
console.error('Failed to toggle pin:', error);
// Return current state on error
const currentPins = await getPinnedEvents();
return currentPins.has(eventId);
}
}
@ -218,31 +301,38 @@ async function publishPinList(eventIds: string[]): Promise<void> { @@ -218,31 +301,38 @@ async function publishPinList(eventIds: string[]): Promise<void> {
/**
* Toggle bookmark status of an event
* Updates localStorage and publishes kind 10003 list event
* Publishes kind 10003 list event (bookmarks are stored in cache and on relays only)
*/
export async function toggleBookmark(eventId: string): Promise<boolean> {
const bookmarked = getBookmarkedEvents();
const isCurrentlyBookmarked = bookmarked.has(eventId);
try {
const session = sessionManager.getSession();
if (!session) {
throw new Error('Not logged in');
}
// Get current bookmarks from published event
const currentBookmarks = await getBookmarkedEvents();
const isCurrentlyBookmarked = currentBookmarks.has(eventId);
// Toggle the bookmark
if (isCurrentlyBookmarked) {
bookmarked.delete(eventId);
currentBookmarks.delete(eventId);
} else {
bookmarked.add(eventId);
currentBookmarks.add(eventId);
}
try {
localStorage.setItem(STORAGE_KEY_BOOKMARKED, JSON.stringify(Array.from(bookmarked)));
// Publish updated bookmark list event
await publishBookmarkList(Array.from(currentBookmarks));
// Publish list event if user is logged in
const session = sessionManager.getSession();
if (session) {
await publishBookmarkList(Array.from(bookmarked));
}
// Invalidate cache so next read gets fresh data
invalidateBookmarkCache();
return !isCurrentlyBookmarked;
} catch (error) {
console.error('Failed to save bookmarked events:', error);
return isCurrentlyBookmarked;
console.error('Failed to toggle bookmark:', error);
// Return current state on error
const currentBookmarks = await getBookmarkedEvents();
return currentBookmarks.has(eventId);
}
}
@ -354,22 +444,347 @@ async function publishBookmarkList(eventIds: string[]): Promise<void> { @@ -354,22 +444,347 @@ async function publishBookmarkList(eventIds: string[]): Promise<void> {
/**
* Toggle highlight status of an event
* Highlights are stored as kind 9802 events, not in a list
* This function is kept for compatibility but does nothing
*/
export function toggleHighlight(eventId: string): boolean {
const highlighted = getHighlightedEvents();
const isCurrentlyHighlighted = highlighted.has(eventId);
// Highlights are stored as kind 9802 events, not in a list
// To create a highlight, publish a kind 9802 event
// This function is kept for compatibility but does nothing
return false;
}
/**
* Get all muted pubkeys from published kind 10000 event (from cache/relays)
*/
export async function getMutedPubkeys(): Promise<Set<string>> {
const mutedPubkeys = new Set<string>();
try {
const session = sessionManager.getSession();
if (!session) return mutedPubkeys;
// Fetch published mute list event from cache/relays
const relays = relayManager.getProfileReadRelays();
const muteLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.MUTE_LIST], authors: [session.pubkey], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
// Extract pubkeys from published mute list
if (muteLists.length > 0) {
const muteList = muteLists[0];
for (const tag of muteList.tags) {
if (tag[0] === 'p' && tag[1]) {
mutedPubkeys.add(tag[1]);
}
}
}
} catch (error) {
console.debug('Error fetching muted pubkeys:', error);
}
return mutedPubkeys;
}
// Cache for muted pubkeys to avoid repeated async calls
let mutedCache: Set<string> | null = null;
let mutedCacheTime: number = 0;
const MUTED_CACHE_TTL = 5000; // 5 seconds
/**
* Check if a pubkey is muted (uses cached result if available)
*/
export async function isMuted(pubkey: string): Promise<boolean> {
const now = Date.now();
if (mutedCache && (now - mutedCacheTime) < MUTED_CACHE_TTL) {
return mutedCache.has(pubkey);
}
mutedCache = await getMutedPubkeys();
mutedCacheTime = now;
return mutedCache.has(pubkey);
}
/**
* Invalidate mute cache (call after toggling mutes)
*/
function invalidateMuteCache() {
mutedCache = null;
mutedCacheTime = 0;
}
/**
* Toggle mute status of a user
* Publishes kind 10000 mute list event (mutes are stored in cache and on relays only)
*/
export async function toggleMute(pubkey: string): Promise<boolean> {
try {
const session = sessionManager.getSession();
if (!session) {
throw new Error('Not logged in');
}
if (isCurrentlyHighlighted) {
highlighted.delete(eventId);
// Get current mutes from published event
const currentMutes = await getMutedPubkeys();
const isCurrentlyMuted = currentMutes.has(pubkey);
// Toggle the mute
if (isCurrentlyMuted) {
currentMutes.delete(pubkey);
} else {
highlighted.add(eventId);
currentMutes.add(pubkey);
}
// Publish updated mute list event
await publishMuteList(Array.from(currentMutes));
// Invalidate cache so next read gets fresh data
invalidateMuteCache();
return !isCurrentlyMuted;
} catch (error) {
console.error('Failed to toggle mute:', error);
// Return current state on error
const currentMutes = await getMutedPubkeys();
return currentMutes.has(pubkey);
}
}
/**
* Publish mute list event (kind 10000)
*/
async function publishMuteList(pubkeys: string[]): Promise<void> {
try {
localStorage.setItem(STORAGE_KEY_HIGHLIGHTED, JSON.stringify(Array.from(highlighted)));
return !isCurrentlyHighlighted;
const session = sessionManager.getSession();
if (!session) return;
// Deduplicate pubkeys
const deduplicatedPubkeys = Array.from(new Set(pubkeys));
// Fetch existing mute list to merge with new entries
const relays = relayManager.getProfileReadRelays();
const existingLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.MUTE_LIST], authors: [session.pubkey], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
// Collect existing p tags
const existingPubkeys = new Set<string>();
if (existingLists.length > 0) {
const existingList = existingLists[0];
for (const tag of existingList.tags) {
if (tag[0] === 'p' && tag[1]) {
existingPubkeys.add(tag[1]);
}
}
}
// Check if we have any changes
const newPubkeys = deduplicatedPubkeys.filter(p => !existingPubkeys.has(p));
const removedPubkeys = [...existingPubkeys].filter(p => !deduplicatedPubkeys.includes(p));
if (newPubkeys.length === 0 && removedPubkeys.length === 0 && existingLists.length > 0) {
return; // No changes, cancel operation
}
// Build final tags: all p tags for muted pubkeys
const tags: string[][] = [];
for (const pubkey of deduplicatedPubkeys) {
tags.push(['p', pubkey]);
}
// Create new mute list event
const listEvent: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.MUTE_LIST,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: ''
};
// Publish to write relays
const writeRelays = relayManager.getPublishRelays(relays, true);
await signAndPublish(listEvent, writeRelays);
} catch (error) {
console.error('Failed to publish mute list:', error);
}
}
/**
* Get all followed pubkeys from published kind 3 event (from cache/relays)
*/
export async function getFollowedPubkeys(): Promise<Set<string>> {
const followedPubkeys = new Set<string>();
try {
const session = sessionManager.getSession();
if (!session) return followedPubkeys;
// Fetch published follow list event (kind 3) from cache/relays
const relays = relayManager.getProfileReadRelays();
const followLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.CONTACTS], authors: [session.pubkey], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
// Extract pubkeys from published follow list
if (followLists.length > 0) {
const followList = followLists[0];
for (const tag of followList.tags) {
if (tag[0] === 'p' && tag[1]) {
followedPubkeys.add(tag[1]);
}
}
}
} catch (error) {
console.debug('Error fetching followed pubkeys:', error);
}
return followedPubkeys;
}
// Cache for followed pubkeys to avoid repeated async calls
let followedCache: Set<string> | null = null;
let followedCacheTime: number = 0;
const FOLLOWED_CACHE_TTL = 5000; // 5 seconds
/**
* Check if a pubkey is followed (uses cached result if available)
*/
export async function isFollowed(pubkey: string): Promise<boolean> {
const now = Date.now();
if (followedCache && (now - followedCacheTime) < FOLLOWED_CACHE_TTL) {
return followedCache.has(pubkey);
}
followedCache = await getFollowedPubkeys();
followedCacheTime = now;
return followedCache.has(pubkey);
}
/**
* Invalidate follow cache (call after toggling follows)
*/
function invalidateFollowCache() {
followedCache = null;
followedCacheTime = 0;
}
/**
* Toggle follow status of a user
* Publishes kind 3 follow list event (follows are stored in cache and on relays only)
*/
export async function toggleFollow(pubkey: string): Promise<boolean> {
try {
const session = sessionManager.getSession();
if (!session) {
throw new Error('Not logged in');
}
// Get current follows from published event
const currentFollows = await getFollowedPubkeys();
const isCurrentlyFollowed = currentFollows.has(pubkey);
// Toggle the follow
if (isCurrentlyFollowed) {
currentFollows.delete(pubkey);
} else {
currentFollows.add(pubkey);
}
// Publish updated follow list event
await publishFollowList(Array.from(currentFollows));
// Invalidate cache so next read gets fresh data
invalidateFollowCache();
return !isCurrentlyFollowed;
} catch (error) {
console.error('Failed to toggle follow:', error);
// Return current state on error
const currentFollows = await getFollowedPubkeys();
return currentFollows.has(pubkey);
}
}
/**
* Publish follow list event (kind 3)
*/
async function publishFollowList(pubkeys: string[]): Promise<void> {
try {
const session = sessionManager.getSession();
if (!session) return;
// Deduplicate pubkeys
const deduplicatedPubkeys = Array.from(new Set(pubkeys));
// Fetch existing follow list to merge with new entries
const relays = relayManager.getProfileReadRelays();
const existingLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.CONTACTS], authors: [session.pubkey], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
// Collect existing p tags
const existingPubkeys = new Set<string>();
const existingPTags: string[][] = []; // Store full p tags to preserve relay hints and petnames
if (existingLists.length > 0) {
const existingList = existingLists[0];
for (const tag of existingList.tags) {
if (tag[0] === 'p' && tag[1]) {
existingPubkeys.add(tag[1]);
existingPTags.push(tag);
}
}
}
// Check if we have any changes
const newPubkeys = deduplicatedPubkeys.filter(p => !existingPubkeys.has(p));
const removedPubkeys = [...existingPubkeys].filter(p => !deduplicatedPubkeys.includes(p));
if (newPubkeys.length === 0 && removedPubkeys.length === 0 && existingLists.length > 0) {
return; // No changes, cancel operation
}
// Build final tags: preserve existing p tags for pubkeys we're keeping, add new ones
const tags: string[][] = [];
const seenPubkeys = new Set<string>();
// First, add existing p tags for pubkeys we're keeping
for (const tag of existingPTags) {
if (tag[1] && deduplicatedPubkeys.includes(tag[1])) {
tags.push(tag);
seenPubkeys.add(tag[1]);
}
}
// Then, add new p tags for pubkeys we're adding (without relay hints or petnames)
for (const pubkey of deduplicatedPubkeys) {
if (!seenPubkeys.has(pubkey)) {
tags.push(['p', pubkey]);
}
}
// Create new follow list event
const listEvent: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.CONTACTS,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: ''
};
// Publish to write relays
const writeRelays = relayManager.getPublishRelays(relays, true);
await signAndPublish(listEvent, writeRelays);
} catch (error) {
console.error('Failed to save highlighted events:', error);
return isCurrentlyHighlighted;
console.error('Failed to publish follow list:', error);
}
}

8
src/lib/types/kind-lookup.ts

@ -95,8 +95,8 @@ export const KIND = { @@ -95,8 +95,8 @@ export const KIND = {
export const KIND_LOOKUP: Record<number, KindInfo> = {
// Core kinds
[KIND.SHORT_TEXT_NOTE]: { number: KIND.SHORT_TEXT_NOTE, description: 'Short Text Note', showInFeed: true },
[KIND.CONTACTS]: { number: KIND.CONTACTS, description: 'Public Message', showInFeed: true, isSecondaryKind: false },
[KIND.SHORT_TEXT_NOTE]: { number: KIND.SHORT_TEXT_NOTE, description: 'Short Text Note', showInFeed: true, isSecondaryKind: false },
[KIND.CONTACTS]: { number: KIND.CONTACTS, description: 'Public Message', showInFeed: false, isSecondaryKind: false },
[KIND.EVENT_DELETION]: { number: KIND.EVENT_DELETION, description: 'Event Deletion', showInFeed: false, isSecondaryKind: false },
[KIND.REACTION]: { number: KIND.REACTION, description: 'Reaction', showInFeed: false, isSecondaryKind: true },
@ -105,7 +105,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = { @@ -105,7 +105,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
[KIND.HIGHLIGHTED_ARTICLE]: { number: KIND.HIGHLIGHTED_ARTICLE, description: 'Highlighted Article', showInFeed: true, isSecondaryKind: false },
// Threads and comments
[KIND.DISCUSSION_THREAD]: { number: KIND.DISCUSSION_THREAD, description: 'Discussion Thread', showInFeed: false, isSecondaryKind: false },
[KIND.DISCUSSION_THREAD]: { number: KIND.DISCUSSION_THREAD, description: 'Discussion Thread', showInFeed: true, isSecondaryKind: false },
[KIND.COMMENT]: { number: KIND.COMMENT, description: 'Comment', showInFeed: true, isSecondaryKind: true },
// Media
@ -113,7 +113,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = { @@ -113,7 +113,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
[KIND.VIDEO_NOTE]: { number: KIND.VIDEO_NOTE, description: 'Video Note', showInFeed: true, isSecondaryKind: false },
[KIND.SHORT_VIDEO_NOTE]: { number: KIND.SHORT_VIDEO_NOTE, description: 'Short Video Note', showInFeed: true, isSecondaryKind: false },
[KIND.VOICE_NOTE]: { number: KIND.VOICE_NOTE, description: 'Voice Note (Yak)', showInFeed: true, isSecondaryKind: false },
[KIND.VOICE_REPLY]: { number: KIND.VOICE_REPLY, description: 'Voice Reply (Yak Back)', showInFeed: false, isSecondaryKind: true },
[KIND.VOICE_REPLY]: { number: KIND.VOICE_REPLY, description: 'Voice Reply (Yak Back)', showInFeed: true, isSecondaryKind: false },
[KIND.FILE_METADATA]: { number: KIND.FILE_METADATA, description: 'File Metadata (GIFs)', showInFeed: false, isSecondaryKind: false },
// Polls

Loading…
Cancel
Save