Release v0.4.1
Auto-search after QR scan in search bar. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"additionalDirectories": [
|
||||||
|
"/home/mleku/src/git.mleku.dev/mleku/coracle"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"description": "A user-friendly Nostr client for exploring relay feeds",
|
"description": "A user-friendly Nostr client for exploring relay feeds",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
11
src/App.tsx
11
src/App.tsx
@@ -12,6 +12,7 @@ import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
|
|||||||
import { FeedProvider } from '@/providers/FeedProvider'
|
import { FeedProvider } from '@/providers/FeedProvider'
|
||||||
import { FollowListProvider } from '@/providers/FollowListProvider'
|
import { FollowListProvider } from '@/providers/FollowListProvider'
|
||||||
import { KindFilterProvider } from '@/providers/KindFilterProvider'
|
import { KindFilterProvider } from '@/providers/KindFilterProvider'
|
||||||
|
import { SocialGraphFilterProvider } from '@/providers/SocialGraphFilterProvider'
|
||||||
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
|
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
|
||||||
import { MuteListProvider } from '@/providers/MuteListProvider'
|
import { MuteListProvider } from '@/providers/MuteListProvider'
|
||||||
import { NostrProvider } from '@/providers/NostrProvider'
|
import { NostrProvider } from '@/providers/NostrProvider'
|
||||||
@@ -51,10 +52,12 @@ export default function App(): JSX.Element {
|
|||||||
<PinnedUsersProvider>
|
<PinnedUsersProvider>
|
||||||
<FeedProvider>
|
<FeedProvider>
|
||||||
<MediaUploadServiceProvider>
|
<MediaUploadServiceProvider>
|
||||||
<KindFilterProvider>
|
<SocialGraphFilterProvider>
|
||||||
<PageManager />
|
<KindFilterProvider>
|
||||||
<Toaster />
|
<PageManager />
|
||||||
</KindFilterProvider>
|
<Toaster />
|
||||||
|
</KindFilterProvider>
|
||||||
|
</SocialGraphFilterProvider>
|
||||||
</MediaUploadServiceProvider>
|
</MediaUploadServiceProvider>
|
||||||
</FeedProvider>
|
</FeedProvider>
|
||||||
</PinnedUsersProvider>
|
</PinnedUsersProvider>
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { Checkbox } from '@/components/ui/checkbox'
|
|||||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTrigger } from '@/components/ui/drawer'
|
import { Drawer, DrawerContent, DrawerHeader, DrawerTrigger } from '@/components/ui/drawer'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import SocialGraphFilter from '@/components/SocialGraphFilter'
|
||||||
import { ExtendedKind } from '@/constants'
|
import { ExtendedKind } from '@/constants'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useKindFilter } from '@/providers/KindFilterProvider'
|
import { useKindFilter } from '@/providers/KindFilterProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
|
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
|
||||||
import { ListFilter } from 'lucide-react'
|
import { ListFilter } from 'lucide-react'
|
||||||
import { kinds } from 'nostr-tools'
|
import { kinds } from 'nostr-tools'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
@@ -34,22 +37,35 @@ const ALL_KINDS = KIND_FILTER_OPTIONS.flatMap(({ kindGroup }) => kindGroup)
|
|||||||
|
|
||||||
export default function KindFilter({
|
export default function KindFilter({
|
||||||
showKinds,
|
showKinds,
|
||||||
onShowKindsChange
|
onShowKindsChange,
|
||||||
|
showSocialGraphFilter = false
|
||||||
}: {
|
}: {
|
||||||
showKinds: number[]
|
showKinds: number[]
|
||||||
onShowKindsChange: (kinds: number[]) => void
|
onShowKindsChange: (kinds: number[]) => void
|
||||||
|
showSocialGraphFilter?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
const { showKinds: savedShowKinds } = useKindFilter()
|
const { showKinds: savedShowKinds } = useKindFilter()
|
||||||
|
const {
|
||||||
|
proximityLevel: savedProximity,
|
||||||
|
includeMode: savedIncludeMode,
|
||||||
|
updateProximityLevel,
|
||||||
|
updateIncludeMode
|
||||||
|
} = useSocialGraphFilter()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const { updateShowKinds } = useKindFilter()
|
const { updateShowKinds } = useKindFilter()
|
||||||
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
|
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
|
||||||
|
const [temporaryProximity, setTemporaryProximity] = useState<number | null>(savedProximity)
|
||||||
|
const [temporaryIncludeMode, setTemporaryIncludeMode] = useState(savedIncludeMode)
|
||||||
const [isPersistent, setIsPersistent] = useState(false)
|
const [isPersistent, setIsPersistent] = useState(false)
|
||||||
const isDifferentFromSaved = useMemo(
|
|
||||||
() => !isSameKindFilter(showKinds, savedShowKinds),
|
const isDifferentFromSaved = useMemo(() => {
|
||||||
[showKinds, savedShowKinds]
|
const kindsDifferent = !isSameKindFilter(showKinds, savedShowKinds)
|
||||||
)
|
const proximityDifferent = showSocialGraphFilter && savedProximity !== null
|
||||||
|
return kindsDifferent || proximityDifferent
|
||||||
|
}, [showKinds, savedShowKinds, savedProximity, showSocialGraphFilter])
|
||||||
|
|
||||||
const isTemporaryDifferentFromSaved = useMemo(
|
const isTemporaryDifferentFromSaved = useMemo(
|
||||||
() => !isSameKindFilter(temporaryShowKinds, savedShowKinds),
|
() => !isSameKindFilter(temporaryShowKinds, savedShowKinds),
|
||||||
[temporaryShowKinds, savedShowKinds]
|
[temporaryShowKinds, savedShowKinds]
|
||||||
@@ -57,8 +73,10 @@ export default function KindFilter({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTemporaryShowKinds(showKinds)
|
setTemporaryShowKinds(showKinds)
|
||||||
|
setTemporaryProximity(savedProximity)
|
||||||
|
setTemporaryIncludeMode(savedIncludeMode)
|
||||||
setIsPersistent(false)
|
setIsPersistent(false)
|
||||||
}, [open])
|
}, [open, savedProximity, savedIncludeMode])
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
if (temporaryShowKinds.length === 0) {
|
if (temporaryShowKinds.length === 0) {
|
||||||
@@ -71,6 +89,16 @@ export default function KindFilter({
|
|||||||
onShowKindsChange(newShowKinds)
|
onShowKindsChange(newShowKinds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply social graph filter changes
|
||||||
|
if (showSocialGraphFilter) {
|
||||||
|
if (temporaryProximity !== savedProximity) {
|
||||||
|
updateProximityLevel(temporaryProximity)
|
||||||
|
}
|
||||||
|
if (temporaryIncludeMode !== savedIncludeMode) {
|
||||||
|
updateIncludeMode(temporaryIncludeMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isPersistent) {
|
if (isPersistent) {
|
||||||
updateShowKinds(newShowKinds)
|
updateShowKinds(newShowKinds)
|
||||||
}
|
}
|
||||||
@@ -155,6 +183,18 @@ export default function KindFilter({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showSocialGraphFilter && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<SocialGraphFilter
|
||||||
|
temporaryProximity={temporaryProximity}
|
||||||
|
temporaryIncludeMode={temporaryIncludeMode}
|
||||||
|
onTemporaryProximityChange={setTemporaryProximity}
|
||||||
|
onTemporaryIncludeModeChange={setTemporaryIncludeMode}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Label className="flex items-center gap-2 cursor-pointer mt-4">
|
<Label className="flex items-center gap-2 cursor-pointer mt-4">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="persistent-filter"
|
id="persistent-filter"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default function NormalFeed({
|
|||||||
isMainFeed = false,
|
isMainFeed = false,
|
||||||
showRelayCloseReason = false,
|
showRelayCloseReason = false,
|
||||||
disable24hMode = false,
|
disable24hMode = false,
|
||||||
|
enableSocialGraphFilter = false,
|
||||||
onRefresh
|
onRefresh
|
||||||
}: {
|
}: {
|
||||||
subRequests: TFeedSubRequest[]
|
subRequests: TFeedSubRequest[]
|
||||||
@@ -23,6 +24,7 @@ export default function NormalFeed({
|
|||||||
isMainFeed?: boolean
|
isMainFeed?: boolean
|
||||||
showRelayCloseReason?: boolean
|
showRelayCloseReason?: boolean
|
||||||
disable24hMode?: boolean
|
disable24hMode?: boolean
|
||||||
|
enableSocialGraphFilter?: boolean
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { hideUntrustedNotes } = useUserTrust()
|
const { hideUntrustedNotes } = useUserTrust()
|
||||||
@@ -87,6 +89,7 @@ export default function NormalFeed({
|
|||||||
<KindFilter
|
<KindFilter
|
||||||
showKinds={temporaryShowKinds}
|
showKinds={temporaryShowKinds}
|
||||||
onShowKindsChange={handleShowKindsChange}
|
onShowKindsChange={handleShowKindsChange}
|
||||||
|
showSocialGraphFilter={enableSocialGraphFilter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -110,6 +113,7 @@ export default function NormalFeed({
|
|||||||
hideUntrustedNotes={hideUntrustedNotes}
|
hideUntrustedNotes={hideUntrustedNotes}
|
||||||
areAlgoRelays={areAlgoRelays}
|
areAlgoRelays={areAlgoRelays}
|
||||||
showRelayCloseReason={showRelayCloseReason}
|
showRelayCloseReason={showRelayCloseReason}
|
||||||
|
applySocialGraphFilter={enableSocialGraphFilter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
|||||||
import { TNavigationColumn, useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
import { TNavigationColumn, useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import threadService from '@/services/thread.service'
|
import threadService from '@/services/thread.service'
|
||||||
@@ -55,6 +56,7 @@ const NoteList = forwardRef<
|
|||||||
filterFn?: (event: Event) => boolean
|
filterFn?: (event: Event) => boolean
|
||||||
showNewNotesDirectly?: boolean
|
showNewNotesDirectly?: boolean
|
||||||
navColumn?: TNavigationColumn
|
navColumn?: TNavigationColumn
|
||||||
|
applySocialGraphFilter?: boolean
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
@@ -70,7 +72,8 @@ const NoteList = forwardRef<
|
|||||||
pinnedEventIds,
|
pinnedEventIds,
|
||||||
filterFn,
|
filterFn,
|
||||||
showNewNotesDirectly = false,
|
showNewNotesDirectly = false,
|
||||||
navColumn = 1
|
navColumn = 1,
|
||||||
|
applySocialGraphFilter = false
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@@ -80,6 +83,7 @@ const NoteList = forwardRef<
|
|||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const { isEventDeleted } = useDeletedEvent()
|
const { isEventDeleted } = useDeletedEvent()
|
||||||
|
const { isPubkeyAllowed } = useSocialGraphFilter()
|
||||||
const { offsetSelection, registerLoadMore, unregisterLoadMore } = useKeyboardNavigation()
|
const { offsetSelection, registerLoadMore, unregisterLoadMore } = useKeyboardNavigation()
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||||
@@ -122,10 +126,22 @@ const NoteList = forwardRef<
|
|||||||
if (filterFn && !filterFn(evt)) {
|
if (filterFn && !filterFn(evt)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// Social graph filter - only apply if enabled for this feed
|
||||||
|
if (applySocialGraphFilter && !isPubkeyAllowed(evt.pubkey)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
[hideUntrustedNotes, mutePubkeySet, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn]
|
[
|
||||||
|
hideUntrustedNotes,
|
||||||
|
mutePubkeySet,
|
||||||
|
JSON.stringify(pinnedEventIds),
|
||||||
|
isEventDeleted,
|
||||||
|
filterFn,
|
||||||
|
applySocialGraphFilter,
|
||||||
|
isPubkeyAllowed
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -3,22 +3,43 @@ import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
|||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { QrCodeIcon } from 'lucide-react'
|
import { QrCodeIcon } from 'lucide-react'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import Nip05 from '../Nip05'
|
import Nip05 from '../Nip05'
|
||||||
import PubkeyCopy from '../PubkeyCopy'
|
|
||||||
import QrCode from '../QrCode'
|
import QrCode from '../QrCode'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
|
||||||
export default function NpubQrCode({ pubkey }: { pubkey: string }) {
|
export default function NpubQrCode({ pubkey }: { pubkey: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
|
const [open, setOpen] = useState(false)
|
||||||
|
const npub = useMemo(() => {
|
||||||
|
// Validate pubkey is a 64-character hex string before encoding
|
||||||
|
if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) return ''
|
||||||
|
try {
|
||||||
|
return nip19.npubEncode(pubkey)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}, [pubkey])
|
||||||
|
|
||||||
|
const handleQrClick = useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(npub)
|
||||||
|
toast.success(t('Copied npub to clipboard'))
|
||||||
|
setOpen(false)
|
||||||
|
}, [npub, t])
|
||||||
|
|
||||||
if (!npub) return null
|
if (!npub) return null
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<div className="bg-muted rounded-full h-5 w-5 flex flex-col items-center justify-center text-muted-foreground hover:text-foreground">
|
<button
|
||||||
|
className="bg-muted rounded-full h-5 w-5 flex flex-col items-center justify-center text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<QrCodeIcon size={14} />
|
<QrCodeIcon size={14} />
|
||||||
</div>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
@@ -26,29 +47,33 @@ export default function NpubQrCode({ pubkey }: { pubkey: string }) {
|
|||||||
<div className="flex items-center w-full gap-2 pointer-events-none px-1">
|
<div className="flex items-center w-full gap-2 pointer-events-none px-1">
|
||||||
<UserAvatar size="big" userId={pubkey} />
|
<UserAvatar size="big" userId={pubkey} />
|
||||||
<div className="flex-1 w-0">
|
<div className="flex-1 w-0">
|
||||||
<Username userId={pubkey} className="text-2xl font-semibold truncate" />
|
<Username userId={pubkey} className="text-2xl font-semibold truncate" showQrCode={false} />
|
||||||
<Nip05 pubkey={pubkey} />
|
<Nip05 pubkey={pubkey} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<QrCode size={512} value={`nostr:${npub}`} />
|
<button
|
||||||
<div className="flex flex-col items-center">
|
onClick={handleQrClick}
|
||||||
<PubkeyCopy pubkey={pubkey} />
|
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
</div>
|
title={t('Click to copy npub')}
|
||||||
|
>
|
||||||
|
<QrCode size={512} value={`nostr:${npub}`} />
|
||||||
|
</button>
|
||||||
|
<div className="text-sm text-muted-foreground">{t('Click QR code to copy npub')}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
return (
|
return (
|
||||||
<Drawer>
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
<DrawerTrigger>{trigger}</DrawerTrigger>
|
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||||
<DrawerContent>{content}</DrawerContent>
|
<DrawerContent>{content}</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger>{trigger}</DialogTrigger>
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
<DialogContent className="w-80 p-0 m-0" onOpenAutoFocus={(e) => e.preventDefault()}>
|
<DialogContent className="w-80 p-0 m-0" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||||
{content}
|
{content}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -342,6 +342,27 @@ const SearchBar = forwardRef<
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => setSearching(true)}
|
onFocus={() => setSearching(true)}
|
||||||
onBlur={() => setSearching(false)}
|
onBlur={() => setSearching(false)}
|
||||||
|
onQrScan={(value) => {
|
||||||
|
setInput(value)
|
||||||
|
// Automatically search after scanning
|
||||||
|
let id = value
|
||||||
|
if (id.startsWith('nostr:')) {
|
||||||
|
id = id.slice(6)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { type } = nip19.decode(id)
|
||||||
|
if (['nprofile', 'npub'].includes(type)) {
|
||||||
|
updateSearch({ type: 'profile', search: id })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (['nevent', 'naddr', 'note'].includes(type)) {
|
||||||
|
updateSearch({ type: 'note', search: id })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a valid nip19 identifier, just set input
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { SearchIcon, X } from 'lucide-react'
|
import { QrCodeIcon, SearchIcon, X } from 'lucide-react'
|
||||||
import { ComponentProps, forwardRef, useEffect, useState } from 'react'
|
import { ComponentProps, forwardRef, useEffect, useState } from 'react'
|
||||||
|
import QrScannerModal from '../QrScannerModal'
|
||||||
|
|
||||||
const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
type SearchInputProps = ComponentProps<'input'> & {
|
||||||
({ value, onChange, className, ...props }, ref) => {
|
onQrScan?: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||||
|
({ value, onChange, className, onQrScan, ...props }, ref) => {
|
||||||
const [displayClear, setDisplayClear] = useState(false)
|
const [displayClear, setDisplayClear] = useState(false)
|
||||||
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null)
|
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null)
|
||||||
|
const [showQrScanner, setShowQrScanner] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayClear(!!value)
|
setDisplayClear(!!value)
|
||||||
@@ -20,34 +26,55 @@ const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleQrScan = (result: string) => {
|
||||||
|
// Strip nostr: prefix if present
|
||||||
|
const value = result.startsWith('nostr:') ? result.slice(6) : result
|
||||||
|
onQrScan?.(value)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
tabIndex={0}
|
<div
|
||||||
className={cn(
|
tabIndex={0}
|
||||||
'flex h-9 w-full items-center rounded-xl border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-all duration-200 md:text-sm [&:has(:focus-visible)]:ring-ring [&:has(:focus-visible)]:ring-2 [&:has(:focus-visible)]:outline-none hover:border-ring/50',
|
className={cn(
|
||||||
className
|
'flex h-9 w-full items-center rounded-xl border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-all duration-200 md:text-sm [&:has(:focus-visible)]:ring-ring [&:has(:focus-visible)]:ring-2 [&:has(:focus-visible)]:outline-none hover:border-ring/50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" onClick={() => inputRef?.focus()} />
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
name="search-input"
|
||||||
|
ref={setRefs}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
{onQrScan && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors size-5 shrink-0 flex items-center justify-center mr-1"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setShowQrScanner(true)}
|
||||||
|
>
|
||||||
|
<QrCodeIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{displayClear && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full bg-foreground/40 hover:bg-foreground transition-opacity size-5 shrink-0 flex flex-col items-center justify-center"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onChange?.({ target: { value: '' } } as any)}
|
||||||
|
>
|
||||||
|
<X className="!size-3 shrink-0 text-background" strokeWidth={4} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showQrScanner && (
|
||||||
|
<QrScannerModal onScan={handleQrScan} onClose={() => setShowQrScanner(false)} />
|
||||||
)}
|
)}
|
||||||
>
|
</>
|
||||||
<SearchIcon className="size-4 shrink-0 opacity-50" onClick={() => inputRef?.focus()} />
|
|
||||||
<input
|
|
||||||
{...props}
|
|
||||||
name="search-input"
|
|
||||||
ref={setRefs}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
|
|
||||||
/>
|
|
||||||
{displayClear && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-full bg-foreground/40 hover:bg-foreground transition-opacity size-5 shrink-0 flex flex-col items-center justify-center"
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => onChange?.({ target: { value: '' } } as any)}
|
|
||||||
>
|
|
||||||
<X className="!size-3 shrink-0 text-background" strokeWidth={4} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
127
src/components/SocialGraphFilter/index.tsx
Normal file
127
src/components/SocialGraphFilter/index.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
|
||||||
|
import { Loader2, Minus, Plus } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const DEPTH_LABELS: Record<number, string> = {
|
||||||
|
1: 'Direct follows',
|
||||||
|
2: 'Follows of follows'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SocialGraphFilterProps {
|
||||||
|
temporaryProximity: number | null
|
||||||
|
temporaryIncludeMode: boolean
|
||||||
|
onTemporaryProximityChange: (level: number | null) => void
|
||||||
|
onTemporaryIncludeModeChange: (include: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SocialGraphFilter({
|
||||||
|
temporaryProximity,
|
||||||
|
temporaryIncludeMode,
|
||||||
|
onTemporaryProximityChange,
|
||||||
|
onTemporaryIncludeModeChange
|
||||||
|
}: SocialGraphFilterProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { graphPubkeyCount, isLoading } = useSocialGraphFilter()
|
||||||
|
|
||||||
|
const isEnabled = temporaryProximity !== null
|
||||||
|
const depth = temporaryProximity ?? 1
|
||||||
|
|
||||||
|
const handleToggle = (enabled: boolean) => {
|
||||||
|
onTemporaryProximityChange(enabled ? 1 : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIncrease = () => {
|
||||||
|
if (depth < 2) {
|
||||||
|
onTemporaryProximityChange(depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDecrease = () => {
|
||||||
|
if (depth > 1) {
|
||||||
|
onTemporaryProximityChange(depth - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="social-graph-filter" className="font-medium">
|
||||||
|
{t('Social graph filter')}
|
||||||
|
</Label>
|
||||||
|
<Switch id="social-graph-filter" checked={isEnabled} onCheckedChange={handleToggle} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEnabled && (
|
||||||
|
<>
|
||||||
|
{/* Include/Exclude toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={temporaryIncludeMode ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => onTemporaryIncludeModeChange(true)}
|
||||||
|
>
|
||||||
|
{t('Include')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={!temporaryIncludeMode ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => onTemporaryIncludeModeChange(false)}
|
||||||
|
>
|
||||||
|
{t('Exclude')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Depth stepper */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{t(DEPTH_LABELS[depth])}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
{t('Loading...')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t('{{count}} users', { count: graphPubkeyCount })
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleDecrease}
|
||||||
|
disabled={depth <= 1}
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="w-6 text-center text-sm font-medium">{depth}</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleIncrease}
|
||||||
|
disabled={depth >= 2}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode description */}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{temporaryIncludeMode
|
||||||
|
? t('Only show notes from users in your social graph')
|
||||||
|
: t('Hide notes from users in your social graph')}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/components/StuffStats/KeyboardShortcut.tsx
Normal file
13
src/components/StuffStats/KeyboardShortcut.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
|
|
||||||
|
export default function KeyboardShortcut({ shortcut }: { shortcut: string }) {
|
||||||
|
const { isEnabled } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
if (!isEnabled) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">
|
||||||
|
{shortcut}
|
||||||
|
</kbd>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import Emoji from '../Emoji'
|
import Emoji from '../Emoji'
|
||||||
import EmojiPicker from '../EmojiPicker'
|
import EmojiPicker from '../EmojiPicker'
|
||||||
import SuggestedEmojis from '../SuggestedEmojis'
|
import SuggestedEmojis from '../SuggestedEmojis'
|
||||||
|
import KeyboardShortcut from './KeyboardShortcut'
|
||||||
import { formatCount } from './utils'
|
import { formatCount } from './utils'
|
||||||
|
|
||||||
export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
||||||
@@ -128,7 +129,7 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
|||||||
<>
|
<>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
|
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
|
||||||
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">R</kbd>
|
<KeyboardShortcut shortcut="R" />
|
||||||
</span>
|
</span>
|
||||||
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
||||||
</>
|
</>
|
||||||
@@ -136,7 +137,7 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
|||||||
<>
|
<>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
<SmilePlus />
|
<SmilePlus />
|
||||||
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">R</kbd>
|
<KeyboardShortcut shortcut="R" />
|
||||||
</span>
|
</span>
|
||||||
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Event } from 'nostr-tools'
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import PostEditor from '../PostEditor'
|
import PostEditor from '../PostEditor'
|
||||||
|
import KeyboardShortcut from './KeyboardShortcut'
|
||||||
import { formatCount } from './utils'
|
import { formatCount } from './utils'
|
||||||
|
|
||||||
export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
||||||
@@ -70,7 +71,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
|||||||
>
|
>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
<MessageCircle />
|
<MessageCircle />
|
||||||
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">r</kbd>
|
<KeyboardShortcut shortcut="r" />
|
||||||
</span>
|
</span>
|
||||||
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
|
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Event } from 'nostr-tools'
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import PostEditor from '../PostEditor'
|
import PostEditor from '../PostEditor'
|
||||||
|
import KeyboardShortcut from './KeyboardShortcut'
|
||||||
import { formatCount } from './utils'
|
import { formatCount } from './utils'
|
||||||
|
|
||||||
export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
||||||
@@ -93,7 +94,7 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
|||||||
>
|
>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
|
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
|
||||||
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">p</kbd>
|
<KeyboardShortcut shortcut="p" />
|
||||||
</span>
|
</span>
|
||||||
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
|
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 're
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import ZapDialog from '../ZapDialog'
|
import ZapDialog from '../ZapDialog'
|
||||||
|
import KeyboardShortcut from './KeyboardShortcut'
|
||||||
|
|
||||||
export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -153,7 +154,7 @@ export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
|||||||
) : (
|
) : (
|
||||||
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
|
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
|
||||||
)}
|
)}
|
||||||
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">z</kbd>
|
<KeyboardShortcut shortcut="z" />
|
||||||
</span>
|
</span>
|
||||||
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
|
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,22 +4,25 @@ import { useFetchProfile } from '@/hooks'
|
|||||||
import { toProfile } from '@/lib/link'
|
import { toProfile } from '@/lib/link'
|
||||||
import { cn, isTouchDevice } from '@/lib/utils'
|
import { cn, isTouchDevice } from '@/lib/utils'
|
||||||
import { SecondaryPageLink } from '@/PageManager'
|
import { SecondaryPageLink } from '@/PageManager'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import NpubQrCode from '../NpubQrCode'
|
||||||
import ProfileCard from '../ProfileCard'
|
import ProfileCard from '../ProfileCard'
|
||||||
import TextWithEmojis from '../TextWithEmojis'
|
import TextWithEmojis from '../TextWithEmojis'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
export default function Username({
|
export default function Username({
|
||||||
userId,
|
userId,
|
||||||
showAt = false,
|
showAt = false,
|
||||||
className,
|
className,
|
||||||
skeletonClassName,
|
skeletonClassName,
|
||||||
withoutSkeleton = false
|
withoutSkeleton = false,
|
||||||
|
showQrCode = true
|
||||||
}: {
|
}: {
|
||||||
userId: string
|
userId: string
|
||||||
showAt?: boolean
|
showAt?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
skeletonClassName?: string
|
skeletonClassName?: string
|
||||||
withoutSkeleton?: boolean
|
withoutSkeleton?: boolean
|
||||||
|
showQrCode?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { profile, isFetching } = useFetchProfile(userId)
|
const { profile, isFetching } = useFetchProfile(userId)
|
||||||
const supportTouch = useMemo(() => isTouchDevice(), [])
|
const supportTouch = useMemo(() => isTouchDevice(), [])
|
||||||
@@ -32,16 +35,21 @@ export default function Username({
|
|||||||
}
|
}
|
||||||
if (!profile) return null
|
if (!profile) return null
|
||||||
|
|
||||||
|
const usernameLink = (
|
||||||
|
<SecondaryPageLink
|
||||||
|
to={toProfile(userId)}
|
||||||
|
className="truncate hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{showAt && '@'}
|
||||||
|
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
|
||||||
|
</SecondaryPageLink>
|
||||||
|
)
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<div className={className}>
|
<div className={cn('flex items-center gap-1', className)}>
|
||||||
<SecondaryPageLink
|
{usernameLink}
|
||||||
to={toProfile(userId)}
|
{showQrCode && <NpubQrCode pubkey={userId} />}
|
||||||
className="truncate hover:underline"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{showAt && '@'}
|
|
||||||
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
|
|
||||||
</SecondaryPageLink>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,13 +72,15 @@ export function SimpleUsername({
|
|||||||
showAt = false,
|
showAt = false,
|
||||||
className,
|
className,
|
||||||
skeletonClassName,
|
skeletonClassName,
|
||||||
withoutSkeleton = false
|
withoutSkeleton = false,
|
||||||
|
showQrCode = true
|
||||||
}: {
|
}: {
|
||||||
userId: string
|
userId: string
|
||||||
showAt?: boolean
|
showAt?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
skeletonClassName?: string
|
skeletonClassName?: string
|
||||||
withoutSkeleton?: boolean
|
withoutSkeleton?: boolean
|
||||||
|
showQrCode?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { profile, isFetching } = useFetchProfile(userId)
|
const { profile, isFetching } = useFetchProfile(userId)
|
||||||
if (!profile && isFetching && !withoutSkeleton) {
|
if (!profile && isFetching && !withoutSkeleton) {
|
||||||
@@ -85,9 +95,12 @@ export function SimpleUsername({
|
|||||||
const { username, emojis } = profile
|
const { username, emojis } = profile
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={cn('flex items-center gap-1', className)}>
|
||||||
{showAt && '@'}
|
<span className="truncate">
|
||||||
<TextWithEmojis text={username} emojis={emojis} emojiClassName="mb-1" />
|
{showAt && '@'}
|
||||||
|
<TextWithEmojis text={username} emojis={emojis} emojiClassName="mb-1" />
|
||||||
|
</span>
|
||||||
|
{showQrCode && <NpubQrCode pubkey={userId} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export const StorageKey = {
|
|||||||
DM_ENCRYPTION_PREFERENCES: 'dmEncryptionPreferences',
|
DM_ENCRYPTION_PREFERENCES: 'dmEncryptionPreferences',
|
||||||
DM_LAST_SEEN_TIMESTAMP: 'dmLastSeenTimestamp',
|
DM_LAST_SEEN_TIMESTAMP: 'dmLastSeenTimestamp',
|
||||||
GRAPH_QUERIES_ENABLED: 'graphQueriesEnabled',
|
GRAPH_QUERIES_ENABLED: 'graphQueriesEnabled',
|
||||||
|
SOCIAL_GRAPH_PROXIMITY: 'socialGraphProximity',
|
||||||
|
SOCIAL_GRAPH_INCLUDE_MODE: 'socialGraphIncludeMode',
|
||||||
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
|
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
|
||||||
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
|
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
|
||||||
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
|
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const MePage = forwardRef<TPageRef>((_, ref) => {
|
|||||||
className="text-xl font-semibold text-wrap"
|
className="text-xl font-semibold text-wrap"
|
||||||
userId={pubkey}
|
userId={pubkey}
|
||||||
skeletonClassName="h-6 w-32"
|
skeletonClassName="h-6 w-32"
|
||||||
|
showQrCode={false}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1 mt-1">
|
<div className="flex gap-1 mt-1">
|
||||||
<PubkeyCopy pubkey={pubkey} />
|
<PubkeyCopy pubkey={pubkey} />
|
||||||
|
|||||||
@@ -28,5 +28,5 @@ export default function PinnedFeed() {
|
|||||||
init()
|
init()
|
||||||
}, [pubkey, pinnedPubkeySet])
|
}, [pubkey, pinnedPubkeySet])
|
||||||
|
|
||||||
return <NormalFeed subRequests={subRequests} isMainFeed />
|
return <NormalFeed subRequests={subRequests} isMainFeed enableSocialGraphFilter />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default function RelaysFeed() {
|
|||||||
areAlgoRelays={areAlgoRelays}
|
areAlgoRelays={areAlgoRelays}
|
||||||
isMainFeed
|
isMainFeed
|
||||||
showRelayCloseReason
|
showRelayCloseReason
|
||||||
|
enableSocialGraphFilter
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type TDMContext = {
|
|||||||
setPreferNip44: (prefer: boolean) => void
|
setPreferNip44: (prefer: boolean) => void
|
||||||
isNewConversation: boolean
|
isNewConversation: boolean
|
||||||
clearNewConversationFlag: () => void
|
clearNewConversationFlag: () => void
|
||||||
|
dismissProvisionalConversation: () => void
|
||||||
// Unread tracking
|
// Unread tracking
|
||||||
totalUnreadCount: number
|
totalUnreadCount: number
|
||||||
hasNewMessages: boolean
|
hasNewMessages: boolean
|
||||||
@@ -85,6 +86,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [preferNip44, setPreferNip44State] = useState(() => storage.getPreferNip44())
|
const [preferNip44, setPreferNip44State] = useState(() => storage.getPreferNip44())
|
||||||
const [hasMoreConversations, setHasMoreConversations] = useState(false)
|
const [hasMoreConversations, setHasMoreConversations] = useState(false)
|
||||||
const [isNewConversation, setIsNewConversation] = useState(false)
|
const [isNewConversation, setIsNewConversation] = useState(false)
|
||||||
|
const [provisionalPubkey, setProvisionalPubkey] = useState<string | null>(null)
|
||||||
const [deletedState, setDeletedState] = useState<TDMDeletedState | null>(null)
|
const [deletedState, setDeletedState] = useState<TDMDeletedState | null>(null)
|
||||||
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
|
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
||||||
@@ -577,6 +579,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Start a new conversation - marks it as new for UI effects (pulsing settings button)
|
// Start a new conversation - marks it as new for UI effects (pulsing settings button)
|
||||||
|
// Creates a provisional conversation that appears in the list immediately
|
||||||
const startConversation = useCallback(
|
const startConversation = useCallback(
|
||||||
(partnerPubkey: string) => {
|
(partnerPubkey: string) => {
|
||||||
// Check if this is a new conversation (not in existing list)
|
// Check if this is a new conversation (not in existing list)
|
||||||
@@ -585,6 +588,18 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
if (!existingConversation) {
|
if (!existingConversation) {
|
||||||
setIsNewConversation(true)
|
setIsNewConversation(true)
|
||||||
|
setProvisionalPubkey(partnerPubkey)
|
||||||
|
// Add a provisional conversation to the list so it appears immediately
|
||||||
|
const provisionalConversation: TConversation = {
|
||||||
|
partnerPubkey,
|
||||||
|
lastMessageAt: Math.floor(Date.now() / 1000),
|
||||||
|
lastMessagePreview: '',
|
||||||
|
unreadCount: 0,
|
||||||
|
preferredEncryption: null
|
||||||
|
}
|
||||||
|
// Add to front of both lists
|
||||||
|
setAllConversations((prev) => [provisionalConversation, ...prev])
|
||||||
|
setConversations((prev) => [provisionalConversation, ...prev])
|
||||||
}
|
}
|
||||||
// Clear messages and select the conversation
|
// Clear messages and select the conversation
|
||||||
setMessages([])
|
setMessages([])
|
||||||
@@ -597,6 +612,25 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setIsNewConversation(false)
|
setIsNewConversation(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Dismiss a provisional conversation (remove from list without sending any messages)
|
||||||
|
const dismissProvisionalConversation = useCallback(() => {
|
||||||
|
if (!provisionalPubkey) return
|
||||||
|
|
||||||
|
// Remove from conversation lists
|
||||||
|
setAllConversations((prev) => prev.filter((c) => c.partnerPubkey !== provisionalPubkey))
|
||||||
|
setConversations((prev) => prev.filter((c) => c.partnerPubkey !== provisionalPubkey))
|
||||||
|
|
||||||
|
// Clear provisional state
|
||||||
|
setProvisionalPubkey(null)
|
||||||
|
setIsNewConversation(false)
|
||||||
|
|
||||||
|
// Deselect if this was the current conversation
|
||||||
|
if (currentConversation === provisionalPubkey) {
|
||||||
|
setCurrentConversation(null)
|
||||||
|
setMessages([])
|
||||||
|
}
|
||||||
|
}, [provisionalPubkey, currentConversation])
|
||||||
|
|
||||||
// Reload the current conversation by clearing its cached state
|
// Reload the current conversation by clearing its cached state
|
||||||
const reloadConversation = useCallback(() => {
|
const reloadConversation = useCallback(() => {
|
||||||
if (!currentConversation) return
|
if (!currentConversation) return
|
||||||
@@ -708,8 +742,14 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Clear provisional state - conversation is now permanent
|
||||||
|
if (provisionalPubkey === currentConversation) {
|
||||||
|
setProvisionalPubkey(null)
|
||||||
|
setIsNewConversation(false)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[pubkey, encryption, currentConversation, relayList, conversations, preferNip44]
|
[pubkey, encryption, currentConversation, relayList, conversations, preferNip44, provisionalPubkey]
|
||||||
)
|
)
|
||||||
|
|
||||||
const setPreferNip44 = useCallback((prefer: boolean) => {
|
const setPreferNip44 = useCallback((prefer: boolean) => {
|
||||||
@@ -927,6 +967,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setPreferNip44,
|
setPreferNip44,
|
||||||
isNewConversation,
|
isNewConversation,
|
||||||
clearNewConversationFlag,
|
clearNewConversationFlag,
|
||||||
|
dismissProvisionalConversation,
|
||||||
// Unread tracking
|
// Unread tracking
|
||||||
totalUnreadCount,
|
totalUnreadCount,
|
||||||
hasNewMessages,
|
hasNewMessages,
|
||||||
|
|||||||
114
src/providers/SocialGraphFilterProvider.tsx
Normal file
114
src/providers/SocialGraphFilterProvider.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useFetchFollowGraph } from '@/hooks/useFetchFollowGraph'
|
||||||
|
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
|
||||||
|
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||||
|
import { useNostr } from './NostrProvider'
|
||||||
|
|
||||||
|
type TSocialGraphFilterContext = {
|
||||||
|
// Settings
|
||||||
|
proximityLevel: number | null // null = disabled, 1 = direct follows, 2 = follows of follows
|
||||||
|
includeMode: boolean // true = include only graph members, false = exclude graph members
|
||||||
|
updateProximityLevel: (level: number | null) => void
|
||||||
|
updateIncludeMode: (include: boolean) => void
|
||||||
|
|
||||||
|
// Cached data
|
||||||
|
graphPubkeys: Set<string> // Pre-computed Set for O(1) lookup
|
||||||
|
graphPubkeyCount: number
|
||||||
|
isLoading: boolean
|
||||||
|
|
||||||
|
// Filter function for use in feeds
|
||||||
|
isPubkeyAllowed: (pubkey: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SocialGraphFilterContext = createContext<TSocialGraphFilterContext | undefined>(undefined)
|
||||||
|
|
||||||
|
export const useSocialGraphFilter = () => {
|
||||||
|
const context = useContext(SocialGraphFilterContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSocialGraphFilter must be used within a SocialGraphFilterProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SocialGraphFilterProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const [proximityLevel, setProximityLevel] = useState<number | null>(
|
||||||
|
storage.getSocialGraphProximity()
|
||||||
|
)
|
||||||
|
const [includeMode, setIncludeMode] = useState<boolean>(storage.getSocialGraphIncludeMode())
|
||||||
|
|
||||||
|
// Fetch the follow graph when proximity is enabled
|
||||||
|
const { pubkeysByDepth, isLoading } = useFetchFollowGraph(
|
||||||
|
proximityLevel !== null ? pubkey : null,
|
||||||
|
proximityLevel ?? 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build the Set of graph pubkeys (always includes self)
|
||||||
|
const graphPubkeys = useMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
|
||||||
|
// Always include self in the graph
|
||||||
|
if (pubkey) {
|
||||||
|
set.add(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pubkeys up to selected depth
|
||||||
|
if (proximityLevel && pubkeysByDepth.length) {
|
||||||
|
for (let depth = 0; depth < proximityLevel && depth < pubkeysByDepth.length; depth++) {
|
||||||
|
pubkeysByDepth[depth].forEach((pk) => set.add(pk))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return set
|
||||||
|
}, [pubkey, proximityLevel, pubkeysByDepth])
|
||||||
|
|
||||||
|
const graphPubkeyCount = graphPubkeys.size
|
||||||
|
|
||||||
|
const updateProximityLevel = useCallback((level: number | null) => {
|
||||||
|
storage.setSocialGraphProximity(level)
|
||||||
|
setProximityLevel(level)
|
||||||
|
dispatchSettingsChanged()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateIncludeMode = useCallback((include: boolean) => {
|
||||||
|
storage.setSocialGraphIncludeMode(include)
|
||||||
|
setIncludeMode(include)
|
||||||
|
dispatchSettingsChanged()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isPubkeyAllowed = useCallback(
|
||||||
|
(targetPubkey: string): boolean => {
|
||||||
|
// If filter disabled, allow all
|
||||||
|
if (proximityLevel === null) return true
|
||||||
|
|
||||||
|
// If loading, allow all (graceful degradation)
|
||||||
|
if (isLoading) return true
|
||||||
|
|
||||||
|
// Always allow self
|
||||||
|
if (targetPubkey === pubkey) return true
|
||||||
|
|
||||||
|
const isInGraph = graphPubkeys.has(targetPubkey)
|
||||||
|
|
||||||
|
// Include mode: only allow if in graph
|
||||||
|
// Exclude mode: only allow if NOT in graph
|
||||||
|
return includeMode ? isInGraph : !isInGraph
|
||||||
|
},
|
||||||
|
[proximityLevel, isLoading, graphPubkeys, includeMode, pubkey]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SocialGraphFilterContext.Provider
|
||||||
|
value={{
|
||||||
|
proximityLevel,
|
||||||
|
includeMode,
|
||||||
|
updateProximityLevel,
|
||||||
|
updateIncludeMode,
|
||||||
|
graphPubkeys,
|
||||||
|
graphPubkeyCount,
|
||||||
|
isLoading,
|
||||||
|
isPubkeyAllowed
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SocialGraphFilterContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -61,6 +61,8 @@ class LocalStorageService {
|
|||||||
private preferNip44: boolean = false
|
private preferNip44: boolean = false
|
||||||
private dmConversationFilter: 'all' | 'follows' = 'all'
|
private dmConversationFilter: 'all' | 'follows' = 'all'
|
||||||
private graphQueriesEnabled: boolean = true
|
private graphQueriesEnabled: boolean = true
|
||||||
|
private socialGraphProximity: number | null = null
|
||||||
|
private socialGraphIncludeMode: boolean = true // true = include only, false = exclude
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!LocalStorageService.instance) {
|
if (!LocalStorageService.instance) {
|
||||||
@@ -251,6 +253,17 @@ class LocalStorageService {
|
|||||||
this.graphQueriesEnabled =
|
this.graphQueriesEnabled =
|
||||||
window.localStorage.getItem(StorageKey.GRAPH_QUERIES_ENABLED) !== 'false'
|
window.localStorage.getItem(StorageKey.GRAPH_QUERIES_ENABLED) !== 'false'
|
||||||
|
|
||||||
|
const socialGraphProximityStr = window.localStorage.getItem(StorageKey.SOCIAL_GRAPH_PROXIMITY)
|
||||||
|
if (socialGraphProximityStr) {
|
||||||
|
const parsed = parseInt(socialGraphProximityStr)
|
||||||
|
if (!isNaN(parsed) && parsed >= 1 && parsed <= 2) {
|
||||||
|
this.socialGraphProximity = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socialGraphIncludeMode =
|
||||||
|
window.localStorage.getItem(StorageKey.SOCIAL_GRAPH_INCLUDE_MODE) !== 'false'
|
||||||
|
|
||||||
// Clean up deprecated data
|
// Clean up deprecated data
|
||||||
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
|
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
|
||||||
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
||||||
@@ -651,6 +664,28 @@ class LocalStorageService {
|
|||||||
this.graphQueriesEnabled = enabled
|
this.graphQueriesEnabled = enabled
|
||||||
window.localStorage.setItem(StorageKey.GRAPH_QUERIES_ENABLED, enabled.toString())
|
window.localStorage.setItem(StorageKey.GRAPH_QUERIES_ENABLED, enabled.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSocialGraphProximity(): number | null {
|
||||||
|
return this.socialGraphProximity
|
||||||
|
}
|
||||||
|
|
||||||
|
setSocialGraphProximity(depth: number | null) {
|
||||||
|
this.socialGraphProximity = depth
|
||||||
|
if (depth === null) {
|
||||||
|
window.localStorage.removeItem(StorageKey.SOCIAL_GRAPH_PROXIMITY)
|
||||||
|
} else {
|
||||||
|
window.localStorage.setItem(StorageKey.SOCIAL_GRAPH_PROXIMITY, depth.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSocialGraphIncludeMode(): boolean {
|
||||||
|
return this.socialGraphIncludeMode
|
||||||
|
}
|
||||||
|
|
||||||
|
setSocialGraphIncludeMode(include: boolean) {
|
||||||
|
this.socialGraphIncludeMode = include
|
||||||
|
window.localStorage.setItem(StorageKey.SOCIAL_GRAPH_INCLUDE_MODE, include.toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const instance = new LocalStorageService()
|
const instance = new LocalStorageService()
|
||||||
|
|||||||
Reference in New Issue
Block a user