feat: kind filter
This commit is contained in:
199
package-lock.json
generated
199
package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"@noble/hashes": "^1.6.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-hover-card": "^1.1.4",
|
||||
@@ -2652,6 +2653,204 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-checkbox": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
|
||||
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-previous": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
|
||||
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-size": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
|
||||
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@noble/hashes": "^1.6.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-hover-card": "^1.1.4",
|
||||
|
||||
35
src/App.tsx
35
src/App.tsx
@@ -1,22 +1,23 @@
|
||||
import 'yet-another-react-lightbox/styles.css'
|
||||
import './index.css'
|
||||
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { BookmarksProvider } from '@/providers/BookmarksProvider'
|
||||
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
|
||||
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
|
||||
import { FeedProvider } from '@/providers/FeedProvider'
|
||||
import { FollowListProvider } from '@/providers/FollowListProvider'
|
||||
import { KindFilterProvider } from '@/providers/KindFilterProvider'
|
||||
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
|
||||
import { MuteListProvider } from '@/providers/MuteListProvider'
|
||||
import { NostrProvider } from '@/providers/NostrProvider'
|
||||
import { ReplyProvider } from '@/providers/ReplyProvider'
|
||||
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
|
||||
import { ThemeProvider } from '@/providers/ThemeProvider'
|
||||
import { Toaster } from './components/ui/sonner'
|
||||
import { TranslationServiceProvider } from '@/providers/TranslationServiceProvider'
|
||||
import { UserTrustProvider } from '@/providers/UserTrustProvider'
|
||||
import { ZapProvider } from '@/providers/ZapProvider'
|
||||
import { PageManager } from './PageManager'
|
||||
import { BookmarksProvider } from './providers/BookmarksProvider'
|
||||
import { ContentPolicyProvider } from './providers/ContentPolicyProvider'
|
||||
import { FavoriteRelaysProvider } from './providers/FavoriteRelaysProvider'
|
||||
import { FeedProvider } from './providers/FeedProvider'
|
||||
import { FollowListProvider } from './providers/FollowListProvider'
|
||||
import { MediaUploadServiceProvider } from './providers/MediaUploadServiceProvider'
|
||||
import { MuteListProvider } from './providers/MuteListProvider'
|
||||
import { NostrProvider } from './providers/NostrProvider'
|
||||
import { ReplyProvider } from './providers/ReplyProvider'
|
||||
import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
|
||||
import { TranslationServiceProvider } from './providers/TranslationServiceProvider'
|
||||
import { UserTrustProvider } from './providers/UserTrustProvider'
|
||||
import { ZapProvider } from './providers/ZapProvider'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
return (
|
||||
@@ -34,8 +35,10 @@ export default function App(): JSX.Element {
|
||||
<FeedProvider>
|
||||
<ReplyProvider>
|
||||
<MediaUploadServiceProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
<KindFilterProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</KindFilterProvider>
|
||||
</MediaUploadServiceProvider>
|
||||
</ReplyProvider>
|
||||
</FeedProvider>
|
||||
|
||||
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}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { kinds } from 'nostr-tools'
|
||||
|
||||
export const JUMBLE_API_BASE_URL = 'https://api.jumble.social'
|
||||
|
||||
export const DEFAULT_FAVORITE_RELAYS = [
|
||||
@@ -36,6 +38,7 @@ export const StorageKey = {
|
||||
HIDE_UNTRUSTED_NOTES: 'hideUntrustedNotes',
|
||||
DEFAULT_SHOW_NSFW: 'defaultShowNsfw',
|
||||
DISMISSED_TOO_MANY_RELAYS_ALERT: 'dismissedTooManyRelaysAlert',
|
||||
SHOW_KINDS: 'showKinds',
|
||||
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
|
||||
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
|
||||
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
|
||||
@@ -74,6 +77,18 @@ export const ExtendedKind = {
|
||||
GROUP_METADATA: 39000
|
||||
}
|
||||
|
||||
export const DEFAULT_SHOW_KINDS = [
|
||||
kinds.ShortTextNote,
|
||||
kinds.Repost,
|
||||
ExtendedKind.PICTURE,
|
||||
ExtendedKind.POLL,
|
||||
ExtendedKind.COMMENT,
|
||||
ExtendedKind.VOICE,
|
||||
ExtendedKind.VOICE_COMMENT,
|
||||
kinds.Highlights,
|
||||
kinds.LongFormArticle
|
||||
]
|
||||
|
||||
export const URL_REGEX =
|
||||
/https?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+(?<![.,;:'")\]}!?,。;:""''!?】)])/gu
|
||||
export const WS_URL_REGEX =
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import KindFilter from '@/components/KindFilter'
|
||||
import NoteList, { TNoteListRef } from '@/components/NoteList'
|
||||
import Tabs from '@/components/Tabs'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { useKindFilter } from '@/providers/KindFilterProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import storage from '@/services/local-storage.service'
|
||||
@@ -15,6 +17,8 @@ export default function ProfileFeed({
|
||||
topSpace?: number
|
||||
}) {
|
||||
const { pubkey: myPubkey } = useNostr()
|
||||
const { showKinds } = useKindFilter()
|
||||
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
|
||||
const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode())
|
||||
const noteListRef = useRef<TNoteListRef>(null)
|
||||
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
|
||||
@@ -78,9 +82,12 @@ export default function ProfileFeed({
|
||||
|
||||
const handleListModeChange = (mode: TNoteListMode) => {
|
||||
setListMode(mode)
|
||||
setTimeout(() => {
|
||||
noteListRef.current?.scrollToTop()
|
||||
}, 0)
|
||||
noteListRef.current?.scrollToTop('smooth')
|
||||
}
|
||||
|
||||
const handleShowKindsChange = (newShowKinds: number[]) => {
|
||||
setTemporaryShowKinds(newShowKinds)
|
||||
noteListRef.current?.scrollToTop()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -92,8 +99,16 @@ export default function ProfileFeed({
|
||||
handleListModeChange(listMode as TNoteListMode)
|
||||
}}
|
||||
threshold={Math.max(800, topSpace)}
|
||||
options={
|
||||
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} />
|
||||
}
|
||||
/>
|
||||
<NoteList
|
||||
ref={noteListRef}
|
||||
subRequests={subRequests}
|
||||
showKinds={temporaryShowKinds}
|
||||
hideReplies={listMode === 'posts'}
|
||||
/>
|
||||
<NoteList ref={noteListRef} subRequests={subRequests} hideReplies={listMode === 'posts'} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
32
src/providers/KindFilterProvider.tsx
Normal file
32
src/providers/KindFilterProvider.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import storage from '@/services/local-storage.service'
|
||||
|
||||
type TKindFilterContext = {
|
||||
showKinds: number[]
|
||||
updateShowKinds: (kinds: number[]) => void
|
||||
}
|
||||
|
||||
const KindFilterContext = createContext<TKindFilterContext | undefined>(undefined)
|
||||
|
||||
export const useKindFilter = () => {
|
||||
const context = useContext(KindFilterContext)
|
||||
if (!context) {
|
||||
throw new Error('useKindFilter must be used within a KindFilterProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function KindFilterProvider({ children }: { children: React.ReactNode }) {
|
||||
const [showKinds, setShowKinds] = useState<number[]>(storage.getShowKinds())
|
||||
|
||||
const updateShowKinds = (kinds: number[]) => {
|
||||
storage.setShowKinds(kinds)
|
||||
setShowKinds(kinds)
|
||||
}
|
||||
|
||||
return (
|
||||
<KindFilterContext.Provider value={{ showKinds, updateShowKinds }}>
|
||||
{children}
|
||||
</KindFilterContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_NIP_96_SERVICE, StorageKey } from '@/constants'
|
||||
import { DEFAULT_NIP_96_SERVICE, DEFAULT_SHOW_KINDS, StorageKey } from '@/constants'
|
||||
import { isSameAccount } from '@/lib/account'
|
||||
import { randomString } from '@/lib/random'
|
||||
import {
|
||||
@@ -34,6 +34,7 @@ class LocalStorageService {
|
||||
private mediaUploadServiceConfigMap: Record<string, TMediaUploadServiceConfig> = {}
|
||||
private defaultShowNsfw: boolean = false
|
||||
private dismissedTooManyRelaysAlert: boolean = false
|
||||
private showKinds: number[] = []
|
||||
|
||||
constructor() {
|
||||
if (!LocalStorageService.instance) {
|
||||
@@ -140,6 +141,9 @@ class LocalStorageService {
|
||||
this.dismissedTooManyRelaysAlert =
|
||||
window.localStorage.getItem(StorageKey.DISMISSED_TOO_MANY_RELAYS_ALERT) === 'true'
|
||||
|
||||
const showKindsStr = window.localStorage.getItem(StorageKey.SHOW_KINDS)
|
||||
this.showKinds = showKindsStr ? JSON.parse(showKindsStr) : DEFAULT_SHOW_KINDS
|
||||
|
||||
// Clean up deprecated data
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
|
||||
@@ -372,6 +376,15 @@ class LocalStorageService {
|
||||
this.dismissedTooManyRelaysAlert = dismissed
|
||||
window.localStorage.setItem(StorageKey.DISMISSED_TOO_MANY_RELAYS_ALERT, dismissed.toString())
|
||||
}
|
||||
|
||||
getShowKinds() {
|
||||
return this.showKinds
|
||||
}
|
||||
|
||||
setShowKinds(kinds: number[]) {
|
||||
this.showKinds = kinds
|
||||
window.localStorage.setItem(StorageKey.SHOW_KINDS, JSON.stringify(kinds))
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new LocalStorageService()
|
||||
|
||||
Reference in New Issue
Block a user