Browse Source

Merge branch 'master' into feature/text-entry

master
limina1 7 months ago
parent
commit
9d5fc82a15
  1. 2
      .cursor/rules/alexandria.mdc
  2. 29
      src/app.css
  3. 25
      src/lib/components/CommentBox.svelte
  4. 231
      src/lib/components/CommentViewer.svelte
  5. 121
      src/lib/components/EventDetails.svelte
  6. 2
      src/lib/components/EventInput.svelte
  7. 8
      src/lib/components/EventSearch.svelte
  8. 348
      src/lib/components/Notifications.svelte
  9. 7
      src/lib/components/cards/BlogHeader.svelte
  10. 56
      src/lib/components/cards/ProfileHeader.svelte
  11. 738
      src/lib/components/embedded_events/EmbeddedEvent.svelte
  12. 2
      src/lib/components/event_input/EventForm.svelte
  13. 2
      src/lib/components/event_input/EventPreview.svelte
  14. 2
      src/lib/components/event_input/TagManager.svelte
  15. 6
      src/lib/components/publications/Publication.svelte
  16. 22
      src/lib/components/publications/PublicationHeader.svelte
  17. 4
      src/lib/components/publications/PublicationSection.svelte
  18. 4
      src/lib/components/publications/table_of_contents.svelte.ts
  19. 16
      src/lib/components/util/ArticleNav.svelte
  20. 8
      src/lib/components/util/CardActions.svelte
  21. 13
      src/lib/components/util/Details.svelte
  22. 2
      src/lib/components/util/Profile.svelte
  23. 22
      src/lib/data_structures/publication_tree.ts
  24. 1
      src/lib/models/user_profile.d.ts
  25. 34
      src/lib/navigator/EventNetwork/Legend.svelte
  26. 29
      src/lib/navigator/EventNetwork/index.svelte
  27. 6
      src/lib/ndk.ts
  28. 2
      src/lib/services/event_search_service.ts
  29. 2
      src/lib/services/search_state_manager.ts
  30. 32
      src/lib/snippets/EmbeddedSnippets.svelte
  31. 16
      src/lib/snippets/MarkupSnippets.svelte
  32. 12
      src/lib/snippets/UserSnippets.svelte
  33. 2
      src/lib/stores/userStore.ts
  34. 4
      src/lib/utils/event_search.ts
  35. 11
      src/lib/utils/image_utils.ts
  36. 4
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  37. 8
      src/lib/utils/markup/advancedMarkupParser.ts
  38. 53
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  39. 246
      src/lib/utils/markup/basicMarkupParser.ts
  40. 241
      src/lib/utils/markup/embeddedMarkupParser.ts
  41. 321
      src/lib/utils/markup/markupServices.ts
  42. 629
      src/lib/utils/markup/markupUtils.ts
  43. 12
      src/lib/utils/mime.ts
  44. 27
      src/lib/utils/nostrUtils.ts
  45. 1
      src/lib/utils/npubCache.ts
  46. 8
      src/lib/utils/profile_search.ts
  47. 109
      src/lib/utils/relayDiagnostics.ts
  48. 11
      src/lib/utils/search_constants.ts
  49. 2
      src/lib/utils/search_result_formatter.ts
  50. 7
      src/lib/utils/search_types.ts
  51. 6
      src/lib/utils/search_utils.ts
  52. 676
      src/lib/utils/subscription_search.ts
  53. 2
      src/lib/utils/websocket_utils.ts
  54. 2
      src/routes/+layout.svelte
  55. 6
      src/routes/about/+page.svelte
  56. 1
      src/routes/contact/+page.svelte
  57. 591
      src/routes/events/+page.svelte
  58. 1
      src/routes/my-notes/+page.svelte
  59. 29
      src/routes/visualize/+page.svelte
  60. 2
      tailwind.config.cjs

2
.cursor/rules/alexandria.mdc

@ -42,10 +42,10 @@ NEVER assume developer intent. If you are unsure about something, ALWAYS stop an
- Use anchor comments prefixed with `AI-NOTE:`, `AI-TODO:`, or `AI-QUESTION:` to share context between AI agents and developers across time. - Use anchor comments prefixed with `AI-NOTE:`, `AI-TODO:`, or `AI-QUESTION:` to share context between AI agents and developers across time.
- Use all-caps prefixes. - Use all-caps prefixes.
- Also _read_ (but do not write) variants of this format that begin with `AI-<date>:` where `<date>` is some date in `MM/DD/YYYY` format. Anchor comments with this format are used by developers to record context.
- **Important:** Before scanning files, ALWAYS search first for `AI-` anchor comments in relevant subdirectories. - **Important:** Before scanning files, ALWAYS search first for `AI-` anchor comments in relevant subdirectories.
- ALWAYS update relevant anchor comments when modifying associated code. - ALWAYS update relevant anchor comments when modifying associated code.
- NEVER remove `AI-` comments unless the developer explicitly instructs it. - NEVER remove `AI-` comments unless the developer explicitly instructs it.
- Don't add a date, as it's always the wrong date.
- Add new anchor comments as relevant when: - Add new anchor comments as relevant when:
- Code is unusually complex. - Code is unusually complex.
- Code is critical to security, performance, or functionality. - Code is critical to security, performance, or functionality.

29
src/app.css

@ -98,6 +98,15 @@
@apply text-gray-900 dark:text-gray-100; @apply text-gray-900 dark:text-gray-100;
} }
/* Responsive card styles */
.responsive-card {
@apply w-full min-w-0 overflow-hidden;
}
.responsive-card-content {
@apply break-words overflow-hidden;
}
h1.h-leather { h1.h-leather {
@apply text-4xl font-bold; @apply text-4xl font-bold;
} }
@ -588,4 +597,24 @@
.prose-invert p:first-line { .prose-invert p:first-line {
font-weight: normal !important; font-weight: normal !important;
} }
/* Prevent first-line indentation in prose content */
.prose p,
.prose-sm p,
.prose-invert p {
text-indent: 0 !important;
}
/* Ensure embedded event content doesn't have unwanted indentation */
.embedded-event .prose p,
.embedded-event .prose-sm p,
.embedded-event .prose-invert p {
text-indent: 0 !important;
margin: 0 !important;
}
/* Prevent indentation for paragraphs with no-indent class */
.no-indent {
text-indent: 0 !important;
}
} }

25
src/lib/components/CommentBox.svelte

@ -1,16 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Button, Textarea, Alert, Modal, Input } from "flowbite-svelte"; import { Button, Textarea, Alert, Modal, Input } from "flowbite-svelte";
import { UserOutline } from "flowbite-svelte-icons"; import { UserOutline } from "flowbite-svelte-icons";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { toNpub, getUserMetadata } from "$lib/utils/nostrUtils"; import { toNpub } from "$lib/utils/nostrUtils";
import { searchProfiles } from "$lib/utils/search_utility"; import { searchProfiles } from "$lib/utils/search_utility";
import type { import type { NostrProfile } from "$lib/utils/search_types";
NostrProfile,
ProfileSearchResult,
} from "$lib/utils/search_utility";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { import {
@ -19,11 +13,11 @@
buildReplyTags, buildReplyTags,
createSignedEvent, createSignedEvent,
publishEvent, publishEvent,
navigateToEvent,
} from "$lib/utils/nostrEventService"; } from "$lib/utils/nostrEventService";
import { tick } from "svelte"; import { tick } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
const props = $props<{ const props = $props<{
event: NDKEvent; event: NDKEvent;
@ -33,7 +27,6 @@
const ndk = getNdkContext(); const ndk = getNdkContext();
let content = $state(""); let content = $state("");
let preview = $state("");
let isSubmitting = $state(false); let isSubmitting = $state(false);
let success = $state<{ relay: string; eventId: string } | null>(null); let success = $state<{ relay: string; eventId: string } | null>(null);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -87,7 +80,6 @@
if (!success) return; if (!success) return;
content = ""; content = "";
preview = "";
}); });
// Markup buttons // Markup buttons
@ -131,7 +123,6 @@
selectedText + selectedText +
suffix + suffix +
content.substring(end); content.substring(end);
updatePreview();
// Set cursor position after the inserted markup // Set cursor position after the inserted markup
setTimeout(() => { setTimeout(() => {
@ -141,13 +132,9 @@
}, 0); }, 0);
} }
async function updatePreview() {
preview = await parseBasicmarkup(content);
}
function clearForm() { function clearForm() {
content = ""; content = "";
preview = "";
error = null; error = null;
showOtherRelays = false; showOtherRelays = false;
showSecondaryRelays = false; showSecondaryRelays = false;
@ -164,7 +151,6 @@
.replace(/^[-*]\s*/gm, "") .replace(/^[-*]\s*/gm, "")
.replace(/^\d+\.\s*/gm, "") .replace(/^\d+\.\s*/gm, "")
.replace(/#(\w+)/g, "$1"); .replace(/#(\w+)/g, "$1");
updatePreview();
} }
async function handleSubmit( async function handleSubmit(
@ -227,7 +213,6 @@
// Clear form after successful submission // Clear form after successful submission
content = ""; content = "";
preview = "";
showOtherRelays = false; showOtherRelays = false;
showSecondaryRelays = false; showSecondaryRelays = false;
} catch (e) { } catch (e) {
@ -251,7 +236,6 @@
const end = textarea.selectionEnd; const end = textarea.selectionEnd;
content = content.substring(0, start) + text + content.substring(end); content = content.substring(0, start) + text + content.substring(end);
updatePreview();
// Wait for DOM updates to complete // Wait for DOM updates to complete
await tick(); await tick();
@ -578,7 +562,6 @@
<div> <div>
<Textarea <Textarea
bind:value={content} bind:value={content}
on:input={updatePreview}
placeholder="Write your comment..." placeholder="Write your comment..."
rows={10} rows={10}
class="w-full" class="w-full"
@ -587,7 +570,7 @@
<div <div
class="prose dark:prose-invert max-w-none p-4 border border-gray-300 dark:border-gray-700 rounded-lg" class="prose dark:prose-invert max-w-none p-4 border border-gray-300 dark:border-gray-700 rounded-lg"
> >
{@html preview} {@render basicMarkup(content, ndk)}
</div> </div>
</div> </div>

231
src/lib/components/CommentViewer.svelte

@ -5,17 +5,17 @@
import { activeInboxRelays, getNdkContext } from "$lib/ndk"; import { activeInboxRelays, getNdkContext } from "$lib/ndk";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import EmbeddedEvent from "./embedded_events/EmbeddedEvent.svelte"; import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
const { event } = $props<{ event: NDKEvent }>(); const { event } = $props<{ event: NDKEvent }>();
const ndk = getNdkContext(); const ndk = getNdkContext();
// AI-NOTE: 2025-01-08 - Clean, efficient comment viewer implementation // AI-NOTE: Clean, efficient comment viewer implementation
// This component fetches and displays threaded comments with proper hierarchy // This component fetches and displays threaded comments with proper hierarchy
// Uses simple, reliable profile fetching and efficient state management // Uses simple, reliable profile fetching and efficient state management
// AI-NOTE: 2025-01-24 - Added support for kind 9802 highlights (NIP-84) // AI-NOTE: Added support for kind 9802 highlights (NIP-84)
// Highlights are displayed with special styling and include source attribution // Highlights are displayed with special styling and include source attribution
// State management // State management
@ -24,6 +24,8 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let profiles = $state(new Map<string, any>()); let profiles = $state(new Map<string, any>());
let activeSub: any = null; let activeSub: any = null;
let isFetching = $state(false); // Track if we're currently fetching to prevent duplicate fetches
let retryCount = $state(0); // Track retry attempts for failed fetches
interface CommentNode { interface CommentNode {
event: NDKEvent; event: NDKEvent;
@ -64,9 +66,17 @@
async function fetchComments() { async function fetchComments() {
if (!event?.id) return; if (!event?.id) return;
// AI-NOTE: Prevent duplicate fetches for the same event
if (isFetching) {
console.log(`[CommentViewer] Already fetching comments, skipping`);
return;
}
isFetching = true;
loading = true; loading = true;
error = null; error = null;
comments = []; comments = [];
retryCount = 0; // Reset retry count for new event
console.log(`[CommentViewer] Fetching comments for event: ${event.id}`); console.log(`[CommentViewer] Fetching comments for event: ${event.id}`);
console.log(`[CommentViewer] Event kind: ${event.kind}`); console.log(`[CommentViewer] Event kind: ${event.kind}`);
@ -98,67 +108,26 @@
console.log(`[CommentViewer] Event address for NIP-22: ${eventAddress}`); console.log(`[CommentViewer] Event address for NIP-22: ${eventAddress}`);
// Use more targeted filters to reduce noise // AI-NOTE: Use a single comprehensive filter to ensure all comments are found
const filters = [ // Multiple filters can cause issues with NDK subscription handling
// Primary filter: events that explicitly reference our target via e-tags const filter = {
{ kinds: [1, 1111, 9802],
kinds: [1, 1111, 9802], "#e": [event.id],
"#e": [event.id], limit: 100, // Increased limit to ensure we get all comments
limit: 50, };
}
];
// Add NIP-22 filter only if we have a valid event address
if (eventAddress) {
filters.push({
kinds: [1111, 9802],
"#a": [eventAddress],
limit: 50,
} as any);
}
console.log(`[CommentViewer] Setting up subscription with ${filters.length} filters:`, filters);
// Debug: Check if the provided event would match our filters console.log(`[CommentViewer] Setting up subscription with filter:`, filter);
console.log(`[CommentViewer] Debug: Checking if event b9a15298f2b203d42ba6d0c56c43def87efc887697460c0febb9542515d5a00b would match our filters`); console.log(`[CommentViewer] Target event ID: ${event.id}`);
console.log(`[CommentViewer] Debug: Target event ID: ${event.id}`); console.log(`[CommentViewer] Event address: ${eventAddress}`);
console.log(`[CommentViewer] Debug: Event address: ${eventAddress}`);
// Get all available relays for a more comprehensive search // Use the full NDK pool relays for comprehensive search
// Use the full NDK pool relays instead of just active relays
const ndkPoolRelays = Array.from(ndk.pool.relays.values()).map(relay => relay.url); const ndkPoolRelays = Array.from(ndk.pool.relays.values()).map(relay => relay.url);
console.log(`[CommentViewer] Using ${ndkPoolRelays.length} NDK pool relays for search:`, ndkPoolRelays); console.log(`[CommentViewer] Using ${ndkPoolRelays.length} NDK pool relays for search:`, ndkPoolRelays);
// Try all filters to find comments with full relay set // Subscribe with single filter
activeSub = ndk.subscribe(filters); activeSub = ndk.subscribe(filter);
// Also try a direct search for the specific comment we're looking for
console.log(`[CommentViewer] Also searching for specific comment: 64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942`);
const specificCommentSub = ndk.subscribe({
ids: ["64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942"]
});
specificCommentSub.on("event", (specificEvent: NDKEvent) => {
console.log(`[CommentViewer] Found specific comment via direct search:`, specificEvent.id);
console.log(`[CommentViewer] Specific comment tags:`, specificEvent.tags);
// Check if this specific comment references our target
const eTags = specificEvent.getMatchingTags("e");
const aTags = specificEvent.getMatchingTags("a");
console.log(`[CommentViewer] Specific comment e-tags:`, eTags.map(t => t[1]));
console.log(`[CommentViewer] Specific comment a-tags:`, aTags.map(t => t[1]));
const hasETag = eTags.some(tag => tag[1] === event.id);
const hasATag = eventAddress ? aTags.some(tag => tag[1] === eventAddress) : false;
console.log(`[CommentViewer] Specific comment has matching e-tag: ${hasETag}`);
console.log(`[CommentViewer] Specific comment has matching a-tag: ${hasATag}`);
});
specificCommentSub.on("eose", () => {
console.log(`[CommentViewer] Specific comment search EOSE`);
specificCommentSub.stop();
});
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
console.log(`[CommentViewer] Subscription timeout - no comments found`); console.log(`[CommentViewer] Subscription timeout - no comments found`);
@ -167,6 +136,7 @@
activeSub = null; activeSub = null;
} }
loading = false; loading = false;
isFetching = false;
}, 10000); }, 10000);
activeSub.on("event", (commentEvent: NDKEvent) => { activeSub.on("event", (commentEvent: NDKEvent) => {
@ -175,12 +145,6 @@
console.log(`[CommentViewer] Comment pubkey: ${commentEvent.pubkey}`); console.log(`[CommentViewer] Comment pubkey: ${commentEvent.pubkey}`);
console.log(`[CommentViewer] Comment content preview: ${commentEvent.content?.slice(0, 100)}...`); console.log(`[CommentViewer] Comment content preview: ${commentEvent.content?.slice(0, 100)}...`);
// Special debug for the specific comment we're looking for
if (commentEvent.id === "64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942") {
console.log(`[CommentViewer] DEBUG: Found the specific comment we're looking for!`);
console.log(`[CommentViewer] DEBUG: Comment tags:`, commentEvent.tags);
}
// Check if this event actually references our target event // Check if this event actually references our target event
let referencesTarget = false; let referencesTarget = false;
let referenceMethod = ""; let referenceMethod = "";
@ -211,7 +175,9 @@
if (referencesTarget) { if (referencesTarget) {
console.log(`[CommentViewer] Comment references target event via ${referenceMethod} - adding to comments`); console.log(`[CommentViewer] Comment references target event via ${referenceMethod} - adding to comments`);
comments = [...comments, commentEvent]; // AI-NOTE: Use immutable update to prevent UI flashing
const newComments = [...comments, commentEvent];
comments = newComments;
fetchProfile(commentEvent.pubkey); fetchProfile(commentEvent.pubkey);
// Fetch nested replies for this comment // Fetch nested replies for this comment
@ -234,18 +200,26 @@
activeSub = null; activeSub = null;
} }
loading = false; loading = false;
isFetching = false;
retryCount = 0; // Reset retry count on successful fetch
// Pre-fetch all profiles after comments are loaded // Pre-fetch all profiles after comments are loaded
preFetchAllProfiles(); preFetchAllProfiles();
// AI-NOTE: 2025-01-24 - Fetch nested replies for all found comments // AI-NOTE: Fetch nested replies for all found comments
comments.forEach(comment => { comments.forEach(comment => {
fetchNestedReplies(comment.id); fetchNestedReplies(comment.id);
}); });
// AI-NOTE: 2025-01-24 - Test for comments if none were found // AI-NOTE: If no comments found and we haven't retried too many times, try again
if (comments.length === 0) { if (comments.length === 0 && retryCount < 2) {
testForComments(); console.log(`[CommentViewer] No comments found, retrying... (attempt ${retryCount + 1})`);
retryCount++;
setTimeout(() => {
if (!isFetching) {
fetchComments();
}
}, 2000); // Wait 2 seconds before retry
} }
}); });
@ -258,12 +232,14 @@
} }
error = "Error fetching comments"; error = "Error fetching comments";
loading = false; loading = false;
isFetching = false;
}); });
} catch (err) { } catch (err) {
console.error(`[CommentViewer] Error setting up subscription:`, err); console.error(`[CommentViewer] Error setting up subscription:`, err);
error = "Error setting up subscription"; error = "Error setting up subscription";
loading = false; loading = false;
isFetching = false;
} }
} }
@ -285,51 +261,7 @@
console.log(`[CommentViewer] Pre-fetching complete`); console.log(`[CommentViewer] Pre-fetching complete`);
} }
// AI-NOTE: 2025-01-24 - Function to manually test for comments
async function testForComments() {
if (!event?.id) return;
console.log(`[CommentViewer] Testing for comments on event: ${event.id}`);
try {
// Try a broader search to see if there are any events that might be comments
const testSub = ndk.subscribe({
kinds: [1, 1111, 9802],
"#e": [event.id],
limit: 10,
});
let testComments = 0;
testSub.on("event", (testEvent: NDKEvent) => {
testComments++;
console.log(`[CommentViewer] Test found event: ${testEvent.id}, kind: ${testEvent.kind}, content: ${testEvent.content?.slice(0, 50)}...`);
// Special debug for the specific comment we're looking for
if (testEvent.id === "64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942") {
console.log(`[CommentViewer] DEBUG: Test found the specific comment we're looking for!`);
console.log(`[CommentViewer] DEBUG: Test comment tags:`, testEvent.tags);
}
// Show the e-tags to help debug
const eTags = testEvent.getMatchingTags("e");
console.log(`[CommentViewer] Test event e-tags:`, eTags.map(t => t[1]));
});
testSub.on("eose", () => {
console.log(`[CommentViewer] Test search found ${testComments} potential comments`);
testSub.stop();
});
// Stop the test after 5 seconds
setTimeout(() => {
testSub.stop();
}, 5000);
} catch (err) {
console.error(`[CommentViewer] Test search error:`, err);
}
}
// Build threaded comment structure // Build threaded comment structure
function buildCommentThread(events: NDKEvent[]): CommentNode[] { function buildCommentThread(events: NDKEvent[]): CommentNode[] {
@ -433,19 +365,57 @@
// Derived value for threaded comments // Derived value for threaded comments
let threadedComments = $derived(buildCommentThread(comments)); let threadedComments = $derived(buildCommentThread(comments));
// Fetch comments when event changes // AI-NOTE: Comment feed update issue when navigating via e-tags
// When clicking e-tags in EventDetails, the comment feed sometimes doesn't update properly
// This can manifest as:
// 1. Empty comment feed even when comments exist
// 2. Flash between nested and flat thread views
// 3. Delayed comment loading
//
// Potential causes:
// - Race condition between event prop change and comment fetching
// - Subscription cleanup timing issues
// - Nested reply fetching interfering with main comment display
// - Relay availability or timeout issues
//
// TODO: Consider adding a small delay before fetching comments to ensure
// the event prop has fully settled, or implement a more robust state
// management system for comment fetching
$effect(() => { $effect(() => {
if (event?.id) { if (event?.id) {
console.log(`[CommentViewer] Event changed, fetching comments for:`, event.id, `kind:`, event.kind); console.log(`[CommentViewer] Event changed, fetching comments for:`, event.id, `kind:`, event.kind);
// AI-NOTE: Clean up previous subscription and reset state
if (activeSub) { if (activeSub) {
activeSub.stop(); activeSub.stop();
activeSub = null; activeSub = null;
} }
fetchComments();
// Reset state for new event
comments = [];
profiles = new Map();
nestedReplyIds = new Set();
isFetchingNestedReplies = false;
retryCount = 0;
// AI-NOTE: Add small delay to prevent race conditions during navigation
setTimeout(() => {
if (event?.id && !isFetching) { // Double-check we're not already fetching
fetchComments();
}
}, 100);
} else {
// Clear state when no event
comments = [];
profiles = new Map();
nestedReplyIds = new Set();
isFetchingNestedReplies = false;
isFetching = false;
retryCount = 0;
} }
}); });
// AI-NOTE: 2025-01-24 - Add recursive comment fetching for nested replies // AI-NOTE: Add recursive comment fetching for nested replies
let isFetchingNestedReplies = $state(false); let isFetchingNestedReplies = $state(false);
let nestedReplyIds = $state<Set<string>>(new Set()); let nestedReplyIds = $state<Set<string>>(new Set());
@ -483,7 +453,9 @@
if (referencesTarget && !comments.some(c => c.id === nestedEvent.id)) { if (referencesTarget && !comments.some(c => c.id === nestedEvent.id)) {
console.log(`[CommentViewer] Adding nested reply to comments`); console.log(`[CommentViewer] Adding nested reply to comments`);
comments = [...comments, nestedEvent]; // AI-NOTE: Use immutable update to prevent UI flashing
const newComments = [...comments, nestedEvent];
comments = newComments;
fetchProfile(nestedEvent.pubkey); fetchProfile(nestedEvent.pubkey);
// Recursively fetch replies to this nested reply // Recursively fetch replies to this nested reply
@ -524,7 +496,9 @@
if (referencesTarget && !comments.some(c => c.id === nip22Event.id)) { if (referencesTarget && !comments.some(c => c.id === nip22Event.id)) {
console.log(`[CommentViewer] Adding NIP-22 nested reply to comments`); console.log(`[CommentViewer] Adding NIP-22 nested reply to comments`);
comments = [...comments, nip22Event]; // AI-NOTE: Use immutable update to prevent UI flashing
const newComments = [...comments, nip22Event];
comments = newComments;
fetchProfile(nip22Event.pubkey); fetchProfile(nip22Event.pubkey);
// Recursively fetch replies to this nested reply // Recursively fetch replies to this nested reply
@ -573,7 +547,7 @@
} }
} }
// AI-NOTE: 2025-01-24 - View button functionality is working correctly // AI-NOTE: View button functionality is working correctly
// This function navigates to the specific event as the main event, allowing // This function navigates to the specific event as the main event, allowing
// users to view replies as the primary content // users to view replies as the primary content
function navigateToComment(commentEvent: NDKEvent) { function navigateToComment(commentEvent: NDKEvent) {
@ -654,7 +628,7 @@
return `${actualLevel * 16}px`; return `${actualLevel * 16}px`;
} }
// AI-NOTE: 2025-01-24 - Get highlight source information // AI-NOTE: Get highlight source information
function getHighlightSource(highlightEvent: NDKEvent): { type: string; value: string; url?: string } | null { function getHighlightSource(highlightEvent: NDKEvent): { type: string; value: string; url?: string } | null {
// Check for e-tags (nostr events) // Check for e-tags (nostr events)
const eTags = highlightEvent.getMatchingTags("e"); const eTags = highlightEvent.getMatchingTags("e");
@ -671,7 +645,7 @@
return null; return null;
} }
// AI-NOTE: 2025-01-24 - Get highlight attribution // AI-NOTE: Get highlight attribution
function getHighlightAttribution(highlightEvent: NDKEvent): Array<{ pubkey: string; role?: string }> { function getHighlightAttribution(highlightEvent: NDKEvent): Array<{ pubkey: string; role?: string }> {
const pTags = highlightEvent.getMatchingTags("p"); const pTags = highlightEvent.getMatchingTags("p");
return pTags.map(tag => ({ return pTags.map(tag => ({
@ -680,7 +654,7 @@
})); }));
} }
// AI-NOTE: 2025-01-24 - Check if highlight has comment // AI-NOTE: Check if highlight has comment
function hasHighlightComment(highlightEvent: NDKEvent): boolean { function hasHighlightComment(highlightEvent: NDKEvent): boolean {
return highlightEvent.getMatchingTags("comment").length > 0; return highlightEvent.getMatchingTags("comment").length > 0;
} }
@ -688,6 +662,7 @@
<!-- Recursive Comment Item Component --> <!-- Recursive Comment Item Component -->
{#snippet CommentItem(node: CommentNode)} {#snippet CommentItem(node: CommentNode)}
{@const comment = node.event.getMatchingTags("comment")[0]?.[1] || "No comment content"}
<div class="mb-4"> <div class="mb-4">
<div <div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 break-words" class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 break-words"
@ -772,7 +747,9 @@
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2"> <div class="text-sm text-gray-600 dark:text-gray-400 mb-2">
<span class="font-medium">Comment:</span> <span class="font-medium">Comment:</span>
</div> </div>
<EmbeddedEvent nostrIdentifier={node.event.getMatchingTags("comment")[0]?.[1]} nestingLevel={0} /> <div class="text-sm text-gray-700 dark:text-gray-300">
{@render basicMarkup(comment, ndk)}
</div>
</div> </div>
{:else} {:else}
<!-- Simple highlight --> <!-- Simple highlight -->
@ -812,7 +789,9 @@
</div> </div>
{:else} {:else}
<!-- Regular comment content --> <!-- Regular comment content -->
<EmbeddedEvent nostrIdentifier={node.event.id} nestingLevel={0} /> <div class="text-sm text-gray-700 dark:text-gray-300">
{@render basicMarkup(node.event.content || "No content", ndk)}
</div>
{/if} {/if}
</div> </div>
</div> </div>

121
src/lib/components/EventDetails.svelte

@ -14,20 +14,31 @@
import { navigateToEvent } from "$lib/utils/nostrEventService"; import { navigateToEvent } from "$lib/utils/nostrEventService";
import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte"; import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte";
import Notifications from "$lib/components/Notifications.svelte"; import Notifications from "$lib/components/Notifications.svelte";
import EmbeddedEvent from "./embedded_events/EmbeddedEvent.svelte"; import {
repostContent,
quotedContent,
} from "$lib/snippets/EmbeddedSnippets.svelte";
import { repostKinds } from "$lib/consts";
import { getNdkContext } from "$lib/ndk";
import type { UserProfile } from "$lib/models/user_profile"; import type { UserProfile } from "$lib/models/user_profile";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
const { const {
event, event,
profile = null, profile = null,
communityStatusMap = {},
} = $props<{ } = $props<{
event: NDKEvent; event: NDKEvent;
profile?: UserProfile | null; profile?: UserProfile | null;
communityStatusMap?: Record<string, boolean>;
}>(); }>();
const ndk = getNdkContext();
let authorDisplayName = $state<string | undefined>(undefined); let authorDisplayName = $state<string | undefined>(undefined);
let showFullContent = $state(false); let showFullContent = $state(false);
let shouldTruncate = $derived(event.content.length > 250 && !showFullContent); let shouldTruncate = $derived(event.content.length > 250 && !showFullContent);
let isRepost = $derived(repostKinds.includes(event.kind) || (event.kind === 1 && event.getMatchingTags("q").length > 0));
function getEventTitle(event: NDKEvent): string { function getEventTitle(event: NDKEvent): string {
// First try to get title from title tag // First try to get title from title tag
@ -70,7 +81,7 @@
} }
function getEventTypeDisplay(event: NDKEvent): string { function getEventTypeDisplay(event: NDKEvent): string {
const [mTag, MTag] = getMimeTags(event.kind || 0); const [_, MTag] = getMimeTags(event.kind || 0);
return MTag[1].split("/")[1] || `Event Kind ${event.kind}`; return MTag[1].split("/")[1] || `Event Kind ${event.kind}`;
} }
@ -178,6 +189,31 @@
text: `t:${tag[1]}`, text: `t:${tag[1]}`,
gotoValue: `t:${tag[1]}`, gotoValue: `t:${tag[1]}`,
}; };
} else if (tag[0] === "q" && tag.length > 1) {
// 'q' tags are quoted events - navigate to the quoted event
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, $activeInboxRelays);
return {
text: `q:${tag[1]}`,
gotoValue: nevent,
};
} catch (error) {
console.warn("Failed to encode nevent for q tag:", tag[1], error);
return { text: `q:${tag[1]}` };
}
} else {
console.warn("Invalid event ID in q tag:", tag[1]);
return { text: `q:${tag[1]}` };
}
} }
return { text: `${tag[0]}:${tag[1]}` }; return { text: `${tag[0]}:${tag[1]}` };
} }
@ -259,9 +295,9 @@
</script> </script>
<div class="flex flex-col space-y-4 min-w-0"> <div class="flex flex-col space-y-4 min-w-0">
{#if event.kind !== 0 && getEventTitle(event)} {#if event.kind !== 0}
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100 break-words"> <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100 break-words">
{getEventTitle(event)} {@render basicMarkup(getEventTitle(event), ndk)}
</h2> </h2>
{/if} {/if}
@ -276,6 +312,7 @@
>Author: {@render userBadge( >Author: {@render userBadge(
toNpub(event.pubkey) as string, toNpub(event.pubkey) as string,
profile?.display_name || undefined, profile?.display_name || undefined,
ndk,
)}</span )}</span
> >
{:else} {:else}
@ -293,30 +330,70 @@
> >
</div> </div>
{#if getEventSummary(event)} <div class="flex flex-col space-y-1 min-w-0">
<div class="flex flex-col space-y-1 min-w-0"> <span class="text-gray-700 dark:text-gray-300">Summary:</span>
<span class="text-gray-700 dark:text-gray-300">Summary:</span> <div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0">
<p class="text-gray-900 dark:text-gray-100 break-words">{getEventSummary(event)}</p> {@render basicMarkup(getEventSummary(event), ndk)}
</div> </div>
{/if} </div>
<!-- Containing Publications --> <!-- Containing Publications -->
<ContainingIndexes {event} /> <ContainingIndexes {event} />
<!-- Content --> <!-- Content -->
{#if event.kind !== 0} {#if event.kind !== 0}
{@const kind = event.kind}
{@const content = event.content.trim()}
<div class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border max-w-full overflow-hidden"> <div class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border max-w-full overflow-hidden">
<div class="flex flex-col space-y-1 min-w-0"> <div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300 font-semibold">Content:</span> <span class="text-gray-700 dark:text-gray-300 font-semibold">Content:</span>
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0"> <div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0">
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}> {#if isRepost}
<EmbeddedEvent nostrIdentifier={event.id} nestingLevel={0} /> <!-- Repost content handling -->
</div> {#if repostKinds.includes(event.kind)}
{#if shouldTruncate} <!-- Kind 6 and 16 reposts - stringified JSON content -->
<button <div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2">
class="mt-2 text-primary-700 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-200" <div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
onclick={() => (showFullContent = true)}>Show more</button {event.kind === 6 ? 'Reposted content:' : 'Generic reposted content:'}
> </div>
{@render repostContent(event.content)}
</div>
{:else if event.kind === 1 && event.getMatchingTags("q").length > 0}
<!-- Quote repost - kind 1 with q tag -->
<div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Quote repost:
</div>
{@render quotedContent(event, [], ndk)}
{#if content}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Added comment:
</div>
{#if repostKinds.includes(kind)}
{@html content}
{:else}
{@render basicMarkup(content, ndk)}
{/if}
</div>
{/if}
</div>
{/if}
{:else}
<!-- Regular content -->
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}>
{#if repostKinds.includes(kind)}
{@html content}
{:else}
{@render basicMarkup(content, ndk)}
{/if}
</div>
{#if shouldTruncate}
<button
class="mt-2 text-primary-700 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-200"
onclick={() => (showFullContent = true)}>Show more</button
>
{/if}
{/if} {/if}
</div> </div>
</div> </div>
@ -328,6 +405,7 @@
<ProfileHeader <ProfileHeader
{event} {event}
{profile} {profile}
{communityStatusMap}
/> />
{/if} {/if}
@ -404,7 +482,14 @@
const tTag = tagInfo.gotoValue!.substring(2); const tTag = tagInfo.gotoValue!.substring(2);
goto(`/events?t=${encodeURIComponent(tTag)}`); goto(`/events?t=${encodeURIComponent(tTag)}`);
} else if (/^[0-9a-fA-F]{64}$/.test(tagInfo.gotoValue!)) { } else if (/^[0-9a-fA-F]{64}$/.test(tagInfo.gotoValue!)) {
// For hex event IDs - use navigateToEvent // AI-NOTE: E-tag navigation may cause comment feed update issues
// When navigating to a new event via e-tag, the CommentViewer component
// may experience timing issues that result in:
// - Empty comment feeds even when comments exist
// - UI flashing between different thread views
// - Delayed comment loading
// This is likely due to race conditions between event prop changes
// and comment fetching in the CommentViewer component.
navigateToEvent(tagInfo.gotoValue!); navigateToEvent(tagInfo.gotoValue!);
} else { } else {
// For other cases, try direct navigation // For other cases, try direct navigation

2
src/lib/components/EventInput.svelte

@ -10,7 +10,7 @@
import { publishEvent, loadEvent } from "./event_input/eventServices"; import { publishEvent, loadEvent } from "./event_input/eventServices";
import { getNdkContext } from "$lib/ndk"; import { getNdkContext } from "$lib/ndk";
// AI-NOTE: 2025-01-24 - Main EventInput component refactored for better separation of concerns // AI-NOTE: Main EventInput component refactored for better separation of concerns
// This component now serves as a container that orchestrates the form, tags, preview, and publishing // This component now serves as a container that orchestrates the form, tags, preview, and publishing
// Get NDK context at component level (can only be called during initialization) // Get NDK context at component level (can only be called during initialization)

8
src/lib/components/EventSearch.svelte

@ -44,7 +44,7 @@
addresses: Set<string>, addresses: Set<string>,
searchType?: string, searchType?: string,
searchTerm?: string, searchTerm?: string,
loading?: boolean, // AI-NOTE: 2025-01-24 - Add loading parameter for second-order search message logic loading?: boolean, // AI-NOTE: Add loading parameter for second-order search message logic
) => void; ) => void;
event: NDKEvent | null; event: NDKEvent | null;
onClear?: () => void; onClear?: () => void;
@ -399,7 +399,7 @@
} }
} }
// AI-NOTE: 2025-01-24 - Effects organized for better readability // AI-NOTE: Effects organized for better readability
$effect(() => { $effect(() => {
if (searching || isResetting || isUserEditing) { if (searching || isResetting || isUserEditing) {
return; return;
@ -507,7 +507,7 @@
} }
}); });
// AI-NOTE: 2025-01-24 - Utility functions for event matching and state management // AI-NOTE: Utility functions for event matching and state management
function isCurrentEventMatch(searchValue: string, event: NDKEvent): boolean { function isCurrentEventMatch(searchValue: string, event: NDKEvent): boolean {
const currentEventId = event.id; const currentEventId = event.id;
let currentNaddr: string | null = null; let currentNaddr: string | null = null;
@ -810,7 +810,7 @@
} }
} }
// AI-NOTE: 2025-01-24 - Background profile search is now handled by centralized searchProfiles function // AI-NOTE: Background profile search is now handled by centralized searchProfiles function
// This function is no longer needed as profile searches go through subscription_search.ts // This function is no longer needed as profile searches go through subscription_search.ts
// which delegates to the centralized profile_search.ts // which delegates to the centralized profile_search.ts

348
src/lib/components/Notifications.svelte

@ -17,12 +17,14 @@
getNotificationType, getNotificationType,
fetchAuthorProfiles, fetchAuthorProfiles,
quotedContent, quotedContent,
} from "$lib/components/embedded_events/EmbeddedSnippets.svelte"; } from "$lib/snippets/EmbeddedSnippets.svelte";
import { buildCompleteRelaySet } from "$lib/utils/relay_management"; import { buildCompleteRelaySet } from "$lib/utils/relay_management";
import { formatDate, neventEncode } from "$lib/utils"; import { formatDate, neventEncode } from "$lib/utils";
import { NDKRelaySetFromNDK } from "$lib/utils/nostrUtils"; import { NDKRelaySetFromNDK } from "$lib/utils/nostrUtils";
import EmbeddedEvent from "./embedded_events/EmbeddedEvent.svelte"; import { repostContent } from "$lib/snippets/EmbeddedSnippets.svelte";
import { repostKinds } from "$lib/consts";
import { getNdkContext } from "$lib/ndk"; import { getNdkContext } from "$lib/ndk";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
const { event } = $props<{ event: NDKEvent }>(); const { event } = $props<{ event: NDKEvent }>();
@ -54,6 +56,16 @@
let authorProfiles = $state<Map<string, { name?: string; displayName?: string; picture?: string }>>(new Map()); let authorProfiles = $state<Map<string, { name?: string; displayName?: string; picture?: string }>>(new Map());
let filteredByUser = $state<string | null>(null); let filteredByUser = $state<string | null>(null);
// AI-NOTE: Client-side pagination - fetch once, paginate locally
let allToMeNotifications = $state<NDKEvent[]>([]); // All fetched "to-me" notifications
let allFromMeNotifications = $state<NDKEvent[]>([]); // All fetched "from-me" notifications
let allPublicMessages = $state<NDKEvent[]>([]); // All fetched public messages
let currentPage = $state(1);
let itemsPerPage = 20; // Show 20 items per page
let hasFetchedToMe = $state(false); // Track if we've already fetched "to-me" data
let hasFetchedFromMe = $state(false); // Track if we've already fetched "from-me" data
let hasFetchedPublic = $state(false); // Track if we've already fetched public messages
// New Message Modal state // New Message Modal state
let showNewMessageModal = $state(false); let showNewMessageModal = $state(false);
let newMessageContent = $state<string>(""); let newMessageContent = $state<string>("");
@ -459,10 +471,52 @@
} }
} }
// AI-NOTE: Simplified notification fetching // AI-NOTE: Client-side pagination calculations
let paginatedNotifications = $derived.by(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const currentNotifications = notificationMode === "to-me" ? allToMeNotifications : allFromMeNotifications;
return currentNotifications.slice(startIndex, endIndex);
});
let paginatedPublicMessages = $derived.by(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return allPublicMessages.slice(startIndex, endIndex);
});
let totalPages = $derived.by(() => {
let totalItems = 0;
if (notificationMode === "public-messages") {
totalItems = allPublicMessages.length;
} else if (notificationMode === "to-me") {
totalItems = allToMeNotifications.length;
} else {
totalItems = allFromMeNotifications.length;
}
return Math.ceil(totalItems / itemsPerPage);
});
let hasNextPage = $derived.by(() => currentPage < totalPages);
let hasPreviousPage = $derived.by(() => currentPage > 1);
// AI-NOTE: Optimized notification fetching - fetch once, paginate locally
async function fetchNotifications() { async function fetchNotifications() {
if (!$userStore.pubkey || !isOwnProfile) return; if (!$userStore.pubkey || !isOwnProfile || isFetching) return;
// Check if we've already fetched data for this specific mode
if (notificationMode === "to-me" && hasFetchedToMe && allToMeNotifications.length > 0) {
currentPage = 1;
notifications = paginatedNotifications;
return;
}
if (notificationMode === "from-me" && hasFetchedFromMe && allFromMeNotifications.length > 0) {
currentPage = 1;
notifications = paginatedNotifications;
return;
}
isFetching = true;
loading = true; loading = true;
error = null; error = null;
@ -481,7 +535,7 @@
? { "#p": [$userStore.pubkey] } ? { "#p": [$userStore.pubkey] }
: { authors: [$userStore.pubkey] } : { authors: [$userStore.pubkey] }
), ),
limit: 100, limit: 500, // Fetch more data once to avoid multiple relay calls
}; };
const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk); const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk);
@ -499,22 +553,41 @@
} }
}); });
notifications = filteredEvents const sortedEvents = filteredEvents
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) .sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
.slice(0, 100);
// Store in the appropriate array based on mode
if (notificationMode === "to-me") {
allToMeNotifications = sortedEvents;
hasFetchedToMe = true;
} else {
allFromMeNotifications = sortedEvents;
hasFetchedFromMe = true;
}
authorProfiles = await fetchAuthorProfiles(notifications, ndk); // Set current page to 1 and update displayed notifications
currentPage = 1;
notifications = paginatedNotifications;
// Load profiles in background
authorProfiles = await fetchAuthorProfiles(sortedEvents, ndk);
} catch (err) { } catch (err) {
console.error("[Notifications] Error fetching notifications:", err); console.error("[Notifications] Error fetching notifications:", err);
error = err instanceof Error ? err.message : "Failed to fetch notifications"; error = err instanceof Error ? err.message : "Failed to fetch notifications";
} finally { } finally {
loading = false; loading = false;
isFetching = false;
} }
} }
// AI-NOTE: Simplified public messages fetching - only kind 24 messages // AI-NOTE: Optimized public messages fetching - fetch once, paginate locally
async function fetchPublicMessages() { async function fetchPublicMessages() {
if (!$userStore.pubkey || !isOwnProfile) return; if (!$userStore.pubkey || !isOwnProfile || isFetching) return;
// Only fetch if we haven't already fetched data for this mode
if (hasFetchedPublic && allPublicMessages.length > 0) {
return;
}
loading = true; loading = true;
error = null; error = null;
@ -524,6 +597,9 @@
const userStoreValue = get(userStore); const userStoreValue = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null; const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
// AI-NOTE: Cache relay set to prevent excessive calls
console.log("[PublicMessages] Building relay set for public messages...");
const relaySet = await buildCompleteRelaySet(ndk, user); const relaySet = await buildCompleteRelaySet(ndk, user);
const relays = [...relaySet.inboxRelays, ...relaySet.outboxRelays]; const relays = [...relaySet.inboxRelays, ...relaySet.outboxRelays];
if (relays.length === 0) throw new Error("No relays available"); if (relays.length === 0) throw new Error("No relays available");
@ -532,8 +608,8 @@
// Fetch only kind 24 messages // Fetch only kind 24 messages
const [messagesEvents, userMessagesEvents] = await Promise.all([ const [messagesEvents, userMessagesEvents] = await Promise.all([
ndk.fetchEvents({ kinds: [24 as any], "#p": [$userStore.pubkey], limit: 200 }, undefined, ndkRelaySet), ndk.fetchEvents({ kinds: [24 as any], "#p": [$userStore.pubkey], limit: 500 }, undefined, ndkRelaySet),
ndk.fetchEvents({ kinds: [24 as any], authors: [$userStore.pubkey], limit: 200 }, undefined, ndkRelaySet) ndk.fetchEvents({ kinds: [24 as any], authors: [$userStore.pubkey], limit: 500 }, undefined, ndkRelaySet)
]); ]);
const allMessages = [ const allMessages = [
@ -541,24 +617,75 @@
...Array.from(userMessagesEvents) ...Array.from(userMessagesEvents)
]; ];
// Deduplicate and filter // Deduplicate and sort
const uniqueMessages = allMessages.filter((event, index, self) => const uniqueMessages = allMessages.filter((event, index, self) =>
index === self.findIndex(e => e.id === event.id) index === self.findIndex(e => e.id === event.id)
); );
publicMessages = uniqueMessages allPublicMessages = uniqueMessages
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) .sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
.slice(0, 200);
authorProfiles = await fetchAuthorProfiles(publicMessages, ndk); // Set current page to 1 and update displayed messages
currentPage = 1;
publicMessages = paginatedPublicMessages;
hasFetchedPublic = true;
// Load profiles in background
authorProfiles = await fetchAuthorProfiles(allPublicMessages, ndk);
} catch (err) { } catch (err) {
console.error("[PublicMessages] Error fetching public messages:", err); console.error("[PublicMessages] Error fetching public messages:", err);
error = err instanceof Error ? err.message : "Failed to fetch public messages"; error = err instanceof Error ? err.message : "Failed to fetch public messages";
} finally { } finally {
loading = false; loading = false;
isFetching = false;
}
}
// AI-NOTE: Pagination navigation functions
function nextPage() {
if (hasNextPage) {
currentPage++;
updateDisplayedItems();
}
}
function previousPage() {
if (hasPreviousPage) {
currentPage--;
updateDisplayedItems();
}
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
updateDisplayedItems();
}
}
// AI-NOTE: Update displayed items based on current page
function updateDisplayedItems() {
if (notificationMode === "public-messages") {
publicMessages = paginatedPublicMessages;
} else {
notifications = paginatedNotifications;
} }
} }
// AI-NOTE: Reset pagination when mode changes
function resetPagination() {
currentPage = 1;
hasFetchedToMe = false;
hasFetchedFromMe = false;
hasFetchedPublic = false;
allToMeNotifications = [];
allFromMeNotifications = [];
allPublicMessages = [];
notifications = [];
publicMessages = [];
authorProfiles.clear();
}
// Check if user is viewing their own profile // Check if user is viewing their own profile
$effect(() => { $effect(() => {
if ($userStore.signedIn && $userStore.pubkey && event.pubkey) { if ($userStore.signedIn && $userStore.pubkey && event.pubkey) {
@ -568,22 +695,64 @@
} }
}); });
// Fetch notifications when viewing own profile or when mode changes // AI-NOTE: Track previous state to prevent unnecessary refetches
let previousMode = $state<"to-me" | "from-me" | "public-messages" | null>(null);
let previousPubkey = $state<string | null>(null);
let previousIsOwnProfile = $state(false);
let isFetching = $state(false); // Guard against concurrent fetches
// Fetch notifications when viewing own profile or when mode changes - with guards
$effect(() => { $effect(() => {
if (isOwnProfile && $userStore.pubkey && $userStore.signedIn) { const currentMode = notificationMode;
if (notificationMode === "public-messages") { const currentPubkey = $userStore.pubkey;
fetchPublicMessages(); const currentIsOwnProfile = isOwnProfile;
} else {
fetchNotifications(); // Only proceed if something actually changed and we're not already fetching
if (currentIsOwnProfile && currentPubkey && $userStore.signedIn && !isFetching) {
if (previousMode !== currentMode || previousPubkey !== currentPubkey || previousIsOwnProfile !== currentIsOwnProfile) {
console.log("[Notifications] Mode or user changed, fetching data...");
// Reset pagination when mode changes
if (currentMode === "public-messages" && !hasFetchedPublic) {
resetPagination();
fetchPublicMessages();
} else if (currentMode !== "public-messages" &&
((currentMode === "to-me" && !hasFetchedToMe) ||
(currentMode === "from-me" && !hasFetchedFromMe))) {
resetPagination();
fetchNotifications();
} else {
// Mode changed but we have data - just update displayed items
currentPage = 1;
updateDisplayedItems();
}
// Update previous state
previousMode = currentMode;
previousPubkey = currentPubkey;
previousIsOwnProfile = currentIsOwnProfile;
} }
} else { } else if ((previousIsOwnProfile || previousPubkey) && !currentIsOwnProfile) {
// Clear notifications when user logs out or is not viewing own profile // Clear notifications when user logs out or is not viewing own profile
notifications = []; console.log("[Notifications] User logged out, clearing data...");
publicMessages = []; resetPagination();
authorProfiles.clear(); previousMode = null;
previousPubkey = null;
previousIsOwnProfile = false;
} }
}); });
// AI-NOTE: Update displayed items when page changes - debounced
let pageUpdateTimeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
if (pageUpdateTimeout) {
clearTimeout(pageUpdateTimeout);
}
pageUpdateTimeout = setTimeout(() => {
updateDisplayedItems();
}, 50);
});
// AI-NOTE: Refactored to avoid blocking $effect with async operations // AI-NOTE: Refactored to avoid blocking $effect with async operations
// Calculate relay set when recipients change - non-blocking approach // Calculate relay set when recipients change - non-blocking approach
$effect(() => { $effect(() => {
@ -678,7 +847,6 @@
onclick={() => openNewMessageModal()} onclick={() => openNewMessageModal()}
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium" class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium"
> >
<PlusOutline class="w-4 h-4" />
New Message New Message
</Button> </Button>
@ -817,7 +985,36 @@
{#if message.content} {#if message.content}
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed"> <div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
<div class="px-2"> <div class="px-2">
<EmbeddedEvent nostrIdentifier={message.id} nestingLevel={0} /> <div class="text-sm text-gray-700 dark:text-gray-300">
{#if repostKinds.includes(message.kind)}
<!-- Repost content - parse stringified JSON -->
<div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{message.kind === 6 ? 'Repost:' : 'Generic repost:'}
</div>
{@render repostContent(message.content)}
</div>
{:else if message.kind === 1 && message.getMatchingTags("q").length > 0}
<!-- Quote repost content -->
<div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Quote repost:
</div>
{@render quotedContent(message, publicMessages, ndk)}
{#if message.content && message.content.trim()}
<div class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Comment:
</div>
{@render basicMarkup(message.content, ndk)}
</div>
{/if}
</div>
{:else}
<!-- Regular content -->
{@render basicMarkup(message.content || "No content", ndk)}
{/if}
</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -830,9 +1027,31 @@
{/each} {/each}
</div> </div>
{#if filteredMessages.length > 100} <!-- Pagination Controls -->
<div class="mt-4 p-3 text-center text-sm text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg"> {#if totalPages > 1}
Showing 100 of {filteredMessages.length} messages {filteredByUser ? `(filtered)` : ''}. Scroll to see more. <div class="mt-4 flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="text-sm text-gray-600 dark:text-gray-400">
Page {currentPage} of {totalPages} ({allPublicMessages.length} total messages)
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={previousPage}
disabled={!hasPreviousPage}
>
Previous
</button>
<span class="text-sm text-gray-600 dark:text-gray-400">
{currentPage} / {totalPages}
</span>
<button
class="px-3 py-1 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={nextPage}
disabled={!hasNextPage}
>
Next
</button>
</div>
</div> </div>
{/if} {/if}
</div> </div>
@ -894,7 +1113,36 @@
{#if notification.content} {#if notification.content}
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed"> <div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
<div class="px-2"> <div class="px-2">
<EmbeddedEvent nostrIdentifier={notification.id} nestingLevel={0} /> <div class="text-sm text-gray-700 dark:text-gray-300">
{#if repostKinds.includes(notification.kind)}
<!-- Repost content - parse stringified JSON -->
<div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{notification.kind === 6 ? 'Repost:' : 'Generic repost:'}
</div>
{@render repostContent(notification.content)}
</div>
{:else if notification.kind === 1 && notification.getMatchingTags("q").length > 0}
<!-- Quote repost content -->
<div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Quote repost:
</div>
{@render quotedContent(notification, notifications, ndk)}
{#if notification.content && notification.content.trim()}
<div class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Comment:
</div>
{@render basicMarkup(notification.content, ndk)}
</div>
{/if}
</div>
{:else}
<!-- Regular content -->
{@render basicMarkup(notification.content || "No content", ndk)}
{/if}
</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -905,9 +1153,31 @@
</div> </div>
{/each} {/each}
{#if notifications.length > 100} <!-- Pagination Controls -->
<div class="mt-4 p-3 text-center text-sm text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg"> {#if totalPages > 1}
Showing 100 of {notifications.length} notifications {notificationMode === "to-me" ? "received" : "sent"}. Scroll to see more. <div class="mt-4 flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="text-sm text-gray-600 dark:text-gray-400">
Page {currentPage} of {totalPages} ({notificationMode === "to-me" ? allToMeNotifications.length : allFromMeNotifications.length} total notifications)
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={previousPage}
disabled={!hasPreviousPage}
>
Previous
</button>
<span class="text-sm text-gray-600 dark:text-gray-400">
{currentPage} / {totalPages}
</span>
<button
class="px-3 py-1 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={nextPage}
disabled={!hasNextPage}
>
Next
</button>
</div>
</div> </div>
{/if} {/if}
</div> </div>
@ -929,7 +1199,9 @@
<div class="quoted-content mb-4 p-3 rounded-r-lg"> <div class="quoted-content mb-4 p-3 rounded-r-lg">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Replying to:</div> <div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Replying to:</div>
<div class="text-sm text-gray-800 dark:text-gray-200"> <div class="text-sm text-gray-800 dark:text-gray-200">
<EmbeddedEvent nostrIdentifier={replyToMessage.id} nestingLevel={0} /> <div class="text-sm text-gray-700 dark:text-gray-300">
{@render basicMarkup(replyToMessage.content || "No content", ndk)}
</div>
</div> </div>
</div> </div>
{/if} {/if}

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

@ -9,7 +9,8 @@
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
import LazyImage from "$components/util/LazyImage.svelte"; import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils"; import { generateDarkPastelColor } from "$lib/utils/image_utils";
import { getNdkContext } from "$lib/ndk";
const { const {
rootId, rootId,
event, event,
@ -22,6 +23,8 @@
active: boolean; active: boolean;
}>(); }>();
const ndk = getNdkContext();
let title: string = $derived(event.getMatchingTags("title")[0]?.[1]); let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
let author: string = $derived( let author: string = $derived(
getMatchingTags(event, "author")[0]?.[1] ?? "unknown", getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
@ -59,7 +62,7 @@
<div class="space-y-4 relative"> <div class="space-y-4 relative">
<div class="flex flex-row justify-between my-2"> <div class="flex flex-row justify-between my-2">
<div class="flex flex-col"> <div class="flex flex-col">
{@render userBadge(authorPubkey, author)} {@render userBadge(authorPubkey, author, ndk)}
<span class="text-gray-700 dark:text-gray-300">{publishedAt()}</span> <span class="text-gray-700 dark:text-gray-300">{publishedAt()}</span>
</div> </div>
</div> </div>

56
src/lib/components/cards/ProfileHeader.svelte

@ -17,6 +17,8 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { isPubkeyInUserLists, fetchCurrentUserLists } from "$lib/utils/user_lists"; import { isPubkeyInUserLists, fetchCurrentUserLists } from "$lib/utils/user_lists";
import { UserOutline } from "flowbite-svelte-icons"; import { UserOutline } from "flowbite-svelte-icons";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
import { getNdkContext } from "$lib/ndk";
const { const {
event, event,
@ -30,6 +32,8 @@
communityStatusMap?: Record<string, boolean>; communityStatusMap?: Record<string, boolean>;
}>(); }>();
const ndk = getNdkContext();
let lnModalOpen = $state(false); let lnModalOpen = $state(false);
let lnurl = $state<string | null>(null); let lnurl = $state<string | null>(null);
let communityStatus = $state<boolean | null>(null); let communityStatus = $state<boolean | null>(null);
@ -51,26 +55,32 @@
$effect(() => { $effect(() => {
if (event?.pubkey) { if (event?.pubkey) {
// First check if we have cached profileData with user list information // First check if we have user list information in the profile prop
const cachedProfileData = (event as any).profileData; if (profile && typeof profile.isInUserLists === 'boolean') {
console.log(`[ProfileHeader] Checking user list status for ${event.pubkey}, cached profileData:`, cachedProfileData); isInUserLists = profile.isInUserLists;
console.log(`[ProfileHeader] Using profile prop user list status for ${event.pubkey}: ${isInUserLists}`);
if (cachedProfileData && typeof cachedProfileData.isInUserLists === 'boolean') {
isInUserLists = cachedProfileData.isInUserLists;
console.log(`[ProfileHeader] Using cached user list status for ${event.pubkey}: ${isInUserLists}`);
} else { } else {
console.log(`[ProfileHeader] No cached user list data, fetching for ${event.pubkey}`); // Then check if we have cached profileData with user list information
// Fallback to fetching user lists const cachedProfileData = (event as any).profileData;
fetchCurrentUserLists() console.log(`[ProfileHeader] Checking user list status for ${event.pubkey}, cached profileData:`, cachedProfileData);
.then((userLists) => {
console.log(`[ProfileHeader] Fetched ${userLists.length} user lists for ${event.pubkey}`); if (cachedProfileData && typeof cachedProfileData.isInUserLists === 'boolean') {
isInUserLists = isPubkeyInUserLists(event.pubkey, userLists); isInUserLists = cachedProfileData.isInUserLists;
console.log(`[ProfileHeader] Final user list status for ${event.pubkey}: ${isInUserLists}`); console.log(`[ProfileHeader] Using cached user list status for ${event.pubkey}: ${isInUserLists}`);
}) } else {
.catch((error) => { console.log(`[ProfileHeader] No cached user list data, fetching for ${event.pubkey}`);
console.error(`[ProfileHeader] Error fetching user lists for ${event.pubkey}:`, error); // Fallback to fetching user lists
isInUserLists = false; fetchCurrentUserLists()
}); .then((userLists) => {
console.log(`[ProfileHeader] Fetched ${userLists.length} user lists for ${event.pubkey}`);
isInUserLists = isPubkeyInUserLists(event.pubkey, userLists);
console.log(`[ProfileHeader] Final user list status for ${event.pubkey}: ${isInUserLists}`);
})
.catch((error) => {
console.error(`[ProfileHeader] Error fetching user lists for ${event.pubkey}:`, error);
isInUserLists = false;
});
}
} }
// Check community status - use cached data if available // Check community status - use cached data if available
@ -141,6 +151,7 @@
profile.display_name || profile.display_name ||
profile.name || profile.name ||
event.pubkey, event.pubkey,
ndk,
)} )}
</div> </div>
{#if communityStatus === true} {#if communityStatus === true}
@ -199,7 +210,11 @@
{#if profile.about} {#if profile.about}
<div class="flex gap-2 min-w-0"> <div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">About:</dt> <dt class="font-semibold min-w-[120px] flex-shrink-0">About:</dt>
<dd class="min-w-0 break-words whitespace-pre-line">{profile.about}</dd> <dd class="min-w-0 break-words">
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0">
{@render basicMarkup(profile.about, ndk)}
</div>
</dd>
</div> </div>
{/if} {/if}
{#if profile.website} {#if profile.website}
@ -269,6 +284,7 @@
{@render userBadge( {@render userBadge(
toNpub(event.pubkey) as string, toNpub(event.pubkey) as string,
profile?.displayName || profile.name || event.pubkey, profile?.displayName || profile.name || event.pubkey,
ndk,
)} )}
<P class="break-all">{profile.lud16}</P> <P class="break-all">{profile.lud16}</P>
</div> </div>

738
src/lib/components/embedded_events/EmbeddedEvent.svelte

@ -1,738 +0,0 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { fetchEventWithFallback, getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { parsedContent } from "$lib/components/embedded_events/EmbeddedSnippets.svelte";
import { naddrEncode } from "$lib/utils";
import { activeInboxRelays, getNdkContext } from "$lib/ndk";
import { goto } from "$app/navigation";
import { getEventType } from "$lib/utils/mime";
import { nip19 } from "nostr-tools";
import { repostKinds } from "$lib/consts";
import { UserOutline } from "flowbite-svelte-icons";
import type { UserProfile } from "$lib/models/user_profile";
const {
nostrIdentifier,
nestingLevel = 0,
} = $props<{
nostrIdentifier: string;
nestingLevel?: number;
}>();
const ndk = getNdkContext();
let event = $state<NDKEvent | null>(null);
let profile = $state< UserProfile | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let authorDisplayName = $state<string | undefined>(undefined);
// Maximum nesting level allowed
const MAX_NESTING_LEVEL = 3;
// AI-NOTE: 2025-01-24 - Embedded event component for rendering nested Nostr events
// Supports up to 3 levels of nesting, after which it falls back to showing just the link
// AI-NOTE: 2025-01-24 - Updated to handle both NIP-19 identifiers and raw event IDs
// If a raw event ID is passed, it automatically creates a nevent identifier
$effect(() => {
if (nostrIdentifier) {
loadEvent();
}
});
async function loadEvent() {
if (nestingLevel >= MAX_NESTING_LEVEL) {
// At max nesting level, don't load the event, just show the link
loading = false;
return;
}
loading = true;
error = null;
try {
if (!ndk) {
throw new Error("No NDK instance available");
}
// Clean the identifier (remove nostr: prefix if present)
const cleanId = nostrIdentifier.replace(/^nostr:/, "");
// Try to decode as NIP-19 identifier first
let decoded;
try {
decoded = nip19.decode(cleanId);
} catch (decodeError) {
// If decoding fails, assume it's a raw event ID and create a nevent
if (/^[0-9a-fA-F]{64}$/.test(cleanId)) {
// It's a valid hex event ID, create a nevent
const nevent = nip19.neventEncode({
id: cleanId,
relays: [],
});
decoded = nip19.decode(nevent);
} else {
throw new Error(`Invalid identifier format: ${cleanId}`);
}
}
if (!decoded) {
throw new Error("Failed to decode Nostr identifier");
}
let eventId: string | undefined;
if (decoded.type === "nevent") {
eventId = decoded.data.id;
} else if (decoded.type === "naddr") {
// For naddr, we need to construct a filter
const naddrData = decoded.data as any;
const filter = {
kinds: [naddrData.kind || 0],
authors: [naddrData.pubkey],
"#d": [naddrData.identifier],
};
const foundEvent = await fetchEventWithFallback(ndk, filter);
if (!foundEvent) {
throw new Error("Event not found");
}
event = foundEvent;
} else if (decoded.type === "note") {
// For note, treat it as a nevent
eventId = (decoded.data as any).id;
} else {
throw new Error(`Unsupported identifier type: ${decoded.type}`);
}
// If we have an event ID, fetch the event
if (eventId && !event) {
event = await fetchEventWithFallback(ndk, eventId);
if (!event) {
throw new Error("Event not found");
}
}
// Load profile for the event author
if (event?.pubkey) {
const npub = toNpub(event.pubkey);
if (npub) {
const userProfile = await getUserMetadata(npub, ndk);
authorDisplayName =
userProfile.displayName ||
(userProfile as any).display_name ||
userProfile.name ||
event.pubkey;
}
}
// Parse profile if it's a profile event
if (event?.kind === 0) {
try {
profile = JSON.parse(event.content);
} catch {
profile = null;
}
}
} catch (err) {
console.error("Error loading embedded event:", err);
error = err instanceof Error ? err.message : "Failed to load event";
} finally {
loading = false;
}
}
function getEventTitle(event: NDKEvent): string {
const titleTag = event.getMatchingTags("title")[0]?.[1];
if (titleTag) return titleTag;
// For profile events, use display name
if (event.kind === 0 && profile) {
return profile.display_name || profile.name || "Profile";
}
// For text events (kind 1), don't show a title if it would duplicate the content
if (event.kind === 1) {
return "";
}
// For other events, use first line of content, but filter out nostr identifiers
if (event.content) {
const firstLine = event.content.split("\n")[0].trim();
if (firstLine) {
// Remove nostr identifiers from the title
const cleanTitle = firstLine.replace(/nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g, '').trim();
if (cleanTitle) return cleanTitle.slice(0, 100);
}
}
return "Untitled";
}
function getEventSummary(event: NDKEvent): string {
if (event.kind === 0 && profile?.about) {
return profile.about;
}
if (event.content) {
const lines = event.content.split("\n");
const summaryLines = lines.slice(1, 3).filter(line => line.trim());
if (summaryLines.length > 0) {
return summaryLines.join(" ").slice(0, 200);
}
}
return "";
}
function navigateToEvent() {
if (event) {
goto(`/events?id=${nostrIdentifier}`);
}
}
function getNaddrUrl(event: NDKEvent): string {
return naddrEncode(event, $activeInboxRelays);
}
function isAddressableEvent(event: NDKEvent): boolean {
return getEventType(event.kind || 0) === "addressable";
}
</script>
{#if nestingLevel >= MAX_NESTING_LEVEL}
<!-- At max nesting level, just show the link -->
<div class="embedded-event-max-nesting min-w-0 overflow-hidden">
<a
href="/events?id={nostrIdentifier}"
class="text-primary-600 dark:text-primary-500 hover:underline break-all"
onclick={(e) => {
e.preventDefault();
goto(`/events?id=${nostrIdentifier}`);
}}
>
{nostrIdentifier}
</a>
</div>
{:else if loading}
<!-- Loading state -->
<div class="embedded-event-loading bg-gray-50 dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700 min-w-0 overflow-hidden">
<div class="flex items-center space-x-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600 flex-shrink-0"></div>
<span class="text-sm text-gray-600 dark:text-gray-400">Loading event...</span>
</div>
</div>
{:else if error}
<!-- Error state -->
<div class="embedded-event-error bg-red-50 dark:bg-red-900/20 rounded-lg p-3 border border-red-200 dark:border-red-800 min-w-0 overflow-hidden">
<div class="flex items-center space-x-2">
<span class="text-red-600 dark:text-red-400 text-sm flex-shrink-0"></span>
<span class="text-sm text-red-600 dark:text-red-400">Failed to load event</span>
</div>
<a
href="/events?id={nostrIdentifier}"
class="text-primary-600 dark:text-primary-500 hover:underline text-sm mt-1 inline-block break-all"
onclick={(e) => {
e.preventDefault();
goto(`/events?id=${nostrIdentifier}`);
}}
>
View event directly
</a>
</div>
{:else if event}
<!-- Event content -->
<div class="embedded-event bg-gray-50 dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700 mb-2 min-w-0 overflow-hidden">
<!-- Event header -->
<div class="flex items-center justify-between mb-3 min-w-0">
<div class="flex items-center space-x-2 min-w-0">
<span class="text-xs text-gray-500 dark:text-gray-400 font-mono flex-shrink-0">
Kind {event.kind}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
({getEventType(event.kind || 0)})
</span>
{#if event.pubkey}
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"></span>
<span class="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0">Author:</span>
<div class="min-w-0 flex-1">
{#if toNpub(event.pubkey)}
{@render userBadge(
toNpub(event.pubkey) as string,
authorDisplayName,
)}
{:else}
<span class="text-xs text-gray-700 dark:text-gray-300 break-all">
{authorDisplayName || event.pubkey.slice(0, 8)}...{event.pubkey.slice(-4)}
</span>
{/if}
</div>
{/if}
</div>
</div>
<!-- Event title -->
{#if getEventTitle(event)}
<h4 class="font-semibold text-gray-900 dark:text-gray-100 mb-2 break-words">
{getEventTitle(event)}
</h4>
{/if}
<!-- Summary for non-content events -->
{#if event.kind !== 1 && getEventSummary(event)}
<div class="mb-2 min-w-0">
<p class="text-sm text-gray-700 dark:text-gray-300 break-words">
{getEventSummary(event)}
</p>
</div>
{/if}
<!-- Content for text events -->
{#if event.kind === 1 || repostKinds.includes(event.kind)}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
{#if repostKinds.includes(event.kind)}
<!-- Repost content -->
<div class="border-l-4 border-primary-300 dark:border-primary-600 pl-3 mb-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Reposted content:
</div>
{@render parsedContent(event.content.slice(0, 300))}
{#if event.content.length > 300}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</div>
{:else}
<!-- Regular text content -->
{@render parsedContent(event.content.slice(0, 300))}
{#if event.content.length > 300}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
{/if}
</div>
<!-- Contact list content (kind 3) -->
{:else if event.kind === 3}
<div class="space-y-2 min-w-0 overflow-hidden">
{#if event.content}
{@const contactData = (() => {
try {
return JSON.parse(event.content);
} catch {
return null;
}
})()}
{#if contactData}
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Contact List</span>
{#if contactData.relays}
<div class="mt-1">
<span class="text-xs text-gray-500 dark:text-gray-400">Relays: {Object.keys(contactData.relays).length}</span>
</div>
{/if}
</div>
{#if contactData.follows}
<div class="mt-2">
<span class="text-xs text-gray-500 dark:text-gray-400">Following: {contactData.follows.length} users</span>
</div>
{/if}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Invalid contact list data
</div>
{/if}
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty contact list
</div>
{/if}
</div>
<!-- Publication index content (kind 30040) -->
{:else if event.kind === 30040}
<div class="space-y-2 min-w-0 overflow-hidden">
{#if event.content}
{@const indexData = (() => {
try {
return JSON.parse(event.content);
} catch {
return null;
}
})()}
{#if indexData}
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Publication Index</span>
{#if indexData.title}
<div class="mt-1">
<span class="text-xs text-gray-500 dark:text-gray-400">Title: {indexData.title}</span>
</div>
{/if}
{#if indexData.summary}
<div class="mt-1">
<span class="text-xs text-gray-500 dark:text-gray-400">Summary: {indexData.summary}</span>
</div>
{/if}
{#if indexData.authors}
<div class="mt-1">
<span class="text-xs text-gray-500 dark:text-gray-400">Authors: {indexData.authors.length}</span>
</div>
{/if}
</div>
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Invalid publication index data
</div>
{/if}
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty publication index
</div>
{/if}
</div>
<!-- Publication content (kinds 30041, 30818) -->
{:else if event.kind === 30041 || event.kind === 30818}
<div class="space-y-2 min-w-0 overflow-hidden">
{#if event.content}
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">
{event.kind === 30041 ? 'Publication Content' : 'Wiki Content'}
</span>
</div>
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
<pre class="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto whitespace-pre-wrap break-words">
{event.content.slice(0, 300)}
{#if event.content.length > 300}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</pre>
</div>
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty {event.kind === 30041 ? 'publication' : 'wiki'} content
</div>
{/if}
</div>
<!-- Long-form content (kind 30023) -->
{:else if event.kind === 30023}
<div class="space-y-2 min-w-0 overflow-hidden">
{#if event.content}
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Long-form Content</span>
</div>
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
<pre class="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto whitespace-pre-wrap break-words">
{event.content.slice(0, 300)}
{#if event.content.length > 300}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</pre>
</div>
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty long-form content
</div>
{/if}
</div>
<!-- Reply/Comment content (kind 1111) -->
{:else if event.kind === 1111}
<div class="space-y-2 min-w-0 overflow-hidden">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Reply/Comment</span>
</div>
{#if event.content && event.content.trim()}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
{@render parsedContent(event.content)}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty reply
</div>
{/if}
</div>
</div>
<!-- Git Issue content (kind 1621) -->
{:else if event.kind === 1621}
<div class="space-y-2 min-w-0 overflow-hidden">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Git Issue</span>
{#if event.tags}
{@const subjectTag = event.tags.find(tag => tag[0] === 'subject')}
{#if subjectTag && subjectTag[1]}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Subject: {subjectTag[1]}
</div>
{/if}
{/if}
</div>
{#if event.content && event.content.trim()}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
{@render parsedContent(event.content)}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty issue description
</div>
{/if}
</div>
</div>
<!-- Git Comment content (kind 1622) -->
{:else if event.kind === 1622}
<div class="space-y-2 min-w-0 overflow-hidden">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Git Comment</span>
</div>
{#if event.content && event.content.trim()}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
{@render parsedContent(event.content)}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty comment
</div>
{/if}
</div>
</div>
<!-- Reaction content (kind 7) -->
{:else if event.kind === 7}
<div class="space-y-2 min-w-0 overflow-hidden">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Reaction</span>
</div>
{#if event.content && event.content.trim()}
<div class="text-lg">
{event.content}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty reaction
</div>
{/if}
</div>
</div>
<!-- Zap receipt content (kind 9735) -->
{:else if event.kind === 9735}
<div class="space-y-2 min-w-0 overflow-hidden">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Zap Receipt</span>
</div>
{#if event.content && event.content.trim()}
{@const zapData = (() => {
try {
return JSON.parse(event.content);
} catch {
return null;
}
})()}
{#if zapData}
<div class="text-xs text-gray-500 dark:text-gray-400">
{#if zapData.amount}
<div>Amount: {zapData.amount} sats</div>
{/if}
{#if zapData.preimage}
<div>Preimage: {zapData.preimage.slice(0, 8)}...</div>
{/if}
{#if zapData.bolt11}
<div>Invoice: {zapData.bolt11.slice(0, 20)}...</div>
{/if}
</div>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
<pre class="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto whitespace-pre-wrap break-words">
{event.content.slice(0, 200)}
{#if event.content.length > 200}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</pre>
</div>
{/if}
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
Empty zap receipt
</div>
{/if}
</div>
</div>
<!-- Image/media content (kind 20) -->
{:else if event.kind === 20}
<div class="space-y-2 min-w-0 overflow-hidden">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="mb-2">
<span class="font-semibold">Image/Media Post</span>
</div>
<!-- Render images from imeta tags -->
{#if event.tags}
{@const imetaTags = event.tags.filter(tag => tag[0] === 'imeta')}
{#if imetaTags.length > 0}
<div class="space-y-2">
{#each imetaTags as imetaTag}
{@const imetaData = (() => {
const data: any = {};
for (let i = 1; i < imetaTag.length; i++) {
const item = imetaTag[i];
if (item.startsWith('url ')) {
data.url = item.substring(4);
} else if (item.startsWith('dim ')) {
data.dimensions = item.substring(4);
} else if (item.startsWith('m ')) {
data.mimeType = item.substring(2);
} else if (item.startsWith('size ')) {
data.size = item.substring(5);
} else if (item.startsWith('blurhash ')) {
data.blurhash = item.substring(9);
} else if (item.startsWith('x ')) {
data.x = item.substring(2);
}
}
return data;
})()}
{#if imetaData.url && imetaData.mimeType?.startsWith('image/')}
<div class="relative">
<img
src={imetaData.url}
alt="imeta"
class="max-w-full h-auto rounded-lg border border-gray-200 dark:border-gray-700"
style="max-height: 300px; object-fit: cover;"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const fallback = (e.target as HTMLImageElement).nextElementSibling;
if (fallback) fallback.classList.remove('hidden');
}}
/>
<div class="hidden text-xs text-gray-500 dark:text-gray-400 mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded">
Image failed to load: {imetaData.url}
</div>
<!-- Image metadata -->
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{#if imetaData.dimensions}
<span class="mr-2">Size: {imetaData.dimensions}</span>
{/if}
{#if imetaData.size}
<span class="mr-2">File: {Math.round(parseInt(imetaData.size) / 1024)}KB</span>
{/if}
{#if imetaData.mimeType}
<span>Type: {imetaData.mimeType}</span>
{/if}
</div>
</div>
{:else if imetaData.url}
<!-- Non-image media -->
<div class="p-3 bg-gray-100 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div class="text-sm text-gray-600 dark:text-gray-400">
<a href={imetaData.url} target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-400 hover:underline">
View Media ({imetaData.mimeType || 'unknown type'})
</a>
</div>
{#if imetaData.size}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Size: {Math.round(parseInt(imetaData.size) / 1024)}KB
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/if}
{/if}
<!-- Text content -->
{#if event.content && event.content.trim()}
<div class="mt-3 prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
{@render parsedContent(event.content)}
</div>
{/if}
<!-- Alt text -->
{#if event.tags}
{@const altTag = event.tags.find(tag => tag[0] === 'alt')}
{#if altTag && altTag[1]}
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">
Alt: {altTag[1]}
</div>
{/if}
{/if}
</div>
</div>
<!-- Profile content -->
{:else if event.kind === 0 && profile}
<div class="space-y-2 min-w-0 overflow-hidden">
{#if profile.picture}
<img
src={profile.picture}
alt="Profile"
class="w-12 h-12 rounded-full object-cover flex-shrink-0"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
}}
/>
<div class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center flex-shrink-0 hidden">
<UserOutline class="w-6 h-6 text-gray-600 dark:text-gray-300" />
</div>
{:else}
<div class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center flex-shrink-0">
<UserOutline class="w-6 h-6 text-gray-600 dark:text-gray-300" />
</div>
{/if}
{#if profile.about}
<p class="text-sm text-gray-700 dark:text-gray-300 break-words">
{profile.about.slice(0, 200)}
{#if profile.about.length > 200}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</p>
{/if}
</div>
<!-- Generic content for other event kinds -->
{:else if event.content}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
<pre class="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto whitespace-pre-wrap break-words">
{event.content.slice(0, 300)}
{#if event.content.length > 300}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</pre>
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
No content
</div>
{/if}
<!-- Event identifiers -->
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 min-w-0 overflow-hidden">
<div class="flex flex-wrap gap-2 text-xs min-w-0">
<span class="text-gray-500 dark:text-gray-400 flex-shrink-0">ID:</span>
<a
href="/events?id={event!.id}"
class="font-mono text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-200 break-all cursor-pointer"
onclick={(e) => {
e.preventDefault();
goto(`/events?id=${event!.id}`);
}}
>
{event!.id.slice(0, 8)}...{event!.id.slice(-4)}
</a>
{#if isAddressableEvent(event!)}
<span class="text-gray-500 dark:text-gray-400 flex-shrink-0">Address:</span>
<span class="font-mono text-gray-700 dark:text-gray-300 break-all">
{getNaddrUrl(event!).slice(0, 12)}...{getNaddrUrl(event!).slice(-8)}
</span>
{/if}
</div>
</div>
</div>
{/if}

2
src/lib/components/event_input/EventForm.svelte

@ -3,7 +3,7 @@
import type { EventData, TagData, ValidationResult } from "./types"; import type { EventData, TagData, ValidationResult } from "./types";
import { validateEvent } from "./validation"; import { validateEvent } from "./validation";
// AI-NOTE: 2025-01-24 - EventForm component handles basic form inputs and validation // AI-NOTE: EventForm component handles basic form inputs and validation
// This component focuses on event kind and content, with validation feedback // This component focuses on event kind and content, with validation feedback
let { let {

2
src/lib/components/event_input/EventPreview.svelte

@ -6,7 +6,7 @@
import { build30040EventSet } from "$lib/utils/event_input_utils"; import { build30040EventSet } from "$lib/utils/event_input_utils";
import type { EventData, TagData, EventPreview } from "./types"; import type { EventData, TagData, EventPreview } from "./types";
// AI-NOTE: 2025-01-24 - EventPreview component shows a preview of the event that will be published // AI-NOTE: EventPreview component shows a preview of the event that will be published
// This component generates a preview based on the current form data // This component generates a preview based on the current form data
let { let {

2
src/lib/components/event_input/TagManager.svelte

@ -3,7 +3,7 @@
import { titleToDTag, requiresDTag } from "$lib/utils/event_input_utils"; import { titleToDTag, requiresDTag } from "$lib/utils/event_input_utils";
import type { TagData, PresetTag } from "./types"; import type { TagData, PresetTag } from "./types";
// AI-NOTE: 2025-01-24 - TagManager component handles tag management with preset tags // AI-NOTE: TagManager component handles tag management with preset tags
// This component automatically manages preset tags based on event kind and content // This component automatically manages preset tags based on event kind and content
let { let {

6
src/lib/components/publications/Publication.svelte

@ -109,7 +109,7 @@
// #endregion // #endregion
// AI-NOTE: 2025-01-24 - Combined effect to handle publicationTree changes and initial loading // AI-NOTE: Combined effect to handle publicationTree changes and initial loading
// This prevents conflicts between separate effects that could cause duplicate loading // This prevents conflicts between separate effects that could cause duplicate loading
$effect(() => { $effect(() => {
if (publicationTree) { if (publicationTree) {
@ -126,7 +126,7 @@
publicationTree.resetIterator(); publicationTree.resetIterator();
} }
// AI-NOTE: 2025-01-24 - Use setTimeout to ensure iterator reset completes before loading // AI-NOTE: Use setTimeout to ensure iterator reset completes before loading
// This prevents race conditions where loadMore is called before the iterator is fully reset // This prevents race conditions where loadMore is called before the iterator is fully reset
setTimeout(() => { setTimeout(() => {
// Load initial content after reset // Load initial content after reset
@ -235,7 +235,7 @@
{ threshold: 0.5 }, { threshold: 0.5 },
); );
// AI-NOTE: 2025-01-24 - Removed duplicate loadMore call // AI-NOTE: Removed duplicate loadMore call
// Initial content loading is handled by the $effect that watches publicationTree // Initial content loading is handled by the $effect that watches publicationTree
// This prevents duplicate loading when both onMount and $effect trigger // This prevents duplicate loading when both onMount and $effect trigger

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

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { naddrEncode, neventEncode } from "$lib/utils"; import { naddrEncode, neventEncode } from "$lib/utils";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { activeInboxRelays } from "$lib/ndk"; import { activeInboxRelays, getNdkContext } from "$lib/ndk";
import { Card } from "flowbite-svelte"; import { Card } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte"; import CardActions from "$components/util/CardActions.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
@ -11,6 +11,8 @@
const { event } = $props<{ event: NDKEvent }>(); const { event } = $props<{ event: NDKEvent }>();
const ndk = getNdkContext();
function getRelayUrls(): string[] { function getRelayUrls(): string[] {
return $activeInboxRelays; return $activeInboxRelays;
} }
@ -67,22 +69,22 @@
{/if} {/if}
</div> </div>
<div class="flex flex-col flex-grow space-x-2 min-w-0"> <div class="flex flex-col flex-grow min-w-0 overflow-hidden">
<div class="flex flex-col flex-grow min-w-0"> <div class="flex flex-col flex-grow min-w-0 overflow-hidden">
<a href="/{href}" class="flex flex-col space-y-2 h-full min-w-0"> <a href="/{href}" class="flex flex-col space-y-2 h-full min-w-0 overflow-hidden">
<div class="flex-grow pt-2 min-w-0"> <div class="flex-grow pt-2 min-w-0 overflow-hidden">
<h2 class="text-lg font-bold line-clamp-2 break-words" {title}>{title}</h2> <h2 class="text-lg font-bold line-clamp-2 break-words overflow-hidden" {title}>{title}</h2>
<h3 class="text-base font-normal mt-2 break-words"> <h3 class="text-base font-normal mt-2 break-words overflow-hidden">
by by
{#if authorPubkey != null} {#if authorPubkey != null}
{@render userBadge(authorPubkey, author)} {@render userBadge(authorPubkey, author, ndk)}
{:else} {:else}
{author} <span class="truncate">{author}</span>
{/if} {/if}
</h3> </h3>
</div> </div>
{#if version != "1"} {#if version != "1"}
<h3 class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-auto break-words">version: {version}</h3> <h3 class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-auto break-words overflow-hidden">version: {version}</h3>
{/if} {/if}
</a> </a>
</div> </div>

4
src/lib/components/publications/PublicationSection.svelte

@ -12,6 +12,7 @@
import type { TableOfContents as TocType } from "./table_of_contents.svelte"; import type { TableOfContents as TocType } from "./table_of_contents.svelte";
import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor"; import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor";
import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser"; import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser";
import NDK from "@nostr-dev-kit/ndk";
let { let {
address, address,
@ -30,6 +31,7 @@
} = $props(); } = $props();
const asciidoctor: Asciidoctor = getContext("asciidoctor"); const asciidoctor: Asciidoctor = getContext("asciidoctor");
const ndk: NDK = getContext("ndk");
let leafEvent: Promise<NDKEvent | null> = $derived.by( let leafEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(address), async () => await publicationTree.getEvent(address),
@ -62,7 +64,7 @@
} else { } else {
// For 30041 and 30818 events, use Asciidoctor (AsciiDoc) // For 30041 and 30818 events, use Asciidoctor (AsciiDoc)
const converted = asciidoctor.convert(content); const converted = asciidoctor.convert(content);
const processed = await postProcessAdvancedAsciidoctorHtml(converted.toString()); const processed = await postProcessAdvancedAsciidoctorHtml(converted.toString(), ndk);
return processed; return processed;
} }
}); });

4
src/lib/components/publications/table_of_contents.svelte.ts

@ -219,7 +219,7 @@ export class TableOfContents {
this.addressMap.set(childAddress, childEntry); this.addressMap.set(childAddress, childEntry);
} }
// AI-NOTE: 2025-01-24 - Removed redundant sorting since the publication tree already preserves 'a' tag order // AI-NOTE: Removed redundant sorting since the publication tree already preserves 'a' tag order
// The children are already in the correct order from the publication tree // The children are already in the correct order from the publication tree
// await this.#matchChildrenToTagOrder(entry); // await this.#matchChildrenToTagOrder(entry);
@ -255,7 +255,7 @@ export class TableOfContents {
return entry; return entry;
} }
// AI-NOTE: 2025-01-24 - Removed #matchChildrenToTagOrder method since the publication tree already preserves 'a' tag order // AI-NOTE: Removed #matchChildrenToTagOrder method since the publication tree already preserves 'a' tag order
// The children are already in the correct order from the publication tree, so no additional sorting is needed // The children are already in the correct order from the publication tree, so no additional sorting is needed
#buildTocEntryFromResolvedNode(address: string) { #buildTocEntryFromResolvedNode(address: string) {

16
src/lib/components/util/ArticleNav.svelte

@ -14,6 +14,9 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { indexKind } from "$lib/consts"; import { indexKind } from "$lib/consts";
import { getNdkContext } from "$lib/ndk";
const ndk = getNdkContext();
let { publicationType, indexEvent } = $props<{ let { publicationType, indexEvent } = $props<{
rootId: any; rootId: any;
@ -114,7 +117,18 @@
}); });
function visualizePublication() { function visualizePublication() {
// Use the event ID directly, but also try to get the tagAddress as a fallback
const eventId = indexEvent.id; const eventId = indexEvent.id;
const tagAddress = indexEvent.tagAddress();
// For debugging, log both identifiers
console.log("[ArticleNav] Visualizing publication:", {
eventId,
tagAddress,
kind: indexEvent.kind,
pubkey: indexEvent.pubkey
});
goto(`/visualize?event=${eventId}`); goto(`/visualize?event=${eventId}`);
} }
@ -182,7 +196,7 @@
</p> </p>
<p> <p>
<span class="whitespace-nowrap" <span class="whitespace-nowrap"
>by {@render userBadge(pubkey, author)}</span >by {@render userBadge(pubkey, author, ndk)}</span
> >
</p> </p>
</div> </div>

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

@ -8,7 +8,7 @@
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { neventEncode, naddrEncode } from "$lib/utils"; import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays } from "$lib/ndk"; import { activeInboxRelays, getNdkContext } from "$lib/ndk";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
@ -17,6 +17,8 @@
// Component props // Component props
let { event } = $props<{ event: NDKEvent }>(); let { event } = $props<{ event: NDKEvent }>();
const ndk = getNdkContext();
// Subscribe to userStore // Subscribe to userStore
let user = $state($userStore); let user = $state($userStore);
userStore.subscribe((val) => (user = val)); userStore.subscribe((val) => (user = val));
@ -205,7 +207,7 @@
<h2 class="text-base font-bold"> <h2 class="text-base font-bold">
by by
{#if originalAuthor} {#if originalAuthor}
{@render userBadge(originalAuthor, author)} {@render userBadge(originalAuthor, author, ndk)}
{:else} {:else}
{author || "Unknown"} {author || "Unknown"}
{/if} {/if}
@ -228,7 +230,7 @@
<div class="flex flex-row"> <div class="flex flex-row">
<h4 class="text-base font-normal mt-2"> <h4 class="text-base font-normal mt-2">
Index author: {@render userBadge(event.pubkey, author)} Index author: {@render userBadge(event.pubkey, author, ndk)}
</h4> </h4>
</div> </div>

13
src/lib/components/util/Details.svelte

@ -7,6 +7,9 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import LazyImage from "$components/util/LazyImage.svelte"; import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils"; import { generateDarkPastelColor } from "$lib/utils/image_utils";
import { getNdkContext } from "$lib/ndk";
const ndk = getNdkContext();
// isModal // isModal
// - don't show interactions in modal view // - don't show interactions in modal view
@ -62,7 +65,7 @@
<div class="flex flex-row justify-between items-center"> <div class="flex flex-row justify-between items-center">
<!-- Index author badge --> <!-- Index author badge -->
<P class="text-base font-normal" <P class="text-base font-normal"
>{@render userBadge(event.pubkey, undefined)}</P >{@render userBadge(event.pubkey, undefined, ndk)}</P
> >
<CardActions {event}></CardActions> <CardActions {event}></CardActions>
</div> </div>
@ -91,13 +94,13 @@
<h2 class="text-base font-bold"> <h2 class="text-base font-bold">
by by
{#if authorTag && pTag && isValidNostrPubkey(pTag)} {#if authorTag && pTag && isValidNostrPubkey(pTag)}
{authorTag} {@render userBadge(pTag, "")} {authorTag} {@render userBadge(pTag, "", ndk)}
{:else if authorTag} {:else if authorTag}
{authorTag} {authorTag}
{:else if pTag && isValidNostrPubkey(pTag)} {:else if pTag && isValidNostrPubkey(pTag)}
{@render userBadge(pTag, "")} {@render userBadge(pTag, "", ndk)}
{:else if originalAuthor !== null} {:else if originalAuthor !== null}
{@render userBadge(originalAuthor, author)} {@render userBadge(originalAuthor, author, ndk)}
{:else} {:else}
unknown unknown
{/if} {/if}
@ -139,7 +142,7 @@
{:else} {:else}
<span>Author:</span> <span>Author:</span>
{/if} {/if}
{@render userBadge(event.pubkey, "")} {@render userBadge(event.pubkey, "", ndk)}
</h4> </h4>
</div> </div>

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

@ -502,7 +502,7 @@
> >
<UserOutline <UserOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none" class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/><span class="underline">View profile</span> /><span class="underline">View notifications</span>
</button> </button>
</li> </li>

22
src/lib/data_structures/publication_tree.ts

@ -70,7 +70,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
#bookmark?: string; #bookmark?: string;
/** /**
* AI-NOTE: 2025-01-24 - Track visited nodes to prevent duplicate iteration * AI-NOTE: Track visited nodes to prevent duplicate iteration
* This ensures that each node is only yielded once during iteration * This ensures that each node is only yielded once during iteration
*/ */
#visitedNodes: Set<string> = new Set(); #visitedNodes: Set<string> = new Set();
@ -234,7 +234,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
/** /**
* AI-NOTE: 2025-01-24 - Reset the cursor to the beginning of the tree * AI-NOTE: Reset the cursor to the beginning of the tree
* This is useful when the component state is reset and we want to start iteration from the beginning * This is useful when the component state is reset and we want to start iteration from the beginning
*/ */
resetCursor() { resetCursor() {
@ -243,7 +243,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
/** /**
* AI-NOTE: 2025-01-24 - Reset the iterator state to start from the beginning * AI-NOTE: Reset the iterator state to start from the beginning
* This ensures that when the component resets, the iterator starts fresh * This ensures that when the component resets, the iterator starts fresh
*/ */
resetIterator() { resetIterator() {
@ -499,7 +499,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
const address = this.#cursor.target.address; const address = this.#cursor.target.address;
// AI-NOTE: 2025-01-24 - Check if this node has already been visited // AI-NOTE: Check if this node has already been visited
if (this.#visitedNodes.has(address)) { if (this.#visitedNodes.has(address)) {
console.debug(`[PublicationTree] Skipping already visited node: ${address}`); console.debug(`[PublicationTree] Skipping already visited node: ${address}`);
return { done: false, value: null }; return { done: false, value: null };
@ -761,7 +761,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
#addNode(address: string, parentNode: PublicationTreeNode) { #addNode(address: string, parentNode: PublicationTreeNode) {
// AI-NOTE: 2025-01-24 - Add debugging to track node addition // AI-NOTE: Add debugging to track node addition
console.debug(`[PublicationTree] Adding node ${address} to parent ${parentNode.address}`); console.debug(`[PublicationTree] Adding node ${address} to parent ${parentNode.address}`);
const lazyNode = new Lazy<PublicationTreeNode>(() => const lazyNode = new Lazy<PublicationTreeNode>(() =>
@ -792,7 +792,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!event) { if (!event) {
const [kind, pubkey, dTag] = address.split(":"); const [kind, pubkey, dTag] = address.split(":");
// AI-NOTE: 2025-01-24 - Enhanced event fetching with comprehensive fallback // AI-NOTE: Enhanced event fetching with comprehensive fallback
// First try to fetch using the enhanced fetchEventWithFallback function // First try to fetch using the enhanced fetchEventWithFallback function
// which includes search relay fallback logic // which includes search relay fallback logic
return fetchEventWithFallback(this.#ndk, { return fetchEventWithFallback(this.#ndk, {
@ -845,7 +845,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
/** /**
* AI-NOTE: 2025-01-24 - Aggressive search relay fallback for publication events * AI-NOTE: Aggressive search relay fallback for publication events
* This method tries to find events on search relays when they're not found on primary relays * This method tries to find events on search relays when they're not found on primary relays
*/ */
async #trySearchRelayFallback( async #trySearchRelayFallback(
@ -942,7 +942,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
/** /**
* AI-NOTE: 2025-01-24 - Helper method to build a node from an event * AI-NOTE: Helper method to build a node from an event
* This extracts the common logic for building nodes from events * This extracts the common logic for building nodes from events
*/ */
async #buildNodeFromEvent( async #buildNodeFromEvent(
@ -1014,7 +1014,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
}); });
// AI-NOTE: 2025-01-24 - Remove e-tag processing from synchronous method // AI-NOTE: Remove e-tag processing from synchronous method
// E-tags should be resolved asynchronously in #resolveNode method // E-tags should be resolved asynchronously in #resolveNode method
// Adding raw event IDs here causes duplicate processing // Adding raw event IDs here causes duplicate processing
console.debug(`[PublicationTree] Found ${eTags.length} e-tags but skipping processing in buildNodeFromEvent`); console.debug(`[PublicationTree] Found ${eTags.length} e-tags but skipping processing in buildNodeFromEvent`);
@ -1028,7 +1028,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
children: [], children: [],
}; };
// AI-NOTE: 2025-01-24 - Fixed child node addition in buildNodeFromEvent // AI-NOTE: Fixed child node addition in buildNodeFromEvent
// Previously called addEventByAddress which expected parent to be in tree // Previously called addEventByAddress which expected parent to be in tree
// Now directly adds child nodes to current node's children array // Now directly adds child nodes to current node's children array
// Add children in the order they appear in the a-tags to preserve section order // Add children in the order they appear in the a-tags to preserve section order
@ -1054,7 +1054,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
#getNodeType(event: NDKEvent): PublicationTreeNodeType { #getNodeType(event: NDKEvent): PublicationTreeNodeType {
// AI-NOTE: 2025-01-24 - Show nested 30040s and their zettel kind leaves // AI-NOTE: Show nested 30040s and their zettel kind leaves
// Only 30040 events with children should be branches // Only 30040 events with children should be branches
// Zettel kinds (30041, 30818, 30023) are always leaves // Zettel kinds (30041, 30818, 30023) are always leaves
if (event.kind === 30040) { if (event.kind === 30040) {

1
src/lib/models/user_profile.d.ts vendored

@ -1,6 +1,7 @@
export interface UserProfile { export interface UserProfile {
name?: string; name?: string;
display_name?: string; display_name?: string;
displayName?: string;
about?: string; about?: string;
picture?: string; picture?: string;
banner?: string; banner?: string;

34
src/lib/navigator/EventNetwork/Legend.svelte

@ -77,7 +77,7 @@
function invertTagSelection() { function invertTagSelection() {
// Invert selection - toggle all tags one by one // Invert selection - toggle all tags one by one
const allTagIds = tagAnchors.map((anchor: any) => `${anchor.type}-${anchor.label}`); const allTagIds = tagAnchors.map((anchor: any) => anchor.value);
// Process all tags // Process all tags
allTagIds.forEach((tagId: string) => { allTagIds.forEach((tagId: string) => {
@ -301,7 +301,7 @@
<div id="tag-anchors-content"> <div id="tag-anchors-content">
{#if autoDisabledTags} {#if autoDisabledTags}
<div class="text-xs text-amber-600 dark:text-amber-400 mb-2 p-2 bg-amber-50 dark:bg-amber-900/20 rounded"> <div class="text-xs text-amber-600 dark:text-amber-400 mb-2 p-2 bg-amber-50 dark:bg-amber-900/20 rounded">
<strong>Note:</strong> All {tagAnchors.length} tags were auto-disabled to prevent graph overload. Click individual tags below to enable them. <strong>Note:</strong> Some tags were auto-disabled to prevent graph overload. Click individual tags below to enable/disable them.
</div> </div>
{/if} {/if}
@ -330,28 +330,42 @@
<span class="text-xs text-gray-700 dark:text-gray-300">Alphabetical</span> <span class="text-xs text-gray-700 dark:text-gray-300">Alphabetical</span>
</label> </label>
</div> </div>
<!-- Invert Selection -->
<label class="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
onclick={invertTagSelection}
class="w-3 h-3"
/>
<span class="text-xs text-gray-700 dark:text-gray-300">Invert Selection</span>
</label>
</div> </div>
<div class="space-y-1 max-h-48 overflow-y-auto"> <div class="space-y-1 max-h-48 overflow-y-auto">
{#each sortedAnchors as tag} {#each sortedAnchors as tag}
{@const isDisabled = disabledTags.has(tag.value)} {@const isDisabled = disabledTags.has(tag.value)}
<button <button
class="flex items-center justify-between w-full p-2 rounded text-left border-none bg-none cursor-pointer transition hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50" class="flex items-center gap-2 w-full p-2 rounded text-left border-none bg-none cursor-pointer transition hover:bg-black/5 dark:hover:bg-white/5"
onclick={() => onTagToggle(tag.value)} onclick={() => onTagToggle(tag.value)}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? onTagToggle(tag.value) : null} onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? onTagToggle(tag.value) : null}
disabled={false}
title={isDisabled ? `Click to show ${tag.label}` : `Click to hide ${tag.label}`} title={isDisabled ? `Click to show ${tag.label}` : `Click to hide ${tag.label}`}
aria-pressed={!isDisabled} aria-pressed={!isDisabled}
> >
<span class="text-xs text-gray-700 dark:text-gray-300 truncate max-w-32" style="opacity: {isDisabled ? 0.5 : 1};" title="{tag.label} ({tag.count})"> <!-- Circular icon with # symbol -->
{tag.label} ({tag.count})
</span>
<div class="flex items-center"> <div class="flex items-center">
<span <span
class="inline-block w-3.5 h-3.5 rotate-45 border-2 border-white" class="w-4 h-4 rounded-full border-2 border-white flex items-center justify-center text-xs text-white font-bold"
style="background-color: {getEventKindColor(30040)}; opacity: {isDisabled ? 0.3 : 1};" style="background-color: {tag.color || '#FB7185'}; opacity: {isDisabled ? 0.3 : 1};"
></span> >
#
</span>
</div> </div>
<!-- Tag label with count -->
<span class="text-xs text-gray-700 dark:text-gray-300 flex-1" style="opacity: {isDisabled ? 0.5 : 1};">
{tag.label}
<span class="text-gray-500">({tag.count})</span>
</span>
</button> </button>
{/each} {/each}
</div> </div>

29
src/lib/navigator/EventNetwork/index.svelte

@ -156,7 +156,7 @@
let autoDisabledTags = $state(false); let autoDisabledTags = $state(false);
// Maximum number of tag anchors before auto-disabling // Maximum number of tag anchors before auto-disabling
const MAX_TAG_ANCHORS = 20; const MAX_TAG_ANCHORS = 50;
// Person nodes state // Person nodes state
let showPersonNodes = $state(false); let showPersonNodes = $state(false);
@ -168,6 +168,7 @@
let totalPersonCount = $state(0); let totalPersonCount = $state(0);
let displayedPersonCount = $state(0); let displayedPersonCount = $state(0);
let hasInitializedPersons = $state(false); let hasInitializedPersons = $state(false);
let hasInitializedTags = $state(new Map<string, boolean>());
// Update dimensions when container changes // Update dimensions when container changes
@ -298,9 +299,22 @@
label: n.title, label: n.title,
count: n.connectedNodes?.length || 0, count: n.connectedNodes?.length || 0,
color: getTagAnchorColor(n.tagType || ""), color: getTagAnchorColor(n.tagType || ""),
value: `${n.tagType}-${n.title}`, // Use the correct tag ID format for toggling
})); }));
// Auto-disable all tag anchors by default (only on first time showing this tag type)
if (!hasInitializedTags.get(selectedTagType) && tagAnchors.length > 0) {
tagAnchorInfo.forEach(anchor => {
disabledTags.add(anchor.value);
});
hasInitializedTags.set(selectedTagType, true);
}
} else { } else {
tagAnchorInfo = []; tagAnchorInfo = [];
// Reset initialization flag for this tag type when tag anchors are hidden
if (hasInitializedTags.get(selectedTagType) && tagAnchorInfo.length === 0) {
hasInitializedTags.set(selectedTagType, false);
}
} }
// Add person nodes if enabled // Add person nodes if enabled
@ -1108,11 +1122,14 @@
if (tagAnchorInfo.length > MAX_TAG_ANCHORS && !autoDisabledTags) { if (tagAnchorInfo.length > MAX_TAG_ANCHORS && !autoDisabledTags) {
// Defer the state update to break the sync cycle // Defer the state update to break the sync cycle
autoDisableTimer = setTimeout(() => { autoDisableTimer = setTimeout(() => {
debug(`Auto-disabling tags: ${tagAnchorInfo.length} exceeds maximum of ${MAX_TAG_ANCHORS}`); debug(`Auto-disabling excess tags: ${tagAnchorInfo.length} exceeds maximum of ${MAX_TAG_ANCHORS}`);
// Sort tags by count (most connected first) and disable the excess ones
const sortedTags = [...tagAnchorInfo].sort((a, b) => b.count - a.count);
const tagsToDisable = sortedTags.slice(MAX_TAG_ANCHORS);
// Disable all tags
const newDisabledTags = new Set<string>(); const newDisabledTags = new Set<string>();
tagAnchorInfo.forEach(anchor => { tagsToDisable.forEach(anchor => {
const tagId = `${anchor.type}-${anchor.label}`; const tagId = `${anchor.type}-${anchor.label}`;
newDisabledTags.add(tagId); newDisabledTags.add(tagId);
}); });
@ -1121,13 +1138,15 @@
autoDisabledTags = true; autoDisabledTags = true;
// Optional: Show a notification to the user // Optional: Show a notification to the user
console.info(`[EventNetwork] Auto-disabled ${tagAnchorInfo.length} tag anchors to prevent graph overload. Click individual tags in the legend to enable them.`); console.info(`[EventNetwork] Auto-disabled ${tagsToDisable.length} tag anchors to prevent graph overload. Click individual tags in the legend to enable them.`);
}, 0); }, 0);
} }
// Reset auto-disabled flag if tag count goes back down // Reset auto-disabled flag if tag count goes back down
if (tagAnchorInfo.length <= MAX_TAG_ANCHORS && autoDisabledTags) { if (tagAnchorInfo.length <= MAX_TAG_ANCHORS && autoDisabledTags) {
autoDisableTimer = setTimeout(() => { autoDisableTimer = setTimeout(() => {
// Clear disabled tags when we're back under the limit
disabledTags.clear();
autoDisabledTags = false; autoDisabledTags = false;
}, 0); }, 0);
} }

6
src/lib/ndk.ts

@ -44,7 +44,7 @@ export function setNdkContext(ndk: NDK): void {
setContext(NDK_CONTEXT_KEY, ndk); setContext(NDK_CONTEXT_KEY, ndk);
} }
// AI-NOTE: 2025-01-08 - Persistent relay storage to avoid recalculation // AI-NOTE: Persistent relay storage to avoid recalculation
let persistentRelaySet: let persistentRelaySet:
| { inboxRelays: string[]; outboxRelays: string[] } | { inboxRelays: string[]; outboxRelays: string[] }
| null = null; | null = null;
@ -532,7 +532,7 @@ export async function updateActiveRelayStores(
forceUpdate: boolean = false, forceUpdate: boolean = false,
): Promise<void> { ): Promise<void> {
try { try {
// AI-NOTE: 2025-01-08 - Use persistent relay set to avoid recalculation // AI-NOTE: Use persistent relay set to avoid recalculation
const now = Date.now(); const now = Date.now();
const cacheExpired = now - relaySetLastUpdated > RELAY_SET_CACHE_DURATION; const cacheExpired = now - relaySetLastUpdated > RELAY_SET_CACHE_DURATION;
@ -869,7 +869,7 @@ export function logout(user: NDKUser): void {
activeInboxRelays.set([]); activeInboxRelays.set([]);
activeOutboxRelays.set([]); activeOutboxRelays.set([]);
// AI-NOTE: 2025-01-08 - Clear persistent relay set on logout // AI-NOTE: Clear persistent relay set on logout
persistentRelaySet = null; persistentRelaySet = null;
relaySetLastUpdated = 0; relaySetLastUpdated = 0;
clearPersistentRelaySet(); // Clear persistent storage clearPersistentRelaySet(); // Clear persistent storage

2
src/lib/services/event_search_service.ts

@ -1,6 +1,6 @@
/** /**
* Service class for handling event search operations * Service class for handling event search operations
* AI-NOTE: 2025-01-24 - Extracted from EventSearch component for better separation of concerns * AI-NOTE: Extracted from EventSearch component for better separation of concerns
*/ */
export class EventSearchService { export class EventSearchService {
/** /**

2
src/lib/services/search_state_manager.ts

@ -1,6 +1,6 @@
/** /**
* Service class for managing search state operations * Service class for managing search state operations
* AI-NOTE: 2025-01-24 - Extracted from EventSearch component for better separation of concerns * AI-NOTE: Extracted from EventSearch component for better separation of concerns
*/ */
export class SearchStateManager { export class SearchStateManager {
/** /**

32
src/lib/components/embedded_events/EmbeddedSnippets.svelte → src/lib/snippets/EmbeddedSnippets.svelte

@ -7,7 +7,7 @@
import { buildCompleteRelaySet } from "$lib/utils/relay_management"; import { buildCompleteRelaySet } from "$lib/utils/relay_management";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { parseEmbeddedMarkup } from "$lib/utils/markup/embeddedMarkupParser"; import { parseEmbeddedMarkup } from "$lib/utils/markup/embeddedMarkupParser";
import type NDK from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk";
export { export {
parsedContent, parsedContent,
@ -275,9 +275,20 @@
{#if quotedMessage} {#if quotedMessage}
{@const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content"} {@const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content"}
{#await parseEmbeddedMarkup(quotedContent, 0) then parsedContent} {#await parseEmbeddedMarkup(quotedContent, 0) then parsedContent}
<button type="button" class="block text-left w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick={() => window.dispatchEvent(new CustomEvent('jump-to-message', { detail: eventId }))}> <div
class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300"
onclick={() => window.dispatchEvent(new CustomEvent('jump-to-message', { detail: eventId }))}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
window.dispatchEvent(new CustomEvent('jump-to-message', { detail: eventId }));
}
}}
>
{@html parsedContent} {@html parsedContent}
</button> </div>
{/await} {/await}
{:else} {:else}
{@const isValidEventId = /^[a-fA-F0-9]{64}$/.test(eventId)} {@const isValidEventId = /^[a-fA-F0-9]{64}$/.test(eventId)}
@ -291,9 +302,20 @@
} }
})()} })()}
{#if nevent} {#if nevent}
<button type="button" class="block text-left w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick={() => window.location.href=`/events?id=${nevent}`}> <div
class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300"
onclick={() => window.location.href=`/events?id=${nevent}`}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
window.location.href = `/events?id=${nevent}`;
}
}}
>
Quoted message not found. Click to view event {eventId.slice(0, 8)}... Quoted message not found. Click to view event {eventId.slice(0, 8)}...
</button> </div>
{:else} {:else}
<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded text-sm text-gray-600 dark:text-gray-300"> <div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded text-sm text-gray-600 dark:text-gray-300">
Quoted message not found. Event ID: {eventId.slice(0, 8)}... Quoted message not found. Event ID: {eventId.slice(0, 8)}...

16
src/lib/snippets/MarkupSnippets.svelte

@ -0,0 +1,16 @@
<script module lang="ts">
import { parseBasicMarkup } from "$lib/utils/markup/basicMarkupParser";
import NDK from "@nostr-dev-kit/ndk";
export { basicMarkup };
</script>
<!-- AI-NOTE: Use the snippet to parse basic markup, such as that found in the `content` field of
many event kinds, into HTML. -->
{#snippet basicMarkup(text: string, ndk?: NDK)}
{#await parseBasicMarkup(text, ndk) then parsed}
{@html parsed}
{:catch error: Error}
<div class="text-red-500">Error processing markup: {error.message}</div>
{/await}
{/snippet}

12
src/lib/snippets/UserSnippets.svelte

@ -6,28 +6,30 @@
getUserMetadata, getUserMetadata,
} from "$lib/utils/nostrUtils"; } from "$lib/utils/nostrUtils";
import type { UserProfile } from "$lib/models/user_profile"; import type { UserProfile } from "$lib/models/user_profile";
import NDK from "@nostr-dev-kit/ndk";
export { userBadge }; export { userBadge };
</script> </script>
{#snippet userBadge(identifier: string, displayText: string | undefined)} {#snippet userBadge(identifier: string, displayText: string | undefined, ndk?: NDK)}
{@const npub = toNpub(identifier)} {@const npub = toNpub(identifier)}
{#if npub} {#if npub}
{#if !displayText || displayText.trim().toLowerCase() === "unknown"} {#if !displayText || displayText.trim().toLowerCase() === "unknown"}
{#await getUserMetadata(npub, undefined, false) then profile} {#await getUserMetadata(npub, ndk, false) then profile}
{@const p = profile as UserProfile} {@const p = profile as UserProfile}
<span class="inline-flex items-center gap-0.5"> <span class="inline-flex items-center gap-0.5">
<button <button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer" class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)} onclick={() => goto(`/events?id=${npub}`)}
> >
@{p.display_name || @{p.displayName ||
p.display_name || p.display_name ||
p.name || p.name ||
npub.slice(0, 8) + "..." + npub.slice(-4)} npub.slice(0, 8) + "..." + npub.slice(-4)}
</button> </button>
</span> </span>
{:catch} {:catch error}
{@const debugError = console.error("Error fetching profile for", npub, ":", error)}
<span class="inline-flex items-center gap-0.5"> <span class="inline-flex items-center gap-0.5">
<button <button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer" class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
@ -38,7 +40,7 @@
</span> </span>
{/await} {/await}
{:else} {:else}
{#await createProfileLinkWithVerification(npub as string, displayText, undefined)} {#await createProfileLinkWithVerification(npub as string, displayText, ndk)}
<span class="inline-flex items-center gap-0.5"> <span class="inline-flex items-center gap-0.5">
<button <button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer" class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"

2
src/lib/stores/userStore.ts

@ -147,7 +147,7 @@ async function getUserPreferredRelays(
// --- Unified login/logout helpers --- // --- Unified login/logout helpers ---
// AI-NOTE: 2025-01-24 - Authentication persistence system // AI-NOTE: Authentication persistence system
// The application stores login information in localStorage to persist authentication across page refreshes. // The application stores login information in localStorage to persist authentication across page refreshes.
// The layout component automatically restores this authentication state on page load. // The layout component automatically restores this authentication state on page load.
// This prevents users from being logged out when refreshing the page. // This prevents users from being logged out when refreshing the page.

4
src/lib/utils/event_search.ts

@ -16,7 +16,7 @@ export async function searchEvent(query: string, ndk: NDK): Promise<NDKEvent | n
return null; return null;
} }
// AI-NOTE: 2025-01-24 - Wait for any relays to be available, not just pool relays // AI-NOTE: Wait for any relays to be available, not just pool relays
// This ensures searches can proceed even if some relay types are not available // This ensures searches can proceed even if some relay types are not available
let attempts = 0; let attempts = 0;
const maxAttempts = 5; // Reduced since we'll use fallback relays const maxAttempts = 5; // Reduced since we'll use fallback relays
@ -47,7 +47,7 @@ export async function searchEvent(query: string, ndk: NDK): Promise<NDKEvent | n
attempts++; attempts++;
} }
// AI-NOTE: 2025-01-24 - Don't fail if no relays are available, let fetchEventWithFallback handle fallbacks // AI-NOTE: Don't fail if no relays are available, let fetchEventWithFallback handle fallbacks
// The fetchEventWithFallback function will use all available relays including fallback relays // The fetchEventWithFallback function will use all available relays including fallback relays
if (ndk.pool.relays.size === 0) { if (ndk.pool.relays.size === 0) {
console.warn( console.warn(

11
src/lib/utils/image_utils.ts

@ -21,13 +21,4 @@ export function generateDarkPastelColor(seed: string): string {
return `#${r.toString(16).padStart(2, "0")}${ return `#${r.toString(16).padStart(2, "0")}${
g.toString(16).padStart(2, "0") g.toString(16).padStart(2, "0")
}${b.toString(16).padStart(2, "0")}`; }${b.toString(16).padStart(2, "0")}`;
} }
/**
* Test function to verify color generation
* @param eventId - The event ID to test
* @returns The generated color
*/
export function testColorGeneration(eventId: string): string {
return generateDarkPastelColor(eventId);
}

4
src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts

@ -1,3 +1,4 @@
import NDK from "@nostr-dev-kit/ndk";
import { postProcessAsciidoctorHtml } from "./asciidoctorPostProcessor.ts"; import { postProcessAsciidoctorHtml } from "./asciidoctorPostProcessor.ts";
import plantumlEncoder from "plantuml-encoder"; import plantumlEncoder from "plantuml-encoder";
@ -10,11 +11,12 @@ import plantumlEncoder from "plantuml-encoder";
*/ */
export async function postProcessAdvancedAsciidoctorHtml( export async function postProcessAdvancedAsciidoctorHtml(
html: string, html: string,
ndk?: NDK,
): Promise<string> { ): Promise<string> {
if (!html) return html; if (!html) return html;
try { try {
// First apply the basic post-processing (wikilinks, nostr addresses) // First apply the basic post-processing (wikilinks, nostr addresses)
let processedHtml = await postProcessAsciidoctorHtml(html); let processedHtml = await postProcessAsciidoctorHtml(html, ndk);
// Unified math block processing // Unified math block processing
processedHtml = fixAllMathBlocks(processedHtml); processedHtml = fixAllMathBlocks(processedHtml);
// Process PlantUML blocks // Process PlantUML blocks

8
src/lib/utils/markup/advancedMarkupParser.ts

@ -1,4 +1,4 @@
import { parseBasicmarkup } from "./basicMarkupParser.ts"; import { parseBasicMarkup } from "./basicMarkupParser.ts";
import hljs from "highlight.js"; import hljs from "highlight.js";
import "highlight.js/lib/common"; // Import common languages import "highlight.js/lib/common"; // Import common languages
import "highlight.js/styles/github-dark.css"; // Dark theme only import "highlight.js/styles/github-dark.css"; // Dark theme only
@ -443,8 +443,8 @@ export async function parseAdvancedmarkup(text: string): Promise<string> {
processedText = processInlineCodeMath(processedText); processedText = processInlineCodeMath(processedText);
// Step 4: Process block-level elements (tables, headings, horizontal rules) // Step 4: Process block-level elements (tables, headings, horizontal rules)
// AI-NOTE: 2025-01-24 - Removed duplicate processBlockquotes call to fix image rendering issues // AI-NOTE: Removed duplicate processBlockquotes call to fix image rendering issues
// Blockquotes are now processed only by parseBasicmarkup to avoid double-processing conflicts // Blockquotes are now processed only by parseBasicMarkup to avoid double-processing conflicts
processedText = processTables(processedText); processedText = processTables(processedText);
processedText = processHeadings(processedText); processedText = processHeadings(processedText);
processedText = processHorizontalRules(processedText); processedText = processHorizontalRules(processedText);
@ -454,7 +454,7 @@ export async function parseAdvancedmarkup(text: string): Promise<string> {
// Step 6: Process basic markup (which will also handle Nostr identifiers) // Step 6: Process basic markup (which will also handle Nostr identifiers)
// This includes paragraphs, inline code, links, lists, etc. // This includes paragraphs, inline code, links, lists, etc.
processedText = await parseBasicmarkup(processedText); processedText = await parseBasicMarkup(processedText);
// Step 7: Restore code blocks // Step 7: Restore code blocks
processedText = restoreCodeBlocks(processedText, blocks); processedText = restoreCodeBlocks(processedText, blocks);

53
src/lib/utils/markup/asciidoctorPostProcessor.ts

@ -1,56 +1,10 @@
import NDK from "@nostr-dev-kit/ndk";
import { import {
processAsciiDocAnchors, processAsciiDocAnchors,
processImageWithReveal, processImageWithReveal,
processNostrIdentifiersInText, processNostrIdentifiersInText,
processWikilinks, processWikilinks,
} from "./markupServices.ts"; } from "./markupUtils.ts";
/**
* Processes nostr addresses in HTML content, but skips addresses that are
* already within hyperlink tags.
*/
async function processNostrAddresses(html: string): Promise<string> {
// Helper to check if a match is within an existing <a> tag
function isWithinLink(text: string, index: number): boolean {
// Look backwards from the match position to find the nearest <a> tag
const before = text.slice(0, index);
const lastOpenTag = before.lastIndexOf("<a");
const lastCloseTag = before.lastIndexOf("</a>");
// If we find an opening <a> tag after the last closing </a> tag, we're inside a link
return lastOpenTag > lastCloseTag;
}
// Process nostr addresses that are not within existing links
const nostrPattern =
/nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
let processedHtml = html;
// Find all nostr addresses
const matches = Array.from(processedHtml.matchAll(nostrPattern));
// Process them in reverse order to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
// Skip if already within a link
if (isWithinLink(processedHtml, matchIndex)) {
continue;
}
// Process the nostr identifier
const processedMatch = await processNostrIdentifiersInText(fullMatch);
// Replace the match in the HTML
processedHtml = processedHtml.slice(0, matchIndex) +
processedMatch +
processedHtml.slice(matchIndex + fullMatch.length);
}
return processedHtml;
}
/** /**
* Processes AsciiDoc image blocks to add reveal/enlarge functionality * Processes AsciiDoc image blocks to add reveal/enlarge functionality
@ -101,6 +55,7 @@ function fixStemBlocks(html: string): string {
*/ */
export async function postProcessAsciidoctorHtml( export async function postProcessAsciidoctorHtml(
html: string, html: string,
ndk?: NDK,
): Promise<string> { ): Promise<string> {
if (!html) return html; if (!html) return html;
@ -110,7 +65,7 @@ export async function postProcessAsciidoctorHtml(
// Then process wikilinks in [[...]] format (if any remain) // Then process wikilinks in [[...]] format (if any remain)
processedHtml = processWikilinks(processedHtml); processedHtml = processWikilinks(processedHtml);
// Then process nostr addresses (but not those already in links) // Then process nostr addresses (but not those already in links)
processedHtml = await processNostrIdentifiersInText(processedHtml); processedHtml = await processNostrIdentifiersInText(processedHtml, ndk);
processedHtml = fixStemBlocks(processedHtml); // Fix math blocks for MathJax processedHtml = fixStemBlocks(processedHtml); // Fix math blocks for MathJax
// Process image blocks to add reveal/enlarge functionality // Process image blocks to add reveal/enlarge functionality
processedHtml = processImageBlocks(processedHtml); processedHtml = processImageBlocks(processedHtml);

246
src/lib/utils/markup/basicMarkupParser.ts

@ -1,232 +1,50 @@
import { nip19 } from "nostr-tools"; import NDK from "@nostr-dev-kit/ndk";
import { import {
processBasicTextFormatting, processBasicFormatting,
processBlockquotes, processBlockquotes,
processEmojiShortcodes, processEmojiShortcodes,
processHashtags,
processImageWithReveal,
processMediaUrl,
processNostrIdentifiersInText, processNostrIdentifiersInText,
processWebSocketUrls,
processWikilinks, processWikilinks,
stripTrackingParams, } from "./markupUtils.ts";
} from "./markupServices.ts";
/* Regex constants for basic markup parsing */
// Links and media
const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g;
// AI-NOTE: 2025-01-24 - Added negative lookbehind (?<!\]\() to prevent processing URLs in markdown syntax
const DIRECT_LINK = /(?<!["'=])(?<!\]\()(https?:\/\/[^\s<>"]+)(?!["'])/g;
// Add this helper function near the top: export function preProcessBasicMarkup(text: string): string {
function replaceAlexandriaNostrLinks(text: string): string { try {
// Regex for Alexandria/localhost URLs // Process basic text formatting first
const alexandriaPattern = let processedText = processBasicFormatting(text);
/^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i;
// Regex for bech32 Nostr identifiers
const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/;
// Regex for 64-char hex
const hexPattern = /\b[a-fA-F0-9]{64}\b/;
// 1. Alexandria/localhost markup links
text = text.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(match, _label, url) => {
if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return match;
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return match;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
return match;
},
);
// 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers // Process emoji shortcuts
text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => { processedText = processEmojiShortcodes(processedText);
if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return url;
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return url;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
// For non-Alexandria/localhost URLs, just return the URL as-is
return url;
});
return text; // Process blockquotes
} processedText = processBlockquotes(processedText);
function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string { return processedText;
function parseList( } catch (e) {
start: number, throw new Error(`[preProcessBasicMarkup] Error: ${e}`);
indent: number,
type: "ol" | "ul",
): [string, number] {
let html = "";
let i = start;
html += `<${type} class="${
type === "ol" ? "list-decimal" : "list-disc"
} ml-6 mb-2">`;
while (i < lines.length) {
const line = lines[i];
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/);
if (!match) break;
const lineIndent = match[1].replace(/\t/g, " ").length;
const isOrdered = /\d+\./.test(match[2]);
const itemType = isOrdered ? "ol" : "ul";
if (lineIndent > indent) {
// Nested list
const [nestedHtml, consumed] = parseList(i, lineIndent, itemType);
html = html.replace(/<\/li>$/, "") + nestedHtml + "</li>";
i = consumed;
continue;
}
if (lineIndent < indent || itemType !== type) {
break;
}
html += `<li class="mb-1">${match[3]}`;
// Check for next line being a nested list
if (i + 1 < lines.length) {
const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
if (nextMatch) {
const nextIndent = nextMatch[1].replace(/\t/g, " ").length;
const nextType = /\d+\./.test(nextMatch[2]) ? "ol" : "ul";
if (nextIndent > lineIndent) {
const [nestedHtml, consumed] = parseList(
i + 1,
nextIndent,
nextType,
);
html += nestedHtml;
i = consumed - 1;
}
}
}
html += "</li>";
i++;
}
html += `</${type}>`;
return [html, i];
} }
if (!lines.length) return "";
const firstLine = lines[0];
const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
const indent = match ? match[1].replace(/\t/g, " ").length : 0;
const type = typeHint || (match && /\d+\./.test(match[2]) ? "ol" : "ul");
const [html] = parseList(0, indent, type);
return html;
} }
function processBasicFormatting(content: string): string { export async function postProcessBasicMarkup(text: string, ndk?: NDK): Promise<string> {
if (!content) return "";
let processedText = content;
try { try {
// Sanitize Alexandria Nostr links before further processing // Process Nostr identifiers last
processedText = replaceAlexandriaNostrLinks(processedText); let processedText = await processNostrIdentifiersInText(text, ndk);
// Process markup images first
processedText = processedText.replace(MARKUP_IMAGE, (match, alt, url) => {
// Clean the URL and alt text
const cleanUrl = url.trim();
const cleanAlt = alt ? alt.trim() : "";
return processImageWithReveal(cleanUrl, cleanAlt);
});
// Process markup links
processedText = processedText.replace(
MARKUP_LINK,
(_match, text, url) =>
`<a href="${
stripTrackingParams(url)
}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`,
);
// Process WebSocket URLs using shared services
processedText = processWebSocketUrls(processedText);
// Process direct media URLs and auto-link all URLs
processedText = processedText.replace(DIRECT_LINK, (match) => {
return processMediaUrl(match);
});
// Process text formatting using shared services
processedText = processBasicTextFormatting(processedText);
// Process hashtags using shared services // Replace wikilinks
processedText = processHashtags(processedText); processedText = processWikilinks(processedText);
// --- Improved List Grouping and Parsing --- return processedText;
const lines = processedText.split("\n"); } catch (e) {
let output = ""; throw new Error(`[postProcessBasicMarkup] Error: ${e}`);
let buffer: string[] = [];
let inList = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^([ \t]*)([*+-]|\d+\.)[ \t]+/.test(line)) {
buffer.push(line);
inList = true;
} else {
if (inList) {
const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
buffer = [];
inList = false;
}
output += (output && !output.endsWith("\n") ? "\n" : "") + line + "\n";
}
}
if (buffer.length) {
const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
}
processedText = output;
// --- End Improved List Grouping and Parsing ---
} catch (e: unknown) {
console.error("Error in processBasicFormatting:", e);
} }
return processedText;
} }
export async function parseBasicmarkup(text: string): Promise<string> { export async function parseBasicMarkup(text: string, ndk?: NDK): Promise<string> {
if (!text) return ""; if (!text) return "";
try { try {
// Process basic text formatting first let processedText = preProcessBasicMarkup(text);
let processedText = processBasicFormatting(text);
// Process emoji shortcuts
processedText = processEmojiShortcodes(processedText);
// Process blockquotes
processedText = processBlockquotes(processedText);
// Process paragraphs - split by double newlines and wrap in p tags // Process paragraphs - split by double newlines and wrap in p tags
// Skip wrapping if content already contains block-level elements // Skip wrapping if content already contains block-level elements
@ -235,7 +53,7 @@ export async function parseBasicmarkup(text: string): Promise<string> {
.map((para) => para.trim()) .map((para) => para.trim())
.filter((para) => para.length > 0) .filter((para) => para.length > 0)
.map((para) => { .map((para) => {
// AI-NOTE: 2025-01-24 - Added img tag to skip wrapping to prevent image rendering issues // AI-NOTE: Added img tag to skip wrapping to prevent image rendering issues
// Skip wrapping if para already contains block-level elements, math blocks, or images // Skip wrapping if para already contains block-level elements, math blocks, or images
if ( if (
/(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr|img)/i /(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr|img)/i
@ -245,21 +63,15 @@ export async function parseBasicmarkup(text: string): Promise<string> {
) { ) {
return para; return para;
} }
return `<p class="my-4">${para}</p>`; return `<p class="my-4 no-indent">${para}</p>`;
}) })
.join("\n"); .join("\n");
// Process Nostr identifiers last // Process Nostr identifiers last
processedText = await processNostrIdentifiersInText(processedText); processedText = await postProcessBasicMarkup(processedText, ndk);
// Replace wikilinks
processedText = processWikilinks(processedText);
return processedText; return processedText;
} catch (e: unknown) { } catch (e) {
console.error("Error in parseBasicmarkup:", e); throw new Error(`Error in parseBasicMarkup: ${e}`);
return `<div class="text-red-500">Error processing markup: ${
(e as Error)?.message ?? "Unknown error"
}</div>`;
} }
} }

241
src/lib/utils/markup/embeddedMarkupParser.ts

@ -1,224 +1,9 @@
import { nip19 } from "nostr-tools"; import { postProcessBasicMarkup, preProcessBasicMarkup } from "./basicMarkupParser.ts";
import { import { processNostrIdentifiersWithEmbeddedEvents } from "./markupUtils.ts";
processBasicTextFormatting,
processBlockquotes,
processEmojiShortcodes,
processHashtags,
processImageWithReveal,
processMediaUrl,
processNostrIdentifiersInText,
processNostrIdentifiersWithEmbeddedEvents,
processWebSocketUrls,
processWikilinks,
stripTrackingParams,
} from "./markupServices.ts";
/* Regex constants for basic markup parsing */
// Links and media
const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g;
// AI-NOTE: 2025-01-24 - Added negative lookbehind (?<!\]\() to prevent processing URLs in markdown syntax
const DIRECT_LINK = /(?<!["'=])(?<!\]\()(https?:\/\/[^\s<>"]+)(?!["'])/g;
// Add this helper function near the top:
function replaceAlexandriaNostrLinks(text: string): string {
// Regex for Alexandria/localhost URLs
const alexandriaPattern =
/^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i;
// Regex for bech32 Nostr identifiers
const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/;
// Regex for 64-char hex
const hexPattern = /\b[a-fA-F0-9]{64}\b/;
// 1. Alexandria/localhost markup links
text = text.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(match, _label, url) => {
if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return match;
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return match;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
return match;
},
);
// 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers
text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => {
if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return url;
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return url;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
// For non-Alexandria/localhost URLs, just return the URL as-is
return url;
});
return text;
}
function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string {
function parseList(
start: number,
indent: number,
type: "ol" | "ul",
): [string, number] {
let html = "";
let i = start;
html += `<${type} class="${
type === "ol" ? "list-decimal" : "list-disc"
} ml-6 mb-2">`;
while (i < lines.length) {
const line = lines[i];
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/);
if (!match) break;
const lineIndent = match[1].replace(/\t/g, " ").length;
const isOrdered = /\d+\./.test(match[2]);
const itemType = isOrdered ? "ol" : "ul";
if (lineIndent > indent) {
// Nested list
const [nestedHtml, consumed] = parseList(i, lineIndent, itemType);
html = html.replace(/<\/li>$/, "") + nestedHtml + "</li>";
i = consumed;
continue;
}
if (lineIndent < indent || itemType !== type) {
break;
}
html += `<li class="mb-1">${match[3]}`;
// Check for next line being a nested list
if (i + 1 < lines.length) {
const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
if (nextMatch) {
const nextIndent = nextMatch[1].replace(/\t/g, " ").length;
const nextType = /\d+\./.test(nextMatch[2]) ? "ol" : "ul";
if (nextIndent > lineIndent) {
const [nestedHtml, consumed] = parseList(
i + 1,
nextIndent,
nextType,
);
html += nestedHtml;
i = consumed - 1;
}
}
}
html += "</li>";
i++;
}
html += `</${type}>`;
return [html, i];
}
if (!lines.length) return "";
const firstLine = lines[0];
const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
const indent = match ? match[1].replace(/\t/g, " ").length : 0;
const type = typeHint || (match && /\d+\./.test(match[2]) ? "ol" : "ul");
const [html] = parseList(0, indent, type);
return html;
}
function processBasicFormatting(content: string): string {
if (!content) return "";
let processedText = content;
try {
// Sanitize Alexandria Nostr links before further processing
processedText = replaceAlexandriaNostrLinks(processedText);
// Process markup images first
processedText = processedText.replace(MARKUP_IMAGE, (match, alt, url) => {
// Clean the URL and alt text
const cleanUrl = url.trim();
const cleanAlt = alt ? alt.trim() : "";
return processImageWithReveal(cleanUrl, cleanAlt);
});
// Process markup links
processedText = processedText.replace(
MARKUP_LINK,
(_match, text, url) =>
`<a href="${
stripTrackingParams(url)
}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`,
);
// Process WebSocket URLs using shared services
processedText = processWebSocketUrls(processedText);
// Process direct media URLs and auto-link all URLs
processedText = processedText.replace(DIRECT_LINK, (match) => {
return processMediaUrl(match);
});
// Process text formatting using shared services
processedText = processBasicTextFormatting(processedText);
// Process hashtags using shared services
processedText = processHashtags(processedText);
// --- Improved List Grouping and Parsing ---
const lines = processedText.split("\n");
let output = "";
let buffer: string[] = [];
let inList = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^([ \t]*)([*+-]|\d+\.)[ \t]+/.test(line)) {
buffer.push(line);
inList = true;
} else {
if (inList) {
const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
buffer = [];
inList = false;
}
output += (output && !output.endsWith("\n") ? "\n" : "") + line + "\n";
}
}
if (buffer.length) {
const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
}
processedText = output;
// --- End Improved List Grouping and Parsing ---
} catch (e: unknown) {
console.error("Error in processBasicFormatting:", e);
}
return processedText;
}
/** /**
* Parse markup with support for embedded Nostr events * Parse markup with support for embedded Nostr events
* AI-NOTE: 2025-01-24 - Enhanced markup parser that supports nested Nostr event embedding * AI-NOTE: Enhanced markup parser that supports nested Nostr event embedding
* Up to 3 levels of nesting are supported, after which events are shown as links * Up to 3 levels of nesting are supported, after which events are shown as links
*/ */
export async function parseEmbeddedMarkup( export async function parseEmbeddedMarkup(
@ -228,14 +13,7 @@ export async function parseEmbeddedMarkup(
if (!text) return ""; if (!text) return "";
try { try {
// Process basic text formatting first let processedText = preProcessBasicMarkup(text);
let processedText = processBasicFormatting(text);
// Process emoji shortcuts
processedText = processEmojiShortcodes(processedText);
// Process blockquotes
processedText = processBlockquotes(processedText);
// Process paragraphs - split by double newlines and wrap in p tags // Process paragraphs - split by double newlines and wrap in p tags
// Skip wrapping if content already contains block-level elements // Skip wrapping if content already contains block-level elements
@ -251,21 +29,18 @@ export async function parseEmbeddedMarkup(
return para; return para;
} }
return `<p class="my-1">${para}</p>`; return `<p class="my-1 no-indent">${para}</p>`;
}) })
.join("\n"); .join("\n");
// Process profile identifiers (npub, nprofile) first using the regular processor // Process event identifiers with embedded events (only event-related identifiers)
processedText = await processNostrIdentifiersInText(processedText);
// Then process event identifiers with embedded events (only event-related identifiers)
processedText = processNostrIdentifiersWithEmbeddedEvents( processedText = processNostrIdentifiersWithEmbeddedEvents(
processedText, processedText,
nestingLevel, nestingLevel,
); );
// Replace wikilinks // Process profile identifiers (npub, nprofile) using the regular processor
processedText = processWikilinks(processedText); processedText = await postProcessBasicMarkup(processedText);
return processedText; return processedText;
} catch (e: unknown) { } catch (e: unknown) {

321
src/lib/utils/markup/markupServices.ts

@ -1,321 +0,0 @@
import NDK from "@nostr-dev-kit/ndk";
import {
createProfileLink,
getUserMetadata,
NOSTR_PROFILE_REGEX,
} from "../nostrUtils.ts";
import * as emoji from "node-emoji";
// Media URL patterns
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i;
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX =
/https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/;
/**
* Shared service for processing images with expand functionality
*/
export function processImageWithReveal(
src: string,
alt: string = "Image",
): string {
if (!src || !IMAGE_EXTENSIONS.test(src.split("?")[0])) {
return `<img src="${src}" alt="${alt}">`;
}
return `<div class="relative inline-block w-[300px] h-48 my-2 group">
<img
src="${src}"
alt="${alt}"
class="w-full h-full object-contain rounded-lg shadow-lg"
loading="lazy"
decoding="async"
/>
<!-- Expand button -->
<button class="absolute top-2 right-2 bg-black/60 hover:bg-black/80 backdrop-blur-sm text-white rounded-full w-8 h-8 flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110 z-20"
onclick="window.open('${src}', '_blank')"
title="Open image in full size"
aria-label="Open image in full size">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</button>
</div>`;
}
/**
* Shared service for processing media URLs
*/
export function processMediaUrl(url: string, alt?: string): string {
const clean = stripTrackingParams(url);
if (YOUTUBE_URL_REGEX.test(clean)) {
const videoId = extractYouTubeVideoId(clean);
if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-2" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${
alt || "YouTube video"
}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(clean)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-2" preload="none" playsinline><source src="${clean}">${
alt || "Video"
}</video>`;
}
if (AUDIO_URL_REGEX.test(clean)) {
return `<audio controls class="w-full my-2" preload="none"><source src="${clean}">${
alt || "Audio"
}</audio>`;
}
if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) {
return processImageWithReveal(clean, alt || "Embedded media");
}
// Default to clickable link
return `<a href="${clean}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${clean}</a>`;
}
/**
* Shared service for processing nostr identifiers
*/
export async function processNostrIdentifiersInText(
text: string,
ndk?: NDK,
): Promise<string> {
let processedText = text;
// Find all profile-related nostr addresses (only npub and nprofile)
const matches = Array.from(processedText.matchAll(NOSTR_PROFILE_REGEX));
// Process them in reverse order to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
// Skip if part of a URL
const before = processedText.slice(
Math.max(0, matchIndex - 12),
matchIndex,
);
if (/https?:\/\/$|www\.$/i.test(before)) {
continue;
}
// Process the nostr identifier directly
let identifier = fullMatch;
if (!identifier.startsWith("nostr:")) {
identifier = "nostr:" + identifier;
}
// Get user metadata and create link
let metadata;
if (ndk) {
metadata = await getUserMetadata(identifier, ndk);
} else {
// Fallback when NDK is not available - just use the identifier
metadata = { name: identifier.slice(0, 8) + "..." + identifier.slice(-4) };
}
const displayText = metadata.displayName || metadata.name;
const link = createProfileLink(identifier, displayText);
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + link +
processedText.slice(matchIndex + fullMatch.length);
}
return processedText;
}
/**
* Shared service for processing nostr identifiers with embedded events
* Replaces nostr: links with embedded event placeholders
* Only processes event-related identifiers (nevent, naddr, note), not profile identifiers (npub, nprofile)
*/
export function processNostrIdentifiersWithEmbeddedEvents(
text: string,
nestingLevel: number = 0,
): string {
const eventPattern = /nostr:(note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
let processedText = text;
// Maximum nesting level allowed
const MAX_NESTING_LEVEL = 3;
// Find all event-related nostr addresses
const matches = Array.from(processedText.matchAll(eventPattern));
// Process them in reverse order to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
let replacement: string;
if (nestingLevel >= MAX_NESTING_LEVEL) {
// At max nesting level, just show the link
replacement =
`<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all">${fullMatch}</a>`;
} else {
// Create a placeholder for embedded event
const componentId = `embedded-event-${
Math.random().toString(36).substr(2, 9)
}`;
replacement =
`<div class="embedded-event-placeholder" data-nostr-id="${fullMatch}" data-nesting-level="${nestingLevel}" id="${componentId}"></div>`;
}
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement +
processedText.slice(matchIndex + fullMatch.length);
}
return processedText;
}
/**
* Shared service for processing emoji shortcodes
*/
export function processEmojiShortcodes(text: string): string {
return emoji.emojify(text);
}
/**
* Shared service for processing WebSocket URLs
*/
export function processWebSocketUrls(text: string): string {
const wssUrlRegex = /wss:\/\/[^\s<>"]+/g;
return text.replace(wssUrlRegex, (match) => {
const cleanUrl = match.slice(6).replace(/\/+$/, "");
return `<a href="https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-500 hover:underline">${match}</a>`;
});
}
/**
* Shared service for processing hashtags
*/
export function processHashtags(text: string): string {
const hashtagRegex = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g;
return text.replace(
hashtagRegex,
'<button class="text-primary-600 dark:text-primary-500 hover:underline cursor-pointer" onclick="window.location.href=\'/events?t=$1\'">#$1</button>',
);
}
/**
* Shared service for processing basic text formatting
*/
export function processBasicTextFormatting(text: string): string {
// Bold: **text** or *text*
text = text.replace(
/(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g,
"<strong>$2</strong>",
);
// Italic: _text_ or __text__
text = text.replace(/\b(_[^_\n]+_|\b__[^_\n]+__)\b/g, (match) => {
const text = match.replace(/^_+|_+$/g, "");
return `<em>${text}</em>`;
});
// Strikethrough: ~~text~~ or ~text~
text = text.replace(
/~~([^~\n]+)~~|~([^~\n]+)~/g,
(_match, doubleText, singleText) => {
const text = doubleText || singleText;
return `<del class="line-through">${text}</del>`;
},
);
return text;
}
/**
* Shared service for processing blockquotes
*/
export function processBlockquotes(text: string): string {
const blockquoteRegex = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm;
return text.replace(blockquoteRegex, (match) => {
const lines = match.split("\n").map((line) => {
return line.replace(/^[ \t]*>[ \t]?/, "").trim();
});
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${
lines.join("\n")
}</blockquote>`;
});
}
// Helper functions
export function stripTrackingParams(url: string): string {
try {
const urlObj = new URL(url);
// Remove common tracking parameters
const trackingParams = [
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"fbclid",
"gclid",
];
trackingParams.forEach((param) => urlObj.searchParams.delete(param));
return urlObj.toString();
} catch {
return url;
}
}
function extractYouTubeVideoId(url: string): string | null {
const match = url.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
);
return match ? match[1] : null;
}
/**
* Normalizes a string for use as a d-tag by converting to lowercase,
* replacing non-alphanumeric characters with dashes, and removing
* leading/trailing dashes.
*/
function normalizeDTag(input: string): string {
return input
.toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Shared service for processing wikilinks in the format [[target]] or [[target|display]]
*/
export function processWikilinks(text: string): string {
// [[target page]] or [[target page|display text]]
return text.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `/events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
},
);
}
/**
* Shared service for processing AsciiDoc anchor tags
*/
export function processAsciiDocAnchors(text: string): string {
return text.replace(/<a id="([^"]+)"><\/a>/g, (_match, id) => {
const normalized = normalizeDTag(id.trim());
const url = `/events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`;
});
}

629
src/lib/utils/markup/markupUtils.ts

@ -0,0 +1,629 @@
import NDK from "@nostr-dev-kit/ndk";
import {
createProfileLink,
getUserMetadata,
nip19,
NOSTR_PROFILE_REGEX,
} from "../nostrUtils.ts";
import * as emoji from "node-emoji";
// Media URL patterns
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i;
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX =
/https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/;
// Links and media
const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g;
// AI-NOTE: Added negative lookbehind (?<!\]\() to prevent processing URLs in markdown syntax
const DIRECT_LINK = /(?<!["'=])(?<!\]\()(https?:\/\/[^\s<>"]+)(?!["'])/g;
// Add this helper function near the top:
export function replaceAlexandriaNostrLinks(text: string): string {
// Regex for Alexandria/localhost URLs
const alexandriaPattern =
/^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i;
// Regex for bech32 Nostr identifiers
const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/;
// Regex for 64-char hex
const hexPattern = /\b[a-fA-F0-9]{64}\b/;
// 1. Alexandria/localhost markup links
text = text.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(match, _label, url) => {
if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return match;
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return match;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
return match;
},
);
// 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers
text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => {
if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return url;
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return url;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
// For non-Alexandria/localhost URLs, just return the URL as-is
return url;
});
return text;
}
export function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string {
function parseList(
start: number,
indent: number,
type: "ol" | "ul",
): [string, number] {
let html = "";
let i = start;
html += `<${type} class="${
type === "ol" ? "list-decimal" : "list-disc"
} ml-6 mb-2">`;
while (i < lines.length) {
const line = lines[i];
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/);
if (!match) break;
const lineIndent = match[1].replace(/\t/g, " ").length;
const isOrdered = /\d+\./.test(match[2]);
const itemType = isOrdered ? "ol" : "ul";
if (lineIndent > indent) {
// Nested list
const [nestedHtml, consumed] = parseList(i, lineIndent, itemType);
html = html.replace(/<\/li>$/, "") + nestedHtml + "</li>";
i = consumed;
continue;
}
if (lineIndent < indent || itemType !== type) {
break;
}
html += `<li class="mb-1">${match[3]}`;
// Check for next line being a nested list
if (i + 1 < lines.length) {
const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
if (nextMatch) {
const nextIndent = nextMatch[1].replace(/\t/g, " ").length;
const nextType = /\d+\./.test(nextMatch[2]) ? "ol" : "ul";
if (nextIndent > lineIndent) {
const [nestedHtml, consumed] = parseList(
i + 1,
nextIndent,
nextType,
);
html += nestedHtml;
i = consumed - 1;
}
}
}
html += "</li>";
i++;
}
html += `</${type}>`;
return [html, i];
}
if (!lines.length) return "";
const firstLine = lines[0];
const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
const indent = match ? match[1].replace(/\t/g, " ").length : 0;
const type = typeHint || (match && /\d+\./.test(match[2]) ? "ol" : "ul");
const [html] = parseList(0, indent, type);
return html;
}
export function processBasicFormatting(content: string): string {
if (!content) return "";
let processedText = content;
try {
// Sanitize Alexandria Nostr links before further processing
processedText = replaceAlexandriaNostrLinks(processedText);
// Process markup images first
processedText = processedText.replace(MARKUP_IMAGE, (match, alt, url) => {
// Clean the URL and alt text
const cleanUrl = url.trim();
const cleanAlt = alt ? alt.trim() : "";
return processImageWithReveal(cleanUrl, cleanAlt);
});
// Process markup links
processedText = processedText.replace(
MARKUP_LINK,
(_match, text, url) =>
`<a href="${
stripTrackingParams(url)
}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`,
);
// Process WebSocket URLs using shared services
processedText = processWebSocketUrls(processedText);
// Process direct media URLs and auto-link all URLs
processedText = processedText.replace(DIRECT_LINK, (match) => {
return processMediaUrl(match);
});
// Process text formatting using shared services
processedText = processBasicTextFormatting(processedText);
// Process hashtags using shared services
processedText = processHashtags(processedText);
// --- Improved List Grouping and Parsing ---
const lines = processedText.split("\n");
let output = "";
let buffer: string[] = [];
let inList = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^([ \t]*)([*+-]|\d+\.)[ \t]+/.test(line)) {
buffer.push(line);
inList = true;
} else {
if (inList) {
const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
buffer = [];
inList = false;
}
output += (output && !output.endsWith("\n") ? "\n" : "") + line + "\n";
}
}
if (buffer.length) {
const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
}
processedText = output;
// --- End Improved List Grouping and Parsing ---
} catch (e: unknown) {
console.error("Error in processBasicFormatting:", e);
}
return processedText;
}
/**
* Shared service for processing images with expand functionality
*/
export function processImageWithReveal(
src: string,
alt: string = "Image",
): string {
if (!src || !IMAGE_EXTENSIONS.test(src.split("?")[0])) {
return `<img src="${src}" alt="${alt}">`;
}
return `<div class="relative inline-block w-[300px] h-48 my-2 group">
<img
src="${src}"
alt="${alt}"
class="w-full h-full object-contain rounded-lg shadow-lg"
loading="lazy"
decoding="async"
/>
<!-- Expand button -->
<button class="absolute top-2 right-2 bg-black/60 hover:bg-black/80 backdrop-blur-sm text-white rounded-full w-8 h-8 flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110 z-20"
onclick="window.open('${src}', '_blank')"
title="Open image in full size"
aria-label="Open image in full size">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</button>
</div>`;
}
/**
* Shared service for processing media URLs
*/
export function processMediaUrl(url: string, alt?: string): string {
const clean = stripTrackingParams(url);
if (YOUTUBE_URL_REGEX.test(clean)) {
const videoId = extractYouTubeVideoId(clean);
if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-2" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${
alt || "YouTube video"
}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(clean)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-2" preload="none" playsinline><source src="${clean}">${
alt || "Video"
}</video>`;
}
if (AUDIO_URL_REGEX.test(clean)) {
return `<audio controls class="w-full my-2" preload="none"><source src="${clean}">${
alt || "Audio"
}</audio>`;
}
if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) {
return processImageWithReveal(clean, alt || "Embedded media");
}
// Default to clickable link
return `<a href="${clean}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${clean}</a>`;
}
/**
* Shared service for processing nostr identifiers
*/
export async function processNostrIdentifiersInText(
text: string,
ndk?: NDK,
): Promise<string> {
let processedText = text;
// Find all profile-related nostr addresses (only npub and nprofile)
const matches = Array.from(processedText.matchAll(NOSTR_PROFILE_REGEX));
// Process them in reverse order to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
// Skip if part of a URL
const before = processedText.slice(
Math.max(0, matchIndex - 12),
matchIndex,
);
if (/https?:\/\/$|www\.$/i.test(before)) {
continue;
}
// Process the nostr identifier directly
let identifier = fullMatch;
if (!identifier.startsWith("nostr:")) {
identifier = "nostr:" + identifier;
}
// Get user metadata and create link
let metadata;
if (ndk) {
metadata = await getUserMetadata(identifier, ndk);
} else {
// Fallback when NDK is not available - just use the identifier
metadata = { name: identifier.slice(0, 8) + "..." + identifier.slice(-4) };
}
const displayText = metadata.displayName || metadata.name;
const link = createProfileLink(identifier, displayText);
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + link +
processedText.slice(matchIndex + fullMatch.length);
}
return processedText;
}
/**
* Shared service for processing nostr identifiers with embedded events
* Replaces nostr: links with embedded event placeholders
* Only processes event-related identifiers (nevent, naddr, note), not profile identifiers (npub, nprofile)
*/
export function processNostrIdentifiersWithEmbeddedEvents(
text: string,
nestingLevel: number = 0,
): string {
const eventPattern = /nostr:(note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
let processedText = text;
// Maximum nesting level allowed
const MAX_NESTING_LEVEL = 3;
// Find all event-related nostr addresses
const matches = Array.from(processedText.matchAll(eventPattern));
// Process them in reverse order to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
let replacement: string;
if (nestingLevel >= MAX_NESTING_LEVEL) {
// At max nesting level, just show the link
replacement =
`<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all">${fullMatch}</a>`;
} else {
// Create a placeholder for embedded event
const componentId = `embedded-event-${
Math.random().toString(36).substr(2, 9)
}`;
replacement =
`<div class="embedded-event-placeholder" data-nostr-id="${fullMatch}" data-nesting-level="${nestingLevel}" id="${componentId}"></div>`;
}
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement +
processedText.slice(matchIndex + fullMatch.length);
}
return processedText;
}
/**
* Shared service for processing all nostr identifiers (both profiles and events)
* Creates clickable links for all nostr identifiers
*/
export function processAllNostrIdentifiers(text: string): string {
let processedText = text;
// Pattern for prefixed nostr identifiers (nostr:npub1, nostr:note1, etc.)
// This handles both full identifiers and partial ones that might appear in content
const prefixedNostrPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
// Pattern for bare nostr identifiers (npub1, note1, nevent1, naddr1)
// Exclude matches that are part of URLs to avoid breaking existing links
const bareNostrPattern = /(?<!https?:\/\/[^\s]*)(?<!wss?:\/\/[^\s]*)(?<!nostr:)(npub1|note1|nevent1|naddr1)[a-zA-Z0-9]{20,}/g;
// Process prefixed nostr identifiers first
const prefixedMatches = Array.from(processedText.matchAll(prefixedNostrPattern));
// Process them in reverse order to avoid index shifting issues
for (let i = prefixedMatches.length - 1; i >= 0; i--) {
const match = prefixedMatches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
// Create shortened display text
const identifier = fullMatch.replace('nostr:', '');
const displayText = `${identifier.slice(0, 8)}...${identifier.slice(-4)}`;
// Create clickable link
const replacement = `<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="${fullMatch}">${displayText}</a>`;
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement +
processedText.slice(matchIndex + fullMatch.length);
}
// Process bare nostr identifiers
const bareMatches = Array.from(processedText.matchAll(bareNostrPattern));
// Process them in reverse order to avoid index shifting issues
for (let i = bareMatches.length - 1; i >= 0; i--) {
const match = bareMatches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
// Create shortened display text
const displayText = `${fullMatch.slice(0, 8)}...${fullMatch.slice(-4)}`;
// Create clickable link with nostr: prefix for the href
const replacement = `<a href="/events?id=nostr:${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="nostr:${fullMatch}">${displayText}</a>`;
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement +
processedText.slice(matchIndex + fullMatch.length);
}
// Also handle any remaining truncated prefixed identifiers that might be cut off or incomplete
const truncatedPrefixedPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{8,}/g;
const truncatedPrefixedMatches = Array.from(processedText.matchAll(truncatedPrefixedPattern));
for (let i = truncatedPrefixedMatches.length - 1; i >= 0; i--) {
const match = truncatedPrefixedMatches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
// Skip if this was already processed by the main pattern
if (fullMatch.length >= 30) continue; // Full identifiers are at least 30 chars
// Create display text for truncated identifiers
const identifier = fullMatch.replace('nostr:', '');
const displayText = identifier.length > 12 ? `${identifier.slice(0, 8)}...${identifier.slice(-4)}` : identifier;
// Create clickable link
const replacement = `<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="${fullMatch}">${displayText}</a>`;
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement +
processedText.slice(matchIndex + fullMatch.length);
}
// Handle truncated bare identifiers
const truncatedBarePattern = /(?<!https?:\/\/[^\s]*)(?<!wss?:\/\/[^\s]*)(?<!nostr:)(npub1|note1|nevent1|naddr1)[a-zA-Z0-9]{8,}/g;
const truncatedBareMatches = Array.from(processedText.matchAll(truncatedBarePattern));
for (let i = truncatedBareMatches.length - 1; i >= 0; i--) {
const match = truncatedBareMatches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
// Skip if this was already processed by the main pattern
if (fullMatch.length >= 30) continue; // Full identifiers are at least 30 chars
// Create display text for truncated identifiers
const displayText = fullMatch.length > 12 ? `${fullMatch.slice(0, 8)}...${fullMatch.slice(-4)}` : fullMatch;
// Create clickable link
const replacement = `<a href="/events?id=nostr:${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="nostr:${fullMatch}">${displayText}</a>`;
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement +
processedText.slice(matchIndex + fullMatch.length);
}
return processedText;
}
/**
* Shared service for processing emoji shortcodes
*/
export function processEmojiShortcodes(text: string): string {
return emoji.emojify(text);
}
/**
* Shared service for processing WebSocket URLs
*/
export function processWebSocketUrls(text: string): string {
const wssUrlRegex = /wss:\/\/[^\s<>"]+/g;
return text.replace(wssUrlRegex, (match) => {
const cleanUrl = match.slice(6).replace(/\/+$/, "");
return `<a href="https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-500 hover:underline">${match}</a>`;
});
}
/**
* Shared service for processing hashtags
*/
export function processHashtags(text: string): string {
const hashtagRegex = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g;
return text.replace(
hashtagRegex,
'<button class="text-primary-600 dark:text-primary-500 hover:underline cursor-pointer" onclick="window.location.href=\'/events?t=$1\'">#$1</button>',
);
}
/**
* Shared service for processing basic text formatting
*/
export function processBasicTextFormatting(text: string): string {
// Bold: **text** or *text*
text = text.replace(
/(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g,
"<strong>$2</strong>",
);
// Italic: _text_ or __text__
text = text.replace(/\b(_[^_\n]+_|\b__[^_\n]+__)\b/g, (match) => {
const text = match.replace(/^_+|_+$/g, "");
return `<em>${text}</em>`;
});
// Strikethrough: ~~text~~ or ~text~
text = text.replace(
/~~([^~\n]+)~~|~([^~\n]+)~/g,
(_match, doubleText, singleText) => {
const text = doubleText || singleText;
return `<del class="line-through">${text}</del>`;
},
);
return text;
}
/**
* Shared service for processing blockquotes
*/
export function processBlockquotes(text: string): string {
const blockquoteRegex = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm;
return text.replace(blockquoteRegex, (match) => {
const lines = match.split("\n").map((line) => {
return line.replace(/^[ \t]*>[ \t]?/, "").trim();
});
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${
lines.join("\n")
}</blockquote>`;
});
}
// Helper functions
export function stripTrackingParams(url: string): string {
try {
const urlObj = new URL(url);
// Remove common tracking parameters
const trackingParams = [
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"fbclid",
"gclid",
];
trackingParams.forEach((param) => urlObj.searchParams.delete(param));
return urlObj.toString();
} catch {
return url;
}
}
function extractYouTubeVideoId(url: string): string | null {
const match = url.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
);
return match ? match[1] : null;
}
/**
* Normalizes a string for use as a d-tag by converting to lowercase,
* replacing non-alphanumeric characters with dashes, and removing
* leading/trailing dashes.
*/
function normalizeDTag(input: string): string {
return input
.toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Shared service for processing wikilinks in the format [[target]] or [[target|display]]
*/
export function processWikilinks(text: string): string {
// [[target page]] or [[target page|display text]]
return text.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `/events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
},
);
}
/**
* Shared service for processing AsciiDoc anchor tags
*/
export function processAsciiDocAnchors(text: string): string {
return text.replace(/<a id="([^"]+)"><\/a>/g, (_match, id) => {
const normalized = normalizeDTag(id.trim());
const url = `/events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`;
});
}

12
src/lib/utils/mime.ts

@ -62,6 +62,18 @@ export function getMimeTags(kind: number): [string, string][] {
MTag = ["M", `note/microblog/${replaceability}`]; MTag = ["M", `note/microblog/${replaceability}`];
break; break;
// Repost (NIP-18)
case 6:
mTag = ["m", "application/json"];
MTag = ["M", `note/repost/${replaceability}`];
break;
// Generic repost (NIP-18)
case 16:
mTag = ["m", "application/json"];
MTag = ["M", `note/generic-repost/${replaceability}`];
break;
// Generic reply // Generic reply
case 1111: case 1111:
mTag = ["m", "text/plain"]; mTag = ["m", "text/plain"];

27
src/lib/utils/nostrUtils.ts

@ -179,7 +179,7 @@ export async function createProfileLinkWithVerification(
/** /**
* Create a note link element * Create a note link element
*/ */
function createNoteLink(identifier: string): string { export function createNoteLink(identifier: string): string {
const cleanId = identifier.replace(/^nostr:/, ""); const cleanId = identifier.replace(/^nostr:/, "");
const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`; const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`;
const escapedId = escapeHtml(cleanId); const escapedId = escapeHtml(cleanId);
@ -191,6 +191,7 @@ function createNoteLink(identifier: string): string {
/** /**
* Process Nostr identifiers in text * Process Nostr identifiers in text
*/ */
// AI-NOTE: Enhanced URL detection to prevent processing nostr identifiers that are part of URLs
export async function processNostrIdentifiers( export async function processNostrIdentifiers(
content: string, content: string,
ndk: NDK, ndk: NDK,
@ -201,7 +202,25 @@ export async function processNostrIdentifiers(
function isPartOfUrl(text: string, index: number): boolean { function isPartOfUrl(text: string, index: number): boolean {
// Look for http(s):// or www. before the match // Look for http(s):// or www. before the match
const before = text.slice(Math.max(0, index - 12), index); const before = text.slice(Math.max(0, index - 12), index);
return /https?:\/\/$|www\.$/i.test(before); if (/https?:\/\/$|www\.$/i.test(before)) {
return true;
}
// Check if the match is part of a larger URL structure
// Look for common URL patterns that might contain nostr identifiers
const beforeContext = text.slice(Math.max(0, index - 50), index);
const afterContext = text.slice(index, Math.min(text.length, index + 50));
// Check if there's a URL-like structure around the match
const urlPatterns = [
/https?:\/\/[^\s]*$/i, // URL starting with http(s)://
/www\.[^\s]*$/i, // URL starting with www.
/[^\s]*\.(com|org|net|io|eu|co|me|app|dev)[^\s]*$/i, // Common TLDs
/[^\s]*\/[^\s]*$/i, // Path-like structures
];
const combinedContext = beforeContext + afterContext;
return urlPatterns.some(pattern => pattern.test(combinedContext));
} }
// Process profiles (npub and nprofile) // Process profiles (npub and nprofile)
@ -392,7 +411,7 @@ export async function fetchEventWithFallback(
filterOrId: string | Filter, filterOrId: string | Filter,
timeoutMs: number = 10000, timeoutMs: number = 10000,
): Promise<NDKEvent | null> { ): Promise<NDKEvent | null> {
// AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive event discovery // AI-NOTE: Use ALL available relays for comprehensive event discovery
// This ensures we don't miss events that might be on any available relay // This ensures we don't miss events that might be on any available relay
// Get all relays from NDK pool first (most comprehensive) // Get all relays from NDK pool first (most comprehensive)
@ -418,7 +437,7 @@ export async function fetchEventWithFallback(
"fetchEventWithFallback: No relays available for event fetch, using fallback relays", "fetchEventWithFallback: No relays available for event fetch, using fallback relays",
); );
// Use fallback relays when no relays are available // Use fallback relays when no relays are available
// AI-NOTE: 2025-01-24 - Include ALL available relays for comprehensive event discovery // AI-NOTE: Include ALL available relays for comprehensive event discovery
// This ensures we don't miss events that might be on any available relay // This ensures we don't miss events that might be on any available relay
allRelays = [ allRelays = [
...secondaryRelays, ...secondaryRelays,

1
src/lib/utils/npubCache.ts

@ -124,6 +124,7 @@ class UnifiedProfileCache {
const metadata: NostrProfile = { const metadata: NostrProfile = {
name: profile?.name || fallback.name, name: profile?.name || fallback.name,
displayName: profile?.displayName || profile?.display_name, displayName: profile?.displayName || profile?.display_name,
display_name: profile?.display_name || profile?.displayName, // AI-NOTE: Added for compatibility
nip05: profile?.nip05, nip05: profile?.nip05,
picture: profile?.picture || profile?.image, picture: profile?.picture || profile?.image,
about: profile?.about, about: profile?.about,

8
src/lib/utils/profile_search.ts

@ -79,7 +79,7 @@ export async function searchProfiles(
if (npub) { if (npub) {
const metadata = await getUserMetadata(npub, ndk); const metadata = await getUserMetadata(npub, ndk);
// AI-NOTE: 2025-01-24 - Fetch the original event timestamp to preserve created_at // AI-NOTE: Fetch the original event timestamp to preserve created_at
let created_at: number | undefined = undefined; let created_at: number | undefined = undefined;
try { try {
const decoded = nip19.decode(npub); const decoded = nip19.decode(npub);
@ -208,7 +208,7 @@ async function searchNip05Domains(
); );
const metadata = await getUserMetadata(npub, ndk); const metadata = await getUserMetadata(npub, ndk);
// AI-NOTE: 2025-01-24 - Fetch the original event timestamp to preserve created_at // AI-NOTE: Fetch the original event timestamp to preserve created_at
let created_at: number | undefined = undefined; let created_at: number | undefined = undefined;
try { try {
const decoded = nip19.decode(npub); const decoded = nip19.decode(npub);
@ -260,7 +260,7 @@ async function searchNip05Domains(
console.log("NIP-05 search: found npub for", nip05Address, ":", npub); console.log("NIP-05 search: found npub for", nip05Address, ":", npub);
const metadata = await getUserMetadata(npub, ndk); const metadata = await getUserMetadata(npub, ndk);
// AI-NOTE: 2025-01-24 - Fetch the original event timestamp to preserve created_at // AI-NOTE: Fetch the original event timestamp to preserve created_at
let created_at: number | undefined = undefined; let created_at: number | undefined = undefined;
try { try {
const decoded = nip19.decode(npub); const decoded = nip19.decode(npub);
@ -326,7 +326,7 @@ async function quickRelaySearch(
const normalizedSearchTerm = normalizeSearchTerm(searchTerm); const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log("Normalized search term for relay search:", normalizedSearchTerm); console.log("Normalized search term for relay search:", normalizedSearchTerm);
// AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive profile discovery // AI-NOTE: Use ALL available relays for comprehensive profile discovery
// This ensures we don't miss profiles due to stale cache or limited relay coverage // This ensures we don't miss profiles due to stale cache or limited relay coverage
// Get all available relays from NDK pool (most comprehensive) // Get all available relays from NDK pool (most comprehensive)

109
src/lib/utils/relayDiagnostics.ts

@ -1,109 +0,0 @@
import { WebSocketPool } from "../data_structures/websocket_pool.ts";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { TIMEOUTS } from "./search_constants.ts";
import { get } from "svelte/store";
export interface RelayDiagnostic {
url: string;
connected: boolean;
requiresAuth: boolean;
error?: string;
responseTime?: number;
}
/**
* Tests connection to a single relay
*/
export async function testRelay(url: string): Promise<RelayDiagnostic> {
const startTime = Date.now();
const ws = await WebSocketPool.instance.acquire(url);
return new Promise((resolve) => {
const timeout = setTimeout(() => {
WebSocketPool.instance.release(ws);
resolve({
url,
connected: false,
requiresAuth: false,
error: "Connection timeout",
responseTime: Date.now() - startTime,
});
}, TIMEOUTS.RELAY_DIAGNOSTICS);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data[0] === "NOTICE" && data[1]?.includes("auth-required")) {
clearTimeout(timeout);
WebSocketPool.instance.release(ws);
resolve({
url,
connected: true,
requiresAuth: true,
responseTime: Date.now() - startTime,
});
}
};
});
}
/**
* Tests all relays and returns diagnostic information
*/
export async function testAllRelays(): Promise<RelayDiagnostic[]> {
// Use the new relay management system
const allRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)];
console.log("[RelayDiagnostics] Testing", allRelays.length, "relays...");
const results = await Promise.allSettled(
allRelays.map((url) => testRelay(url)),
);
return results.map((result, index) => {
if (result.status === "fulfilled") {
return result.value;
} else {
return {
url: allRelays[index],
connected: false,
requiresAuth: false,
error: "Test failed",
};
}
});
}
/**
* Gets working relays from diagnostic results
*/
export function getWorkingRelays(diagnostics: RelayDiagnostic[]): string[] {
return diagnostics.filter((d) => d.connected).map((d) => d.url);
}
/**
* Logs relay diagnostic results to console
*/
export function logRelayDiagnostics(diagnostics: RelayDiagnostic[]): void {
console.group("[RelayDiagnostics] Results");
const working = diagnostics.filter((d) => d.connected);
const failed = diagnostics.filter((d) => !d.connected);
console.log(`✅ Working relays (${working.length}):`);
working.forEach((d) => {
console.log(
` - ${d.url}${d.requiresAuth ? " (requires auth)" : ""}${
d.responseTime ? ` (${d.responseTime}ms)` : ""
}`,
);
});
if (failed.length > 0) {
console.log(`❌ Failed relays (${failed.length}):`);
failed.forEach((d) => {
console.log(` - ${d.url}: ${d.error || "Unknown error"}`);
});
}
console.groupEnd();
}

11
src/lib/utils/search_constants.ts

@ -17,7 +17,7 @@ export const TIMEOUTS = {
SUBSCRIPTION_SEARCH: 10000, SUBSCRIPTION_SEARCH: 10000,
/** Timeout for second-order search operations */ /** Timeout for second-order search operations */
SECOND_ORDER_SEARCH: 3000, // AI-NOTE: 2025-01-24 - Reduced timeout since we limit scope SECOND_ORDER_SEARCH: 30000, // AI-NOTE: Increased timeout to allow more time for relay responses
/** Timeout for relay diagnostics */ /** Timeout for relay diagnostics */
RELAY_DIAGNOSTICS: 5000, RELAY_DIAGNOSTICS: 5000,
@ -47,22 +47,25 @@ export const SEARCH_LIMITS = {
SPECIFIC_PROFILE: 10, SPECIFIC_PROFILE: 10,
/** Limit for general profile searches */ /** Limit for general profile searches */
GENERAL_PROFILE: 100, // AI-NOTE: 2025-01-24 - Reduced from 500 to prevent wild searches GENERAL_PROFILE: 100, // AI-NOTE: Reduced from 500 to prevent wild searches
/** Limit for general content searches (t-tag, d-tag, etc.) */ /** Limit for general content searches (t-tag, d-tag, etc.) */
GENERAL_CONTENT: 100, // AI-NOTE: 2025-01-24 - Added limit for all content searches GENERAL_CONTENT: 100, // AI-NOTE: Added limit for all content searches
/** Limit for community relay checks */ /** Limit for community relay checks */
COMMUNITY_CHECK: 1, COMMUNITY_CHECK: 1,
/** Limit for second-order search results */ /** Limit for second-order search results */
SECOND_ORDER_RESULTS: 100, SECOND_ORDER_RESULTS: 50, // AI-NOTE: Reduced to improve performance and reduce timeout issues
/** Maximum results for profile searches */ /** Maximum results for profile searches */
MAX_PROFILE_RESULTS: 20, MAX_PROFILE_RESULTS: 20,
/** Batch size for profile fetching operations */ /** Batch size for profile fetching operations */
BATCH_SIZE: 50, BATCH_SIZE: 50,
/** Maximum events to fetch before processing in subscription search */
SUBSCRIPTION_FETCH_LIMIT: 1000,
} as const; } as const;
// Nostr event kind ranges // Nostr event kind ranges

2
src/lib/utils/search_result_formatter.ts

@ -1,6 +1,6 @@
/** /**
* Utility class for formatting search result messages * Utility class for formatting search result messages
* AI-NOTE: 2025-01-24 - Extracted from EventSearch component for better separation of concerns * AI-NOTE: Extracted from EventSearch component for better separation of concerns
*/ */
export class SearchResultFormatter { export class SearchResultFormatter {
/** /**

7
src/lib/utils/search_types.ts

@ -20,6 +20,7 @@ export interface Filter {
export interface NostrProfile { export interface NostrProfile {
name?: string; name?: string;
displayName?: string; displayName?: string;
display_name?: string; // AI-NOTE: Added for compatibility with existing code
nip05?: string; nip05?: string;
picture?: string; picture?: string;
about?: string; about?: string;
@ -29,7 +30,7 @@ export interface NostrProfile {
pubkey?: string; pubkey?: string;
isInUserLists?: boolean; isInUserLists?: boolean;
listKinds?: number[]; listKinds?: number[];
created_at?: number; // AI-NOTE: 2025-01-24 - Timestamp for proper date display created_at?: number; // AI-NOTE: Timestamp for proper date display
} }
/** /**
@ -64,8 +65,8 @@ export type SearchSubscriptionType = "d" | "t" | "n";
export interface SearchFilter { export interface SearchFilter {
filter: Filter; filter: Filter;
subscriptionType: string; subscriptionType: string;
searchTerm?: string; // AI-NOTE: 2025-01-24 - Optional search term for client-side filtering searchTerm?: string; // AI-NOTE: Optional search term for client-side filtering
preloadedEvents?: NDKEvent[]; // AI-NOTE: 2025-01-24 - Preloaded events for profile searches preloadedEvents?: NDKEvent[]; // AI-NOTE: Preloaded events for profile searches
} }
/** /**

6
src/lib/utils/search_utils.ts

@ -106,8 +106,8 @@ export function createProfileFromEvent(event: NDKEvent, profileData: any): any {
website: profileData.website, website: profileData.website,
lud16: profileData.lud16, lud16: profileData.lud16,
pubkey: event.pubkey, pubkey: event.pubkey,
created_at: event.created_at, // AI-NOTE: 2025-01-24 - Preserve timestamp for proper date display created_at: event.created_at, // AI-NOTE: Preserve timestamp for proper date display
isInUserLists: profileData.isInUserLists, // AI-NOTE: 2025-01-24 - Preserve user list information isInUserLists: profileData.isInUserLists, // AI-NOTE: Preserve user list information
listKinds: profileData.listKinds, // AI-NOTE: 2025-01-24 - Preserve list kinds information listKinds: profileData.listKinds, // AI-NOTE: Preserve list kinds information
}; };
} }

676
src/lib/utils/subscription_search.ts

@ -25,6 +25,168 @@ const normalizeUrl = (url: string): string => {
return url.replace(/\/$/, ""); // Remove trailing slash return url.replace(/\/$/, ""); // Remove trailing slash
}; };
// AI-NOTE: Define prioritized event kinds for subscription search
const PRIORITIZED_EVENT_KINDS = new Set([
1, // Text notes
1111, // Comments
9802, // Highlights
20, // Article
21, // Article
22, // Article
1222, // Long-form content
1244, // Long-form content
30023, // Long-form content
30040, // Long-form content
30041, // Long-form content
]);
/**
* Prioritize events for subscription search results
* @param events Array of events to prioritize
* @param targetPubkey The pubkey being searched for (for n: searches only - events from this pubkey get highest priority)
* @param maxResults Maximum number of results to return
* @param ndk NDK instance for user list and community checks
* @returns Prioritized array of events
*
* Priority tiers:
* 1. Prioritized event kinds (1, 1111, 9802, 20, 21, 22, 1222, 1244, 30023, 30040, 30041) + target pubkey events (n: searches only)
* 2. Events from user's follows (if logged in)
* 3. Events from community members
* 4. All other events
*/
async function prioritizeSearchEvents(
events: NDKEvent[],
targetPubkey?: string,
maxResults: number = SEARCH_LIMITS.GENERAL_CONTENT,
ndk?: NDK
): Promise<NDKEvent[]> {
if (events.length === 0) {
return [];
}
// AI-NOTE: Get user lists and community status for prioritization
let userFollowPubkeys = new Set<string>();
let communityMemberPubkeys = new Set<string>();
// Only attempt user list and community checks if NDK is provided
if (ndk) {
try {
// Import user list functions dynamically to avoid circular dependencies
const { fetchCurrentUserLists, getPubkeysFromListKind } = await import("./user_lists.ts");
const { checkCommunity } = await import("./community_checker.ts");
// Get current user's follow lists (if logged in)
const userLists = await fetchCurrentUserLists(undefined, ndk);
userFollowPubkeys = getPubkeysFromListKind(userLists, 3); // Kind 3 = follow list
// Check community status for unique pubkeys in events (limit to prevent hanging)
const uniquePubkeys = new Set(events.map(e => e.pubkey).filter(Boolean));
const pubkeysToCheck = Array.from(uniquePubkeys).slice(0, 20); // Limit to first 20 pubkeys
console.log(`subscription_search: Checking community status for ${pubkeysToCheck.length} pubkeys out of ${uniquePubkeys.size} total`);
const communityChecks = await Promise.allSettled(
pubkeysToCheck.map(async (pubkey) => {
try {
const isCommunityMember = await Promise.race([
checkCommunity(pubkey),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Community check timeout')), 2000)
)
]);
return { pubkey, isCommunityMember };
} catch (error) {
console.warn(`subscription_search: Community check failed for ${pubkey}:`, error);
return { pubkey, isCommunityMember: false };
}
})
);
// Build set of community member pubkeys
communityChecks.forEach(result => {
if (result.status === "fulfilled" && result.value.isCommunityMember) {
communityMemberPubkeys.add(result.value.pubkey);
}
});
console.log("subscription_search: Prioritization data loaded:", {
userFollows: userFollowPubkeys.size,
communityMembers: communityMemberPubkeys.size,
totalEvents: events.length
});
} catch (error) {
console.warn("subscription_search: Failed to load prioritization data:", error);
}
} else {
console.log("subscription_search: No NDK provided, skipping user list and community checks");
}
// Separate events into priority tiers
const tier1: NDKEvent[] = []; // Events from target pubkey (n: searches only) + prioritized kinds
const tier2: NDKEvent[] = []; // Events from user's follows
const tier3: NDKEvent[] = []; // Events from community members
const tier4: NDKEvent[] = []; // All other events
for (const event of events) {
const isFromTarget = targetPubkey && event.pubkey === targetPubkey;
const isPrioritizedKind = PRIORITIZED_EVENT_KINDS.has(event.kind || 0);
const isFromFollow = userFollowPubkeys.has(event.pubkey || "");
const isFromCommunityMember = communityMemberPubkeys.has(event.pubkey || "");
// AI-NOTE: Prioritized kinds are always in tier 1
// Target pubkey priority only applies to n: searches (when targetPubkey is provided)
if (isPrioritizedKind || isFromTarget) {
tier1.push(event);
} else if (isFromFollow) {
tier2.push(event);
} else if (isFromCommunityMember) {
tier3.push(event);
} else {
tier4.push(event);
}
}
// Sort each tier by creation time (newest first)
tier1.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
tier2.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
tier3.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
tier4.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
// Combine tiers in priority order, respecting the limit
const result: NDKEvent[] = [];
// Add tier 1 events (highest priority)
result.push(...tier1);
// Add tier 2 events (follows) if we haven't reached the limit
const remainingAfterTier1 = maxResults - result.length;
if (remainingAfterTier1 > 0) {
result.push(...tier2.slice(0, remainingAfterTier1));
}
// Add tier 3 events (community members) if we haven't reached the limit
const remainingAfterTier2 = maxResults - result.length;
if (remainingAfterTier2 > 0) {
result.push(...tier3.slice(0, remainingAfterTier2));
}
// Add tier 4 events (others) if we haven't reached the limit
const remainingAfterTier3 = maxResults - result.length;
if (remainingAfterTier3 > 0) {
result.push(...tier4.slice(0, remainingAfterTier3));
}
console.log("subscription_search: Event prioritization complete:", {
tier1: tier1.length, // Prioritized kinds + target pubkey (n: searches only)
tier2: tier2.length, // User follows
tier3: tier3.length, // Community members
tier4: tier4.length, // Others
total: result.length
});
return result;
}
/** /**
* Filter out unwanted events from search results * Filter out unwanted events from search results
* @param events Array of NDKEvent to filter * @param events Array of NDKEvent to filter
@ -46,7 +208,7 @@ export async function searchBySubscription(
callbacks?: SearchCallbacks, callbacks?: SearchCallbacks,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<SearchResult> { ): Promise<SearchResult> {
const startTime = Date.now(); // AI-NOTE: 2025-01-08 - Track search performance const startTime = Date.now(); // AI-NOTE: Track search performance
const normalizedSearchTerm = searchTerm.toLowerCase().trim(); const normalizedSearchTerm = searchTerm.toLowerCase().trim();
console.log("subscription_search: Starting search:", { console.log("subscription_search: Starting search:", {
@ -60,7 +222,7 @@ export async function searchBySubscription(
if (cachedResult) { if (cachedResult) {
console.log("subscription_search: Found cached result:", cachedResult); console.log("subscription_search: Found cached result:", cachedResult);
// AI-NOTE: 2025-01-24 - Ensure cached events have created_at property preserved // AI-NOTE: Ensure cached events have created_at property preserved
// This fixes the "Unknown date" issue when events are retrieved from cache // This fixes the "Unknown date" issue when events are retrieved from cache
const eventsWithCreatedAt = cachedResult.events.map(event => { const eventsWithCreatedAt = cachedResult.events.map(event => {
if (event && typeof event === 'object' && !event.created_at) { if (event && typeof event === 'object' && !event.created_at) {
@ -93,16 +255,28 @@ export async function searchBySubscription(
tTagEvents: tTagEventsWithCreatedAt tTagEvents: tTagEventsWithCreatedAt
}; };
// AI-NOTE: 2025-01-24 - For profile searches, return cached results immediately // AI-NOTE: Return cached results immediately but trigger second-order search in background
// The EventSearch component now handles cache checking before calling this function // This ensures we get fast results while still updating second-order data
if (searchType === "n") { console.log("subscription_search: Returning cached result immediately, triggering background second-order search");
console.log(
"subscription_search: Returning cached profile result immediately", // Trigger second-order search in background for all search types
); if (ndk) {
return resultWithCreatedAt; // Start second-order search in background for n and d searches only
} else { if (searchType === "n" || searchType === "d") {
return resultWithCreatedAt; console.log("subscription_search: Triggering background second-order search for cached result");
} performSecondOrderSearchInBackground(
searchType as "n" | "d",
eventsWithCreatedAt,
cachedResult.eventIds || new Set(),
cachedResult.addresses || new Set(),
ndk,
searchType === "n" ? eventsWithCreatedAt[0]?.pubkey : undefined,
callbacks
);
}
}
return resultWithCreatedAt;
} }
if (!ndk) { if (!ndk) {
@ -118,7 +292,7 @@ export async function searchBySubscription(
searchState.timeoutId = setTimeout(() => { searchState.timeoutId = setTimeout(() => {
console.log("subscription_search: Search timeout reached"); console.log("subscription_search: Search timeout reached");
cleanup(); cleanup();
}, TIMEOUTS.SUBSCRIPTION_SEARCH); // AI-NOTE: 2025-01-24 - Use standard timeout since cache is checked first }, TIMEOUTS.SUBSCRIPTION_SEARCH); // AI-NOTE: Use standard timeout since cache is checked first
// Check for abort signal // Check for abort signal
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
@ -140,7 +314,7 @@ export async function searchBySubscription(
"relays", "relays",
); );
// AI-NOTE: 2025-01-24 - Check for preloaded events first (for profile searches) // AI-NOTE: Check for preloaded events first (for profile searches)
if (searchFilter.preloadedEvents && searchFilter.preloadedEvents.length > 0) { if (searchFilter.preloadedEvents && searchFilter.preloadedEvents.length > 0) {
console.log("subscription_search: Using preloaded events:", searchFilter.preloadedEvents.length); console.log("subscription_search: Using preloaded events:", searchFilter.preloadedEvents.length);
processPrimaryRelayResults( processPrimaryRelayResults(
@ -161,6 +335,27 @@ export async function searchBySubscription(
normalizedSearchTerm, normalizedSearchTerm,
); );
searchCache.set(searchType, normalizedSearchTerm, immediateResult); searchCache.set(searchType, normalizedSearchTerm, immediateResult);
// AI-NOTE: For profile searches, start background second-order search even for preloaded events
if (searchType === "n") {
console.log(
"subscription_search: Profile found from preloaded events, starting background second-order search",
);
// Start Phase 2 in background for second-order results
searchOtherRelaysInBackground(
searchType,
searchFilter,
searchState,
ndk,
callbacks,
cleanup,
);
// Clear the main timeout since we're returning early
cleanup();
}
return immediateResult; return immediateResult;
} }
} }
@ -172,11 +367,19 @@ export async function searchBySubscription(
"subscription_search: Searching primary relay with filter:", "subscription_search: Searching primary relay with filter:",
searchFilter.filter, searchFilter.filter,
); );
const primaryEvents = await ndk.fetchEvents(
// Add timeout to primary relay search
const primaryEventsPromise = ndk.fetchEvents(
searchFilter.filter, searchFilter.filter,
{ closeOnEose: true }, { closeOnEose: true },
primaryRelaySet, primaryRelaySet,
); );
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Primary relay search timeout")), TIMEOUTS.SUBSCRIPTION_SEARCH);
});
const primaryEvents = await Promise.race([primaryEventsPromise, timeoutPromise]) as any;
console.log( console.log(
"subscription_search: Primary relay returned", "subscription_search: Primary relay returned",
@ -205,7 +408,7 @@ export async function searchBySubscription(
); );
searchCache.set(searchType, normalizedSearchTerm, immediateResult); searchCache.set(searchType, normalizedSearchTerm, immediateResult);
// AI-NOTE: 2025-01-08 - For profile searches, return immediately when found // AI-NOTE: For profile searches, return immediately when found
// but still start background search for second-order results // but still start background search for second-order results
if (searchType === "n") { if (searchType === "n") {
console.log( console.log(
@ -226,6 +429,9 @@ export async function searchBySubscription(
console.log( console.log(
`subscription_search: Profile search completed in ${elapsed}ms`, `subscription_search: Profile search completed in ${elapsed}ms`,
); );
// Clear the main timeout since we're returning early
cleanup();
return immediateResult; return immediateResult;
} }
@ -239,13 +445,15 @@ export async function searchBySubscription(
cleanup, cleanup,
); );
// Clear the main timeout since we're returning early
cleanup();
return immediateResult; return immediateResult;
} else { } else {
console.log( console.log(
"subscription_search: No results from primary relay", "subscription_search: No results from primary relay",
); );
// AI-NOTE: 2025-01-08 - For profile searches, if no results found in search relays, // AI-NOTE: For profile searches, if no results found in search relays,
// try all relays as fallback // try all relays as fallback
if (searchType === "n") { if (searchType === "n") {
console.log( console.log(
@ -257,11 +465,18 @@ export async function searchBySubscription(
ndk, ndk,
); );
try { try {
const fallbackEvents = await ndk.fetchEvents( // Add timeout to fallback search
const fallbackEventsPromise = ndk.fetchEvents(
searchFilter.filter, searchFilter.filter,
{ closeOnEose: true }, { closeOnEose: true },
allRelaySet, allRelaySet,
); );
const fallbackTimeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Fallback search timeout")), TIMEOUTS.SUBSCRIPTION_SEARCH);
});
const fallbackEvents = await Promise.race([fallbackEventsPromise, fallbackTimeoutPromise]) as any;
console.log( console.log(
"subscription_search: Fallback search returned", "subscription_search: Fallback search returned",
@ -293,6 +508,9 @@ export async function searchBySubscription(
console.log( console.log(
`subscription_search: Profile search completed in ${elapsed}ms (fallback)`, `subscription_search: Profile search completed in ${elapsed}ms (fallback)`,
); );
// Clear the main timeout since we're returning early
cleanup();
return fallbackResult; return fallbackResult;
} }
} catch (fallbackError) { } catch (fallbackError) {
@ -300,6 +518,11 @@ export async function searchBySubscription(
"subscription_search: Fallback search failed:", "subscription_search: Fallback search failed:",
fallbackError, fallbackError,
); );
// If it's a timeout error, continue to return empty result
if (fallbackError instanceof Error && fallbackError.message.includes("timeout")) {
console.log("subscription_search: Fallback search timed out, returning empty result");
}
} }
console.log( console.log(
@ -309,12 +532,15 @@ export async function searchBySubscription(
searchType, searchType,
normalizedSearchTerm, normalizedSearchTerm,
); );
// AI-NOTE: 2025-01-08 - Don't cache empty profile results as they may be due to search issues // AI-NOTE: Don't cache empty profile results as they may be due to search issues
// rather than the profile not existing // rather than the profile not existing
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
console.log( console.log(
`subscription_search: Profile search completed in ${elapsed}ms (not found)`, `subscription_search: Profile search completed in ${elapsed}ms (not found)`,
); );
// Clear the main timeout since we're returning early
cleanup();
return emptyResult; return emptyResult;
} else { } else {
console.log( console.log(
@ -327,6 +553,14 @@ export async function searchBySubscription(
`subscription_search: Error searching primary relay:`, `subscription_search: Error searching primary relay:`,
error, error,
); );
// If it's a timeout error, continue to Phase 2 instead of failing
if (error instanceof Error && error.message.includes("timeout")) {
console.log("subscription_search: Primary relay search timed out, continuing to Phase 2");
} else {
// For other errors, we might want to fail the search
throw error;
}
} }
} else { } else {
console.log( console.log(
@ -344,7 +578,7 @@ export async function searchBySubscription(
cleanup, cleanup,
); );
// AI-NOTE: 2025-01-08 - Log performance for non-profile searches // AI-NOTE: Log performance for non-profile searches
if (searchType !== "n") { if (searchType !== "n") {
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
console.log( console.log(
@ -352,6 +586,8 @@ export async function searchBySubscription(
); );
} }
// Clear the main timeout since we're completing normally
cleanup();
return result; return result;
} }
@ -429,7 +665,7 @@ async function createSearchFilter(
return tFilter; return tFilter;
} }
case "n": { case "n": {
// AI-NOTE: 2025-01-24 - Use the existing profile search functionality // AI-NOTE: Use the existing profile search functionality
// This properly handles NIP-05 lookups and name searches // This properly handles NIP-05 lookups and name searches
const { searchProfiles } = await import("./profile_search.ts"); const { searchProfiles } = await import("./profile_search.ts");
const profileResult = await searchProfiles(normalizedSearchTerm, ndk); const profileResult = await searchProfiles(normalizedSearchTerm, ndk);
@ -439,7 +675,7 @@ async function createSearchFilter(
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile); event.content = JSON.stringify(profile);
// AI-NOTE: 2025-01-24 - Convert npub to hex public key for compatibility with nprofileEncode // AI-NOTE: Convert npub to hex public key for compatibility with nprofileEncode
// The profile.pubkey is an npub (bech32-encoded), but nprofileEncode expects hex-encoded public key // The profile.pubkey is an npub (bech32-encoded), but nprofileEncode expects hex-encoded public key
let hexPubkey = profile.pubkey || ""; let hexPubkey = profile.pubkey || "";
if (profile.pubkey && profile.pubkey.startsWith("npub")) { if (profile.pubkey && profile.pubkey.startsWith("npub")) {
@ -455,7 +691,7 @@ async function createSearchFilter(
event.pubkey = hexPubkey; event.pubkey = hexPubkey;
event.kind = 0; event.kind = 0;
// AI-NOTE: 2025-01-24 - Use the preserved created_at timestamp from the profile // AI-NOTE: Use the preserved created_at timestamp from the profile
// This ensures the profile cards show the actual creation date instead of "Unknown date" // This ensures the profile cards show the actual creation date instead of "Unknown date"
if ((profile as any).created_at) { if ((profile as any).created_at) {
event.created_at = (profile as any).created_at; event.created_at = (profile as any).created_at;
@ -474,7 +710,7 @@ async function createSearchFilter(
filter: { kinds: [0], limit: 1 }, // Dummy filter filter: { kinds: [0], limit: 1 }, // Dummy filter
subscriptionType: "profile-search", subscriptionType: "profile-search",
searchTerm: normalizedSearchTerm, searchTerm: normalizedSearchTerm,
preloadedEvents: events, // AI-NOTE: 2025-01-24 - Pass preloaded events preloadedEvents: events, // AI-NOTE: Pass preloaded events
}; };
console.log("subscription_search: Created profile filter with preloaded events:", nFilter); console.log("subscription_search: Created profile filter with preloaded events:", nFilter);
return nFilter; return nFilter;
@ -489,7 +725,7 @@ async function createSearchFilter(
/** /**
* Create primary relay set for search operations * Create primary relay set for search operations
* AI-NOTE: 2025-01-24 - Updated to use all available relays to prevent search failures * AI-NOTE: Updated to use all available relays to prevent search failures
*/ */
function createPrimaryRelaySet( function createPrimaryRelaySet(
searchType: SearchSubscriptionType, searchType: SearchSubscriptionType,
@ -502,7 +738,7 @@ function createPrimaryRelaySet(
poolRelays.map((r: any) => r.url), poolRelays.map((r: any) => r.url),
); );
// AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive search coverage // AI-NOTE: Use ALL available relays for comprehensive search coverage
// This ensures searches don't fail due to missing relays and provides maximum event discovery // This ensures searches don't fail due to missing relays and provides maximum event discovery
if (searchType === "n") { if (searchType === "n") {
@ -545,7 +781,7 @@ function createPrimaryRelaySet(
activeRelays, activeRelays,
}); });
// AI-NOTE: 2025-01-24 - Use all pool relays instead of filtering to active relays only // AI-NOTE: Use all pool relays instead of filtering to active relays only
// This ensures we don't miss events that might be on other relays // This ensures we don't miss events that might be on other relays
console.debug( console.debug(
"subscription_search: Using ALL pool relays for comprehensive search coverage:", "subscription_search: Using ALL pool relays for comprehensive search coverage:",
@ -573,7 +809,17 @@ function processPrimaryRelayResults(
"events from primary relay", "events from primary relay",
); );
// AI-NOTE: Apply subscription fetch limit to primary relay results
const maxEvents = SEARCH_LIMITS.SUBSCRIPTION_FETCH_LIMIT;
let processedCount = 0;
for (const event of events) { for (const event of events) {
// Check if we've reached the event limit
if (processedCount >= maxEvents) {
console.log(`subscription_search: Reached event limit of ${maxEvents} in primary relay processing`);
break;
}
// Check for abort signal // Check for abort signal
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
cleanup?.(); cleanup?.();
@ -591,6 +837,7 @@ function processPrimaryRelayResults(
} else { } else {
processContentEvent(event, searchType, searchState); processContentEvent(event, searchType, searchState);
} }
processedCount++;
} catch (e) { } catch (e) {
console.warn("subscription_search: Error processing event:", e); console.warn("subscription_search: Error processing event:", e);
// Invalid JSON or other error, skip // Invalid JSON or other error, skip
@ -598,7 +845,7 @@ function processPrimaryRelayResults(
} }
console.log( console.log(
"subscription_search: Processed events - firstOrder:", `subscription_search: Processed ${processedCount} events (limit: ${maxEvents}) - firstOrder:`,
searchState.firstOrderEvents.length, searchState.firstOrderEvents.length,
"profiles:", "profiles:",
searchState.foundProfiles.length, searchState.foundProfiles.length,
@ -748,7 +995,7 @@ function searchOtherRelaysInBackground(
callbacks?: SearchCallbacks, callbacks?: SearchCallbacks,
cleanup?: () => void, cleanup?: () => void,
): Promise<SearchResult> { ): Promise<SearchResult> {
// AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive search coverage // AI-NOTE: Use ALL available relays for comprehensive search coverage
// This ensures we don't miss events that might be on any available relay // This ensures we don't miss events that might be on any available relay
const otherRelays = new NDKRelaySet( const otherRelays = new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values())), new Set(Array.from(ndk.pool.relays.values())),
@ -775,7 +1022,20 @@ function searchOtherRelaysInBackground(
callbacks.onSubscriptionCreated(sub); callbacks.onSubscriptionCreated(sub);
} }
// AI-NOTE: Track event count to enforce subscription fetch limit
let eventCount = 0;
const maxEvents = SEARCH_LIMITS.SUBSCRIPTION_FETCH_LIMIT;
sub.on("event", (event: NDKEvent) => { sub.on("event", (event: NDKEvent) => {
// Check if we've reached the event limit
if (eventCount >= maxEvents) {
console.log(`subscription_search: Reached event limit of ${maxEvents}, stopping event processing`);
sub.stop();
return;
}
eventCount++;
try { try {
if (searchType === "n") { if (searchType === "n") {
processProfileEvent( processProfileEvent(
@ -793,17 +1053,42 @@ function searchOtherRelaysInBackground(
}); });
return new Promise<SearchResult>((resolve) => { return new Promise<SearchResult>((resolve) => {
sub.on("eose", () => { let resolved = false;
const result = processEoseResults(
searchType, // Add timeout to prevent hanging
searchState, const timeoutId = setTimeout(async () => {
searchFilter, if (!resolved) {
ndk, console.log("subscription_search: Background search timeout, resolving with current results");
callbacks, resolved = true;
); sub.stop();
searchCache.set(searchType, searchState.normalizedSearchTerm, result); const result = await processEoseResults(
cleanup?.(); searchType,
resolve(result); searchState,
searchFilter,
ndk,
callbacks,
);
searchCache.set(searchType, searchState.normalizedSearchTerm, result);
cleanup?.();
resolve(result);
}
}, TIMEOUTS.SUBSCRIPTION_SEARCH);
sub.on("eose", async () => {
if (!resolved) {
resolved = true;
clearTimeout(timeoutId);
const result = await processEoseResults(
searchType,
searchState,
searchFilter,
ndk,
callbacks,
);
searchCache.set(searchType, searchState.normalizedSearchTerm, result);
cleanup?.();
resolve(result);
}
}); });
}); });
} }
@ -811,19 +1096,19 @@ function searchOtherRelaysInBackground(
/** /**
* Process EOSE results * Process EOSE results
*/ */
function processEoseResults( async function processEoseResults(
searchType: SearchSubscriptionType, searchType: SearchSubscriptionType,
searchState: any, searchState: any,
searchFilter: SearchFilter, searchFilter: SearchFilter,
ndk: NDK, ndk: NDK,
callbacks?: SearchCallbacks, callbacks?: SearchCallbacks,
): SearchResult { ): Promise<SearchResult> {
if (searchType === "n") { if (searchType === "n") {
return processProfileEoseResults(searchState, searchFilter, ndk, callbacks); return processProfileEoseResults(searchState, searchFilter, ndk, callbacks);
} else if (searchType === "d") { } else if (searchType === "d") {
return processContentEoseResults(searchState, searchType, ndk); return await processContentEoseResults(searchState, searchType, ndk, callbacks);
} else if (searchType === "t") { } else if (searchType === "t") {
return processTTagEoseResults(searchState); return await processTTagEoseResults(searchState, ndk);
} }
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm); return createEmptySearchResult(searchType, searchState.normalizedSearchTerm);
@ -857,6 +1142,10 @@ function processProfileEoseResults(
.sort((a, b) => b.created_at - a.created_at) .sort((a, b) => b.created_at - a.created_at)
.map((x) => x.event); .map((x) => x.event);
// AI-NOTE: For profile searches, we don't apply prioritization to the profiles themselves
// since they are all kind 0 events and should be shown in chronological order
// However, we do pass the target pubkey to the second-order search for prioritization
// Perform second-order search for npub searches // Perform second-order search for npub searches
if ( if (
searchFilter.subscriptionType === "npub-specific" || searchFilter.subscriptionType === "npub-specific" ||
@ -922,11 +1211,12 @@ function processProfileEoseResults(
/** /**
* Process content EOSE results * Process content EOSE results
*/ */
function processContentEoseResults( async function processContentEoseResults(
searchState: any, searchState: any,
searchType: SearchSubscriptionType, searchType: SearchSubscriptionType,
ndk: NDK, ndk: NDK,
): SearchResult { callbacks?: SearchCallbacks,
): Promise<SearchResult> {
if (searchState.firstOrderEvents.length === 0) { if (searchState.firstOrderEvents.length === 0) {
return createEmptySearchResult( return createEmptySearchResult(
searchType, searchType,
@ -946,6 +1236,19 @@ function processContentEoseResults(
} }
const dedupedEvents = Object.values(deduped).map((x) => x.event); const dedupedEvents = Object.values(deduped).map((x) => x.event);
// AI-NOTE: Apply prioritization to first-order events for d-tag searches
// For d-tag searches, we don't have a specific target pubkey, so we only prioritize by event kind
const prioritizedEvents = await prioritizeSearchEvents(
dedupedEvents,
undefined, // No specific target pubkey for d-tag searches
SEARCH_LIMITS.GENERAL_CONTENT,
ndk
);
// AI-NOTE: Attach profile data to first-order events for display
// This ensures profile pictures and other metadata are available in the UI
await attachProfileDataToEvents(prioritizedEvents, ndk);
// Perform second-order search for d-tag searches // Perform second-order search for d-tag searches
if (dedupedEvents.length > 0) { if (dedupedEvents.length > 0) {
performSecondOrderSearchInBackground( performSecondOrderSearchInBackground(
@ -954,11 +1257,13 @@ function processContentEoseResults(
searchState.eventIds, searchState.eventIds,
searchState.eventAddresses, searchState.eventAddresses,
ndk, ndk,
undefined, // targetPubkey not needed for d-tag searches
callbacks,
); );
} }
return { return {
events: dedupedEvents, events: prioritizedEvents,
secondOrder: [], secondOrder: [],
tTagEvents: [], tTagEvents: [],
eventIds: searchState.eventIds, eventIds: searchState.eventIds,
@ -971,13 +1276,28 @@ function processContentEoseResults(
/** /**
* Process t-tag EOSE results * Process t-tag EOSE results
*/ */
function processTTagEoseResults(searchState: any): SearchResult { async function processTTagEoseResults(searchState: any, ndk?: NDK): Promise<SearchResult> {
if (searchState.tTagEvents.length === 0) { if (searchState.tTagEvents.length === 0) {
return createEmptySearchResult("t", searchState.normalizedSearchTerm); return createEmptySearchResult("t", searchState.normalizedSearchTerm);
} }
// AI-NOTE: Apply prioritization to t-tag search results
// For t-tag searches, we don't have a specific target pubkey, so we only prioritize by event kind
const prioritizedEvents = await prioritizeSearchEvents(
searchState.tTagEvents,
undefined, // No specific target pubkey for t-tag searches
SEARCH_LIMITS.GENERAL_CONTENT,
ndk
);
// AI-NOTE: Attach profile data to t-tag events for display
// This ensures profile pictures and other metadata are available in the UI
if (ndk) {
await attachProfileDataToEvents(prioritizedEvents, ndk);
}
return { return {
events: searchState.tTagEvents, events: prioritizedEvents,
secondOrder: [], secondOrder: [],
tTagEvents: [], tTagEvents: [],
eventIds: new Set(), eventIds: new Set(),
@ -1026,42 +1346,35 @@ async function performSecondOrderSearchInBackground(
); );
let allSecondOrderEvents: NDKEvent[] = []; let allSecondOrderEvents: NDKEvent[] = [];
// Set a timeout for second-order search // Set a timeout for the initial event fetching only
const timeoutPromise = new Promise((_, reject) => { const fetchTimeoutPromise = new Promise((_, reject) => {
setTimeout( setTimeout(
() => reject(new Error("Second-order search timeout")), () => reject(new Error("Second-order search fetch timeout")),
TIMEOUTS.SECOND_ORDER_SEARCH, TIMEOUTS.SECOND_ORDER_SEARCH,
); );
}); });
const searchPromise = (async () => { const fetchPromise = (async () => {
if (searchType === "n" && targetPubkey) { if (searchType === "n" && targetPubkey) {
console.log( console.log(
"subscription_search: Searching for events mentioning pubkey:", "subscription_search: Searching for events mentioning pubkey:",
targetPubkey, targetPubkey,
); );
// AI-NOTE: 2025-01-24 - Use only active relays for second-order profile search to prevent hanging // AI-NOTE: Use all available relays for second-order search to maximize results
const activeRelays = [
...get(activeInboxRelays),
...get(activeOutboxRelays),
];
const availableRelays = activeRelays
.map((url) => ndk.pool.relays.get(url))
.filter((relay): relay is any => relay !== undefined);
const relaySet = new NDKRelaySet( const relaySet = new NDKRelaySet(
new Set(availableRelays), new Set(Array.from(ndk.pool.relays.values())),
ndk, ndk,
); );
console.log( console.log(
"subscription_search: Using", "subscription_search: Using",
activeRelays.length, ndk.pool.relays.size,
"active relays for second-order search", "relays for second-order search",
); );
// Search for events that mention this pubkey via p-tags // Search for events that mention this pubkey via p-tags
const pTagFilter = { "#p": [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging const pTagFilter = { "#p": [targetPubkey], limit: 50 }; // AI-NOTE: Limit results to prevent hanging
const pTagEvents = await ndk.fetchEvents( const pTagEvents = await ndk.fetchEvents(
pTagFilter, pTagFilter,
{ closeOnEose: true }, { closeOnEose: true },
@ -1074,8 +1387,8 @@ async function performSecondOrderSearchInBackground(
targetPubkey, targetPubkey,
); );
// AI-NOTE: 2025-01-24 - Also search for events written by this pubkey with limit // AI-NOTE: Also search for events written by this pubkey with limit
const authorFilter = { authors: [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging const authorFilter = { authors: [targetPubkey], limit: 50 }; // AI-NOTE: Limit results to prevent hanging
const authorEvents = await ndk.fetchEvents( const authorEvents = await ndk.fetchEvents(
authorFilter, authorFilter,
{ closeOnEose: true }, { closeOnEose: true },
@ -1141,64 +1454,114 @@ async function performSecondOrderSearchInBackground(
...filteredATagEvents, ...filteredATagEvents,
]; ];
} }
})();
// Deduplicate by event ID // Race between fetch and timeout - only timeout the initial event fetching
const uniqueSecondOrder = new Map<string, NDKEvent>(); await Promise.race([fetchPromise, fetchTimeoutPromise]);
allSecondOrderEvents.forEach((event) => {
if (event.id) { // Now do the prioritization without timeout
uniqueSecondOrder.set(event.id, event); console.log("subscription_search: Event fetching completed, starting prioritization...");
}
}); // Deduplicate by event ID
const uniqueSecondOrder = new Map<string, NDKEvent>();
allSecondOrderEvents.forEach((event) => {
if (event.id) {
uniqueSecondOrder.set(event.id, event);
}
});
let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values()); let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values());
// Remove any events already in first order // Remove any events already in first order
const firstOrderIds = new Set(firstOrderEvents.map((e) => e.id)); const firstOrderIds = new Set(firstOrderEvents.map((e) => e.id));
deduplicatedSecondOrder = deduplicatedSecondOrder.filter( deduplicatedSecondOrder = deduplicatedSecondOrder.filter(
(e) => !firstOrderIds.has(e.id), (e) => !firstOrderIds.has(e.id),
); );
// Sort by creation date (newest first) and limit to newest results // AI-NOTE: Apply prioritization to second-order search results with timeout
const sortedSecondOrder = deduplicatedSecondOrder // Prioritize events from the target pubkey and specific event kinds
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) const prioritizationPromise = prioritizeSearchEvents(
.slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS); deduplicatedSecondOrder,
targetPubkey,
SEARCH_LIMITS.SECOND_ORDER_RESULTS,
ndk
);
const prioritizationTimeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Prioritization timeout')), 15000); // 15 second timeout
});
let prioritizedSecondOrder: NDKEvent[];
try {
prioritizedSecondOrder = await Promise.race([
prioritizationPromise,
prioritizationTimeoutPromise
]) as NDKEvent[];
console.log( console.log(
"subscription_search: Second-order search completed with", "subscription_search: Second-order search completed with",
sortedSecondOrder.length, prioritizedSecondOrder.length,
"prioritized results",
);
} catch (error) {
console.warn("subscription_search: Prioritization failed, using simple sorting:", error);
// Fallback to simple sorting if prioritization fails
prioritizedSecondOrder = deduplicatedSecondOrder.sort((a, b) => {
// Prioritize events from target pubkey first (for n: searches)
if (targetPubkey) {
const aIsTarget = a.pubkey === targetPubkey;
const bIsTarget = b.pubkey === targetPubkey;
if (aIsTarget && !bIsTarget) return -1;
if (!aIsTarget && bIsTarget) return 1;
}
// Prioritize by event kind (for t: searches and general prioritization)
const aIsPrioritized = PRIORITIZED_EVENT_KINDS.has(a.kind || 0);
const bIsPrioritized = PRIORITIZED_EVENT_KINDS.has(b.kind || 0);
if (aIsPrioritized && !bIsPrioritized) return -1;
if (!aIsPrioritized && bIsPrioritized) return 1;
// Then sort by creation time (newest first)
return (b.created_at || 0) - (a.created_at || 0);
}).slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS);
console.log(
"subscription_search: Using fallback sorting with",
prioritizedSecondOrder.length,
"results", "results",
); );
}
// Update the search results with second-order events // AI-NOTE: Attach profile data to second-order events for display
const result: SearchResult = { // This ensures profile pictures and other metadata are available in the UI
events: firstOrderEvents, await attachProfileDataToEvents(prioritizedSecondOrder, ndk);
secondOrder: sortedSecondOrder,
tTagEvents: [], // Update the search results with second-order events
eventIds: searchType === "n" const result: SearchResult = {
? new Set(firstOrderEvents.map((p) => p.id)) events: firstOrderEvents,
: eventIds, secondOrder: prioritizedSecondOrder,
addresses: searchType === "n" ? new Set() : addresses, tTagEvents: [],
searchType: searchType, eventIds: searchType === "n"
searchTerm: "", // This will be set by the caller ? new Set(firstOrderEvents.map((p) => p.id))
}; : eventIds,
addresses: searchType === "n" ? new Set() : addresses,
// Notify UI of updated results searchType: searchType,
if (callbacks?.onSecondOrderUpdate) { searchTerm: "", // This will be set by the caller
console.log( };
"subscription_search: Calling onSecondOrderUpdate callback with",
sortedSecondOrder.length,
"second-order events",
);
callbacks.onSecondOrderUpdate(result);
} else {
console.log(
"subscription_search: No onSecondOrderUpdate callback available",
);
}
})();
// Race between search and timeout // Notify UI of updated results
await Promise.race([searchPromise, timeoutPromise]); if (callbacks?.onSecondOrderUpdate) {
console.log(
"subscription_search: Calling onSecondOrderUpdate callback with",
prioritizedSecondOrder.length,
"second-order events",
);
callbacks.onSecondOrderUpdate(result);
} else {
console.log(
"subscription_search: No onSecondOrderUpdate callback available",
);
}
} catch (err) { } catch (err) {
console.error( console.error(
`[Search] Error in second-order ${searchType}-tag search:`, `[Search] Error in second-order ${searchType}-tag search:`,
@ -1206,3 +1569,88 @@ async function performSecondOrderSearchInBackground(
); );
} }
} }
/**
* Attach profile data to events for display purposes
* This function fetches and attaches profile information to events so they can display profile pictures and other metadata
* @param events Array of events to attach profile data to
* @param ndk NDK instance for fetching profile data
* @returns Promise that resolves when profile data is attached
*/
async function attachProfileDataToEvents(events: NDKEvent[], ndk: NDK): Promise<void> {
if (events.length === 0) {
return;
}
console.log(`subscription_search: Attaching profile data to ${events.length} events`);
try {
// Import user list functions dynamically to avoid circular dependencies
const { fetchCurrentUserLists, isPubkeyInUserLists } = await import("./user_lists.ts");
// Get current user's lists for user list status
const userLists = await fetchCurrentUserLists(undefined, ndk);
// Get unique pubkeys from events
const uniquePubkeys = new Set<string>();
events.forEach((event) => {
if (event.pubkey) {
uniquePubkeys.add(event.pubkey);
}
});
console.log(`subscription_search: Found ${uniquePubkeys.size} unique pubkeys to fetch profiles for`);
// Fetch profile data for each unique pubkey
const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => {
try {
// Import getUserMetadata dynamically to avoid circular dependencies
const { getUserMetadata } = await import("./nostrUtils.ts");
const npub = await import("./nostrUtils.ts").then(m => m.toNpub(pubkey));
if (npub) {
const profileData = await getUserMetadata(npub, ndk, true);
if (profileData) {
// Check if this pubkey is in user's lists
const isInLists = isPubkeyInUserLists(pubkey, userLists);
// Return profile data with user list status
return {
pubkey,
profileData: {
...profileData,
isInUserLists: isInLists
}
};
}
}
} catch (error) {
console.warn(`subscription_search: Failed to fetch profile for ${pubkey}:`, error);
}
return null;
});
const profileResults = await Promise.allSettled(profilePromises);
// Create a map of pubkey to profile data
const profileMap = new Map<string, any>();
profileResults.forEach((result) => {
if (result.status === "fulfilled" && result.value) {
profileMap.set(result.value.pubkey, result.value.profileData);
}
});
console.log(`subscription_search: Successfully fetched ${profileMap.size} profiles`);
// Attach profile data to each event
events.forEach((event) => {
if (event.pubkey && profileMap.has(event.pubkey)) {
(event as any).profileData = profileMap.get(event.pubkey);
}
});
console.log(`subscription_search: Profile data attachment complete`);
} catch (error) {
console.error("subscription_search: Error attaching profile data:", error);
}
}

2
src/lib/utils/websocket_utils.ts

@ -97,7 +97,7 @@ export async function fetchNostrEvent(
} }
} }
// AI-NOTE: 2025-01-24 - Enhanced relay strategy for better event discovery // AI-NOTE: Enhanced relay strategy for better event discovery
// Always include search relays in the relay set for comprehensive event discovery // Always include search relays in the relay set for comprehensive event discovery
const { searchRelays, secondaryRelays } = await import("../consts.ts"); const { searchRelays, secondaryRelays } = await import("../consts.ts");
const allRelays = [...availableRelays, ...searchRelays, ...secondaryRelays]; const allRelays = [...availableRelays, ...searchRelays, ...secondaryRelays];

2
src/routes/+layout.svelte

@ -26,7 +26,7 @@
const rect = document.body.getBoundingClientRect(); const rect = document.body.getBoundingClientRect();
// document.body.style.height = `${rect.height}px`; // document.body.style.height = `${rect.height}px`;
// AI-NOTE: 2025-01-24 - Restore authentication state from localStorage on page load // AI-NOTE: Restore authentication state from localStorage on page load
// This function automatically restores the user's login state when the page is refreshed, // This function automatically restores the user's login state when the page is refreshed,
// preventing the user from being logged out unexpectedly. It handles extension, npub, and Amber logins. // preventing the user from being logged out unexpectedly. It handles extension, npub, and Amber logins.
async function restoreAuthentication() { async function restoreAuthentication() {

6
src/routes/about/+page.svelte

@ -3,10 +3,13 @@
import { Heading, Img, P, A } from "flowbite-svelte"; import { Heading, Img, P, A } from "flowbite-svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import RelayStatus from "$lib/components/RelayStatus.svelte"; import RelayStatus from "$lib/components/RelayStatus.svelte";
import { getNdkContext } from "$lib/ndk";
// Get the git tag version from environment variables // Get the git tag version from environment variables
const appVersion = import.meta.env.APP_VERSION || "development"; const appVersion = import.meta.env.APP_VERSION || "development";
const isVersionKnown = appVersion !== "development"; const isVersionKnown = appVersion !== "development";
const ndk = getNdkContext();
</script> </script>
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
@ -52,6 +55,7 @@
We are easiest to contact over our Nostr address {@render userBadge( We are easiest to contact over our Nostr address {@render userBadge(
"npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz", "npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz",
"GitCitadel", "GitCitadel",
ndk,
)}. Or, you can visit us on our <A )}. Or, you can visit us on our <A
href="https://gitcitadel.com" href="https://gitcitadel.com"
title="GitCitadel Homepage" title="GitCitadel Homepage"

1
src/routes/contact/+page.svelte

@ -309,6 +309,7 @@
You can contact us on Nostr {@render userBadge( You can contact us on Nostr {@render userBadge(
"npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz", "npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz",
"GitCitadel", "GitCitadel",
ndk,
)} or you can view submitted issues on the <A )} or you can view submitted issues on the <A
href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues"
target="_blank">Alexandria repo page.</A target="_blank">Alexandria repo page.</A

591
src/routes/events/+page.svelte

@ -21,7 +21,8 @@
import { getEventType } from "$lib/utils/mime"; import { getEventType } from "$lib/utils/mime";
import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte"; import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte";
import { checkCommunity } from "$lib/utils/search_utility"; import { checkCommunity } from "$lib/utils/search_utility";
import EmbeddedEvent from "$lib/components/embedded_events/EmbeddedEvent.svelte"; import { repostContent, quotedContent } from "$lib/snippets/EmbeddedSnippets.svelte";
import { repostKinds } from "$lib/consts";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { import {
fetchCurrentUserLists, fetchCurrentUserLists,
@ -30,6 +31,29 @@
import { UserOutline } from "flowbite-svelte-icons"; import { UserOutline } from "flowbite-svelte-icons";
import type { UserProfile } from "$lib/models/user_profile"; import type { UserProfile } from "$lib/models/user_profile";
import type { SearchType } from "$lib/models/search_type"; import type { SearchType } from "$lib/models/search_type";
import { clearAllCaches } from "$lib/utils/cache_manager";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
// AI-NOTE: Add cache clearing function for testing second-order search
// This can be called from browser console: window.clearCache()
if (typeof window !== 'undefined') {
(window as any).clearCache = () => {
console.log('Clearing all caches for testing...');
clearAllCaches();
console.log('Caches cleared. Try searching again to test second-order search.');
};
// AI-NOTE: Add function to clear specific search cache
// Usage: window.clearSearchCache('n', 'silberengel')
(window as any).clearSearchCache = (searchType: string, searchTerm: string) => {
console.log(`Clearing search cache for ${searchType}:${searchTerm}...`);
// Import searchCache dynamically
import('$lib/utils/searchCache').then(({ searchCache }) => {
searchCache.clear();
console.log('Search cache cleared. Try searching again to test second-order search.');
});
};
}
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -56,21 +80,34 @@
// Get NDK context during component initialization // Get NDK context during component initialization
const ndk = getNdkContext(); const ndk = getNdkContext();
// AI-NOTE: Event navigation and comment feed update issue
// When navigating to events via e-tags, the CommentViewer component may experience
// timing issues that cause comment feed problems. This function is called when
// a new event is found, and it triggers the CommentViewer to update.
// The CommentViewer has been updated with better state management to handle
// these race conditions.
function handleEventFound(newEvent: NDKEvent) { function handleEventFound(newEvent: NDKEvent) {
event = newEvent; event = newEvent;
showSidePanel = true; showSidePanel = true;
// searchInProgress = false; // searchInProgress = false;
// secondOrderSearchMessage = null; // secondOrderSearchMessage = null;
// AI-NOTE: 2025-01-24 - Properly parse profile data for kind 0 events // AI-NOTE: Properly parse profile data for kind 0 events
if (newEvent.kind === 0) { if (newEvent.kind === 0) {
try { try {
const parsedProfile = parseProfileContent(newEvent); const parsedProfile = parseProfileContent(newEvent);
if (parsedProfile) { if (parsedProfile) {
profile = parsedProfile; // Check if we already have user list information from the search results
const existingProfileData = (newEvent as any).profileData;
// If the event doesn't have user list information, fetch it if (existingProfileData && typeof existingProfileData.isInUserLists === "boolean") {
if (typeof parsedProfile.isInUserLists !== "boolean") { // Use the existing user list status from search results
profile = { ...parsedProfile, isInUserLists: existingProfileData.isInUserLists } as any;
console.log(`[Events Page] Using existing user list status for ${newEvent.pubkey}: ${existingProfileData.isInUserLists}`);
} else {
// Set initial profile and fetch user list information
profile = parsedProfile;
// Fetch user list information
fetchCurrentUserLists(undefined, ndk) fetchCurrentUserLists(undefined, ndk)
.then((userLists) => { .then((userLists) => {
const isInLists = isPubkeyInUserLists( const isInLists = isPubkeyInUserLists(
@ -79,11 +116,12 @@
); );
// Update the profile with user list information // Update the profile with user list information
profile = { ...parsedProfile, isInUserLists: isInLists } as any; profile = { ...parsedProfile, isInUserLists: isInLists } as any;
// Also update the event's profileData // Also update the event's profileData for consistency
(newEvent as any).profileData = { (newEvent as any).profileData = {
...parsedProfile, ...parsedProfile,
isInUserLists: isInLists, isInUserLists: isInLists,
}; };
console.log(`[Events Page] Updated user list status for ${newEvent.pubkey}: ${isInLists}`);
}) })
.catch(() => { .catch(() => {
profile = { ...parsedProfile, isInUserLists: false } as any; profile = { ...parsedProfile, isInUserLists: false } as any;
@ -91,6 +129,7 @@
...parsedProfile, ...parsedProfile,
isInUserLists: false, isInUserLists: false,
}; };
console.log(`[Events Page] Set default user list status for ${newEvent.pubkey}: false`);
}); });
} }
} else { } else {
@ -108,33 +147,32 @@
profile = null; profile = null;
} }
// AI-NOTE: 2025-01-24 - Ensure profile is cached for the event author // AI-NOTE: Ensure profile is cached for the event author
if (newEvent.pubkey) { if (newEvent.pubkey) {
cacheProfileForPubkey(newEvent.pubkey); cacheProfileForPubkey(newEvent.pubkey);
// Update profile data with user list information // Also check community status for the individual event if not already cached
updateProfileDataWithUserLists([newEvent]);
// Also check community status for the individual event
if (!communityStatus[newEvent.pubkey]) { if (!communityStatus[newEvent.pubkey]) {
checkCommunity(newEvent.pubkey) checkCommunity(newEvent.pubkey)
.then((status) => { .then((status) => {
communityStatus = { ...communityStatus, [newEvent.pubkey]: status }; communityStatus = { ...communityStatus, [newEvent.pubkey]: status };
console.log(`[Events Page] Updated community status for ${newEvent.pubkey}: ${status}`);
}) })
.catch(() => { .catch(() => {
communityStatus = { ...communityStatus, [newEvent.pubkey]: false }; communityStatus = { ...communityStatus, [newEvent.pubkey]: false };
console.log(`[Events Page] Set default community status for ${newEvent.pubkey}: false`);
}); });
} }
} }
} }
// AI-NOTE: 2025-01-24 - Function to ensure profile is cached for a pubkey // AI-NOTE: Function to ensure profile is cached for a pubkey
async function cacheProfileForPubkey(pubkey: string) { async function cacheProfileForPubkey(pubkey: string) {
try { try {
const npub = toNpub(pubkey); const npub = toNpub(pubkey);
if (npub) { if (npub) {
// Force fetch to ensure profile is cached // Force fetch to ensure profile is cached
await getUserMetadata(npub, undefined, true); await getUserMetadata(npub, ndk, true);
console.log(`[Events Page] Cached profile for pubkey: ${pubkey}`); console.log(`[Events Page] Cached profile for pubkey: ${pubkey}`);
} }
} catch (error) { } catch (error) {
@ -145,32 +183,7 @@
} }
} }
// AI-NOTE: 2025-01-24 - Function to update profile data with user list information
async function updateProfileDataWithUserLists(events: NDKEvent[]) {
try {
const userLists = await fetchCurrentUserLists(undefined, ndk);
for (const event of events) {
if (event.kind === 0 && event.pubkey) {
const existingProfileData =
(event as any).profileData || parseProfileContent(event);
if (existingProfileData) {
const isInLists = isPubkeyInUserLists(event.pubkey, userLists);
(event as any).profileData = {
...existingProfileData,
isInUserLists: isInLists,
};
}
}
}
} catch (error) {
console.warn(
"[Events Page] Failed to update profile data with user lists:",
error,
);
}
}
// Use Svelte 5 idiomatic effect to update searchValue and searchType based on URL parameters // Use Svelte 5 idiomatic effect to update searchValue and searchType based on URL parameters
$effect(() => { $effect(() => {
@ -220,7 +233,7 @@
} }
}); });
// AI-NOTE: 2025-01-24 - Function to ensure events have created_at property // AI-NOTE: Function to ensure events have created_at property
// This fixes the "Unknown date" issue when events are retrieved from cache // This fixes the "Unknown date" issue when events are retrieved from cache
function ensureEventProperties(events: NDKEvent[]): NDKEvent[] { function ensureEventProperties(events: NDKEvent[]): NDKEvent[] {
return events.map((event) => { return events.map((event) => {
@ -247,7 +260,7 @@
searchTypeParam?: string, searchTypeParam?: string,
searchTermParam?: string, searchTermParam?: string,
) { ) {
// AI-NOTE: 2025-01-24 - Ensure all events have proper properties // AI-NOTE: Ensure all events have proper properties
const processedResults = ensureEventProperties(results); const processedResults = ensureEventProperties(results);
const processedSecondOrder = ensureEventProperties(secondOrder); const processedSecondOrder = ensureEventProperties(secondOrder);
const processedTTagEvents = ensureEventProperties(tTagEvents); const processedTTagEvents = ensureEventProperties(tTagEvents);
@ -264,7 +277,7 @@
searchInProgress = searchInProgress =
loading || (results.length > 0 && secondOrder.length === 0); loading || (results.length > 0 && secondOrder.length === 0);
// AI-NOTE: 2025-01-08 - Only show second-order search message if we're actually searching // AI-NOTE: Only show second-order search message if we're actually searching
// Don't show it for cached results that have no second-order events // Don't show it for cached results that have no second-order events
if ( if (
results.length > 0 && results.length > 0 &&
@ -298,30 +311,11 @@
checkCommunityStatusForResults(tTagEvents); checkCommunityStatusForResults(tTagEvents);
} }
// AI-NOTE: 2025-01-24 - Cache profiles for all search results // AI-NOTE: Profile data is now handled in subscription_search.ts
cacheProfilesForEvents([...results, ...secondOrder, ...tTagEvents]); // No need to cache profiles here as they're already attached to events
} }
// AI-NOTE: 2025-01-24 - Function to cache profiles for multiple events
async function cacheProfilesForEvents(events: NDKEvent[]) {
const uniquePubkeys = new Set<string>();
events.forEach((event) => {
if (event.pubkey) {
uniquePubkeys.add(event.pubkey);
}
});
// Cache profiles in parallel
const cachePromises = Array.from(uniquePubkeys).map((pubkey) =>
cacheProfileForPubkey(pubkey),
);
await Promise.allSettled(cachePromises);
// AI-NOTE: 2025-01-24 - Update profile data with user list information for cached events
await updateProfileDataWithUserLists(events);
console.log(`[Events Page] Profile caching complete`);
}
function handleClear() { function handleClear() {
searchType = null; searchType = null;
@ -390,7 +384,7 @@
return "Reference"; return "Reference";
} }
// AI-NOTE: 2025-01-24 - Function to parse profile content from kind 0 events // AI-NOTE: Function to parse profile content from kind 0 events
function parseProfileContent(event: NDKEvent): UserProfile | null { function parseProfileContent(event: NDKEvent): UserProfile | null {
if (event.kind !== 0 || !event.content) { if (event.kind !== 0 || !event.content) {
return null; return null;
@ -477,15 +471,16 @@
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<div <div
class="flex flex-col lg:flex-row w-full max-w-7xl my-6 px-4 mx-auto gap-6" class="flex flex-col lg:flex-row w-full max-w-7xl my-6 px-4 mx-auto gap-6 overflow-hidden"
> >
<!-- Left Panel: Search and Results --> <!-- Left Panel: Search and Results -->
<div <div
class={showSidePanel class={showSidePanel
? "w-full lg:w-80 lg:min-w-80" ? "w-full lg:w-80 lg:min-w-80"
: "flex-1 max-w-4xl mx-auto"} : "w-full max-w-4xl mx-auto lg:max-w-4xl"}
class:min-w-0={true}
> >
<div class="main-leather flex flex-col space-y-6"> <div class="main-leather flex flex-col space-y-6 w-full">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<Heading tag="h1" class="h-leather mb-2">Events</Heading> <Heading tag="h1" class="h-leather mb-2">Events</Heading>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -564,7 +559,7 @@
? "lg:block hidden" ? "lg:block hidden"
: "block"} : "block"}
> >
<Heading tag="h2" class="h-leather mb-4 break-words"> <Heading tag="h2" class="h-leather mb-4 break-words overflow-hidden">
{#if searchType === "n"} {#if searchType === "n"}
Search Results for name: "{searchTerm && Search Results for name: "{searchTerm &&
searchTerm.length > 50 searchTerm.length > 50
@ -586,63 +581,95 @@
})()}" ({searchResults.length} events) })()}" ({searchResults.length} events)
{/if} {/if}
</Heading> </Heading>
<div class="space-y-4"> <div class="space-y-4 w-full max-w-full">
{#each searchResults as result, index} {#each searchResults as result, index}
{@const profileData = {@const profileData =
(result as any).profileData || parseProfileContent(result)} (result as any).profileData || parseProfileContent(result)}
<button <button
class="w-full text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-white dark:bg-primary-900/70 hover:bg-gray-100 dark:hover:bg-primary-800 focus:bg-gray-100 dark:focus:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors overflow-hidden" class="responsive-card text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-white dark:bg-primary-900/70 hover:bg-gray-100 dark:hover:bg-primary-800 focus:bg-gray-100 dark:focus:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
onclick={() => handleEventFound(result)} onclick={() => handleEventFound(result)}
> >
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1 responsive-card-content">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1 min-w-0">
<span <span
class="font-medium text-gray-800 dark:text-gray-100" class="font-medium text-gray-800 dark:text-gray-100 flex-shrink-0"
>{searchType === "n" ? "Profile" : "Event"} >{searchType === "n" ? "Profile" : "Event"}
{index + 1}</span {index + 1}</span
>
<span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span
>
{#if profileData?.isInUserLists}
<div
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists (follows, etc.)"
> >
<svg <span class="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0"
class="w-3 h-3 text-red-600 dark:text-red-400" >Kind: {result.kind}</span
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{:else if result.pubkey && communityStatus[result.pubkey]}
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
> >
<svg <div class="flex items-center gap-2 ml-auto flex-shrink-0">
class="w-3 h-3 text-yellow-600 dark:text-yellow-400" <!-- Indicators -->
fill="currentColor" {#if profileData?.isInUserLists}
viewBox="0 0 24 24" <div
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists (follows, etc.)"
> >
<path <svg
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" class="w-3 h-3 text-red-600 dark:text-red-400"
/> fill="currentColor"
</svg> viewBox="0 0 24 24"
</div> >
{:else} <path
<div class="flex-shrink-0 w-4 h-4"></div> d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
{/if} />
<span class="text-xs text-gray-600 dark:text-gray-400"> </svg>
{@render userBadge( </div>
toNpub(result.pubkey) as string, {/if}
profileData?.display_name || profileData?.name, {#if result.pubkey && communityStatus[result.pubkey]}
)} <div
</span> class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{/if}
{#if !profileData?.isInUserLists && !(result.pubkey && communityStatus[result.pubkey])}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
<!-- Profile picture -->
{#if profileData?.picture}
<img
src={profileData.picture}
alt="Profile"
class="w-6 h-6 rounded-full object-cover border border-gray-200 dark:border-gray-600 flex-shrink-0"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = "none";
(e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden");
}}
/>
<div
class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0 hidden"
>
<UserOutline class="w-3 h-3 text-gray-600 dark:text-gray-300" />
</div>
{:else}
<div
class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0"
>
<UserOutline class="w-3 h-3 text-gray-600 dark:text-gray-300" />
</div>
{/if}
<!-- User badge -->
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
profileData?.display_name || profileData?.name,
ndk,
)}
</span>
</div>
<span <span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
> >
@ -659,7 +686,7 @@
<img <img
src={profileData.picture} src={profileData.picture}
alt="Profile" alt="Profile"
class="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-gray-600" class="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-gray-600 flex-shrink-0"
onerror={(e) => { onerror={(e) => {
(e.target as HTMLImageElement).style.display = (e.target as HTMLImageElement).style.display =
"none"; "none";
@ -671,7 +698,7 @@
}} }}
/> />
<div <div
class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 hidden" class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 hidden flex-shrink-0"
> >
<UserOutline <UserOutline
class="w-6 h-6 text-gray-600 dark:text-gray-300" class="w-6 h-6 text-gray-600 dark:text-gray-300"
@ -679,14 +706,14 @@
</div> </div>
{:else} {:else}
<div <div
class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600" class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0"
> >
<UserOutline <UserOutline
class="w-6 h-6 text-gray-600 dark:text-gray-300" class="w-6 h-6 text-gray-600 dark:text-gray-300"
/> />
</div> </div>
{/if} {/if}
<div class="flex flex-col min-w-0 flex-1"> <div class="flex flex-col min-w-0 flex-1 overflow-hidden">
{#if profileData.display_name || profileData.name} {#if profileData.display_name || profileData.name}
<span <span
class="font-medium text-gray-900 dark:text-gray-100 truncate" class="font-medium text-gray-900 dark:text-gray-100 truncate"
@ -696,7 +723,7 @@
{/if} {/if}
{#if profileData.about} {#if profileData.about}
<span <span
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 break-words"
> >
{profileData.about} {profileData.about}
</span> </span>
@ -751,10 +778,34 @@
<div <div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words" class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
> >
<EmbeddedEvent {#if repostKinds.includes(result.kind)}
nostrIdentifier={result.id} <!-- Repost content - parse stringified JSON -->
nestingLevel={0} <div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2">
/> <div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{result.kind === 6 ? 'Repost:' : 'Generic repost:'}
</div>
{@render repostContent(result.content)}
</div>
{:else if result.kind === 1 && result.getMatchingTags("q").length > 0}
<!-- Quote repost content -->
<div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Quote repost:
</div>
{@render quotedContent(result, [], ndk)}
{#if result.content && result.content.trim()}
<div class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Comment:
</div>
{@render basicMarkup(result.content.slice(0, 100) + (result.content.length > 100 ? "..." : ""), ndk)}
</div>
{/if}
</div>
{:else}
<!-- Regular content -->
{@render basicMarkup(result.content.slice(0, 200) + (result.content.length > 200 ? "..." : ""), ndk)}
{/if}
</div> </div>
{/if} {/if}
{/if} {/if}
@ -773,7 +824,7 @@
? "lg:block hidden" ? "lg:block hidden"
: "block"} : "block"}
> >
<Heading tag="h2" class="h-leather mb-4"> <Heading tag="h2" class="h-leather mb-4 break-words overflow-hidden">
Second-Order Events (References, Replies, Quotes) ({secondOrderResults.length} Second-Order Events (References, Replies, Quotes) ({secondOrderResults.length}
events) events)
</Heading> </Heading>
@ -786,49 +837,96 @@
Events that reference, reply to, highlight, or quote the Events that reference, reply to, highlight, or quote the
original events. original events.
</P> </P>
<div class="space-y-4"> <div class="space-y-4 w-full max-w-full">
{#each secondOrderResults as result, index} {#each secondOrderResults as result, index}
{@const profileData = {@const profileData =
(result as any).profileData || parseProfileContent(result)} (result as any).profileData || parseProfileContent(result)}
<button <button
class="w-full text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-primary-800/50 hover:bg-gray-100 dark:hover:bg-primary-700 focus:bg-gray-100 dark:focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors overflow-hidden" class="responsive-card text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-primary-800/50 hover:bg-gray-100 dark:hover:bg-primary-700 focus:bg-gray-100 dark:focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
onclick={() => handleEventFound(result)} onclick={() => handleEventFound(result)}
> >
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1 responsive-card-content">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1 min-w-0">
<span <span
class="font-medium text-gray-800 dark:text-gray-100" class="font-medium text-gray-800 dark:text-gray-100 flex-shrink-0"
>Reference {index + 1}</span >Reference {index + 1}</span
> >
<span class="text-xs text-gray-600 dark:text-gray-400" <span class="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0"
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if result.pubkey && communityStatus[result.pubkey]} <div class="flex items-center gap-2 ml-auto flex-shrink-0">
<div <!-- Indicators -->
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" {#if profileData?.isInUserLists}
title="Has posted to the community" <div
> class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
<svg title="In your lists (follows, etc.)"
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
> >
<path <svg
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" class="w-3 h-3 text-red-600 dark:text-red-400"
/> fill="currentColor"
</svg> viewBox="0 0 24 24"
</div> >
{:else} <path
<div class="flex-shrink-0 w-4 h-4"></div> d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
{/if} />
<span class="text-xs text-gray-600 dark:text-gray-400"> </svg>
{@render userBadge( </div>
toNpub(result.pubkey) as string, {/if}
profileData?.display_name || profileData?.name, {#if result.pubkey && communityStatus[result.pubkey]}
)} <div
</span> class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{/if}
{#if !profileData?.isInUserLists && !(result.pubkey && communityStatus[result.pubkey])}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
<!-- Profile picture -->
{#if profileData?.picture}
<img
src={profileData.picture}
alt="Profile"
class="w-6 h-6 rounded-full object-cover border border-gray-200 dark:border-gray-600 flex-shrink-0"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = "none";
(e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden");
}}
/>
<div
class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0 hidden"
>
<UserOutline class="w-3 h-3 text-gray-600 dark:text-gray-300" />
</div>
{:else}
<div
class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0"
>
<UserOutline class="w-3 h-3 text-gray-600 dark:text-gray-300" />
</div>
{/if}
<!-- User badge -->
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
profileData?.display_name || profileData?.name,
ndk,
)}
</span>
</div>
<span <span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" class="text-xs text-gray-500 dark:text-gray-400 ml-auto flex-shrink-0"
> >
{result.created_at {result.created_at
? new Date( ? new Date(
@ -852,7 +950,7 @@
<img <img
src={profileData.picture} src={profileData.picture}
alt="Profile" alt="Profile"
class="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-gray-600" class="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-gray-600 flex-shrink-0"
onerror={(e) => { onerror={(e) => {
(e.target as HTMLImageElement).style.display = (e.target as HTMLImageElement).style.display =
"none"; "none";
@ -860,7 +958,7 @@
/> />
{:else} {:else}
<div <div
class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600" class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0"
> >
<span <span
class="text-lg font-medium text-gray-600 dark:text-gray-300" class="text-lg font-medium text-gray-600 dark:text-gray-300"
@ -873,7 +971,7 @@
</span> </span>
</div> </div>
{/if} {/if}
<div class="flex flex-col min-w-0 flex-1"> <div class="flex flex-col min-w-0 flex-1 overflow-hidden">
{#if profileData.display_name || profileData.name} {#if profileData.display_name || profileData.name}
<span <span
class="font-medium text-gray-900 dark:text-gray-100 truncate" class="font-medium text-gray-900 dark:text-gray-100 truncate"
@ -883,7 +981,7 @@
{/if} {/if}
{#if profileData.about} {#if profileData.about}
<span <span
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 break-words"
> >
{profileData.about} {profileData.about}
</span> </span>
@ -938,10 +1036,34 @@
<div <div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words" class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
> >
<EmbeddedEvent {#if repostKinds.includes(result.kind)}
nostrIdentifier={result.id} <!-- Repost content - parse stringified JSON -->
nestingLevel={0} <div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2">
/> <div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{result.kind === 6 ? 'Repost:' : 'Generic repost:'}
</div>
{@render repostContent(result.content)}
</div>
{:else if result.kind === 1 && result.getMatchingTags("q").length > 0}
<!-- Quote repost content -->
<div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Quote repost:
</div>
{@render quotedContent(result, [], ndk)}
{#if result.content && result.content.trim()}
<div class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Comment:
</div>
{@render basicMarkup(result.content.slice(0, 100) + (result.content.length > 100 ? "..." : ""), ndk)}
</div>
{/if}
</div>
{:else}
<!-- Regular content -->
{@render basicMarkup(result.content.slice(0, 200) + (result.content.length > 200 ? "..." : ""), ndk)}
{/if}
</div> </div>
{/if} {/if}
{/if} {/if}
@ -960,7 +1082,7 @@
? "lg:block hidden" ? "lg:block hidden"
: "block"} : "block"}
> >
<Heading tag="h2" class="h-leather mb-4"> <Heading tag="h2" class="h-leather mb-4 break-words overflow-hidden">
Search Results for t-tag: "{searchTerm || Search Results for t-tag: "{searchTerm ||
(searchType === "t" ? searchValue : "")}" ({tTagResults.length} (searchType === "t" ? searchValue : "")}" ({tTagResults.length}
events) events)
@ -968,47 +1090,94 @@
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400"> <P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Events that are tagged with the t-tag. Events that are tagged with the t-tag.
</P> </P>
<div class="space-y-4"> <div class="space-y-4 w-full max-w-full">
{#each tTagResults as result, index} {#each tTagResults as result, index}
{@const profileData = {@const profileData =
(result as any).profileData || parseProfileContent(result)} (result as any).profileData || parseProfileContent(result)}
<button <button
class="w-full text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-primary-800/50 hover:bg-gray-100 dark:hover:bg-primary-700 focus:bg-gray-100 dark:focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors overflow-hidden" class="responsive-card text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-primary-800/50 hover:bg-gray-100 dark:hover:bg-primary-700 focus:bg-gray-100 dark:focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
onclick={() => handleEventFound(result)} onclick={() => handleEventFound(result)}
> >
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1 responsive-card-content">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1 min-w-0">
<span <span
class="font-medium text-gray-800 dark:text-gray-100" class="font-medium text-gray-800 dark:text-gray-100 flex-shrink-0"
>Tagged Event {index + 1}</span >Tagged Event {index + 1}</span
> >
<span class="text-xs text-gray-600 dark:text-gray-400" <span class="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0"
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if result.pubkey && communityStatus[result.pubkey]} <div class="flex items-center gap-2 ml-auto flex-shrink-0">
<div <!-- Indicators -->
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" {#if profileData?.isInUserLists}
title="Has posted to the community" <div
> class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
<svg title="In your lists (follows, etc.)"
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
> >
<path <svg
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" class="w-3 h-3 text-red-600 dark:text-red-400"
/> fill="currentColor"
</svg> viewBox="0 0 24 24"
</div> >
{:else} <path
<div class="flex-shrink-0 w-4 h-4"></div> d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
{/if} />
<span class="text-xs text-gray-600 dark:text-gray-400"> </svg>
{@render userBadge( </div>
toNpub(result.pubkey) as string, {/if}
profileData?.display_name || profileData?.name, {#if result.pubkey && communityStatus[result.pubkey]}
)} <div
</span> class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{/if}
{#if !profileData?.isInUserLists && !(result.pubkey && communityStatus[result.pubkey])}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
<!-- Profile picture -->
{#if profileData?.picture}
<img
src={profileData.picture}
alt="Profile"
class="w-6 h-6 rounded-full object-cover border border-gray-200 dark:border-gray-600 flex-shrink-0"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = "none";
(e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden");
}}
/>
<div
class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0 hidden"
>
<UserOutline class="w-3 h-3 text-gray-600 dark:text-gray-300" />
</div>
{:else}
<div
class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0"
>
<UserOutline class="w-3 h-3 text-gray-600 dark:text-gray-300" />
</div>
{/if}
<!-- User badge -->
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
profileData?.display_name || profileData?.name,
ndk,
)}
</span>
</div>
<span <span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
> >
@ -1111,10 +1280,34 @@
<div <div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words" class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
> >
<EmbeddedEvent {#if repostKinds.includes(result.kind)}
nostrIdentifier={result.id} <!-- Repost content - parse stringified JSON -->
nestingLevel={0} <div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2">
/> <div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
{result.kind === 6 ? 'Repost:' : 'Generic repost:'}
</div>
{@render repostContent(result.content)}
</div>
{:else if result.kind === 1 && result.getMatchingTags("q").length > 0}
<!-- Quote repost content -->
<div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Quote repost:
</div>
{@render quotedContent(result, [], ndk)}
{#if result.content && result.content.trim()}
<div class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
Comment:
</div>
{@render basicMarkup(result.content.slice(0, 100) + (result.content.length > 100 ? "..." : ""), ndk)}
</div>
{/if}
</div>
{:else}
<!-- Regular content -->
{@render basicMarkup(result.content.slice(0, 200) + (result.content.length > 200 ? "..." : ""), ndk)}
{/if}
</div> </div>
{/if} {/if}
{/if} {/if}
@ -1197,7 +1390,7 @@
{/if} {/if}
<div class="min-w-0 overflow-hidden"> <div class="min-w-0 overflow-hidden">
<EventDetails {event} {profile} /> <EventDetails {event} {profile} communityStatusMap={communityStatus} />
</div> </div>
<div class="min-w-0 overflow-hidden"> <div class="min-w-0 overflow-hidden">
<RelayActions {event} /> <RelayActions {event} />

1
src/routes/my-notes/+page.svelte

@ -65,6 +65,7 @@
}); });
renderedContent[event.id] = await postProcessAsciidoctorHtml( renderedContent[event.id] = await postProcessAsciidoctorHtml(
html as string, html as string,
ndk,
); );
} }
// Collect unique tags by type // Collect unique tags by type

29
src/routes/visualize/+page.svelte

@ -17,7 +17,7 @@
import { getEventKindColor, getEventKindName } from "$lib/utils/eventColors"; import { getEventKindColor, getEventKindName } from "$lib/utils/eventColors";
import { extractPubkeysFromEvents, batchFetchProfiles } from "$lib/utils/npubCache"; import { extractPubkeysFromEvents, batchFetchProfiles } from "$lib/utils/npubCache";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { getNdkContext } from "$lib/ndk"; import { getNdkContext, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
// Import utility functions for tag-based event fetching // Import utility functions for tag-based event fetching
// These functions handle the complex logic of finding publications by tags // These functions handle the complex logic of finding publications by tags
// and extracting their associated content events // and extracting their associated content events
@ -28,6 +28,7 @@
} from "$lib/utils/tag_event_fetch"; } from "$lib/utils/tag_event_fetch";
import { deduplicateAndCombineEvents } from "$lib/utils/eventDeduplication"; import { deduplicateAndCombineEvents } from "$lib/utils/eventDeduplication";
import type { EventCounts } from "$lib/types"; import type { EventCounts } from "$lib/types";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
const ndk = getNdkContext(); const ndk = getNdkContext();
@ -394,14 +395,26 @@
const shouldFetchIndex = publicationConfigs.some(ec => ec.kind === INDEX_EVENT_KIND); const shouldFetchIndex = publicationConfigs.some(ec => ec.kind === INDEX_EVENT_KIND);
if (data.eventId) { if (data.eventId) {
// Fetch specific publication // Fetch specific publication using comprehensive relay search
debug(`Fetching specific publication: ${data.eventId}`); debug(`Fetching specific publication: ${data.eventId}`);
const event = await ndk.fetchEvent(data.eventId);
// Log the event ID being searched for
console.log("[VisualizePage] Attempting to fetch event:", data.eventId);
const event = await fetchEventWithFallback(ndk, data.eventId, 15000);
if (!event) { if (!event) {
throw new Error(`Publication not found: ${data.eventId}`); console.error("[VisualizePage] Event not found:", data.eventId);
throw new Error(`Publication not found: ${data.eventId}. The event may not be available on any of the configured relays.`);
} }
console.log("[VisualizePage] Successfully fetched event:", {
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
title: event.getMatchingTags("title")[0]?.[1]
});
if (event.kind !== INDEX_EVENT_KIND) { if (event.kind !== INDEX_EVENT_KIND) {
throw new Error(`Event ${data.eventId} is not a publication index (kind ${INDEX_EVENT_KIND})`); throw new Error(`Event ${data.eventId} is not a publication index (kind ${INDEX_EVENT_KIND})`);
} }
@ -735,6 +748,14 @@
// Fetch events when component mounts // Fetch events when component mounts
onMount(() => { onMount(() => {
debug("Component mounted"); debug("Component mounted");
// Log relay configuration for debugging
console.log("[VisualizePage] Relay configuration:", {
poolRelays: Array.from(ndk.pool.relays.values()).map(r => r.url),
activeInboxRelays: get(activeInboxRelays),
activeOutboxRelays: get(activeOutboxRelays)
});
fetchEvents(); fetchEvents();
}); });
</script> </script>

2
tailwind.config.cjs

@ -1,5 +1,6 @@
import flowbite from "flowbite/plugin"; import flowbite from "flowbite/plugin";
import plugin from "tailwindcss/plugin"; import plugin from "tailwindcss/plugin";
import typography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config}*/ /** @type {import('tailwindcss').Config}*/
const config = { const config = {
@ -94,6 +95,7 @@ const config = {
plugins: [ plugins: [
flowbite(), flowbite(),
typography,
plugin(function ({ addUtilities, matchUtilities }) { plugin(function ({ addUtilities, matchUtilities }) {
addUtilities({ addUtilities({
".content-visibility-auto": { ".content-visibility-auto": {

Loading…
Cancel
Save