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. 159
      src/lib/components/util/CardActions.svelte
  4. 30
      src/lib/components/util/CopyToClipboard.svelte
  5. 45
      src/lib/parser.ts
  6. 112
      src/lib/utils/nostrUtils.ts

2
README.md

@ -75,7 +75,7 @@ To run the container, in detached mode (-d): @@ -75,7 +75,7 @@ To run the container, in detached mode (-d):
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:

1
src/lib/components/PublicationHeader.svelte

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

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

@ -1,17 +1,21 @@ @@ -1,17 +1,21 @@
<script lang="ts">
import {
ClipboardCheckOutline,
ClipboardCleanOutline,
DotsVerticalOutline,
EyeOutline,
ShareNodesOutline
} from "flowbite-svelte-icons";
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 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
let title = $derived(event.tags.find((t: string[]) => t[0] === 'title')?.[1] ?? '');
@ -26,94 +30,81 @@ @@ -26,94 +30,81 @@
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 jsonModalOpen: boolean = $state(false);
// UI state
let detailsModalOpen: boolean = $state(false);
let eventIdCopied: boolean = $state(false);
let shareLinkCopied: boolean = $state(false);
let isOpen: 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() {
console.debug("[CardActions] Opening menu", { eventId: event.id });
isOpen = true;
}
/**
* Closes the actions popover menu and removes focus
*/
function closePopover() {
console.debug("[CardActions] Closing menu", { eventId: event.id });
isOpen = false;
const menu = document.getElementById('dots-' + event.id);
if (menu) menu.blur();
}
function shareNjump() {
const relays: string[] = standardRelays;
try {
const naddr = naddrEncode(event, relays);
console.debug(naddr);
navigator.clipboard.writeText(`https://njump.me/${naddr}`);
shareLinkCopied = true;
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;
/**
* Gets the appropriate identifier (nevent or naddr) for copying
* @param type - The type of identifier to get ('nevent' or 'naddr')
* @returns The encoded identifier string
*/
function getIdentifier(type: 'nevent' | 'naddr'): string {
const encodeFn = type === 'nevent' ? neventEncode : naddrEncode;
return encodeFn(event, activeRelays);
}
/**
* Opens the event details modal
*/
function viewDetails() {
console.debug("[CardActions] Opening details modal", {
eventId: event.id,
title: event.title,
author: event.author
});
detailsModalOpen = true;
}
// --- Custom JSON pretty-printer with naddr hyperlinking ---
/**
* Returns HTML for pretty-printed JSON, with naddrs as links to /events?id=naddr1...
*/
function jsonWithNaddrLinks(obj: any): string {
const NADDR_REGEX = /\b(\d{5}:[a-f0-9]{64}:[a-zA-Z0-9._-]+)\b/g;
function replacer(_key: string, value: any) {
return value;
}
// 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;
}
// Log component initialization
console.debug("[CardActions] Initialized", {
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
title: event.title,
author: event.author
});
// Escape < and > for HTML safety, but allow our <a> tags
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;
}
</script>
<div class="group bg-highlight dark:bg-primary-1000 rounded" role="group" onmouseenter={openPopover}>
@ -142,22 +133,18 @@ @@ -142,22 +133,18 @@
</button>
</li>
<li>
<button class='btn-leather w-full text-left' onclick={shareNjump}>
{#if shareLinkCopied}
<ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else}
<ShareNodesOutline class="inline mr-2" /> Share via NJump
{/if}
</button>
<CopyToClipboard
displayText="Copy naddr address"
copyText={getIdentifier('naddr')}
icon={ShareNodesOutline}
/>
</li>
<li>
<button class='btn-leather w-full text-left' onclick={copyEventId}>
{#if eventIdCopied}
<ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else}
<ClipboardCleanOutline class="inline mr-2" /> Copy event ID
{/if}
</button>
<CopyToClipboard
displayText="Copy nevent address"
copyText={getIdentifier('nevent')}
icon={ClipboardCleanOutline}
/>
</li>
</ul>
</div>
@ -214,7 +201,7 @@ @@ -214,7 +201,7 @@
<h5 class="text-sm">Identifier: {identifier}</h5>
{/if}
<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"
>
View Event Details

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

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

45
src/lib/parser.ts

@ -152,6 +152,10 @@ export default class Pharos { @@ -152,6 +152,10 @@ export default class Pharos {
}
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 {
this.html = this.asciidoctor.convert(content, {
'extension_registry': this.pharosExtensions,
@ -1119,3 +1123,44 @@ export const tocUpdate = writable(0); @@ -1119,3 +1123,44 @@ export const tocUpdate = writable(0);
// Whenever you update the publication tree, call:
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');
}

112
src/lib/utils/nostrUtils.ts

@ -185,6 +185,55 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> { @@ -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:
* 1. First tries standard relays with timeout
@ -196,42 +245,59 @@ export async function fetchEventWithFallback( @@ -196,42 +245,59 @@ export async function fetchEventWithFallback(
filterOrId: string | NDKFilter<NDKKind>,
timeoutMs: number = 3000
): 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 = [
NDKRelaySet.fromRelayUrls(standardRelays, ndk),
NDKRelaySet.fromRelayUrls(allRelays, ndk)
NDKRelaySet.fromRelayUrls(standardRelays, ndk), // 1. Standard relays
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 {
let found: NDKEvent | null = null;
const triedRelaySets: string[] = [];
// Helper function to try fetching from a relay set
async function tryFetchFromRelaySet(relaySet: NDKRelaySet, setName: string): Promise<NDKEvent | null> {
if (relaySet.relays.size === 0) return null;
triedRelaySets.push(setName);
// Try standard relays first
if (typeof filterOrId === 'string' && /^[0-9a-f]{64}$/i.test(filterOrId)) {
found = await withTimeout(ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySets[0]));
if (!found) {
// Fallback to all relays
found = await withTimeout(ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySets[1]));
}
return await ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySet).withTimeout(timeoutMs);
} else {
const filter = typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId;
const results = await withTimeout(ndk.fetchEvents(filter, undefined, relaySets[0]));
found = 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;
const results = await ndk.fetchEvents(filter, undefined, relaySet).withTimeout(timeoutMs);
return results instanceof Set ? Array.from(results)[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) {
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;
}

Loading…
Cancel
Save