feat: kind filter
This commit is contained in:
parent
f3f72e2f28
commit
4b9ead8319
13 changed files with 607 additions and 72 deletions
179
src/components/KindFilter/index.tsx
Normal file
179
src/components/KindFilter/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
26
src/components/ui/checkbox.tsx
Normal file
26
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue