feat: kind filter

This commit is contained in:
codytseng 2025-08-23 22:23:35 +08:00
parent f3f72e2f28
commit 4b9ead8319
13 changed files with 607 additions and 72 deletions

View file

@ -0,0 +1,179 @@
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Drawer, DrawerContent, DrawerHeader, DrawerTrigger } from '@/components/ui/drawer'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { DEFAULT_SHOW_KINDS, ExtendedKind } from '@/constants'
import { cn } from '@/lib/utils'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { ListFilter } from 'lucide-react'
import { kinds } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
const SUPPORTED_KINDS = [
{ kindGroup: [kinds.ShortTextNote, ExtendedKind.COMMENT], label: 'Posts' },
{ kindGroup: [kinds.Repost], label: 'Reposts' },
{ kindGroup: [kinds.LongFormArticle], label: 'Articles' },
{ kindGroup: [kinds.Highlights], label: 'Highlights' },
{ kindGroup: [ExtendedKind.POLL], label: 'Polls' },
{ kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' },
{ kindGroup: [ExtendedKind.PICTURE], label: 'Photo Posts' }
]
export default function KindFilter({
showKinds,
onShowKindsChange
}: {
showKinds: number[]
onShowKindsChange: (kinds: number[]) => void
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
const { updateShowKinds } = useKindFilter()
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
const [isPersistent, setIsPersistent] = useState(false)
const isFilterApplied = useMemo(() => {
return showKinds.length !== DEFAULT_SHOW_KINDS.length
}, [showKinds])
useEffect(() => {
setTemporaryShowKinds(showKinds)
}, [open])
const handleApply = () => {
if (temporaryShowKinds.length === 0) {
// must select at least one kind
return
}
const newShowKinds = [...temporaryShowKinds].sort()
let isSame = true
for (let index = 0; index < newShowKinds.length; index++) {
if (showKinds[index] !== newShowKinds[index]) {
isSame = false
break
}
}
if (!isSame) {
onShowKindsChange(newShowKinds)
}
if (isPersistent) {
updateShowKinds(newShowKinds)
}
setIsPersistent(false)
setOpen(false)
}
const trigger = (
<Button
variant="ghost"
size="titlebar-icon"
className={cn('mr-1', !isFilterApplied && 'text-muted-foreground')}
onClick={() => {
if (isSmallScreen) {
setOpen(true)
}
}}
>
<ListFilter />
</Button>
)
const content = (
<div>
<div className="grid grid-cols-2 gap-2">
{SUPPORTED_KINDS.map(({ kindGroup, label }) => (
<Label
key={label}
className="focus:bg-accent/50 cursor-pointer flex items-start gap-3 rounded-lg border px-4 py-3 has-[[aria-checked=true]]:border-primary has-[[aria-checked=true]]:bg-primary/20"
>
<Checkbox
id="toggle-2"
checked={kindGroup.every((k) => temporaryShowKinds.includes(k))}
onCheckedChange={(checked) => {
if (checked) {
// add all kinds in this group
setTemporaryShowKinds((prev) => Array.from(new Set([...prev, ...kindGroup])))
} else {
// remove all kinds in this group
setTemporaryShowKinds((prev) => prev.filter((k) => !kindGroup.includes(k)))
}
}}
/>
<div className="grid gap-1.5">
<p className="leading-none font-medium">{label}</p>
<p className="text-muted-foreground text-xs">kind {kindGroup.join(', ')}</p>
</div>
</Label>
))}
</div>
<div className="flex gap-2 mt-4">
<Button
variant="secondary"
onClick={() => {
setTemporaryShowKinds(DEFAULT_SHOW_KINDS)
}}
className="flex-1"
>
{t('Select All')}
</Button>
<Button
variant="secondary"
onClick={() => {
setTemporaryShowKinds([])
}}
className="flex-1"
>
{t('Clear All')}
</Button>
</div>
<Label className="flex items-center gap-2 cursor-pointer mt-4">
<Checkbox
id="persistent-filter"
checked={isPersistent}
onCheckedChange={(checked) => setIsPersistent(!!checked)}
/>
<span className="text-sm">{t('Remember my choice')}</span>
</Label>
<Button
onClick={handleApply}
className="mt-4 w-full"
disabled={temporaryShowKinds.length === 0}
>
{t('Apply')}
</Button>
</div>
)
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild></DrawerTrigger>
<DrawerContent className="px-4">
<DrawerHeader />
{content}
</DrawerContent>
</Drawer>
</>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent className="w-96" collisionPadding={16}>
{content}
</PopoverContent>
</Popover>
)
}

View file

@ -1,9 +1,11 @@
import NoteList, { TNoteListRef } from '@/components/NoteList'
import Tabs from '@/components/Tabs'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import storage from '@/services/local-storage.service'
import { TFeedSubRequest, TNoteListMode } from '@/types'
import { useRef, useState } from 'react'
import KindFilter from '../KindFilter'
export default function NormalFeed({
subRequests,
@ -15,6 +17,8 @@ export default function NormalFeed({
isMainFeed?: boolean
}) {
const { hideUntrustedNotes } = useUserTrust()
const { showKinds } = useKindFilter()
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode())
const noteListRef = useRef<TNoteListRef>(null)
@ -23,9 +27,12 @@ export default function NormalFeed({
if (isMainFeed) {
storage.setNoteListMode(mode)
}
setTimeout(() => {
noteListRef.current?.scrollToTop()
}, 0)
noteListRef.current?.scrollToTop('smooth')
}
const handleShowKindsChange = (newShowKinds: number[]) => {
setTemporaryShowKinds(newShowKinds)
noteListRef.current?.scrollToTop()
}
return (
@ -39,9 +46,13 @@ export default function NormalFeed({
onTabChange={(listMode) => {
handleListModeChange(listMode as TNoteListMode)
}}
options={
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} />
}
/>
<NoteList
ref={noteListRef}
showKinds={temporaryShowKinds}
subRequests={subRequests}
hideReplies={listMode === 'posts'}
hideUntrustedNotes={hideUntrustedNotes}

View file

@ -1,6 +1,5 @@
import NewNotesButton from '@/components/NewNotesButton'
import { Button } from '@/components/ui/button'
import { ExtendedKind } from '@/constants'
import {
getReplaceableCoordinateFromEvent,
isReplaceableEvent,
@ -12,7 +11,7 @@ import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types'
import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools'
import { Event } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
@ -20,30 +19,20 @@ import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const LIMIT = 100
const ALGO_LIMIT = 500
const KINDS = [
kinds.ShortTextNote,
kinds.Repost,
kinds.Highlights,
kinds.LongFormArticle,
ExtendedKind.COMMENT,
ExtendedKind.POLL,
ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.PICTURE
]
const SHOW_COUNT = 10
const NoteList = forwardRef(
(
{
subRequests,
showKinds,
filterMutedNotes = true,
hideReplies = false,
hideUntrustedNotes = false,
areAlgoRelays = false
}: {
subRequests: TFeedSubRequest[]
showKinds: number[]
filterMutedNotes?: boolean
hideReplies?: boolean
hideUntrustedNotes?: boolean
@ -100,8 +89,10 @@ const NoteList = forwardRef(
})
}, [newEvents, hideReplies, hideUntrustedNotes, filterMutedNotes, mutePubkeys])
const scrollToTop = () => {
topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
const scrollToTop = (behavior: ScrollBehavior = 'instant') => {
setTimeout(() => {
topRef.current?.scrollIntoView({ behavior, block: 'start' })
}, 20)
}
useImperativeHandle(ref, () => ({ scrollToTop }), [])
@ -115,11 +106,17 @@ const NoteList = forwardRef(
setNewEvents([])
setHasMore(true)
if (showKinds.length === 0) {
setLoading(false)
setHasMore(false)
return () => {}
}
const { closer, timelineKey } = await client.subscribeTimeline(
subRequests.map(({ urls, filter }) => ({
urls,
filter: {
kinds: KINDS,
kinds: showKinds,
...filter,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
@ -156,7 +153,7 @@ const NoteList = forwardRef(
return () => {
promise.then((closer) => closer())
}
}, [JSON.stringify(subRequests), refreshCount])
}, [JSON.stringify(subRequests), refreshCount, showKinds])
useEffect(() => {
const options = {
@ -264,5 +261,5 @@ NoteList.displayName = 'NoteList'
export default NoteList
export type TNoteListRef = {
scrollToTop: () => void
scrollToTop: (behavior?: ScrollBehavior) => void
}

View file

@ -1,7 +1,8 @@
import { cn } from '@/lib/utils'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import { useMemo } from 'react'
import { ReactNode, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ScrollArea, ScrollBar } from '../ui/scroll-area'
type TabDefinition = {
value: string
@ -12,49 +13,92 @@ export default function Tabs({
tabs,
value,
onTabChange,
threshold = 800
threshold = 800,
options = null
}: {
tabs: TabDefinition[]
value: string
onTabChange?: (tab: string) => void
threshold?: number
options?: ReactNode
}) {
const { t } = useTranslation()
const { deepBrowsing, lastScrollTop } = useDeepBrowsing()
const activeIndex = useMemo(() => tabs.findIndex((tab) => tab.value === value), [value, tabs])
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 })
const updateIndicatorPosition = () => {
const activeIndex = tabs.findIndex((tab) => tab.value === value)
if (activeIndex >= 0 && tabRefs.current[activeIndex]) {
const activeTab = tabRefs.current[activeIndex]
const { offsetWidth, offsetLeft } = activeTab
const padding = 48 // 24px padding on each side
setIndicatorStyle({
width: offsetWidth - padding,
left: offsetLeft + padding / 2
})
}
}
useEffect(() => {
const animationId = requestAnimationFrame(() => {
updateIndicatorPosition()
})
return () => {
cancelAnimationFrame(animationId)
}
}, [tabs, value])
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
updateIndicatorPosition()
})
tabRefs.current.forEach((tab) => {
if (tab) resizeObserver.observe(tab)
})
return () => {
resizeObserver.disconnect()
}
}, [tabs])
return (
<div
className={cn(
'sticky flex top-12 py-1 bg-background z-30 w-full transition-transform',
'sticky flex justify-between top-12 bg-background z-30 w-full transition-transform',
deepBrowsing && lastScrollTop > threshold ? '-translate-y-[calc(100%+12rem)]' : ''
)}
>
{tabs.map((tab) => (
<div
key={tab.value}
className={cn(
`flex-1 text-center py-2 font-semibold clickable cursor-pointer rounded-lg`,
value === tab.value ? '' : 'text-muted-foreground'
)}
onClick={() => {
onTabChange?.(tab.value)
}}
>
{t(tab.label)}
<ScrollArea className="flex-1 w-0">
<div className="flex w-fit relative">
{tabs.map((tab, index) => (
<div
key={tab.value}
ref={(el) => (tabRefs.current[index] = el)}
className={cn(
`w-fit text-center py-2 px-6 my-1 font-semibold clickable cursor-pointer rounded-lg`,
value === tab.value ? '' : 'text-muted-foreground'
)}
onClick={() => {
onTabChange?.(tab.value)
}}
>
{t(tab.label)}
</div>
))}
<div
className="absolute bottom-0 h-1 bg-primary rounded-full transition-all duration-500"
style={{
width: `${indicatorStyle.width}px`,
left: `${indicatorStyle.left}px`
}}
/>
</div>
))}
<div
className="absolute bottom-0 left-0 transition-all duration-500"
style={{
width: `${100 / tabs.length}%`,
left: `${activeIndex >= 0 ? activeIndex * (100 / tabs.length) : 0}%`
}}
>
<div className="px-4">
<div className="w-full h-1 bg-primary rounded-full" />
</div>
</div>
<ScrollBar orientation="horizontal" className="opacity-0 pointer-events-none" />
</ScrollArea>
{options && <div className="py-1 flex items-center">{options}</div>}
</div>
)
}

View file

@ -0,0 +1,26 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-accent shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View file

@ -96,7 +96,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}