Browse Source

add media feeds

imwald
Silberengel 3 weeks ago
parent
commit
870030eb1f
  1. 13
      src/components/HelpAndAccountMenu.tsx
  2. 76
      src/components/MediaGridItem/index.tsx
  3. 32
      src/components/NormalFeed/index.tsx
  4. 42
      src/components/NoteList/index.tsx
  5. 7
      src/components/Profile/index.tsx
  6. 40
      src/components/UserAvatar/index.tsx
  7. 7
      src/lib/nostr-build.ts
  8. 15
      src/pages/secondary/ProfileEditorPage/index.tsx
  9. 4
      src/services/local-storage.service.ts
  10. 2
      src/types/index.d.ts

13
src/components/HelpAndAccountMenu.tsx

@ -14,6 +14,7 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { isVideo } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useFetchProfile } from '@/hooks/useFetchProfile' import { useFetchProfile } from '@/hooks/useFetchProfile'
@ -93,12 +94,18 @@ function SidebarAccountMenu({
active && 'bg-accent/50' active && 'bg-accent/50'
)} )}
> >
{isVideo(avatar ?? '') ? (
<div className="size-8 shrink-0 overflow-hidden rounded-full">
<video src={avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline />
</div>
) : (
<Avatar className="size-8 shrink-0"> <Avatar className="size-8 shrink-0">
<AvatarImage src={avatar} /> <AvatarImage src={avatar} />
<AvatarFallback> <AvatarFallback>
<img src={defaultAvatar} alt="" /> <img src={defaultAvatar} alt="" />
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
)}
<span className="truncate max-xl:hidden">{username}</span> <span className="truncate max-xl:hidden">{username}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -139,12 +146,18 @@ function TitlebarAccountMenu({
aria-label={t('Account menu')} aria-label={t('Account menu')}
> >
{resolvedProfile ? ( {resolvedProfile ? (
isVideo(resolvedProfile.avatar ?? '') ? (
<div className={cn('w-6 h-6 overflow-hidden rounded-full', active ? 'ring-primary ring-1' : '')}>
<video src={resolvedProfile.avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline />
</div>
) : (
<Avatar className={cn('w-6 h-6', active ? 'ring-primary ring-1' : '')}> <Avatar className={cn('w-6 h-6', active ? 'ring-primary ring-1' : '')}>
<AvatarImage src={resolvedProfile.avatar} className="object-cover object-center" /> <AvatarImage src={resolvedProfile.avatar} className="object-cover object-center" />
<AvatarFallback> <AvatarFallback>
<img src={defaultAvatar} alt="" /> <img src={defaultAvatar} alt="" />
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
)
) : ( ) : (
<Skeleton className={cn('w-6 h-6 rounded-full', active ? 'ring-primary ring-1' : '')} /> <Skeleton className={cn('w-6 h-6 rounded-full', active ? 'ring-primary ring-1' : '')} />
)} )}

76
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 (
<div
className="relative aspect-square cursor-pointer overflow-hidden bg-muted"
onClick={handleClick}
>
{displayUrl ? (
isVideo && !first?.image ? (
<video
src={displayUrl}
className="h-full w-full object-cover"
muted
preload="metadata"
/>
) : (
<img
src={displayUrl}
alt={first?.alt ?? ''}
className="h-full w-full object-cover"
loading="lazy"
/>
)
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground/40">
{isAudio ? <Music className="size-8" /> : <Play className="size-8" />}
</div>
)}
{/* Top-right badge */}
{isVideo && (
<div className="absolute right-2 top-2 rounded bg-black/60 p-1">
<Play className="size-6 fill-white text-white" />
</div>
)}
{isAudio && (
<div className="absolute right-2 top-2 rounded bg-black/60 p-1">
<Music className="size-6 text-white" />
</div>
)}
{hasMultiple && !isVideo && !isAudio && (
<div className="absolute right-2 top-2 rounded bg-black/60 p-1">
<Images className="size-6 text-white" />
</div>
)}
</div>
)
}

32
src/components/NormalFeed/index.tsx

@ -119,14 +119,29 @@ const NormalFeed = forwardRef<TNoteListRef, {
setFeedFilterTabRowHost(node) setFeedFilterTabRowHost(node)
}, []) }, [])
const MEDIA_KINDS = useMemo(() => [20, 21, 22, 1222], [])
const tabs = useMemo( const tabs = useMemo(
(): TabDefinition[] => [ (): TabDefinition[] => {
const base: TabDefinition[] = [
{ value: 'posts', label: 'Notes' }, { value: 'posts', label: 'Notes' },
{ value: 'postsAndReplies', label: 'Replies' } { 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( const handleListModeChange = useCallback(
(mode: TNoteListMode | string) => { (mode: TNoteListMode | string) => {
const noteListMode = mode as TNoteListMode const noteListMode = mode as TNoteListMode
@ -248,8 +263,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
showKind1111={showKind1111} showKind1111={showKind1111}
seeAllFeedEvents={feedKindFilterBypass} seeAllFeedEvents={feedKindFilterBypass}
withKindFilter={withKindFilter} withKindFilter={withKindFilter}
showAllKinds={listShowAllKinds} subRequests={effectiveSubRequests}
subRequests={subRequests}
hideReplies={listMode === 'posts'} hideReplies={listMode === 'posts'}
hideUntrustedNotes={hideUntrustedNotes} hideUntrustedNotes={hideUntrustedNotes}
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
@ -259,9 +273,11 @@ const NormalFeed = forwardRef<TNoteListRef, {
mergeTimelineWhenSubRequestFiltersMatch={mergeTimelineWhenSubRequestFiltersMatch} mergeTimelineWhenSubRequestFiltersMatch={mergeTimelineWhenSubRequestFiltersMatch}
followingFeedDeltaSubRequests={followingFeedDeltaSubRequests} followingFeedDeltaSubRequests={followingFeedDeltaSubRequests}
feedTimelineScopeKey={feedTimelineScopeKey} feedTimelineScopeKey={feedTimelineScopeKey}
useFilterAsIs={useFilterAsIs} gridLayout={listMode === 'media'}
clientSideKindFilter={clientSideKindFilter} useFilterAsIs={listMode === 'media' ? true : useFilterAsIs}
allowKindlessRelayExplore={allowKindlessRelayExplore} clientSideKindFilter={listMode === 'media' ? false : clientSideKindFilter}
allowKindlessRelayExplore={listMode === 'media' ? false : allowKindlessRelayExplore}
showAllKinds={listMode === 'media' ? true : listShowAllKinds}
showFeedClientFilter={showFeedClientFilter} showFeedClientFilter={showFeedClientFilter}
hostPrimaryPageName={hostPrimaryPageName} hostPrimaryPageName={hostPrimaryPageName}
feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined} feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined}

42
src/components/NoteList/index.tsx

@ -78,6 +78,7 @@ import {
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
import MediaGridItem from '../MediaGridItem'
const LIMIT = 100 // Increased from 200 to load more events per request const LIMIT = 100 // Increased from 200 to load more events per request
const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds
@ -468,7 +469,8 @@ const NoteList = forwardRef(
*/ */
feedClientFilterTabRowHost, feedClientFilterTabRowHost,
onSingleRelayKindlessEmpty, onSingleRelayKindlessEmpty,
feedTopNotice feedTopNotice,
gridLayout = false
}: { }: {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
showKinds: number[] showKinds: number[]
@ -521,6 +523,8 @@ const NoteList = forwardRef(
onSingleRelayKindlessEmpty?: () => void onSingleRelayKindlessEmpty?: () => void
/** Optional banner above the feed (e.g. kindless→kinds fallback). */ /** Optional banner above the feed (e.g. kindless→kinds fallback). */
feedTopNotice?: ReactNode feedTopNotice?: ReactNode
/** When true, render events as an Instagram-style 3-column square media grid. */
gridLayout?: boolean
}, },
ref ref
) => { ) => {
@ -1475,16 +1479,9 @@ const NoteList = forwardRef(
return () => {} return () => {}
} }
if (!relayCapabilityReady && !oneShotFetch) { // Offline check must come before relayCapabilityReady: for internet relay
setLoading(true) // shards, relayCapabilityReady never becomes true while offline (NIP-11
return () => {} // fetch cannot complete), so checking it first causes an infinite loading spin.
}
// 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.
if (isOfflineRef.current && subRequestsRef.current.length > 0) { if (isOfflineRef.current && subRequestsRef.current.length > 0) {
const hasAnyLocalRelay = subRequestsRef.current.some((req) => const hasAnyLocalRelay = subRequestsRef.current.some((req) =>
req.urls.some((u) => isLocalNetworkUrl(u)) req.urls.some((u) => isLocalNetworkUrl(u))
@ -1500,6 +1497,11 @@ const NoteList = forwardRef(
} }
} }
if (!relayCapabilityReady && !oneShotFetch) {
setLoading(true)
return () => {}
}
const prevSubKey = prevSubRequestsKeyForTimelineRef.current const prevSubKey = prevSubRequestsKeyForTimelineRef.current
const userPulledRefresh = refreshCount !== timelineEffectLastRefreshCountRef.current const userPulledRefresh = refreshCount !== timelineEffectLastRefreshCountRef.current
if (userPulledRefresh) { if (userPulledRefresh) {
@ -3140,7 +3142,14 @@ const NoteList = forwardRef(
{t('Feed full search empty')} {t('Feed full search empty')}
</div> </div>
) : null} ) : null}
{gridLayout ? (
<div className="grid grid-cols-3 gap-0.5 pr-4">
{clientFilteredEvents.map((event) => ( {clientFilteredEvents.map((event) => (
<MediaGridItem key={event.id} event={event} />
))}
</div>
) : (
clientFilteredEvents.map((event) => (
<NoteCard <NoteCard
key={event.id} key={event.id}
className="w-full" className="w-full"
@ -3148,18 +3157,23 @@ const NoteList = forwardRef(
filterMutedNotes={filterMutedNotes} filterMutedNotes={filterMutedNotes}
bottomNoteLabel={eventReasonLabelMap.get(event.id)} bottomNoteLabel={eventReasonLabelMap.get(event.id)}
/> />
))} ))
)}
{listSourceEvents.length === 0 && {listSourceEvents.length === 0 &&
!feedFullSearchActive && !feedFullSearchActive &&
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? ( (loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (
<div <div
ref={bottomRef} ref={bottomRef}
className="min-h-[40vh] space-y-2 px-1 py-4" className={gridLayout ? 'grid grid-cols-3 gap-0.5 pr-4 min-h-[40vh]' : 'min-h-[40vh] space-y-2 px-1 py-4'}
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-busy="true" aria-busy="true"
> >
{Array.from({ length: 5 }).map((_, i) => ( {gridLayout
? Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="aspect-square animate-pulse bg-muted" />
))
: Array.from({ length: 5 }).map((_, i) => (
<NoteCardLoadingSkeleton key={i} /> <NoteCardLoadingSkeleton key={i} />
))} ))}
</div> </div>

7
src/components/Profile/index.tsx

@ -18,6 +18,7 @@ import { getPaymentInfoFromEvent } from '@/lib/event-metadata'
import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-feedback' import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-feedback'
import { toProfileEditor } from '@/lib/link' import { toProfileEditor } from '@/lib/link'
import { generateImageByPubkey } from '@/lib/pubkey' import { generateImageByPubkey } from '@/lib/pubkey'
import { isVideo } from '@/lib/url'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -405,12 +406,18 @@ export default function Profile({
<div> <div>
<div className="relative bg-cover bg-center mb-2"> <div className="relative bg-cover bg-center mb-2">
<ProfileBanner banner={banner} pubkey={pubkey} className="w-full aspect-[3/1]" /> <ProfileBanner banner={banner} pubkey={pubkey} className="w-full aspect-[3/1]" />
{isVideo(avatar ?? '') ? (
<div className="w-24 h-24 md:w-48 md:h-48 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background overflow-hidden rounded-full bg-muted">
<video src={avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline />
</div>
) : (
<Avatar className="w-24 h-24 md:w-48 md:h-48 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background"> <Avatar className="w-24 h-24 md:w-48 md:h-48 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background">
<AvatarImage src={avatar} className="object-cover object-center" /> <AvatarImage src={avatar} className="object-cover object-center" />
<AvatarFallback> <AvatarFallback>
<img src={defaultImage} /> <img src={defaultImage} />
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
)}
</div> </div>
<div className="px-4"> <div className="px-4">
<div className="flex justify-end h-8 gap-2 items-center"> <div className="flex justify-end h-8 gap-2 items-center">

40
src/components/UserAvatar/index.tsx

@ -1,6 +1,7 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
import { toNostrBuildThumbUrl } from '@/lib/nostr-build' import { toNostrBuildThumbUrl } from '@/lib/nostr-build'
import { isVideo } from '@/lib/url'
import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey' import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -56,13 +57,13 @@ function useDeferRemoteProfileAvatar(
const remoteHttp = useMemo(() => { const remoteHttp = useMemo(() => {
const a = profileAvatar?.trim() const a = profileAvatar?.trim()
if (!a || !isHttpOrHttpsUrl(a)) return '' 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 // Always use the nostr.build thumbnail route for profile pictures — it's
// typically < 50 KB regardless of the original file size. // typically < 50 KB regardless of the original file size.
return toNostrBuildThumbUrl(a) return toNostrBuildThumbUrl(a)
}, [profileAvatar]) }, [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 alreadyCached = remoteHttp ? loadedAvatarUrls.has(remoteHttp) : false
const [sizeBlocked, setSizeBlocked] = useState(false) const [sizeBlocked, setSizeBlocked] = useState(false)
@ -88,7 +89,6 @@ function useDeferRemoteProfileAvatar(
return '' return ''
}, [profileAvatar]) }, [profileAvatar])
// Already cached → show immediately without waiting for IntersectionObserver.
const [allowRemote, setAllowRemote] = useState(() => remoteHttp === '' || alreadyCached) const [allowRemote, setAllowRemote] = useState(() => remoteHttp === '' || alreadyCached)
useEffect(() => { useEffect(() => {
@ -195,6 +195,8 @@ export default function UserAvatar({
const [imgError, setImgError] = useState(false) const [imgError, setImgError] = useState(false)
const [currentSrc, setCurrentSrc] = useState(avatarSrc) const [currentSrc, setCurrentSrc] = useState(avatarSrc)
const isVideoAvatar = useMemo(() => isVideo(profile?.avatar?.trim() ?? ''), [profile?.avatar])
// Reset error state when src changes // Reset error state when src changes
useEffect(() => { useEffect(() => {
setImgError(false) setImgError(false)
@ -239,6 +241,20 @@ export default function UserAvatar({
}} }}
> >
{!imgError && currentSrc ? ( {!imgError && currentSrc ? (
isVideoAvatar ? (
<video
src={currentSrc}
className="block w-full h-full object-cover object-center"
autoPlay
muted
loop
playsInline
onCanPlay={() => {
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
}}
onError={handleImageError}
/>
) : (
<img <img
src={currentSrc} src={currentSrc}
alt="" alt=""
@ -249,6 +265,7 @@ export default function UserAvatar({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />
)
) : ( ) : (
// Show initials or placeholder when image fails // Show initials or placeholder when image fails
<div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground"> <div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground">
@ -314,6 +331,8 @@ export function SimpleUserAvatar({
const [imgError, setImgError] = useState(false) const [imgError, setImgError] = useState(false)
const [currentSrc, setCurrentSrc] = useState(avatarSrc) const [currentSrc, setCurrentSrc] = useState(avatarSrc)
const isVideoAvatar = useMemo(() => isVideo(profile?.avatar?.trim() ?? ''), [profile?.avatar])
// Reset error state when src changes // Reset error state when src changes
useEffect(() => { useEffect(() => {
setImgError(false) setImgError(false)
@ -352,6 +371,20 @@ export function SimpleUserAvatar({
className={cn('shrink-0 relative overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)} className={cn('shrink-0 relative overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)}
> >
{!imgError && currentSrc ? ( {!imgError && currentSrc ? (
isVideoAvatar ? (
<video
src={currentSrc}
className="block w-full h-full object-cover object-center"
autoPlay
muted
loop
playsInline
onCanPlay={() => {
if (currentSrc && isHttpOrHttpsUrl(currentSrc)) loadedAvatarUrls.add(currentSrc)
}}
onError={handleImageError}
/>
) : (
<img <img
src={currentSrc} src={currentSrc}
alt="" alt=""
@ -362,6 +395,7 @@ export function SimpleUserAvatar({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />
)
) : ( ) : (
// Show initials or placeholder when image fails // Show initials or placeholder when image fails
<div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground"> <div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground">

7
src/lib/nostr-build.ts

@ -4,8 +4,11 @@
* nostr.build generates a lightweight thumbnail at /thumb/<filename> for every * nostr.build generates a lightweight thumbnail at /thumb/<filename> for every
* uploaded image. Thumbnails are typically < 50 KB regardless of the original * uploaded image. Thumbnails are typically < 50 KB regardless of the original
* file size a huge bandwidth win for profile pictures and feed previews. * file size a huge bandwidth win for profile pictures and feed previews.
* Note: the /thumb/ route only works for image files never apply it to video URLs.
*/ */
import { isVideo } from './url'
/** Returns true when a URL is hosted on any nostr.build domain. */ /** Returns true when a URL is hosted on any nostr.build domain. */
export function isNostrBuildUrl(url: string): boolean { export function isNostrBuildUrl(url: string): boolean {
const u = (url ?? '').trim() const u = (url ?? '').trim()
@ -17,10 +20,12 @@ export function isNostrBuildUrl(url: string): boolean {
} }
} }
/** Returns true when the URL is on nostr.build but does NOT yet use the /thumb/ path. */ /** Returns true when the URL is on nostr.build but does NOT yet use the /thumb/ path, and is not a video file. */
export function canUseNostrBuildThumb(url: string): boolean { export function canUseNostrBuildThumb(url: string): boolean {
const u = (url ?? '').trim() const u = (url ?? '').trim()
if (!u) return false if (!u) return false
// /thumb/ is image-only on nostr.build — never apply it to video files
if (isVideo(u)) return false
try { try {
const parsed = new URL(u) const parsed = new URL(u)
if (!parsed.hostname.endsWith('nostr.build')) return false if (!parsed.hostname.endsWith('nostr.build')) return false

15
src/pages/secondary/ProfileEditorPage/index.tsx

@ -33,6 +33,7 @@ import {
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build' import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build'
import { isVideo } from '@/lib/url'
import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react' import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
@ -457,14 +458,28 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
onUploadStart={() => setUploadingAvatar(true)} onUploadStart={() => setUploadingAvatar(true)}
onUploadEnd={() => setUploadingAvatar(false)} onUploadEnd={() => setUploadingAvatar(false)}
className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full" className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full"
accept="image/*,video/mp4,video/webm,video/quicktime"
maxFileSizeMb={2} maxFileSizeMb={2}
> >
<div className="w-full h-full overflow-hidden rounded-full bg-muted">
{isVideo(avatar) ? (
<video
src={avatar}
className="w-full h-full object-cover object-center"
autoPlay
muted
loop
playsInline
/>
) : (
<Avatar className="w-full h-full"> <Avatar className="w-full h-full">
<AvatarImage src={avatar} className="object-cover object-center" /> <AvatarImage src={avatar} className="object-cover object-center" />
<AvatarFallback> <AvatarFallback>
<img src={defaultImage} /> <img src={defaultImage} />
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
)}
</div>
<div className="absolute top-0 bg-muted/30 w-full h-full rounded-full flex flex-col justify-center items-center"> <div className="absolute top-0 bg-muted/30 w-full h-full rounded-full flex flex-col justify-center items-center">
{uploadingAvatar ? ( {uploadingAvatar ? (
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />

4
src/services/local-storage.service.ts

@ -147,7 +147,7 @@ class LocalStorageService {
} }
const noteListModeStr = window.localStorage.getItem(StorageKey.NOTE_LIST_MODE) const noteListModeStr = window.localStorage.getItem(StorageKey.NOTE_LIST_MODE)
this.noteListMode = this.noteListMode =
noteListModeStr && ['posts', 'postsAndReplies', 'pictures'].includes(noteListModeStr) noteListModeStr && ['posts', 'postsAndReplies', 'media'].includes(noteListModeStr)
? (noteListModeStr as TNoteListMode) ? (noteListModeStr as TNoteListMode)
: 'posts' : 'posts'
const relaySetsStr = window.localStorage.getItem(StorageKey.RELAY_SETS) const relaySetsStr = window.localStorage.getItem(StorageKey.RELAY_SETS)
@ -487,7 +487,7 @@ class LocalStorageService {
this.fontSize = (get(StorageKey.FONT_SIZE) as TFontSize) ?? this.fontSize this.fontSize = (get(StorageKey.FONT_SIZE) as TFontSize) ?? this.fontSize
} }
const noteListModeStr = get(StorageKey.NOTE_LIST_MODE) const noteListModeStr = get(StorageKey.NOTE_LIST_MODE)
if (noteListModeStr != null && ['posts', 'postsAndReplies', 'pictures'].includes(noteListModeStr)) { if (noteListModeStr != null && ['posts', 'postsAndReplies', 'media'].includes(noteListModeStr)) {
this.noteListMode = noteListModeStr as TNoteListMode this.noteListMode = noteListModeStr as TNoteListMode
} }
const accountsStr = get(StorageKey.ACCOUNTS) const accountsStr = get(StorageKey.ACCOUNTS)

2
src/types/index.d.ts vendored

@ -205,7 +205,7 @@ export type TPublishEventExtras = {
publishBatchLabel?: string publishBatchLabel?: string
} }
export type TNoteListMode = 'posts' | 'postsAndReplies' | 'you' | 'bookmarksAndHashtags' export type TNoteListMode = 'posts' | 'postsAndReplies' | 'media'
export type TNotificationType = 'all' | 'mentions' | 'reactions' | 'zaps' export type TNotificationType = 'all' | 'mentions' | 'reactions' | 'zaps'

Loading…
Cancel
Save