index.tsx raw
1 import { Button } from '@/components/ui/button'
2 import { Checkbox } from '@/components/ui/checkbox'
3 import { Drawer, DrawerContent, DrawerHeader, DrawerTrigger } from '@/components/ui/drawer'
4 import { Label } from '@/components/ui/label'
5 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
6 import { Separator } from '@/components/ui/separator'
7 import SocialGraphFilter from '@/components/SocialGraphFilter'
8 import { ExtendedKind } from '@/constants'
9 import { cn } from '@/lib/utils'
10 import { useKindFilter } from '@/providers/KindFilterProvider'
11 import { useScreenSize } from '@/providers/ScreenSizeProvider'
12 import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
13 import { ListFilter } from 'lucide-react'
14 import { kinds } from 'nostr-tools'
15 import { useEffect, useMemo, useState } from 'react'
16 import { useTranslation } from 'react-i18next'
17
18 const KIND_FILTER_OPTIONS = [
19 { kindGroup: [kinds.ShortTextNote, ExtendedKind.COMMENT], label: 'Posts' },
20 { kindGroup: [kinds.Repost, kinds.GenericRepost], label: 'Reposts' },
21 { kindGroup: [kinds.LongFormArticle], label: 'Articles' },
22 { kindGroup: [kinds.Highlights], label: 'Highlights' },
23 { kindGroup: [ExtendedKind.POLL], label: 'Polls' },
24 { kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' },
25 { kindGroup: [ExtendedKind.PICTURE], label: 'Photo Posts' },
26 {
27 kindGroup: [
28 ExtendedKind.VIDEO,
29 ExtendedKind.SHORT_VIDEO,
30 ExtendedKind.ADDRESSABLE_NORMAL_VIDEO,
31 ExtendedKind.ADDRESSABLE_SHORT_VIDEO
32 ],
33 label: 'Video Posts'
34 }
35 ]
36 const ALL_KINDS = KIND_FILTER_OPTIONS.flatMap(({ kindGroup }) => kindGroup)
37
38 export default function KindFilter({
39 showKinds,
40 onShowKindsChange,
41 showSocialGraphFilter = false
42 }: {
43 showKinds: number[]
44 onShowKindsChange: (kinds: number[]) => void
45 showSocialGraphFilter?: boolean
46 }) {
47 const { t } = useTranslation()
48 const { isSmallScreen } = useScreenSize()
49 const { showKinds: savedShowKinds } = useKindFilter()
50 const {
51 proximityLevel: savedProximity,
52 includeMode: savedIncludeMode,
53 updateProximityLevel,
54 updateIncludeMode
55 } = useSocialGraphFilter()
56 const [open, setOpen] = useState(false)
57 const { updateShowKinds } = useKindFilter()
58 const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
59 const [temporaryProximity, setTemporaryProximity] = useState<number | null>(savedProximity)
60 const [temporaryIncludeMode, setTemporaryIncludeMode] = useState(savedIncludeMode)
61 const [isPersistent, setIsPersistent] = useState(false)
62
63 const isDifferentFromSaved = useMemo(() => {
64 const kindsDifferent = !isSameKindFilter(showKinds, savedShowKinds)
65 const proximityDifferent = showSocialGraphFilter && savedProximity !== null
66 return kindsDifferent || proximityDifferent
67 }, [showKinds, savedShowKinds, savedProximity, showSocialGraphFilter])
68
69 const isTemporaryDifferentFromSaved = useMemo(
70 () => !isSameKindFilter(temporaryShowKinds, savedShowKinds),
71 [temporaryShowKinds, savedShowKinds]
72 )
73
74 useEffect(() => {
75 setTemporaryShowKinds(showKinds)
76 setTemporaryProximity(savedProximity)
77 setTemporaryIncludeMode(savedIncludeMode)
78 setIsPersistent(false)
79 }, [open, savedProximity, savedIncludeMode])
80
81 const handleApply = () => {
82 if (temporaryShowKinds.length === 0) {
83 // must select at least one kind
84 return
85 }
86
87 const newShowKinds = [...temporaryShowKinds].sort()
88 if (!isSameKindFilter(newShowKinds, showKinds)) {
89 onShowKindsChange(newShowKinds)
90 }
91
92 // Apply social graph filter changes
93 if (showSocialGraphFilter) {
94 if (temporaryProximity !== savedProximity) {
95 updateProximityLevel(temporaryProximity)
96 }
97 if (temporaryIncludeMode !== savedIncludeMode) {
98 updateIncludeMode(temporaryIncludeMode)
99 }
100 }
101
102 if (isPersistent) {
103 updateShowKinds(newShowKinds)
104 }
105
106 setIsPersistent(false)
107 setOpen(false)
108 }
109
110 const trigger = (
111 <Button
112 variant="ghost"
113 size="titlebar-icon"
114 className={cn(
115 'relative w-fit px-3 hover:text-foreground',
116 !isDifferentFromSaved && 'text-muted-foreground'
117 )}
118 onClick={() => {
119 if (isSmallScreen) {
120 setOpen(true)
121 }
122 }}
123 >
124 <ListFilter size={16} />
125 {isDifferentFromSaved && (
126 <div className="absolute size-2 rounded-full bg-primary left-7 top-2 ring-2 ring-background" />
127 )}
128 </Button>
129 )
130
131 const content = (
132 <div>
133 <div className="grid grid-cols-2 gap-2">
134 {KIND_FILTER_OPTIONS.map(({ kindGroup, label }) => {
135 const checked = kindGroup.every((k) => temporaryShowKinds.includes(k))
136 return (
137 <div
138 key={label}
139 className={cn(
140 'cursor-pointer grid gap-1.5 rounded-lg border px-4 py-3',
141 checked ? 'border-primary/60 bg-primary/5' : 'clickable'
142 )}
143 onClick={() => {
144 if (!checked) {
145 // add all kinds in this group
146 setTemporaryShowKinds((prev) => Array.from(new Set([...prev, ...kindGroup])))
147 } else {
148 // remove all kinds in this group
149 setTemporaryShowKinds((prev) => prev.filter((k) => !kindGroup.includes(k)))
150 }
151 }}
152 >
153 <p className="leading-none font-medium">{t(label)}</p>
154 <p className="text-muted-foreground text-xs">kind {kindGroup.join(', ')}</p>
155 </div>
156 )
157 })}
158 </div>
159
160 <div className="grid grid-cols-3 gap-2 mt-4">
161 <Button
162 variant="secondary"
163 onClick={() => {
164 setTemporaryShowKinds(ALL_KINDS)
165 }}
166 >
167 {t('Select All')}
168 </Button>
169 <Button
170 variant="secondary"
171 onClick={() => {
172 setTemporaryShowKinds([])
173 }}
174 >
175 {t('Clear All')}
176 </Button>
177 <Button
178 variant="secondary"
179 onClick={() => setTemporaryShowKinds(savedShowKinds)}
180 disabled={!isTemporaryDifferentFromSaved}
181 >
182 {t('Reset')}
183 </Button>
184 </div>
185
186 {showSocialGraphFilter && (
187 <>
188 <Separator className="my-4" />
189 <SocialGraphFilter
190 temporaryProximity={temporaryProximity}
191 temporaryIncludeMode={temporaryIncludeMode}
192 onTemporaryProximityChange={setTemporaryProximity}
193 onTemporaryIncludeModeChange={setTemporaryIncludeMode}
194 />
195 </>
196 )}
197
198 <Label className="flex items-center gap-2 cursor-pointer mt-4">
199 <Checkbox
200 id="persistent-filter"
201 checked={isPersistent}
202 onCheckedChange={(checked) => setIsPersistent(!!checked)}
203 />
204 <span className="text-sm">{t('Set as default filter')}</span>
205 </Label>
206
207 <Button
208 onClick={handleApply}
209 className="mt-4 w-full"
210 disabled={temporaryShowKinds.length === 0}
211 >
212 {t('Apply')}
213 </Button>
214 </div>
215 )
216
217 if (isSmallScreen) {
218 return (
219 <>
220 {trigger}
221 <Drawer open={open} onOpenChange={setOpen}>
222 <DrawerTrigger asChild></DrawerTrigger>
223 <DrawerContent className="px-4">
224 <DrawerHeader />
225 {content}
226 </DrawerContent>
227 </Drawer>
228 </>
229 )
230 }
231
232 return (
233 <Popover open={open} onOpenChange={setOpen}>
234 <PopoverTrigger asChild>{trigger}</PopoverTrigger>
235 <PopoverContent className="w-96" collisionPadding={16} sideOffset={0}>
236 {content}
237 </PopoverContent>
238 </Popover>
239 )
240 }
241
242 function isSameKindFilter(a: number[], b: number[]) {
243 if (a.length !== b.length) {
244 return false
245 }
246 const sortedA = [...a].sort()
247 const sortedB = [...b].sort()
248 return sortedA.every((value, index) => value === sortedB[index])
249 }
250