diff --git a/public/healthz.json b/public/healthz.json index 55f2aa6..730eefb 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.3.1", - "buildTime": "2026-02-12T11:24:49.574Z", + "buildTime": "2026-02-12T11:34:23.480Z", "gitCommit": "unknown", - "timestamp": 1770895489574 + "timestamp": 1770896063480 } \ No newline at end of file diff --git a/src/lib/components/content/MediaAttachments.svelte b/src/lib/components/content/MediaAttachments.svelte index 7640ff3..11ef411 100644 --- a/src/lib/components/content/MediaAttachments.svelte +++ b/src/lib/components/content/MediaAttachments.svelte @@ -81,6 +81,84 @@ return false; } + // Helper function to parse imeta tag and return metadata + function parseImetaTag(tag: string[]): { + url?: string; + mimeType?: string; + width?: number; + height?: number; + alt?: string; + thumbnailUrl?: string; + blurhash?: string; + } { + let url: string | undefined; + let mimeType: string | undefined; + let width: number | undefined; + let height: number | undefined; + let alt: string | undefined; + let thumbnailUrl: string | undefined; + let blurhash: string | undefined; + + for (let i = 1; i < tag.length; i++) { + const item = tag[i]; + if (item.startsWith('url ')) { + url = item.substring(4).trim(); + } else if (item.startsWith('m ')) { + mimeType = item.substring(2).trim(); + } else if (item.startsWith('x ')) { + width = parseInt(item.substring(2).trim(), 10); + } else if (item.startsWith('y ')) { + height = parseInt(item.substring(2).trim(), 10); + } else if (item.startsWith('alt ')) { + alt = item.substring(4).trim(); + } else if (item.startsWith('thumb ')) { + thumbnailUrl = item.substring(6).trim(); + } else if (item.startsWith('blurhash ')) { + blurhash = item.substring(9).trim(); + } else if (item.startsWith('bh ')) { + blurhash = item.substring(3).trim(); + } else if (item.startsWith('dim ')) { + // Parse "dim WIDTHxHEIGHT" + const dimStr = item.substring(4).trim(); + const dimMatch = dimStr.match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/i); + if (dimMatch) { + width = parseInt(dimMatch[1], 10); + height = parseInt(dimMatch[2], 10); + } + } + } + + return { url, mimeType, width, height, alt, thumbnailUrl, blurhash }; + } + + // Helper function to get imeta metadata for a URL + function getImetaForUrl(url: string): { + mimeType?: string; + width?: number; + height?: number; + alt?: string; + thumbnailUrl?: string; + blurhash?: string; + } | null { + const normalized = normalizeUrl(url); + for (const tag of event.tags) { + if (tag[0] === 'imeta') { + const imeta = parseImetaTag(tag); + if (imeta.url && normalizeUrl(imeta.url) === normalized) { + return { + mimeType: imeta.mimeType, + width: imeta.width, + height: imeta.height, + alt: imeta.alt, + thumbnailUrl: imeta.thumbnailUrl, + blurhash: imeta.blurhash + }; + } + } + } + return null; + } + function extractMedia(): MediaItem[] { const media: MediaItem[] = []; const seen = new Set(); @@ -90,83 +168,156 @@ if (imageTag && imageTag[1]) { const normalized = normalizeUrl(imageTag[1]); if (!seen.has(normalized)) { + // Check for imeta metadata for this URL + const imeta = getImetaForUrl(imageTag[1]); + + let type: 'image' | 'video' | 'audio' = 'image'; + if (imeta?.mimeType) { + if (imeta.mimeType.startsWith('video/')) type = 'video'; + else if (imeta.mimeType.startsWith('audio/')) type = 'audio'; + } + media.push({ url: imageTag[1], - type: 'image', - source: 'image-tag' + type, + source: 'image-tag', + ...(imeta || {}) }); seen.add(normalized); } } - // 2. imeta tags (NIP-92) - only display if NOT already in content + // 2. Extract from markdown content (images in markdown syntax) + const imageRegex = /!\[.*?\]\((.*?)\)/g; + let match; + while ((match = imageRegex.exec(event.content)) !== null) { + const url = match[1]; + const normalized = normalizeUrl(url); + if (!seen.has(normalized)) { + // Check for imeta metadata for this URL + const imeta = getImetaForUrl(url); + + let type: 'image' | 'video' | 'audio' = 'image'; + if (imeta?.mimeType) { + if (imeta.mimeType.startsWith('video/')) type = 'video'; + else if (imeta.mimeType.startsWith('audio/')) type = 'audio'; + } + + media.push({ + url, + type, + source: 'content', + ...(imeta || {}) + }); + seen.add(normalized); + } + } + + // 2b. Extract from AsciiDoc content (images in AsciiDoc syntax: image::url[] or image:url[]) + const asciidocImageRegex = /image::?([^\s\[\]]+)(?:\[[^\]]*\])?/g; + asciidocImageRegex.lastIndex = 0; // Reset regex + let asciidocMatch; + while ((asciidocMatch = asciidocImageRegex.exec(event.content)) !== null) { + const url = asciidocMatch[1]; + // Skip if it's not a URL (could be a relative path, but we only want absolute URLs) + if (!url.startsWith('http://') && !url.startsWith('https://')) { + continue; + } + const normalized = normalizeUrl(url); + if (!seen.has(normalized)) { + // Check for imeta metadata for this URL + const imeta = getImetaForUrl(url); + + let type: 'image' | 'video' | 'audio' = 'image'; + if (imeta?.mimeType) { + if (imeta.mimeType.startsWith('video/')) type = 'video'; + else if (imeta.mimeType.startsWith('audio/')) type = 'audio'; + } + + media.push({ + url, + type, + source: 'content', + ...(imeta || {}) + }); + seen.add(normalized); + } + } + + // 3. Extract plain image URLs from content (not just markdown) + const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp|mp4|webm|ogg|mov|avi|mkv|mp3|wav|flac|aac|m4a)(\?[^\s<>"{}|\\^`\[\]]*)?)/gi; + urlRegex.lastIndex = 0; // Reset regex + while ((match = urlRegex.exec(event.content)) !== null) { + const url = match[1]; + const normalized = normalizeUrl(url); + // Skip if already added (from markdown or image tag) + if (seen.has(normalized)) { + continue; + } + + // Check for imeta metadata for this URL + const imeta = getImetaForUrl(url); + + let type: 'image' | 'video' | 'audio' = 'image'; + const ext = match[2].toLowerCase(); + if (['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'].includes(ext)) { + type = 'video'; + } else if (['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(ext)) { + type = 'audio'; + } + + // Override type from imeta if available + if (imeta?.mimeType) { + if (imeta.mimeType.startsWith('video/')) type = 'video'; + else if (imeta.mimeType.startsWith('audio/')) type = 'audio'; + else if (imeta.mimeType.startsWith('image/')) type = 'image'; + } + + media.push({ + url, + type, + source: 'content', + ...(imeta || {}) + }); + seen.add(normalized); + } + + // 4. imeta tags (NIP-92) - only display if NOT already in content/image tag + // (imeta is metadata, so if URL is already extracted above, skip adding it again) for (const tag of event.tags) { if (tag[0] === 'imeta') { - let url: string | undefined; - let mimeType: string | undefined; - let width: number | undefined; - let height: number | undefined; - let alt: string | undefined; - let thumbnailUrl: string | undefined; - let blurhash: string | undefined; - - for (let i = 1; i < tag.length; i++) { - const item = tag[i]; - if (item.startsWith('url ')) { - url = item.substring(4).trim(); - } else if (item.startsWith('m ')) { - mimeType = item.substring(2).trim(); - } else if (item.startsWith('x ')) { - width = parseInt(item.substring(2).trim(), 10); - } else if (item.startsWith('y ')) { - height = parseInt(item.substring(2).trim(), 10); - } else if (item.startsWith('alt ')) { - alt = item.substring(4).trim(); - } else if (item.startsWith('thumb ')) { - thumbnailUrl = item.substring(6).trim(); - } else if (item.startsWith('blurhash ')) { - blurhash = item.substring(9).trim(); - } else if (item.startsWith('bh ')) { - blurhash = item.substring(3).trim(); - } else if (item.startsWith('dim ')) { - // Parse "dim WIDTHxHEIGHT" - const dimStr = item.substring(4).trim(); - const dimMatch = dimStr.match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/i); - if (dimMatch) { - width = parseInt(dimMatch[1], 10); - height = parseInt(dimMatch[2], 10); - } + const imeta = parseImetaTag(tag); + if (imeta.url) { + const normalized = normalizeUrl(imeta.url); + // Skip if already added from content/image tag + if (seen.has(normalized)) { + continue; } - } - - if (url) { - const normalized = normalizeUrl(url); + // Skip if already displayed in content (imeta is just metadata reference) // UNLESS forceRender is true (for media kinds where media is the primary content) - if (!forceRender && isUrlInContent(url)) { + if (!forceRender && isUrlInContent(imeta.url)) { continue; } - if (!seen.has(normalized)) { - let type: 'image' | 'video' | 'audio' = 'image'; - if (mimeType) { - if (mimeType.startsWith('video/')) type = 'video'; - else if (mimeType.startsWith('audio/')) type = 'audio'; - } - - media.push({ - url, - thumbnailUrl, - blurhash, - type, - mimeType, - width, - height, - alt, - source: 'imeta' - }); - seen.add(normalized); + let type: 'image' | 'video' | 'audio' = 'image'; + if (imeta.mimeType) { + if (imeta.mimeType.startsWith('video/')) type = 'video'; + else if (imeta.mimeType.startsWith('audio/')) type = 'audio'; } + + media.push({ + url: imeta.url, + thumbnailUrl: imeta.thumbnailUrl, + blurhash: imeta.blurhash, + type, + mimeType: imeta.mimeType, + width: imeta.width, + height: imeta.height, + alt: imeta.alt, + source: 'imeta' + }); + seen.add(normalized); } } } @@ -268,25 +419,6 @@ } } - // 5. Extract from markdown content (images in markdown syntax) - const imageRegex = /!\[.*?\]\((.*?)\)/g; - let match; - while ((match = imageRegex.exec(event.content)) !== null) { - const url = match[1]; - const normalized = normalizeUrl(url); - if (!seen.has(normalized)) { - media.push({ - url, - type: 'image', - source: 'content' - }); - seen.add(normalized); - } - } - - // 6. Don't extract plain image URLs from content - let markdown render them inline - // This ensures images appear where the URL is in the content, not at the top - // Only extract images from tags (image, imeta, file) which are handled above // Final deduplication pass: ensure no duplicates by normalized URL const deduplicated: MediaItem[] = []; @@ -592,7 +724,7 @@ .media-gallery { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 1rem; margin-top: 1rem; } diff --git a/src/lib/components/content/MediaViewer.svelte b/src/lib/components/content/MediaViewer.svelte index c4eaeaa..a14b321 100644 --- a/src/lib/components/content/MediaViewer.svelte +++ b/src/lib/components/content/MediaViewer.svelte @@ -74,22 +74,10 @@ display: flex; align-items: center; justify-content: center; - padding: 2rem; + padding: 0; animation: fadeIn 0.2s ease-out; } - @media (max-width: 768px) { - .media-viewer-backdrop { - padding: 1rem; - } - } - - @media (max-width: 640px) { - .media-viewer-backdrop { - padding: 0.5rem; - } - } - @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } @@ -97,33 +85,19 @@ .media-viewer-content { position: relative; - max-width: 90vw; - max-height: 90vh; + max-width: 100vw; + max-height: 100vh; display: flex; align-items: center; justify-content: center; width: 100%; } - @media (max-width: 768px) { - .media-viewer-content { - max-width: 95vw; - max-height: 95vh; - } - } - - @media (max-width: 640px) { - .media-viewer-content { - max-width: 100vw; - max-height: 100vh; - } - } - .media-viewer-close { position: absolute; - top: -2.5rem; - right: 0; - background: rgba(255, 255, 255, 0.2); + top: 1rem; + right: 1rem; + background: rgba(0, 0, 0, 0.6); border: none; color: white; font-size: 2rem; @@ -136,15 +110,7 @@ justify-content: center; line-height: 1; transition: background 0.2s; - } - - @media (max-width: 768px) { - .media-viewer-close { - top: -2rem; - width: 2rem; - height: 2rem; - font-size: 1.5rem; - } + z-index: 10001; } @media (max-width: 640px) { @@ -154,31 +120,24 @@ width: 2rem; height: 2rem; font-size: 1.5rem; - background: rgba(0, 0, 0, 0.6); } } .media-viewer-close:hover { - background: rgba(255, 255, 255, 0.3); + background: rgba(0, 0, 0, 0.8); } .media-viewer-media { - max-width: 100%; - max-height: 90vh; + max-width: 100vw; + max-height: 100vh; + width: auto; + height: auto; object-fit: contain; border-radius: 0.5rem; } - @media (max-width: 768px) { - .media-viewer-media { - max-height: 95vh; - border-radius: 0.25rem; - } - } - @media (max-width: 640px) { .media-viewer-media { - max-height: 100vh; border-radius: 0; } } diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte index 89cc8ff..5a88087 100644 --- a/src/lib/modules/feed/FeedPage.svelte +++ b/src/lib/modules/feed/FeedPage.svelte @@ -12,12 +12,14 @@ import { page } from '$app/stores'; import Pagination from '../../components/ui/Pagination.svelte'; import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../utils/pagination.js'; + import { isReply } from '../../utils/event-utils.js'; interface Props { singleRelay?: string; + showOnlyOPs?: boolean; } - let { singleRelay }: Props = $props(); + let { singleRelay, showOnlyOPs = false }: Props = $props(); // Expose API for parent component via component reference // Note: The warning about loadOlderEvents is a false positive - functions don't need to be reactive @@ -74,8 +76,12 @@ } } - // Use all events directly (no filtering) - let events = $derived(allEvents); + // Filter events based on showOnlyOPs + let events = $derived( + showOnlyOPs + ? allEvents.filter(event => !isReply(event)) + : allEvents + ); // Pagination let currentPage = $derived(getCurrentPage($page.url.searchParams)); diff --git a/src/lib/utils/event-utils.ts b/src/lib/utils/event-utils.ts new file mode 100644 index 0000000..feea0cb --- /dev/null +++ b/src/lib/utils/event-utils.ts @@ -0,0 +1,19 @@ +import type { NostrEvent } from '../types/nostr.js'; + +/** + * Check if an event is a reply (has event reference tags) + * An event is considered a reply if it has e/E/q tags pointing to other events, + * or a/A/i/I tags with parameterized references + */ +export function isReply(event: NostrEvent): boolean { + return event.tags.some((t) => { + const tagName = t[0]; + if (tagName === 'e' || tagName === 'E' || tagName === 'q') { + return t[1] && t[1] !== event.id; + } + if (tagName === 'a' || tagName === 'A' || tagName === 'i' || tagName === 'I') { + return t[1] && t[1].includes(':'); + } + return false; + }); +} diff --git a/src/routes/feed/+page.svelte b/src/routes/feed/+page.svelte index 2f28aa1..de373b3 100644 --- a/src/routes/feed/+page.svelte +++ b/src/routes/feed/+page.svelte @@ -13,6 +13,8 @@ loadWaitingRoomEvents: () => void; } | null = $state(null); + let showOnlyOPs = $state(false); + onMount(async () => { await nostrClient.initialize(); }); @@ -27,6 +29,16 @@

/Feed

+
+ +
- + @@ -90,6 +102,37 @@ } } + .feed-filter { + display: flex; + align-items: center; + margin-right: 1rem; + } + + .feed-filter-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + user-select: none; + font-size: 0.875rem; + color: var(--fog-text, #1e293b); + } + + :global(.dark) .feed-filter-label { + color: var(--fog-dark-text, #f1f5f9); + } + + .feed-filter-checkbox { + cursor: pointer; + width: 1rem; + height: 1rem; + accent-color: var(--fog-accent, #64748b); + } + + :global(.dark) .feed-filter-checkbox { + accent-color: var(--fog-dark-accent, #94a3b8); + } + .feed-header-buttons { display: flex; gap: 0.5rem; diff --git a/src/routes/lists/+page.svelte b/src/routes/lists/+page.svelte index 46c27e0..2bbb7c0 100644 --- a/src/routes/lists/+page.svelte +++ b/src/routes/lists/+page.svelte @@ -12,6 +12,7 @@ import { page } from '$app/stores'; import Pagination from '../../lib/components/ui/Pagination.svelte'; import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js'; + import { isReply } from '../../lib/utils/event-utils.js'; interface ListInfo { kind: number; @@ -27,13 +28,21 @@ let loading = $state(true); let loadingEvents = $state(false); let hasLists = $derived(lists.length > 0); + let showOnlyOPs = $state(false); + + // Filter events based on showOnlyOPs + let filteredEvents = $derived( + showOnlyOPs + ? events.filter(event => !isReply(event)) + : events + ); // Pagination let currentPage = $derived(getCurrentPage($page.url.searchParams)); let paginatedEvents = $derived( - events.length > ITEMS_PER_PAGE - ? getPaginatedItems(events, currentPage, ITEMS_PER_PAGE) - : events + filteredEvents.length > ITEMS_PER_PAGE + ? getPaginatedItems(filteredEvents, currentPage, ITEMS_PER_PAGE) + : filteredEvents ); // Subscribe to session changes to reactively update login status @@ -377,13 +386,24 @@ {/each} + +
+ +
{#if loadingEvents}

Loading events...

- {:else if selectedList && events.length === 0} + {:else if selectedList && filteredEvents.length === 0}

No events found for this list.

@@ -393,8 +413,8 @@ {/each} - {#if events.length > ITEMS_PER_PAGE} - + {#if filteredEvents.length > ITEMS_PER_PAGE} + {/if} {/if} {/if} @@ -428,6 +448,36 @@ border-color: var(--fog-dark-accent, #94a3b8); } + .lists-filter { + display: flex; + align-items: center; + } + + .lists-filter-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + user-select: none; + font-size: 0.875rem; + color: var(--fog-text, #1e293b); + } + + :global(.dark) .lists-filter-label { + color: var(--fog-dark-text, #f1f5f9); + } + + .lists-filter-checkbox { + cursor: pointer; + width: 1rem; + height: 1rem; + accent-color: var(--fog-accent, #64748b); + } + + :global(.dark) .lists-filter-checkbox { + accent-color: var(--fog-dark-accent, #94a3b8); + } + .events-list { display: flex; flex-direction: column;