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 @@ @@ -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
}

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

@ -81,6 +81,84 @@ @@ -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<string>();
@ -90,83 +168,156 @@ @@ -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 @@ @@ -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 @@ @@ -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;
}

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

@ -74,22 +74,10 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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;
}
}

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

@ -12,12 +12,14 @@ @@ -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 @@ @@ -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));

19
src/lib/utils/event-utils.ts

@ -0,0 +1,19 @@ @@ -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 @@ @@ -13,6 +13,8 @@
loadWaitingRoomEvents: () => void;
} | null = $state(null);
let showOnlyOPs = $state(false);
onMount(async () => {
await nostrClient.initialize();
});
@ -27,6 +29,16 @@ @@ -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>
</div>
<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">
<a href="/write?kind=1" class="see-more-events-btn-header" title="Write a new post">
Write
@ -52,7 +64,7 @@ @@ -52,7 +64,7 @@
</div>
</div>
<FeedPage bind:this={feedPageComponent} />
<FeedPage bind:this={feedPageComponent} showOnlyOPs={showOnlyOPs} />
</div>
</main>
@ -90,6 +102,37 @@ @@ -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;

62
src/routes/lists/+page.svelte

@ -12,6 +12,7 @@ @@ -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 @@ @@ -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 @@ @@ -377,13 +386,24 @@
{/each}
</select>
</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>
{#if loadingEvents}
<div class="text-center py-8">
<p>Loading events...</p>
</div>
{:else if selectedList && events.length === 0}
{:else if selectedList && filteredEvents.length === 0}
<div class="text-center py-8">
<p>No events found for this list.</p>
</div>
@ -393,8 +413,8 @@ @@ -393,8 +413,8 @@
<FeedPost post={event} fullView={false} />
{/each}
</div>
{#if events.length > ITEMS_PER_PAGE}
<Pagination totalItems={events.length} itemsPerPage={ITEMS_PER_PAGE} />
{#if filteredEvents.length > ITEMS_PER_PAGE}
<Pagination totalItems={filteredEvents.length} itemsPerPage={ITEMS_PER_PAGE} />
{/if}
{/if}
{/if}
@ -428,6 +448,36 @@ @@ -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;

Loading…
Cancel
Save