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:
woikos
2026-01-05 20:38:28 +01:00
parent 8a9795a53a
commit 08f75a902d
23 changed files with 572 additions and 78 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"additionalDirectories": [
"/home/mleku/src/git.mleku.dev/mleku/coracle"
]
}
}

View File

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

View File

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

View File

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

View File

@@ -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}
/> />
)} )}
</> </>

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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>}
</> </>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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} />

View File

@@ -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 />
} }

View File

@@ -28,6 +28,7 @@ export default function RelaysFeed() {
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
isMainFeed isMainFeed
showRelayCloseReason showRelayCloseReason
enableSocialGraphFilter
/> />
) )
} }

View File

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

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

View File

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