From 870030eb1f89b16d3dd89165b65a4d2065aeb4c4 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 8 Apr 2026 18:55:32 +0200 Subject: [PATCH] add media feeds --- src/components/HelpAndAccountMenu.tsx | 37 ++++++--- src/components/MediaGridItem/index.tsx | 76 ++++++++++++++++++ src/components/NormalFeed/index.tsx | 36 ++++++--- src/components/NoteList/index.tsx | 62 ++++++++------ src/components/Profile/index.tsx | 19 +++-- src/components/UserAvatar/index.tsx | 80 +++++++++++++------ src/lib/nostr-build.ts | 7 +- .../secondary/ProfileEditorPage/index.tsx | 27 +++++-- src/services/local-storage.service.ts | 4 +- src/types/index.d.ts | 2 +- 10 files changed, 265 insertions(+), 85 deletions(-) create mode 100644 src/components/MediaGridItem/index.tsx diff --git a/src/components/HelpAndAccountMenu.tsx b/src/components/HelpAndAccountMenu.tsx index 0eb50f56..8b6f1f9e 100644 --- a/src/components/HelpAndAccountMenu.tsx +++ b/src/components/HelpAndAccountMenu.tsx @@ -14,6 +14,7 @@ import { } from '@/components/ui/dropdown-menu' import { Skeleton } from '@/components/ui/skeleton' import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey' +import { isVideo } from '@/lib/url' import { cn } from '@/lib/utils' import { usePrimaryPage } from '@/contexts/primary-page-context' import { useFetchProfile } from '@/hooks/useFetchProfile' @@ -93,12 +94,18 @@ function SidebarAccountMenu({ active && 'bg-accent/50' )} > - - - - - - + {isVideo(avatar ?? '') ? ( +
+
+ ) : ( + + + + + + + )} {username} @@ -139,12 +146,18 @@ function TitlebarAccountMenu({ aria-label={t('Account menu')} > {resolvedProfile ? ( - - - - - - + isVideo(resolvedProfile.avatar ?? '') ? ( +
+
+ ) : ( + + + + + + + ) ) : ( )} diff --git a/src/components/MediaGridItem/index.tsx b/src/components/MediaGridItem/index.tsx new file mode 100644 index 00000000..d9f4efad --- /dev/null +++ b/src/components/MediaGridItem/index.tsx @@ -0,0 +1,76 @@ +import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' +import { toNote } from '@/lib/link' +import client from '@/services/client.service' +import { extractAllMediaFromEvent } from '@/services/media-extraction.service' +import { useSmartNoteNavigationOptional } from '@/PageManager' +import { Images, Music, Play } from 'lucide-react' +import { type Event } from 'nostr-tools' +import { useMemo } from 'react' + +export default function MediaGridItem({ event }: { event: Event }) { + const { navigateToNote } = useSmartNoteNavigationOptional() + + const media = useMemo(() => extractAllMediaFromEvent(event), [event]) + const first = media.all[0] + + const isVideo = + first?.m?.startsWith('video/') || event.kind === 21 || event.kind === 22 + const isAudio = first?.m?.startsWith('audio/') || event.kind === 1222 + const hasMultiple = media.all.length > 1 + + // For videos prefer the poster image; fall back to video URL (browser extracts frame) + const displayUrl = isVideo + ? (first?.image ?? first?.url) + : (first?.thumb ?? first?.url) + + const handleClick = () => { + client.addEventToCache(event) + navigateToNote(toNote(event), event, getCachedThreadContextEvents(event)) + } + + return ( +
+ {displayUrl ? ( + isVideo && !first?.image ? ( +
+ ) +} diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index 6aeab324..849b51ee 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -119,14 +119,29 @@ const NormalFeed = forwardRef [20, 21, 22, 1222], []) + const tabs = useMemo( - (): TabDefinition[] => [ - { value: 'posts', label: 'Notes' }, - { value: 'postsAndReplies', label: 'Replies' } - ], - [] + (): TabDefinition[] => { + const base: TabDefinition[] = [ + { value: 'posts', label: 'Notes' }, + { value: 'postsAndReplies', label: 'Replies' } + ] + if (isMainFeed) base.push({ value: 'media', label: 'Media' }) + return base + }, + [isMainFeed] ) + /** When in media mode, replace each shard's kinds with the media set. */ + const effectiveSubRequests = useMemo(() => { + if (listMode !== 'media') return subRequests + return subRequests.map((req) => ({ + ...req, + filter: { ...req.filter, kinds: MEDIA_KINDS } + })) + }, [listMode, subRequests, MEDIA_KINDS]) + const handleListModeChange = useCallback( (mode: TNoteListMode | string) => { const noteListMode = mode as TNoteListMode @@ -248,8 +263,7 @@ const NormalFeed = forwardRef void /** Optional banner above the feed (e.g. kindless→kinds fallback). */ feedTopNotice?: ReactNode + /** When true, render events as an Instagram-style 3-column square media grid. */ + gridLayout?: boolean }, ref ) => { @@ -1475,16 +1479,9 @@ const NoteList = forwardRef( return () => {} } - if (!relayCapabilityReady && !oneShotFetch) { - setLoading(true) - return () => {} - } - - // Synchronous offline check — must run before the async init() so state - // updates happen in the same React batch as the effect itself. - // If every relay URL in every shard is non-local while offline, show an - // immediate empty state instead of spinning while waiting for connections - // that can never succeed. + // Offline check must come before relayCapabilityReady: for internet relay + // shards, relayCapabilityReady never becomes true while offline (NIP-11 + // fetch cannot complete), so checking it first causes an infinite loading spin. if (isOfflineRef.current && subRequestsRef.current.length > 0) { const hasAnyLocalRelay = subRequestsRef.current.some((req) => req.urls.some((u) => isLocalNetworkUrl(u)) @@ -1500,6 +1497,11 @@ const NoteList = forwardRef( } } + if (!relayCapabilityReady && !oneShotFetch) { + setLoading(true) + return () => {} + } + const prevSubKey = prevSubRequestsKeyForTimelineRef.current const userPulledRefresh = refreshCount !== timelineEffectLastRefreshCountRef.current if (userPulledRefresh) { @@ -3140,28 +3142,40 @@ const NoteList = forwardRef( {t('Feed full search empty')} ) : null} - {clientFilteredEvents.map((event) => ( - - ))} + {gridLayout ? ( +
+ {clientFilteredEvents.map((event) => ( + + ))} +
+ ) : ( + clientFilteredEvents.map((event) => ( + + )) + )} {listSourceEvents.length === 0 && !feedFullSearchActive && (loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} + {gridLayout + ? Array.from({ length: 9 }).map((_, i) => ( +
+ )) + : Array.from({ length: 5 }).map((_, i) => ( + + ))}
) : listSourceEvents.length > 0 && (feedFullSearchActive ? showCount < listSourceEvents.length : hasMore) ? ( diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index ba31ab82..9cbca91d 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -18,6 +18,7 @@ import { getPaymentInfoFromEvent } from '@/lib/event-metadata' import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-feedback' import { toProfileEditor } from '@/lib/link' import { generateImageByPubkey } from '@/lib/pubkey' +import { isVideo } from '@/lib/url' import { usePrimaryPage } from '@/contexts/primary-page-context' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' @@ -405,12 +406,18 @@ export default function Profile({
- - - - - - + {isVideo(avatar ?? '') ? ( +
+
+ ) : ( + + + + + + + )}
diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index e3756c98..ad3be03c 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -1,6 +1,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { useFetchProfile } from '@/hooks' import { toNostrBuildThumbUrl } from '@/lib/nostr-build' +import { isVideo } from '@/lib/url' import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey' import { toProfile } from '@/lib/link' import { cn } from '@/lib/utils' @@ -56,13 +57,13 @@ function useDeferRemoteProfileAvatar( const remoteHttp = useMemo(() => { const a = profileAvatar?.trim() if (!a || !isHttpOrHttpsUrl(a)) return '' + // Video files don't have a /thumb/ route — serve them as-is. + if (isVideo(a)) return a // Always use the nostr.build thumbnail route for profile pictures — it's // typically < 50 KB regardless of the original file size. return toNostrBuildThumbUrl(a) }, [profileAvatar]) - // If this URL loaded successfully earlier this session it's already in the browser's - // HTTP cache — skip both the viewport delay and the size check. const alreadyCached = remoteHttp ? loadedAvatarUrls.has(remoteHttp) : false const [sizeBlocked, setSizeBlocked] = useState(false) @@ -88,7 +89,6 @@ function useDeferRemoteProfileAvatar( return '' }, [profileAvatar]) - // Already cached → show immediately without waiting for IntersectionObserver. const [allowRemote, setAllowRemote] = useState(() => remoteHttp === '' || alreadyCached) useEffect(() => { @@ -195,6 +195,8 @@ export default function UserAvatar({ const [imgError, setImgError] = useState(false) const [currentSrc, setCurrentSrc] = useState(avatarSrc) + const isVideoAvatar = useMemo(() => isVideo(profile?.avatar?.trim() ?? ''), [profile?.avatar]) + // Reset error state when src changes useEffect(() => { setImgError(false) @@ -239,16 +241,31 @@ export default function UserAvatar({ }} > {!imgError && currentSrc ? ( - + isVideoAvatar ? ( +