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

199
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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>
<KindFilterProvider>
<PageManager />
<Toaster />
</KindFilterProvider>
</MediaUploadServiceProvider>
</ReplyProvider>
</FeedProvider>

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('smooth')
}
const handleShowKindsChange = (newShowKinds: number[]) => {
setTemporaryShowKinds(newShowKinds)
noteListRef.current?.scrollToTop()
}, 0)
}
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,29 +13,72 @@ 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) => (
<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(
`flex-1 text-center py-2 font-semibold clickable cursor-pointer rounded-lg`,
`w-fit text-center py-2 px-6 my-1 font-semibold clickable cursor-pointer rounded-lg`,
value === tab.value ? '' : 'text-muted-foreground'
)}
onClick={() => {
@@ -45,16 +89,16 @@ export default function Tabs({
</div>
))}
<div
className="absolute bottom-0 left-0 transition-all duration-500"
className="absolute bottom-0 h-1 bg-primary rounded-full transition-all duration-500"
style={{
width: `${100 / tabs.length}%`,
left: `${activeIndex >= 0 ? activeIndex * (100 / tabs.length) : 0}%`
width: `${indicatorStyle.width}px`,
left: `${indicatorStyle.left}px`
}}
>
<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}

View File

@@ -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 =

View File

@@ -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('smooth')
}
const handleShowKindsChange = (newShowKinds: number[]) => {
setTemporaryShowKinds(newShowKinds)
noteListRef.current?.scrollToTop()
}, 0)
}
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'} />
</>
)
}

View 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>
)
}

View File

@@ -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()