@ -18,7 +18,25 @@
@@ -18,7 +18,25 @@
import { KIND } from '../../types/kind-lookup.js';
import CommentForm from '../comments/CommentForm.svelte';
import { getProfile } from '../../services/cache/profile-cache.js';
import MediaViewer from '../../components/content/MediaViewer.svelte';
import { getEventLink } from '../../services/event-links.js';
import Icon from '../../components/ui/Icon.svelte';
// Media gallery state
let mediaViewerOpen = $state(false);
let mediaViewerUrl = $state< string | null > (null);
let selectedMediaEvent = $state< NostrEvent | null > (null);
interface MediaItem {
url: string;
type: 'image' | 'video' | 'audio';
thumbnailUrl?: string;
event: NostrEvent;
title?: string;
summary?: string;
alt?: string;
content?: string;
}
let profile = $state< ProfileData | null > (null);
let profileEvent = $state< NostrEvent | null > (null); // The kind 0 event
@ -33,6 +51,181 @@
@@ -33,6 +51,181 @@
let loadingWall = $state(false);
let loadingMedia = $state(false);
let activeTab = $state< 'pins' | 'media' | 'notifications' | 'interactions' | 'wall'>('pins');
// Extract media items from media events
function extractMediaItems(events: NostrEvent[]): MediaItem[] {
const items: MediaItem[] = [];
const seen = new Set< string > ();
for (const event of events) {
// Extract metadata from event tags
const titleTag = event.tags.find((t) => t[0] === 'title');
const summaryTag = event.tags.find((t) => t[0] === 'summary');
const altTag = event.tags.find((t) => t[0] === 'alt');
const title = titleTag?.[1] || undefined;
const summary = summaryTag?.[1] || undefined;
const alt = altTag?.[1] || undefined;
const content = event.content || undefined;
// Normalize URL helper
function normalizeUrl(url: string): string {
try {
const parsed = new URL(url);
return `${ parsed . protocol } //${ parsed . host . toLowerCase ()} ${ parsed . pathname } `.replace(/\/$/, '');
} catch {
return url.trim().replace(/\/$/, '').toLowerCase();
}
}
// Parse imeta tag
function parseImetaTag(tag: string[]): {
url?: string;
mimeType?: string;
thumbnailUrl?: string;
} {
let url: string | undefined;
let mimeType: string | undefined;
let thumbnailUrl: 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('thumb ')) {
thumbnailUrl = item.substring(6).trim();
}
}
return { url , mimeType , thumbnailUrl } ;
}
// Get imeta for URL
function getImetaForUrl(url: string): { mimeType? : string ; thumbnailUrl? : 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 , thumbnailUrl : imeta.thumbnailUrl } ;
}
}
}
return null;
}
// 1. Check image tag
const imageTag = event.tags.find((t) => t[0] === 'image');
if (imageTag && imageTag[1]) {
const normalized = normalizeUrl(imageTag[1]);
if (!seen.has(normalized)) {
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';
}
items.push({
url: imageTag[1],
type,
thumbnailUrl: imeta?.thumbnailUrl,
event,
title,
summary,
alt,
content
});
seen.add(normalized);
}
}
// 2. Check imeta tags
for (const tag of event.tags) {
if (tag[0] === 'imeta') {
const imeta = parseImetaTag(tag);
if (imeta.url) {
const normalized = normalizeUrl(imeta.url);
if (!seen.has(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';
}
items.push({
url: imeta.url,
type,
thumbnailUrl: imeta.thumbnailUrl,
event,
title,
summary,
alt,
content
});
seen.add(normalized);
}
}
}
}
// 3. Extract from content (plain URLs)
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;
let match;
while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[1];
const normalized = normalizeUrl(url);
if (!seen.has(normalized)) {
const ext = match[2].toLowerCase();
let type: 'image' | 'video' | 'audio' = 'image';
if (['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'].includes(ext)) {
type = 'video';
} else if (['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(ext)) {
type = 'audio';
}
const imeta = getImetaForUrl(url);
if (imeta?.mimeType) {
if (imeta.mimeType.startsWith('video/')) type = 'video';
else if (imeta.mimeType.startsWith('audio/')) type = 'audio';
}
items.push({
url,
type,
thumbnailUrl: imeta?.thumbnailUrl,
event,
title,
summary,
alt,
content
});
seen.add(normalized);
}
}
}
return items;
}
// Get all media items from media events
let mediaItems = $derived(extractMediaItems(mediaEvents));
function openMediaViewer(item: MediaItem) {
mediaViewerUrl = item.url;
selectedMediaEvent = item.event;
mediaViewerOpen = true;
}
function closeMediaViewer() {
mediaViewerOpen = false;
mediaViewerUrl = null;
selectedMediaEvent = null;
}
function viewEvent(event: NostrEvent) {
// Store event in sessionStorage so the event page can use it without re-fetching
sessionStorage.setItem('aitherboard_preloadedEvent', JSON.stringify(event));
goto(getEventLink(event));
}
let nip05Validations = $state< Record < string , boolean | null > >({} ); // null = checking, true = valid, false = invalid
// Compute pubkey from route params
let profilePubkey = $derived.by(() => decodePubkey($page.params.pubkey));
@ -173,7 +366,7 @@
@@ -173,7 +366,7 @@
async function loadProfileEvent(pubkey: string) {
if (!isMounted) return;
try {
// Check sessionStorage for a preloaded kind 0 event (from ProfileBadge click)
// PRIORITY 1: Check sessionStorage for a preloaded kind 0 event (from ProfileBadge click) - INSTANT
if (typeof window !== 'undefined') {
const preloadedEventStr = sessionStorage.getItem('aitherboard_preloadedProfileEvent');
if (preloadedEventStr) {
@ -181,32 +374,34 @@
@@ -181,32 +374,34 @@
const preloadedEvent = JSON.parse(preloadedEventStr) as NostrEvent;
// Verify the event is a kind 0 event and matches the pubkey
if (preloadedEvent.kind === KIND.METADATA && preloadedEvent.pubkey === pubkey) {
// Use the preloaded event
// Use the preloaded event INSTANTLY
profileEvent = preloadedEvent;
// Clear it after reading
sessionStorage.removeItem('aitherboard_preloadedProfileEvent');
// Don't load wall comments here - load them when Wall tab is clicked
return;
} else {
// Wrong pubkey - clear it
sessionStorage.removeItem('aitherboard_preloadedProfileEvent');
}
} catch (parseError) {
// Invalid JSON in sessionStorage, continue with normal loading
console.warn('Failed to parse preloaded profile event from sessionStorage:', parseError);
sessionStorage.removeItem('aitherboard_preloadedProfileEvent');
}
}
}
// Try cache first
// PRIORITY 2: Try cache (very fast)
const cached = await getProfile(pubkey);
if (cached) {
if (cached && cached.event ) {
profileEvent = cached.event;
// Don't load wall comments here - load them when Wall tab is clicked
return;
}
// Fetch from relays if not in cache - shorter timeout
// Include profile owner's relay lists
const relays = await getProfileRelaysForPubkey(pubkey );
// PRIORITY 3: Fetch from relays with cache-first (should be fast if in cache)
// Use default relays for fastest response (don't wait for relay lists)
const relays = relayManager.getProfileReadRelays( );
const events = await nostrClient.fetchEvents(
[{ kinds : [ KIND . METADATA ], authors : [ pubkey ], limit : 1 } ],
relays,
@ -373,12 +568,13 @@
@@ -373,12 +568,13 @@
async function checkMediaAvailability(pubkey: string) {
if (!isMounted) return;
try {
const profileRelays = relayManager.getProfileReadRelays();
// Use feed relays for media events (kinds 20, 21, 22 are feed-like)
const feedRelays = relayManager.getFeedReadRelays();
// Check if any events of kinds 20, 21, 22 exist for this pubkey
const mediaCheck = await nostrClient.fetchEvents(
[{ kinds : [ KIND . PICTURE_NOTE , KIND . VIDEO_NOTE , KIND . SHORT_VIDEO_NOTE ], authors : [ pubkey ], limit : 1 } ],
profile Relays,
{ useCache : 'cache-first' , cacheResults : true , timeout : config.short Timeout }
feed Relays,
{ useCache : 'cache-first' , cacheResults : true , timeout : config.medium Timeout }
);
if (!isMounted) return;
@ -393,14 +589,15 @@
@@ -393,14 +589,15 @@
if (!isMounted) return;
loadingMedia = true;
try {
const profileRelays = relayManager.getFeedReadRelays();
// Use feed relays for media events (kinds 20, 21, 22 are feed-like)
const feedRelays = relayManager.getFeedReadRelays();
const fetchPromise = nostrClient.fetchEvents(
[{ kinds : [ KIND . PICTURE_NOTE , KIND . VIDEO_NOTE , KIND . SHORT_VIDEO_NOTE ], authors : [ pubkey ], limit : config.feedLimit } ],
profile Relays,
feed Relays,
{
useCache: 'cache-first',
cacheResults: true,
timeout: config.short Timeout,
timeout: config.medium Timeout,
onUpdate: (newMedia) => {
if (!isMounted) return;
// Merge with existing media
@ -758,25 +955,74 @@
@@ -758,25 +955,74 @@
loading = true;
try {
// Step 0: Get relays including profile owner's relay lists (non-blocking, with timeout)
const profileRelaysPromise = getProfileRelaysForPubkey(pubkey);
// CRITICAL: Check sessionStorage SYNCHRONOUSLY first (before any async operations)
// This ensures instant loading when coming from ProfileBadge click
if (typeof window !== 'undefined') {
const preloadedEventStr = sessionStorage.getItem('aitherboard_preloadedProfileEvent');
if (preloadedEventStr) {
try {
const preloadedEvent = JSON.parse(preloadedEventStr) as NostrEvent;
// Verify the event is a kind 0 event and matches the pubkey
if (preloadedEvent.kind === KIND.METADATA && preloadedEvent.pubkey === pubkey) {
// Use the preloaded event INSTANTLY - set it synchronously
profileEvent = preloadedEvent;
// Clear it after reading
sessionStorage.removeItem('aitherboard_preloadedProfileEvent');
} else {
// Wrong pubkey - clear it
sessionStorage.removeItem('aitherboard_preloadedProfileEvent');
}
} catch (parseError) {
// Invalid JSON in sessionStorage, clear it
sessionStorage.removeItem('aitherboard_preloadedProfileEvent');
}
}
}
// Step 0: Load profile event from cache (if not already set from sessionStorage)
// This is critical for fast loading - check cache immediately
const profileEventPromise = profileEvent ? Promise.resolve() : loadProfileEvent(pubkey);
// Step 1: Load profile and status first (fast from cache) - display immediately
// Step 1: Load profile and status from cache f irst (fast) - display immediately
// fetchProfile uses parseProfile which prioritizes tags over JSON content
// Use profile owner's relays if available, otherwise fall back to default
const relaysPromise = profileRelaysPromise.catch(() => relayManager.getProfileReadRelays());
const relays = await relaysPromise;
// Use default relays for cache-first loading (fastest)
const relays = relayManager.getProfileReadRelays();
const profilePromise = fetchProfile(pubkey, relays);
const statusPromise = fetchUserStatus(pubkey, relays);
const statusEventPromise = fetchUserStatusEvent(pubkey, relays);
// Load pins from cache FIRST (very fast)
const pinsPromise = loadPins(pubkey);
// Check media availability from cache FIRST (very fast)
const mediaCheckPromise = checkMediaAvailability(pubkey);
// Wait for all cache-first loads in parallel
activeFetchPromises.add(profilePromise);
activeFetchPromises.add(statusPromise);
activeFetchPromises.add(statusEventPromise);
const [profileData, status, statusEvent] = await Promise.all([profilePromise, statusPromise, statusEventPromise]);
activeFetchPromises.add(profileEventPromise);
activeFetchPromises.add(pinsPromise);
activeFetchPromises.add(mediaCheckPromise);
const [profileData, status, statusEvent] = await Promise.all([
profilePromise,
statusPromise,
statusEventPromise
]);
// Wait for background cache loads (don't block on these)
Promise.all([profileEventPromise, pinsPromise, mediaCheckPromise]).catch(() => {
// Background loads failed - non-critical
});
activeFetchPromises.delete(profilePromise);
activeFetchPromises.delete(statusPromise);
activeFetchPromises.delete(statusEventPromise);
activeFetchPromises.delete(profileEventPromise);
activeFetchPromises.delete(pinsPromise);
activeFetchPromises.delete(mediaCheckPromise);
// Check if this load was aborted or if pubkey changed
if (!isMounted || abortController.signal.aborted || currentLoadPubkey !== pubkey) {
@ -801,21 +1047,11 @@
@@ -801,21 +1047,11 @@
}
}
// Load pins and notifications/interactions in parallel (non-blocking)
// Don't wait for these - they'll update the UI as they load
// Load notifications/interactions in parallel (non-blocking, cache-first )
// Pins and media check already loaded above
const loadPromises: Promise< void > [] = [];
// Always load pins (for both own and other profiles)
loadPromises.push(loadPins(pubkey).catch(() => {
// Failed to load pins - non-critical
}));
// Check if media (kinds 20, 21, 22) are available
loadPromises.push(checkMediaAvailability(pubkey).catch(() => {
// Failed to check media - non-critical
}));
// Load notifications or interactions based on profile type
// Load notifications or interactions based on profile type (cache-first)
if (isOwnProfile) {
loadPromises.push(loadNotifications(pubkey).catch(() => {
// Failed to load notifications - non-critical
@ -823,7 +1059,7 @@
@@ -823,7 +1059,7 @@
interactionsWithMe = [];
} else {
notifications = [];
// Load interactions if logged in and viewing another user's profile
// Load interactions if logged in and viewing another user's profile (cache-first)
if (currentUserPubkey) {
loadPromises.push(loadInteractionsWithMe(pubkey, currentUserPubkey).catch(() => {
// Failed to load interactions - non-critical
@ -851,12 +1087,6 @@
@@ -851,12 +1087,6 @@
}
}
});
// Load profile event in background (only needed for wall tab)
// Don't await - load it when user clicks Wall tab
loadProfileEvent(pubkey).catch(() => {
// Failed to load profile event - non-critical
});
} catch (error) {
// Only update state if this load wasn't aborted
if (!abortController.signal.aborted && currentLoadPubkey === pubkey) {
@ -1025,14 +1255,85 @@
@@ -1025,14 +1255,85 @@
{ :else if activeTab === 'media' }
{ #if loadingMedia }
< p class = "text-fog-text-light dark:text-fog-dark-text-light" > Loading media...< / p >
{ :else if mediaEvent s . length === 0 }
{ :else if mediaItem s . length === 0 }
< p class = "text-fog-text-light dark:text-fog-dark-text-light" > No media posts yet.< / p >
{ : else }
< div class = "media-list" >
{ #each mediaEvents as media ( media . id )}
< FeedPost post = { media } / >
< div class = "media-gallery" >
{ #each mediaItems as item ( item . url )}
< div
class="media-gallery-item"
role="button"
tabindex="0"
onclick={() => openMediaViewer ( item )}
onkeydown={( e ) => { if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault (); openMediaViewer ( item ); } }}
>
{ #if item . type === 'image' }
< img
src={ item . thumbnailUrl || item . url }
alt={ item . alt || 'Media' }
class="media-thumbnail"
loading="lazy"
/>
{ :else if item . type === 'video' }
< div class = "media-thumbnail video-thumbnail" >
{ #if item . thumbnailUrl }
< img src = { item . thumbnailUrl } alt= { item . alt || 'Video thumbnail' } class = "video-thumb-img" loading = "lazy" />
{ : else }
< video
src={ item . url }
class="video-thumb-video"
preload="metadata"
muted
playsinline
onloadeddata={( e ) => {
const video = e.currentTarget as HTMLVideoElement;
video.currentTime = 0.1;
}}
>< / video >
< div class = "video-placeholder-bg" > < / div >
{ /if }
< div class = "video-play-overlay" > ▶< / div >
< / div >
{ : else }
< div class = "media-thumbnail audio-thumbnail" >
< div class = "audio-placeholder" >
< svg width = "48" height = "48" viewBox = "0 0 24 24" fill = "none" xmlns = "http://www.w3.org/2000/svg" >
< path d = "M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" fill = "currentColor" / >
< / svg >
< / div >
< / div >
{ /if }
< div class = "media-info" >
{ #if item . title }
< div class = "media-title" > { item . title } </ div >
{ /if }
{ #if item . summary }
< div class = "media-summary" > { item . summary } </ div >
{ /if }
{ #if item . alt && ! item . title && ! item . summary }
< div class = "media-alt" > { item . alt } </ div >
{ /if }
{ #if item . content && ! item . title && ! item . summary && ! item . alt }
< div class = "media-content" > { item . content } </ div >
{ /if }
< / div >
< div
class="media-overlay"
role="button"
tabindex="0"
onclick={( e ) => { e . stopPropagation (); viewEvent ( item . event ); }}
onkeydown={( e ) => { if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault (); e . stopPropagation (); viewEvent ( item . event ); } }}
>
< button class = "media-view-event-btn" title = "View event" type = "button" >
< Icon name = "eye" size = { 16 } / >
< / button >
< / div >
< / div >
{ /each }
< / div >
{ #if mediaViewerUrl && mediaViewerOpen }
< MediaViewer url = { mediaViewerUrl } isOpen= { mediaViewerOpen } onClose = { closeMediaViewer } / >
{ /if }
{ /if }
{ :else if activeTab === 'notifications' }
{ #if notifications . length === 0 }
@ -1349,4 +1650,235 @@
@@ -1349,4 +1650,235 @@
max-width: 100%;
}
}
/* Media gallery - Masonry layout like Pinterest */
.media-gallery {
column-width: 150px;
column-gap: 0.75rem;
margin-top: 1rem;
}
@media (min-width: 640px) {
.media-gallery {
column-gap: 1rem;
}
}
.media-gallery-item {
position: relative;
display: inline-block;
width: 150px;
margin-bottom: 0.75rem;
cursor: pointer;
border-radius: 0.5rem;
overflow: hidden;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
transition: transform 0.2s, box-shadow 0.2s;
outline: none;
break-inside: avoid;
page-break-inside: avoid;
}
@media (min-width: 640px) {
.media-gallery-item {
margin-bottom: 1rem;
}
}
.media-gallery-item:focus {
outline: 2px solid var(--fog-accent, #3b82f6);
outline-offset: 2px;
}
:global(.dark) .media-gallery-item {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
.media-gallery-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
:global(.dark) .media-gallery-item:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.media-thumbnail {
width: 100%;
height: auto;
object-fit: cover;
display: block;
max-width: 100%;
}
.video-thumbnail,
.audio-thumbnail {
width: 100%;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--fog-accent, #3b82f6) 0%, var(--fog-text-light, #52667a) 100%);
position: relative;
aspect-ratio: 4/3;
}
:global(.dark) .video-thumbnail,
:global(.dark) .audio-thumbnail {
background: linear-gradient(135deg, var(--fog-accent, #3b82f6) 0%, var(--fog-text-light, #52667a) 100%);
opacity: 0.8;
}
.video-placeholder-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, var(--fog-accent, #3b82f6) 0%, var(--fog-text-light, #52667a) 100%);
z-index: -1;
}
.video-thumb-img {
width: 100%;
height: auto;
object-fit: cover;
display: block;
}
.video-thumb-video {
width: 100%;
height: auto;
object-fit: cover;
pointer-events: none;
display: block;
position: relative;
z-index: 1;
}
.video-play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 2rem;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
pointer-events: none;
}
.audio-placeholder {
color: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.audio-placeholder svg {
width: 48px;
height: 48px;
}
.media-overlay {
position: absolute;
top: 0;
right: 0;
padding: 0.5rem;
opacity: 0;
transition: opacity 0.2s;
outline: none;
}
.media-overlay:focus {
opacity: 1;
}
.media-gallery-item:hover .media-overlay {
opacity: 1;
}
.media-view-event-btn {
background: rgba(0, 0, 0, 0.7);
border: none;
border-radius: 0.375rem;
padding: 0.5rem;
cursor: pointer;
color: white;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.media-view-event-btn:hover {
background: rgba(0, 0, 0, 0.9);
}
.media-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.5), transparent);
padding: 0.75rem;
color: white;
font-size: 0.875rem;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
max-height: 50%;
overflow: hidden;
}
.media-gallery-item:hover .media-info {
opacity: 1;
}
.media-title {
font-weight: 600;
margin-bottom: 0.25rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.media-summary {
font-size: 0.75rem;
opacity: 0.9;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.media-alt {
font-size: 0.75rem;
opacity: 0.9;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.media-content {
font-size: 0.75rem;
opacity: 0.9;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
< / style >