+
+
+
+
+
+ {isEnabled && (
+ <>
+ {/* Include/Exclude toggle */}
+
+
+
+
+
+ {/* Depth stepper */}
+
+
+
{t(DEPTH_LABELS[depth])}
+
+ {isLoading ? (
+
+
+ {t('Loading...')}
+
+ ) : (
+ t('{{count}} users', { count: graphPubkeyCount })
+ )}
+
+
+
+
+
{depth}
+
+
+
+
+ {/* Mode description */}
+
+ {temporaryIncludeMode
+ ? t('Only show notes from users in your social graph')
+ : t('Hide notes from users in your social graph')}
+
+ >
+ )}
+
+ )
+}
diff --git a/src/components/StuffStats/KeyboardShortcut.tsx b/src/components/StuffStats/KeyboardShortcut.tsx
new file mode 100644
index 00000000..c2c3de14
--- /dev/null
+++ b/src/components/StuffStats/KeyboardShortcut.tsx
@@ -0,0 +1,13 @@
+import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
+
+export default function KeyboardShortcut({ shortcut }: { shortcut: string }) {
+ const { isEnabled } = useKeyboardNavigation()
+
+ if (!isEnabled) return null
+
+ return (
+
-
e.stopPropagation()}
- >
- {showAt && '@'}
-
-
+
+ {usernameLink}
+ {showQrCode && }
)
@@ -64,13 +72,15 @@ export function SimpleUsername({
showAt = false,
className,
skeletonClassName,
- withoutSkeleton = false
+ withoutSkeleton = false,
+ showQrCode = true
}: {
userId: string
showAt?: boolean
className?: string
skeletonClassName?: string
withoutSkeleton?: boolean
+ showQrCode?: boolean
}) {
const { profile, isFetching } = useFetchProfile(userId)
if (!profile && isFetching && !withoutSkeleton) {
@@ -85,9 +95,12 @@ export function SimpleUsername({
const { username, emojis } = profile
return (
-
- {showAt && '@'}
-
+
+
+ {showAt && '@'}
+
+
+ {showQrCode && }
)
}
diff --git a/src/constants.ts b/src/constants.ts
index b02f77c3..98831733 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -48,6 +48,8 @@ export const StorageKey = {
DM_ENCRYPTION_PREFERENCES: 'dmEncryptionPreferences',
DM_LAST_SEEN_TIMESTAMP: 'dmLastSeenTimestamp',
GRAPH_QUERIES_ENABLED: 'graphQueriesEnabled',
+ SOCIAL_GRAPH_PROXIMITY: 'socialGraphProximity',
+ SOCIAL_GRAPH_INCLUDE_MODE: 'socialGraphIncludeMode',
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
diff --git a/src/pages/primary/MePage/index.tsx b/src/pages/primary/MePage/index.tsx
index 52034ba7..a6af2f92 100644
--- a/src/pages/primary/MePage/index.tsx
+++ b/src/pages/primary/MePage/index.tsx
@@ -62,6 +62,7 @@ const MePage = forwardRef
((_, ref) => {
className="text-xl font-semibold text-wrap"
userId={pubkey}
skeletonClassName="h-6 w-32"
+ showQrCode={false}
/>
diff --git a/src/pages/primary/NoteListPage/PinnedFeed.tsx b/src/pages/primary/NoteListPage/PinnedFeed.tsx
index 2238553a..0d9e6ef6 100644
--- a/src/pages/primary/NoteListPage/PinnedFeed.tsx
+++ b/src/pages/primary/NoteListPage/PinnedFeed.tsx
@@ -28,5 +28,5 @@ export default function PinnedFeed() {
init()
}, [pubkey, pinnedPubkeySet])
- return
+ return
}
diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx
index 05604deb..ad6e2795 100644
--- a/src/pages/primary/NoteListPage/RelaysFeed.tsx
+++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx
@@ -28,6 +28,7 @@ export default function RelaysFeed() {
areAlgoRelays={areAlgoRelays}
isMainFeed
showRelayCloseReason
+ enableSocialGraphFilter
/>
)
}
diff --git a/src/providers/DMProvider.tsx b/src/providers/DMProvider.tsx
index cb629073..77be764d 100644
--- a/src/providers/DMProvider.tsx
+++ b/src/providers/DMProvider.tsx
@@ -33,6 +33,7 @@ type TDMContext = {
setPreferNip44: (prefer: boolean) => void
isNewConversation: boolean
clearNewConversationFlag: () => void
+ dismissProvisionalConversation: () => void
// Unread tracking
totalUnreadCount: number
hasNewMessages: boolean
@@ -85,6 +86,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
const [preferNip44, setPreferNip44State] = useState(() => storage.getPreferNip44())
const [hasMoreConversations, setHasMoreConversations] = useState(false)
const [isNewConversation, setIsNewConversation] = useState(false)
+ const [provisionalPubkey, setProvisionalPubkey] = useState
(null)
const [deletedState, setDeletedState] = useState(null)
const [selectedMessages, setSelectedMessages] = useState>(new Set())
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)
+ // Creates a provisional conversation that appears in the list immediately
const startConversation = useCallback(
(partnerPubkey: string) => {
// Check if this is a new conversation (not in existing list)
@@ -585,6 +588,18 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
)
if (!existingConversation) {
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
setMessages([])
@@ -597,6 +612,25 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
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
const reloadConversation = useCallback(() => {
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) => {
@@ -927,6 +967,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
setPreferNip44,
isNewConversation,
clearNewConversationFlag,
+ dismissProvisionalConversation,
// Unread tracking
totalUnreadCount,
hasNewMessages,
diff --git a/src/providers/SocialGraphFilterProvider.tsx b/src/providers/SocialGraphFilterProvider.tsx
new file mode 100644
index 00000000..60de37fc
--- /dev/null
+++ b/src/providers/SocialGraphFilterProvider.tsx
@@ -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 // Pre-computed Set for O(1) lookup
+ graphPubkeyCount: number
+ isLoading: boolean
+
+ // Filter function for use in feeds
+ isPubkeyAllowed: (pubkey: string) => boolean
+}
+
+const SocialGraphFilterContext = createContext(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(
+ storage.getSocialGraphProximity()
+ )
+ const [includeMode, setIncludeMode] = useState(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()
+
+ // 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 (
+
+ {children}
+
+ )
+}
diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts
index 1991523b..aa9069f2 100644
--- a/src/services/local-storage.service.ts
+++ b/src/services/local-storage.service.ts
@@ -61,6 +61,8 @@ class LocalStorageService {
private preferNip44: boolean = false
private dmConversationFilter: 'all' | 'follows' = 'all'
private graphQueriesEnabled: boolean = true
+ private socialGraphProximity: number | null = null
+ private socialGraphIncludeMode: boolean = true // true = include only, false = exclude
constructor() {
if (!LocalStorageService.instance) {
@@ -251,6 +253,17 @@ class LocalStorageService {
this.graphQueriesEnabled =
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
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
@@ -651,6 +664,28 @@ class LocalStorageService {
this.graphQueriesEnabled = enabled
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()