From 5c5774ffc595948cfe04b17fd87aa8c90a29a39c Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 20:01:27 -0500 Subject: [PATCH 1/2] fix: four more findings from the audit Settings showed two privacy switches (Public Profile, Show Activity Status) that were local state wired to nothing, and a Delete Account button whose only outcome was a toast saying deletion is not implemented; all three are gone rather than promising something the chain cannot do. The avatar-settings hook now ignores a load that resolves after the user changed. The crypto-price hook kept its skip-cache flag in state, so refetch ran the effect twice, once bypassing the cache and once again when the flag reset; it is a ref now. Sending a DM treated a failed invite lookup as no invite and created another on every transient error; an unknown answer now skips creation. --- app/settings/page.tsx | 49 -------------------------- hooks/use-avatar.ts | 9 +++-- hooks/use-crypto-price.ts | 17 ++++----- lib/services/direct-message-service.ts | 16 +++++---- 4 files changed, 26 insertions(+), 65 deletions(-) diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 91ad8361..90668d1e 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -15,7 +15,6 @@ import { MoonIcon, SunIcon, ComputerDesktopIcon, - ExclamationTriangleIcon, UserGroupIcon, UserPlusIcon, LockClosedIcon, @@ -31,7 +30,6 @@ import * as RadioGroup from '@radix-ui/react-radio-group' import { SettingsSwitch } from '@/components/settings/settings-switch' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' -import toast from 'react-hot-toast' import { KeyBackupSettings } from '@/components/settings/key-backup-settings' import { BlockedUsersSettings } from '@/components/settings/blocked-users' import { PrivateFeedSettings } from '@/components/settings/private-feed-settings' @@ -133,12 +131,6 @@ function SettingsPage() { } } - // Privacy settings - const [privacySettings, setPrivacySettings] = useState({ - publicProfile: true, - showActivity: true, - }) - // Account creation date from profile const [accountCreatedAt, setAccountCreatedAt] = useState(null) @@ -206,13 +198,6 @@ function SettingsPage() { router.back() } - // TODO: Implement account deletion - const handleDeleteAccount = () => { - if (confirm('Are you sure you want to delete your account? This action cannot be undone.')) { - toast.error('Account deletion is not yet implemented') - } - } - const renderMainSettings = () => (
{visibleSections.map((section) => ( @@ -313,14 +298,6 @@ function SettingsPage() { Log Out -
@@ -381,32 +358,6 @@ function SettingsPage() {

Privacy

-
-
-

Public Profile

-

Allow anyone to view your profile

-
- - setPrivacySettings(prev => ({ ...prev, publicProfile: checked })) - } - /> -
- -
-
-

Show Activity Status

-

Let others see when you're active

-
- - setPrivacySettings(prev => ({ ...prev, showActivity: checked })) - } - /> -
-

Link Previews

diff --git a/hooks/use-avatar.ts b/hooks/use-avatar.ts index 560262e7..b997e041 100644 --- a/hooks/use-avatar.ts +++ b/hooks/use-avatar.ts @@ -1,7 +1,7 @@ 'use client' import { logger } from '@/lib/logger'; -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import type { DiceBearStyle } from '@/lib/services/unified-profile-service' export interface AvatarSettings { @@ -38,8 +38,11 @@ export function useAvatarSettings(userId: string): UseAvatarSettingsResult { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) + // Bumped per load so a slow response for a previous user cannot overwrite a newer one. + const requestRef = useRef(0) const loadSettings = useCallback(async () => { + const request = ++requestRef.current if (!userId) { setLoading(false) return @@ -53,6 +56,7 @@ export function useAvatarSettings(userId: string): UseAvatarSettingsResult { // Get profile to extract avatar settings const profile = await unifiedProfileService.getProfile(userId) + if (request !== requestRef.current) return if (profile?.avatar) { // Parse the avatar field to extract settings @@ -119,10 +123,11 @@ export function useAvatarSettings(userId: string): UseAvatarSettingsResult { }) } } catch (err) { + if (request !== requestRef.current) return logger.error('useAvatarSettings: Error loading settings:', err) setError('Failed to load avatar settings') } finally { - setLoading(false) + if (request === requestRef.current) setLoading(false) } }, [userId]) diff --git a/hooks/use-crypto-price.ts b/hooks/use-crypto-price.ts index 4ea51362..29c3490c 100644 --- a/hooks/use-crypto-price.ts +++ b/hooks/use-crypto-price.ts @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { cryptoPriceService } from '@/lib/services/crypto-price-service' export interface UseCryptoPriceResult { @@ -30,10 +30,11 @@ export function useCryptoPrice( const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [fetchTrigger, setFetchTrigger] = useState(0) - const [skipCache, setSkipCache] = useState(false) + // A ref, not state: the effect reads it once per run and it must not itself re-run the effect. + const skipCacheRef = useRef(false) const refetch = useCallback(() => { - setSkipCache(true) + skipCacheRef.current = true setFetchTrigger((prev) => prev + 1) }, []) @@ -60,6 +61,9 @@ export function useCryptoPrice( let cancelled = false + const skipCache = skipCacheRef.current + skipCacheRef.current = false + const fetchPrice = async () => { setIsLoading(true) setError(null) @@ -87,10 +91,7 @@ export function useCryptoPrice( setPriceSources([]) setError(err instanceof Error ? err.message : 'Failed to fetch price') } finally { - if (!cancelled) { - setIsLoading(false) - setSkipCache(false) - } + if (!cancelled) setIsLoading(false) } } @@ -101,7 +102,7 @@ export function useCryptoPrice( return () => { cancelled = true } - }, [fiatAmount, fiatCurrency, scheme, fetchTrigger, skipCache]) + }, [fiatAmount, fiatCurrency, scheme, fetchTrigger]) return { cryptoAmount, diff --git a/lib/services/direct-message-service.ts b/lib/services/direct-message-service.ts index dd99849b..4964bc98 100644 --- a/lib/services/direct-message-service.ts +++ b/lib/services/direct-message-service.ts @@ -73,10 +73,12 @@ class DirectMessageService { } } - // 4. Check if we need to create a conversation invite + // 4. Create the conversation invite if this is the first message that way. + // An unknown answer (lookup failed) skips creation: a missing invite + // costs one inbox hint, a duplicate costs credits every time. const existingInvite = await this.getMyInviteToRecipient(senderId, recipientId) - if (!existingInvite) { + if (existingInvite === null) { // Create conversation invite const senderPubKey = getPublicKeyFromPrivate(privateKey) @@ -602,10 +604,11 @@ class DirectMessageService { /** * Get my invite to a recipient */ + /** The sender's invite to the recipient; `null` when there is none, `undefined` when the lookup failed. */ private async getMyInviteToRecipient( senderId: string, recipientId: string - ): Promise | null> { + ): Promise | null | undefined> { try { const sdk = await getEvoSdk() @@ -622,8 +625,9 @@ class DirectMessageService { const docs = this.extractDocuments(response) return docs[0] || null - } catch { - return null + } catch (error) { + logger.warn('Could not check for an existing conversation invite:', error) + return undefined } } @@ -634,7 +638,7 @@ class DirectMessageService { senderId: string, recipientId: string ): Promise | null> { - return this.getMyInviteToRecipient(senderId, recipientId) + return (await this.getMyInviteToRecipient(senderId, recipientId)) ?? null } /** From d1ff6222117027c1f313270839d50086e735ff9b Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 20:08:30 -0500 Subject: [PATCH 2/2] fix(price): clear the skip-cache flag before the early returns A refetch requested while the inputs were invalid left the flag set, so the next valid run bypassed the cache. The flag is read and cleared at the top of the effect now. Also drops a doubled doc comment in the DM service. --- hooks/use-crypto-price.ts | 7 ++++--- lib/services/direct-message-service.ts | 3 --- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/hooks/use-crypto-price.ts b/hooks/use-crypto-price.ts index 29c3490c..6ccf4e74 100644 --- a/hooks/use-crypto-price.ts +++ b/hooks/use-crypto-price.ts @@ -39,6 +39,10 @@ export function useCryptoPrice( }, []) useEffect(() => { + // Read once per run; a refetch requested while inputs were invalid must not carry over. + const skipCache = skipCacheRef.current + skipCacheRef.current = false + // Reset state if inputs are invalid if (!fiatAmount || !fiatCurrency || !scheme || fiatAmount <= 0) { setCryptoAmount(null) @@ -61,9 +65,6 @@ export function useCryptoPrice( let cancelled = false - const skipCache = skipCacheRef.current - skipCacheRef.current = false - const fetchPrice = async () => { setIsLoading(true) setError(null) diff --git a/lib/services/direct-message-service.ts b/lib/services/direct-message-service.ts index 4964bc98..beb57db5 100644 --- a/lib/services/direct-message-service.ts +++ b/lib/services/direct-message-service.ts @@ -601,9 +601,6 @@ class DirectMessageService { } } - /** - * Get my invite to a recipient - */ /** The sender's invite to the recipient; `null` when there is none, `undefined` when the lookup failed. */ private async getMyInviteToRecipient( senderId: string,