refactor: feed switcher
This commit is contained in:
parent
ce2976e3f9
commit
6cf78992a6
22 changed files with 221 additions and 129 deletions
|
|
@ -6,7 +6,8 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
|||
import { useFeed } from '@/providers/FeedProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { usePinnedUsers } from '@/providers/PinnedUsersProvider'
|
||||
import { Star, UsersRound } from 'lucide-react'
|
||||
import { Settings2, Star, UsersRound } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
import RelaySetCard from '../RelaySetCard'
|
||||
|
|
@ -17,81 +18,112 @@ export default function FeedSwitcher({ close }: { close?: () => void }) {
|
|||
const { relaySets, favoriteRelays } = useFavoriteRelays()
|
||||
const { feedInfo, switchFeed } = useFeed()
|
||||
const { pinnedPubkeySet } = usePinnedUsers()
|
||||
const filteredRelaySets = useMemo(
|
||||
() => relaySets.filter((set) => set.relayUrls.length > 0),
|
||||
[relaySets]
|
||||
)
|
||||
const hasRelays = filteredRelaySets.length > 0 || favoriteRelays.length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<FeedSwitcherItem
|
||||
isActive={feedInfo?.feedType === 'following'}
|
||||
disabled={!pubkey}
|
||||
onClick={() => {
|
||||
if (!pubkey) return
|
||||
switchFeed('following', { pubkey })
|
||||
close?.()
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex justify-center items-center w-6 h-6 shrink-0">
|
||||
<UsersRound className="size-4" />
|
||||
</div>
|
||||
<div>{t('Following')}</div>
|
||||
</div>
|
||||
</FeedSwitcherItem>
|
||||
|
||||
<FeedSwitcherItem
|
||||
isActive={feedInfo?.feedType === 'pinned'}
|
||||
disabled={!pubkey || pinnedPubkeySet.size === 0}
|
||||
onClick={() => {
|
||||
if (!pubkey) return
|
||||
switchFeed('pinned', { pubkey })
|
||||
close?.()
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex justify-center items-center w-6 h-6 shrink-0">
|
||||
<Star className="size-4" />
|
||||
</div>
|
||||
<div>{t('Special Follow')}</div>
|
||||
</div>
|
||||
</FeedSwitcherItem>
|
||||
|
||||
<div className="flex justify-end items-center text-sm">
|
||||
<SecondaryPageLink
|
||||
to={toRelaySettings()}
|
||||
className="text-primary font-semibold"
|
||||
onClick={() => close?.()}
|
||||
>
|
||||
{t('edit')}
|
||||
</SecondaryPageLink>
|
||||
</div>
|
||||
{relaySets
|
||||
.filter((set) => set.relayUrls.length > 0)
|
||||
.map((set) => (
|
||||
<RelaySetCard
|
||||
key={set.id}
|
||||
relaySet={set}
|
||||
select={feedInfo?.feedType === 'relays' && set.id === feedInfo.id}
|
||||
onSelectChange={(select) => {
|
||||
if (!select) return
|
||||
switchFeed('relays', { activeRelaySetId: set.id })
|
||||
<div className="space-y-4">
|
||||
{/* Personal Feeds Section */}
|
||||
<div className="space-y-2">
|
||||
<SectionHeader title={t('Personal Feeds')} />
|
||||
<div className="space-y-1.5">
|
||||
<FeedSwitcherItem
|
||||
isActive={feedInfo?.feedType === 'following'}
|
||||
disabled={!pubkey}
|
||||
onClick={() => {
|
||||
if (!pubkey) return
|
||||
switchFeed('following', { pubkey })
|
||||
close?.()
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex justify-center items-center size-6 shrink-0">
|
||||
<UsersRound className="size-5" />
|
||||
</div>
|
||||
<div className="flex-1">{t('Following')}</div>
|
||||
</div>
|
||||
</FeedSwitcherItem>
|
||||
|
||||
<FeedSwitcherItem
|
||||
isActive={feedInfo?.feedType === 'pinned'}
|
||||
disabled={!pubkey || pinnedPubkeySet.size === 0}
|
||||
onClick={() => {
|
||||
if (!pubkey) return
|
||||
switchFeed('pinned', { pubkey })
|
||||
close?.()
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex justify-center items-center size-6 shrink-0">
|
||||
<Star className="size-5" />
|
||||
</div>
|
||||
<div className="flex-1">{t('Special Follow')}</div>
|
||||
</div>
|
||||
</FeedSwitcherItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Relay Feeds Section */}
|
||||
{hasRelays && (
|
||||
<div className="space-y-2">
|
||||
<SectionHeader
|
||||
title={t('Relay Feeds')}
|
||||
action={
|
||||
<SecondaryPageLink
|
||||
to={toRelaySettings()}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary-hover transition-colors font-medium"
|
||||
onClick={() => close?.()}
|
||||
>
|
||||
<Settings2 className="size-3" />
|
||||
{t('edit')}
|
||||
</SecondaryPageLink>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{favoriteRelays.map((relay) => (
|
||||
<FeedSwitcherItem
|
||||
key={relay}
|
||||
isActive={feedInfo?.feedType === 'relay' && feedInfo.id === relay}
|
||||
onClick={() => {
|
||||
switchFeed('relay', { relay })
|
||||
close?.()
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2 items-center w-full">
|
||||
<RelayIcon url={relay} />
|
||||
<div className="flex-1 w-0 truncate">{simplifyUrl(relay)}</div>
|
||||
<div className="space-y-1.5">
|
||||
{filteredRelaySets.map((set) => (
|
||||
<RelaySetCard
|
||||
key={set.id}
|
||||
relaySet={set}
|
||||
select={feedInfo?.feedType === 'relays' && set.id === feedInfo.id}
|
||||
onSelectChange={(select) => {
|
||||
if (!select) return
|
||||
switchFeed('relays', { activeRelaySetId: set.id })
|
||||
close?.()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{favoriteRelays.map((relay) => (
|
||||
<FeedSwitcherItem
|
||||
key={relay}
|
||||
isActive={feedInfo?.feedType === 'relay' && feedInfo.id === relay}
|
||||
onClick={() => {
|
||||
switchFeed('relay', { relay })
|
||||
close?.()
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-3 items-center w-full">
|
||||
<RelayIcon url={relay} className="shrink-0" />
|
||||
<div className="flex-1 w-0 truncate">{simplifyUrl(relay)}</div>
|
||||
</div>
|
||||
</FeedSwitcherItem>
|
||||
))}
|
||||
</div>
|
||||
</FeedSwitcherItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between items-center px-1 py-1">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{title}
|
||||
</h3>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -100,30 +132,29 @@ function FeedSwitcherItem({
|
|||
children,
|
||||
isActive,
|
||||
disabled,
|
||||
onClick,
|
||||
controls
|
||||
onClick
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
isActive: boolean
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
controls?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full border rounded-lg p-4',
|
||||
'group relative w-full border rounded-lg px-3 py-2.5 transition-all duration-200',
|
||||
disabled && 'opacity-50 pointer-events-none',
|
||||
isActive ? 'border-primary bg-primary/5' : 'clickable'
|
||||
isActive
|
||||
? 'border-primary bg-primary/5 shadow-sm'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent/50 clickable'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabled) return
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="font-semibold flex-1">{children}</div>
|
||||
{controls}
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<div className="font-medium flex-1 min-w-0">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export default function MailboxRelay({
|
|||
onClick={() => push(toRelay(mailboxRelay.url))}
|
||||
>
|
||||
<RelayIcon url={mailboxRelay.url} />
|
||||
<div className="truncate">{mailboxRelay.url}</div>
|
||||
<div className="truncate flex-1 w-0">{mailboxRelay.url}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useFetchRelayInfo } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Server } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import Image from '../Image'
|
||||
|
||||
export default function RelayIcon({
|
||||
url,
|
||||
className,
|
||||
iconSize = 14
|
||||
classNames
|
||||
}: {
|
||||
url?: string
|
||||
className?: string
|
||||
iconSize?: number
|
||||
classNames?: {
|
||||
fallback?: string
|
||||
}
|
||||
}) {
|
||||
const { relayInfo } = useFetchRelayInfo(url)
|
||||
const iconUrl = useMemo(() => {
|
||||
|
|
@ -23,12 +25,21 @@ export default function RelayIcon({
|
|||
return `${u.protocol === 'wss:' ? 'https:' : 'http:'}//${u.host}/favicon.ico`
|
||||
}, [url, relayInfo])
|
||||
|
||||
const fallback = <Server className={cn('size-5 bg-transparent', classNames?.fallback)} />
|
||||
|
||||
if (!iconUrl) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar className={cn('w-6 h-6', className)}>
|
||||
<AvatarImage src={iconUrl} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<Server size={iconSize} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Image
|
||||
image={{ url: iconUrl, dim: { width: 20, height: 20 } }}
|
||||
className={cn('size-6 rounded-full', className)}
|
||||
classNames={{
|
||||
skeleton: cn('size-6 rounded-full', className),
|
||||
errorPlaceholder: 'bg-transparent rounded-none shrink-0'
|
||||
}}
|
||||
errorPlaceholder={fallback}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { TRelaySet } from '@/types'
|
||||
import { ChevronDown, FolderClosed } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
|
@ -18,17 +19,22 @@ export default function RelaySetCard({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`w-full border rounded-lg p-4 clickable ${select ? 'border-primary bg-primary/5' : ''}`}
|
||||
className={cn(
|
||||
'group relative w-full border rounded-lg px-3 py-2.5 transition-all duration-200',
|
||||
select
|
||||
? 'border-primary bg-primary/5 shadow-sm'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent/50 clickable'
|
||||
)}
|
||||
onClick={() => onSelectChange(!select)}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex space-x-2 items-center cursor-pointer">
|
||||
<div className="flex justify-center items-center w-6 h-6 shrink-0">
|
||||
<FolderClosed className="size-4" />
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<div className="flex gap-3 items-center flex-1 min-w-0">
|
||||
<div className="flex justify-center items-center size-6 shrink-0">
|
||||
<FolderClosed className="size-5" />
|
||||
</div>
|
||||
<div className="h-8 font-semibold flex items-center select-none">{relaySet.name}</div>
|
||||
<div className="font-medium select-none truncate">{relaySet.name}</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-1 items-center shrink-0">
|
||||
<RelayUrlsExpandToggle expand={expand} onExpandChange={setExpand}>
|
||||
{t('n relays', { n: relaySet.relayUrls.length })}
|
||||
</RelayUrlsExpandToggle>
|
||||
|
|
@ -50,16 +56,16 @@ function RelayUrlsExpandToggle({
|
|||
}) {
|
||||
return (
|
||||
<div
|
||||
className="text-sm text-muted-foreground flex items-center gap-1 cursor-pointer hover:text-foreground"
|
||||
className="text-xs text-muted-foreground flex items-center gap-0.5 cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onExpandChange(!expand)
|
||||
}}
|
||||
>
|
||||
<div className="select-none">{children}</div>
|
||||
<div className="select-none font-medium">{children}</div>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${expand ? 'rotate-180' : ''}`}
|
||||
size={14}
|
||||
className={cn('transition-transform duration-200', expand && 'rotate-180')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -69,11 +75,11 @@ function RelayUrls({ urls }: { urls: string[] }) {
|
|||
if (!urls) return null
|
||||
|
||||
return (
|
||||
<div className="pl-1 space-y-1">
|
||||
<div className="mt-2.5 pt-2.5 border-t space-y-1.5">
|
||||
{urls.map((url) => (
|
||||
<div key={url} className="flex items-center gap-3">
|
||||
<RelayIcon url={url} className="w-4 h-4" iconSize={10} />
|
||||
<div className="text-muted-foreground text-sm truncate">{url}</div>
|
||||
<div key={url} className="flex items-center gap-2.5 pl-1">
|
||||
<RelayIcon url={url} className="size-4 shrink-0" classNames={{ fallback: 'size-3' }} />
|
||||
<div className="text-muted-foreground text-xs truncate">{url}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue