Browse Source

media refinements

add OP-only checkbox to feeds
master
Silberengel 1 month ago
parent
commit
b4bd419b66
  1. 4
      public/healthz.json
  2. 294
      src/lib/components/content/MediaAttachments.svelte
  3. 65
      src/lib/components/content/MediaViewer.svelte
  4. 12
      src/lib/modules/feed/FeedPage.svelte
  5. 19
      src/lib/utils/event-utils.ts
  6. 45
      src/routes/feed/+page.svelte
  7. 62
      src/routes/lists/+page.svelte

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.3.1", "version": "0.3.1",
"buildTime": "2026-02-12T11:24:49.574Z", "buildTime": "2026-02-12T11:34:23.480Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770895489574 "timestamp": 1770896063480
} }

294
src/lib/components/content/MediaAttachments.svelte

@ -81,6 +81,84 @@
return false; 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[] { function extractMedia(): MediaItem[] {
const media: MediaItem[] = []; const media: MediaItem[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@ -90,83 +168,156 @@
if (imageTag && imageTag[1]) { if (imageTag && imageTag[1]) {
const normalized = normalizeUrl(imageTag[1]); const normalized = normalizeUrl(imageTag[1]);
if (!seen.has(normalized)) { 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({ media.push({
url: imageTag[1], url: imageTag[1],
type: 'image', type,
source: 'image-tag' source: 'image-tag',
...(imeta || {})
}); });
seen.add(normalized); 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) { for (const tag of event.tags) {
if (tag[0] === 'imeta') { if (tag[0] === 'imeta') {
let url: string | undefined; const imeta = parseImetaTag(tag);
let mimeType: string | undefined; if (imeta.url) {
let width: number | undefined; const normalized = normalizeUrl(imeta.url);
let height: number | undefined; // Skip if already added from content/image tag
let alt: string | undefined; if (seen.has(normalized)) {
let thumbnailUrl: string | undefined; continue;
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);
}
} }
}
if (url) {
const normalized = normalizeUrl(url);
// Skip if already displayed in content (imeta is just metadata reference) // Skip if already displayed in content (imeta is just metadata reference)
// UNLESS forceRender is true (for media kinds where media is the primary content) // UNLESS forceRender is true (for media kinds where media is the primary content)
if (!forceRender && isUrlInContent(url)) { if (!forceRender && isUrlInContent(imeta.url)) {
continue; continue;
} }
if (!seen.has(normalized)) { let type: 'image' | 'video' | 'audio' = 'image';
let type: 'image' | 'video' | 'audio' = 'image'; if (imeta.mimeType) {
if (mimeType) { if (imeta.mimeType.startsWith('video/')) type = 'video';
if (mimeType.startsWith('video/')) type = 'video'; else if (imeta.mimeType.startsWith('audio/')) type = 'audio';
else if (mimeType.startsWith('audio/')) type = 'audio';
}
media.push({
url,
thumbnailUrl,
blurhash,
type,
mimeType,
width,
height,
alt,
source: 'imeta'
});
seen.add(normalized);
} }
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 // Final deduplication pass: ensure no duplicates by normalized URL
const deduplicated: MediaItem[] = []; const deduplicated: MediaItem[] = [];
@ -592,7 +724,7 @@
.media-gallery { .media-gallery {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 1rem; gap: 1rem;
margin-top: 1rem; margin-top: 1rem;
} }

65
src/lib/components/content/MediaViewer.svelte

@ -74,22 +74,10 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 2rem; padding: 0;
animation: fadeIn 0.2s ease-out; 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 { @keyframes fadeIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
@ -97,33 +85,19 @@
.media-viewer-content { .media-viewer-content {
position: relative; position: relative;
max-width: 90vw; max-width: 100vw;
max-height: 90vh; max-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; 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 { .media-viewer-close {
position: absolute; position: absolute;
top: -2.5rem; top: 1rem;
right: 0; right: 1rem;
background: rgba(255, 255, 255, 0.2); background: rgba(0, 0, 0, 0.6);
border: none; border: none;
color: white; color: white;
font-size: 2rem; font-size: 2rem;
@ -136,15 +110,7 @@
justify-content: center; justify-content: center;
line-height: 1; line-height: 1;
transition: background 0.2s; transition: background 0.2s;
} z-index: 10001;
@media (max-width: 768px) {
.media-viewer-close {
top: -2rem;
width: 2rem;
height: 2rem;
font-size: 1.5rem;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@ -154,31 +120,24 @@
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
font-size: 1.5rem; font-size: 1.5rem;
background: rgba(0, 0, 0, 0.6);
} }
} }
.media-viewer-close:hover { .media-viewer-close:hover {
background: rgba(255, 255, 255, 0.3); background: rgba(0, 0, 0, 0.8);
} }
.media-viewer-media { .media-viewer-media {
max-width: 100%; max-width: 100vw;
max-height: 90vh; max-height: 100vh;
width: auto;
height: auto;
object-fit: contain; object-fit: contain;
border-radius: 0.5rem; border-radius: 0.5rem;
} }
@media (max-width: 768px) {
.media-viewer-media {
max-height: 95vh;
border-radius: 0.25rem;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.media-viewer-media { .media-viewer-media {
max-height: 100vh;
border-radius: 0; border-radius: 0;
} }
} }

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

@ -12,12 +12,14 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import Pagination from '../../components/ui/Pagination.svelte'; import Pagination from '../../components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../utils/pagination.js'; import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../utils/pagination.js';
import { isReply } from '../../utils/event-utils.js';
interface Props { interface Props {
singleRelay?: string; singleRelay?: string;
showOnlyOPs?: boolean;
} }
let { singleRelay }: Props = $props(); let { singleRelay, showOnlyOPs = false }: Props = $props();
// Expose API for parent component via component reference // Expose API for parent component via component reference
// Note: The warning about loadOlderEvents is a false positive - functions don't need to be reactive // 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) // Filter events based on showOnlyOPs
let events = $derived(allEvents); let events = $derived(
showOnlyOPs
? allEvents.filter(event => !isReply(event))
: allEvents
);
// Pagination // Pagination
let currentPage = $derived(getCurrentPage($page.url.searchParams)); let currentPage = $derived(getCurrentPage($page.url.searchParams));

19
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;
});
}

45
src/routes/feed/+page.svelte

@ -13,6 +13,8 @@
loadWaitingRoomEvents: () => void; loadWaitingRoomEvents: () => void;
} | null = $state(null); } | null = $state(null);
let showOnlyOPs = $state(false);
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
}); });
@ -27,6 +29,16 @@
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Feed</h1> <h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Feed</h1>
</div> </div>
<div class="feed-controls"> <div class="feed-controls">
<div class="feed-filter">
<label class="feed-filter-label">
<input
type="checkbox"
bind:checked={showOnlyOPs}
class="feed-filter-checkbox"
/>
<span>Show only OPs</span>
</label>
</div>
<div class="feed-header-buttons"> <div class="feed-header-buttons">
<a href="/write?kind=1" class="see-more-events-btn-header" title="Write a new post"> <a href="/write?kind=1" class="see-more-events-btn-header" title="Write a new post">
Write Write
@ -52,7 +64,7 @@
</div> </div>
</div> </div>
<FeedPage bind:this={feedPageComponent} /> <FeedPage bind:this={feedPageComponent} showOnlyOPs={showOnlyOPs} />
</div> </div>
</main> </main>
@ -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 { .feed-header-buttons {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;

62
src/routes/lists/+page.svelte

@ -12,6 +12,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import Pagination from '../../lib/components/ui/Pagination.svelte'; import Pagination from '../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js'; import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js';
import { isReply } from '../../lib/utils/event-utils.js';
interface ListInfo { interface ListInfo {
kind: number; kind: number;
@ -27,13 +28,21 @@
let loading = $state(true); let loading = $state(true);
let loadingEvents = $state(false); let loadingEvents = $state(false);
let hasLists = $derived(lists.length > 0); 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 // Pagination
let currentPage = $derived(getCurrentPage($page.url.searchParams)); let currentPage = $derived(getCurrentPage($page.url.searchParams));
let paginatedEvents = $derived( let paginatedEvents = $derived(
events.length > ITEMS_PER_PAGE filteredEvents.length > ITEMS_PER_PAGE
? getPaginatedItems(events, currentPage, ITEMS_PER_PAGE) ? getPaginatedItems(filteredEvents, currentPage, ITEMS_PER_PAGE)
: events : filteredEvents
); );
// Subscribe to session changes to reactively update login status // Subscribe to session changes to reactively update login status
@ -377,13 +386,24 @@
{/each} {/each}
</select> </select>
</div> </div>
<div class="lists-filter mb-4">
<label class="lists-filter-label">
<input
type="checkbox"
bind:checked={showOnlyOPs}
class="lists-filter-checkbox"
/>
<span>Show only OPs</span>
</label>
</div>
</div> </div>
{#if loadingEvents} {#if loadingEvents}
<div class="text-center py-8"> <div class="text-center py-8">
<p>Loading events...</p> <p>Loading events...</p>
</div> </div>
{:else if selectedList && events.length === 0} {:else if selectedList && filteredEvents.length === 0}
<div class="text-center py-8"> <div class="text-center py-8">
<p>No events found for this list.</p> <p>No events found for this list.</p>
</div> </div>
@ -393,8 +413,8 @@
<FeedPost post={event} fullView={false} /> <FeedPost post={event} fullView={false} />
{/each} {/each}
</div> </div>
{#if events.length > ITEMS_PER_PAGE} {#if filteredEvents.length > ITEMS_PER_PAGE}
<Pagination totalItems={events.length} itemsPerPage={ITEMS_PER_PAGE} /> <Pagination totalItems={filteredEvents.length} itemsPerPage={ITEMS_PER_PAGE} />
{/if} {/if}
{/if} {/if}
{/if} {/if}
@ -428,6 +448,36 @@
border-color: var(--fog-dark-accent, #94a3b8); 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 { .events-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

Loading…
Cancel
Save