Browse Source

added timeout to existing functions, revamped timeout and cardactions and fixed asciidoc browser console error

master
Silberengel 10 months ago
parent
commit
f5ae449e36
  1. 2
      README.md
  2. 1
      src/lib/components/PublicationHeader.svelte
  3. 161
      src/lib/components/util/CardActions.svelte
  4. 30
      src/lib/components/util/CopyToClipboard.svelte
  5. 45
      src/lib/parser.ts
  6. 118
      src/lib/utils/nostrUtils.ts

2
README.md

@ -75,7 +75,7 @@ To run the container, in detached mode (-d):
docker run -d --rm --name=gc-alexandria -p 4174:80 gc-alexandria docker run -d --rm --name=gc-alexandria -p 4174:80 gc-alexandria
``` ```
The container is then viewable on your [local machine](http://localhost:4174). The container is then viewable on your [local machine](http://localhost:4173).
If you want to see the container process (assuming it's the last process to start), enter: If you want to see the container process (assuming it's the last process to start), enter:

1
src/lib/components/PublicationHeader.svelte

@ -29,6 +29,7 @@
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
console.log("PublicationHeader event:", event);
</script> </script>
{#if title != null && href != null} {#if title != null && href != null}

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

@ -1,17 +1,21 @@
<script lang="ts"> <script lang="ts">
import { import {
ClipboardCheckOutline,
ClipboardCleanOutline, ClipboardCleanOutline,
DotsVerticalOutline, DotsVerticalOutline,
EyeOutline, EyeOutline,
ShareNodesOutline ShareNodesOutline
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import { Button, Modal, Popover } from "flowbite-svelte"; import { Button, Modal, Popover } from "flowbite-svelte";
import { standardRelays } from "$lib/consts"; import { standardRelays, FeedType } from "$lib/consts";
import { neventEncode, naddrEncode } from "$lib/utils"; import { neventEncode, naddrEncode } from "$lib/utils";
import InlineProfile from "$components/util/InlineProfile.svelte"; import InlineProfile from "$components/util/InlineProfile.svelte";
import { feedType } from "$lib/stores";
import { inboxRelays, ndkSignedIn } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
let { event } = $props(); // Component props
let { event } = $props<{ event: NDKEvent }>();
// Derive metadata from event // Derive metadata from event
let title = $derived(event.tags.find((t: string[]) => t[0] === 'title')?.[1] ?? ''); let title = $derived(event.tags.find((t: string[]) => t[0] === 'title')?.[1] ?? '');
@ -26,94 +30,81 @@
let publisher = $derived(event.tags.find((t: string[]) => t[0] === 'publisher')?.[1] ?? null); let publisher = $derived(event.tags.find((t: string[]) => t[0] === 'publisher')?.[1] ?? null);
let identifier = $derived(event.tags.find((t: string[]) => t[0] === 'identifier')?.[1] ?? null); let identifier = $derived(event.tags.find((t: string[]) => t[0] === 'identifier')?.[1] ?? null);
let jsonModalOpen: boolean = $state(false); // UI state
let detailsModalOpen: boolean = $state(false); let detailsModalOpen: boolean = $state(false);
let eventIdCopied: boolean = $state(false); let isOpen: boolean = $state(false);
let shareLinkCopied: boolean = $state(false);
let isOpen = $state(false); /**
* Selects the appropriate relay set based on user state and feed type
* - Uses user's inbox relays when signed in and viewing personal feed
* - Falls back to standard relays for anonymous users or standard feed
*/
let activeRelays = $derived(
(() => {
const isUserFeed = $ndkSignedIn && $feedType === FeedType.UserRelays;
const relays = isUserFeed ? $inboxRelays : standardRelays;
console.debug("[CardActions] Selected relays:", {
eventId: event.id,
isSignedIn: $ndkSignedIn,
feedType: $feedType,
isUserFeed,
relayCount: relays.length,
relayUrls: relays
});
return relays;
})()
);
/**
* Opens the actions popover menu
*/
function openPopover() { function openPopover() {
console.debug("[CardActions] Opening menu", { eventId: event.id });
isOpen = true; isOpen = true;
} }
/**
* Closes the actions popover menu and removes focus
*/
function closePopover() { function closePopover() {
console.debug("[CardActions] Closing menu", { eventId: event.id });
isOpen = false; isOpen = false;
const menu = document.getElementById('dots-' + event.id); const menu = document.getElementById('dots-' + event.id);
if (menu) menu.blur(); if (menu) menu.blur();
} }
function shareNjump() { /**
const relays: string[] = standardRelays; * Gets the appropriate identifier (nevent or naddr) for copying
* @param type - The type of identifier to get ('nevent' or 'naddr')
try { * @returns The encoded identifier string
const naddr = naddrEncode(event, relays); */
console.debug(naddr); function getIdentifier(type: 'nevent' | 'naddr'): string {
navigator.clipboard.writeText(`https://njump.me/${naddr}`); const encodeFn = type === 'nevent' ? neventEncode : naddrEncode;
shareLinkCopied = true; return encodeFn(event, activeRelays);
setTimeout(() => {
shareLinkCopied = false;
}, 4000);
}
catch (e) {
console.error('Failed to encode naddr:', e);
}
}
function copyEventId() {
console.debug("copyEventID");
const relays: string[] = standardRelays;
const nevent = neventEncode(event, relays);
navigator.clipboard.writeText(nevent);
eventIdCopied = true;
setTimeout(() => {
eventIdCopied = false;
}, 4000);
}
function viewJson() {
console.debug("viewJSON");
jsonModalOpen = true;
}
function viewDetails() {
detailsModalOpen = true;
} }
// --- Custom JSON pretty-printer with naddr hyperlinking ---
/** /**
* Returns HTML for pretty-printed JSON, with naddrs as links to /events?id=naddr1... * Opens the event details modal
*/ */
function jsonWithNaddrLinks(obj: any): string { function viewDetails() {
const NADDR_REGEX = /\b(\d{5}:[a-f0-9]{64}:[a-zA-Z0-9._-]+)\b/g; console.debug("[CardActions] Opening details modal", {
function replacer(_key: string, value: any) { eventId: event.id,
return value; title: event.title,
} author: event.author
// Stringify with 2-space indent
let json = JSON.stringify(obj, replacer, 2);
// Replace naddr addresses with links
json = json.replace(NADDR_REGEX, (match) => {
try {
const [kind, pubkey, dtag] = match.split(":");
// Compose a fake event for naddrEncode
const fakeEvent = {
kind: parseInt(kind),
pubkey,
tags: [["d", dtag]],
};
const naddr = naddrEncode(fakeEvent as any, standardRelays);
return `<a href='./events?id=${naddr}' class='text-primary-600 underline' target='_blank'>${match}</a>`;
} catch {
return match;
}
}); });
// Escape < and > for HTML safety, but allow our <a> tags detailsModalOpen = true;
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
json = json.replace(/&lt;a /g, '<a ').replace(/&lt;\/a&gt;/g, '</a>');
return json;
} }
// Log component initialization
console.debug("[CardActions] Initialized", {
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
title: event.title,
author: event.author
});
</script> </script>
<div class="group bg-highlight dark:bg-primary-1000 rounded" role="group" onmouseenter={openPopover}> <div class="group bg-highlight dark:bg-primary-1000 rounded" role="group" onmouseenter={openPopover}>
@ -142,22 +133,18 @@
</button> </button>
</li> </li>
<li> <li>
<button class='btn-leather w-full text-left' onclick={shareNjump}> <CopyToClipboard
{#if shareLinkCopied} displayText="Copy naddr address"
<ClipboardCheckOutline class="inline mr-2" /> Copied! copyText={getIdentifier('naddr')}
{:else} icon={ShareNodesOutline}
<ShareNodesOutline class="inline mr-2" /> Share via NJump />
{/if}
</button>
</li> </li>
<li> <li>
<button class='btn-leather w-full text-left' onclick={copyEventId}> <CopyToClipboard
{#if eventIdCopied} displayText="Copy nevent address"
<ClipboardCheckOutline class="inline mr-2" /> Copied! copyText={getIdentifier('nevent')}
{:else} icon={ClipboardCleanOutline}
<ClipboardCleanOutline class="inline mr-2" /> Copy event ID />
{/if}
</button>
</li> </li>
</ul> </ul>
</div> </div>
@ -214,7 +201,7 @@
<h5 class="text-sm">Identifier: {identifier}</h5> <h5 class="text-sm">Identifier: {identifier}</h5>
{/if} {/if}
<a <a
href="/events?id={neventEncode(event, standardRelays)}" href="/events?id={neventEncode(event, activeRelays)}"
class="mt-4 btn-leather text-center text-primary-700 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 font-semibold" class="mt-4 btn-leather text-center text-primary-700 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 font-semibold"
> >
View Event Details View Event Details

30
src/lib/components/util/CopyToClipboard.svelte

@ -1,27 +1,41 @@
<script lang='ts'> <script lang='ts'>
import { ClipboardCheckOutline, ClipboardCleanOutline } from "flowbite-svelte-icons"; import { ClipboardCheckOutline, ClipboardCleanOutline } from "flowbite-svelte-icons";
import { withTimeout } from "$lib/utils/nostrUtils";
let { displayText, copyText = displayText} = $props(); let { displayText, copyText = displayText, icon = ClipboardCleanOutline } = $props<{
displayText: string;
copyText?: string;
icon?: typeof ClipboardCleanOutline;
}>();
let copied: boolean = $state(false); let copied: boolean = $state(false);
async function copyToClipboard() { async function copyToClipboard() {
try { try {
await navigator.clipboard.writeText(copyText); await withTimeout(navigator.clipboard.writeText(copyText), 2000);
copied = true; copied = true;
setTimeout(() => { await withTimeout(
new Promise(resolve => setTimeout(resolve, 4000)),
4000
).then(() => {
copied = false; copied = false;
}, 4000); }).catch(() => {
// If timeout occurs, still reset the state
copied = false;
});
} catch (err) { } catch (err) {
console.error("Failed to copy: ", err); console.error("[CopyToClipboard] Failed to copy:", err instanceof Error ? err.message : err);
} }
} }
</script> </script>
<button class='btn-leather text-nowrap' onclick={copyToClipboard}> <button class='btn-leather w-full text-left' onclick={copyToClipboard}>
{#if copied} {#if copied}
<ClipboardCheckOutline class="!fill-none dark:!fill-none inline mr-1" /> Copied! <ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else} {:else}
<ClipboardCleanOutline class="!fill-none dark:!fill-none inline mr-1" /> {displayText} {#if icon}
<icon class="inline mr-2"></icon>
{/if}
{displayText}
{/if} {/if}
</button> </button>

45
src/lib/parser.ts

@ -152,6 +152,10 @@ export default class Pharos {
} }
parse(content: string, options?: ProcessorOptions | undefined): void { parse(content: string, options?: ProcessorOptions | undefined): void {
// Ensure the content is valid AsciiDoc and has a header and the doctype book
content = ensureAsciiDocHeader(content);
try { try {
this.html = this.asciidoctor.convert(content, { this.html = this.asciidoctor.convert(content, {
'extension_registry': this.pharosExtensions, 'extension_registry': this.pharosExtensions,
@ -1119,3 +1123,44 @@ export const tocUpdate = writable(0);
// Whenever you update the publication tree, call: // Whenever you update the publication tree, call:
tocUpdate.update(n => n + 1); tocUpdate.update(n => n + 1);
function ensureAsciiDocHeader(content: string): string {
const lines = content.split(/\r?\n/);
let headerIndex = -1;
let hasDoctype = false;
// Find the first non-empty line as header
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '') continue;
if (lines[i].trim().startsWith('=')) {
headerIndex = i;
console.debug('[Pharos] AsciiDoc document header:', lines[i].trim());
break;
} else {
throw new Error('AsciiDoc document is missing a header at the top.');
}
}
if (headerIndex === -1) {
throw new Error('AsciiDoc document is missing a header.');
}
// Check for doctype in the next non-empty line after header
let nextLine = headerIndex + 1;
while (nextLine < lines.length && lines[nextLine].trim() === '') {
nextLine++;
}
if (nextLine < lines.length && lines[nextLine].trim().startsWith(':doctype:')) {
hasDoctype = true;
}
// Insert doctype immediately after header if not present
if (!hasDoctype) {
lines.splice(headerIndex + 1, 0, ':doctype: book');
}
// Log the state of the lines before returning
console.debug('[Pharos] AsciiDoc lines after header/doctype normalization:', lines.slice(0, 5));
return lines.join('\n');
}

118
src/lib/utils/nostrUtils.ts

@ -185,6 +185,55 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
} }
} }
/**
* Generic utility function to add a timeout to any promise
* Can be used in two ways:
* 1. Method style: promise.withTimeout(5000)
* 2. Function style: withTimeout(promise, 5000)
*
* @param thisOrPromise Either the promise to timeout (function style) or the 'this' context (method style)
* @param timeoutMsOrPromise Timeout duration in milliseconds (function style) or the promise (method style)
* @returns The promise result if completed before timeout, otherwise throws an error
* @throws Error with message 'Timeout' if the promise doesn't resolve within timeoutMs
*/
export function withTimeout<T>(
thisOrPromise: Promise<T> | number,
timeoutMsOrPromise?: number | Promise<T>
): Promise<T> {
// Handle method-style call (promise.withTimeout(5000))
if (typeof thisOrPromise === 'number') {
const timeoutMs = thisOrPromise;
const promise = timeoutMsOrPromise as Promise<T>;
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
)
]);
}
// Handle function-style call (withTimeout(promise, 5000))
const promise = thisOrPromise;
const timeoutMs = timeoutMsOrPromise as number;
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
)
]);
}
// Add the method to Promise prototype
declare global {
interface Promise<T> {
withTimeout(timeoutMs: number): Promise<T>;
}
}
Promise.prototype.withTimeout = function<T>(this: Promise<T>, timeoutMs: number): Promise<T> {
return withTimeout(timeoutMs, this);
};
/** /**
* Fetches an event using a two-step relay strategy: * Fetches an event using a two-step relay strategy:
* 1. First tries standard relays with timeout * 1. First tries standard relays with timeout
@ -196,42 +245,59 @@ export async function fetchEventWithFallback(
filterOrId: string | NDKFilter<NDKKind>, filterOrId: string | NDKFilter<NDKKind>,
timeoutMs: number = 3000 timeoutMs: number = 3000
): Promise<NDKEvent | null> { ): Promise<NDKEvent | null> {
const allRelays = Array.from(new Set([...standardRelays, ...bootstrapRelays])); // Get user relays if logged in
const userRelays = ndk.activeUser ?
Array.from(ndk.pool?.relays.values() || [])
.filter(r => r.status === 1) // Only use connected relays
.map(r => r.url) :
[];
// Create three relay sets in priority order
const relaySets = [ const relaySets = [
NDKRelaySet.fromRelayUrls(standardRelays, ndk), NDKRelaySet.fromRelayUrls(standardRelays, ndk), // 1. Standard relays
NDKRelaySet.fromRelayUrls(allRelays, ndk) NDKRelaySet.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
NDKRelaySet.fromRelayUrls(bootstrapRelays, ndk) // 3. Bootstrap relays (last resort)
]; ];
async function withTimeout<T>(promise: Promise<T>): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeoutMs))
]);
}
try { try {
let found: NDKEvent | null = null; let found: NDKEvent | null = null;
const triedRelaySets: string[] = [];
// Try standard relays first // Helper function to try fetching from a relay set
if (typeof filterOrId === 'string' && /^[0-9a-f]{64}$/i.test(filterOrId)) { async function tryFetchFromRelaySet(relaySet: NDKRelaySet, setName: string): Promise<NDKEvent | null> {
found = await withTimeout(ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySets[0])); if (relaySet.relays.size === 0) return null;
if (!found) { triedRelaySets.push(setName);
// Fallback to all relays
found = await withTimeout(ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySets[1])); if (typeof filterOrId === 'string' && /^[0-9a-f]{64}$/i.test(filterOrId)) {
} return await ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySet).withTimeout(timeoutMs);
} else { } else {
const filter = typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId; const filter = typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId;
const results = await withTimeout(ndk.fetchEvents(filter, undefined, relaySets[0])); const results = await ndk.fetchEvents(filter, undefined, relaySet).withTimeout(timeoutMs);
found = results instanceof Set ? Array.from(results)[0] as NDKEvent : null; return results instanceof Set ? Array.from(results)[0] as NDKEvent : null;
if (!found) {
// Fallback to all relays
const fallbackResults = await withTimeout(ndk.fetchEvents(filter, undefined, relaySets[1]));
found = fallbackResults instanceof Set ? Array.from(fallbackResults)[0] as NDKEvent : null;
} }
} }
// Try each relay set in order
for (const [index, relaySet] of relaySets.entries()) {
const setName = index === 0 ? 'standard relays' :
index === 1 ? 'user relays' :
'bootstrap relays';
found = await tryFetchFromRelaySet(relaySet, setName);
if (found) break;
}
if (!found) { if (!found) {
console.warn('Event not found after timeout. Some relays may be offline or slow.'); const timeoutSeconds = timeoutMs / 1000;
const relayUrls = relaySets.map((set, i) => {
const setName = i === 0 ? 'standard relays' :
i === 1 ? 'user relays' :
'bootstrap relays';
const urls = Array.from(set.relays).map(r => r.url);
return urls.length > 0 ? `${setName} (${urls.join(', ')})` : null;
}).filter(Boolean).join(', then ');
console.warn(`Event not found after ${timeoutSeconds}s timeout. Tried ${relayUrls}. Some relays may be offline or slow.`);
return null; return null;
} }

Loading…
Cancel
Save