diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4282d218f..f69dbd170 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,21 @@ jobs: GITHUB_TOKEN: ${{ secrets.SOVRAN_CI_TOKEN || secrets.GITHUB_TOKEN }} run: | bun run deps:sovran-latest - bun install + # --ignore-scripts: the token is needed only to AUTH the + # @sovranbitcoin/* registry fetch, not to run lifecycle code, so no + # package install script executes while the secret is in scope + # (build-pipeline-secret-boundary). + bun install --ignore-scripts + + # Run the deferred install scripts with NO secret in scope: trusted + # dependency scripts (e.g. @shopify/react-native-skia's install-skia) via + # `bun pm trust`, then the first-party postinstall (patch-package, bitchat + # patching, local-sibling linking). None of these need the registry token. + - name: Run install scripts (no secrets in scope) + working-directory: ${{ env.APP_DIR }} + run: | + bun pm trust --all + bun run postinstall - name: Lint working-directory: ${{ env.APP_DIR }} @@ -61,6 +75,15 @@ jobs: working-directory: ${{ env.APP_DIR }} run: bun run knip + # Fails when any component bails out of the React Compiler. The compiler + # is the source of truth for memoization (ADR 0006); a bailout means a + # component renders unmemoized and a "redundant" manual memo we removed is + # now actually missing. The eslint-plugin-react-compiler rule that would + # catch this is dead under the repo's zod@4 override, so this is the gate. + - name: React Compiler coverage + working-directory: ${{ env.APP_DIR }} + run: bun run check:react-compiler + - name: Test working-directory: ${{ env.APP_DIR }} run: bun run test @@ -99,7 +122,21 @@ jobs: GITHUB_TOKEN: ${{ secrets.SOVRAN_CI_TOKEN || secrets.GITHUB_TOKEN }} run: | bun run deps:sovran-latest - bun install + # --ignore-scripts: the token is needed only to AUTH the + # @sovranbitcoin/* registry fetch, not to run lifecycle code, so no + # package install script executes while the secret is in scope + # (build-pipeline-secret-boundary). + bun install --ignore-scripts + + # Run the deferred install scripts with NO secret in scope: trusted + # dependency scripts (e.g. @shopify/react-native-skia's install-skia) via + # `bun pm trust`, then the first-party postinstall (patch-package, bitchat + # patching, local-sibling linking). None of these need the registry token. + - name: Run install scripts (no secrets in scope) + working-directory: ${{ env.APP_DIR }} + run: | + bun pm trust --all + bun run postinstall - name: Run analyze-structure working-directory: ${{ env.APP_DIR }} diff --git a/__tests__/dmDecryptNip04.test.ts b/__tests__/dmDecryptNip04.test.ts index 7b68c06e7..e51df3a1f 100644 --- a/__tests__/dmDecryptNip04.test.ts +++ b/__tests__/dmDecryptNip04.test.ts @@ -5,7 +5,7 @@ */ import { finalizeEvent, generateSecretKey, getPublicKey, nip04 } from 'nostr-tools'; import { decryptDmEnvelopes } from '@/features/payments/data/dmDecryptPipeline'; -import type { DmEnvelope } from '@/features/payments/data/dmEnvelopeClient'; +import type { DmEnvelope } from '@/features/payments/data/dmEnvelopeTypes'; jest.mock('@react-native-async-storage/async-storage', () => require('@react-native-async-storage/async-storage/jest/async-storage-mock') diff --git a/__tests__/facadeFeedAdapter.test.ts b/__tests__/facadeFeedAdapter.test.ts index b89069e5f..9253cd885 100644 --- a/__tests__/facadeFeedAdapter.test.ts +++ b/__tests__/facadeFeedAdapter.test.ts @@ -20,7 +20,12 @@ describe('resolvedFeedPageToParseResult', () => { tier: 'relay' as const, items: [ { type: 'note' as const, event: event(NOTE, 200, [['q', QUOTED]]) }, - { type: 'repost' as const, repostEvent: event(REPOST, 150), originalEvent: event(ORIG, 100), originalEventId: ORIG }, + { + type: 'repost' as const, + repostEvent: event(REPOST, 150), + originalEvent: event(ORIG, 100), + originalEventId: ORIG, + }, ], stats: { [NOTE]: { likes: 5, reposts: 2, replies: 1, zaps: 3, satsZapped: 2100 } }, profiles: { [PUB]: { name: 'alice', picture: 'http://x/a.png' } }, @@ -40,7 +45,12 @@ describe('resolvedFeedPageToParseResult', () => { expect(repost.type === 'repost' && repost.originalEventId).toBe(ORIG); expect(repost.timestamp).toBe(150); // stats → NoteMetrics field rename - expect(result.metricsMap.get(NOTE)).toEqual({ likeCount: 5, repostCount: 2, replyCount: 1, satsZapped: 2100 }); + expect(result.metricsMap.get(NOTE)).toEqual({ + likeCount: 5, + repostCount: 2, + replyCount: 1, + satsZapped: 2100, + }); // profiles + cursor expect(result.profilesMap.get(PUB)).toEqual({ name: 'alice', picture: 'http://x/a.png' }); expect(result.paginationUntil).toBe(100); diff --git a/__tests__/loggerChild.test.ts b/__tests__/loggerChild.test.ts index 25e33849b..8f09b96e6 100644 --- a/__tests__/loggerChild.test.ts +++ b/__tests__/loggerChild.test.ts @@ -315,7 +315,7 @@ describe('logger redaction safety (audit 56.json F-001 / F-008 / F-012)', () => dedupWindowMs: 1000, }); log.debug('evt'); - const firstSnapshot = JSON.parse(JSON.stringify(log.getRecentLogs())); + const firstSnapshot = structuredClone(log.getRecentLogs()); log.debug('evt'); log.debug('evt'); log.debug('evt'); diff --git a/__tests__/loggerValueCompact.test.ts b/__tests__/loggerValueCompact.test.ts new file mode 100644 index 000000000..1ac54c25e --- /dev/null +++ b/__tests__/loggerValueCompact.test.ts @@ -0,0 +1,78 @@ +import { compactValue, redactKnownSecretSubstrings } from '@/shared/lib/loggerValueCompact'; + +const OPTS = { maxStringLength: 120, maxArrayItems: 5, maxDepth: 4, maxObjectKeys: 15 }; + +describe('compactValue — structural compaction', () => { + it('passes primitives through untouched', () => { + expect(compactValue(42, OPTS)).toBe(42); + expect(compactValue(true, OPTS)).toBe(true); + expect(compactValue(null, OPTS)).toBe(null); + expect(compactValue(undefined, OPTS)).toBe(undefined); + expect(compactValue('short', OPTS)).toBe('short'); + }); + + it('truncates long arrays and notes the remainder', () => { + const result = compactValue([1, 2, 3, 4, 5, 6, 7], OPTS) as unknown[]; + expect(result.slice(0, 5)).toEqual([1, 2, 3, 4, 5]); + expect(result[5]).toBe('…2 more'); + }); + + it('caps object keys at maxObjectKeys with a _more marker', () => { + const obj: Record = {}; + for (let i = 0; i < 20; i++) obj[`k${i}`] = i; + const result = compactValue(obj, { ...OPTS, maxObjectKeys: 3 }) as Record; + expect(Object.keys(result)).toEqual(['k0', 'k1', 'k2', '…17_more']); + }); + + it('summarizes a Uint8Array as a buffer brand (never the bytes)', () => { + expect(compactValue(new Uint8Array([1, 2, 3, 4]), OPTS)).toEqual({ _kind: 'buffer', bytes: 4 }); + }); + + it('summarizes Error / Date / Map / Set without leaking structure', () => { + expect(compactValue(new Date('2026-01-01T00:00:00.000Z'), OPTS)).toEqual({ + _kind: 'date', + iso: '2026-01-01T00:00:00.000Z', + }); + const err = compactValue(new Error('boom'), OPTS) as { _kind: string; message: string }; + expect(err._kind).toBe('error'); + expect(err.message).toBe('boom'); + expect(compactValue(new Set([1, 2]), OPTS)).toMatchObject({ _kind: 'set', size: 2 }); + expect(compactValue(new Map([['a', 1]]), OPTS)).toMatchObject({ _kind: 'map', size: 1 }); + }); + + it('collapses objects past maxDepth instead of recursing forever', () => { + const deep = { a: { b: { c: { d: { e: 1 } } } } }; + const result = compactValue(deep, { ...OPTS, maxDepth: 2 }); + expect(JSON.stringify(result)).toContain('_kind'); + }); +}); + +describe('compactValue — field-name driven redaction', () => { + it('brands a value under a sensitive field name even when the value looks innocent', () => { + expect(compactValue('whatever', OPTS, 0, 'privateKey')).toEqual({ + _kind: 'private_key', + len: 8, + }); + expect(compactValue('seedphrase here', OPTS, 0, 'mnemonic')).toEqual({ + _kind: 'secret', + len: 15, + }); + expect(compactValue('Bearer abc', OPTS, 0, 'authorization')).toEqual({ + _kind: 'secret', + len: 10, + }); + }); + + it('prefers a specifically branded secret value over the field-derived kind', () => { + const nsec = 'nsec1' + 'q'.repeat(58); + expect(compactValue(nsec, OPTS, 0, 'someKey')).toMatchObject({ _kind: 'nsec' }); + }); +}); + +describe('redactKnownSecretSubstrings', () => { + it('replaces embedded secrets but leaves plain text intact', () => { + expect(redactKnownSecretSubstrings('all good here')).toBe('all good here'); + const out = redactKnownSecretSubstrings(`token=${'eyJabc.eyJdef.sig'}`); + expect(out).toContain(''); + }); +}); diff --git a/__tests__/mintInfoScreenSource.test.ts b/__tests__/mintInfoScreenSource.test.ts index 1b922e333..ebc357784 100644 --- a/__tests__/mintInfoScreenSource.test.ts +++ b/__tests__/mintInfoScreenSource.test.ts @@ -23,9 +23,7 @@ describe('mint info screen source', () => { }); it('uses Nostr profile data to pretty-display the first Nostr contact', () => { - expect(source).toContain( - 'const contactRows = useMemo(() => getSortedMintInfoContacts(contact), [contact]);' - ); + expect(source).toContain('const contactRows = getSortedMintInfoContacts(contact);'); expect(source).toContain('useNostrProfile('); expect(source).toContain('getMintInfoNostrDisplayName(rowProfile, fallbackNpub ?? c.info)'); expect(source).toContain('picture={rowPicture}'); diff --git a/__tests__/naggFeedClient.test.ts b/__tests__/naggFeedClient.test.ts index 40ef32ca0..56c0c487d 100644 --- a/__tests__/naggFeedClient.test.ts +++ b/__tests__/naggFeedClient.test.ts @@ -170,7 +170,13 @@ describe('createNaggFeedClient (app-view REST)', () => { mockFetch.mockResolvedValueOnce( restResponse({ notifications: { - nodes: [{ event: note({ id: 'n1', pubkey: 'carol' }), reason: 'mention', actorVertexScore: 0.5 }], + nodes: [ + { + event: note({ id: 'n1', pubkey: 'carol' }), + reason: 'mention', + actorVertexScore: 0.5, + }, + ], pageInfo: { hasNextPage: false, endCursor: null }, }, metrics: { n1: emptyStats }, @@ -253,7 +259,11 @@ describe('createNaggFeedClient (app-view REST)', () => { }) ); - await loadClient().getFeed({ spec: JSON.stringify({ id: 'feed' }), userPubkey: 'viewer', limit: 5 }); + await loadClient().getFeed({ + spec: JSON.stringify({ id: 'feed' }), + userPubkey: 'viewer', + limit: 5, + }); for (const call of mockFetch.mock.calls) { expect(String(call[0])).not.toContain('/graphql'); diff --git a/__tests__/rebalanceRouting.test.ts b/__tests__/rebalanceRouting.test.ts new file mode 100644 index 000000000..cb1d73c89 --- /dev/null +++ b/__tests__/rebalanceRouting.test.ts @@ -0,0 +1,115 @@ +/** + * @jest-environment node + */ +import type { GetInfoResponse } from '@cashu/cashu-ts'; + +import { computeRouteSuggestion } from '@/features/mint/lib/rebalanceRouting'; +import type { AuditMintResponse } from '@/shared/lib/apiClient'; +import type { MiddlemanRoutingSettings } from '@/shared/stores/global/settingsStore'; + +const mockBuildSwapGraph = jest.fn(); +const mockAddLocalHistoryEdges = jest.fn(); +const mockGetLocalCandidates = jest.fn((..._args: unknown[]) => [] as string[]); +const mockPickIntermediaryPath = jest.fn(); + +jest.mock('@/features/mint/components/rebalance', () => ({ + buildSwapGraph: (...args: unknown[]) => mockBuildSwapGraph(...args), + addLocalHistoryEdges: (...args: unknown[]) => mockAddLocalHistoryEdges(...args), + getLocalCandidatesForDestination: (...args: unknown[]) => mockGetLocalCandidates(...args), + pickIntermediaryPath: (...args: unknown[]) => mockPickIntermediaryPath(...args), +})); + +// Variable-based casts (the repo bans object-literal type assertions). +function fakeAudit(url: string): AuditMintResponse { + const audit = { mintUrl: url }; + return audit as unknown as AuditMintResponse; +} +function mintInfo(name: string): GetInfoResponse { + const info = { name }; + return info as unknown as GetInfoResponse; +} +const emptyRouting = (() => { + const r = {}; + return r as unknown as MiddlemanRoutingSettings; +})(); +const emptyMintInfo: Record = {}; + +const baseInput = () => ({ + fromMintUrl: 'https://a.mint', + toMintUrl: 'https://b.mint', + planMintUrls: ['https://a.mint', 'https://b.mint'], + trustedMintUrls: ['https://t.mint'], + mintInfoMap: emptyMintInfo, + middlemanRouting: emptyRouting, + groups: [], + fetchAudit: jest.fn(async (url: string) => fakeAudit(url)), +}); + +describe('computeRouteSuggestion', () => { + beforeEach(() => { + mockBuildSwapGraph.mockReset().mockReturnValue({}); + mockAddLocalHistoryEdges.mockReset(); + mockGetLocalCandidates.mockReset().mockReturnValue([]); + mockPickIntermediaryPath.mockReset(); + }); + + it('returns null when no intermediary path is found', async () => { + mockPickIntermediaryPath.mockReturnValue({ path: null }); + const result = await computeRouteSuggestion(baseInput()); + expect(result).toBeNull(); + }); + + it('maps a found path to display names, falling back to the url', async () => { + mockPickIntermediaryPath.mockReturnValue({ path: ['https://a.mint', 'https://x.mint'] }); + const result = await computeRouteSuggestion({ + ...baseInput(), + mintInfoMap: { 'https://a.mint': mintInfo('Alpha') }, + }); + expect(result).toEqual({ + path: ['https://a.mint', 'https://x.mint'], + pathNames: ['Alpha', 'https://x.mint'], + }); + }); + + it('dedupes candidates and audits each via the injected port', async () => { + mockGetLocalCandidates.mockReturnValue(['https://local.mint', 'https://a.mint']); + mockPickIntermediaryPath.mockReturnValue({ path: null }); + const input = baseInput(); + await computeRouteSuggestion(input); + const auditedUrls = input.fetchAudit.mock.calls.map((c) => c[0]); + // 'a' appears in both plan and local candidates — must be audited once. + expect(new Set(auditedUrls).size).toBe(auditedUrls.length); + expect(auditedUrls).toEqual( + expect.arrayContaining([ + 'https://a.mint', + 'https://b.mint', + 'https://t.mint', + 'https://local.mint', + ]) + ); + }); + + it('passes only successful audits to the graph builder', async () => { + mockPickIntermediaryPath.mockReturnValue({ path: null }); + const input = { + ...baseInput(), + fetchAudit: jest.fn(async (url: string) => + url === 'https://b.mint' ? null : fakeAudit(url) + ), + }; + await computeRouteSuggestion(input); + const audits = mockBuildSwapGraph.mock.calls[0][0] as { mintUrl: string }[]; + expect(audits.some((a) => a.mintUrl === 'https://b.mint')).toBe(false); + expect(audits.length).toBeGreaterThan(0); + }); + + it('caps the candidate set at 12 audits', async () => { + mockGetLocalCandidates.mockReturnValue( + Array.from({ length: 30 }, (_, i) => `https://m${i}.mint`) + ); + mockPickIntermediaryPath.mockReturnValue({ path: null }); + const input = baseInput(); + await computeRouteSuggestion(input); + expect(input.fetchAudit.mock.calls.length).toBeLessThanOrEqual(12); + }); +}); diff --git a/__tests__/sovranPaymentConfig.profile.test.ts b/__tests__/sovranPaymentConfig.profile.test.ts index f1ed90602..d8fc9648f 100644 --- a/__tests__/sovranPaymentConfig.profile.test.ts +++ b/__tests__/sovranPaymentConfig.profile.test.ts @@ -4,10 +4,8 @@ import type { PaymentMachine } from '@sovranbitcoin/colada'; import type { Manager } from '@cashu/coco-core'; -import { - createSovranHandlers, - createSovranNotifications, -} from '@/features/send/lib/sovranPaymentConfig'; +import { createSovranHandlers } from '@/features/send/lib/sovranHandlers'; +import { createSovranNotifications } from '@/features/send/lib/sovranNotifications'; import { sendMemoPopup } from '@/shared/lib/popup'; import { getEncodedToken } from '@cashu/cashu-ts'; import { sendBLEPrivateMessageWhole } from '@/features/bitchat/lib/blePrivateDelivery'; diff --git a/__tests__/threadItems.test.ts b/__tests__/threadItems.test.ts index 5553266aa..c0a4dc903 100644 --- a/__tests__/threadItems.test.ts +++ b/__tests__/threadItems.test.ts @@ -177,10 +177,16 @@ describe('thread item builders', () => { it('initial fetch keeps the seeded reply order as a stable prefix (no reshuffle)', () => { const root = note({ id: 'root', content: 'root', tags: [], createdAt: 100 }); const seededReply = note({ - id: 'seeded-reply', content: 'seeded', tags: [['e', 'root', '', 'reply']], createdAt: 101, + id: 'seeded-reply', + content: 'seeded', + tags: [['e', 'root', '', 'reply']], + createdAt: 101, }); const rankedFirst = note({ - id: 'ranked-first', content: 'ranked higher by the server', tags: [['e', 'root', '', 'reply']], createdAt: 102, + id: 'ranked-first', + content: 'ranked higher by the server', + tags: [['e', 'root', '', 'reply']], + createdAt: 102, }); const result: ThreadResult = { allEvents: mapEvents([root, seededReply, rankedFirst]), diff --git a/app-env.d.ts b/app-env.d.ts deleted file mode 100644 index 0fbe2e537..000000000 --- a/app-env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -// @ts-ignore -/// diff --git a/app/(drawer)/(tabs)/_layout.tsx b/app/(drawer)/(tabs)/_layout.tsx index ca20ef655..d30128467 100644 --- a/app/(drawer)/(tabs)/_layout.tsx +++ b/app/(drawer)/(tabs)/_layout.tsx @@ -11,6 +11,16 @@ export const unstable_settings = { initialRouteName: 'index', }; +/* eslint-disable no-restricted-syntax -- native tab-bar theme palette (Expo + default Colors), resolved per-appearance by DynamicColorIOS; these are colour + definitions for the iOS native tab bar, intentionally fixed per light/dark + appearance rather than app-theme tokens. */ +const TAB_LABEL_DARK = '#ECEDEE'; +const TAB_LABEL_LIGHT = '#11181C'; +const TAB_TINT_DARK = '#fff'; +const TAB_TINT_LIGHT = '#0a7ea4'; +/* eslint-enable no-restricted-syntax */ + type TabName = 'feed' | 'index' | 'contacts' | 'notifications' | 'ai'; type TabDef = { @@ -66,15 +76,15 @@ export default function TabLayout() { labelStyle={{ color: Platform.select({ ios: DynamicColorIOS({ - dark: '#ECEDEE', - light: '#11181C', + dark: TAB_LABEL_DARK, + light: TAB_LABEL_LIGHT, }), }), }} tintColor={Platform.select({ ios: DynamicColorIOS({ - dark: '#fff', - light: '#0a7ea4', + dark: TAB_TINT_DARK, + light: TAB_TINT_LIGHT, }), })} disableTransparentOnScrollEdge> diff --git a/app/(drawer)/(tabs)/index/_layout.tsx b/app/(drawer)/(tabs)/index/_layout.tsx index cda9ef150..549424d8c 100644 --- a/app/(drawer)/(tabs)/index/_layout.tsx +++ b/app/(drawer)/(tabs)/index/_layout.tsx @@ -1,5 +1,3 @@ -import { useCallback } from 'react'; - import { usePaymentFlowMachine } from '@sovranbitcoin/colada/react'; import { MintSelector } from '@/features/wallet'; import { clearPaymentContext } from '@/shared/stores/runtime/clearPaymentContext'; @@ -13,7 +11,7 @@ export default function HomeLayout() { const walletContext = useWalletContext(); const machine = usePaymentFlowMachine({ walletContext }); - const handleRequestMintList = useCallback(() => { + const handleRequestMintList = () => { cashuLog.info('wallet.header.mint_selector.request', { source: 'wallet-header', trustedMintCount: walletContext.trustedMintUrls.length, @@ -21,7 +19,7 @@ export default function HomeLayout() { }); clearPaymentContext('wallet.mint_selector'); void machine.requestMintSelector({ reset: true }); - }, [machine, walletContext.preferredMintUrl, walletContext.trustedMintUrls.length]); + }; // Shared inline header search, like Feed/Contacts. `transparent` keeps the // wallpaper showing through the header; the wallet's MintSelector stays as the diff --git a/app/(drawer)/(tabs)/notifications/_layout.tsx b/app/(drawer)/(tabs)/notifications/_layout.tsx index f3dfaf9bb..12eca2fc4 100644 --- a/app/(drawer)/(tabs)/notifications/_layout.tsx +++ b/app/(drawer)/(tabs)/notifications/_layout.tsx @@ -1,4 +1,3 @@ -import { useCallback } from 'react'; import { Stack } from 'expo-router'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; import { DrawerActions, useNavigation } from '@react-navigation/native'; @@ -8,17 +7,19 @@ import { HeaderProfileButton } from '@/shared/blocks/HeaderProfileButton'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { ScreenHeaderAction } from '@/shared/ui/composed/ScreenHeaderAction'; +// Closure-free, so it lives at module scope rather than being reallocated each +// render (prefer-module-scope-pure-function). +function openNotificationSettings() { + router.push('/(settings-flow)/notification-policy'); +} + export default function NotificationsLayout() { const [iconColor, surface] = useThemeColor(['foreground', 'surface'] as const); const navigation = useNavigation(); - const openDrawer = useCallback(() => { + const openDrawer = () => { navigation.dispatch(DrawerActions.openDrawer()); - }, [navigation]); - - const openNotificationSettings = useCallback(() => { - router.push('/(settings-flow)/notification-policy'); - }, []); + }; return ( diff --git a/app/(drawer)/_layout.tsx b/app/(drawer)/_layout.tsx index 86816e231..b54f7da50 100644 --- a/app/(drawer)/_layout.tsx +++ b/app/(drawer)/_layout.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import { Drawer } from 'expo-router/drawer'; import { GestureHandlerRootView, @@ -188,38 +188,32 @@ function CustomDrawerContent(props: DrawerContentComponentProps) { prevStatusRef.current = drawerStatus; }, [drawerStatus]); - const isRouteActive = useCallback( - (route: MenuRoute) => { - const item = MENU_ITEMS.find((m) => m.route === route); - if (!item) return false; - return segmentsMatch(segments as string[], item.activeSegments); - }, - [segments] - ); + const isRouteActive = (route: MenuRoute) => { + const item = MENU_ITEMS.find((m) => m.route === route); + if (!item) return false; + return segmentsMatch(segments as string[], item.activeSegments); + }; - const handleNavigation = useCallback( - (route: MenuRoute) => { - if (navInProgressRef.current) return; - const item = MENU_ITEMS.find((m) => m.route === route); - const active = item - ? segmentsMatch(segmentsRef.current as string[], item.activeSegments) - : false; - if (active) { - props.navigation.closeDrawer(); - return; - } - navInProgressRef.current = true; - router.navigate(route); + const handleNavigation = (route: MenuRoute) => { + if (navInProgressRef.current) return; + const item = MENU_ITEMS.find((m) => m.route === route); + const active = item + ? segmentsMatch(segmentsRef.current as string[], item.activeSegments) + : false; + if (active) { props.navigation.closeDrawer(); - setTimeout(() => { - navInProgressRef.current = false; - }, 400); - }, - [props.navigation] - ); + return; + } + navInProgressRef.current = true; + router.navigate(route); + props.navigation.closeDrawer(); + setTimeout(() => { + navInProgressRef.current = false; + }, 400); + }; const surface = useThemeColor('surface'); - const closeDrawer = useCallback(() => props.navigation.closeDrawer(), [props.navigation]); + const closeDrawer = () => props.navigation.closeDrawer(); return ( diff --git a/app/(filter-flow)/_layout.tsx b/app/(filter-flow)/_layout.tsx index ab4cc7270..68f662591 100644 --- a/app/(filter-flow)/_layout.tsx +++ b/app/(filter-flow)/_layout.tsx @@ -6,7 +6,6 @@ * Used for transaction filtering options. */ -import { useMemo } from 'react'; import { Stack } from 'expo-router'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { createFlowLayoutScreenOptions } from '../../config/flowLayoutOptions'; @@ -17,9 +16,9 @@ const FILTERS_OPTIONS = { title: 'Filters' }; export default function FilterFlowLayout() { const [foreground, background] = useThemeColor(['foreground', 'background'] as const); - const screenOptions = useMemo( - () => createFlowLayoutScreenOptions({ foreground, background }, { androidSheet: true }), - [foreground, background] + const screenOptions = createFlowLayoutScreenOptions( + { foreground, background }, + { androidSheet: true } ); return ( diff --git a/app/(map-flow)/_layout.tsx b/app/(map-flow)/_layout.tsx index 9deb8b797..b4422e560 100644 --- a/app/(map-flow)/_layout.tsx +++ b/app/(map-flow)/_layout.tsx @@ -8,7 +8,6 @@ * - detail: Merchant details (horizontal push) */ -import { useMemo } from 'react'; import { Stack } from 'expo-router'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { createFlowLayoutScreenOptions } from '../../config/flowLayoutOptions'; @@ -20,9 +19,9 @@ const DETAIL_OPTIONS = { title: 'Merchant details' }; export default function MapFlowLayout() { const [foreground, background] = useThemeColor(['foreground', 'background'] as const); - const screenOptions = useMemo( - () => createFlowLayoutScreenOptions({ foreground, background }, { androidSheet: true }), - [foreground, background] + const screenOptions = createFlowLayoutScreenOptions( + { foreground, background }, + { androidSheet: true } ); return ( diff --git a/app/(mint-flow)/_layout.tsx b/app/(mint-flow)/_layout.tsx index 9b942596b..af56f87a4 100644 --- a/app/(mint-flow)/_layout.tsx +++ b/app/(mint-flow)/_layout.tsx @@ -11,7 +11,6 @@ * The first screen shows a close button, subsequent screens show a back button. */ -import { useMemo } from 'react'; import { Stack } from 'expo-router'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { createFlowLayoutScreenOptions } from '../../config/flowLayoutOptions'; @@ -28,9 +27,9 @@ const USER_MESSAGES_OPTIONS = { headerShown: false }; export default function MintFlowLayout() { const [foreground, background] = useThemeColor(['foreground', 'background'] as const); - const screenOptions = useMemo( - () => createFlowLayoutScreenOptions({ foreground, background }, { androidSheet: true }), - [foreground, background] + const screenOptions = createFlowLayoutScreenOptions( + { foreground, background }, + { androidSheet: true } ); return ( diff --git a/app/(mint-flow)/list.tsx b/app/(mint-flow)/list.tsx index 71e8e1023..a1b347096 100644 --- a/app/(mint-flow)/list.tsx +++ b/app/(mint-flow)/list.tsx @@ -11,14 +11,13 @@ * boundary per AUDIT.md dim-5 — `continueParams` is fed to JSON.parse. */ -import React, { useMemo, useState, useCallback } from 'react'; +import React, { useState } from 'react'; import { Stack, Link } from 'expo-router'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; import { useFocusEffect } from '@react-navigation/native'; import { z } from 'zod'; import { useBalanceContext, useMints } from '@cashu/coco-react'; -import type { MintAvailability } from '@sovranbitcoin/colada'; import { MintListScreen } from '@/features/mint'; import { useMintCatalog } from '@/features/mint/hooks/useMintCatalog'; @@ -27,6 +26,7 @@ import { ScreenHeaderAction } from '@/shared/ui/composed/ScreenHeaderAction'; import { amountToNumber } from '@/shared/lib/cashu/amount'; import { cashuLog } from '@/shared/lib/logger'; import { useRouteParams } from '@/shared/lib/nav/useRouteParams'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; const ParamsSchema = z.object({ showAddMintsButton: z.enum(['true', 'false']).optional(), @@ -40,13 +40,6 @@ const ParamsSchema = z.object({ continueParams: z.string().min(1).max(4_000).optional(), }); -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - function MintListRoute() { const params = useRouteParams(ParamsSchema, { where: 'mint-flow.list' }); @@ -60,38 +53,28 @@ function MintListRoute() { // Force list rebuild when this screen regains focus (e.g. after adding a mint) const [focusKey, setFocusKey] = useState(0); - useFocusEffect( - useCallback(() => { - setFocusKey((k) => k + 1); - cashuLog.info('mint.list.refocus', { - trustedMintCount: trustedMints.length, - }); - }, [trustedMints.length]) - ); + useFocusEffect(() => { + setFocusKey((k) => k + 1); + cashuLog.info('mint.list.refocus', { + trustedMintCount: trustedMints.length, + }); + }); // Build a neutral availability array (all mints available, no flow constraints). - const availability = useMemo( - () => - trustedMints.map((m) => ({ - mintUrl: m.mintUrl, - balance: amountToNumber(mintBalances[m.mintUrl]?.total), - status: 'available' as const, - reason: null, - isPreferred: false, - })), - [trustedMints, mintBalances] - ); + const availability = trustedMints.map((m) => ({ + mintUrl: m.mintUrl, + balance: amountToNumber(mintBalances[m.mintUrl]?.total), + status: 'available' as const, + reason: null, + isPreferred: false, + })); // One bulk fetch — same source colada uses for Select Mint, so // the audit / score pills render identically across both surfaces. - const mintUrls = useMemo(() => trustedMints.map((m) => m.mintUrl), [trustedMints]); + const mintUrls = trustedMints.map((m) => m.mintUrl); const catalog = useMintCatalog(mintUrls); - const items = useMemo( - () => buildMintListItems(trustedMints, availability, catalog), - // eslint-disable-next-line react-hooks/exhaustive-deps - [trustedMints, availability, catalog, focusKey] - ); + const items = buildMintListItems(trustedMints, availability, catalog); return ( <> @@ -123,6 +106,7 @@ function MintListRoute() { if (onSelectAction === 'continue' && params?.continuePathname) { const continueParams = params.continueParams ? JSON.parse(params.continueParams) : {}; router.navigate({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic persisted route string cannot be statically typed against expo-router's route union pathname: params.continuePathname as any, params: { ...continueParams, diff --git a/app/(profile-flow)/_layout.tsx b/app/(profile-flow)/_layout.tsx index 658e7f0d5..f18f301e5 100644 --- a/app/(profile-flow)/_layout.tsx +++ b/app/(profile-flow)/_layout.tsx @@ -6,7 +6,6 @@ * side-slide profile stack used by Contacts, Feed, and similar surfaces. */ -import { useMemo } from 'react'; import { Stack } from 'expo-router'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { createFlowLayoutScreenOptions } from '../../config/flowLayoutOptions'; @@ -24,9 +23,9 @@ const BITCHAT_DM_OPTIONS = { headerShown: false }; export default function ProfileFlowLayout() { const [foreground, background] = useThemeColor(['foreground', 'background'] as const); - const screenOptions = useMemo( - () => createFlowLayoutScreenOptions({ foreground, background }, { androidSheet: true }), - [foreground, background] + const screenOptions = createFlowLayoutScreenOptions( + { foreground, background }, + { androidSheet: true } ); return ( diff --git a/app/(receive-flow)/_layout.tsx b/app/(receive-flow)/_layout.tsx index 064e5c750..8b48235af 100644 --- a/app/(receive-flow)/_layout.tsx +++ b/app/(receive-flow)/_layout.tsx @@ -13,7 +13,6 @@ * The first screen shows a close button, subsequent screens show a back button. */ -import { useMemo } from 'react'; import { Stack } from 'expo-router'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; @@ -36,9 +35,9 @@ const CAMERA_OPTIONS = { export default function ReceiveFlowLayout() { const [foreground, background] = useThemeColor(['foreground', 'background'] as const); - const screenOptions = useMemo( - () => createFlowLayoutScreenOptions({ foreground, background }, { androidSheet: true }), - [foreground, background] + const screenOptions = createFlowLayoutScreenOptions( + { foreground, background }, + { androidSheet: true } ); return ( diff --git a/app/(receive-flow)/lightningReceive.tsx b/app/(receive-flow)/lightningReceive.tsx index b6dcc4e22..be4bc9b0d 100644 --- a/app/(receive-flow)/lightningReceive.tsx +++ b/app/(receive-flow)/lightningReceive.tsx @@ -2,7 +2,7 @@ * @fileoverview Receive-flow Lightning receive route. */ -import { useCallback, useEffect } from 'react'; +import { useEffect } from 'react'; import { LightningReceiveRoute } from '@/features/receive'; import { usePaymentFlowMachine } from '@sovranbitcoin/colada/react'; @@ -35,10 +35,10 @@ export default function LightningReceiveRouteWrapper() { walletContext.mintBalances, walletContext.trustedMintUrls.length, ]); - const handleRequestMintList = useCallback(() => { + const handleRequestMintList = () => { cashuLog.info('receive.lightning.mint_list.requested', { source: 'pill' }); void machine.requestMintSelector(); - }, [machine]); + }; return ( { + const handleRequestMintList = () => { cashuLog.info('receive.mint_quote.mint_list.requested', { source: 'pill' }); void machine.requestMintSelector(); - }, [machine]); + }; if (!params) return null; diff --git a/app/(receive-flow)/onchainReceive.tsx b/app/(receive-flow)/onchainReceive.tsx index 89c4e0ae5..fb50b2430 100644 --- a/app/(receive-flow)/onchainReceive.tsx +++ b/app/(receive-flow)/onchainReceive.tsx @@ -2,7 +2,7 @@ * @fileoverview Receive-flow Onchain receive route. */ -import { useCallback, useEffect } from 'react'; +import { useEffect } from 'react'; import { OnchainReceiveRoute } from '@/features/receive'; import { usePaymentFlowMachine } from '@sovranbitcoin/colada/react'; @@ -35,10 +35,10 @@ export default function OnchainReceiveRouteWrapper() { walletContext.mintBalances, walletContext.trustedMintUrls.length, ]); - const handleRequestMintList = useCallback(() => { + const handleRequestMintList = () => { cashuLog.info('receive.onchain.mint_list.requested', { source: 'pill' }); void machine.requestMintSelector(); - }, [machine]); + }; return ( createFlowLayoutScreenOptions({ foreground, background }, { androidSheet: true }), - [foreground, background] + const screenOptions = createFlowLayoutScreenOptions( + { foreground, background }, + { androidSheet: true } ); return ( diff --git a/app/(send-flow)/lightningSend.tsx b/app/(send-flow)/lightningSend.tsx index 140fc8200..53aa1742a 100644 --- a/app/(send-flow)/lightningSend.tsx +++ b/app/(send-flow)/lightningSend.tsx @@ -3,7 +3,7 @@ */ import { LightningSendRoute } from '@/features/send'; -import { useCallback, useEffect } from 'react'; +import { useEffect } from 'react'; import { usePaymentFlowMachine } from '@sovranbitcoin/colada/react'; import { useWalletContext } from '@/shared/providers/WalletContextProvider'; import { cashuLog } from '@/shared/lib/logger'; @@ -19,10 +19,10 @@ export default function LightningSendRouteWrapper() { }); }, [walletContext.mintBalances, walletContext.trustedMintUrls.length]); - const handleRequestMintList = useCallback(() => { + const handleRequestMintList = () => { cashuLog.info('send.lightning.mint_list.requested', { source: 'pill' }); void machine.requestMintSelector(); - }, [machine]); + }; return ( diff --git a/app/(send-flow)/meltQuote.tsx b/app/(send-flow)/meltQuote.tsx index 5252d75d2..c85857161 100644 --- a/app/(send-flow)/meltQuote.tsx +++ b/app/(send-flow)/meltQuote.tsx @@ -5,7 +5,7 @@ * `Stack.Screen` title comes from `(send-flow)/_layout.tsx`. */ -import React, { useCallback, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { MeltQuoteRoute } from '@/features/send'; import { usePaymentFlowMachine } from '@sovranbitcoin/colada/react'; @@ -24,10 +24,10 @@ export default function ModalScreen() { }); }, [walletContext.mintBalances, walletContext.trustedMintUrls.length]); - const handleRequestMintList = useCallback(() => { + const handleRequestMintList = () => { cashuLog.info('melt.mint_list.requested', { source: 'pill' }); void machine.requestMintSelector(); - }, [machine]); + }; return ; } diff --git a/app/(send-flow)/paymentRequest.tsx b/app/(send-flow)/paymentRequest.tsx index 1cb17fe76..4fd4a90a0 100644 --- a/app/(send-flow)/paymentRequest.tsx +++ b/app/(send-flow)/paymentRequest.tsx @@ -11,7 +11,7 @@ * previously forwarded raw to the screen. */ -import React, { useCallback, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; import { z } from 'zod'; @@ -45,19 +45,19 @@ function ModalScreen() { walletContext.trustedMintUrls.length, ]); - const handleRequestMintList = useCallback(() => { + const handleRequestMintList = () => { cashuLog.info('payment_request.mint_list.requested', { source: 'pill' }); void machine.requestMintSelector(); - }, [machine]); + }; - const handleCancel = useCallback(() => { + const handleCancel = () => { cashuLog.info('payment_request.route.cancel', { where: 'send-flow.paymentRequest', hasEntry: !!params?.paymentRequestEntry, entryLength: params?.paymentRequestEntry?.length ?? 0, }); router.dismissTo('/'); - }, [params?.paymentRequestEntry]); + }; if (!params) return null; diff --git a/app/(settings-flow)/_layout.tsx b/app/(settings-flow)/_layout.tsx index 3f61f6b11..761d02581 100644 --- a/app/(settings-flow)/_layout.tsx +++ b/app/(settings-flow)/_layout.tsx @@ -6,7 +6,6 @@ * Screens within push horizontally with close/back header. */ -import { useMemo } from 'react'; import { Stack } from 'expo-router'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { createFlowLayoutScreenOptions } from '../../config/flowLayoutOptions'; @@ -32,10 +31,7 @@ const DELETE_OPTIONS = { title: 'Delete account' }; export default function SettingsFlowLayout() { const [foreground, background] = useThemeColor(['foreground', 'background'] as const); - const screenOptions = useMemo( - () => createFlowLayoutScreenOptions({ foreground, background }), - [foreground, background] - ); + const screenOptions = createFlowLayoutScreenOptions({ foreground, background }); return ( diff --git a/app/(signer-flow)/_layout.tsx b/app/(signer-flow)/_layout.tsx index 0ffe1147b..5def5e3da 100644 --- a/app/(signer-flow)/_layout.tsx +++ b/app/(signer-flow)/_layout.tsx @@ -17,7 +17,6 @@ * padding scroll content with `useHeaderHeight()` instead of `safeArea`. */ -import { useMemo } from 'react'; import { Stack } from 'expo-router'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { createFlowLayoutScreenOptions } from '../../config/flowLayoutOptions'; @@ -35,10 +34,7 @@ const CONNECT_OPTIONS = { title: 'Connect App' }; export default function SignerFlowLayout() { const [foreground, background] = useThemeColor(['foreground', 'background'] as const); - const screenOptions = useMemo( - () => createFlowLayoutScreenOptions({ foreground, background }), - [foreground, background] - ); + const screenOptions = createFlowLayoutScreenOptions({ foreground, background }); return ( diff --git a/app/(signer-flow)/connect.tsx b/app/(signer-flow)/connect.tsx index 08a76dca8..255550d71 100644 --- a/app/(signer-flow)/connect.tsx +++ b/app/(signer-flow)/connect.tsx @@ -11,7 +11,7 @@ * never log it. */ -import { useCallback, useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { z } from 'zod'; import { @@ -39,8 +39,9 @@ function toastAndGoToHub(body: string): void { router.replace('/(signer-flow)' as never); } +const onInvalid = () => toastAndGoToHub(PAIRING_ERROR_INVALID_LINK); + export default function SignerConnectRoute() { - const onInvalid = useCallback(() => toastAndGoToHub(PAIRING_ERROR_INVALID_LINK), []); const params = useRouteParams(ParamsSchema, { where: 'signer-flow.connect', onInvalid }); const uri = params?.uri; const openedRef = useRef(false); diff --git a/app/(stories-flow)/_layout.tsx b/app/(stories-flow)/_layout.tsx index 1b4f38743..6a8b4f6c9 100644 --- a/app/(stories-flow)/_layout.tsx +++ b/app/(stories-flow)/_layout.tsx @@ -1,9 +1,10 @@ import { Stack } from 'expo-router'; +import { INVARIANT_BLACK } from '@/shared/lib/brandColors'; import { getImmersiveFlowScreenOptions } from '@/config/flowLayoutOptions'; export default function StoriesFlowLayout() { return ( - + ); diff --git a/app/(theme-flow)/_layout.tsx b/app/(theme-flow)/_layout.tsx index f8f1d0e2e..c10e09113 100644 --- a/app/(theme-flow)/_layout.tsx +++ b/app/(theme-flow)/_layout.tsx @@ -6,7 +6,6 @@ * Screens within push horizontally with close/back header. */ -import { useMemo } from 'react'; import { Stack } from 'expo-router'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { createFlowLayoutScreenOptions } from '../../config/flowLayoutOptions'; @@ -19,9 +18,9 @@ const GALLERY_OPTIONS = { title: 'Gallery' }; export default function ThemeFlowLayout() { const [foreground, background] = useThemeColor(['foreground', 'background'] as const); - const screenOptions = useMemo( - () => createFlowLayoutScreenOptions({ foreground, background }, { androidSheet: true }), - [foreground, background] + const screenOptions = createFlowLayoutScreenOptions( + { foreground, background }, + { androidSheet: true } ); return ( diff --git a/app/(transactions-flow)/_layout.tsx b/app/(transactions-flow)/_layout.tsx index 5ba1631d8..1a206788f 100644 --- a/app/(transactions-flow)/_layout.tsx +++ b/app/(transactions-flow)/_layout.tsx @@ -17,7 +17,6 @@ * Uses native header for liquid glass button animations. */ -import { useMemo } from 'react'; import { Stack } from 'expo-router'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { createFlowLayoutScreenOptions } from '../../config/flowLayoutOptions'; @@ -47,21 +46,19 @@ const SWAP_OPTIONS = { title: 'Swap' }; function TransactionsFlowContent() { const [foreground, background] = useThemeColor(['foreground', 'background'] as const); - const screenOptions = useMemo( - () => createFlowLayoutScreenOptions({ foreground, background }, { androidSheet: true }), - [foreground, background] - ); - const transactionsOptions = useMemo( - () => ({ - title: 'Transactions', - headerTransparent: true, - headerStyle: TRANSPARENT_HEADER_STYLE, - contentStyle: { - backgroundColor: background, - }, - }), - [background] + const screenOptions = createFlowLayoutScreenOptions( + { foreground, background }, + { androidSheet: true } ); + const transactionsOptions = { + title: 'Transactions', + headerTransparent: true, + headerStyle: TRANSPARENT_HEADER_STYLE, + + contentStyle: { + backgroundColor: background, + }, + }; return ( diff --git a/app/(transactions-flow)/transactions.tsx b/app/(transactions-flow)/transactions.tsx index 7eeb50f48..085b82a07 100644 --- a/app/(transactions-flow)/transactions.tsx +++ b/app/(transactions-flow)/transactions.tsx @@ -10,7 +10,7 @@ * AUDIT.md dim-5 — strings are downcast to closed unions. */ -import React, { useCallback } from 'react'; +import React from 'react'; import { Stack } from 'expo-router'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; import { z } from 'zod'; @@ -73,6 +73,84 @@ function FilterButton() { ); } +const handleTransactionPress = (historyEntry: HistoryEntry) => { + const serializedHistoryEntry = JSON.stringify(historyEntry); + const entryState = + typeof (historyEntry as Record).state === 'string' + ? (historyEntry as Record).state + : null; + + switch (historyEntry.type) { + case 'mint': { + const pathname = getMintDetailPathname(historyEntry); + cashuLog.info('transactions.route.open_detail', { + type: historyEntry.type, + state: entryState, + pathname, + serializedLength: serializedHistoryEntry.length, + }); + router.navigate({ + pathname, + params: { + mintHistoryEntry: serializedHistoryEntry, + }, + }); + return; + } + case 'melt': { + const pathname = getMeltDetailPathname(historyEntry); + cashuLog.info('transactions.route.open_detail', { + type: historyEntry.type, + state: entryState, + pathname, + serializedLength: serializedHistoryEntry.length, + }); + router.navigate({ + pathname, + params: { + meltHistoryEntry: serializedHistoryEntry, + }, + }); + return; + } + case 'send': { + cashuLog.info('transactions.route.open_detail', { + type: historyEntry.type, + state: entryState, + pathname: '/sendToken', + serializedLength: serializedHistoryEntry.length, + }); + router.navigate({ + pathname: '/sendToken', + params: { + sendHistoryEntry: serializedHistoryEntry, + }, + }); + return; + } + case 'receive': { + cashuLog.info('transactions.route.open_detail', { + type: historyEntry.type, + state: entryState, + pathname: '/receiveToken', + serializedLength: serializedHistoryEntry.length, + }); + router.navigate({ + pathname: '/receiveToken', + params: { + receiveHistoryEntry: serializedHistoryEntry, + }, + }); + return; + } + default: + cashuLog.warn('transactions.route.open_detail.unsupported', { + type: (historyEntry as Record).type, + state: entryState, + }); + } +}; + function TransactionsRoute() { const params = useRouteParams(ParamsSchema, { where: 'transactions-flow.transactions' }); const { @@ -151,86 +229,6 @@ function TransactionsRoute() { setCounterparty, ]); - // Handle transaction press — declared before the early-return so the hook - // order stays stable across renders. - const handleTransactionPress = useCallback((historyEntry: HistoryEntry) => { - const serializedHistoryEntry = JSON.stringify(historyEntry); - const entryState = - typeof (historyEntry as Record).state === 'string' - ? (historyEntry as Record).state - : null; - - switch (historyEntry.type) { - case 'mint': { - const pathname = getMintDetailPathname(historyEntry); - cashuLog.info('transactions.route.open_detail', { - type: historyEntry.type, - state: entryState, - pathname, - serializedLength: serializedHistoryEntry.length, - }); - router.navigate({ - pathname, - params: { - mintHistoryEntry: serializedHistoryEntry, - }, - }); - return; - } - case 'melt': { - const pathname = getMeltDetailPathname(historyEntry); - cashuLog.info('transactions.route.open_detail', { - type: historyEntry.type, - state: entryState, - pathname, - serializedLength: serializedHistoryEntry.length, - }); - router.navigate({ - pathname, - params: { - meltHistoryEntry: serializedHistoryEntry, - }, - }); - return; - } - case 'send': { - cashuLog.info('transactions.route.open_detail', { - type: historyEntry.type, - state: entryState, - pathname: '/sendToken', - serializedLength: serializedHistoryEntry.length, - }); - router.navigate({ - pathname: '/sendToken', - params: { - sendHistoryEntry: serializedHistoryEntry, - }, - }); - return; - } - case 'receive': { - cashuLog.info('transactions.route.open_detail', { - type: historyEntry.type, - state: entryState, - pathname: '/receiveToken', - serializedLength: serializedHistoryEntry.length, - }); - router.navigate({ - pathname: '/receiveToken', - params: { - receiveHistoryEntry: serializedHistoryEntry, - }, - }); - return; - } - default: - cashuLog.warn('transactions.route.open_detail.unsupported', { - type: (historyEntry as Record).type, - state: entryState, - }); - } - }, []); - if (!params) return null; return ( diff --git a/app/(user-flow)/_layout.tsx b/app/(user-flow)/_layout.tsx index 517454e6d..0665835b6 100644 --- a/app/(user-flow)/_layout.tsx +++ b/app/(user-flow)/_layout.tsx @@ -11,7 +11,6 @@ * The first screen shows a close button, subsequent screens show a back button. */ -import { useMemo } from 'react'; import { Stack } from 'expo-router'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { @@ -32,18 +31,12 @@ export default function UserFlowLayout() { 'background', 'surface', ] as const); - const screenOptions = useMemo( - () => createFlowLayoutScreenOptions({ foreground, background }), - [foreground, background] - ); - const threadOptions = useMemo( - () => ({ - title: 'Thread', - contentStyle: { backgroundColor: surface }, - ...androidHeaderScrimOptions(surface), - }), - [surface] - ); + const screenOptions = createFlowLayoutScreenOptions({ foreground, background }); + const threadOptions = { + title: 'Thread', + contentStyle: { backgroundColor: surface }, + ...androidHeaderScrimOptions(surface), + }; return ( diff --git a/app/_layout.tsx b/app/_layout.tsx index 65fdc698c..084409b39 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -145,30 +145,26 @@ function AccountScopedProviders({ cashuLog.info('app.account_scoped_providers.unmount', { accountIndex }); }; }, [accountIndex]); - const InnerProviders = useMemo( - () => - compose([ - MigrationGate, - [NostrKeysProvider, { defaultAccountIndex: accountIndex }], - [NostrNDKProvider, { accountIndex }], - // NIP-46 signer service — stays cold (no sockets) until the user has - // ≥1 connected app or an in-flight pairing. Must sit directly after - // NostrNDKProvider: it gates on its isInitialized flag. - NostrSignerProvider, - [WhitenoiseProvider, { accountIndex }], - CocoProvider, - WalletContextProvider, - SovranColadaProvider, - ActionSheetProvider, - PricelistProvider, - // Mounts BitChat DM listeners once per account scope without - // starting BLE on app launch. BLE discovery announces to nearby - // bitchat clients, so explicit peer-list/chat surfaces own startup. - BitchatBLEProvider, - AppGate, - ]), - [accountIndex] - ); + const InnerProviders = compose([ + MigrationGate, + [NostrKeysProvider, { defaultAccountIndex: accountIndex }], + [NostrNDKProvider, { accountIndex }], + // NIP-46 signer service — stays cold (no sockets) until the user has + // ≥1 connected app or an in-flight pairing. Must sit directly after + // NostrNDKProvider: it gates on its isInitialized flag. + NostrSignerProvider, + [WhitenoiseProvider, { accountIndex }], + CocoProvider, + WalletContextProvider, + SovranColadaProvider, + ActionSheetProvider, + PricelistProvider, + // Mounts BitChat DM listeners once per account scope without + // starting BLE on app launch. BLE discovery announces to nearby + // bitchat clients, so explicit peer-list/chat surfaces own startup. + BitchatBLEProvider, + AppGate, + ]); return {children}; } @@ -470,13 +466,13 @@ function NativeSplashLayoutGate({ children }: { children: React.ReactNode }) { return unsub; }, []); - const onLayoutRootView = useCallback(() => { + const onLayoutRootView = () => { setHasRootLaidOut(true); rootViewRef.current?.measureInWindow((x, y) => { setParentOffset((prev) => (prev.x === x && prev.y === y ? prev : { x, y })); initLog('SplashMorph', `parent offset measured — x=${x} y=${y}`); }); - }, []); + }; // Reset the machine on a profile switch. Only honored AFTER we've unmounted // from a prior cycle — during boot the same `isInitializing=true` signal @@ -705,19 +701,16 @@ function NativeSplashLayoutGate({ children }: { children: React.ReactNode }) { [isMorphing] ); - const qrIconLayerStyle = useMemo( - () => ({ - ...StyleSheet.absoluteFillObject, - justifyContent: 'center' as const, - alignItems: 'center' as const, - opacity: isMorphing ? 1 : 0, - transitionProperty: ['opacity'], - transitionDuration: `${MORPH_DURATION_MS * 0.6}ms`, - transitionDelay: `${MORPH_DURATION_MS * 0.35}ms`, - transitionTimingFunction: MORPH_TIMING, - }), - [isMorphing] - ); + const qrIconLayerStyle = { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center' as const, + alignItems: 'center' as const, + opacity: isMorphing ? 1 : 0, + transitionProperty: ['opacity'], + transitionDuration: `${MORPH_DURATION_MS * 0.6}ms`, + transitionDelay: `${MORPH_DURATION_MS * 0.35}ms`, + transitionTimingFunction: MORPH_TIMING, + }; return ( (SHARE_CONFIGS[type]?.title ?? 'Share'); - const handleTitleChange = useCallback((title: string) => { + const handleTitleChange = (title: string) => { setHeaderTitle(title); - }, []); + }; useScreenOptions( () => ({ diff --git a/assets/icons/index.tsx b/assets/icons/index.tsx index 098448e25..81c09cd62 100644 --- a/assets/icons/index.tsx +++ b/assets/icons/index.tsx @@ -12,7 +12,7 @@ type IconProps = { name: string; color?: string; size?: number; - style?: any; + style?: StyleProp; className?: string; }; diff --git a/bun.lock b/bun.lock index b4d131893..0c6849c92 100644 --- a/bun.lock +++ b/bun.lock @@ -163,6 +163,7 @@ "@iconify-json/solar": "^1.2.5", "@iconify-json/stash": "^1.2.4", "@iconify-json/tabler": "^1.2.33", + "@okee-tech/eslint-plugin-neverthrow": "^1.0.14", "@types/jest": "^30.0.0", "@types/lodash": "^4.17.20", "@types/react": "~19.2.2", @@ -174,9 +175,12 @@ "eslint": "^9.25.1", "eslint-config-expo": "~55.0.0", "eslint-config-prettier": "^10.1.2", + "eslint-plugin-no-secrets": "^2.3.3", "eslint-plugin-prettier": "^5.5.0", "eslint-plugin-react-compiler": "19.1.0-rc.2", - "eslint-plugin-react-perf": "^3.3.3", + "eslint-plugin-security": "^4.0.1", + "eslint-plugin-sonarjs": "^4.1.0", + "eslint-plugin-unicorn": "^68.0.0", "eslint-plugin-unused-imports": "^4.1.4", "get-image-colors": "^4.0.1", "jest": "30.3.0", @@ -187,6 +191,7 @@ "patch-package": "^8.0.1", "prettier": "^3.2.5", "prettier-plugin-tailwindcss": "^0.5.11", + "react-compiler-healthcheck": "1.0.0", "react-test-renderer": "19.2.0", "tailwindcss": "^4.1.17", "typescript": "~5.9.2", @@ -252,7 +257,7 @@ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], @@ -858,6 +863,8 @@ "@nostr-dev-kit/ndk-wallet": ["@nostr-dev-kit/ndk-wallet@0.3.16", "", { "dependencies": { "@nostr-dev-kit/ndk": "2.11.0", "debug": "^4.3.4", "light-bolt11-decoder": "^3.0.0", "tseep": "^1.1.1", "typescript": "^5.4.4", "webln": "^0.3.2" }, "peerDependencies": { "@cashu/cashu-ts": "*", "@cashu/crypto": "*" } }, "sha512-pWqFfEa2jGsMOCLVgIbUG7ADS6T4Y5VMCnuh4YrEVV4+6WLBrZAWw//60dD7xOuIDamYdDc5GV5dFSE8nCmK6w=="], + "@okee-tech/eslint-plugin-neverthrow": ["@okee-tech/eslint-plugin-neverthrow@1.0.14", "", { "dependencies": { "eslint": "^9.38.0", "eslint-flat-config-utils": "^2.1.4", "neverthrow": "^8.2.0", "typescript": "^5.9.3", "typescript-eslint": "^8.46.2", "vue-eslint-parser": "^10.2.0" } }, "sha512-9XggF52hOBkXyXYjQktdTq4ZGUStANF3aSIAS+ykmBh5vQDQ4nV7A3Jdn7EaQCoo4pocCryqvyqtjFc8ohMJtw=="], + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.19.1", "", { "os": "android", "cpu": "arm" }, "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg=="], "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.19.1", "", { "os": "android", "cpu": "arm64" }, "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA=="], @@ -1190,19 +1197,19 @@ "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.62.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.62.0", "@typescript-eslint/types": "^8.62.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ=="], "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2" } }, "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.62.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g=="], "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/utils": "8.58.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg=="], "@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.62.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.62.0", "@typescript-eslint/tsconfig-utils": "8.62.0", "@typescript-eslint/types": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.62.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", "@typescript-eslint/typescript-estree": "8.62.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g=="], "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="], @@ -1424,6 +1431,8 @@ "bitchat-module": ["bitchat-module@file:modules/bitchat-module", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="], "bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], @@ -1448,6 +1457,8 @@ "bufferutil": ["bufferutil@4.1.0", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw=="], + "builtin-modules": ["builtin-modules@3.3.0", "", {}, "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -1500,13 +1511,13 @@ "chromium-edge-launcher": ["chromium-edge-launcher@0.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg=="], - "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], - "cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], + "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], @@ -1626,7 +1637,7 @@ "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], - "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], + "detect-indent": ["detect-indent@7.0.2", "", {}, "sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -1722,6 +1733,8 @@ "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], + "eslint-flat-config-utils": ["eslint-flat-config-utils@2.1.4", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-bEnmU5gqzS+4O+id9vrbP43vByjF+8KOs+QuuV4OlqAuXmnRW2zfI/Rza1fQvdihQ5h4DUo0NqFAiViD4mSrzQ=="], + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="], "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], @@ -1732,6 +1745,8 @@ "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], + "eslint-plugin-no-secrets": ["eslint-plugin-no-secrets@2.3.3", "", { "peerDependencies": { "eslint": ">=5" } }, "sha512-sroCsCscpwQxZ/O9Ml69w5qt/2nvHhXDeyUnSqSC5dMANGxt4rQgUn7xbPvDmhbMPmUCAc2Y3SRU0cXet7kNWQ=="], + "eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.12" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], @@ -1740,7 +1755,11 @@ "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], - "eslint-plugin-react-perf": ["eslint-plugin-react-perf@3.3.3", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" } }, "sha512-EzPdxsRJg5IllCAH9ny/3nK7sv9251tvKmi/d3Ouv5KzI8TB3zNhzScxL9wnh9Hvv8GYC5LEtzTauynfOEYiAw=="], + "eslint-plugin-security": ["eslint-plugin-security@4.0.1", "", { "dependencies": { "safe-regex": "^2.1.1" } }, "sha512-/lZCkOxPOWaf1jXAqgICrS8St3BMBccIPvhOSUYuV6VCr1o5nFVG998FnTLt6w2Nxb8Uo0nM8fzmnhp+GY/aEg=="], + + "eslint-plugin-sonarjs": ["eslint-plugin-sonarjs@4.1.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "builtin-modules": "^3.3.0", "bytes": "^3.1.2", "functional-red-black-tree": "^1.0.1", "globals": "^17.6.0", "jsx-ast-utils-x": "^0.1.0", "lodash.merge": "^4.6.2", "minimatch": "^10.2.5", "scslre": "^0.3.0", "semver": "^7.8.4", "ts-api-utils": "^2.5.0", "typescript": ">=5", "yaml": "^2.9.0" }, "peerDependencies": { "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, "sha512-rh+FlVz0yfd2RNIb6WqSkuGh0addX/Qi5scwQ5FphXDFrM6fZKcxP1+attJ78yUKcyYfiu6MTaISPpAFPzqRJw=="], + + "eslint-plugin-unicorn": ["eslint-plugin-unicorn@68.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "@eslint-community/eslint-utils": "^4.9.1", "browserslist": "^4.28.2", "change-case": "^5.4.4", "ci-info": "^4.4.0", "core-js-compat": "^3.49.0", "detect-indent": "^7.0.2", "find-up-simple": "^1.0.1", "globals": "^17.6.0", "indent-string": "^5.0.0", "is-builtin-module": "^5.0.0", "jsesc": "^3.1.0", "pluralize": "^8.0.0", "regjsparser": "^0.13.1", "semver": "^7.8.4", "strip-indent": "^4.1.1" }, "peerDependencies": { "eslint": ">=10.4" } }, "sha512-mHYWvX948Q4H3bGc39bsNMxD/leOuNI+Iws9NVsoSz5VA7EGP86wnz7mZ/SPSvRhefT8L4hd8DHfDuGC+lIoCQ=="], "eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.4.1", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin"] }, "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ=="], @@ -1940,6 +1959,8 @@ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "find-up-simple": ["find-up-simple@1.0.1", "", {}, "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ=="], + "find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -1972,6 +1993,8 @@ "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + "functional-red-black-tree": ["functional-red-black-tree@1.0.1", "", {}, "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g=="], + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], "fuuu": ["fuuu@0.0.8", "", {}, "sha512-zQFHWeevoaQRBtrXoHJ/qz/HvhkSz5+ZPu1+sIqXQxfh/MlB0Mi1G+1wAfaZS6z4mRtp9BgPqyYOWwBTka96SA=="], @@ -2108,6 +2131,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -2138,6 +2163,8 @@ "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], + "is-builtin-module": ["is-builtin-module@5.0.0", "", { "dependencies": { "builtin-modules": "^5.0.0" } }, "sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA=="], + "is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="], "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], @@ -2162,6 +2189,8 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], @@ -2194,6 +2223,8 @@ "is-typedarray": ["is-typedarray@1.0.0", "", {}, "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="], + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], @@ -2330,6 +2361,8 @@ "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + "jsx-ast-utils-x": ["jsx-ast-utils-x@0.1.0", "", {}, "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw=="], + "kdbush": ["kdbush@4.0.2", "", {}, "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -2402,7 +2435,7 @@ "lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="], - "log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="], + "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -2576,7 +2609,7 @@ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - "ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], + "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], @@ -2656,6 +2689,8 @@ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], "polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], @@ -2716,6 +2751,8 @@ "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], + "react-compiler-healthcheck": ["react-compiler-healthcheck@1.0.0", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "chalk": "4", "fast-glob": "^3.3.2", "ora": "5.4.1", "yargs": "^17.7.2", "zod": "^3.22.4 || ^4.0.0", "zod-validation-error": "^3.0.3 || ^4.0.0" }, "bin": { "react-compiler-healthcheck": "dist/index.js" } }, "sha512-B3YV3Z9NrABREM6f9jTfA6IuJu8x/BuXN4alrxGO9I3nSEl1+2p57KCi94T2OvveAU76/7DTeUQxqeS2AGpUIg=="], + "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], @@ -2804,6 +2841,8 @@ "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "refa": ["refa@0.12.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.8.0" } }, "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], @@ -2812,6 +2851,10 @@ "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], + "regexp-ast-analysis": ["regexp-ast-analysis@0.7.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.8.0", "refa": "^0.12.1" } }, "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A=="], + + "regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="], + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="], @@ -2842,7 +2885,7 @@ "resolve-workspace-root": ["resolve-workspace-root@2.0.1", "", {}, "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w=="], - "restore-cursor": ["restore-cursor@2.0.0", "", { "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" } }, "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q=="], + "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -2864,6 +2907,8 @@ "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + "safe-regex": ["safe-regex@2.1.1", "", { "dependencies": { "regexp-tree": "~0.1.1" } }, "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A=="], + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -2874,6 +2919,8 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "scslre": ["scslre@0.3.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.8.0", "refa": "^0.12.0", "regexp-ast-analysis": "^0.7.0" } }, "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], @@ -3000,6 +3047,8 @@ "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "strip-indent": ["strip-indent@4.1.1", "", {}, "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA=="], + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], @@ -3134,6 +3183,8 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript-eslint": ["typescript-eslint@8.62.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.62.0", "@typescript-eslint/parser": "8.62.0", "@typescript-eslint/typescript-estree": "8.62.0", "@typescript-eslint/utils": "8.62.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q=="], + "typescript-lru-cache": ["typescript-lru-cache@2.0.0", "", {}, "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA=="], "ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], @@ -3190,6 +3241,8 @@ "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], @@ -3214,6 +3267,8 @@ "vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], + "vue-eslint-parser": ["vue-eslint-parser@10.4.1", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA=="], + "w3c-xmlserializer": ["w3c-xmlserializer@4.0.0", "", { "dependencies": { "xml-name-validator": "^4.0.0" } }, "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw=="], "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], @@ -3288,7 +3343,7 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -3310,6 +3365,8 @@ "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -3318,8 +3375,12 @@ "@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@bacons/apple-targets/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "@bacons/xcode/@expo/plist": ["@expo/plist@0.0.18", "", { "dependencies": { "@xmldom/xmldom": "~0.7.0", "base64-js": "^1.2.3", "xmlbuilder": "^14.0.0" } }, "sha512-+48gRqUiz65R21CZ/IXa7RNBXgAI/uPSdvJqoN9x1hfL44DNbUoWHgHiEXTx7XelcATpDwNTz6sHLfy0iNqf+w=="], @@ -3332,6 +3393,8 @@ "@cashu/crypto/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@changesets/apply-release-plan/detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], + "@changesets/apply-release-plan/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], @@ -3356,6 +3419,10 @@ "@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + "@expo/cli/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + + "@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], + "@expo/cli/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -3368,6 +3435,8 @@ "@expo/metro-runtime/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], + "@expo/prebuild-config/@react-native/normalize-colors": ["@react-native/normalize-colors@0.83.4", "", {}, "sha512-9ezxaHjxqTkTOLg62SGg7YhFaE+fxa/jlrWP0nwf7eGFHlGOiTAaRR2KUfiN3K05e+EMbEhgcH/c7bgaXeGyJw=="], "@gandlaf21/bc-ur/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], @@ -3390,8 +3459,6 @@ "@jest/console/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - "@jest/core/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], - "@jest/core/jest-snapshot": ["jest-snapshot@30.3.0", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", "@jest/snapshot-utils": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", "expect": "30.3.0", "graceful-fs": "^4.2.11", "jest-diff": "30.3.0", "jest-matcher-utils": "30.3.0", "jest-message-util": "30.3.0", "jest-util": "30.3.0", "pretty-format": "30.3.0", "semver": "^7.7.2", "synckit": "^0.11.8" } }, "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ=="], "@jest/core/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], @@ -3512,10 +3579,30 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "@typescript-eslint/parser/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="], + + "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.62.0", "", {}, "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="], + + "@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA=="], + + "@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.62.0", "", {}, "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg=="], + + "@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.0" } }, "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA=="], + + "@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.62.0", "", {}, "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg=="], + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], @@ -3548,6 +3635,10 @@ "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "chromium-edge-launcher/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3576,6 +3667,8 @@ "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "eslint-plugin-expo/@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA=="], + "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -3586,6 +3679,16 @@ "eslint-plugin-react-compiler/hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "eslint-plugin-sonarjs/globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="], + + "eslint-plugin-sonarjs/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "eslint-plugin-sonarjs/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "eslint-plugin-unicorn/globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="], + + "eslint-plugin-unicorn/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "expo/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], @@ -3630,6 +3733,8 @@ "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "is-builtin-module/builtin-modules": ["builtin-modules@5.2.0", "", {}, "sha512-02yxLeyxF4dNl6SlY6/5HfRSrSdZ/sCPoxy2kZNP5dZZX8LSAD9aE2gtJIUgWrsQTiMPl3mxESyrobSwvRGisQ=="], + "jest-circus/@jest/environment": ["@jest/environment@30.3.0", "", { "dependencies": { "@jest/fake-timers": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "jest-mock": "30.3.0" } }, "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw=="], "jest-circus/@jest/expect": ["@jest/expect@30.3.0", "", { "dependencies": { "expect": "30.3.0", "jest-snapshot": "30.3.0" } }, "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg=="], @@ -3640,8 +3745,6 @@ "jest-config/babel-jest": ["babel-jest@30.3.0", "", { "dependencies": { "@jest/transform": "30.3.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ=="], - "jest-config/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], - "jest-config/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "jest-config/jest-environment-node": ["jest-environment-node@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/fake-timers": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "jest-mock": "30.3.0", "jest-util": "30.3.0", "jest-validate": "30.3.0" } }, "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ=="], @@ -3712,8 +3815,6 @@ "jest-snapshot/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], - "jest-util/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], - "jest-watch-select-projects/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], "jest-watch-typeahead/ansi-escapes": ["ansi-escapes@6.2.1", "", {}, "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig=="], @@ -3732,12 +3833,12 @@ "jsonfile/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "knip/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "light-bolt11-decoder/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "log-symbols/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], - "metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], @@ -3756,6 +3857,8 @@ "metro-config/jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="], + "metro-config/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "metro-source-map/source-map": ["source-map@0.5.6", "", {}, "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA=="], "metro-symbolicate/source-map": ["source-map@0.5.6", "", {}, "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA=="], @@ -3780,9 +3883,11 @@ "nostr-tools/@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="], - "ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + "ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "patch-package/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], - "ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], + "patch-package/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], "path-scurry/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], @@ -3838,8 +3943,6 @@ "request/uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="], - "restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="], - "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "rimraf/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], @@ -3868,10 +3971,18 @@ "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + "typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.62.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/type-utils": "8.62.0", "@typescript-eslint/utils": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.62.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw=="], + + "typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.62.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", "@typescript-eslint/typescript-estree": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA=="], + "uniwind/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], "vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + "vue-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "vue-eslint-parser/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "websocket/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3928,6 +4039,14 @@ "@changesets/write/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "@expo/cli/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + + "@expo/cli/ora/cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], + + "@expo/cli/ora/log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="], + + "@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], + "@expo/cli/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], "@expo/cli/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -3942,6 +4061,14 @@ "@expo/metro-runtime/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@expo/package-manager/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + + "@expo/package-manager/ora/cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], + + "@expo/package-manager/ora/log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="], + + "@expo/package-manager/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], + "@internet-privacy/marmot-ts/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -3974,6 +4101,8 @@ "@jest/fake-timers/jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@jest/fake-timers/jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "@jest/fake-timers/jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@jest/globals/@jest/types/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], @@ -4054,6 +4183,8 @@ "@react-native/metro-config/metro-config/metro-core": ["metro-core@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.84.4" } }, "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ=="], + "@react-native/metro-config/metro-config/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "@sovranbitcoin/colada/@scure/bip39/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], @@ -4078,8 +4209,26 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="], + + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="], + + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="], + + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ=="], + "applesauce-core/nostr-tools/@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="], "applesauce-core/nostr-tools/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], @@ -4120,8 +4269,12 @@ "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="], + "eslint-plugin-react-compiler/hermes-parser/hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + "eslint-plugin-sonarjs/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "expo/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], "expo/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -4158,10 +4311,14 @@ "jest-environment-jsdom/@jest/types/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "jest-environment-jsdom/jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "jest-environment-jsdom/jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "jest-environment-node/@jest/types/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "jest-environment-node/jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "jest-environment-node/jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "jest-haste-map/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], @@ -4196,6 +4353,8 @@ "jest-snapshot/jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "jest-snapshot/jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "jest-snapshot/jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "jest-snapshot/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], @@ -4220,16 +4379,12 @@ "jest-worker/jest-util/@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], + "jest-worker/jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "jest-worker/jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "log-symbols/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], - - "log-symbols/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], - - "log-symbols/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], - "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.33.3", "", {}, "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg=="], "metro-cache/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -4252,14 +4407,6 @@ "node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], - - "ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], - - "ora/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], - - "ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], - "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], @@ -4282,14 +4429,26 @@ "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], - "rimraf/glob/jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], "rimraf/glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.0" } }, "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "@typescript-eslint/typescript-estree": "8.62.0", "@typescript-eslint/utils": "8.62.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.0" } }, "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.62.0", "", {}, "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ=="], + "uniwind/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], "uniwind/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], @@ -4316,18 +4475,40 @@ "@bacons/apple-targets/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@expo/cli/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + + "@expo/cli/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "@expo/cli/ora/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + + "@expo/cli/ora/cli-cursor/restore-cursor": ["restore-cursor@2.0.0", "", { "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" } }, "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q=="], + + "@expo/cli/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], + "@expo/cli/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], "@expo/fingerprint/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "@expo/metro-runtime/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], + "@expo/package-manager/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + + "@expo/package-manager/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "@expo/package-manager/ora/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + + "@expo/package-manager/ora/cli-cursor/restore-cursor": ["restore-cursor@2.0.0", "", { "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" } }, "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q=="], + + "@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], + "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "@jest/create-cache-key-function/@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], "@jest/environment/@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], + "@jest/environment/jest-mock/jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "@jest/environment/jest-mock/jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@jest/expect/expect/jest-matcher-utils/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], @@ -4340,6 +4521,8 @@ "@jest/expect/expect/jest-util/@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], + "@jest/expect/expect/jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "@jest/expect/expect/jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@jest/fake-timers/@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], @@ -4352,6 +4535,8 @@ "@jest/globals/@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], + "@jest/globals/jest-mock/jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "@jest/globals/jest-mock/jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@jest/reporters/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], @@ -4412,14 +4597,28 @@ "@react-native/metro-config/metro-config/metro-core/metro-resolver": ["metro-resolver@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="], + + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="], + + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "applesauce-core/nostr-tools/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "applesauce-core/nostr-tools/@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], "babel-jest/@jest/transform/@jest/types/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "babel-jest/@jest/transform/jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "babel-jest/@jest/transform/jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "babel-jest/@jest/transform/write-file-atomic/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -4430,6 +4629,14 @@ "babel-plugin-module-resolver/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="], + + "eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="], + + "eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "eslint-plugin-sonarjs/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "expo/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -4470,6 +4677,8 @@ "jest-watch-typeahead/jest-watcher/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], + "jest-watch-typeahead/jest-watcher/jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "jest-watch-typeahead/jest-watcher/jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "jest-watch-typeahead/jest-watcher/string-length/char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], @@ -4478,10 +4687,6 @@ "jest-worker/jest-util/@jest/types/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], - "log-symbols/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], - - "log-symbols/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], - "metro-config/jest-validate/@jest/types/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], "metro-config/jest-validate/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], @@ -4490,10 +4695,6 @@ "metro-config/jest-validate/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], - - "ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], - "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], @@ -4516,6 +4717,32 @@ "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.62.0", "", {}, "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.62.0", "", {}, "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.62.0", "", {}, "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@expo/cli/ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "@expo/cli/ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + + "@expo/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="], + + "@expo/cli/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "@expo/package-manager/ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "@expo/package-manager/ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + + "@expo/package-manager/ora/cli-cursor/restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="], + + "@expo/package-manager/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "@jest/expect/expect/jest-matcher-utils/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], @@ -4560,8 +4787,16 @@ "@react-native/metro-config/metro-config/metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "babel-jest/@jest/transform/@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], + "eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "jest-watch-typeahead/jest-watcher/@jest/test-result/@jest/console/jest-message-util": ["jest-message-util@29.7.0", "", { "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "micromatch": "^4.0.4", "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" } }, "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w=="], "jest-watch-typeahead/jest-watcher/@jest/test-result/@jest/console/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], @@ -4570,14 +4805,10 @@ "jest-worker/jest-util/@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], - "log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], - "metro-config/jest-validate/@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], "metro-config/jest-validate/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], - "ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], - "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], @@ -4586,6 +4817,14 @@ "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + + "@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], + + "@expo/package-manager/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + + "@expo/package-manager/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], + "@jest/expect/expect/jest-matcher-utils/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], "@jest/expect/expect/jest-message-util/@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], @@ -4598,6 +4837,10 @@ "@react-native/metro-config/metro-config/jest-validate/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "jest-watch-typeahead/jest-watcher/@jest/test-result/@jest/console/jest-message-util/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], "qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], diff --git a/codereview/analyze-structure/index.mjs b/codereview/analyze-structure/index.mjs index e95eb15b4..a3f72b176 100644 --- a/codereview/analyze-structure/index.mjs +++ b/codereview/analyze-structure/index.mjs @@ -896,6 +896,39 @@ function extractImports(src) { } } + // ── Dynamic imports & CommonJS require ────────────────────────────────────── + // The static `import … from` / `export … from` forms above miss runtime-lazy + // edges: `await import('…')`, `void import('…')`, and `require('…')`. A module + // reached ONLY this way (a lazy route, a deferred side-effect import, or a + // `require()` the bundler resolves) otherwise reports as a dead orphan, and a + // symbol pulled only via `const { x } = await import('…')` reports as an unused + // export. Record the module edge in every case, plus the destructured binding + // names when the call site binds them. These are tagged `isDynamic` so they + // feed fan-in (orphan detection) and imported-names (unused-export detection) + // WITHOUT participating in static-coupling metrics — a lazy import is precisely + // how a static cycle is broken, so it must not count as a cycle/fanout edge. + const recordDynamic = (mod, namesRaw) => { + const isExternal = !mod.startsWith('.') && !mod.startsWith('@/'); + if (!byModule.has(mod)) { + byModule.set(mod, { module: mod, names: [], isExternal, isDynamic: true }); + } + const entry = byModule.get(mod); + if (!namesRaw) return; + for (const chunk of namesRaw.split(',')) { + const parts = chunk.trim().split(/\s+as\s+/); + const name = (parts[0] || '').trim().replace(/^type\s+/, ''); + if (name && /^\w+$/.test(name)) entry.names.push(name); + } + }; + // `const { a, b } = await import('mod')` / `… = require('mod')` — names + edge. + const DYNAMIC_DESTRUCTURE = + /(?:const|let|var)\s*\{([^}]*)\}\s*=\s*(?:await\s+)?(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + for (const m of stripped.matchAll(DYNAMIC_DESTRUCTURE)) recordDynamic(m[2], m[1]); + // Any remaining `import('mod')` / `require('mod')` — side-effect or + // member-accessed; edge only (bound names are not recoverable from the form). + const DYNAMIC_BARE = /(? !e.isReexport).length; + const fanin = (faninMap.get(f.fullPath) || []).filter((e) => !e.isReexport && !e.isDynamic) + .length; const fanout = fanoutMap.get(f.fullPath)?.size || 0; if (fanin === 0) continue; // also an orphan — covered by the Orphans report rows.push({ @@ -2009,7 +2051,9 @@ function renderComponent(allFiles) { function computeHubSpoke(allFiles, faninMap, fanoutMap) { return allFiles .map((f) => { - const fanin = faninMap.get(f.fullPath)?.length || 0; + // Dynamic edges are deferred, not static coupling — a hub is a static + // coordination point, so count only static (non-dynamic) fan-in here. + const fanin = (faninMap.get(f.fullPath) || []).filter((e) => !e.isDynamic).length; const fanout = fanoutMap.get(f.fullPath)?.size || 0; return { file: relative(targetDir, f.fullPath), diff --git a/components.json b/components.json deleted file mode 100644 index a45eb48ec..000000000 --- a/components.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "tailwind.config.js", - "css": "global.css", - "baseColor": "neutral", - "cssVariables": true - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - } -} diff --git a/config/modalScreens.ts b/config/modalScreens.ts index 5f17cf13a..1297b3683 100644 --- a/config/modalScreens.ts +++ b/config/modalScreens.ts @@ -1,4 +1,5 @@ import { Platform } from 'react-native'; +import { INVARIANT_BLACK } from '@/shared/lib/brandColors'; import type { NativeStackNavigationOptions } from '@react-navigation/native-stack'; export interface ModalConfig { @@ -176,7 +177,7 @@ const standaloneScreens: ModalConfig[] = [ slideFromRight('(settings-flow)'), slideFromRight('(signer-flow)'), slideFromRight('(user-flow)'), - fullScreenModal('(stories-flow)', { contentStyle: { backgroundColor: '#000' } }), + fullScreenModal('(stories-flow)', { contentStyle: { backgroundColor: INVARIANT_BLACK } }), modalTransparent('camera', 'Scan QR'), modalWithBlur('share', 'formSheet'), modalWithBlur('lightningSend', 'modal', 'Send Lightning'), diff --git a/docs/adr/0006-react-compiler-supersedes-react-perf.md b/docs/adr/0006-react-compiler-supersedes-react-perf.md new file mode 100644 index 000000000..cf019889e --- /dev/null +++ b/docs/adr/0006-react-compiler-supersedes-react-perf.md @@ -0,0 +1,90 @@ +# 0006 — React Compiler supersedes the react-perf eslint rules; verified manual-memoization sweep + +Status: accepted (corrected in part by [0007](0007-react-compiler-coverage-gate.md)) +Date: 2026-06-24 + +## Context + +A React Doctor pass flagged 890 `react-compiler-no-manual-memoization` findings +("delete this `useMemo`/`useCallback`/`memo` — React Compiler already caches it"). +React Compiler **is** enabled here (`app.json` `experiments.reactCompiler: true` +via `babel-preset-expo`), so the premise is sound in principle. + +But the rule collided with a deliberately-documented project policy. `eslint.config.js` +carried three `react-perf/jsx-no-new-{object,array,function}-as-prop` rules at `warn`, +with a comment stating they were **intentionally paired** with the react-compiler +eslint rule: *"react-perf flags the props that would prevent memoisation even with +the compiler active."* In other words, prior intent was to keep prop-stabilizing +memoization. Blanket-deleting memos that feed JSX props therefore did not *remove* a +problem — it *traded* a react-doctor finding for a react-perf finding (measured: a +single representative file produced 9 new react-perf warnings). + +Two of the "top 3" rules in the same report were also confirmed **false positives** +and were left untouched (recorded here so they are not "re-fixed" later): + +- `rn-no-non-native-navigator` (×3): the recipe's fix target + `@react-navigation/native-drawer` does not exist (npm 404); the sites are a + deliberate `expo-router/drawer` surface, a `useDrawerProgress` animation hook, and + a **type-only** `BottomTabBarProps` import — none has a native-stack equivalent. +- `js-length-check-first` (×2): both are the rule's **own** documented exceptions — + one already has the length guard on the preceding line (`feedRows.ts:212`), the + other is an intentional prefix-match (`_layout.tsx:124`) where adding a length + check would break active-route detection. + +## Decision + +1. **React Compiler is the source of truth for memoization; remove the react-perf + rules.** Deleted the three `react-perf/*` rules, their explanatory comment, the + plugin `require`, and the `eslint-plugin-react-perf` devDependency. The compiler's + auto-memoization (validated per-site, below) supersedes the manual prop-stability + lint. No shim, no downgrade-to-off — the rules are gone. + +2. **Sweep manual memoization, but only where the compiler provably covers it.** The + removal set was bounded by what `react-compiler-no-manual-memoization` flagged, + minus everything load-bearing: + - **Kept**: every `React.memo`/`memo()` render-bailout wrapper (57) — *but see + [0007](0007-react-compiler-coverage-gate.md): the FIRST pass (`584186b4`), + predating this policy, had already stripped 20 wrappers; 0007 restores them* — + every + `preserve-manual-memoization` site (the compiler's own "cannot auto-memoize" + signal), every memo with an explanatory comment above it, and every + generic-typed `useMemo`/`useCallback` (dropping the type arg breaks + inner-param inference). + - **Skipped**: any `useMemo`/`useCallback` whose binding is consumed by a hook + **dependency array** (removing it makes the dep unstable → the effect re-fires + every render), and any multi-statement block-bodied `useMemo` (not safely + inlinable). + +3. **`preserve-manual-memoization` is the guardrail, not react-perf.** A removed memo + that fed a `React.memo` child's bailout stays correct **only** if the compiler + keeps that value stable. The compiler-bailout detector is + `preserve-manual-memoization`; any file where the sweep produced a *new* preserve + finding was reverted wholesale (8 sites across 2 files, plus 14 other files with + genuine new findings). + +## Verification + +Methodology (per react-doctor's own `--scope changed --base ` design, plus a +full before/after count delta against the HEAD report): + +- Representative sample first (`sendMemoSheet.tsx`): of 15 flagged memos, 12 removed, + 3 reverted (caught by `--scope changed` as effect-deps regressions), 1 pure + function hoisted to module scope. +- Bulk AST codemod (string-splicing, formatting-preserving) over the rest, then + `prettier` + `eslint --fix` (unused-import cleanup), guarded by a per-`(file,rule)` + count comparison against the original full report. +- **Outcome: total issues 2434 → 2121, manual-memoization findings 890 → 580 + (310 cleared), and zero net-new issues of any rule.** `tsc --noEmit`, `eslint` + (0 errors), and `knip` all clean. + +## Consequences + +- Prop-stability is now the compiler's responsibility, not a lint gate. If a + component ever opts out of the compiler (`"use no memo"`) or trips a compiler + bailout, `preserve-manual-memoization` — not react-perf — is what will flag the + manual memo that must stay. +- ~270 memo findings remain (block-bodied `useMemo`, generics, dep-array-referenced + bindings, commented memos, and the 16 reverted files). These are intentionally + out of scope for this pass and can be revisited individually. +- The two false-positive rule clusters above are documented so future passes skip + them rather than re-attempting an impossible "fix." diff --git a/docs/adr/0007-react-compiler-coverage-gate.md b/docs/adr/0007-react-compiler-coverage-gate.md new file mode 100644 index 000000000..e72313a02 --- /dev/null +++ b/docs/adr/0007-react-compiler-coverage-gate.md @@ -0,0 +1,125 @@ +# 0007 — React.memo render-bailouts are not compiler-replaceable; coverage gate + +Status: accepted +Date: 2026-06-24 + +## Context + +An audit of the React-Compiler manual-memoization sweep (ADR 0006, plus the two +earlier passes) against React's official "don't strip memos blindly" guidance +(react.dev/blog/2025/10/07/react-compiler-1) found that **the first pass +(`584186b4`) removed ~20 `React.memo`/`memo()` wrappers and never restored +them** — including the app's most render-sensitive components: charts +(`MonthlyChart`, `SpentThisMonth`, `ReceivedThisMonth`, `StatsCard`), animated / +visual surfaces (`AnimatedBackgroundView`, `ScrollableGradientOverlay`, +`PatternBackground`, `WallpaperThumbnail`, `GlassSearchBar`, `AndroidHeaderScrim`), +feed media (`ImageBlock`, `LinkEmbedView`, `MemoizedMediaPagerPage`), and transfer / +transaction list rows (`TransferCard`, `TransferEntryRow`, `TransferSeparator`, +`TransferErrorBanner`, `CollapsedLegGroup`, `SwapTransactionRow`, `UnitPreviewCard`). + +This matters because **`React.memo` is a different mechanism from value +memoization, and the React Compiler does not reliably replace it.** `useMemo` / +`useCallback` cache a value *inside* a render; the compiler reproduces that. But +`React.memo` is a *props-comparison render bailout* — it stops the component from +re-rendering at all when its props are shallow-equal. The compiler only avoids a +child re-render when the *parent* is compiled and hands back a referentially stable +element; for an expensive leaf rendered across many call sites (a chart, an animated +background, a virtualized list row), the explicit `React.memo` was the author's +deliberate, guaranteed bailout. React's guidance is explicit: **keep `React.memo`.** + +Runtime logs corroborated the regression. A `log-doctor renders` pass over an +on-device session showed the three instrumented stripped components re-rendering +repeatedly — `tx.monthly_chart.render` 6×, `bg.view.render` 7×, +`ScrollableGradientOverlay` 4× — matching the user-reported lag. + +**Root cause of why it went undetected:** the `react-compiler/react-compiler` ESLint +rule — which surfaces compiler bailouts and is the tool React's guidance names for +exactly this — **never runs**. It throws at config load under the repo-wide `zod@4` +override (`eslint.config.js` loads it in a `try/catch` that falls back to `null`), +and `eslint-plugin-react-hooks` is v5.2.0 (no react-compiler rule). So no automated +check caught the stripped wrappers, and none would catch a future compiler bailout. + +A secondary miss: the sweep's "skip bindings consumed by a dependency array" guard +only inspected the **same file**. `getMintInfo` (`useMintManagement`) was +de-memoized despite feeding `useEffect` deps in *consumer* files (`useMintInfo.ts`, +`useMintContacts.ts`) — the cross-file escape-hatch case React's point #1 names. + +## Decision + +1. **Restore every stripped `React.memo`/`memo()` wrapper (20 components).** They + were author-intended render bailouts; the compiler does not replace them. A bare + shallow-compare on a component the compiler already covers is harmless; removing + it was the error. + +2. **Restore `getMintInfo`'s `useCallback([manager])`.** A callback consumed as a + `useEffect` dependency in a consumer hook is the effect-dependency escape hatch — + keep it regardless of whether the compiler happens to stabilize it. + +3. **Add a React Compiler coverage gate** (`scripts/check-react-compiler.mjs`, npm + `check:react-compiler`, CI step after Knip). It runs the compiler's own + `react-compiler-healthcheck` and **fails when compiled < total**. This restores + the bailout signal the dead ESLint rule was supposed to provide, using the + compiler itself as the source of truth rather than an approximation. + +## What was NOT changed (verified clean) + +- **Compiler bailouts: none.** `react-compiler-healthcheck` reports 596/596 + components compile. React's "manual memo still load-bearing in a bailout file" + concern does not apply here — there are no bailout files. +- **Value-`useMemo`s feeding dep arrays (the dep-array sites flagged in triage):** + left removed. The compiler re-memoizes a value keyed on the same inputs the manual + `useMemo` used; removing it does not destabilize a dependency when the compiler + compiles the component (which it does, everywhere). Primitives in dep arrays are + value-compared regardless. These are not regressions. +- The `npub_to_pubkey` / `mint.info.fetch.success` log volume is `debug`-level and + largely over-logging on hot pure-function / SWR-cache-read paths — a logging-noise + question, not a memoization regression. **Addressed in the second pass (below).** + +## Second pass — deeper diff audit + +A follow-up pass systematically scanned the sweep for *classes* of corner the +first pass found only ad-hoc. Clean: no `"use no memo"` opt-out files, no +side-effecting `useMemo` factories removed, no dependency arrays edited in place. +Three actions taken: + +1. **Gesture memos restored (3).** Pass 3 (`8bd4f743`) stripped `useMemo` from + `ThreadEmbedSheet` `panGesture`, `DistributionSlider` `gesture`, and + `NearPayScreen` `panGesture` — **despite the earlier passes explicitly excluding + gesture/animation memos as load-bearing**. A fresh `Gesture.Pan()` each render + re-registers the `GestureDetector` (a footgun the render-count log wouldn't + show). Re-wrapped with their original dependency arrays. +2. **Over-logging gated.** `mint.info.fetch.success` (useMintManagement, 31% of a + sample log) and `nostr.client.npub_to_pubkey` (30%) — together 61% of log + volume, the user-reported "repeated logs." Both are `debug` logs on hot paths + (every SWR cache read; every per-row npub decode). Removed the redundant + hook-level logs (the SWR cache layer already records hit/miss/fetch; only decode + FAILURE is kept for npub). This is logging hygiene, **not** a memo regression — + `mint.info.fetch` was confirmed over-logging, not effect re-fire. +3. **`addQuery` `useCallback` restored** (`useRecentSearches`) — a callback consumed + as a cross-file `useEffect` dep; deps (`[addSearch, surface]`) are stable, so no + exhaustive-deps fallout. + +**Context-provider `value` memos: investigated, deliberately left to the compiler.** +The sweep de-memoized ~7 context `value` memos (`OfflineProvider`, +`PricelistProvider`, `NostrNDKProvider`, `HeroTransitionProvider`, +`TransactionsFilterContext`, `SearchLayout`, `Screen`). Restoring them was tried +and **reverted**: it introduced 7 net-new `react-hooks/exhaustive-deps` warnings +because the values' callback dependencies aren't manually memoized — a hand-restored +context memo is *ineffective* unless its entire dependency graph is also manually +memoized, which is precisely the manual-memo-graph the compiler exists to replace. +The compiler stabilizes context values (all 7 providers are in the 596/596 that +compile), the runtime log showed no app-wide re-render storm, and the coverage gate +now enforces that. So context values stay compiler-owned (consistent with the +ADR-0006 thesis); re-introducing the manual memos would be redundant and lint-noisy. + +## Consequences + +- The compiler-coverage premise is now enforced in CI, not assumed. A new bailout — + or a future over-aggressive memo removal that induces one — reds the build. +- Future enhancement (not done here, to avoid destabilizing the lint run in this PR): + upgrade `eslint-plugin-react-hooks` to v6 for the in-editor `react-hooks/react-compiler` + rule, which works without the broken `zod@3`-bound `eslint-plugin-react-compiler`. +- **Corrects ADR 0006**, which stated the sweep "kept every `React.memo` wrapper + (57)." That held for passes 2–3 (`b797d47f`, `8bd4f743`), but pass 1 (`584186b4`) + had already stripped 20 before the keep-wrappers policy was adopted; this ADR + restores them. diff --git a/eslint-suppressions.json b/eslint-suppressions.json deleted file mode 100644 index 4ba420775..000000000 --- a/eslint-suppressions.json +++ /dev/null @@ -1,383 +0,0 @@ -{ - "__tests__/naggFeedClient.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "app/(drawer)/(tabs)/_layout.tsx": { - "no-restricted-syntax": { - "count": 4 - } - }, - "app/(mint-flow)/list.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "app/(stories-flow)/_layout.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "assets/icons/index.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "config/modalScreens.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, - "features/ai/components/AiMessageBubble.tsx": { - "unused-imports/no-unused-imports": { - "count": 1 - } - }, - "features/ai/hooks/useAiSend.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, - "features/feed/components/nostr/MetricsFooter.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "features/feed/components/nostr/StoriesCarousel.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "no-restricted-syntax": { - "count": 3 - } - }, - "features/feed/components/nostr/StoryProgressBar.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "features/feed/components/nostr/easeGradient.ts": { - "no-restricted-imports": { - "count": 2 - } - }, - "features/feed/components/nostr/image-overlay/AnimatedImageOverlay.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "features/feed/components/nostr/image-overlay/BottomPanel.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "features/feed/hooks/useNostrEngagement.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "features/feed/screens/NotificationsScreen.tsx": { - "no-restricted-syntax": { - "count": 5 - } - }, - "features/map/screens/MapScreen.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "features/map/screens/MerchantDetailScreen.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "features/mint/components/distribution/DistributionBar.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "features/mint/components/rebalance/rebalancePlanner.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "features/mint/components/rebalance/releaseTrustWindow.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "features/mint/screens/MintDistributionScreen.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "features/onboarding/components/types.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "features/send/providers/Colada.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "features/send/screens/PaymentRequestScreen.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "features/settings/screens/SettingsRecoveryScreen.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "features/settings/screens/SettingsRoutingScreen.tsx": { - "no-restricted-syntax": { - "count": 2 - } - }, - "features/splitBill/components/ParticipantCard.tsx": { - "no-restricted-syntax": { - "count": 10 - } - }, - "features/theme/components/UnitPreviewCard.tsx": { - "no-restricted-syntax": { - "count": 6 - } - }, - "features/theme/components/WallpaperThumbnail.tsx": { - "no-restricted-syntax": { - "count": 7 - } - }, - "features/theme/screens/GalleryScreen.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "features/transactions/components/MonthlyChart.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "features/transactions/components/TransactionLocationSection.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "features/transactions/screens/FiltersScreen.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "features/transactions/screens/SwapTransactionScreen.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - }, - "no-restricted-syntax": { - "count": 1 - } - }, - "features/wallet/components/BitcoinNearYou.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "features/whitenoise/client/network.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "features/whitenoise/components/WhitenoiseSetupBanner.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "navigation/nativeTabs.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "redux/cashu/types.deprecated.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "redux/nostr/reducer.deprecated.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "redux/store/reducer.deprecated.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "redux/store/store.deprecated.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 29 - } - }, - "shared/blocks/popup/ActionMenuHost.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "shared/blocks/popup/PopupHost.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 2 - }, - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "shared/blocks/transfer/TransferEntryRow.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "shared/blocks/transfer/TransferSeparator.tsx": { - "no-restricted-syntax": { - "count": 3 - } - }, - "shared/hooks/useThemeColor.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, - "shared/lib/colorExtraction.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "shared/lib/downloadedThemeRegistry.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "shared/lib/map/btcMapClusterCache.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "shared/lib/map/categories.ts": { - "no-restricted-syntax": { - "count": 5 - } - }, - "shared/lib/map/mapClustering.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, - "shared/lib/persist/createMergeWithSchema.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "shared/lib/popup/ToastSlab.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "no-restricted-syntax": { - "count": 2 - } - }, - "shared/lib/routstr/api.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 3 - }, - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "no-restricted-globals": { - "count": 4 - } - }, - "shared/lib/typedUpdate.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, - "shared/lib/utils.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, - "shared/providers/hero-transition/HeroTransitionProvider.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - }, - "no-restricted-syntax": { - "count": 1 - } - }, - "shared/providers/hero-transition/measure.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/stores/global/migrateSettings.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/stores/profile/restoreActiveSessionView.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "shared/ui/composed/AmountFormatter.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "shared/ui/composed/RowStatsAccent.tsx": { - "no-restricted-syntax": { - "count": 2 - } - }, - "shared/ui/composed/SpriteView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "shared/ui/composed/chat/ChatMessageBubble.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "shared/ui/composed/chat/LiquidChatComposer.tsx": { - "no-restricted-syntax": { - "count": 4 - } - }, - "shared/ui/primitives/Button.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "shared/ui/primitives/SelectableCheck/SelectableCheck.circle.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "shared/ui/primitives/Text.tsx": { - "no-restricted-syntax": { - "count": 3 - } - }, - "shared/ui/primitives/View/HStack.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "shared/ui/primitives/View/VStack.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - } -} diff --git a/eslint.config.js b/eslint.config.js index 941911a43..700cdc942 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,6 +3,15 @@ const { defineConfig } = require('eslint/config'); const expoConfig = require('eslint-config-expo/flat'); const eslintPluginPrettierRecommended = require('eslint-plugin-prettier/recommended'); +// High-value bad-pattern rule plugins adopted alongside the suppression cleanup. +const neverthrowPlugin = require('@okee-tech/eslint-plugin-neverthrow'); +const noSecretsPlugin = require('eslint-plugin-no-secrets'); +const securityPlugin = require('eslint-plugin-security'); +// eslint-plugin-unicorn v68 is ESM-only — its CJS interop exposes the plugin on +// `.default`. Fall back to the namespace for older/other resolutions. +const unicornPlugin = require('eslint-plugin-unicorn').default ?? require('eslint-plugin-unicorn'); +const sonarjsPlugin = require('eslint-plugin-sonarjs'); + // eslint-plugin-react-compiler@19.1.0-rc.2 calls zod@3's `z.function().args()`, // which the repo-wide `zod@4` override (package.json `overrides`) removes — so // `require`-ing it throws at config-load and crashes the ENTIRE lint run. Load @@ -96,33 +105,6 @@ module.exports = defineConfig([ ], }, }, - // React-perf rules — catch inline `{}`, `[]`, and `() => ...` passed - // as JSX props. Every render creates a fresh reference, defeating - // `React.memo` / `useMemo` downstream and re-firing - // `useEffect`/`useCallback` deps. Direct payoff for the perf-slice - // cluster (`stabilize derived collections in mintSelect`, `stabilise - // relay-flush re-fire`, `scope useAiSend stream + balance to a hook - // controller`, `perf(chat): stabilise chat-surface render lifecycle`). - // - // Set at `warn` initially — the rules are precise but historically - // noisy in codebases that haven't been pass-optimised. Surfaces in PR - // review without failing CI. Pairs with the react-compiler rule - // above: react-compiler flags compiler bailouts, react-perf flags - // the props that *would* prevent memoisation even with the compiler - // active. - // - // `jsx-no-jsx-as-prop` (passing a `` as a prop) is also - // available but commonly used in real patterns (slot props, list - // renderers); skipped to keep noise low. - { - files: ['**/*.tsx'], - plugins: { 'react-perf': require('eslint-plugin-react-perf') }, - rules: { - 'react-perf/jsx-no-new-object-as-prop': 'warn', - 'react-perf/jsx-no-new-array-as-prop': 'warn', - 'react-perf/jsx-no-new-function-as-prop': 'warn', - }, - }, // React Compiler ESLint rule. Flags components and effects that the // compiler can't auto-memoize: render-body mutations, prop mutations, // refs misuse, conditional hooks, derived collections that aren't @@ -149,6 +131,110 @@ module.exports = defineConfig([ }, ] : []), + // ── High-value bad-pattern rules (adopted with the suppression cleanup) ── + // Type-aware correctness rules — this block sits in the TS/TSX project so it + // picks up the `projectService` type info configured above. + // (`prefer-nullish-coalescing` is intentionally NOT here yet: its `||`->`??` + // autofix is behaviour-changing for legitimate falsy `0`/`''` values, so it + // lands at `warn` in its own block below, pending a dedicated audit.) + { + files: ['**/*.ts', '**/*.tsx'], + plugins: { neverthrow: neverthrowPlugin }, + rules: { + // A created Result/ResultAsync that is never consumed is a silently + // dropped error — in a wallet, a swallowed funds/key failure. Staged at + // `warn` (like react-compiler); promote to `error` once the floor is zero. + 'neverthrow/must-consume-result': 'warn', + // `await` on a non-thenable (a neverthrow Result, a sync value) is an + // async-correctness bug. Near-zero false positives. + '@typescript-eslint/await-thenable': 'error', + // A missing variant in a Cashu/Zod discriminated-union switch is a real + // wallet bug; non-union switches must carry a default. Staged at `warn`: + // the initial sweep surfaced ~18 genuine gaps (missing union members / + // absent defaults) whose correct handling is per-switch semantic work — a + // focused follow-up, not part of this lint cleanup. Promote to `error` + // once that floor is zero. + '@typescript-eslint/switch-exhaustiveness-check': [ + 'warn', + { requireDefaultForNonUnion: true }, + ], + // Cheap, safe autofix for the deep event.tags / profile access in feed code. + '@typescript-eslint/prefer-optional-chain': 'error', + // `amountSats || fallback` silently discards a legitimate `0`; `memo || x` + // clobbers an intentional empty string — `??` is what we mean. Staged at + // `warn`: the `||`->`??` autofix is behaviour-changing, so each site needs + // a falsy-`0`/`''`-intent audit. Surfaced now; promote to `error` after + // that dedicated audit pass. NOT auto-fixed here for that reason. + '@typescript-eslint/prefer-nullish-coalescing': 'warn', + }, + }, + // Non-type-aware bug catchers — cherry-picked from unicorn + sonarjs (NOT + // their recommended presets, which are materially noisier). Plus import + // hygiene (the `import` plugin is already registered by eslint-config-expo). + { + files: ['**/*.ts', '**/*.tsx'], + plugins: { unicorn: unicornPlugin, sonarjs: sonarjsPlugin }, + rules: { + 'unicorn/no-thenable': 'error', // an object with a `then` prop silently breaks `await` + 'unicorn/no-instanceof-array': 'error', + 'unicorn/error-message': 'error', + 'unicorn/throw-new-error': 'error', + 'sonarjs/no-identical-expressions': 'error', + 'sonarjs/no-all-duplicated-branches': 'error', + 'sonarjs/no-element-overwrite': 'error', + 'sonarjs/no-collection-size-mischeck': 'error', + 'sonarjs/non-existent-operator': 'error', // `=+` / `=!` typo detection + 'import/no-duplicates': 'error', + 'import/no-self-import': 'error', + // no-cycle is the expensive rule (graph traversal) and circular-dep + // backlog is its own cleanup — gate the depth and keep it at `warn`. + 'import/no-cycle': ['warn', { maxDepth: 3, ignoreExternal: true }], + }, + }, + // Wallet-relevant security rules. eslint-plugin-security's `recommended` is a + // documented false-positive firehose, so cherry-pick only the high-signal, + // low-FP ones (detect-object-injection deliberately omitted). + { + files: ['**/*.ts', '**/*.tsx'], + plugins: { security: securityPlugin, 'no-secrets': noSecretsPlugin }, + rules: { + 'security/detect-pseudoRandomBytes': 'error', // non-CSPRNG -> mnemonic/key risk + 'security/detect-eval-with-expression': 'error', + 'security/detect-bidi-characters': 'error', // Trojan-source attacks + // Entropy-based secret detector — the single most wallet-relevant guard + // (an accidentally committed nsec/API key). Staged at `warn` while the + // tolerance is tuned; public Nostr/Cashu encodings are ignored so they + // don't trip it. Promote to `error` once the floor is zero. + 'no-secrets/no-secrets': [ + 'warn', + { + tolerance: 4.2, + ignoreContent: [ + '^npub1', + '^nsec1', + '^nprofile1', + '^note1', + '^cashu[AB]', + '^[0-9a-f]{64}$', + ], + }, + ], + }, + }, + // no-secrets off in tests/fixtures/vectors: BIP-39 vectors, sample cashu + // tokens, and crypto test vectors are high-entropy by design, not leaks. + { + files: [ + '**/__tests__/**', + '**/*.test.ts', + '**/*.test.tsx', + '**/*.spec.ts', + '**/*.spec.tsx', + '**/*.vectors.*', + '**/fixtures/**', + ], + rules: { 'no-secrets/no-secrets': 'off' }, + }, { plugins: { 'unused-imports': require('eslint-plugin-unused-imports'), @@ -401,6 +487,20 @@ module.exports = defineConfig([ 'no-restricted-globals': 'off', }, }, + // routstr/api.ts legitimately uses raw `fetch` throughout: every endpoint + // funnels failures through `throwResponseError`, which needs the raw + // `Response` to read `response.status`, parse HTML/JSON error bodies, extract + // 402 insufficient-balance details, and clear the stored API key on 401 — plus + // `sendMessage` consumes the response as an SSE `ReadableStream`. `fetchJson` + // collapses non-ok responses into a generic Error and never exposes the + // Response, so it cannot carry this status-aware error contract. Raw fetch is + // the right tool here, not a wrapper bypass. + { + files: ['shared/lib/routstr/api.ts'], + rules: { + 'no-restricted-globals': 'off', + }, + }, // `no-console` exemptions — three legitimate sites: // - shared/lib/loggerCore.ts: transport-fallback escape hatch. When // the logger's own transport throws, it falls through to @@ -444,9 +544,48 @@ module.exports = defineConfig([ 'shared/lib/brandColors.ts', 'shared/lib/colorExtraction.ts', 'config/backgroundImageThemes.ts', + // useThemeColor IS the theme resolver — its last-resort `#000000` fallback + // is the answer, not a stray literal. + 'shared/hooks/useThemeColor.ts', + // categories.ts owns the fixed BTCMap category-marker palette (a data + // table consumed by the map; not a component, so `useThemeColor` can't + // apply and the colours are intentionally theme-invariant). + 'shared/lib/map/categories.ts', + // RowStatsAccent exports the canonical STAT_COLOR_* accent constants. + 'shared/ui/composed/RowStatsAccent.tsx', ], rules: { 'no-restricted-syntax': 'off', }, }, + // Legacy persisted-Redux migration scaffolding. These `*.deprecated.ts` + // files are still live — `app/_layout.tsx`, `shared/blocks/MigrationGate.tsx`, + // and the `shared/lib/migrations` / `shared/lib/cashu/migration.ts` paths + // import them to read and migrate OLD on-disk Redux blobs whose shapes + // predate the current stores. `any` here is reading genuinely-unknown + // historical input, not application-domain types, and the whole subsystem is + // slated for deletion once the migration window closes — typing it would be + // churn on soon-dead code. Scope the exemption to these files only. + { + files: ['redux/**/*.deprecated.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + // TODO(reanimated-migration): these three files still use the legacy RN + // `Animated` API for self-contained effects (Button ripple, SpriteView + // parallax, AmountFormatter digit roll). Migrating them to Reanimated v4 + // changes animation behaviour and is deferred to its own PR. Until then the + // raw `Animated` import is intentional here — scoped off rather than buried + // as an opaque count in eslint-suppressions.json. + { + files: [ + 'shared/ui/primitives/Button.tsx', + 'shared/ui/composed/SpriteView.tsx', + 'shared/ui/composed/AmountFormatter.tsx', + ], + rules: { + 'no-restricted-imports': 'off', + }, + }, ]); diff --git a/features/ai/components/AiHeaderTitle.tsx b/features/ai/components/AiHeaderTitle.tsx index 6bdf109a4..c8191b227 100644 --- a/features/ai/components/AiHeaderTitle.tsx +++ b/features/ai/components/AiHeaderTitle.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; import Icon from 'assets/icons'; @@ -25,7 +25,7 @@ export function AiHeaderTitle() { const balance = useRoutstrStore((s) => s.balance); const { keys: nostrKeys } = useNostrKeysContext(); - const onPress = useCallback(() => { + const onPress = () => { if (!nostrKeys?.pubkey) { staticPopup('no-wallet-available'); return; @@ -42,7 +42,7 @@ export function AiHeaderTitle() { }), }, }); - }, [nostrKeys?.pubkey]); + }; // Routstr stores msats — floor to whole sats for display. const sats = balance != null ? Math.floor(balance / 1000) : 0; diff --git a/features/ai/components/AiMessageBubble.tsx b/features/ai/components/AiMessageBubble.tsx index 8c538c920..076c9ff01 100644 --- a/features/ai/components/AiMessageBubble.tsx +++ b/features/ai/components/AiMessageBubble.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { View } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import * as Clipboard from 'expo-clipboard'; @@ -12,7 +12,6 @@ import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { popup } from '@/shared/lib/popup'; import { AmountFormatter } from '@/shared/ui/composed/AmountFormatter'; import { formatRelative } from '@/shared/lib/date'; -import { duration } from '@/shared/styles/tokens'; import { useStreamingContent, useStreamingReasoning, @@ -321,21 +320,21 @@ function AssistantBubble({ message, isStreaming, onRetry, branchNav }: AiMessage if (isLive) userToggledRef.current = false; }, [isLive]); - const handleToggleReasoning = useCallback(() => { + const handleToggleReasoning = () => { userToggledRef.current = true; setReasoningExpanded((v) => !v); - }, []); + }; - const handleCopy = useCallback(async () => { + const handleCopy = async () => { if (!displayedContent) return; await Clipboard.setStringAsync(displayedContent); popup({ message: 'Copied', icon: 'icon:lets-icons:copy', type: 'success' }); - }, [displayedContent]); + }; - const handleRetry = useCallback(() => { + const handleRetry = () => { if (!onRetry) return; onRetry(message.id); - }, [onRetry, message.id]); + }; const showActions = hasContent && !isStreaming; diff --git a/features/ai/hooks/useAiSend.ts b/features/ai/hooks/useAiSend.ts index 0a4b13937..1fa773221 100644 --- a/features/ai/hooks/useAiSend.ts +++ b/features/ai/hooks/useAiSend.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useRoutstrStore } from '@/shared/stores/profile/routstrStore'; import { useRoutstrTopUpStore } from '@/shared/stores/runtime/routstrTopUpStore'; import { useMintStore } from '@/shared/stores/profile/mintStore'; @@ -132,27 +132,24 @@ export function useAiSend() { [] ); - const navigateToTopUp = useCallback( - (pendingMessage: string) => { - if (!nostrKeys?.pubkey) { - staticPopup('no-wallet-available'); - return; - } - useRoutstrTopUpStore.getState().start(pendingMessage); - const preferredMint = useMintStore.getState().selectedMint ?? ''; - router.navigate({ - pathname: '/(send-flow)/amount', - params: { - amountEntry: JSON.stringify({ - destination: 'sendEcash', - unit: 'sat', - selectedMintUrl: preferredMint, - }), - }, - }); - }, - [nostrKeys?.pubkey] - ); + const navigateToTopUp = (pendingMessage: string) => { + if (!nostrKeys?.pubkey) { + staticPopup('no-wallet-available'); + return; + } + useRoutstrTopUpStore.getState().start(pendingMessage); + const preferredMint = useMintStore.getState().selectedMint ?? ''; + router.navigate({ + pathname: '/(send-flow)/amount', + params: { + amountEntry: JSON.stringify({ + destination: 'sendEcash', + unit: 'sat', + selectedMintUrl: preferredMint, + }), + }, + }); + }; /** * Drives a single placeholder assistant message to completion: walks the @@ -160,535 +157,509 @@ export function useAiSend() { * persisted message, and stamps cost asynchronously off the post-stream * balance diff. Both `send` and `retry` funnel through here. */ - const streamIntoPlaceholder = useCallback( - async (params: { - assistantMessageId: string; - apiMessages: { role: 'user' | 'assistant' | 'system'; content: string }[]; - flowId: string; - retriedFromMessageId?: string; - pendingUserMessageForTopUp: string; - }) => { - const { assistantMessageId, apiMessages, flowId, pendingUserMessageForTopUp } = params; - if (!apiKey) { - staticPopup('no-api-key'); - return; - } - - // Abort any prior in-flight stream and wait for its balance refresh - // to settle so this flow's snapshot reflects the prior call's debit. - streamControllerRef.current?.abort(); - if (balancePromiseRef.current) { - await balancePromiseRef.current.catch(() => {}); - } - const controller = new AbortController(); - streamControllerRef.current = controller; - - const storeState = useRoutstrStore.getState(); - const balanceBeforeMsats = storeState.balance ?? 0; - const balanceSats = Math.floor(balanceBeforeMsats / 1000); - const tier = getTierById(storeState.selectedTier); - const provider = getProviderById(storeState.selectedProvider); - const cachedModels = storeState.modelsCache?.data ?? []; - // Resolve the (provider, tier) pair against the live catalog, then - // take the affordable head of the same-tier chain across the other - // providers as runtime fallback for connect-time failures. - const primaryModel = resolveSelectedModel(provider.id, tier.id, balanceSats, cachedModels); - const allCandidates = resolveCandidateChainForSlot(provider.id, tier.id, cachedModels); - const primaryIdx = allCandidates.indexOf(primaryModel); - const candidateChain = - primaryIdx >= 0 ? allCandidates.slice(primaryIdx) : [primaryModel, ...allCandidates]; - - setStatus({ isSending: true, streamingMessageId: assistantMessageId }); - // Captures `Date.now()` for the live "Thinking for X seconds" - // counter rendered by the bubble. Resets text + reasoning channels - // so a previous stream's tail can't leak into this one. - startStreaming(assistantMessageId); - - // Hoisted so the catch block (402 popup) can reference whichever - // candidate we were last attempting when the request failed. - let modelToUse = primaryModel; - - // AI completions routinely run multiple seconds; keep the span's - // slow-escalation thresholds well above the default 1s/5s so a normal - // success doesn't log as ERROR (audit 34 F-005). - const span = aiLog.startSpan( - 'ai.send', - { - flowId, - tier: tier.id, - provider: provider.id, - model: primaryModel, - candidateCount: candidateChain.length, - balanceSats, - retried: params.retriedFromMessageId ?? null, - }, - { warnAtMs: 15_000, errorAtMs: 60_000 } - ); - - try { - const apiInputChars = apiMessages.reduce((n, m) => n + m.content.length, 0); - const sendStart = performance.now(); - aiLog.info('ai.send.request', { - flowId, - tier: tier.id, - provider: provider.id, - primaryModel, - candidates: candidateChain, - historyMessages: apiMessages.length, - totalInputChars: apiInputChars, - }); + const streamIntoPlaceholder = async (params: { + assistantMessageId: string; + apiMessages: { role: 'user' | 'assistant' | 'system'; content: string }[]; + flowId: string; + retriedFromMessageId?: string; + pendingUserMessageForTopUp: string; + }) => { + const { assistantMessageId, apiMessages, flowId, pendingUserMessageForTopUp } = params; + if (!apiKey) { + staticPopup('no-api-key'); + return; + } + + // Abort any prior in-flight stream and wait for its balance refresh + // to settle so this flow's snapshot reflects the prior call's debit. + streamControllerRef.current?.abort(); + if (balancePromiseRef.current) { + await balancePromiseRef.current.catch(() => {}); + } + const controller = new AbortController(); + streamControllerRef.current = controller; + + const storeState = useRoutstrStore.getState(); + const balanceBeforeMsats = storeState.balance ?? 0; + const balanceSats = Math.floor(balanceBeforeMsats / 1000); + const tier = getTierById(storeState.selectedTier); + const provider = getProviderById(storeState.selectedProvider); + const cachedModels = storeState.modelsCache?.data ?? []; + // Resolve the (provider, tier) pair against the live catalog, then + // take the affordable head of the same-tier chain across the other + // providers as runtime fallback for connect-time failures. + const primaryModel = resolveSelectedModel(provider.id, tier.id, balanceSats, cachedModels); + const allCandidates = resolveCandidateChainForSlot(provider.id, tier.id, cachedModels); + const primaryIdx = allCandidates.indexOf(primaryModel); + const candidateChain = + primaryIdx >= 0 ? allCandidates.slice(primaryIdx) : [primaryModel, ...allCandidates]; + + setStatus({ isSending: true, streamingMessageId: assistantMessageId }); + // Captures `Date.now()` for the live "Thinking for X seconds" + // counter rendered by the bubble. Resets text + reasoning channels + // so a previous stream's tail can't leak into this one. + startStreaming(assistantMessageId); + + // Hoisted so the catch block (402 popup) can reference whichever + // candidate we were last attempting when the request failed. + let modelToUse = primaryModel; + + // AI completions routinely run multiple seconds; keep the span's + // slow-escalation thresholds well above the default 1s/5s so a normal + // success doesn't log as ERROR (audit 34 F-005). + const span = aiLog.startSpan( + 'ai.send', + { + flowId, + tier: tier.id, + provider: provider.id, + model: primaryModel, + candidateCount: candidateChain.length, + balanceSats, + retried: params.retriedFromMessageId ?? null, + }, + { warnAtMs: 15_000, errorAtMs: 60_000 } + ); + + try { + const apiInputChars = apiMessages.reduce((n, m) => n + m.content.length, 0); + const sendStart = performance.now(); + aiLog.info('ai.send.request', { + flowId, + tier: tier.id, + provider: provider.id, + primaryModel, + candidates: candidateChain, + historyMessages: apiMessages.length, + totalInputChars: apiInputChars, + }); - // Pre-flight diagnostic: every input the affordability gate - // considered, plus the raw `/models` row for each candidate. If a - // request goes through despite the chip claiming the tier was - // unaffordable, this is the comparison point for `max_cost` vs - // actual cost (logged later as `ai.send.actual_cost`). - const candidateSnapshots = candidateChain.map((id) => { - const model = cachedModels.find((m) => m.id === id); - return { - ...getAffordabilityDetails(id, balanceSats, cachedModels), - catalogEntryFound: !!model, - sats_pricing: model?.sats_pricing ?? null, - pricing: model?.pricing ?? null, - context_length: model?.context_length ?? null, - enabled: model?.enabled ?? null, - }; - }); - aiLog.info('ai.send.affordability_check', { - flowId, - balanceMsats: balanceBeforeMsats, - balanceSats, - selectedTier: tier.id, - selectedProvider: provider.id, - buffer: AFFORD_BUFFER, - catalogSize: cachedModels.length, - candidates: candidateSnapshots, - }); + // Pre-flight diagnostic: every input the affordability gate + // considered, plus the raw `/models` row for each candidate. If a + // request goes through despite the chip claiming the tier was + // unaffordable, this is the comparison point for `max_cost` vs + // actual cost (logged later as `ai.send.actual_cost`). + const candidateSnapshots = candidateChain.map((id) => { + const model = cachedModels.find((m) => m.id === id); + return { + ...getAffordabilityDetails(id, balanceSats, cachedModels), + catalogEntryFound: !!model, + sats_pricing: model?.sats_pricing ?? null, + pricing: model?.pricing ?? null, + context_length: model?.context_length ?? null, + enabled: model?.enabled ?? null, + }; + }); + aiLog.info('ai.send.affordability_check', { + flowId, + balanceMsats: balanceBeforeMsats, + balanceSats, + selectedTier: tier.id, + selectedProvider: provider.id, + buffer: AFFORD_BUFFER, + catalogSize: cachedModels.length, + candidates: candidateSnapshots, + }); - let stream: AsyncIterable | undefined; - let lastConnectErr: unknown = null; - for (let i = 0; i < candidateChain.length; i++) { - const candidate = candidateChain[i]; - try { - const result = await sendMessage(apiKey, apiMessages, { - model: candidate, - temperature: 0.7, - signal: controller.signal, - }); - stream = result.stream; - modelToUse = candidate; - if (i > 0) { - aiLog.warn('ai.send.fallback_used', { - flowId, - tier: tier.id, - provider: provider.id, - attemptedIndex: i, - primaryModel, - fellBackTo: candidate, - }); - } - break; - } catch (err) { - lastConnectErr = err; - if (isAbortError(err)) throw err; - if (!isRetryableConnectError(err) || i === candidateChain.length - 1) throw err; - aiLog.warn('ai.send.candidate_failed', { + let stream: Awaited>['stream'] | undefined; + let lastConnectErr: unknown = null; + for (let i = 0; i < candidateChain.length; i++) { + const candidate = candidateChain[i]; + try { + const result = await sendMessage(apiKey, apiMessages, { + model: candidate, + temperature: 0.7, + signal: controller.signal, + }); + stream = result.stream; + modelToUse = candidate; + if (i > 0) { + aiLog.warn('ai.send.fallback_used', { flowId, tier: tier.id, provider: provider.id, - candidate, - status: (err as { status?: number })?.status, - nextCandidate: candidateChain[i + 1], + attemptedIndex: i, + primaryModel, + fellBackTo: candidate, }); } + break; + } catch (err) { + lastConnectErr = err; + if (isAbortError(err)) throw err; + if (!isRetryableConnectError(err) || i === candidateChain.length - 1) throw err; + aiLog.warn('ai.send.candidate_failed', { + flowId, + tier: tier.id, + provider: provider.id, + candidate, + status: (err as { status?: number })?.status, + nextCandidate: candidateChain[i + 1], + }); } - aiLog.info('ai.stream.connection', { - flowId, - model: modelToUse, - ttfb_ms: r2(performance.now() - sendStart), - }); + } + aiLog.info('ai.stream.connection', { + flowId, + model: modelToUse, + ttfb_ms: r2(performance.now() - sendStart), + }); - if (!stream) throw lastConnectErr ?? new Error('Stream not available'); - - let fullContent = ''; - let fullReasoning = ''; - let chunkCount = 0; - let chunksWithContent = 0; - let chunksWithReasoning = 0; - let thinkingSec = 0; - let lastHapticAt = 0; - let lastChunkAt = 0; - let lastProgressAt = sendStart; - let lastProgressChunks = 0; - let lastProgressChars = 0; - let firstChunkAt = 0; - let firstContentAt = 0; - let firstReasoningAt = 0; - let maxGap = 0; - let stallCount = 0; - - for await (const chunk of stream) { - chunkCount++; - const now = performance.now(); - const gap = lastChunkAt === 0 ? 0 : now - lastChunkAt; - if (gap > maxGap) maxGap = gap; - if (lastChunkAt !== 0 && gap > STREAM_STALL_THRESHOLD_MS) { - stallCount++; - aiLog.warn('ai.stream.stall', { + if (!stream) throw lastConnectErr ?? new Error('Stream not available'); + + let fullContent = ''; + let fullReasoning = ''; + let chunkCount = 0; + let chunksWithContent = 0; + let chunksWithReasoning = 0; + let thinkingSec = 0; + let lastHapticAt = 0; + let lastChunkAt = 0; + let lastProgressAt = sendStart; + let lastProgressChunks = 0; + let lastProgressChars = 0; + let firstChunkAt = 0; + let firstContentAt = 0; + let firstReasoningAt = 0; + let maxGap = 0; + let stallCount = 0; + + for await (const chunk of stream) { + chunkCount++; + const now = performance.now(); + const gap = lastChunkAt === 0 ? 0 : now - lastChunkAt; + if (gap > maxGap) maxGap = gap; + if (lastChunkAt !== 0 && gap > STREAM_STALL_THRESHOLD_MS) { + stallCount++; + aiLog.warn('ai.stream.stall', { + flowId, + gap_ms: r2(gap), + afterChunk: chunkCount - 1, + charsBefore: fullContent.length, + }); + } + lastChunkAt = now; + + if (firstChunkAt === 0) { + firstChunkAt = now; + aiLog.info('ai.stream.first_chunk', { + flowId, + ttfc_ms: r2(now - sendStart), + }); + } + + const delta = chunk.choices?.[0]?.delta; + const content = delta?.content || delta?.message?.content || delta?.text || null; + const reasoning = delta?.reasoning_content || delta?.reasoning || null; + + if (reasoning) { + if (firstReasoningAt === 0) { + firstReasoningAt = now; + aiLog.info('ai.stream.first_reasoning', { flowId, - gap_ms: r2(gap), - afterChunk: chunkCount - 1, - charsBefore: fullContent.length, + ttfr_ms: r2(now - sendStart), }); } - lastChunkAt = now; + chunksWithReasoning++; + fullReasoning += reasoning; + // Push the live reasoning to the streaming buffer so the bubble + // can render the model's thought process as it arrives, instead + // of waiting for stream completion to surface it. + setStreamingReasoning(assistantMessageId, fullReasoning); + } - if (firstChunkAt === 0) { - firstChunkAt = now; - aiLog.info('ai.stream.first_chunk', { + if (content) { + if (firstContentAt === 0) { + firstContentAt = now; + thinkingSec = Math.round((now - sendStart) / 1000); + aiLog.info('ai.stream.first_token', { flowId, - ttfc_ms: r2(now - sendStart), + ttft_ms: r2(now - sendStart), + hadReasoningFirst: firstReasoningAt > 0, }); } + chunksWithContent++; + fullContent += content; + setStreamingText(assistantMessageId, fullContent); - const delta = chunk.choices?.[0]?.delta; - const content = - delta?.content || (delta as any)?.message?.content || (delta as any)?.text || null; - const reasoning = (delta as any)?.reasoning_content || (delta as any)?.reasoning || null; - - if (reasoning) { - if (firstReasoningAt === 0) { - firstReasoningAt = now; - aiLog.info('ai.stream.first_reasoning', { - flowId, - ttfr_ms: r2(now - sendStart), - }); - } - chunksWithReasoning++; - fullReasoning += reasoning; - // Push the live reasoning to the streaming buffer so the bubble - // can render the model's thought process as it arrives, instead - // of waiting for stream completion to surface it. - setStreamingReasoning(assistantMessageId, fullReasoning); - } - - if (content) { - if (firstContentAt === 0) { - firstContentAt = now; - thinkingSec = Math.round((now - sendStart) / 1000); - aiLog.info('ai.stream.first_token', { - flowId, - ttft_ms: r2(now - sendStart), - hadReasoningFirst: firstReasoningAt > 0, - }); - } - chunksWithContent++; - fullContent += content; - setStreamingText(assistantMessageId, fullContent); - - if (now - lastHapticAt >= HAPTIC_THROTTLE_MS) { - lastHapticAt = now; - void EnhancedHaptics.navigateHaptic(); - } - } - - if (now - lastProgressAt >= STREAM_PROGRESS_INTERVAL_MS) { - const windowMs = now - lastProgressAt; - const windowChunks = chunkCount - lastProgressChunks; - const windowChars = fullContent.length - lastProgressChars; - aiLog.debug('ai.stream.progress', { - flowId, - chunks: chunkCount, - chars: fullContent.length, - elapsed_ms: r2(now - sendStart), - window_ms: r2(windowMs), - window_chunks_per_sec: r2((windowChunks / windowMs) * 1000), - window_chars_per_sec: r2((windowChars / windowMs) * 1000), - }); - lastProgressAt = now; - lastProgressChunks = chunkCount; - lastProgressChars = fullContent.length; + if (now - lastHapticAt >= HAPTIC_THROTTLE_MS) { + lastHapticAt = now; + void EnhancedHaptics.navigateHaptic(); } } - const streamEnd = performance.now(); - const totalMs = streamEnd - sendStart; - const streamMs = firstChunkAt > 0 ? streamEnd - firstChunkAt : 0; - aiLog.info('ai.stream.complete', { - flowId, - model: modelToUse, - chunks: chunkCount, - chunksWithContent, - chunksWithReasoning, - chars: fullContent.length, - reasoningChars: fullReasoning.length, - ttfc_ms: firstChunkAt > 0 ? r2(firstChunkAt - sendStart) : null, - ttft_ms: firstContentAt > 0 ? r2(firstContentAt - sendStart) : null, - ttfr_ms: firstReasoningAt > 0 ? r2(firstReasoningAt - sendStart) : null, - total_ms: r2(totalMs), - stream_ms: r2(streamMs), - max_gap_ms: r2(maxGap), - stalls: stallCount, - avg_inter_chunk_ms: chunkCount > 1 ? r2(streamMs / (chunkCount - 1)) : 0, - chunks_per_sec: streamMs > 0 ? r2((chunkCount / streamMs) * 1000) : 0, - chars_per_sec: streamMs > 0 ? r2((fullContent.length / streamMs) * 1000) : 0, - }); - - // Single atomic write: persist final content + reasoning + thinking - // duration in place, preserving the placeholder's parentId so the - // tree shape doesn't shift mid-finalisation. - const finalizePayload = pickFinalizeMessage({ - fullContent, - fullReasoning, - chunkCount, - }); - if (finalizePayload) { - finalizeAssistantMessage(assistantMessageId, { - ...finalizePayload, - thinkingDurationSec: thinkingSec, + if (now - lastProgressAt >= STREAM_PROGRESS_INTERVAL_MS) { + const windowMs = now - lastProgressAt; + const windowChunks = chunkCount - lastProgressChunks; + const windowChars = fullContent.length - lastProgressChars; + aiLog.debug('ai.stream.progress', { + flowId, + chunks: chunkCount, + chars: fullContent.length, + elapsed_ms: r2(now - sendStart), + window_ms: r2(windowMs), + window_chunks_per_sec: r2((windowChunks / windowMs) * 1000), + window_chars_per_sec: r2((windowChars / windowMs) * 1000), }); + lastProgressAt = now; + lastProgressChunks = chunkCount; + lastProgressChars = fullContent.length; } - aiLog.info('ai.send.assistant_finalized', { - flowId, - messageId: assistantMessageId, - chars: fullContent.length, + } + + const streamEnd = performance.now(); + const totalMs = streamEnd - sendStart; + const streamMs = firstChunkAt > 0 ? streamEnd - firstChunkAt : 0; + aiLog.info('ai.stream.complete', { + flowId, + model: modelToUse, + chunks: chunkCount, + chunksWithContent, + chunksWithReasoning, + chars: fullContent.length, + reasoningChars: fullReasoning.length, + ttfc_ms: firstChunkAt > 0 ? r2(firstChunkAt - sendStart) : null, + ttft_ms: firstContentAt > 0 ? r2(firstContentAt - sendStart) : null, + ttfr_ms: firstReasoningAt > 0 ? r2(firstReasoningAt - sendStart) : null, + total_ms: r2(totalMs), + stream_ms: r2(streamMs), + max_gap_ms: r2(maxGap), + stalls: stallCount, + avg_inter_chunk_ms: chunkCount > 1 ? r2(streamMs / (chunkCount - 1)) : 0, + chunks_per_sec: streamMs > 0 ? r2((chunkCount / streamMs) * 1000) : 0, + chars_per_sec: streamMs > 0 ? r2((fullContent.length / streamMs) * 1000) : 0, + }); + + // Single atomic write: persist final content + reasoning + thinking + // duration in place, preserving the placeholder's parentId so the + // tree shape doesn't shift mid-finalisation. + const finalizePayload = pickFinalizeMessage({ + fullContent, + fullReasoning, + chunkCount, + }); + if (finalizePayload) { + finalizeAssistantMessage(assistantMessageId, { + ...finalizePayload, + thinkingDurationSec: thinkingSec, }); + } + aiLog.info('ai.send.assistant_finalized', { + flowId, + messageId: assistantMessageId, + chars: fullContent.length, + }); - if (!isAnonymous) updateCurrentSessionTitle(); - - // Balance refresh runs detached. We use the diff (before − after) to - // stamp `costSats` on the just-finalised message — works for both - // the initial send and retries because each retry has its own - // pre-call balance snapshot. - const balanceStart = performance.now(); - // Snapshot what the affordability gate predicted for the model we - // actually used, so the post-stream log can quote both numbers. - const predicted = getAffordabilityDetails(modelToUse, balanceSats, cachedModels); - const usedModelCatalogEntry = cachedModels.find((m) => m.id === modelToUse) ?? null; - // Track this flow's balance promise so the next streamIntoPlaceholder - // call awaits it before snapshotting balanceBeforeMsats — without that - // a retry tap during balance refresh re-uses the stale store balance - // and double-counts the cost diff. - const balancePromise = checkBalance(apiKey, { signal: controller.signal }) - .then((data) => { - setBalance(data.balance); - const costMsats = balanceBeforeMsats - data.balance; - const costSats = costMsats > 0 ? Math.ceil(costMsats / 1000) : undefined; - if (costSats != null && finalizePayload) { - finalizeAssistantMessage(assistantMessageId, { - ...finalizePayload, - thinkingDurationSec: thinkingSec, - costSats, - }); - } - aiLog.info('ai.balance.refresh', { - flowId, - duration_ms: r2(performance.now() - balanceStart), - balance_msats: data.balance, - balance_sats: Math.floor(data.balance / 1000), - costSats: costSats ?? null, + if (!isAnonymous) updateCurrentSessionTitle(); + + // Balance refresh runs detached. We use the diff (before − after) to + // stamp `costSats` on the just-finalised message — works for both + // the initial send and retries because each retry has its own + // pre-call balance snapshot. + const balanceStart = performance.now(); + // Snapshot what the affordability gate predicted for the model we + // actually used, so the post-stream log can quote both numbers. + const predicted = getAffordabilityDetails(modelToUse, balanceSats, cachedModels); + const usedModelCatalogEntry = cachedModels.find((m) => m.id === modelToUse) ?? null; + // Track this flow's balance promise so the next streamIntoPlaceholder + // call awaits it before snapshotting balanceBeforeMsats — without that + // a retry tap during balance refresh re-uses the stale store balance + // and double-counts the cost diff. + const balancePromise = checkBalance(apiKey, { signal: controller.signal }) + .then((data) => { + setBalance(data.balance); + const costMsats = balanceBeforeMsats - data.balance; + const costSats = costMsats > 0 ? Math.ceil(costMsats / 1000) : undefined; + if (costSats != null && finalizePayload) { + finalizeAssistantMessage(assistantMessageId, { + ...finalizePayload, + thinkingDurationSec: thinkingSec, + costSats, }); - // Predicted-vs-actual reconciliation. This is the log that - // proves the "Top up X sats" indicator is over-conservative - // when actual cost is much lower than the buffered threshold. - const predictedCeilingSats = predicted.bufferedThresholdSats; - const ratio = - predictedCeilingSats != null && costSats != null && costSats > 0 - ? r2(predictedCeilingSats / costSats) - : null; - aiLog.info('ai.send.actual_cost', { - flowId, - modelUsed: modelToUse, - tier: tier.id, - provider: provider.id, - actualCostMsats: costMsats, - actualCostSats: costSats ?? 0, - // New realistic estimate (what `canAffordModel` now gates on). - predicted_estimated_turn_sats: predicted.estimatedTurnCostSats, - // Raw catalog ceiling, kept so we can keep watching the - // estimate-vs-worst-case spread over time. - predicted_max_cost_sats: predicted.maxCostSats, - predicted_buffered_threshold_sats: predictedCeilingSats, - predicted_affordable: predicted.affordable, - predicted_deficit_sats: predicted.deficitSats, - balance_before_msats: balanceBeforeMsats, - balance_before_sats: Math.floor(balanceBeforeMsats / 1000), - balance_after_msats: data.balance, - balance_after_sats: Math.floor(data.balance / 1000), - // ratio = how many times larger the buffered threshold is - // than reality. We want this near 1; the previous max_cost - // gate was producing 100× ratios for chat turns. - predicted_to_actual_ratio: ratio, - catalog_sats_pricing: usedModelCatalogEntry?.sats_pricing ?? null, - catalog_context_length: usedModelCatalogEntry?.context_length ?? null, - }); - }) - .catch((err) => { - if (isAbortError(err)) return; - aiLog.warn('ai.send.balance_refresh_failed', { flowId, err }); + } + aiLog.info('ai.balance.refresh', { + flowId, + duration_ms: r2(performance.now() - balanceStart), + balance_msats: data.balance, + balance_sats: Math.floor(data.balance / 1000), + costSats: costSats ?? null, }); - balancePromiseRef.current = balancePromise; - - span.end({ outcome: 'ok', chunks: chunkCount, chars: fullContent.length }); - } catch (err: any) { - if (isAbortError(err)) { - aiLog.info('ai.send.aborted', { flowId }); - removeMessages(new Set([assistantMessageId])); - span.end({ outcome: 'aborted' }); - return; - } - aiLog.error('ai.send.failed', { - flowId, - status: err?.status, - type: err?.error?.type, - err, - }); - span.end({ outcome: 'error', status: err?.status }); - // Drop the placeholder on failure so the chat list doesn't show an - // empty bubble. The active path re-derives to the previous leaf. - removeMessages(new Set([assistantMessageId])); - - if (err?.status === 402) { - const requiredMsats = err?.error?.details?.required as number | undefined; - const availableMsats = err?.error?.details?.available as number | undefined; - const requiredSats = requiredMsats != null ? Math.ceil(requiredMsats / 1000) : null; - const availableSats = availableMsats != null ? Math.floor(availableMsats / 1000) : null; - const friendlyName = getModelDisplayName(modelToUse, cachedModels); - const detail = - requiredSats != null && availableSats != null - ? `${friendlyName} needs ${requiredSats} sats; you have ${availableSats}.` - : `${friendlyName} costs more than your current balance.`; - actionMenuPopup({ - title: 'Insufficient balance', - buttons: [ - { - text: 'Switch to Auto', - description: detail, - icon: AUTO_ICON, - onPress: (close) => { - // Drop to the cheapest tier on the user's currently - // selected provider — we keep their provider choice so - // a Claude user doesn't unexpectedly land on OpenAI just - // because the request 402'd. - setSelectedSlot({ - provider: provider.id, - tier: 'auto', - }); - paramPopup('model-switched', { modelName: `${provider.label} Auto` }); - close(); - }, - }, - { - text: 'Top up', - icon: 'fluent:wallet-20-filled', - onPress: (close) => { - close(); - navigateToTopUp(pendingUserMessageForTopUp); - }, - }, - ], + // Predicted-vs-actual reconciliation. This is the log that + // proves the "Top up X sats" indicator is over-conservative + // when actual cost is much lower than the buffered threshold. + const predictedCeilingSats = predicted.bufferedThresholdSats; + const ratio = + predictedCeilingSats != null && costSats != null && costSats > 0 + ? r2(predictedCeilingSats / costSats) + : null; + aiLog.info('ai.send.actual_cost', { + flowId, + modelUsed: modelToUse, + tier: tier.id, + provider: provider.id, + actualCostMsats: costMsats, + actualCostSats: costSats ?? 0, + // New realistic estimate (what `canAffordModel` now gates on). + predicted_estimated_turn_sats: predicted.estimatedTurnCostSats, + // Raw catalog ceiling, kept so we can keep watching the + // estimate-vs-worst-case spread over time. + predicted_max_cost_sats: predicted.maxCostSats, + predicted_buffered_threshold_sats: predictedCeilingSats, + predicted_affordable: predicted.affordable, + predicted_deficit_sats: predicted.deficitSats, + balance_before_msats: balanceBeforeMsats, + balance_before_sats: Math.floor(balanceBeforeMsats / 1000), + balance_after_msats: data.balance, + balance_after_sats: Math.floor(data.balance / 1000), + // ratio = how many times larger the buffered threshold is + // than reality. We want this near 1; the previous max_cost + // gate was producing 100× ratios for chat turns. + predicted_to_actual_ratio: ratio, + catalog_sats_pricing: usedModelCatalogEntry?.sats_pricing ?? null, + catalog_context_length: usedModelCatalogEntry?.context_length ?? null, }); - } else { - staticPopup('send-message-failed', { text: err?.error?.message ?? err?.message }); - } - } finally { - clearStreaming(); - setStatus({ isSending: false, streamingMessageId: null }); - } - }, - [ - apiKey, - isAnonymous, - removeMessages, - finalizeAssistantMessage, - setBalance, - setSelectedSlot, - updateCurrentSessionTitle, - navigateToTopUp, - ] - ); - - const sendInner = useCallback( - async (userMessage: string) => { - const trimmed = userMessage.trim(); - if (!trimmed) return; + }) + .catch((err) => { + if (isAbortError(err)) return; + aiLog.warn('ai.send.balance_refresh_failed', { flowId, err }); + }); + balancePromiseRef.current = balancePromise; - if (!apiKey) { - staticPopup('no-api-key'); + span.end({ outcome: 'ok', chunks: chunkCount, chars: fullContent.length }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- caught value is an arbitrary thrown error (RoutstrError or Error); the handler reads .status/.error + } catch (err: any) { + if (isAbortError(err)) { + aiLog.info('ai.send.aborted', { flowId }); + removeMessages(new Set([assistantMessageId])); + span.end({ outcome: 'aborted' }); return; } - - if (!isAnonymous && !currentSessionId) { - createSession(); - } - - // Active path determines the parent of the new user message — we - // append under whatever branch is currently visible to the user. - const stateNow = useRoutstrStore.getState(); - const activePath = deriveActivePath(stateNow.conversationHistory, stateNow.activeChildren); - const tail = activePath[activePath.length - 1]; - const parentForUser: string | null = tail?.id ?? null; - - const timestamp = Date.now(); - const userMessageId = `msg-${timestamp}-u`; - const assistantMessageId = `msg-${timestamp + 1}-a`; - const flowId = `ai-send-${timestamp}`; - - addMessage({ - id: userMessageId, - parentId: parentForUser, - role: 'user', - content: trimmed, - timestamp, - pending: true, - }); - addMessage({ - id: assistantMessageId, - parentId: userMessageId, - role: 'assistant', - content: '', - timestamp: timestamp + 1, + aiLog.error('ai.send.failed', { + flowId, + status: err?.status, + type: err?.error?.type, + err, }); - - // Build context = active path + just-added user message. We read the - // freshly-added messages via the active path because the store has - // already absorbed them. - const stateAfter = useRoutstrStore.getState(); - const apiMessages = deriveActivePath( - stateAfter.conversationHistory, - stateAfter.activeChildren - ) - .filter((m) => m.id !== assistantMessageId && m.content) - .map((m) => ({ - role: m.role as 'user' | 'assistant' | 'system', - content: m.content, - })); - - try { - await streamIntoPlaceholder({ - assistantMessageId, - apiMessages, - flowId, - pendingUserMessageForTopUp: trimmed, + span.end({ outcome: 'error', status: err?.status }); + // Drop the placeholder on failure so the chat list doesn't show an + // empty bubble. The active path re-derives to the previous leaf. + removeMessages(new Set([assistantMessageId])); + + if (err?.status === 402) { + const requiredMsats = err?.error?.details?.required as number | undefined; + const availableMsats = err?.error?.details?.available as number | undefined; + const requiredSats = requiredMsats != null ? Math.ceil(requiredMsats / 1000) : null; + const availableSats = availableMsats != null ? Math.floor(availableMsats / 1000) : null; + const friendlyName = getModelDisplayName(modelToUse, cachedModels); + const detail = + requiredSats != null && availableSats != null + ? `${friendlyName} needs ${requiredSats} sats; you have ${availableSats}.` + : `${friendlyName} costs more than your current balance.`; + actionMenuPopup({ + title: 'Insufficient balance', + buttons: [ + { + text: 'Switch to Auto', + description: detail, + icon: AUTO_ICON, + onPress: (close) => { + // Drop to the cheapest tier on the user's currently + // selected provider — we keep their provider choice so + // a Claude user doesn't unexpectedly land on OpenAI just + // because the request 402'd. + setSelectedSlot({ + provider: provider.id, + tier: 'auto', + }); + paramPopup('model-switched', { modelName: `${provider.label} Auto` }); + close(); + }, + }, + { + text: 'Top up', + icon: 'fluent:wallet-20-filled', + onPress: (close) => { + close(); + navigateToTopUp(pendingUserMessageForTopUp); + }, + }, + ], }); - } finally { - // The user message's optimistic spinner clears the moment the - // streaming round-trip resolves — success or error, the request - // left our hands. Errors surface via the assistant placeholder / - // popup, not the user bubble's check. - setMessagePending(userMessageId, false); + } else { + staticPopup('send-message-failed', { text: err?.error?.message ?? err?.message }); } - }, - [ - apiKey, - isAnonymous, - currentSessionId, - createSession, - addMessage, - setMessagePending, - streamIntoPlaceholder, - ] - ); + } finally { + clearStreaming(); + setStatus({ isSending: false, streamingMessageId: null }); + } + }; + + const sendInner = async (userMessage: string) => { + const trimmed = userMessage.trim(); + if (!trimmed) return; + + if (!apiKey) { + staticPopup('no-api-key'); + return; + } + + if (!isAnonymous && !currentSessionId) { + createSession(); + } + + // Active path determines the parent of the new user message — we + // append under whatever branch is currently visible to the user. + const stateNow = useRoutstrStore.getState(); + const activePath = deriveActivePath(stateNow.conversationHistory, stateNow.activeChildren); + const tail = activePath[activePath.length - 1]; + const parentForUser: string | null = tail?.id ?? null; + + const timestamp = Date.now(); + const userMessageId = `msg-${timestamp}-u`; + const assistantMessageId = `msg-${timestamp + 1}-a`; + const flowId = `ai-send-${timestamp}`; + + addMessage({ + id: userMessageId, + parentId: parentForUser, + role: 'user', + content: trimmed, + timestamp, + pending: true, + }); + addMessage({ + id: assistantMessageId, + parentId: userMessageId, + role: 'assistant', + content: '', + timestamp: timestamp + 1, + }); + + // Build context = active path + just-added user message. We read the + // freshly-added messages via the active path because the store has + // already absorbed them. + const stateAfter = useRoutstrStore.getState(); + const apiMessages = deriveActivePath(stateAfter.conversationHistory, stateAfter.activeChildren) + .filter((m) => m.id !== assistantMessageId && m.content) + .map((m) => ({ + role: m.role as 'user' | 'assistant' | 'system', + content: m.content, + })); + + try { + await streamIntoPlaceholder({ + assistantMessageId, + apiMessages, + flowId, + pendingUserMessageForTopUp: trimmed, + }); + } finally { + // The user message's optimistic spinner clears the moment the + // streaming round-trip resolves — success or error, the request + // left our hands. Errors surface via the assistant placeholder / + // popup, not the user bubble's check. + setMessagePending(userMessageId, false); + } + }; // `isSending` (React state) only blocks subsequent sends after the first // `setStatus` flush — a rapid double-tap lands both calls into @@ -704,66 +675,63 @@ export function useAiSend() { * sibling (follow-up exchanges) drop out of view immediately and can be * brought back via the bubble's chevron nav. */ - const retryInner = useCallback( - async (messageId: string) => { - if (!apiKey) { - staticPopup('no-api-key'); - return; - } - const stateNow = useRoutstrStore.getState(); - const original = stateNow.conversationHistory.find((m) => m.id === messageId); - if (!original || original.role !== 'assistant') { - aiLog.warn('ai.retry.invalid_target', { messageId, role: original?.role }); - return; - } - // Build the context that produced `messageId`: every ancestor up to - // and including the user turn that prompted it. Excludes `messageId` - // itself so we generate a *fresh* response. - const ancestors = getAncestorsExclusive(messageId, stateNow.conversationHistory); - const apiMessages = ancestors - .filter((m) => m.content) - .map((m) => ({ - role: m.role as 'user' | 'assistant' | 'system', - content: m.content, - })); - if (apiMessages.length === 0) { - aiLog.warn('ai.retry.no_context', { messageId }); - return; - } - - const timestamp = Date.now(); - const newAssistantId = `msg-${timestamp}-r`; - const flowId = `ai-retry-${timestamp}`; - const parentId = original.parentId ?? null; - - addMessage({ - id: newAssistantId, - parentId, - role: 'assistant', - content: '', - timestamp, - }); - // Flip immediately so the chat list shows the streaming sibling as - // the active branch. The user can navigate back to the original via - // the bubble's chevron nav. - if (parentId != null) { - setActiveBranch(parentId, newAssistantId); - } - - // The user message that prompted this exchange — used as the "pending - // message" if the retry hits a 402 and we need to surface a top-up. - const lastUserContent = ancestors.filter((m) => m.role === 'user').pop()?.content ?? ''; - - await streamIntoPlaceholder({ - assistantMessageId: newAssistantId, - apiMessages, - flowId, - retriedFromMessageId: messageId, - pendingUserMessageForTopUp: lastUserContent, - }); - }, - [apiKey, addMessage, setActiveBranch, streamIntoPlaceholder] - ); + const retryInner = async (messageId: string) => { + if (!apiKey) { + staticPopup('no-api-key'); + return; + } + const stateNow = useRoutstrStore.getState(); + const original = stateNow.conversationHistory.find((m) => m.id === messageId); + if (original?.role !== 'assistant') { + aiLog.warn('ai.retry.invalid_target', { messageId, role: original?.role }); + return; + } + // Build the context that produced `messageId`: every ancestor up to + // and including the user turn that prompted it. Excludes `messageId` + // itself so we generate a *fresh* response. + const ancestors = getAncestorsExclusive(messageId, stateNow.conversationHistory); + const apiMessages = ancestors + .filter((m) => m.content) + .map((m) => ({ + role: m.role as 'user' | 'assistant' | 'system', + content: m.content, + })); + if (apiMessages.length === 0) { + aiLog.warn('ai.retry.no_context', { messageId }); + return; + } + + const timestamp = Date.now(); + const newAssistantId = `msg-${timestamp}-r`; + const flowId = `ai-retry-${timestamp}`; + const parentId = original.parentId ?? null; + + addMessage({ + id: newAssistantId, + parentId, + role: 'assistant', + content: '', + timestamp, + }); + // Flip immediately so the chat list shows the streaming sibling as + // the active branch. The user can navigate back to the original via + // the bubble's chevron nav. + if (parentId != null) { + setActiveBranch(parentId, newAssistantId); + } + + // The user message that prompted this exchange — used as the "pending + // message" if the retry hits a 402 and we need to surface a top-up. + const lastUserContent = ancestors.filter((m) => m.role === 'user').pop()?.content ?? ''; + + await streamIntoPlaceholder({ + assistantMessageId: newAssistantId, + apiMessages, + flowId, + retriedFromMessageId: messageId, + pendingUserMessageForTopUp: lastUserContent, + }); + }; // Retry shares the double-tap exposure with `send`: a rapid tap on the // regenerate chevron would spawn two sibling assistants and bill twice. diff --git a/features/ai/screens/AiChatScreen.tsx b/features/ai/screens/AiChatScreen.tsx index 0ad25c0fd..445a8e500 100644 --- a/features/ai/screens/AiChatScreen.tsx +++ b/features/ai/screens/AiChatScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Keyboard, ScrollView, View as RNView, type LayoutChangeEvent } from 'react-native'; import { useHeaderHeight } from '@react-navigation/elements'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -64,6 +64,8 @@ const ESTIMATED_BUBBLE_HEIGHT = 80; * a visible band where the list meets the composer, and FlashList's * separate virtualization sidesteps it cleanly. */ +const keyExtractor = (m: RoutstrMessage) => m.id; + export function AiChatScreen() { useLifecycleLogger('AiChatScreen'); @@ -126,10 +128,7 @@ export function AiChatScreen() { const { send, retry, isSending, streamingMessageId } = useAiSend(); - const activeMessages = useMemo( - () => deriveActivePath(conversationHistory, activeChildren), - [conversationHistory, activeChildren] - ); + const activeMessages = deriveActivePath(conversationHistory, activeChildren); // Mount visibility — narrow set, fires once. No imperative scroll-chase // plumbing: FlashList's `maintainVisibleContentPosition` with @@ -171,21 +170,18 @@ export function AiChatScreen() { return map; }, [activeMessages, conversationHistory, setActiveBranch]); - const handleRetry = useCallback( - (messageId: string) => { - aiLog.info('ai.retry.dispatch', { messageId }); - void retry(messageId); - }, - [retry] - ); + const handleRetry = (messageId: string) => { + aiLog.info('ai.retry.dispatch', { messageId }); + void retry(messageId); + }; // Composer state (draft + measured height for list bottom padding). const [draft, setDraft] = useState(''); const [composerHeight, setComposerHeight] = useState(0); - const handleComposerLayout = useCallback((e: LayoutChangeEvent) => { + const handleComposerLayout = (e: LayoutChangeEvent) => { const next = e.nativeEvent.layout.height; setComposerHeight((prev) => (Math.abs(prev - next) > 0.5 ? next : prev)); - }, []); + }; // Composer + list both ride the keyboard via a single shared translate // (UI thread, no Yoga re-layout per frame). The math: @@ -247,7 +243,7 @@ export function AiChatScreen() { } }); - const handleSubmit = useCallback(() => { + const handleSubmit = () => { const text = draft.trim(); if (!text) return; setDraft(''); @@ -255,7 +251,7 @@ export function AiChatScreen() { // Errors already logged; consumer's onSend is expected to surface // user-visible feedback (popups/banners). }); - }, [draft, dispatchSend]); + }; // Perf loggers — same canonical emits the shared ChatScreen produces, so // the AI surface stays observable in chat.kav.* and chat.list.history_change @@ -269,36 +265,28 @@ export function AiChatScreen() { }); useChatKeyboardAnimationLogger({ log: aiLog, surface: SURFACE }); - const renderItem = useCallback( - ({ item }: { item: RoutstrMessage; index: number }) => ( - - - - ), - [branchNavById, handleRetry, isSending, streamingMessageId] + const renderItem = ({ item }: { item: RoutstrMessage; index: number }) => ( + + + ); - const keyExtractor = useCallback((m: RoutstrMessage) => m.id, []); - // Tap-to-dismiss-keyboard on the empty placeholder mirrors the previous // behaviour. Mounted in place of the list when there are no messages; the // composer stays mounted over the top, ready to accept the first message. - const emptyContent = useMemo( - () => ( - - - - ), - [] + const emptyContent = ( + + + ); // Pad bottom of the list so the newest bubble rests just above the @@ -312,12 +300,9 @@ export function AiChatScreen() { // padding it would otherwise insert). The AI Stack header is its own // opaque/translucent surface above the screen scene; content sliding // under it on scroll is the intended chat UX. - const listContentContainerStyle = useMemo( - () => ({ - paddingBottom: composerHeight + bottomInset + 16, - }), - [composerHeight, bottomInset] - ); + const listContentContainerStyle = { + paddingBottom: composerHeight + bottomInset + 16, + }; return ( diff --git a/features/bitchat/components/BluetoothNotice.tsx b/features/bitchat/components/BluetoothNotice.tsx index f7901a332..c7fd2f458 100644 --- a/features/bitchat/components/BluetoothNotice.tsx +++ b/features/bitchat/components/BluetoothNotice.tsx @@ -11,7 +11,7 @@ * ("don't ask again"). * - unsupported → text only (emulators / BLE-less hardware). */ -import React, { useCallback } from 'react'; +import React from 'react'; import { Platform } from 'react-native'; import opacity from 'hex-color-opacity'; import Icon from 'assets/icons'; @@ -74,7 +74,7 @@ export function BluetoothNotice({ bluetooth: bluetoothProp }: BluetoothNoticePro const bluetooth = bluetoothProp ?? ownBluetooth; const copy = COPY[bluetooth.status]; const action = noticeAction(bluetooth); - const handlePress = useCallback(() => action?.onPress(), [action]); + const handlePress = () => action?.onPress(); if (!copy) return null; @@ -106,7 +106,7 @@ export function BluetoothInlineNotice({ bluetooth: bluetoothProp }: BluetoothNot ] as const); const copy = COPY[bluetooth.status]; const action = noticeAction(bluetooth); - const handlePress = useCallback(() => action?.onPress(), [action]); + const handlePress = () => action?.onPress(); if (!copy) return null; diff --git a/features/bitchat/hooks/useBLEPeers.ts b/features/bitchat/hooks/useBLEPeers.ts index 4b778b098..b5ee3be19 100644 --- a/features/bitchat/hooks/useBLEPeers.ts +++ b/features/bitchat/hooks/useBLEPeers.ts @@ -168,7 +168,7 @@ export function useBLEPeers(): UseBLEPeersResult { }; }, [identityMaterial, nickname, profileScope, creq, refresh]); - const connectedCount = useMemo(() => peers.filter((p) => p.isConnected).length, [peers]); + const connectedCount = peers.filter((p) => p.isConnected).length; return { peers, connectedCount, refresh }; } diff --git a/features/bitchat/hooks/useBitChat.ts b/features/bitchat/hooks/useBitChat.ts index 558de52ea..16a14cd24 100644 --- a/features/bitchat/hooks/useBitChat.ts +++ b/features/bitchat/hooks/useBitChat.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { startBLE, sendBLEMessage, @@ -357,206 +357,203 @@ export function useBitChat( // Send // =========================================================== - const sendMessage = useCallback( - async (content: string) => { - bitchatLog.info('bitchat.hook.send', { transport, contentLen: content.length }); - - switch (transport) { - case 'ble': { - // Public BLE — no own-echo, add locally. - const ownMsg: ChatMessage = { - id: mintLocalId('own'), - content, - sender: nickname || 'You', - senderId: '', - timestamp: Date.now(), - isPrivate: false, - isOwn: true, - isPending: true, - }; - setMessages((prev) => [...prev, ownMsg]); - try { - await sendBLEMessage(content); - setMessages((prev) => - prev.map((m) => (m.id === ownMsg.id ? { ...m, isPending: false } : m)) - ); - } catch (err) { - bitchatLog.error('bitchat.hook.ble_send_failed', { - error: err instanceof Error ? err.message : String(err), - }); - setMessages((prev) => prev.filter((m) => m.id !== ownMsg.id)); - } - break; + const sendMessage = async (content: string) => { + bitchatLog.info('bitchat.hook.send', { transport, contentLen: content.length }); + + switch (transport) { + case 'ble': { + // Public BLE — no own-echo, add locally. + const ownMsg: ChatMessage = { + id: mintLocalId('own'), + content, + sender: nickname || 'You', + senderId: '', + timestamp: Date.now(), + isPrivate: false, + isOwn: true, + isPending: true, + }; + setMessages((prev) => [...prev, ownMsg]); + try { + await sendBLEMessage(content); + setMessages((prev) => + prev.map((m) => (m.id === ownMsg.id ? { ...m, isPending: false } : m)) + ); + } catch (err) { + bitchatLog.error('bitchat.hook.ble_send_failed', { + error: err instanceof Error ? err.message : String(err), + }); + setMessages((prev) => prev.filter((m) => m.id !== ownMsg.id)); } + break; + } - case 'ble-dm': { - if (!dmPeerID) return; - // Noise-encrypted DM. Add an optimistic message to the GLOBAL - // store (not local state) so the app-wide delivery-status - // listener in BitchatBLEProvider can update its - // status as the native side reports - // `sending → sent → delivered`. Empty senderId matches the ble - // public path so `useMessageGrouping` doesn't conflate own + - // peer runs. - const messageID = mintLocalId('ble-dm'); - const ownMsg: BleDmMessage = { - id: messageID, - content, - sender: nickname || 'You', - senderId: '', - timestamp: Date.now(), - isPrivate: true, - isOwn: true, - isPending: true, - deliveryStatus: 'sending', - }; - const store = useBitchatDmMessagesStore.getState(); - store.appendOutgoing(ownMsg, dmPeerID); - - // Diagnostic: capture the recipient's real-time link state at send - // time. `isConnected` is the cached announce-state and can stay - // true after the BLE link silently dies; `hasDirectLink` is the - // authoritative flag for whether sendEncrypted can deliver without - // bouncing through mesh-flood + 15s spool. When users report - // "Network shows connected but DMs fail", this log distinguishes - // (a) peer genuinely reachable / handshake failing for other reason - // from (b) cached-state lying about reachability. - const peerSnapshot = getBLEPeers().find((p) => p.peerID === dmPeerID); - bitchatLog.info('bitchat.hook.ble_dm_link_state', { + case 'ble-dm': { + if (!dmPeerID) return; + // Noise-encrypted DM. Add an optimistic message to the GLOBAL + // store (not local state) so the app-wide delivery-status + // listener in BitchatBLEProvider can update its + // status as the native side reports + // `sending → sent → delivered`. Empty senderId matches the ble + // public path so `useMessageGrouping` doesn't conflate own + + // peer runs. + const messageID = mintLocalId('ble-dm'); + const ownMsg: BleDmMessage = { + id: messageID, + content, + sender: nickname || 'You', + senderId: '', + timestamp: Date.now(), + isPrivate: true, + isOwn: true, + isPending: true, + deliveryStatus: 'sending', + }; + const store = useBitchatDmMessagesStore.getState(); + store.appendOutgoing(ownMsg, dmPeerID); + + // Diagnostic: capture the recipient's real-time link state at send + // time. `isConnected` is the cached announce-state and can stay + // true after the BLE link silently dies; `hasDirectLink` is the + // authoritative flag for whether sendEncrypted can deliver without + // bouncing through mesh-flood + 15s spool. When users report + // "Network shows connected but DMs fail", this log distinguishes + // (a) peer genuinely reachable / handshake failing for other reason + // from (b) cached-state lying about reachability. + const peerSnapshot = getBLEPeers().find((p) => p.peerID === dmPeerID); + bitchatLog.info('bitchat.hook.ble_dm_link_state', { + peerID: dmPeerID, + messageID, + knownToNative: !!peerSnapshot, + isConnected: peerSnapshot?.isConnected ?? false, + hasDirectLink: peerSnapshot?.hasDirectLink ?? false, + lastSeenAgeMs: peerSnapshot ? Math.round(Date.now() - peerSnapshot.lastSeen) : null, + }); + + // Watchdog: if this message hasn't reached at least `sent` within + // BLE_DM_STUCK_TIMEOUT_MS, mark it `failed` so the bubble surfaces + // a tap-to-retry instead of spinning forever. + // + // Deliberately does NOT call `resetBLEPrivateChat` + restart the + // handshake on its own — upstream's `NoiseRateLimiter` enforces + // 10 handshakes/peer/minute (NoiseSecurityConstants.swift:31), and + // an auto-reset every 15s combined with the natural handshake the + // next send triggers can burn through that budget in < 90s. Once + // exhausted, BOTH sides silently reject handshake init packets at + // the rate-limit gate for the next minute, so EVERY following DM + // fails. User-initiated retry (the bubble tap) spaces attempts + // out enough to stay under the limit. + const watchdog = setTimeout(() => { + const current = useBitchatDmMessagesStore + .getState() + .getForPeer(dmPeerID) + .find((m) => m.id === messageID); + if (current?.deliveryStatus !== 'sending') return; + bitchatLog.warn('bitchat.hook.ble_dm_stuck', { peerID: dmPeerID, messageID, - knownToNative: !!peerSnapshot, - isConnected: peerSnapshot?.isConnected ?? false, - hasDirectLink: peerSnapshot?.hasDirectLink ?? false, - lastSeenAgeMs: peerSnapshot ? Math.round(Date.now() - peerSnapshot.lastSeen) : null, + timeoutMs: BLE_DM_STUCK_TIMEOUT_MS, + }); + useBitchatDmMessagesStore.getState().applyDeliveryStatus({ + messageID, + status: 'failed', + reason: 'timeout', + }); + }, BLE_DM_STUCK_TIMEOUT_MS); + + try { + const startedAt = Date.now(); + const returnedID = await sendBLEPrivateMessage(dmPeerID, content, nickname, messageID); + // Diagnostic: confirms the native AsyncFunction returned cleanly + // (mesh started, peerID valid, dispatch enqueued). Useful for + // distinguishing "native send rejected" from "native sent but no + // delivery-status events arrived" in log-doctor. + bitchatLog.info('bitchat.hook.ble_dm_send_resolved', { + messageID, + returnedID, + dispatchMs: Date.now() - startedAt, + }); + // Delivery state transitions arrive on `onBLEDeliveryStatus` + // via the app-level listener — the watchdog cancels itself + // when applyDeliveryStatus moves the message past `sending`. + } catch (err) { + clearTimeout(watchdog); + bitchatLog.error('bitchat.hook.ble_dm_send_failed', { + error: err instanceof Error ? err.message : String(err), + }); + // Native rejected outright (e.g. mesh not started, invalid + // peerID). Mark the bubble failed so the user sees it. + store.applyDeliveryStatus({ + messageID, + status: 'failed', + reason: err instanceof Error ? err.message : String(err), }); - - // Watchdog: if this message hasn't reached at least `sent` within - // BLE_DM_STUCK_TIMEOUT_MS, mark it `failed` so the bubble surfaces - // a tap-to-retry instead of spinning forever. - // - // Deliberately does NOT call `resetBLEPrivateChat` + restart the - // handshake on its own — upstream's `NoiseRateLimiter` enforces - // 10 handshakes/peer/minute (NoiseSecurityConstants.swift:31), and - // an auto-reset every 15s combined with the natural handshake the - // next send triggers can burn through that budget in < 90s. Once - // exhausted, BOTH sides silently reject handshake init packets at - // the rate-limit gate for the next minute, so EVERY following DM - // fails. User-initiated retry (the bubble tap) spaces attempts - // out enough to stay under the limit. - const watchdog = setTimeout(() => { - const current = useBitchatDmMessagesStore - .getState() - .getForPeer(dmPeerID) - .find((m) => m.id === messageID); - if (current?.deliveryStatus !== 'sending') return; - bitchatLog.warn('bitchat.hook.ble_dm_stuck', { - peerID: dmPeerID, - messageID, - timeoutMs: BLE_DM_STUCK_TIMEOUT_MS, - }); - useBitchatDmMessagesStore.getState().applyDeliveryStatus({ - messageID, - status: 'failed', - reason: 'timeout', - }); - }, BLE_DM_STUCK_TIMEOUT_MS); - - try { - const startedAt = Date.now(); - const returnedID = await sendBLEPrivateMessage(dmPeerID, content, nickname, messageID); - // Diagnostic: confirms the native AsyncFunction returned cleanly - // (mesh started, peerID valid, dispatch enqueued). Useful for - // distinguishing "native send rejected" from "native sent but no - // delivery-status events arrived" in log-doctor. - bitchatLog.info('bitchat.hook.ble_dm_send_resolved', { - messageID, - returnedID, - dispatchMs: Date.now() - startedAt, - }); - // Delivery state transitions arrive on `onBLEDeliveryStatus` - // via the app-level listener — the watchdog cancels itself - // when applyDeliveryStatus moves the message past `sending`. - } catch (err) { - clearTimeout(watchdog); - bitchatLog.error('bitchat.hook.ble_dm_send_failed', { - error: err instanceof Error ? err.message : String(err), - }); - // Native rejected outright (e.g. mesh not started, invalid - // peerID). Mark the bubble failed so the user sees it. - store.applyDeliveryStatus({ - messageID, - status: 'failed', - reason: err instanceof Error ? err.message : String(err), - }); - } - break; } + break; + } - case 'nostr': { - // Public geohash chat echoes our own message back via the - // subscription, so we add an optimistic row keyed on the local - // mint id; once the relay round-trip resolves we flip its pending - // flag. The native echo arrives later as a separate message — - // distinct id, harmless visual duplicate that the relay wins. - const ownMsg: ChatMessage = { - id: mintLocalId('own'), - content, - sender: nickname || 'You', - senderId: '', - timestamp: Date.now(), - isPrivate: false, - isOwn: true, - isPending: true, - }; - setMessages((prev) => [...prev, ownMsg]); - try { - await sendGeohashMessage(content, nickname); - setMessages((prev) => - prev.map((m) => (m.id === ownMsg.id ? { ...m, isPending: false } : m)) - ); - } catch (err) { - bitchatLog.error('bitchat.hook.nostr_send_failed', { - error: err instanceof Error ? err.message : String(err), - }); - setMessages((prev) => prev.filter((m) => m.id !== ownMsg.id)); - } - break; + case 'nostr': { + // Public geohash chat echoes our own message back via the + // subscription, so we add an optimistic row keyed on the local + // mint id; once the relay round-trip resolves we flip its pending + // flag. The native echo arrives later as a separate message — + // distinct id, harmless visual duplicate that the relay wins. + const ownMsg: ChatMessage = { + id: mintLocalId('own'), + content, + sender: nickname || 'You', + senderId: '', + timestamp: Date.now(), + isPrivate: false, + isOwn: true, + isPending: true, + }; + setMessages((prev) => [...prev, ownMsg]); + try { + await sendGeohashMessage(content, nickname); + setMessages((prev) => + prev.map((m) => (m.id === ownMsg.id ? { ...m, isPending: false } : m)) + ); + } catch (err) { + bitchatLog.error('bitchat.hook.nostr_send_failed', { + error: err instanceof Error ? err.message : String(err), + }); + setMessages((prev) => prev.filter((m) => m.id !== ownMsg.id)); } + break; + } - case 'nostr-dm': { - if (!dmPeerID) return; - // NIP-17 gift-wrap DMs don't echo back to the sender via the - // subscription, so add locally. Empty senderId for the same - // grouping reason as 'ble-dm' above. - const ownMsg: ChatMessage = { - id: mintLocalId('own'), - content, - sender: nickname || 'You', - senderId: '', - timestamp: Date.now(), - isPrivate: true, - isOwn: true, - isPending: true, - }; - setMessages((prev) => [...prev, ownMsg]); - try { - await sendGeohashPrivateMessage(dmPeerID, content); - setMessages((prev) => - prev.map((m) => (m.id === ownMsg.id ? { ...m, isPending: false } : m)) - ); - } catch (err) { - bitchatLog.error('bitchat.hook.nostr_dm_send_failed', { - error: err instanceof Error ? err.message : String(err), - }); - setMessages((prev) => prev.filter((m) => m.id !== ownMsg.id)); - } - break; + case 'nostr-dm': { + if (!dmPeerID) return; + // NIP-17 gift-wrap DMs don't echo back to the sender via the + // subscription, so add locally. Empty senderId for the same + // grouping reason as 'ble-dm' above. + const ownMsg: ChatMessage = { + id: mintLocalId('own'), + content, + sender: nickname || 'You', + senderId: '', + timestamp: Date.now(), + isPrivate: true, + isOwn: true, + isPending: true, + }; + setMessages((prev) => [...prev, ownMsg]); + try { + await sendGeohashPrivateMessage(dmPeerID, content); + setMessages((prev) => + prev.map((m) => (m.id === ownMsg.id ? { ...m, isPending: false } : m)) + ); + } catch (err) { + bitchatLog.error('bitchat.hook.nostr_dm_send_failed', { + error: err instanceof Error ? err.message : String(err), + }); + setMessages((prev) => prev.filter((m) => m.id !== ownMsg.id)); } + break; } - }, - [transport, nickname, dmPeerID] - ); + } + }; // For `ble-dm` the source of truth is the global store (populated by // BitchatBLEProvider's listener + this hook's send path). All other diff --git a/features/bitchat/screens/GeohashChatScreen.tsx b/features/bitchat/screens/GeohashChatScreen.tsx index 9c167fe0f..e512156c8 100644 --- a/features/bitchat/screens/GeohashChatScreen.tsx +++ b/features/bitchat/screens/GeohashChatScreen.tsx @@ -7,7 +7,7 @@ * DM modes mount the shared ``. */ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; import { Stack } from 'expo-router'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; @@ -90,11 +90,8 @@ export function GeohashChatScreen({ // the reachability banner above the BLE-DM composer. const { peers: blePeers, connectedCount: bleConnectedCount } = useBLEPeers(); const bluetooth = useBluetoothState(); - const dmPeerSnapshot = useMemo( - () => - transport === 'ble-dm' && dmPeerID ? blePeers.find((p) => p.peerID === dmPeerID) : undefined, - [blePeers, transport, dmPeerID] - ); + const dmPeerSnapshot = + transport === 'ble-dm' && dmPeerID ? blePeers.find((p) => p.peerID === dmPeerID) : undefined; useEffect(() => { bitchatLog.debug('bitchat.screen.messages', { @@ -123,31 +120,27 @@ export function GeohashChatScreen({ const handleBack = onBack ?? (() => router.back()); - const bubbleMessages = useMemo( - () => - messages.map((m: ChatMessage) => { - // `ble-dm` messages carry a richer `deliveryStatus` from the global - // store; all other transports just have `isPending`. Cast to read - // the optional field without forcing every ChatMessage shape to - // declare it. - const richStatus = (m as { deliveryStatus?: ChatBubbleMessage['deliveryStatus'] }) - .deliveryStatus; - const deliveryStatus: ChatBubbleMessage['deliveryStatus'] | undefined = m.isOwn - ? (richStatus ?? (m.isPending ? 'sending' : 'sent')) - : undefined; - return { - id: m.id, - content: m.content, - senderId: m.senderId, - sender: m.sender, - timestamp: m.timestamp, - isOwn: m.isOwn, - deliveryStatus, - cashuToken: extractCashuToken(m.content) ?? undefined, - }; - }), - [messages] - ); + const bubbleMessages = messages.map((m: ChatMessage) => { + // `ble-dm` messages carry a richer `deliveryStatus` from the global + // store; all other transports just have `isPending`. Cast to read + // the optional field without forcing every ChatMessage shape to + // declare it. + const richStatus = (m as { deliveryStatus?: ChatBubbleMessage['deliveryStatus'] }) + .deliveryStatus; + const deliveryStatus: ChatBubbleMessage['deliveryStatus'] | undefined = m.isOwn + ? (richStatus ?? (m.isPending ? 'sending' : 'sent')) + : undefined; + return { + id: m.id, + content: m.content, + senderId: m.senderId, + sender: m.sender, + timestamp: m.timestamp, + isOwn: m.isOwn, + deliveryStatus, + cashuToken: extractCashuToken(m.content) ?? undefined, + }; + }); const header = isDM ? ( { + router.back(); +}; +const keyExtractor = (peer: BLEPeer) => peer.peerID; +const renderPeerItem = ({ item }: { item: BLEPeer }) => ; + export default function NetworkSheet() { useLifecycleLogger('BitchatNetworkSheet', bitchatLog); const [foreground, surfaceSecondary] = useThemeColor([ @@ -78,24 +84,16 @@ export default function NetworkSheet() { // through the mesh-flood spool (which expires after 15s). Surface this // distinction in both the sort order and the header count so users don't // think "5 connected" means "5 reachable for DM". - const directLinkCount = useMemo(() => peers.filter((p) => p.hasDirectLink).length, [peers]); + const directLinkCount = peers.filter((p) => p.hasDirectLink).length; // Sort: direct-link first, then mesh-reachable, then offline; ties broken // by lastSeen desc. Matches upstream's MeshPeerList preference for "best // reachability first". - const sortedPeers = useMemo(() => { - return [...peers].sort((a, b) => { - if (a.hasDirectLink !== b.hasDirectLink) return a.hasDirectLink ? -1 : 1; - if (a.isConnected !== b.isConnected) return a.isConnected ? -1 : 1; - return b.lastSeen - a.lastSeen; - }); - }, [peers]); - - const handleClose = useCallback(() => { - router.back(); - }, []); - const keyExtractor = useCallback((peer: BLEPeer) => peer.peerID, []); - const renderPeerItem = useCallback(({ item }: { item: BLEPeer }) => , []); + const sortedPeers = [...peers].sort((a, b) => { + if (a.hasDirectLink !== b.hasDirectLink) return a.hasDirectLink ? -1 : 1; + if (a.isConnected !== b.isConnected) return a.isConnected ? -1 : 1; + return b.lastSeen - a.lastSeen; + }); const subtitleText = useMemo(() => { if (bluetoothBlocked) return 'Bluetooth unavailable'; diff --git a/features/camera/hooks/useHandleCameraPermission.ts b/features/camera/hooks/useHandleCameraPermission.ts index 03faec7c3..7d5639655 100644 --- a/features/camera/hooks/useHandleCameraPermission.ts +++ b/features/camera/hooks/useHandleCameraPermission.ts @@ -1,5 +1,3 @@ -import { useState, useEffect } from 'react'; - import { useCameraPermissions } from 'expo-camera'; import { Linking } from 'react-native'; @@ -8,11 +6,10 @@ import { log } from '@/shared/lib/logger'; export function useHandleCameraPermission() { const [permission, requestPermission] = useCameraPermissions(); - const [isChecking, setIsChecking] = useState(true); - - useEffect(() => { - if (permission) setIsChecking(false); - }, [permission]); + // `isChecking` is just "permission not resolved yet": expo-camera returns null + // until the permission state loads, then a PermissionResponse object. Derive it + // rather than mirror it into state through an effect (set-state-in-effect). + const isChecking = !permission; const handlePermission = async (): Promise => { if (!permission) { diff --git a/features/camera/index.ts b/features/camera/index.ts index 01fd9c2cc..6c47d292b 100644 --- a/features/camera/index.ts +++ b/features/camera/index.ts @@ -1,5 +1,5 @@ // camera feature barrel -export { CameraScreen, type ScanningData } from './screens/CameraScreen'; +export { CameraScreen } from './screens/CameraScreen'; export { StandaloneCameraScreen } from './screens/StandaloneCameraScreen'; export { useHandleCameraPermission } from './hooks/useHandleCameraPermission'; diff --git a/features/camera/screens/CameraScreen/CameraScreen.tsx b/features/camera/screens/CameraScreen/CameraScreen.tsx index cfd69ad15..8c8808aed 100644 --- a/features/camera/screens/CameraScreen/CameraScreen.tsx +++ b/features/camera/screens/CameraScreen/CameraScreen.tsx @@ -2,7 +2,7 @@ * @fileoverview Camera screen — scan QR, paste, gallery. Uses machine.scan directly. */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { AppState, Platform } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import { useFocusEffect } from 'expo-router'; @@ -109,10 +109,10 @@ export function CameraScreen({ signerPairOnly = false }: CameraScreenProps = {}) const appStateRef = useRef(AppState.currentState); const isProcessingRef = useRef(false); - const onOptionDismiss = useCallback(() => { + const onOptionDismiss = () => { isProcessingRef.current = false; setLoading(false); - }, []); + }; const machine = usePaymentFlowMachine({ walletContext, @@ -127,16 +127,14 @@ export function CameraScreen({ signerPairOnly = false }: CameraScreenProps = {}) return () => sub?.remove(); }, []); - useFocusEffect( - useCallback(() => { - setIsFocused(true); - setProgress(0); - setLoading(false); - isProcessingRef.current = false; - machine.reset(); - return () => setIsFocused(false); - }, [machine]) - ); + useFocusEffect(() => { + setIsFocused(true); + setProgress(0); + setLoading(false); + isProcessingRef.current = false; + machine.reset(); + return () => setIsFocused(false); + }); // Auto-disengage the flashlight one second after the camera reports ready. // Effect-scoped so unmounting before the timer fires cancels the late @@ -152,10 +150,7 @@ export function CameraScreen({ signerPairOnly = false }: CameraScreenProps = {}) /** Common gate for every scan source. iOS can deliver taps during the * inactive→background transition; AppState/isFocused must be live. */ - const shouldAcceptScan = useCallback( - () => appStateRef.current === 'active' && isFocused, - [isFocused] - ); + const shouldAcceptScan = () => appStateRef.current === 'active' && isFocused; /** * NIP-46 pairing entry. Hands the raw value to the shared @@ -163,7 +158,7 @@ export function CameraScreen({ signerPairOnly = false }: CameraScreenProps = {}) * failures surface as the plan's error toast. The value embeds a pairing * bearer secret — never logged. */ - const handleSignerScan = useCallback((raw: string, invalidBody: string) => { + const handleSignerScan = (raw: string, invalidBody: string) => { const now = Date.now(); if ( raw === lastSignerScanRef.current.data && @@ -181,56 +176,50 @@ export function CameraScreen({ signerPairOnly = false }: CameraScreenProps = {}) type: 'error', }); } - }, []); + }; + + const handleScan = async (data: ScanningData) => { + const isUr = data.data.toLowerCase().startsWith('ur:'); + if (!shouldAcceptScan()) return; + if (!isUr && isProcessingRef.current) return; + + // Debounce: skip identical scans within 500ms + const now = Date.now(); + if (data.data === lastScanRef.current.data && now - lastScanRef.current.t < 500) return; + lastScanRef.current = { data: data.data, t: now }; - const handleScan = useCallback( - async (data: ScanningData) => { - const isUr = data.data.toLowerCase().startsWith('ur:'); - if (!shouldAcceptScan()) return; - if (!isUr && isProcessingRef.current) return; - - // Debounce: skip identical scans within 500ms - const now = Date.now(); - if (data.data === lastScanRef.current.data && now - lastScanRef.current.t < 500) return; - lastScanRef.current = { data: data.data, t: now }; - - // NIP-46 pairing intercept — BEFORE the payment machine sees the value. - // In signer-pair mode EVERY scan routes here: no payment fallback. - if (NIP46_SCHEME_RE.test(data.data.trim()) || signerPairOnly) { - handleSignerScan(data.data.trim(), PAIRING_ERROR_INVALID_QR); - return; - } - - log.info('camera.scan.detected', { - type: data.type ?? 'qr', - isUr, - dataLength: data.data.length, + // NIP-46 pairing intercept — BEFORE the payment machine sees the value. + // In signer-pair mode EVERY scan routes here: no payment fallback. + if (NIP46_SCHEME_RE.test(data.data.trim()) || signerPairOnly) { + handleSignerScan(data.data.trim(), PAIRING_ERROR_INVALID_QR); + return; + } + + log.info('camera.scan.detected', { + type: data.type ?? 'qr', + isUr, + dataLength: data.data.length, + }); + isProcessingRef.current = true; + setLoading(true); + try { + const result = await machine.scan?.(data.data, { source: data.type ?? 'qr' }); + applyScanResult(result, setProgress, setLoading, isProcessingRef); + } catch (err) { + log.error('camera.scan.failed', { + error: err instanceof Error ? err : new Error(String(err)), }); - isProcessingRef.current = true; - setLoading(true); - try { - const result = await machine.scan?.(data.data, { source: data.type ?? 'qr' }); - applyScanResult(result, setProgress, setLoading, isProcessingRef); - } catch (err) { - log.error('camera.scan.failed', { - error: err instanceof Error ? err : new Error(String(err)), - }); - setLoading(false); - setProgress(0); - isProcessingRef.current = false; - } - }, - [handleSignerScan, machine, shouldAcceptScan, signerPairOnly] - ); + setLoading(false); + setProgress(0); + isProcessingRef.current = false; + } + }; - const handleBarcodeScanned = useCallback( - (result: { data?: string }) => { - if (result?.data) void handleScan({ data: result.data, type: 'qr' }); - }, - [handleScan] - ); + const handleBarcodeScanned = (result: { data?: string }) => { + if (result?.data) void handleScan({ data: result.data, type: 'qr' }); + }; - const handleClipboardPress = useCallback(async () => { + const handleClipboardPress = async () => { if (!shouldAcceptScan()) return; log.info('camera.scan.clipboard'); // Clipboard paste honors the same NIP-46 intercept as live scans — a @@ -257,9 +246,9 @@ export function CameraScreen({ signerPairOnly = false }: CameraScreenProps = {}) setProgress(0); isProcessingRef.current = false; } - }, [handleSignerScan, machine, shouldAcceptScan, signerPairOnly]); + }; - const handleGalleryPress = useCallback(async () => { + const handleGalleryPress = async () => { if (!shouldAcceptScan()) return; log.info('camera.scan.gallery'); isProcessingRef.current = true; @@ -275,19 +264,19 @@ export function CameraScreen({ signerPairOnly = false }: CameraScreenProps = {}) setProgress(0); isProcessingRef.current = false; } - }, [machine, shouldAcceptScan]); + }; - const handleCameraReady = useCallback(() => { + const handleCameraReady = () => { setCameraReady(true); - }, []); + }; - const toggleFlashlight = useCallback(() => { + const toggleFlashlight = () => { setFlashlightOn((p) => !p); - }, []); + }; - const requestPermission = useCallback(() => { + const requestPermission = () => { void handlePermission(); - }, [handlePermission]); + }; const shared = { foreground, diff --git a/features/camera/screens/CameraScreen/index.ts b/features/camera/screens/CameraScreen/index.ts index 831e32606..9b640d526 100644 --- a/features/camera/screens/CameraScreen/index.ts +++ b/features/camera/screens/CameraScreen/index.ts @@ -1,2 +1 @@ export { CameraScreen } from './CameraScreen'; -export type { ScanningData } from './types'; diff --git a/features/composer/config/types.ts b/features/composer/config/types.ts index eacf10d9f..2c9cae241 100644 --- a/features/composer/config/types.ts +++ b/features/composer/config/types.ts @@ -61,5 +61,3 @@ export interface PollDraft { /** Unix seconds; undefined = no expiry. */ endsAt?: number; } - -export type ComposerMode = 'new' | 'reply' | 'quote'; diff --git a/features/composer/publish/buildDraftEvent.ts b/features/composer/publish/buildDraftEvent.ts index 53f9d85bf..384460aff 100644 --- a/features/composer/publish/buildDraftEvent.ts +++ b/features/composer/publish/buildDraftEvent.ts @@ -12,14 +12,14 @@ /** NIP-37 draft event kind (parameterized replaceable). */ export const DRAFT_KIND = 31234; -export interface UnsignedTargetEvent { +interface UnsignedTargetEvent { kind: number; content: string; created_at: number; tags: string[][]; } -export interface UnsignedDraftEvent { +interface UnsignedDraftEvent { kind: number; content: string; created_at: number; diff --git a/features/composer/publish/buildNoteEvent.ts b/features/composer/publish/buildNoteEvent.ts index 75de1a2bd..45e5978ec 100644 --- a/features/composer/publish/buildNoteEvent.ts +++ b/features/composer/publish/buildNoteEvent.ts @@ -28,7 +28,7 @@ export type ComposerTarget = relayHint?: string; }; -export interface BuildNoteInput { +interface BuildNoteInput { blocks: readonly ComposerBlock[]; target: ComposerTarget; mentionPubkeys?: readonly string[]; @@ -37,7 +37,7 @@ export interface BuildNoteInput { createdAt?: number; } -export interface UnsignedNote { +interface UnsignedNote { kind: 1; content: string; created_at: number; diff --git a/features/composer/publish/useComposerActions.ts b/features/composer/publish/useComposerActions.ts index c64662ff0..43f17e3bf 100644 --- a/features/composer/publish/useComposerActions.ts +++ b/features/composer/publish/useComposerActions.ts @@ -6,7 +6,6 @@ * serialization). `usePublishNote` uploads any pending media, serializes the * block model to kind:1, and publishes through the outbox-aware seam. */ -import { useCallback } from 'react'; import { NDKEvent, useNDK } from '@nostr-dev-kit/ndk-mobile'; import type NDK from '@nostr-dev-kit/ndk-mobile'; @@ -33,13 +32,10 @@ type ComposerSnapshot = ReturnType; /** Returns a function that opens the composer for a given target. */ export function useOpenComposer(): (target: ComposerTarget, context?: ComposerOpenContext) => void { const open = useComposerStore((s) => s.open); - return useCallback( - (target, context) => { - open(target, context); - router.navigate('/composer'); - }, - [open] - ); + return (target, context) => { + open(target, context); + router.navigate('/composer'); + }; } export type PublishOutcome = @@ -93,7 +89,7 @@ async function publishPoll(ndk: NDK, state: ComposerSnapshot): Promise Promise { const { ndk } = useNDK(); - return useCallback(async (): Promise => { + return async (): Promise => { if (!ndk?.signer) return 'no-key'; const state = useComposerStore.getState(); @@ -206,5 +202,5 @@ export function usePublishNote(): () => Promise { }); if (outcome === 'ok') useComposerStore.getState().close(); return outcome; - }, [ndk]); + }; } diff --git a/features/composer/ui/ComposeFab.tsx b/features/composer/ui/ComposeFab.tsx index 6bf50f59b..14648d400 100644 --- a/features/composer/ui/ComposeFab.tsx +++ b/features/composer/ui/ComposeFab.tsx @@ -3,7 +3,7 @@ * * The feed's only write entry point — opens the composer for a fresh post. */ -import React, { useCallback } from 'react'; +import React from 'react'; import { StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -31,7 +31,7 @@ export function ComposeFab() { // so `0` is flush. Adding the tab-bar height (as a list padding would) here // double-counts it and floats the FAB too high. const bottom = (isExpo55NativeTabsSupported() ? insets.bottom : 0) + FAB_TAB_BAR_GAP; - const onPress = useCallback(() => openComposer({ mode: 'new' }), [openComposer]); + const onPress = () => openComposer({ mode: 'new' }); return ( s.setPoll); const [foreground, muted, accent] = useThemeColor(['foreground', 'muted', 'accent'] as const); - const update = useCallback((next: PollDraft) => setPoll(next), [setPoll]); + const update = (next: PollDraft) => setPoll(next); if (!poll) return null; diff --git a/features/composer/ui/PostComposer.tsx b/features/composer/ui/PostComposer.tsx index 89405ecab..8789189f5 100644 --- a/features/composer/ui/PostComposer.tsx +++ b/features/composer/ui/PostComposer.tsx @@ -7,9 +7,8 @@ * `ComposeConfig`; the char meter enforces the relay-sourced budget; send goes * through the outbox-aware publish seam. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { - ActivityIndicator, Keyboard, KeyboardAvoidingView, Platform, @@ -18,16 +17,11 @@ import { TextInput, View, } from 'react-native'; -import { Image } from 'expo-image'; import * as ImagePicker from 'expo-image-picker'; import { useNDK } from '@nostr-dev-kit/ndk-mobile'; import { router } from 'expo-router'; -import { Button } from 'heroui-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import Icon from 'assets/icons'; -import { INVARIANT_WHITE } from '@/shared/lib/brandColors'; -import { Pressable } from '@/shared/ui/primitives/Pressable'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { uploadMedia } from '@/shared/lib/nostr/media/mediaUpload'; import { useComposeConfig } from '@/features/composer/config/useComposeConfig'; @@ -37,6 +31,9 @@ import { type PublishOutcome, } from '@/features/composer/publish/useComposerActions'; import { PollComposeForm, emptyPollDraft } from '@/features/composer/ui/PollComposeForm'; +import { PostComposerHeader } from '@/features/composer/ui/PostComposerHeader'; +import { PostComposerMediaTray } from '@/features/composer/ui/PostComposerMediaTray'; +import { PostComposerToolbar } from '@/features/composer/ui/PostComposerToolbar'; import { Avatar } from '@/shared/ui/primitives/Avatar'; import { Text } from '@/shared/ui/primitives/Text'; import { useProfileStore } from '@/shared/stores/global/profileStore'; @@ -55,7 +52,7 @@ import { type NoteMetrics, type ProfileInfo, } from '@/features/feed/components/nostr/feedTypes'; -import { alpha, spacing } from '@/shared/styles/tokens'; +import { spacing } from '@/shared/styles/tokens'; const AVATAR_SIZE = 36; const EMPTY_EVENTS = new Map(); @@ -130,7 +127,7 @@ export function PostComposer() { return map; }, [parentEvent, parentProfile]); const textBlock = blocks.find((b) => b.kind === 'text'); - const mediaBlocks = useMemo(() => blocks.filter((b) => b.kind === 'media'), [blocks]); + const mediaBlocks = blocks.filter((b) => b.kind === 'media'); const textLength = textBlock?.kind === 'text' ? textBlock.text.length : 0; const hasPostContent = (textBlock?.kind === 'text' && textBlock.text.trim().length > 0) || mediaBlocks.length > 0; @@ -201,12 +198,12 @@ export function PostComposer() { }, }); - const handleCancel = useCallback(() => { + const handleCancel = () => { close(); router.back(); - }, [close]); + }; - const handlePost = useCallback(async () => { + const handlePost = async () => { if (!canPost) return; setBusy(true); setError(null); @@ -217,9 +214,9 @@ export function PostComposer() { return; } setError(OUTCOME_MESSAGE[outcome] ?? 'Something went wrong.'); - }, [canPost, publish]); + }; - const handleAddMedia = useCallback(async () => { + const handleAddMedia = async () => { if (!ndk || mediaBlocks.length >= config.maxMedia) return; const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images', 'videos'], @@ -247,41 +244,22 @@ export function PostComposer() { removeBlock(id); setError('Media upload failed. Try a different file.'); } - }, [ndk, mediaBlocks.length, config.maxMedia, addMediaBlock, updateBlock, removeBlock]); + }; return ( - - - - + mode={target?.mode ?? 'new'} + keyboardVisible={keyboardVisible} + busy={busy} + canPost={canPost} + paddingTop={insets.top + spacing.md} + onCancel={handleCancel} + onPost={handlePost} + /> 0 ? ( - - {mediaBlocks.map((block) => - block.kind === 'media' ? ( - - - {block.uploadProgress !== undefined ? ( - - - - - - ) : null} - - - ) : null - )} - + ) : null} {!showPollBeforeQuote && poll ? : null} @@ -425,60 +353,24 @@ export function PostComposer() { - - = config.maxMedia} - hitSlop={10} - accessibilityRole="button" - accessibilityLabel="Add photo or video"> - = config.maxMedia ? mutedColor : accentColor} - /> - - setPoll(poll ? undefined : emptyPollDraft())} - disabled={!config.allowPoll || mediaBlocks.length > 0} - hitSlop={10} - accessibilityRole="button" - accessibilityLabel={poll ? 'Remove poll' : 'Add poll'}> - 0 - ? mutedColor - : poll - ? accentColor - : mutedColor - } - /> - - - - {remaining} - - + mode={target?.mode ?? 'new'} + keyboardVisible={keyboardVisible} + mediaCount={mediaBlocks.length} + canPost={canPost} + paddingBottom={keyboardVisible ? 10 : insets.bottom + 10} + mutedColor={mutedColor} + accentColor={accentColor} + dangerColor={dangerColor} + remaining={remaining} + overBudget={overBudget} + addMediaDisabled={!!poll || mediaBlocks.length >= config.maxMedia} + pollDisabled={!config.allowPoll || mediaBlocks.length > 0} + pollActive={!!poll} + onAddMedia={handleAddMedia} + onTogglePoll={() => setPoll(poll ? undefined : emptyPollDraft())} + /> ); } @@ -557,14 +449,6 @@ function ReplyOriginalPost({ } const styles = StyleSheet.create({ - headerRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingLeft: spacing.sm, - paddingRight: spacing.lg, - paddingBottom: spacing.md, - }, ogRow: { flexDirection: 'row', gap: 12, paddingTop: 12 }, ogContent: { flex: 1 }, gutterCol: { width: AVATAR_SIZE, alignItems: 'center' }, @@ -592,18 +476,6 @@ const styles = StyleSheet.create({ paddingBottom: spacing['2xl'], marginBottom: spacing.md, }, - disabledPostButton: { opacity: alpha.disabled }, - uploadScrim: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - borderRadius: 12, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(0,0,0,0.35)', - }, flex1: { flex: 1, }, diff --git a/features/composer/ui/PostComposerHeader.tsx b/features/composer/ui/PostComposerHeader.tsx new file mode 100644 index 000000000..8916762e2 --- /dev/null +++ b/features/composer/ui/PostComposerHeader.tsx @@ -0,0 +1,69 @@ +/** + * @fileoverview Composer header row: Cancel + Post buttons. + * + * Extracted from `PostComposer` as a pure presentational seam. The + * `VisualLayoutProbe` keeps its original scope/component/itemKey strings so the + * content-shift log taxonomy (`composer.*`) is unchanged. + */ +import { StyleSheet } from 'react-native'; +import { Button } from 'heroui-native'; + +import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; +import { alpha, spacing } from '@/shared/styles/tokens'; + +type Props = { + scope: string; + mode: string; + keyboardVisible: boolean; + busy: boolean; + canPost: boolean; + paddingTop: number; + onCancel: () => void; + onPost: () => void; +}; + +export function PostComposerHeader({ + scope, + mode, + keyboardVisible, + busy, + canPost, + paddingTop, + onCancel, + onPost, +}: Props) { + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + headerRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingLeft: spacing.sm, + paddingRight: spacing.lg, + paddingBottom: spacing.md, + }, + disabledPostButton: { opacity: alpha.disabled }, +}); diff --git a/features/composer/ui/PostComposerMediaTray.tsx b/features/composer/ui/PostComposerMediaTray.tsx new file mode 100644 index 000000000..85d312ee4 --- /dev/null +++ b/features/composer/ui/PostComposerMediaTray.tsx @@ -0,0 +1,107 @@ +/** + * @fileoverview Composer media tray: horizontal strip of picked/uploading media. + * + * Extracted from `PostComposer` as a pure presentational seam. Probe + * scope/component/itemKey strings are preserved so the content-shift log + * taxonomy (`composer.*`) is unchanged. Rendered only when there is media. + */ +import { ActivityIndicator, ScrollView, StyleSheet, View } from 'react-native'; +import { Image } from 'expo-image'; +import { Button } from 'heroui-native'; + +import Icon from 'assets/icons'; +import type { ComposerBlock } from '@/features/composer/config/types'; +import { INVARIANT_WHITE } from '@/shared/lib/brandColors'; +import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; +import type { VisualScrollMetricsReporter } from '@/shared/lib/contentShiftLog'; + +type Props = { + scope: string; + mediaBlocks: ComposerBlock[]; + scrollMetrics: VisualScrollMetricsReporter; + mutedColor: string; + onRemove: (id: string) => void; +}; + +export function PostComposerMediaTray({ + scope, + mediaBlocks, + scrollMetrics, + mutedColor, + onRemove, +}: Props) { + return ( + + {mediaBlocks.map((block) => + block.kind === 'media' ? ( + + + {block.uploadProgress !== undefined ? ( + + + + + + ) : null} + + + ) : null + )} + + ); +} + +const styles = StyleSheet.create({ + media: { width: 96, height: 96, borderRadius: 12 }, + uploadScrim: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.35)', + }, +}); diff --git a/features/composer/ui/PostComposerToolbar.tsx b/features/composer/ui/PostComposerToolbar.tsx new file mode 100644 index 000000000..28964a885 --- /dev/null +++ b/features/composer/ui/PostComposerToolbar.tsx @@ -0,0 +1,93 @@ +/** + * @fileoverview Composer bottom toolbar: add-media + poll toggles and char meter. + * + * Extracted from `PostComposer` as a pure presentational seam. The + * `VisualLayoutProbe` keeps its original scope/component/itemKey strings so the + * content-shift log taxonomy (`composer.*`) is unchanged. Enablement is computed + * by the parent and passed in as booleans so this stays config-agnostic. + */ +import { StyleSheet, View } from 'react-native'; + +import Icon from 'assets/icons'; +import { Pressable } from '@/shared/ui/primitives/Pressable'; +import { Text } from '@/shared/ui/primitives/Text'; +import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; + +type Props = { + scope: string; + mode: string; + keyboardVisible: boolean; + mediaCount: number; + canPost: boolean; + paddingBottom: number; + mutedColor: string; + accentColor: string; + dangerColor: string; + remaining: number; + overBudget: boolean; + addMediaDisabled: boolean; + pollDisabled: boolean; + pollActive: boolean; + onAddMedia: () => void; + onTogglePoll: () => void; +}; + +export function PostComposerToolbar({ + scope, + mode, + keyboardVisible, + mediaCount, + canPost, + paddingBottom, + mutedColor, + accentColor, + dangerColor, + remaining, + overBudget, + addMediaDisabled, + pollDisabled, + pollActive, + onAddMedia, + onTogglePoll, +}: Props) { + return ( + + + + + + + + + + {remaining} + + + ); +} diff --git a/features/contacts/components/SearchPostsList.tsx b/features/contacts/components/SearchPostsList.tsx index ad47ce9c7..df607a4a2 100644 --- a/features/contacts/components/SearchPostsList.tsx +++ b/features/contacts/components/SearchPostsList.tsx @@ -5,7 +5,7 @@ * resolver) fetches their posts, rendered read-only with `PostCard` — tapping a * post opens its thread. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import { List } from '@/shared/ui/composed/List'; @@ -102,47 +102,38 @@ export function SearchPostsList({ pubkeys }: { pubkeys: string[] }) { [metricsMap] ); - const renderItem = useCallback( - ({ item }: { item: FeedItem }) => { - if (item.type !== 'note') return null; - return ( - - ); - }, - [getMetrics, profilesMap, quotedMap] - ); + const renderItem = ({ item }: { item: FeedItem }) => { + if (item.type !== 'note') return null; + return ( + + ); + }; - const renderEmptyPeople = useMemo( - () => ( - - - - ), - [] + const renderEmptyPeople = ( + + + ); - const renderEmptyPosts = useMemo( - () => ( - - - - ), - [] + const renderEmptyPosts = ( + + + ); if (pubkeys.length === 0) { diff --git a/features/contacts/components/search/SearchFilterItem.tsx b/features/contacts/components/search/SearchFilterItem.tsx index ccddc075e..53f65cc57 100644 --- a/features/contacts/components/search/SearchFilterItem.tsx +++ b/features/contacts/components/search/SearchFilterItem.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { Text, StyleSheet } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import Icon from 'assets/icons'; @@ -29,8 +29,8 @@ function FilterItem({ const [foreground, surfaceTertiary] = useThemeColor(['foreground', 'surface-tertiary'] as const); // Pre-compute colors on JS thread so they can be used safely in worklets - const pressedBg = useMemo(() => opacity(surfaceTertiary, 0.6), [surfaceTertiary]); - const activeBg = useMemo(() => opacity(surfaceTertiary, 0.5), [surfaceTertiary]); + const pressedBg = opacity(surfaceTertiary, 0.6); + const activeBg = opacity(surfaceTertiary, 0.5); const rStyle = useAnimatedStyle(() => { return { diff --git a/features/contacts/hooks/useAllSearchResults.ts b/features/contacts/hooks/useAllSearchResults.ts index 52c516acc..8a8898a8b 100644 --- a/features/contacts/hooks/useAllSearchResults.ts +++ b/features/contacts/hooks/useAllSearchResults.ts @@ -68,13 +68,14 @@ export function useAllSearchResults(query: string): UseAllSearchResultsResult { // immediately; missing/stale entries trigger a relay subscription. This // mirrors the same overlay the split-bill picker does for its // `useContactSearch` hits. - const realPubkeys = useMemo( - () => - displayResults - .filter((r) => !!r.profile && !r.pubkey.startsWith('placeholder-')) - .map((r) => r.pubkey), - [displayResults] - ); + const realPubkeys = useMemo(() => { + // Single pass: filter (has profile, not a placeholder) + project to pubkey. + const pubkeys: string[] = []; + for (const r of displayResults) { + if (r.profile && !r.pubkey.startsWith('placeholder-')) pubkeys.push(r.pubkey); + } + return pubkeys; + }, [displayResults]); const { metadata: cachedMetadata } = useNostrProfileMetadataMany(realPubkeys); return useMemo(() => { diff --git a/features/contacts/lib/navigateToProfile.ts b/features/contacts/lib/navigateToProfile.ts index e5cf7f818..abe59c1fe 100644 --- a/features/contacts/lib/navigateToProfile.ts +++ b/features/contacts/lib/navigateToProfile.ts @@ -10,13 +10,7 @@ import { Keyboard } from 'react-native'; import { paymentLog } from '@/shared/lib/logger'; import { guardedRouter } from '@/shared/hooks/useGuardedRouter'; - -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; export function navigateToProfile(pubkey: string | null | undefined, mintUrl?: string): void { Keyboard.dismiss(); diff --git a/features/contacts/screens/ContactsScreen.tsx b/features/contacts/screens/ContactsScreen.tsx index 58fc5b846..721d8005d 100644 --- a/features/contacts/screens/ContactsScreen.tsx +++ b/features/contacts/screens/ContactsScreen.tsx @@ -7,10 +7,8 @@ import { useGuardedRouter } from '@/shared/hooks/useGuardedRouter'; import { useTabBarBottomPadding } from '@/shared/hooks/useTabBarBottomPadding'; import { useNostrKeysContext } from '@/shared/providers/NostrKeysProvider'; import { useMintManagement } from '@/features/mint'; -import { - useNip17RecentContacts, - type RecentContact, -} from '@/features/payments/hooks/useNip17RecentContacts'; +import { useNip17RecentContacts } from '@/features/payments/hooks/useNip17RecentContacts'; +import type { RecentContact } from '@/features/payments/hooks/recentContactTypes'; import { Spinner } from '@/shared/ui/primitives/Spinner'; import { useMintContacts, type MintContact } from '@/features/payments/hooks/useMintContacts'; import { prefetchImages } from '@/shared/lib/imageCache'; diff --git a/features/feed/components/FeedTabButton.tsx b/features/feed/components/FeedTabButton.tsx index bd0cfbc14..522bc9da0 100644 --- a/features/feed/components/FeedTabButton.tsx +++ b/features/feed/components/FeedTabButton.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { StyleSheet, View } from 'react-native'; import opacity from 'hex-color-opacity'; import Icon from '@/assets/icons'; @@ -23,8 +23,8 @@ export function FeedTabButton({ onPress?: () => void; }) { const [foreground, surfaceTertiary] = useThemeColor(['foreground', 'surface-tertiary'] as const); - const activeBg = useMemo(() => opacity(surfaceTertiary, 0.5), [surfaceTertiary]); - const pressedBg = useMemo(() => opacity(surfaceTertiary, 0.65), [surfaceTertiary]); + const activeBg = opacity(surfaceTertiary, 0.5); + const pressedBg = opacity(surfaceTertiary, 0.65); return ( { + const handleFirstLayout = (event: LayoutChangeEvent) => { const nextHeight = Math.round(event.nativeEvent.layout.height); // The first post's height drives the connector line length AND the // second (reply) post's vertical offset. When it changes after async // content settles, the whole pair reflows below it. setFirstHeight((currentHeight) => (currentHeight === nextHeight ? currentHeight : nextHeight)); - }, []); + }; const connectorStyle = useMemo(() => { const secondAvatarTop = firstHeight + secondAvatarCenterY - FEED_AVATAR_SIZE / 2; @@ -472,11 +472,11 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { void loadFeed(activeSpecIndex); }, [activeSpecIndex, currentSpec, userPubkey, preferenceKey, loadFeed]); - const handleRefresh = useCallback(() => { + const handleRefresh = () => { if (!currentSpec || isRefreshing) return; setIsRefreshing(true); void loadFeed(activeSpecIndex, true); - }, [activeSpecIndex, currentSpec, isRefreshing, loadFeed]); + }; // ── Pagination: load older items ── @@ -619,13 +619,13 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { } }, [beginNetworkLoad, currentSpec, isActiveLoad, userPubkey, startTransition]); - const handleEndReached = useCallback(() => { + const handleEndReached = () => { // Don't start pagination while the first page is still loading or a refresh // is in flight — otherwise the footer spinner stacks on top of the // empty-state / refresh spinner (duplicate spinners). if (isLoading || isRefreshing) return; void loadMoreItems(); - }, [loadMoreItems, isLoading, isRefreshing]); + }; // ── Derived data ── @@ -666,7 +666,7 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { const toggleRepostRef = useLatestRef(toggleRepost); const overlaySourceIndexRef = useRef(-1); - const feedIndicesWithVideo = useMemo(() => computeFeedIndicesWithVideo(feedItems), [feedItems]); + const feedIndicesWithVideo = computeFeedIndicesWithVideo(feedItems); const onOverlayOpenedFromIndex = useCallback((index: number) => { overlaySourceIndexRef.current = index; @@ -686,7 +686,7 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { [feedItems, getDisplayMetrics, getEngagementState, toggleLike, toggleRepost] ); - const getVideoFeedLayoutsAndIndex = useCallback((): { + const getVideoFeedLayoutsAndIndex = (): { layouts: ImageOverlayReplaceLayout[]; initialIndex: number; } | null => { @@ -696,52 +696,46 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { .map((i) => buildLayoutForVideoIndex(i)) .filter((l): l is ImageOverlayReplaceLayout => l != null); return layouts.length ? { layouts, initialIndex: 0 } : null; - }, [feedIndicesWithVideo, buildLayoutForVideoIndex]); - - const onSwipeUpToNextPost = useCallback( - (openNext: (layout: ImageOverlayReplaceLayout) => void) => { - const current = overlaySourceIndexRef.current; - const nextVideoIndex = feedIndicesWithVideo.find((i) => i > current); - if (typeof nextVideoIndex !== 'number') return; - const layout = buildLayoutForVideoIndex(nextVideoIndex); - if (!layout) return; - overlaySourceIndexRef.current = nextVideoIndex; - openNext(layout); - }, - [feedIndicesWithVideo, buildLayoutForVideoIndex] - ); + }; + + const onSwipeUpToNextPost = (openNext: (layout: ImageOverlayReplaceLayout) => void) => { + const current = overlaySourceIndexRef.current; + const nextVideoIndex = feedIndicesWithVideo.find((i) => i > current); + if (typeof nextVideoIndex !== 'number') return; + const layout = buildLayoutForVideoIndex(nextVideoIndex); + if (!layout) return; + overlaySourceIndexRef.current = nextVideoIndex; + openNext(layout); + }; // ── Render ── - const getThreadContext = useCallback( - (replyPreviewEvents?: readonly FeedEvent[]) => { - const allEvents = new Map(); - for (const it of feedItems) { - if (it.rootEvent) { - allEvents.set(it.rootEvent.id, it.rootEvent); - } - if (it.type === 'note') { - allEvents.set(it.event.id, it.event); - for (const replyPreviewEvent of it.replyPreviewEvents ?? []) { - allEvents.set(replyPreviewEvent.id, replyPreviewEvent); - } - } else if (it.originalEvent) { - allEvents.set(it.originalEvent.id, it.originalEvent); - } + const getThreadContext = (replyPreviewEvents?: readonly FeedEvent[]) => { + const allEvents = new Map(); + for (const it of feedItems) { + if (it.rootEvent) { + allEvents.set(it.rootEvent.id, it.rootEvent); } - for (const replyPreviewEvent of replyPreviewEvents ?? []) { - allEvents.set(replyPreviewEvent.id, replyPreviewEvent); + if (it.type === 'note') { + allEvents.set(it.event.id, it.event); + for (const replyPreviewEvent of it.replyPreviewEvents ?? []) { + allEvents.set(replyPreviewEvent.id, replyPreviewEvent); + } + } else if (it.originalEvent) { + allEvents.set(it.originalEvent.id, it.originalEvent); } - return { - allEvents, - profiles: profilesRef.current, - metrics: metricsRef.current, - quotedEvents: quotedRef.current, - replyPreviewEventIds: replyPreviewEvents?.map((event) => event.id), - }; - }, - [feedItems, profilesRef, metricsRef, quotedRef] - ); + } + for (const replyPreviewEvent of replyPreviewEvents ?? []) { + allEvents.set(replyPreviewEvent.id, replyPreviewEvent); + } + return { + allEvents, + profiles: profilesRef.current, + metrics: metricsRef.current, + quotedEvents: quotedRef.current, + replyPreviewEventIds: replyPreviewEvents?.map((event) => event.id), + }; + }; const getThreadContextRef = useLatestRef(getThreadContext); const resolveReposter = useCallback( @@ -797,183 +791,89 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { }); }, [feedItems.length, feedRows.length, isLoading, activeSpecIndex, feedSpecs]); - const renderFeedItem = useCallback( - ({ item: row, index }: { item: FeedRow; index: number }) => { - const item = row.item; - const feedIndex = index; - if (item.type === 'note') { - const metrics = row.metrics; - const engagement = row.engagement; - const contextRootEvent = row.rootEvent; - const replyPreviewEvents = item.replyPreviewEvents ?? []; - if (!contextRootEvent && replyPreviewEvents.length > 0) { - return ( - toggleLikeRef.current(item.event)} - onMorePress={() => openPostActions(item.event)} - onRepostPress={() => toggleRepostRef.current(item.event)} - skipAnimation={!isFirstRender.current} - getThreadContext={() => getThreadContextRef.current(replyPreviewEvents)} - showFooterBorder={false} - fullBleedFooterBorder - /> - } - second={ - <> - {replyPreviewEvents.map((replyEvent, replyIndex) => { - const replyMetrics = getDisplayMetrics(replyEvent.id); - const replyEngagement = getEngagementState(replyEvent.id); - const isLastReply = replyIndex === replyPreviewEvents.length - 1; - return ( - toggleLikeRef.current(replyEvent)} - onMorePress={() => openPostActions(replyEvent)} - onRepostPress={() => toggleRepostRef.current(replyEvent)} - getThreadContext={() => getThreadContextRef.current()} - showFooterBorder={isLastReply} - fullBleedFooterBorder - /> - ); - })} - - } - /> - ); - } - if (contextRootEvent) { - const rootEvent = contextRootEvent; - const rootMetrics = row.rootMetrics ?? DEFAULT_METRICS; - const rootEngagement = row.rootEngagement ?? DEFAULT_ENGAGEMENT_STATE; - return ( - toggleLikeRef.current(rootEvent)} - onMorePress={() => openPostActions(rootEvent)} - onRepostPress={() => toggleRepostRef.current(rootEvent)} - skipAnimation={!isFirstRender.current} - getThreadContext={() => getThreadContextRef.current()} - showFooterBorder={false} - fullBleedFooterBorder - /> - } - second={ - toggleLikeRef.current(item.event)} - onMorePress={() => openPostActions(item.event)} - onRepostPress={() => toggleRepostRef.current(item.event)} - getThreadContext={() => getThreadContextRef.current()} - fullBleedFooterBorder - /> - } - /> - ); - } + const renderFeedItem = ({ item: row, index }: { item: FeedRow; index: number }) => { + const item = row.item; + const feedIndex = index; + if (item.type === 'note') { + const metrics = row.metrics; + const engagement = row.engagement; + const contextRootEvent = row.rootEvent; + const replyPreviewEvents = item.replyPreviewEvents ?? []; + if (!contextRootEvent && replyPreviewEvents.length > 0) { return ( - toggleLikeRef.current(item.event)} - onMorePress={() => openPostActions(item.event)} - onRepostPress={() => toggleRepostRef.current(item.event)} - skipAnimation={!isFirstRender.current} - getThreadContext={() => getThreadContextRef.current()} - fullBleedFooterBorder + toggleLikeRef.current(item.event)} + onMorePress={() => openPostActions(item.event)} + onRepostPress={() => toggleRepostRef.current(item.event)} + skipAnimation={!isFirstRender.current} + getThreadContext={() => getThreadContextRef.current(replyPreviewEvents)} + showFooterBorder={false} + fullBleedFooterBorder + /> + } + second={ + <> + {replyPreviewEvents.map((replyEvent, replyIndex) => { + const replyMetrics = getDisplayMetrics(replyEvent.id); + const replyEngagement = getEngagementState(replyEvent.id); + const isLastReply = replyIndex === replyPreviewEvents.length - 1; + return ( + toggleLikeRef.current(replyEvent)} + onMorePress={() => openPostActions(replyEvent)} + onRepostPress={() => toggleRepostRef.current(replyEvent)} + getThreadContext={() => getThreadContextRef.current()} + showFooterBorder={isLastReply} + fullBleedFooterBorder + /> + ); + })} + + } /> ); } - - const originalEvent = item.originalEvent; - const repostEngagement = row.engagement; - const contextRootEvent = row.rootEvent; - if (contextRootEvent && originalEvent) { + if (contextRootEvent) { const rootEvent = contextRootEvent; const rootMetrics = row.rootMetrics ?? DEFAULT_METRICS; const rootEngagement = row.rootEngagement ?? DEFAULT_ENGAGEMENT_STATE; return ( } second={ - toggleLikeRef.current(originalEvent)} - onMorePress={() => openPostActions(originalEvent)} - onRepostPress={() => toggleRepostRef.current(originalEvent)} - skipAnimation={!isFirstRender.current} + liked={engagement.liked} + replied={engagement.replied} + reposted={engagement.reposted} + likePending={engagement.likePending} + repostPending={engagement.repostPending} + likePendingDirection={engagement.likePendingDirection} + repostPendingDirection={engagement.repostPendingDirection} + onLikePress={() => toggleLikeRef.current(item.event)} + onMorePress={() => openPostActions(item.event)} + onRepostPress={() => toggleRepostRef.current(item.event)} getThreadContext={() => getThreadContextRef.current()} fullBleedFooterBorder /> @@ -1034,47 +930,133 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { ); } return ( - toggleLikeRef.current(originalEvent) : undefined} - onRepostPress={originalEvent ? () => toggleRepostRef.current(originalEvent) : undefined} + liked={engagement.liked} + replied={engagement.replied} + reposted={engagement.reposted} + likePending={engagement.likePending} + repostPending={engagement.repostPending} + likePendingDirection={engagement.likePendingDirection} + repostPendingDirection={engagement.repostPendingDirection} + onLikePress={() => toggleLikeRef.current(item.event)} + onMorePress={() => openPostActions(item.event)} + onRepostPress={() => toggleRepostRef.current(item.event)} skipAnimation={!isFirstRender.current} getThreadContext={() => getThreadContextRef.current()} fullBleedFooterBorder /> ); - }, - [ - getMetrics, - getDisplayMetrics, - getEngagementState, - onOverlayOpenedFromIndex, - toggleLikeRef, - toggleRepostRef, - getThreadContextRef, - openPostActions, - ] - ); + } - const refreshTintColor = useMemo(() => opacity(foreground, 0.5), [foreground]); + const originalEvent = item.originalEvent; + const repostEngagement = row.engagement; + const contextRootEvent = row.rootEvent; + if (contextRootEvent && originalEvent) { + const rootEvent = contextRootEvent; + const rootMetrics = row.rootMetrics ?? DEFAULT_METRICS; + const rootEngagement = row.rootEngagement ?? DEFAULT_ENGAGEMENT_STATE; + return ( + toggleLikeRef.current(rootEvent)} + onMorePress={() => openPostActions(rootEvent)} + onRepostPress={() => toggleRepostRef.current(rootEvent)} + skipAnimation={!isFirstRender.current} + getThreadContext={() => getThreadContextRef.current()} + showFooterBorder={false} + fullBleedFooterBorder + /> + } + second={ + toggleLikeRef.current(originalEvent)} + onMorePress={() => openPostActions(originalEvent)} + onRepostPress={() => toggleRepostRef.current(originalEvent)} + skipAnimation={!isFirstRender.current} + getThreadContext={() => getThreadContextRef.current()} + fullBleedFooterBorder + /> + } + /> + ); + } + return ( + toggleLikeRef.current(originalEvent) : undefined} + onRepostPress={originalEvent ? () => toggleRepostRef.current(originalEvent) : undefined} + skipAnimation={!isFirstRender.current} + getThreadContext={() => getThreadContextRef.current()} + fullBleedFooterBorder + /> + ); + }; + + const refreshTintColor = opacity(foreground, 0.5); const pullToAi = usePullToAiRefreshControl({ // Suppress the pull-to-refresh spinner during the initial (cold-start) load @@ -1084,15 +1066,12 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { tintColor: refreshTintColor, }); - const onScroll = useCallback( - (e: { nativeEvent: { contentOffset: { y: number } } }) => { - scrollOffsetRef.current = e.nativeEvent.contentOffset.y; - if (imageOverlay?.scrollOffsetY != null) { - imageOverlay.scrollOffsetY.value = e.nativeEvent.contentOffset.y; - } - }, - [imageOverlay] - ); + const onScroll = (e: { nativeEvent: { contentOffset: { y: number } } }) => { + scrollOffsetRef.current = e.nativeEvent.contentOffset.y; + if (imageOverlay?.scrollOffsetY != null) { + imageOverlay.scrollOffsetY.value = e.nativeEvent.contentOffset.y; + } + }; return ( @@ -1154,7 +1133,7 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { const LIST_CONTENT_STYLE = { paddingBottom: 120 }; -export const DEFAULT_FEED_SPECS: FeedSpec[] = [ +const DEFAULT_FEED_SPECS: FeedSpec[] = [ { name: FEED_FILTER_FOR_YOU, spec: JSON.stringify({ id: 'for-you', kind: 'notes', hours: 24 }), diff --git a/features/feed/components/RecentPeopleSearchStrip.tsx b/features/feed/components/RecentPeopleSearchStrip.tsx index 43fb83087..4fb2ec5a9 100644 --- a/features/feed/components/RecentPeopleSearchStrip.tsx +++ b/features/feed/components/RecentPeopleSearchStrip.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React from 'react'; import { ScrollView, StyleSheet, @@ -34,7 +34,7 @@ export function RecentPeopleSearchStrip({ }) { const entries = useRecentPeopleStore((state) => state.entries); const clearRecentPeople = useRecentPeopleStore((state) => state.clearRecentPeople); - const pubkeys = useMemo(() => entries.map((entry) => entry.pubkey), [entries]); + const pubkeys = entries.map((entry) => entry.pubkey); const rows = useRecentPeopleProfiles(pubkeys); const [foreground, muted, accent] = useThemeColor(['foreground', 'muted', 'accent'] as const); const stripScrollMetrics = useVisualScrollMetricsLogger({ @@ -54,18 +54,15 @@ export function RecentPeopleSearchStrip({ onLayout: handleStripLayout, onScroll: reportStripScroll, } = stripScrollMetrics; - const handleStripScroll = useCallback( - (event: NativeSyntheticEvent) => { - reportStripScroll(event); - remeasureVisualLayoutScope(RECENT_PEOPLE_VISUAL_SCOPE, 'horizontal_scroll', { - extra: { - rows: rows.length, - title, - }, - }); - }, - [reportStripScroll, rows.length, title] - ); + const handleStripScroll = (event: NativeSyntheticEvent) => { + reportStripScroll(event); + remeasureVisualLayoutScope(RECENT_PEOPLE_VISUAL_SCOPE, 'horizontal_scroll', { + extra: { + rows: rows.length, + title, + }, + }); + }; if (rows.length === 0) return null; @@ -149,7 +146,7 @@ function RecentPersonCard({ foreground: string; muted: string; }) { - const handlePress = useCallback(() => navigateToProfile(pubkey), [pubkey]); + const handlePress = () => navigateToProfile(pubkey); return ( { + const handlePost = async () => { if (!ndk || !canPost || !replyTarget) return; setPosting(true); const blocks: ComposerBlock[] = [{ id: 'reply-text', kind: 'text', text }, ...mediaBlocks]; @@ -180,9 +180,9 @@ export function ThreadReplyBar({ inputRef.current?.blur(); Keyboard.dismiss(); } - }, [ndk, canPost, text, mediaBlocks, replyTarget]); + }; - const handleAddMedia = useCallback(async () => { + const handleAddMedia = async () => { if (!ndk) return; const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images', 'videos'], @@ -208,7 +208,7 @@ export function ThreadReplyBar({ b.id === id ? { ...b, descriptor: upload.value, uploadProgress: undefined } : b ); }); - }, [ndk]); + }; // Hand the current draft + reply context to the full composer. const expandToFull = useCallback( diff --git a/features/feed/components/ThreadView.tsx b/features/feed/components/ThreadView.tsx index fbf12c672..d99a6b873 100644 --- a/features/feed/components/ThreadView.tsx +++ b/features/feed/components/ThreadView.tsx @@ -173,12 +173,12 @@ function ReplySortPicker({ foreground: string; surfaceTertiary: string; }) { - const activeBg = useMemo(() => opacity(surfaceTertiary, 0.5), [surfaceTertiary]); - const pressedBg = useMemo(() => opacity(surfaceTertiary, 0.65), [surfaceTertiary]); + const activeBg = opacity(surfaceTertiary, 0.5); + const pressedBg = opacity(surfaceTertiary, 0.65); const selectedOption = REPLY_SORT_OPTIONS.find((option) => option.id === selected) ?? REPLY_SORT_OPTIONS[0]; - const openSortMenu = useCallback(() => { + const openSortMenu = () => { actionMenuPopup({ title: 'Replies', buttons: REPLY_SORT_OPTIONS.map((option) => ({ @@ -193,7 +193,7 @@ function ReplySortPicker({ }, })), }); - }, [onSelect, selected]); + }; return ( @@ -266,8 +266,8 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { const openPostActions = usePostActions({ getProfileName }); const openComposer = useOpenComposer(); - const targetItem = useMemo(() => items.find((item) => item.type === 'target'), [items]); - const hasParents = useMemo(() => items.some((i) => i.type === 'parent'), [items]); + const targetItem = items.find((item) => item.type === 'target'); + const hasParents = items.some((i) => i.type === 'parent'); const displayItems = useMemo(() => { if (isLoading && items.length === 0) { @@ -374,7 +374,7 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { [metricsRef] ); - const actionableEvents = useMemo(() => items.map((item) => item.event), [items]); + const actionableEvents = items.map((item) => item.event); const { getDisplayMetrics, getEngagementState, toggleLike, toggleRepost, engagementRevision } = useNostrEngagement(actionableEvents, getMetrics); @@ -389,106 +389,85 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { }; }, [items, profilesRef, metricsRef, quotedEventsRef]); - const renderThreadItem = useCallback( - ({ item, index }: { item: ThreadListItem; index: number }) => { - if (item.type === 'target-skeleton') { - return ; - } - - if (item.type === 'reply-skeleton') { - // FlashList recycles cells, which fights reanimated exit animations — an - // exiting skeleton renders in a recycled cell's position for a frame (the - // "skeleton in the wrong place" glitch), so the skeleton simply unmounts - // and the real reply fades IN over it (see REPLY_FADE_IN below). - return ; - } - - if (item.type === 'reply-sort-tabs') { - return ( - - ); - } - - const isParent = item.type === 'parent'; - const isTarget = item.type === 'target'; - - const metrics = getDisplayMetrics(item.event.id); - const engagement = getEngagementState(item.event.id); - - const card = ( - 0 : isTarget ? hasParents : false} - showLineBelow={isParent} - liked={engagement.liked} - replied={engagement.replied} - reposted={engagement.reposted} - likePending={engagement.likePending} - repostPending={engagement.repostPending} - likePendingDirection={engagement.likePendingDirection} - repostPendingDirection={engagement.repostPendingDirection} - onLikePress={() => toggleLike(item.event)} - onRepostPress={() => toggleRepost(item.event)} - onCommentPress={() => - openComposer(deriveReplyTarget(item.event), { - parentEvent: item.event, - parentProfile: profilesRef.current.get(item.event.pubkey), - }) - } - onMorePress={() => openPostActions(item.event)} - getThreadContext={getThreadContext} + const renderThreadItem = ({ item, index }: { item: ThreadListItem; index: number }) => { + if (item.type === 'target-skeleton') { + return ; + } + + if (item.type === 'reply-skeleton') { + // FlashList recycles cells, which fights reanimated exit animations — an + // exiting skeleton renders in a recycled cell's position for a frame (the + // "skeleton in the wrong place" glitch), so the skeleton simply unmounts + // and the real reply fades IN over it (see REPLY_FADE_IN below). + return ; + } + + if (item.type === 'reply-sort-tabs') { + return ( + ); + } - // Each reply fades its real content in as it loads. The ENTER animation is - // recycling-tolerant on FlashList (it plays at the cell's correct spot and - // doesn't re-fire on scroll, since recycled cells reuse the instance) — unlike - // a skeleton EXIT, which lingered in a recycled cell's slot (the "wrong - // place" glitch), so the reply only ever fades IN. - if (item.type === 'reply') { - return {card}; - } - return card; - }, - [ - openPostActions, - openComposer, - embedOpen, - targetFooterOpacity, - getDisplayMetrics, - getEngagementState, - getMetrics, - hasParents, - profilesRef, - quotedEventsRef, - replySort, - setReplySort, - foreground, - surfaceTertiary, - toggleLike, - toggleRepost, - getThreadContext, - ] - ); + const isParent = item.type === 'parent'; + const isTarget = item.type === 'target'; + + const metrics = getDisplayMetrics(item.event.id); + const engagement = getEngagementState(item.event.id); + + const card = ( + 0 : isTarget ? hasParents : false} + showLineBelow={isParent} + liked={engagement.liked} + replied={engagement.replied} + reposted={engagement.reposted} + likePending={engagement.likePending} + repostPending={engagement.repostPending} + likePendingDirection={engagement.likePendingDirection} + repostPendingDirection={engagement.repostPendingDirection} + onLikePress={() => toggleLike(item.event)} + onRepostPress={() => toggleRepost(item.event)} + onCommentPress={() => + openComposer(deriveReplyTarget(item.event), { + parentEvent: item.event, + parentProfile: profilesRef.current.get(item.event.pubkey), + }) + } + onMorePress={() => openPostActions(item.event)} + getThreadContext={getThreadContext} + /> + ); + + // Each reply fades its real content in as it loads. The ENTER animation is + // recycling-tolerant on FlashList (it plays at the cell's correct spot and + // doesn't re-fire on scroll, since recycled cells reuse the instance) — unlike + // a skeleton EXIT, which lingered in a recycled cell's slot (the "wrong + // place" glitch), so the reply only ever fades IN. + if (item.type === 'reply') { + return {card}; + } + return card; + }; - const handleEndReached = useCallback(() => { + const handleEndReached = () => { // Don't start reply pagination while the initial fetch is running — the // footer spinner would otherwise overlap the loading skeletons. if (isFetching) return; void loadMoreReplies(); - }, [loadMoreReplies, isFetching]); + }; // FlashList re-renders rows when `data` changes by reference or when `extraData` // changes. Engagement (likes/reposts), pagination flags and the sort live outside @@ -510,23 +489,23 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { // target PostCard's MetricsFooter wiring). Hooks stay above the early // return below. const targetEvent = targetItem?.event; - const onTargetComment = useCallback(() => { + const onTargetComment = () => { if (!targetEvent) return; openComposer(deriveReplyTarget(targetEvent), { parentEvent: targetEvent, parentProfile: profilesRef.current.get(targetEvent.pubkey), }); - }, [targetEvent, openComposer, profilesRef]); - const onTargetLike = useCallback(() => { + }; + const onTargetLike = () => { if (targetEvent) void toggleLike(targetEvent); - }, [targetEvent, toggleLike]); - const onTargetRepost = useCallback(() => { + }; + const onTargetRepost = () => { if (targetEvent) void toggleRepost(targetEvent); - }, [targetEvent, toggleRepost]); + }; const quotePost = useQuotePost(); - const onTargetQuote = useCallback(() => { + const onTargetQuote = () => { if (targetEvent) quotePost(targetEvent, profilesRef.current.get(targetEvent.pubkey)); - }, [targetEvent, quotePost, profilesRef]); + }; if (error && items.length === 0) { return ( diff --git a/features/feed/components/UserFeed.tsx b/features/feed/components/UserFeed.tsx index 824d1fe42..8af48b5a5 100644 --- a/features/feed/components/UserFeed.tsx +++ b/features/feed/components/UserFeed.tsx @@ -699,7 +699,7 @@ export function UserFeed({ onVideoPostsReady?.(videoPosts); }, [videoPosts, onVideoPostsReady]); - const feedIndicesWithVideo = useMemo(() => computeFeedIndicesWithVideo(feedItems), [feedItems]); + const feedIndicesWithVideo = computeFeedIndicesWithVideo(feedItems); const onOverlayOpenedFromIndex = useCallback((index: number) => { overlaySourceIndexRef.current = index; diff --git a/features/feed/components/nostr/MetricsFooter.tsx b/features/feed/components/nostr/MetricsFooter.tsx index 4a31af1ca..af6a895b3 100644 --- a/features/feed/components/nostr/MetricsFooter.tsx +++ b/features/feed/components/nostr/MetricsFooter.tsx @@ -14,7 +14,7 @@ import { HStack } from '@/shared/ui/primitives/View/HStack'; import { View } from '@/shared/ui/primitives/View/View'; import Icon from 'assets/icons'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; -import { COMMENT_ACCENT } from '@/shared/lib/brandColors'; +import { COMMENT_ACCENT, LIKE_ACCENT } from '@/shared/lib/brandColors'; import { openRepostMenu } from '@/features/feed/lib/repostMenu'; import type { NoteMetrics } from './feedTypes'; import { formatCount, formatSats } from './feedFormat'; @@ -145,7 +145,7 @@ export const MetricsFooter = React.memo(function MetricsFooter({ const repliedColor = COMMENT_ACCENT; const iconColor = opacity(borderColor, alpha.disabled); const textColor = opacity(borderColor, alpha.disabled); - const likedColor = '#ff5a7a'; + const likedColor = LIKE_ACCENT; const iconSizes = compact ? POST_ACTION_ICON_SIZES.compact : POST_ACTION_ICON_SIZES.regular; const textSize = compact ? 11 : 13; diff --git a/features/feed/components/nostr/NoteContent.tsx b/features/feed/components/nostr/NoteContent.tsx index 2b41ef3c8..417bdcbb6 100644 --- a/features/feed/components/nostr/NoteContent.tsx +++ b/features/feed/components/nostr/NoteContent.tsx @@ -239,7 +239,7 @@ const LightningBlock = React.memo(function LightningBlock({ meltTarget }: { melt ] as const); const walletContext = useWalletContext(); const machine = usePaymentFlowMachine({ walletContext }); - const decoded = useMemo(() => decodeFeedInvoice(meltTarget), [meltTarget]); + const decoded = decodeFeedInvoice(meltTarget); if (!decoded) { return ( @@ -311,21 +311,21 @@ export const QuotedPostCard = React.memo(function QuotedPostCard({ 'surface-tertiary', ] as const); - const suppressQuotedTapStart = useCallback(() => { + const suppressQuotedTapStart = () => { onPressIn?.(); - }, [onPressIn]); + }; - const suppressQuotedTapEnd = useCallback(() => { + const suppressQuotedTapEnd = () => { onPressOut?.(); - }, [onPressOut]); + }; - const handleOpenQuotedThread = useCallback(() => { + const handleOpenQuotedThread = () => { if (!event) return; router.navigate({ pathname: '/(user-flow)/thread', params: { eventId: event.id }, }); - }, [event]); + }; if (!event) { return ( @@ -483,18 +483,15 @@ export const NoteContent = React.memo(function NoteContent({ const shift = useShiftLogger('NoteContent'); const noteKey = overlayEvent?.id ?? 'inline-note'; - const toggleExpanded = useCallback( - (next: boolean) => { - feedLog.info('feed.shift.note.expand', { - component: 'NoteContent', - key: noteKey, - expanded: next, - contentLength: content.length, - }); - setExpanded(next); - }, - [noteKey, content.length] - ); + const toggleExpanded = (next: boolean) => { + feedLog.info('feed.shift.note.expand', { + component: 'NoteContent', + key: noteKey, + expanded: next, + contentLength: content.length, + }); + setExpanded(next); + }; const onBeforeOpen = useCallback(() => { if (typeof feedIndex === 'number' && onOverlayOpenedFromIndex) { @@ -529,7 +526,7 @@ export const NoteContent = React.memo(function NoteContent({ }, [content]); // NIP-92 imeta metadata (alt text / dimensions) keyed by media url. - const imetaByUrl = useMemo(() => parseImetaTags(overlayEvent?.tags ?? []), [overlayEvent]); + const imetaByUrl = parseImetaTags(overlayEvent?.tags ?? []); const { mediaSegments, allMediaUrls, allMediaTypes, overlayPost } = useMemo(() => { const media = blockSegments.filter( diff --git a/features/feed/components/nostr/PostCard.tsx b/features/feed/components/nostr/PostCard.tsx index 605236255..26dff0ad7 100644 --- a/features/feed/components/nostr/PostCard.tsx +++ b/features/feed/components/nostr/PostCard.tsx @@ -577,8 +577,8 @@ export const PostCardSkeleton = React.memo(function PostCardSkeleton({ exiting?: boolean; }) { const [foreground, loadingShimmerSurface] = useThemeColor(['foreground', 'surface'] as const); - const textMuted = useMemo(() => ({ color: opacity(foreground, alpha.muted) }), [foreground]); - const targetDateStyle = useMemo(() => [textMuted, pcStyles.targetDate], [textMuted]); + const textMuted = { color: opacity(foreground, alpha.muted) }; + const targetDateStyle = [textMuted, pcStyles.targetDate]; const replyVariant = REPLY_SKELETON_VARIANTS[index % REPLY_SKELETON_VARIANTS.length]; // Render invisible, then fade in once the row has laid out ("settled") so the @@ -715,7 +715,7 @@ const MetricsFooterSkeleton = React.memo(function MetricsFooterSkeleton({ // size (via a `Text loading` placeholder) — a hardcoded label rectangle was ~7px // shorter, which made the reply row grow when real text replaced the skeleton. const labelTextSize = compact ? 11 : 13; - const skeletonFill = useMemo(() => opacity(borderColor, 0.07), [borderColor]); + const skeletonFill = opacity(borderColor, 0.07); const footerStyle = useMemo( () => [ sharedStyles.noteFooter, @@ -733,7 +733,7 @@ const MetricsFooterSkeleton = React.memo(function MetricsFooterSkeleton({ }), [glyph, skeletonFill] ); - const labelStyle = useMemo(() => ({ width: labelWidth }), [labelWidth]); + const labelStyle = { width: labelWidth }; return ( diff --git a/features/feed/components/nostr/StoriesCarousel.tsx b/features/feed/components/nostr/StoriesCarousel.tsx index cafcee748..5ed68ca3a 100644 --- a/features/feed/components/nostr/StoriesCarousel.tsx +++ b/features/feed/components/nostr/StoriesCarousel.tsx @@ -6,6 +6,7 @@ */ import React, { useCallback, useEffect, useRef, useState, type FC } from 'react'; +import { INVARIANT_BLACK, INVARIANT_WHITE } from '@/shared/lib/brandColors'; import { FlatList, Platform, @@ -170,13 +171,10 @@ export const StoriesCarousel: FC = ({ }, [onVisualListMetricsChange] ); - const reportCarouselScrollFromUI = useCallback( - (scroll: number, size: number, contentLength: number) => { - metricsRef.current = { contentLength, scroll, size }; - reportCarouselMetrics('scroll'); - }, - [reportCarouselMetrics] - ); + const reportCarouselScrollFromUI = (scroll: number, size: number, contentLength: number) => { + metricsRef.current = { contentLength, scroll, size }; + reportCarouselMetrics('scroll'); + }; const scrollHandler = useAnimatedScrollHandler({ onBeginDrag: () => { @@ -205,54 +203,48 @@ export const StoriesCarousel: FC = ({ pointerEvents: Platform.OS === 'android' ? 'auto' : carouselPointerEvents.get(), })); - const onViewableItemsChanged = useCallback( - ({ viewableItems, changed }: { viewableItems: ViewToken[]; changed: ViewToken[] }) => { - if (viewableItems.length > 0 && viewableItems[0]?.index !== null) { - setListCurrentIndex(viewableItems[0].index!); - } - onVisualViewableItemsChanged({ - ...storyViewabilityRange([...viewableItems, ...changed]), - viewableItems: viewableItems.map(storyVisualToken), - changed: changed.map(storyVisualToken), - }); - }, - [onVisualViewableItemsChanged] - ); - const handleCarouselLayout = useCallback( - (event: LayoutChangeEvent) => { - const size = event.nativeEvent.layout.width; - metricsRef.current.size = size; - metricsRef.current.contentLength = storyUsers.length * size; - reportCarouselMetrics('layout'); - }, - [reportCarouselMetrics, storyUsers.length] - ); - const handleCarouselContentSizeChange = useCallback( - (contentWidth: number) => { - metricsRef.current.contentLength = contentWidth; - reportCarouselMetrics('content-size'); - }, - [reportCarouselMetrics] - ); - const handleCarouselScrollSettled = useCallback( - (event: NativeSyntheticEvent) => { - const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; - metricsRef.current = { - contentLength: contentSize.width, - scroll: contentOffset.x, - size: layoutMeasurement.width, - }; - reportCarouselMetrics('settled'); - remeasureVisualLayoutScope(STORIES_VISUAL_SCOPE, 'scroll-settled', { - extra: { - userCount: storyUsers.length, - listCurrentIndex, - isClosing, - }, - }); - }, - [isClosing, listCurrentIndex, reportCarouselMetrics, storyUsers.length] - ); + const onViewableItemsChanged = ({ + viewableItems, + changed, + }: { + viewableItems: ViewToken[]; + changed: ViewToken[]; + }) => { + if (viewableItems.length > 0 && viewableItems[0]?.index !== null) { + setListCurrentIndex(viewableItems[0].index!); + } + onVisualViewableItemsChanged({ + ...storyViewabilityRange([...viewableItems, ...changed]), + viewableItems: viewableItems.map(storyVisualToken), + changed: changed.map(storyVisualToken), + }); + }; + const handleCarouselLayout = (event: LayoutChangeEvent) => { + const size = event.nativeEvent.layout.width; + metricsRef.current.size = size; + metricsRef.current.contentLength = storyUsers.length * size; + reportCarouselMetrics('layout'); + }; + const handleCarouselContentSizeChange = (contentWidth: number) => { + metricsRef.current.contentLength = contentWidth; + reportCarouselMetrics('content-size'); + }; + const handleCarouselScrollSettled = (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + metricsRef.current = { + contentLength: contentSize.width, + scroll: contentOffset.x, + size: layoutMeasurement.width, + }; + reportCarouselMetrics('settled'); + remeasureVisualLayoutScope(STORIES_VISUAL_SCOPE, 'scroll-settled', { + extra: { + userCount: storyUsers.length, + listCurrentIndex, + isClosing, + }, + }); + }; useEffect(() => { metricsRef.current = { @@ -280,6 +272,7 @@ export const StoriesCarousel: FC = ({ width, }}> item.pubkey} @@ -418,13 +411,13 @@ const UserStoriesItem: FC = ({ } }); - const pausePlayer = useCallback(() => { + const pausePlayer = () => { safePlayerCall(player, (p) => p.pause()); - }, [player]); + }; - const resumePlayer = useCallback(() => { + const resumePlayer = () => { safePlayerCall(player, (p) => p.play()); - }, [player]); + }; useAnimatedReaction( () => isDragging.get(), @@ -437,55 +430,44 @@ const UserStoriesItem: FC = ({ } ); - const onStoryPress = useCallback( - (e: GestureResponderEvent) => { - const isLeft = e.nativeEvent.pageX < screenWidth / 2; - const isLastStory = currentStoryIndex === user.videoPosts.length - 1; - const isFirstStory = currentStoryIndex === 0; - - if (isLeft) { - if (userIndex === 0 && isFirstStory) return; - if (isFirstStory) { - scrollRef.current?.scrollToIndex({ index: userIndex - 1, animated: true }); - } else { - setCurrentStoryIndex(currentStoryIndex - 1); - } + const onStoryPress = (e: GestureResponderEvent) => { + const isLeft = e.nativeEvent.pageX < screenWidth / 2; + const isLastStory = currentStoryIndex === user.videoPosts.length - 1; + const isFirstStory = currentStoryIndex === 0; + + if (isLeft) { + if (userIndex === 0 && isFirstStory) return; + if (isFirstStory) { + scrollRef.current?.scrollToIndex({ index: userIndex - 1, animated: true }); } else { - if (userIndex === totalUsers - 1 && isLastStory) { - onClose?.(); - return; - } - if (isLastStory) { - scrollRef.current?.scrollToIndex({ index: userIndex + 1, animated: true }); - } else { - setCurrentStoryIndex(currentStoryIndex + 1); - } + setCurrentStoryIndex(currentStoryIndex - 1); } - }, - [ - currentStoryIndex, - userIndex, - totalUsers, - scrollRef, - screenWidth, - user.videoPosts.length, - onClose, - ] - ); + } else { + if (userIndex === totalUsers - 1 && isLastStory) { + onClose?.(); + return; + } + if (isLastStory) { + scrollRef.current?.scrollToIndex({ index: userIndex + 1, animated: true }); + } else { + setCurrentStoryIndex(currentStoryIndex + 1); + } + } + }; - const onStoryLongPress = useCallback(() => { + const onStoryLongPress = () => { safePlayerCall(player, (p) => p.pause()); - }, [player]); + }; - const onStoryPressOut = useCallback(() => { + const onStoryPressOut = () => { if (isDragging.get()) return; safePlayerCall(player, (p) => p.play()); - }, [isDragging, player]); + }; - const handleClose = useCallback(() => { + const handleClose = () => { safePlayerCall(player, (p) => p.pause()); onClose?.(); - }, [player, onClose]); + }; const profileName = user.profile?.name || user.pubkey.slice(0, 12) + '…'; const profilePicture = user.profile?.picture; @@ -563,7 +545,7 @@ const UserStoriesItem: FC = ({ {profileName} - + @@ -579,7 +561,7 @@ const UserStoriesItem: FC = ({ const styles = StyleSheet.create({ flex1: { flex: 1 }, videoRadius: { borderRadius: 16 }, - closingPlaceholder: { backgroundColor: '#000' }, + closingPlaceholder: { backgroundColor: INVARIANT_BLACK }, topGradient: { position: 'absolute', top: 0, @@ -607,7 +589,7 @@ const styles = StyleSheet.create({ gap: 8, }, profileName: { - color: '#fff', + color: INVARIANT_WHITE, }, closeButton: { width: 32, diff --git a/features/feed/components/nostr/StoryProgressBar.tsx b/features/feed/components/nostr/StoryProgressBar.tsx index 33f4979db..d342a4dce 100644 --- a/features/feed/components/nostr/StoryProgressBar.tsx +++ b/features/feed/components/nostr/StoryProgressBar.tsx @@ -6,6 +6,7 @@ */ import React, { FC } from 'react'; +import { INVARIANT_WHITE } from '@/shared/lib/brandColors'; import { StyleSheet, View } from 'react-native'; import Animated, { Extrapolation, @@ -53,6 +54,6 @@ const styles = StyleSheet.create({ fill: { height: '100%', borderRadius: 999, - backgroundColor: '#fff', + backgroundColor: INVARIANT_WHITE, }, }); diff --git a/features/feed/components/nostr/easeGradient.ts b/features/feed/components/nostr/easeGradient.ts index 371f816e8..23d980129 100644 --- a/features/feed/components/nostr/easeGradient.ts +++ b/features/feed/components/nostr/easeGradient.ts @@ -4,6 +4,11 @@ * Adapted from https://github.com/phamfoo/react-native-easing-gradient (MIT) */ +// This MIT adaptation drives RN's internal +// `Animated.Interpolation.__createInterpolation` color-interpolation API (used +// below for gradient color stops); Reanimated has no equivalent, so the raw +// `Animated`/`Easing` imports are load-bearing here, not a legacy-animation smell. +// eslint-disable-next-line no-restricted-imports -- see note above import { Animated, Easing, type EasingFunction } from 'react-native'; // @ts-expect-error - internal RN API for color interpolation diff --git a/features/feed/components/nostr/feedParse.ts b/features/feed/components/nostr/feedParse.ts index b9c702356..dce0198de 100644 --- a/features/feed/components/nostr/feedParse.ts +++ b/features/feed/components/nostr/feedParse.ts @@ -1,7 +1,7 @@ import { nip19 } from 'nostr-tools'; import type { ContentSegment, FeedEvent } from './feedTypes'; -export const IMAGE_EXT = /\.(jpe?g|png|gif|webp|svg)(\?.*)?$/i; +const IMAGE_EXT = /\.(jpe?g|png|gif|webp|svg)(\?.*)?$/i; export const VIDEO_EXT = /\.(mp4|webm|mov|m4v|avi)(\?.*)?$/i; // Bounded quantifiers protect parseContent against adversarial relay content @@ -190,7 +190,7 @@ export function tryNpubEncode(hex: string): string { } /** NIP-92 imeta metadata for a media url. */ -export interface ImetaInfo { +interface ImetaInfo { url: string; mimeType?: string; alt?: string; @@ -260,35 +260,6 @@ export function prettifyUrl(raw: string): string { } } -export function normalizeFeedEvent(value: unknown): FeedEvent | null { - if (!value || typeof value !== 'object') return null; - const input = value as Record; - if ( - typeof input.id !== 'string' || - typeof input.kind !== 'number' || - typeof input.pubkey !== 'string' || - typeof input.content !== 'string' || - typeof input.created_at !== 'number' || - !Array.isArray(input.tags) - ) { - return null; - } - - const content = - input.content.length > MAX_FEED_CONTENT_LEN - ? input.content.slice(0, MAX_FEED_CONTENT_LEN) + '…' - : input.content; - - return { - id: input.id, - kind: input.kind, - pubkey: input.pubkey, - content, - created_at: input.created_at, - tags: input.tags.filter(Array.isArray) as string[][], - }; -} - export function parseJson(raw: string): T | null { try { return JSON.parse(raw) as T; @@ -296,8 +267,3 @@ export function parseJson(raw: string): T | null { return null; } } - -export function getFirstTagValue(event: FeedEvent, tagName: string): string | undefined { - const tag = event.tags.find((t) => t[0] === tagName); - return tag?.[1]; -} diff --git a/features/feed/components/nostr/image-overlay/AnimatedImageOverlay.tsx b/features/feed/components/nostr/image-overlay/AnimatedImageOverlay.tsx index 1c6f7cbcd..cb7ffdd04 100644 --- a/features/feed/components/nostr/image-overlay/AnimatedImageOverlay.tsx +++ b/features/feed/components/nostr/image-overlay/AnimatedImageOverlay.tsx @@ -4,6 +4,7 @@ */ import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { INVARIANT_WHITE } from '@/shared/lib/brandColors'; import { BackHandler, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { @@ -93,7 +94,7 @@ function AnimatedImageOverlayContent({ }) { const rawInsets = useSafeAreaInsets(); // Override the unreliable in-overlay bottom inset with the correct one. - const insets = useMemo(() => ({ ...rawInsets, bottom: safeBottom }), [rawInsets, safeBottom]); + const insets = { ...rawInsets, bottom: safeBottom }; const { width: screenWidth, height: screenHeight } = useWindowDimensions(); // The sheet is a true bottom drawer (not an overlay on the image), so it reads // like the feed it came from: same `surface` background, with the handle and @@ -201,13 +202,10 @@ function AnimatedImageOverlayContent({ // before the collapsing drawer gets shorter than the bar (no poke-out). const [replyBarHeight, setReplyBarHeight] = useState(0); const replyBarHeightSv = useSharedValue(0); - const handleReplyBarHeight = useCallback( - (height: number) => { - setReplyBarHeight(height); - replyBarHeightSv.value = height; - }, - [replyBarHeightSv] - ); + const handleReplyBarHeight = (height: number) => { + setReplyBarHeight(height); + replyBarHeightSv.value = height; + }; useEffect(() => { if (!activeOverlayPost) setSheetOpen(false); }, [activeOverlayPost]); @@ -258,7 +256,7 @@ function AnimatedImageOverlayContent({ const onVerticalPagerSnap = useCallback( (index: number) => { - if (!videoFeedLayouts || !videoFeedLayouts.length) return; + if (!videoFeedLayouts?.length) return; const clamped = Math.max(0, Math.min(index, videoFeedLayouts.length - 1)); const layout = videoFeedLayouts[clamped]; if (layout) setVideoFeedIndex(clamped, layout); @@ -633,12 +631,9 @@ function AnimatedImageOverlayContent({ /** When touch is in scroll area we delay activate/fail until onTouchesMove to detect drag direction. */ const panelTouchStartYSv = useSharedValue(-1); - const panelScrollHandler = useCallback( - (e: { nativeEvent: { contentOffset: { y: number } } }) => { - scrollOffsetYInPanel.value = e.nativeEvent.contentOffset.y; - }, - [scrollOffsetYInPanel] - ); + const panelScrollHandler = (e: { nativeEvent: { contentOffset: { y: number } } }) => { + scrollOffsetYInPanel.value = e.nativeEvent.contentOffset.y; + }; /** Min movement (px) in scroll area before we activate/fail. */ const PANEL_DRAG_THRESHOLD = 10; @@ -702,108 +697,94 @@ function AnimatedImageOverlayContent({ */ const scrollAreaPanRef = useRef(undefined); const dismissPanRef = useRef(undefined); - const scrollAreaPan = useMemo( - () => - Gesture.Pan() - .manualActivation(true) - .onTouchesDown((e, stateManager) => { - 'worklet'; - if (e.numberOfTouches !== 1) { - stateManager.fail(); - return; - } - panelTouchStartYSv.value = e.allTouches[0]?.y ?? 0; - }) - .onTouchesMove((e, stateManager) => { - 'worklet'; - if (panelTouchStartYSv.value < 0) return; - if (e.numberOfTouches !== 1) { - panelTouchStartYSv.value = -1; - stateManager.fail(); - return; - } - const touchY = e.allTouches[0]?.y ?? 0; - const deltaY = touchY - panelTouchStartYSv.value; - const sheetAtSmallSnap = panelHeightSv.value < scrollVsDragMidHeight; - const draggedDown = deltaY >= PANEL_DRAG_THRESHOLD; - const draggedUp = deltaY <= -PANEL_DRAG_THRESHOLD; - if (draggedDown || draggedUp) { - panelTouchStartYSv.value = -1; - if (sheetAtSmallSnap) { - stateManager.activate(); - } else { - const scrollAtTop = scrollOffsetYInPanel.value <= SCROLL_AT_TOP_THRESHOLD; - if (scrollAtTop && draggedDown) { - stateManager.activate(); - } else { - stateManager.fail(); - } - } - } - }) - .onTouchesUp((_e, stateManager) => { - 'worklet'; - if (panelTouchStartYSv.value >= 0) { - panelTouchStartYSv.value = -1; - stateManager.fail(); - } - }) - .onTouchesCancelled((_e, stateManager) => { - 'worklet'; - if (panelTouchStartYSv.value >= 0) { - panelTouchStartYSv.value = -1; + const scrollAreaPan = Gesture.Pan() + .manualActivation(true) + .onTouchesDown((e, stateManager) => { + 'worklet'; + if (e.numberOfTouches !== 1) { + stateManager.fail(); + return; + } + panelTouchStartYSv.value = e.allTouches[0]?.y ?? 0; + }) + .onTouchesMove((e, stateManager) => { + 'worklet'; + if (panelTouchStartYSv.value < 0) return; + if (e.numberOfTouches !== 1) { + panelTouchStartYSv.value = -1; + stateManager.fail(); + return; + } + const touchY = e.allTouches[0]?.y ?? 0; + const deltaY = touchY - panelTouchStartYSv.value; + const sheetAtSmallSnap = panelHeightSv.value < scrollVsDragMidHeight; + const draggedDown = deltaY >= PANEL_DRAG_THRESHOLD; + const draggedUp = deltaY <= -PANEL_DRAG_THRESHOLD; + if (draggedDown || draggedUp) { + panelTouchStartYSv.value = -1; + if (sheetAtSmallSnap) { + stateManager.activate(); + } else { + const scrollAtTop = scrollOffsetYInPanel.value <= SCROLL_AT_TOP_THRESHOLD; + if (scrollAtTop && draggedDown) { + stateManager.activate(); + } else { stateManager.fail(); } - }) - .onStart(() => { - panelDragStartSv.value = panelHeightSv.value; - }) - .onChange((e) => { - const maxH = panelMaxHeight; - const next = panelDragStartSv.value - e.translationY; - panelHeightSv.value = Math.max(0, Math.min(maxH, next)); - }) - .onEnd((e) => { + } + } + }) + .onTouchesUp((_e, stateManager) => { + 'worklet'; + if (panelTouchStartYSv.value >= 0) { + panelTouchStartYSv.value = -1; + stateManager.fail(); + } + }) + .onTouchesCancelled((_e, stateManager) => { + 'worklet'; + if (panelTouchStartYSv.value >= 0) { + panelTouchStartYSv.value = -1; + stateManager.fail(); + } + }) + .onStart(() => { + panelDragStartSv.value = panelHeightSv.value; + }) + .onChange((e) => { + const maxH = panelMaxHeight; + const next = panelDragStartSv.value - e.translationY; + panelHeightSv.value = Math.max(0, Math.min(maxH, next)); + }) + .onEnd((e) => { + 'worklet'; + const current = panelHeightSv.value; + const velocityY = -e.velocityY; + const SNAP_0 = 0; + const SNAP_60 = snap60Height; + const SNAP_100 = panelMaxHeight; + const t30 = screenHeight * 0.3; + const t80 = screenHeight * 0.8; + let snapTo: number; + if (velocityY > 250) snapTo = SNAP_100; + else if (velocityY < -250) snapTo = current < screenHeight * 0.5 ? SNAP_0 : SNAP_60; + else if (current < t30) snapTo = SNAP_0; + else if (current < t80) snapTo = SNAP_60; + else snapTo = SNAP_100; + const closeSheet = snapTo <= 0; + panelHeightSv.value = withTiming( + snapTo, + { + duration: BOTTOM_PANEL_STIFF_DURATION_MS, + easing: Easing.out(Easing.cubic), + }, + (finished) => { 'worklet'; - const current = panelHeightSv.value; - const velocityY = -e.velocityY; - const SNAP_0 = 0; - const SNAP_60 = snap60Height; - const SNAP_100 = panelMaxHeight; - const t30 = screenHeight * 0.3; - const t80 = screenHeight * 0.8; - let snapTo: number; - if (velocityY > 250) snapTo = SNAP_100; - else if (velocityY < -250) snapTo = current < screenHeight * 0.5 ? SNAP_0 : SNAP_60; - else if (current < t30) snapTo = SNAP_0; - else if (current < t80) snapTo = SNAP_60; - else snapTo = SNAP_100; - const closeSheet = snapTo <= 0; - panelHeightSv.value = withTiming( - snapTo, - { - duration: BOTTOM_PANEL_STIFF_DURATION_MS, - easing: Easing.out(Easing.cubic), - }, - (finished) => { - 'worklet'; - if (finished && closeSheet) scheduleOnRN(setSheetOpenFromReaction, false); - } - ); - }) - .withRef(scrollAreaPanRef), - [ - panelHeightSv, - panelDragStartSv, - panelMaxHeight, - snap60Height, - screenHeight, - scrollVsDragMidHeight, - panelTouchStartYSv, - scrollOffsetYInPanel, - setSheetOpenFromReaction, - ] - ); + if (finished && closeSheet) scheduleOnRN(setSheetOpenFromReaction, false); + } + ); + }) + .withRef(scrollAreaPanRef); const triggerSwipeUpToNext = useCallback(() => { if (onSwipeUpToNextPost && openReplace) { @@ -1158,7 +1139,7 @@ function AnimatedImageOverlayContent({ rCloseBtnStyle, ]}> triggerClose()} style={StyleSheet.absoluteFill}> - + {activeUrl ? ( diff --git a/features/feed/components/nostr/image-overlay/BottomPanel.tsx b/features/feed/components/nostr/image-overlay/BottomPanel.tsx index 7283de84b..88429441b 100644 --- a/features/feed/components/nostr/image-overlay/BottomPanel.tsx +++ b/features/feed/components/nostr/image-overlay/BottomPanel.tsx @@ -9,7 +9,7 @@ * - InlinePanelImage: image with blurred letterbox for aspect ratio mismatch */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { BlurView } from 'expo-blur'; @@ -25,7 +25,7 @@ import type { ContentSegment } from '../feedTypes'; import type { ImageOverlayPost } from './types'; import { BOTTOM_PANEL_PADDING_HORIZONTAL, BOTTOM_PANEL_PADDING_TOP } from './config'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; -import { COMMENT_ACCENT } from '@/shared/lib/brandColors'; +import { COMMENT_ACCENT, LIKE_ACCENT } from '@/shared/lib/brandColors'; import { openRepostMenu } from '@/features/feed/lib/repostMenu'; import { useQuotePost } from '@/features/feed/lib/useQuotePost'; import { Log } from '@/shared/lib/logger'; @@ -42,7 +42,7 @@ const OVERLAY_CLOSE_BEFORE_MENU_MS = 240; */ function useOverlayRepostMenu(post: ImageOverlayPost, onRequestClose?: () => void) { const quotePost = useQuotePost(); - return useCallback(() => { + return () => { onRequestClose?.(); setTimeout(() => { openRepostMenu({ @@ -51,13 +51,13 @@ function useOverlayRepostMenu(post: ImageOverlayPost, onRequestClose?: () => voi onQuote: () => quotePost(post.event, post.profile ?? undefined), }); }, OVERLAY_CLOSE_BEFORE_MENU_MS); - }, [post, onRequestClose, quotePost]); + }; } // Absolute bar text stays white — it floats over the dark, blurred image, not // over the sheet's `surface` background. const PANEL_TEXT = 'rgba(255,255,255,0.95)'; const PANEL_TEXT_MUTED = 'rgba(255,255,255,0.6)'; -const LIKED_COLOR = '#ff5a7a'; +const LIKED_COLOR = LIKE_ACCENT; const PANEL_CONTENT_TRUNCATE_LIMIT = 120; const PANEL_INLINE_IMAGE_MAX_HEIGHT = 200; @@ -139,14 +139,14 @@ function extractPanelText(content: string): string { function InlinePanelImage({ uri }: { uri: string }) { const [naturalSize, setNaturalSize] = useState<{ w: number; h: number } | null>(null); const [layoutWidth, setLayoutWidth] = useState(0); - const onLoad = useCallback((e: { source: { width: number; height: number } }) => { + const onLoad = (e: { source: { width: number; height: number } }) => { const { width, height } = e.source; if (!width || !height) return; setNaturalSize({ w: width, h: height }); - }, []); - const onLayout = useCallback((e: { nativeEvent: { layout: { width: number } } }) => { + }; + const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => { setLayoutWidth(e.nativeEvent.layout.width); - }, []); + }; const { boxHeight, imageStyle } = useMemo(() => { const placeholderHeight = 120; if (!layoutWidth) { @@ -435,17 +435,17 @@ export const ImageOverlayAbsoluteBar = React.memo(function ImageOverlayAbsoluteB const displayName = profile?.name ?? `${event.pubkey.slice(0, 8)}…`; const shortTime = formatRelative(event.created_at * 1000, 'compact'); const fullContent = event.content.trim(); - const textContent = useMemo(() => extractPanelText(fullContent), [fullContent]); + const textContent = extractPanelText(fullContent); const contentPreview = textContent.slice(0, 120); const contentTruncated = textContent.length > 120; - const handleCommentPress = useCallback(() => { + const handleCommentPress = () => { onOpenSheet(); - }, [onOpenSheet]); + }; - const handleShowMorePress = useCallback(() => { + const handleShowMorePress = () => { onOpenSheet({ expandContent: true }); - }, [onOpenSheet]); + }; return ( diff --git a/features/feed/components/nostr/image-overlay/ImageBlock.tsx b/features/feed/components/nostr/image-overlay/ImageBlock.tsx index b87f593b4..f0db741cd 100644 --- a/features/feed/components/nostr/image-overlay/ImageBlock.tsx +++ b/features/feed/components/nostr/image-overlay/ImageBlock.tsx @@ -4,7 +4,7 @@ * Applies blur to thumbnail when overlay is displaced. */ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useRef, useState } from 'react'; import { Platform, StyleSheet } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import Reanimated, { @@ -135,24 +135,21 @@ export const ImageBlock = React.memo(function ImageBlock({ overlayActive: imageOverlay?.activeUrl === url, }), }); - const setContainerRef = useCallback( - (node: React.ComponentRef | null) => { - containerRef.current = node; - visualLayout.ref(node); - }, - [visualLayout] - ); + const setContainerRef = (node: React.ComponentRef | null) => { + containerRef.current = node; + visualLayout.ref(node); + }; type Measureable = { measureInWindow: (cb: (x: number, y: number, w: number, h: number) => void) => void; }; - const measureSourceRef = useCallback((): Measureable | null => { + const measureSourceRef = (): Measureable | null => { const imageNode = imageRef.current as Measureable | null; const containerNode = containerRef.current as Measureable | null; if (imageNode && typeof imageNode.measureInWindow === 'function') return imageNode; if (containerNode && typeof containerNode.measureInWindow === 'function') return containerNode; return null; - }, []); + }; /** * measureInWindow + the provider's tap-calibrated measure-space correction. @@ -163,19 +160,19 @@ export const ImageBlock = React.memo(function ImageBlock({ * through here so tap-time, onLayout, and JIT-close rects share one space. */ const correctionRef = imageOverlay?.measureSpaceCorrection; - const measureCorrected = useCallback( - (node: Measureable, cb: (x: number, y: number, w: number, h: number) => void) => { - node.measureInWindow((pageX, pageY, width, height) => { - const corr = correctionRef?.current; - if (Platform.OS === 'android' && corr) { - cb(pageX + corr.dx, pageY + corr.dy, width, height); - } else { - cb(pageX, pageY, width, height); - } - }); - }, - [correctionRef] - ); + const measureCorrected = ( + node: Measureable, + cb: (x: number, y: number, w: number, h: number) => void + ) => { + node.measureInWindow((pageX, pageY, width, height) => { + const corr = correctionRef?.current; + if (Platform.OS === 'android' && corr) { + cb(pageX + corr.dx, pageY + corr.dy, width, height); + } else { + cb(pageX, pageY, width, height); + } + }); + }; /** * Just-in-time re-measure for dismiss targeting. Recycled FlashList rows @@ -183,7 +180,7 @@ export const ImageBlock = React.memo(function ImageBlock({ * onLayout can be stale by close time; close() calls this to re-measure the * live node. Resolves null when the node is unmounted/unmeasurable. */ - const measureNow = useCallback((): Promise => { + const measureNow = (): Promise => { return new Promise((resolve) => { const node = measureSourceRef(); if (!node) { @@ -198,9 +195,9 @@ export const ImageBlock = React.memo(function ImageBlock({ resolve({ pageX, pageY, width, height }); }); }); - }, [measureSourceRef, measureCorrected]); + }; - const registerLayout = useCallback(() => { + const registerLayout = () => { const node = measureSourceRef(); if (!node) return; measureCorrected(node, (pageX: number, pageY: number, width: number, height: number) => { @@ -212,151 +209,110 @@ export const ImageBlock = React.memo(function ImageBlock({ : { measureNow } ); }); - }, [ - imageOverlay, - url, - overlayEvent?.id, - layoutIndex, - measureSourceRef, - measureCorrected, - measureNow, - ]); + }; - const handlePress = useCallback( - (event?: { nativeEvent?: GestureTouchPoint }) => { - if (!imageOverlay?.open) return; - onBeforeOpen?.(); - const node = measureSourceRef(); - if (!node) return; - // Capture the touch's native-truth coordinates synchronously: pageX/Y is - // root-window space from the NATIVE view hierarchy (includes every - // native-only displacement); locationX/Y is within the pressed view - // (which shares the measured node's origin — the Pressable absolute-fills - // the container, and the image fills the container). The difference - // against measureInWindow's raw answer IS the systematic shadow-tree - // error for this surface — ~statusBar+toolbar when RNS drops its - // contentOffset state, ~0 when the pipeline works. - const touch = event?.nativeEvent; - const trueX = touch != null ? touch.pageX - touch.locationX : null; - const trueY = touch != null ? touch.pageY - touch.locationY : null; - node.measureInWindow((rawPageX: number, rawPageY: number, width: number, height: number) => { - if ( - Platform.OS === 'android' && - correctionRef && - trueX != null && - trueY != null && - Number.isFinite(trueX) && - Number.isFinite(trueY) - ) { - const dx = trueX - rawPageX; - const dy = trueY - rawPageY; - // Sanity gates: x should match almost exactly; y can be off by up to - // a native header (~120dp) or a sheet's top offset. Anything wilder - // means locationX/Y was unreliable for this event — keep the last - // good calibration instead. - if (Math.abs(dx) <= 4 && dy >= -4 && dy <= 240) { - if (Math.abs(correctionRef.current.dy - dy) > 1) { - feedLog.debug('image_overlay.measure_correction', { dx, dy }); - } - correctionRef.current = { dx: Math.abs(dx) <= 1 ? 0 : dx, dy }; + const handlePress = (event?: { nativeEvent?: GestureTouchPoint }) => { + if (!imageOverlay?.open) return; + onBeforeOpen?.(); + const node = measureSourceRef(); + if (!node) return; + // Capture the touch's native-truth coordinates synchronously: pageX/Y is + // root-window space from the NATIVE view hierarchy (includes every + // native-only displacement); locationX/Y is within the pressed view + // (which shares the measured node's origin — the Pressable absolute-fills + // the container, and the image fills the container). The difference + // against measureInWindow's raw answer IS the systematic shadow-tree + // error for this surface — ~statusBar+toolbar when RNS drops its + // contentOffset state, ~0 when the pipeline works. + const touch = event?.nativeEvent; + const trueX = touch != null ? touch.pageX - touch.locationX : null; + const trueY = touch != null ? touch.pageY - touch.locationY : null; + node.measureInWindow((rawPageX: number, rawPageY: number, width: number, height: number) => { + if ( + Platform.OS === 'android' && + correctionRef && + trueX != null && + trueY != null && + Number.isFinite(trueX) && + Number.isFinite(trueY) + ) { + const dx = trueX - rawPageX; + const dy = trueY - rawPageY; + // Sanity gates: x should match almost exactly; y can be off by up to + // a native header (~120dp) or a sheet's top offset. Anything wilder + // means locationX/Y was unreliable for this event — keep the last + // good calibration instead. + if (Math.abs(dx) <= 4 && dy >= -4 && dy <= 240) { + if (Math.abs(correctionRef.current.dy - dy) > 1) { + feedLog.debug('image_overlay.measure_correction', { dx, dy }); } + correctionRef.current = { dx: Math.abs(dx) <= 1 ? 0 : dx, dy }; } - const corr = Platform.OS === 'android' && correctionRef ? correctionRef.current : null; - const pageX = rawPageX + (corr?.dx ?? 0); - const pageY = rawPageY + (corr?.dy ?? 0); - // Tap-time registration so close() has a measureNow for this key even - // when the row was recycled and onLayout never re-fired. - imageOverlay.registerThumbnailLayout( - url, - { pageX, pageY, width, height }, - overlayEvent?.id != null - ? { eventId: overlayEvent.id, imageIndex: layoutIndex, measureNow } - : { measureNow } - ); - const post: ImageOverlayPost | undefined = - overlayEvent && overlayMetrics - ? { - event: { - id: overlayEvent.id, - kind: overlayEvent.kind, - pubkey: overlayEvent.pubkey, - content: overlayEvent.content, - tags: overlayEvent.tags, - created_at: overlayEvent.created_at, - }, - metrics: { - replyCount: overlayMetrics.replyCount, - repostCount: overlayMetrics.repostCount, - likeCount: overlayMetrics.likeCount, - satsZapped: overlayMetrics.satsZapped, - }, - profile: overlayProfile ?? null, - reposted, - liked, - replied, - repostPending, - likePending, - repostPendingDirection, - likePendingDirection, - onCommentPress, - onRepostPress, - onLikePress, - onActionPressIn, - onActionPressOut, - } + } + const corr = Platform.OS === 'android' && correctionRef ? correctionRef.current : null; + const pageX = rawPageX + (corr?.dx ?? 0); + const pageY = rawPageY + (corr?.dy ?? 0); + // Tap-time registration so close() has a measureNow for this key even + // when the row was recycled and onLayout never re-fired. + imageOverlay.registerThumbnailLayout( + url, + { pageX, pageY, width, height }, + overlayEvent?.id != null + ? { eventId: overlayEvent.id, imageIndex: layoutIndex, measureNow } + : { measureNow } + ); + const post: ImageOverlayPost | undefined = + overlayEvent && overlayMetrics + ? { + event: { + id: overlayEvent.id, + kind: overlayEvent.kind, + pubkey: overlayEvent.pubkey, + content: overlayEvent.content, + tags: overlayEvent.tags, + created_at: overlayEvent.created_at, + }, + metrics: { + replyCount: overlayMetrics.replyCount, + repostCount: overlayMetrics.repostCount, + likeCount: overlayMetrics.likeCount, + satsZapped: overlayMetrics.satsZapped, + }, + profile: overlayProfile ?? null, + reposted, + liked, + replied, + repostPending, + likePending, + repostPendingDirection, + likePendingDirection, + onCommentPress, + onRepostPress, + onLikePress, + onActionPressIn, + onActionPressOut, + } + : undefined; + const urls = + allMediaUrls && allMediaUrls.length > 0 + ? allMediaUrls + : allImageUrls && allImageUrls.length > 1 + ? allImageUrls : undefined; - const urls = - allMediaUrls && allMediaUrls.length > 0 - ? allMediaUrls - : allImageUrls && allImageUrls.length > 1 - ? allImageUrls - : undefined; - imageOverlay.open({ - url, - aspectRatio, - pageX, - pageY, - width, - height, - urls: urls && urls.length > 1 ? urls : undefined, - mediaTypes: - mediaTypes && urls && mediaTypes.length === urls.length ? mediaTypes : undefined, - initialIndex: mediaIndex ?? imageIndex ?? 0, - post: post ?? null, - }); + imageOverlay.open({ + url, + aspectRatio, + pageX, + pageY, + width, + height, + urls: urls && urls.length > 1 ? urls : undefined, + mediaTypes: mediaTypes && mediaTypes.length === urls?.length ? mediaTypes : undefined, + initialIndex: mediaIndex ?? imageIndex ?? 0, + post: post ?? null, }); - }, - [ - imageOverlay, - measureSourceRef, - measureNow, - correctionRef, - url, - aspectRatio, - allImageUrls, - allMediaUrls, - mediaTypes, - mediaIndex, - imageIndex, - layoutIndex, - onBeforeOpen, - overlayEvent, - overlayMetrics, - overlayProfile, - reposted, - liked, - replied, - repostPending, - likePending, - repostPendingDirection, - likePendingDirection, - onCommentPress, - onRepostPress, - onLikePress, - onActionPressIn, - onActionPressOut, - ] - ); + }); + }; const fallbackBlur = useSharedValue(0); const thumbnailBlur = imageOverlay?.thumbnailBlurIntensity ?? fallbackBlur; diff --git a/features/feed/components/nostr/image-overlay/index.ts b/features/feed/components/nostr/image-overlay/index.ts index 3e0a081d5..0c395d697 100644 --- a/features/feed/components/nostr/image-overlay/index.ts +++ b/features/feed/components/nostr/image-overlay/index.ts @@ -16,13 +16,7 @@ */ // Provider & hook -export { - ImageOverlayProvider, - useImageOverlay, - IMAGE_OVERLAY_TIMING_CONFIG, - computeExpandedSize, -} from './provider'; -export type { ImageOverlayProviderProps } from './provider'; +export { ImageOverlayProvider, useImageOverlay } from './provider'; // Main overlay component export { AnimatedImageOverlay } from './AnimatedImageOverlay'; @@ -31,11 +25,4 @@ export { AnimatedImageOverlay } from './AnimatedImageOverlay'; export { ImageBlock } from './ImageBlock'; // Types (re-export for consumers) -export type { - ImageOverlayPost, - ImageOverlayLayout, - ImageOverlayReplaceLayout, - ThumbnailLayout, - ImageOverlayContextValue, - MediaType, -} from './types'; +export type { ImageOverlayPost, ImageOverlayLayout, ImageOverlayReplaceLayout } from './types'; diff --git a/features/feed/components/nostr/image-overlay/provider.tsx b/features/feed/components/nostr/image-overlay/provider.tsx index fe887f771..97137483e 100644 --- a/features/feed/components/nostr/image-overlay/provider.tsx +++ b/features/feed/components/nostr/image-overlay/provider.tsx @@ -143,7 +143,7 @@ function measureWithTimeout( }); } -export type ImageOverlayProviderProps = { +type ImageOverlayProviderProps = { children: React.ReactNode; /** When provided, overlay panel shows live metrics (optimistic counts) for the active post. */ getDisplayMetrics?: (eventId: string) => NoteMetrics; @@ -441,7 +441,7 @@ export function ImageOverlayProvider({ const urls = layout.urls && layout.urls.length > 1 ? layout.urls : [layout.url]; const types = - layout.mediaTypes && layout.mediaTypes.length === urls.length + layout.mediaTypes?.length === urls.length ? layout.mediaTypes : urls.map((u) => inferMediaType(u)); // Videos render at natural aspect via contentFit=contain inside the @@ -631,7 +631,7 @@ export function ImageOverlayProvider({ const urls = layout.urls && layout.urls.length > 1 ? layout.urls : [layout.url]; const types = - layout.mediaTypes && layout.mediaTypes.length === urls.length + layout.mediaTypes?.length === urls.length ? layout.mediaTypes : urls.map((u) => inferMediaType(u)); // See open() above — a multi-item pager (video, or more than one image) diff --git a/features/feed/components/nostr/poll/PollCard.tsx b/features/feed/components/nostr/poll/PollCard.tsx index 62ddc17cd..61c73cf53 100644 --- a/features/feed/components/nostr/poll/PollCard.tsx +++ b/features/feed/components/nostr/poll/PollCard.tsx @@ -6,7 +6,7 @@ * viewer has voted or the poll has closed. Voting publishes a kind:1018 through * the central seam and works with the local signer (no key gating). */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { StyleSheet, View } from 'react-native'; import { NDKEvent, useNDK } from '@nostr-dev-kit/ndk-mobile'; @@ -38,9 +38,9 @@ export function PollCard({ event }: { event: FeedEvent }) { 'success', ] as const); - const poll = useMemo(() => parsePoll(event), [event]); + const poll = parsePoll(event); const votes = usePollVotes(poll.id); - const tally = useMemo(() => tallyPoll(poll, votes, viewerPubkey), [poll, votes, viewerPubkey]); + const tally = tallyPoll(poll, votes, viewerPubkey); const [selected, setSelected] = useState([]); const [voting, setVoting] = useState(false); @@ -49,17 +49,14 @@ export function PollCard({ event }: { event: FeedEvent }) { const showResults = voted || closed; const isMulti = poll.pollType === 'multiplechoice'; - const toggleSelect = useCallback( - (id: string) => { - if (showResults) return; - setSelected((prev) => - isMulti ? (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]) : [id] - ); - }, - [isMulti, showResults] - ); + const toggleSelect = (id: string) => { + if (showResults) return; + setSelected((prev) => + isMulti ? (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]) : [id] + ); + }; - const submitVote = useCallback(async () => { + const submitVote = async () => { if (!ndk || selected.length === 0) return; setVoting(true); const unsigned = buildVoteEvent({ @@ -79,7 +76,7 @@ export function PollCard({ event }: { event: FeedEvent }) { resolveOn: 'first-ok', }); setVoting(false); - }, [ndk, selected, poll.id, poll.relays]); + }; return ( diff --git a/features/feed/components/nostr/poll/buildPollEvents.ts b/features/feed/components/nostr/poll/buildPollEvents.ts index 4930c0b08..0757a5f7c 100644 --- a/features/feed/components/nostr/poll/buildPollEvents.ts +++ b/features/feed/components/nostr/poll/buildPollEvents.ts @@ -5,14 +5,14 @@ import { POLL_KIND, POLL_VOTE_KIND } from '@/features/feed/components/nostr/poll/pollParse'; import type { PollOption, PollType } from '@/features/feed/components/nostr/poll/pollTypes'; -export interface UnsignedPollEvent { +interface UnsignedPollEvent { kind: number; content: string; created_at: number; tags: string[][]; } -export interface PollQuoteReference { +interface PollQuoteReference { eventId: string; pubkey: string; relayHint?: string; diff --git a/features/feed/components/nostr/poll/pollParse.ts b/features/feed/components/nostr/poll/pollParse.ts index 0624243a6..513df9263 100644 --- a/features/feed/components/nostr/poll/pollParse.ts +++ b/features/feed/components/nostr/poll/pollParse.ts @@ -35,9 +35,13 @@ function tagsOf(event: EventLike): string[][] { /** Parses a kind:1068 event into a poll definition. */ export function parsePoll(event: EventLike): PollDefinition { const tags = tagsOf(event); - const options = tags - .filter((t) => t[0] === 'option' && typeof t[1] === 'string') - .map((t) => ({ id: t[1], label: typeof t[2] === 'string' ? t[2] : '' })); + // Single pass: keep `option` tags with a string id and project to {id,label}. + const options: PollDefinition['options'] = []; + for (const t of tags) { + if (t[0] === 'option' && typeof t[1] === 'string') { + options.push({ id: t[1], label: typeof t[2] === 'string' ? t[2] : '' }); + } + } const pollTypeTag = tags.find((t) => t[0] === 'polltype')?.[1]; const pollType: PollType = pollTypeTag === 'multiplechoice' ? 'multiplechoice' : 'singlechoice'; @@ -45,7 +49,10 @@ export function parsePoll(event: EventLike): PollDefinition { const endsAtRaw = tags.find((t) => t[0] === 'endsAt')?.[1]; const endsAt = endsAtRaw && /^\d+$/.test(endsAtRaw) ? Number(endsAtRaw) : undefined; - const relays = tags.filter((t) => t[0] === 'relay' && typeof t[1] === 'string').map((t) => t[1]); + const relays: string[] = []; + for (const t of tags) { + if (t[0] === 'relay' && typeof t[1] === 'string') relays.push(t[1]); + } return { id: event.id ?? '', diff --git a/features/feed/components/nostr/poll/usePollVotes.ts b/features/feed/components/nostr/poll/usePollVotes.ts index be6eafe1c..ef8a45b33 100644 --- a/features/feed/components/nostr/poll/usePollVotes.ts +++ b/features/feed/components/nostr/poll/usePollVotes.ts @@ -5,8 +5,6 @@ * cutoff), so it stays an app-side relay subscription rather than a generic * nagg recipe — nagg remains protocol-generic. */ -import { useMemo } from 'react'; - import { useSubscribe } from '@nostr-dev-kit/ndk-mobile'; import { POLL_VOTE_KIND } from '@/features/feed/components/nostr/poll/pollParse'; @@ -19,10 +17,7 @@ interface VoteEvent { /** Returns the raw `kind:1018` vote events referencing `pollId`. */ export function usePollVotes(pollId: string): VoteEvent[] { - const filters = useMemo( - () => (pollId ? [{ kinds: [POLL_VOTE_KIND], '#e': [pollId], limit: 1000 }] : null), - [pollId] - ); + const filters = pollId ? [{ kinds: [POLL_VOTE_KIND], '#e': [pollId], limit: 1000 }] : null; const { events } = useSubscribe({ filters }); return (events ?? []) as VoteEvent[]; } diff --git a/features/feed/components/nostr/videoLayout.ts b/features/feed/components/nostr/videoLayout.ts index 789a0d849..fa62ea877 100644 --- a/features/feed/components/nostr/videoLayout.ts +++ b/features/feed/components/nostr/videoLayout.ts @@ -10,7 +10,7 @@ import type { } from './feedTypes'; import { DEFAULT_METRICS } from './feedTypes'; import type { ImageOverlayPost } from './image-overlay/types'; -import { URL_REGEX, VIDEO_EXT, normalizeFeedEvent, parseContent, parseJson } from './feedParse'; +import { URL_REGEX, VIDEO_EXT, parseContent } from './feedParse'; export const MAX_VIDEO_FEED_PAGES = 20; @@ -44,17 +44,6 @@ export function buildDedupedVideoPosts(events: FeedEvent[]): VideoPostRecord[] { return result; } -export function getEmbeddedRepostEvent( - repostEvent: FeedEvent, - expectedEventId?: string -): FeedEvent | undefined { - if (!repostEvent.content) return undefined; - const parsed = normalizeFeedEvent(parseJson(repostEvent.content)); - if (!parsed) return undefined; - if (expectedEventId && parsed.id !== expectedEventId) return undefined; - return parsed; -} - /** * Given an array of feed items and a profiles ref, build the * ImageOverlayReplaceLayout for a specific item by feed index. diff --git a/features/feed/components/thread-embed/EmbedActionBar.tsx b/features/feed/components/thread-embed/EmbedActionBar.tsx index 364c44e01..0348e5a66 100644 --- a/features/feed/components/thread-embed/EmbedActionBar.tsx +++ b/features/feed/components/thread-embed/EmbedActionBar.tsx @@ -9,7 +9,7 @@ * the like/repost/comment buttons pass through; only a real vertical drag moves * the sheet. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { StyleSheet, type LayoutChangeEvent } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { @@ -102,10 +102,8 @@ export const EmbedActionBar = React.memo(function EmbedActionBar({ [interactive, sheetTranslateY, snapMiddle, snapInline, startY] ); - const handleLayout = useCallback( - (e: LayoutChangeEvent) => setActionBarHeight?.(Math.round(e.nativeEvent.layout.height)), - [setActionBarHeight] - ); + const handleLayout = (e: LayoutChangeEvent) => + setActionBarHeight?.(Math.round(e.nativeEvent.layout.height)); return ( diff --git a/features/feed/components/thread-embed/LinkEmbedView.tsx b/features/feed/components/thread-embed/LinkEmbedView.tsx index 84556acf2..60e6a674b 100644 --- a/features/feed/components/thread-embed/LinkEmbedView.tsx +++ b/features/feed/components/thread-embed/LinkEmbedView.tsx @@ -7,7 +7,7 @@ * web view must never receive nsec / seed / profile data. See * `sovran-security-keys` / `critical-failures`. */ -import React, { useCallback } from 'react'; +import React from 'react'; import { StyleSheet } from 'react-native'; import Animated, { type SharedValue, useAnimatedStyle } from 'react-native-reanimated'; import { WebView, type WebViewProps } from 'react-native-webview'; @@ -28,12 +28,9 @@ export const LinkEmbedView = React.memo(function LinkEmbedView({ }) { const fadeStyle = useAnimatedStyle(() => ({ opacity: embedOpacity.value })); - const handleScroll = useCallback>( - (e) => { - onScroll(e.nativeEvent.contentOffset.y); - }, - [onScroll] - ); + const handleScroll = (e: Parameters>[0]) => { + onScroll(e.nativeEvent.contentOffset.y); + }; return ( diff --git a/features/feed/components/thread-embed/ThreadEmbedSheet.tsx b/features/feed/components/thread-embed/ThreadEmbedSheet.tsx index b1c67d9a3..8f3330b65 100644 --- a/features/feed/components/thread-embed/ThreadEmbedSheet.tsx +++ b/features/feed/components/thread-embed/ThreadEmbedSheet.tsx @@ -69,37 +69,39 @@ export function ThreadEmbedSheet({ // Native gesture standing in for the list's scroll, so pan + scroll coexist. const nativeGesture = useMemo(() => Gesture.Native(), []); - const panGesture = useMemo(() => { - return Gesture.Pan() - .enabled(embedActive) - .activeOffsetY([-SHEET_PAN_ACTIVATION, SHEET_PAN_ACTIVATION]) - .simultaneousWithExternalGesture(nativeGesture) - .onStart(() => { - 'worklet'; - if (!sheetTranslateY) return; - startY.value = sheetTranslateY.value; - }) - .onUpdate((e) => { - 'worklet'; - if (!sheetTranslateY || !scrollY) return; - const expandedAtStart = startY.value <= 1; - const atTop = scrollY.value <= 1; - // Drag started while collapsed/mid → it controls the sheet directly. - // Drag started expanded, at the top, pulling down → begin the collapse. - // Otherwise the list scrolls (the provider keeps it scrollable only - // while expanded, so the two never move together). - if (!expandedAtStart || (atTop && e.translationY > 0)) { - sheetTranslateY.value = clamp(startY.value + e.translationY, 0, snapInline); - } - }) - .onEnd((e) => { - 'worklet'; - if (!sheetTranslateY) return; - const target = nearestSnap(sheetTranslateY.value, e.velocityY, snapMiddle, snapInline); - if (Math.abs(target - startY.value) > 1) runOnJS(embedHaptic)(); - sheetTranslateY.value = withSpring(target, SHEET_SPRING); - }); - }, [embedActive, nativeGesture, sheetTranslateY, scrollY, snapMiddle, snapInline, startY]); + const panGesture = useMemo( + () => + Gesture.Pan() + .enabled(embedActive) + .activeOffsetY([-SHEET_PAN_ACTIVATION, SHEET_PAN_ACTIVATION]) + .simultaneousWithExternalGesture(nativeGesture) + .onStart(() => { + 'worklet'; + if (!sheetTranslateY) return; + startY.value = sheetTranslateY.value; + }) + .onUpdate((e) => { + 'worklet'; + if (!sheetTranslateY || !scrollY) return; + const expandedAtStart = startY.value <= 1; + const atTop = scrollY.value <= 1; + // Drag started while collapsed/mid → it controls the sheet directly. + // Drag started expanded, at the top, pulling down → begin the collapse. + // Otherwise the list scrolls (the provider keeps it scrollable only + // while expanded, so the two never move together). + if (!expandedAtStart || (atTop && e.translationY > 0)) { + sheetTranslateY.value = clamp(startY.value + e.translationY, 0, snapInline); + } + }) + .onEnd((e) => { + 'worklet'; + if (!sheetTranslateY) return; + const target = nearestSnap(sheetTranslateY.value, e.velocityY, snapMiddle, snapInline); + if (Math.abs(target - startY.value) > 1) runOnJS(embedHaptic)(); + sheetTranslateY.value = withSpring(target, SHEET_SPRING); + }), + [embedActive, nativeGesture, sheetTranslateY, scrollY, snapMiddle, snapInline, startY] + ); const sheetStyle = useAnimatedStyle(() => { const ty = sheetTranslateY?.value ?? 0; diff --git a/features/feed/data/facadeFeedAdapter.ts b/features/feed/data/facadeFeedAdapter.ts index 53ca5d54b..1e9f969b4 100644 --- a/features/feed/data/facadeFeedAdapter.ts +++ b/features/feed/data/facadeFeedAdapter.ts @@ -9,9 +9,12 @@ import type { FeedParseResult } from './feedClient'; // without the facade-builder's React-Native chain. /** Map the app's JSON feed spec to the facade FeedSpec (ranked home feeds only). */ -export function mapAppSpecToFeedSpec(specJson: string, userPubkey?: string): facade.FeedSpec | null { +export function mapAppSpecToFeedSpec( + specJson: string, + userPubkey?: string +): facade.FeedSpec | null { const parsed = parseJson>(specJson); - if (!parsed || parsed.kind !== 'notes') return null; + if (parsed?.kind !== 'notes') return null; switch (parsed.id) { case 'for-you': return { kind: 'for-you', ...(userPubkey ? { viewerPubkey: userPubkey } : {}) }; diff --git a/features/feed/data/feedSpec.ts b/features/feed/data/feedSpec.ts index 88038cc3d..91a0ec58a 100644 --- a/features/feed/data/feedSpec.ts +++ b/features/feed/data/feedSpec.ts @@ -1,4 +1,3 @@ -import { isNostrPubkeyHex } from '@/shared/lib/nostr/secureStorage'; import { parseJson } from '@/features/feed/components/nostr/feedParse'; export function hydrateSpecWithPubkey(spec: string, pubkey: string | undefined): string { @@ -16,20 +15,3 @@ export function hasEmptyExplicitPubkeys(spec: string): boolean { const parsed = parseJson>(spec); return !!parsed && Array.isArray(parsed.pubkeys) && parsed.pubkeys.length === 0; } - -export function getCategoryPubkeysFromSpec(spec: string): string[] { - const parsed = parseJson>(spec); - if (!parsed) return []; - if (parsed.id !== 'feed' || parsed.kind !== 'notes' || parsed.notes !== 'authored') return []; - if (!Array.isArray(parsed.pubkeys)) return []; - - const seen = new Set(); - const pubkeys: string[] = []; - for (const value of parsed.pubkeys) { - if (!isNostrPubkeyHex(value)) continue; - if (seen.has(value)) continue; - seen.add(value); - pubkeys.push(value); - } - return pubkeys; -} diff --git a/features/feed/hooks/useNostrEngagement.ts b/features/feed/hooks/useNostrEngagement.ts index e26403352..7264b6cd0 100644 --- a/features/feed/hooks/useNostrEngagement.ts +++ b/features/feed/hooks/useNostrEngagement.ts @@ -33,7 +33,7 @@ const OPTIMISTIC_STALE_WARN_MS = 30_000; interface ToggleEngagementOpts { target: FeedEvent; - ndk: any; + ndk: NonNullable['ndk']>; kind: typeof Reaction | typeof Repost; currentState: boolean; isPending: boolean; @@ -188,7 +188,7 @@ export function useNostrEngagement( return map; }, [events]); - const eventIds = useMemo(() => Array.from(eventsById.keys()), [eventsById]); + const eventIds = Array.from(eventsById.keys()); // ---- settle optimistic entries when the global sync catches up ---- @@ -292,78 +292,56 @@ export function useNostrEngagement( // ---- toggle actions (unified via toggleEngagement) ---- - const toggleLikeInner = useCallback( - async (target: FeedEvent) => { - if (!nostrKeys?.pubkey || !ndk) { - paramPopup('engagement-update-failed', 'like'); - return; - } - const state = getEngagementState(target.id); - const { setLikeOptimistic, clearLikeOptimistic } = useNostrSocialStore.getState(); - await toggleEngagement({ - target, - ndk, - kind: Reaction, - currentState: state.liked, - isPending: state.likePending, - previousOptimistic: optimisticLikesByEventId[target.id], - relatedEventIdFromStore: engagementByEventId[target.id]?.liked?.ownEventId, - displayedCount: getDisplayMetrics(target.id).likeCount, - baseCount: getBaseMetrics(target.id).likeCount, - setOptimistic: setLikeOptimistic, - clearOptimistic: clearLikeOptimistic, - buildContent: () => '+', - label: 'like', - }); - }, - [ - getBaseMetrics, - getDisplayMetrics, - getEngagementState, - engagementByEventId, + const toggleLikeInner = async (target: FeedEvent) => { + if (!nostrKeys?.pubkey || !ndk) { + paramPopup('engagement-update-failed', 'like'); + return; + } + const state = getEngagementState(target.id); + const { setLikeOptimistic, clearLikeOptimistic } = useNostrSocialStore.getState(); + await toggleEngagement({ + target, ndk, - nostrKeys?.pubkey, - optimisticLikesByEventId, - ] - ); + kind: Reaction, + currentState: state.liked, + isPending: state.likePending, + previousOptimistic: optimisticLikesByEventId[target.id], + relatedEventIdFromStore: engagementByEventId[target.id]?.liked?.ownEventId, + displayedCount: getDisplayMetrics(target.id).likeCount, + baseCount: getBaseMetrics(target.id).likeCount, + setOptimistic: setLikeOptimistic, + clearOptimistic: clearLikeOptimistic, + buildContent: () => '+', + label: 'like', + }); + }; - const toggleRepostInner = useCallback( - async (target: FeedEvent) => { - if (!nostrKeys?.pubkey || !ndk) { - paramPopup('engagement-update-failed', 'repost'); - return; - } - const state = getEngagementState(target.id); - const { setRepostOptimistic, clearRepostOptimistic, unmarkRepostDeleted, markRepostDeleted } = - useNostrSocialStore.getState(); - await toggleEngagement({ - target, - ndk, - kind: Repost, - currentState: state.reposted, - isPending: state.repostPending, - previousOptimistic: optimisticRepostsByEventId[target.id], - relatedEventIdFromStore: engagementByEventId[target.id]?.reposted?.ownEventId, - displayedCount: getDisplayMetrics(target.id).repostCount, - baseCount: getBaseMetrics(target.id).repostCount, - setOptimistic: setRepostOptimistic, - clearOptimistic: clearRepostOptimistic, - buildContent: (t) => JSON.stringify(t), - onActivated: () => unmarkRepostDeleted(target.id), - onDeactivated: () => markRepostDeleted(target.id), - label: 'repost', - }); - }, - [ - getBaseMetrics, - getDisplayMetrics, - getEngagementState, - engagementByEventId, + const toggleRepostInner = async (target: FeedEvent) => { + if (!nostrKeys?.pubkey || !ndk) { + paramPopup('engagement-update-failed', 'repost'); + return; + } + const state = getEngagementState(target.id); + const { setRepostOptimistic, clearRepostOptimistic, unmarkRepostDeleted, markRepostDeleted } = + useNostrSocialStore.getState(); + await toggleEngagement({ + target, ndk, - nostrKeys?.pubkey, - optimisticRepostsByEventId, - ] - ); + kind: Repost, + currentState: state.reposted, + isPending: state.repostPending, + previousOptimistic: optimisticRepostsByEventId[target.id], + relatedEventIdFromStore: engagementByEventId[target.id]?.reposted?.ownEventId, + displayedCount: getDisplayMetrics(target.id).repostCount, + baseCount: getBaseMetrics(target.id).repostCount, + setOptimistic: setRepostOptimistic, + clearOptimistic: clearRepostOptimistic, + buildContent: (t) => JSON.stringify(t), + onActivated: () => unmarkRepostDeleted(target.id), + onDeactivated: () => markRepostDeleted(target.id), + label: 'repost', + }); + }; // Per-target single-flight: tapping like on post A while post B is still // publishing must not block — use the target id as the key so concurrent diff --git a/features/feed/hooks/usePostActions.ts b/features/feed/hooks/usePostActions.ts index 771e783de..4bdf4a590 100644 --- a/features/feed/hooks/usePostActions.ts +++ b/features/feed/hooks/usePostActions.ts @@ -1,4 +1,3 @@ -import { useCallback } from 'react'; import { Share } from 'react-native'; import * as Clipboard from 'expo-clipboard'; @@ -28,54 +27,51 @@ export function usePostActions(options?: { const ignorePubkey = useFeedIgnoreStore((s) => s.ignorePubkey); const getProfileName = options?.getProfileName; - return useCallback( - (event: FeedEvent) => { - const fallback = tryNpubEncode(event.pubkey).slice(0, 12) + '…'; - const links = buildShareLinks(event, getOwnWriteRelays()[0]); - const shareButtons: MenuButton[] = links - ? [ - { - text: 'Share', - icon: 'mdi:share-variant-outline', - onPress: (close) => { - close(); - void Share.share({ message: links.njumpUrl }); - }, + return (event: FeedEvent) => { + const fallback = tryNpubEncode(event.pubkey).slice(0, 12) + '…'; + const links = buildShareLinks(event, getOwnWriteRelays()[0]); + const shareButtons: MenuButton[] = links + ? [ + { + text: 'Share', + icon: 'mdi:share-variant-outline', + onPress: (close) => { + close(); + void Share.share({ message: links.njumpUrl }); }, - { - text: 'Copy link', - icon: 'mdi:link-variant', - onPress: (close) => { - close(); - void Clipboard.setStringAsync(links.njumpUrl); - }, + }, + { + text: 'Copy link', + icon: 'mdi:link-variant', + onPress: (close) => { + close(); + void Clipboard.setStringAsync(links.njumpUrl); }, - ] - : []; - const buttons: MenuButton[] = [ - ...shareButtons, - { - text: 'Ignore post', - icon: 'mdi:eye-off-outline', - testID: 'thread-ignore-post', - onPress: (close) => { - close(); - ignoreEvent(event.id); }, + ] + : []; + const buttons: MenuButton[] = [ + ...shareButtons, + { + text: 'Ignore post', + icon: 'mdi:eye-off-outline', + testID: 'thread-ignore-post', + onPress: (close) => { + close(); + ignoreEvent(event.id); }, - { - text: 'Ignore person', - description: getProfileName?.(event.pubkey) ?? fallback, - icon: 'mdi:account-cancel-outline', - testID: 'thread-ignore-person', - onPress: (close) => { - close(); - ignorePubkey(event.pubkey); - }, + }, + { + text: 'Ignore person', + description: getProfileName?.(event.pubkey) ?? fallback, + icon: 'mdi:account-cancel-outline', + testID: 'thread-ignore-person', + onPress: (close) => { + close(); + ignorePubkey(event.pubkey); }, - ]; - actionMenuPopup({ title: 'Post', buttons }); - }, - [ignoreEvent, ignorePubkey, getProfileName] - ); + }, + ]; + actionMenuPopup({ title: 'Post', buttons }); + }; } diff --git a/features/feed/hooks/useRecentPeopleProfiles.ts b/features/feed/hooks/useRecentPeopleProfiles.ts index 7af2c2705..f7340c477 100644 --- a/features/feed/hooks/useRecentPeopleProfiles.ts +++ b/features/feed/hooks/useRecentPeopleProfiles.ts @@ -74,13 +74,9 @@ export function useRecentPeopleProfiles(pubkeys: readonly string[]): RecentPeopl [loadingKey] ); - return useMemo( - () => - normalizedPubkeys.map((pubkey) => ({ - pubkey, - metadata: byPubkey[pubkey], - isLoading: loadingPubkeys.has(pubkey), - })), - [normalizedPubkeys, byPubkey, loadingPubkeys] - ); + return normalizedPubkeys.map((pubkey) => ({ + pubkey, + metadata: byPubkey[pubkey], + isLoading: loadingPubkeys.has(pubkey), + })); } diff --git a/features/feed/hooks/useThread.ts b/features/feed/hooks/useThread.ts index 569c1dd33..48adda8a5 100644 --- a/features/feed/hooks/useThread.ts +++ b/features/feed/hooks/useThread.ts @@ -171,7 +171,7 @@ export function useThread(eventId: string): UseThreadResult { [eventId, replySort, viewerPubkey] ); - const loadMoreReplies = useCallback(async () => { + const loadMoreReplies = async () => { if ( !eventId || isInitialFetchingRef.current || @@ -241,7 +241,7 @@ export function useThread(eventId: string): UseThreadResult { setIsLoadingMoreReplies(false); } } - }, [applyThreadResult, eventId, replySort, viewerPubkey]); + }; useEffect(() => { if (!eventId) return; diff --git a/features/feed/index.ts b/features/feed/index.ts index 87312fd52..af041ae2d 100644 --- a/features/feed/index.ts +++ b/features/feed/index.ts @@ -5,11 +5,6 @@ export { NotificationFollowersScreen } from './screens/NotificationFollowersScre export { NotificationsScreen } from './screens/NotificationsScreen'; export { ThreadScreen } from './screens/ThreadScreen'; export { StoriesScreen } from './screens/StoriesScreen'; -export { HomeFeed } from './components/HomeFeed'; -export { ThreadView } from './components/ThreadView'; export { UserFeed } from './components/UserFeed'; -export { StoriesCarousel, type StoryUser } from './components/nostr/StoriesCarousel'; -export { useNostrEngagement } from './hooks/useNostrEngagement'; -export { createNaggFeedClient } from './data/naggFeedClient'; -export type { FeedClient, FeedEnrichmentUpdates } from './data/feedClient'; +export type { StoryUser } from './components/nostr/StoriesCarousel'; export type { VideoPostRecord } from './components/nostr/feedTypes'; diff --git a/features/feed/lib/buildThreadStructure.ts b/features/feed/lib/buildThreadStructure.ts index 59c44d45b..1f2f049c0 100644 --- a/features/feed/lib/buildThreadStructure.ts +++ b/features/feed/lib/buildThreadStructure.ts @@ -59,13 +59,13 @@ export function buildThreadStructure( const eTags = eTagsOf(ev); const replyTag = eTags.find((t) => t[3] === 'reply'); - if (replyTag && replyTag[1] === eventId) { + if (replyTag?.[1] === eventId) { replies.push(ev); continue; } if (!replyTag) { const rootTag = eTags.find((t) => t[3] === 'root'); - if (rootTag && rootTag[1] === eventId) { + if (rootTag?.[1] === eventId) { replies.push(ev); continue; } diff --git a/features/feed/lib/feedEmptyStates.ts b/features/feed/lib/feedEmptyStates.ts index 86a94655e..36028d19e 100644 --- a/features/feed/lib/feedEmptyStates.ts +++ b/features/feed/lib/feedEmptyStates.ts @@ -26,7 +26,7 @@ export function selectFeedEmptyMode(signals: FeedEmptySignals): FeedEmptyMode { return 'empty'; } -export interface FeedEmptyCopy { +interface FeedEmptyCopy { icon: string; title: string; subtitle: string; diff --git a/features/feed/lib/feedRows.ts b/features/feed/lib/feedRows.ts index 746f66df9..5b55ab350 100644 --- a/features/feed/lib/feedRows.ts +++ b/features/feed/lib/feedRows.ts @@ -15,7 +15,7 @@ export type FeedRow = { quotedEvents: Map; reposterName?: string; reposterPubkey?: string; - reposters?: Array<{ name: string; pubkey: string }>; + reposters?: { name: string; pubkey: string }[]; }; export const DEFAULT_ENGAGEMENT_STATE: EngagementViewState = Object.freeze({ @@ -26,7 +26,7 @@ export const DEFAULT_ENGAGEMENT_STATE: EngagementViewState = Object.freeze({ repostPending: false, }); -export type BuildFeedRowsOptions = { +type BuildFeedRowsOptions = { items: FeedItem[]; previousRows: FeedRow[]; profilesMap: Map; @@ -77,7 +77,7 @@ export function buildFeedRows({ }); } -export function getFeedItemKey(item: FeedItem): string { +function getFeedItemKey(item: FeedItem): string { return item.type === 'note' ? item.event.id : item.originalEventId; } @@ -93,10 +93,6 @@ export function getFeedRowItemType(row: FeedRow): string { }`; } -export function feedRowsAreEqual(previous: FeedRow, next: FeedRow): boolean { - return previous === next; -} - function getPrimaryEventId(item: FeedItem): string { return item.type === 'note' ? item.event.id : item.originalEventId; } @@ -184,7 +180,7 @@ function defaultResolveReposter(item: Extract): { function resolveReposters( item: Extract, resolveReposter: NonNullable -): Array<{ name: string; pubkey: string }> { +): { name: string; pubkey: string }[] { const reposterEvents = item.reposters && item.reposters.length > 0 ? item.reposters.map((reposter) => reposter.event) @@ -209,11 +205,11 @@ function feedRowContentEqual(previous: FeedRow, next: FeedRow): boolean { } function repostersEqual( - a: Array<{ name: string; pubkey: string }> | undefined, - b: Array<{ name: string; pubkey: string }> | undefined + a: { name: string; pubkey: string }[] | undefined, + b: { name: string; pubkey: string }[] | undefined ): boolean { if (a === b) return true; - if (!a || !b || a.length !== b.length) return false; + if (!a || a.length !== b?.length) return false; return a.every((reposter, index) => { const other = b[index]; return other?.name === reposter.name && other.pubkey === reposter.pubkey; diff --git a/features/feed/lib/notificationCopy.ts b/features/feed/lib/notificationCopy.ts index ba335e2c1..6ecb15c0a 100644 --- a/features/feed/lib/notificationCopy.ts +++ b/features/feed/lib/notificationCopy.ts @@ -45,12 +45,3 @@ export function notificationReplyScopeLabel(scope: FeedNotificationReplyScope): return 'Thread replies'; } } - -export function notificationReplyScopeDescription(scope: FeedNotificationReplyScope): string { - switch (scope) { - case 'DIRECT': - return 'Only replies whose direct parent is your post.'; - case 'THREAD': - return 'Replies anywhere under a post you authored.'; - } -} diff --git a/features/feed/lib/threadItems.ts b/features/feed/lib/threadItems.ts index c847e212b..3a1f8b387 100644 --- a/features/feed/lib/threadItems.ts +++ b/features/feed/lib/threadItems.ts @@ -66,7 +66,7 @@ export function orderedReplyIdsForThreadResult( const pageReplyIds = uniqueIds(result.replyPageEventIds.filter((id) => replyIds.has(id))); const pageReplyIdSet = new Set(pageReplyIds); const fallbackReplyIds = uniqueIds( - result.thread.replies.map((event) => event.id).filter((id) => !pageReplyIdSet.has(id)) + result.thread.replies.flatMap((event) => (pageReplyIdSet.has(event.id) ? [] : [event.id])) ); const existingOrder = uniqueIds(existingReplyOrder.filter((id) => replyIds.has(id))); const existingOrderSet = new Set(existingOrder); diff --git a/features/feed/lib/threadSeedCache.ts b/features/feed/lib/threadSeedCache.ts index 886c823a9..af71d58f6 100644 --- a/features/feed/lib/threadSeedCache.ts +++ b/features/feed/lib/threadSeedCache.ts @@ -41,10 +41,6 @@ export function seedThread(eventId: string, seed: ThreadSeed): void { } } -export function readThreadSeed(eventId: string): ThreadSeed | undefined { - return cache.get(eventId); -} - export function consumeThreadSeed(eventId: string): ThreadSeed | undefined { const seed = cache.get(eventId); if (seed) cache.delete(eventId); diff --git a/features/feed/lib/useQuotePost.ts b/features/feed/lib/useQuotePost.ts index 66e796e21..ba91267b1 100644 --- a/features/feed/lib/useQuotePost.ts +++ b/features/feed/lib/useQuotePost.ts @@ -3,29 +3,24 @@ * behind every Quote action (post cards, the image overlay, etc.) so they all * carry the quoted post + author into the composer identically. */ -import { useCallback } from 'react'; - import { useOpenComposer } from '@/features/composer/publish/useComposerActions'; import { tryNeventEncode } from '@/features/feed/components/nostr/feedParse'; import type { FeedEvent, ProfileInfo } from '@/features/feed/components/nostr/feedTypes'; export function useQuotePost(): (event: FeedEvent, profile?: ProfileInfo) => void { const openComposer = useOpenComposer(); - return useCallback( - (event, profile) => { - const nevent = tryNeventEncode(event.id, event.pubkey, event.kind); - openComposer( - { - mode: 'quote', - quotedId: event.id, - quotedPubkey: event.pubkey, - quotedNevent: nevent || undefined, - }, - // Carry the quoted post + its author so the composer renders it under the - // input, the same way reply mode renders the post being replied to. - { parentEvent: event, parentProfile: profile } - ); - }, - [openComposer] - ); + return (event, profile) => { + const nevent = tryNeventEncode(event.id, event.pubkey, event.kind); + openComposer( + { + mode: 'quote', + quotedId: event.id, + quotedPubkey: event.pubkey, + quotedNevent: nevent || undefined, + }, + // Carry the quoted post + its author so the composer renders it under the + // input, the same way reply mode renders the post being replied to. + { parentEvent: event, parentProfile: profile } + ); + }; } diff --git a/features/feed/screens/FeedScreen.tsx b/features/feed/screens/FeedScreen.tsx index 56079a60e..1e5342954 100644 --- a/features/feed/screens/FeedScreen.tsx +++ b/features/feed/screens/FeedScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useMemo } from 'react'; +import React, { useState, useMemo } from 'react'; import { View, StyleSheet } from 'react-native'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { ScreenContainer } from '@/features/contacts/components/ScreenContainer'; @@ -39,7 +39,7 @@ function FeedFilters({ onSelectForYou, onSelectFollowingMode, }: FeedFiltersProps) { - const openFollowingMenu = useCallback(() => { + const openFollowingMenu = () => { actionMenuPopup({ title: 'Following', buttons: [ @@ -67,18 +67,15 @@ function FeedFilters({ }, ], }); - }, [followingMode, onSelectFollowingMode]); + }; - const handleTabPress = useCallback( - (tab: FeedTabId) => { - if (tab === FEED_TAB_FOR_YOU) { - onSelectForYou(); - return; - } - openFollowingMenu(); - }, - [onSelectForYou, openFollowingMenu] - ); + const handleTabPress = (tab: FeedTabId) => { + if (tab === FEED_TAB_FOR_YOU) { + onSelectForYou(); + return; + } + openFollowingMenu(); + }; return ( @@ -126,18 +123,18 @@ export function FeedScreen() { ? FEED_FILTER_FOLLOWING_POPULAR : FEED_FILTER_FOLLOWING_RECENT; }, [activeTab, followingMode]); - const handleSelectForYou = useCallback(() => { + const handleSelectForYou = () => { feedLog.info('feed.filter.change', { filter: FEED_FILTER_FOR_YOU }); setActiveTab(FEED_TAB_FOR_YOU); - }, []); + }; - const handleSelectFollowingMode = useCallback((mode: FollowingMode) => { + const handleSelectFollowingMode = (mode: FollowingMode) => { const filter = mode === 'Popular' ? FEED_FILTER_FOLLOWING_POPULAR : FEED_FILTER_FOLLOWING_RECENT; feedLog.info('feed.filter.change', { filter, mode }); setFollowingMode(mode); setActiveTab(FEED_TAB_FOLLOWING); - }, []); + }; return ( diff --git a/features/feed/screens/NotificationsScreen.tsx b/features/feed/screens/NotificationsScreen.tsx index 53c17feff..84a8bd316 100644 --- a/features/feed/screens/NotificationsScreen.tsx +++ b/features/feed/screens/NotificationsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { FlatList, RefreshControl, @@ -142,48 +142,42 @@ export function NotificationsScreen() { 'muted', 'surface-tertiary', ] as const); - const notificationsVisualScope = useMemo( - () => `feed.notifications.${activeTab.toLowerCase()}.list`, - [activeTab] - ); + const notificationsVisualScope = `feed.notifications.${activeTab.toLowerCase()}.list`; const notificationListMetricsRef = useRef({ contentLength: null, scroll: 0, size: null, }); - const fetchNotificationsPage = useCallback( - async ({ - signal, - until, - refresh, - }: { - signal: AbortSignal; - until?: number; - refresh?: boolean; - }) => { - // The App tab is client-only (synthetic announcements) — never fetch. - if (!viewerPubkey || activeTab === 'APP') return null; - const client = getFeedClient(); - try { - return await client.getNotifications({ - viewerPubkey, - tab: activeTab, - policy, - replyScope, - limit: NOTIFICATIONS_PAGE_SIZE, - until, - refresh, - signal, - }); - } finally { - client.dispose?.(); - } - }, - [activeTab, policy, replyScope, viewerPubkey] - ); + const fetchNotificationsPage = async ({ + signal, + until, + refresh, + }: { + signal: AbortSignal; + until?: number; + refresh?: boolean; + }) => { + // The App tab is client-only (synthetic announcements) — never fetch. + if (!viewerPubkey || activeTab === 'APP') return null; + const client = getFeedClient(); + try { + return await client.getNotifications({ + viewerPubkey, + tab: activeTab, + policy, + replyScope, + limit: NOTIFICATIONS_PAGE_SIZE, + until, + refresh, + signal, + }); + } finally { + client.dispose?.(); + } + }; - const applyFirstPage = useCallback((page: FeedNotificationsResult | null) => { + const applyFirstPage = (page: FeedNotificationsResult | null) => { paginationUntilRef.current = page?.paginationUntil ?? 0; seenKeysRef.current = new Set((page?.notifications ?? []).map(notificationDedupeKey)); // Optimistic: as long as there's a cursor, try to page. load-more stops as @@ -195,95 +189,90 @@ export function NotificationsScreen() { paginationUntil: page?.paginationUntil ?? 0, }); setResult(page); - }, []); - - const loadFirstPage = useCallback( - (signal: AbortSignal, mode: LoadMode) => { - const sequence = ++loadSequenceRef.current; - // No viewer, or the client-only App tab → nothing to fetch; the synthetic - // items (welcome card) render without a server round-trip. - if (!viewerPubkey || activeTab === 'APP') { - applyFirstPage(null); - setErrorMessage(null); - setIsInitialLoading(false); - setIsRefreshing(false); - setIsLoadingMore(false); - return; - } + }; - const cacheKey = notificationsPageKey({ viewerPubkey, tab: activeTab, policy, replyScope }); - - // Warm navigation (key touched earlier this session): paint the cached - // page instantly and revalidate. Cold start (first focus this session): - // show loading, never a stale first paint. - let paintedFromCache = false; - if (mode === 'initial') { - const cached = notificationsPageCache.isColdStart(cacheKey) - ? undefined - : notificationsPageCache.getEntry(cacheKey); - if (cached) { - applyFirstPage(cached.data); - setIsInitialLoading(false); - paintedFromCache = true; - } else { - setResult(null); - setIsInitialLoading(true); - } - } else { - setIsRefreshing(true); - } + const loadFirstPage = (signal: AbortSignal, mode: LoadMode) => { + const sequence = ++loadSequenceRef.current; + // No viewer, or the client-only App tab → nothing to fetch; the synthetic + // items (welcome card) render without a server round-trip. + if (!viewerPubkey || activeTab === 'APP') { + applyFirstPage(null); setErrorMessage(null); + setIsInitialLoading(false); + setIsRefreshing(false); + setIsLoadingMore(false); + return; + } - // Only an explicit pull-to-refresh forces nagg to revalidate. An initial - // focus reads the shared response cache (which auto-revalidates a stale - // entry in the background), so opening the screen no longer pays the full - // recompute cost on every mount. - void fetchNotificationsPage({ signal, refresh: mode === 'refresh' }) - .then((page) => { - if (signal.aborted || sequence !== loadSequenceRef.current) return; - applyFirstPage(page); - if (page) notificationsPageCache.setEntry(cacheKey, page, { viewerKey: viewerPubkey }); - notificationsPageCache.markTouched(cacheKey); - }) - .catch((error) => { - if (signal.aborted || sequence !== loadSequenceRef.current) return; - const message = error instanceof Error ? error.message : String(error); - feedLog.warn('feed.notifications.load_failed', { message }); - setErrorMessage(message); - // Keep the warm-painted page on a transient failure. - if (mode === 'initial' && !paintedFromCache) applyFirstPage(null); - }) - .finally(() => { - if (signal.aborted || sequence !== loadSequenceRef.current) return; - if (mode === 'initial') setIsInitialLoading(false); - else setIsRefreshing(false); - }); - }, - [applyFirstPage, fetchNotificationsPage, viewerPubkey, activeTab, policy, replyScope] - ); + const cacheKey = notificationsPageKey({ viewerPubkey, tab: activeTab, policy, replyScope }); + + // Warm navigation (key touched earlier this session): paint the cached + // page instantly and revalidate. Cold start (first focus this session): + // show loading, never a stale first paint. + let paintedFromCache = false; + if (mode === 'initial') { + const cached = notificationsPageCache.isColdStart(cacheKey) + ? undefined + : notificationsPageCache.getEntry(cacheKey); + if (cached) { + applyFirstPage(cached.data); + setIsInitialLoading(false); + paintedFromCache = true; + } else { + setResult(null); + setIsInitialLoading(true); + } + } else { + setIsRefreshing(true); + } + setErrorMessage(null); + + // Only an explicit pull-to-refresh forces nagg to revalidate. An initial + // focus reads the shared response cache (which auto-revalidates a stale + // entry in the background), so opening the screen no longer pays the full + // recompute cost on every mount. + void fetchNotificationsPage({ signal, refresh: mode === 'refresh' }) + .then((page) => { + if (signal.aborted || sequence !== loadSequenceRef.current) return; + applyFirstPage(page); + if (page) notificationsPageCache.setEntry(cacheKey, page, { viewerKey: viewerPubkey }); + notificationsPageCache.markTouched(cacheKey); + }) + .catch((error) => { + if (signal.aborted || sequence !== loadSequenceRef.current) return; + const message = error instanceof Error ? error.message : String(error); + feedLog.warn('feed.notifications.load_failed', { message }); + setErrorMessage(message); + // Keep the warm-painted page on a transient failure. + if (mode === 'initial' && !paintedFromCache) applyFirstPage(null); + }) + .finally(() => { + if (signal.aborted || sequence !== loadSequenceRef.current) return; + if (mode === 'initial') setIsInitialLoading(false); + else setIsRefreshing(false); + }); + }; - useFocusEffect( - useCallback(() => { - const controller = new AbortController(); - loadFirstPage(controller.signal, 'initial'); - return () => { - controller.abort(); - refreshControllerRef.current?.abort(); - refreshControllerRef.current = null; - loadSequenceRef.current += 1; - }; - }, [loadFirstPage]) - ); + useFocusEffect(() => { + const controller = new AbortController(); + loadFirstPage(controller.signal, 'initial'); + return () => { + controller.abort(); + refreshControllerRef.current?.abort(); + refreshControllerRef.current = null; + loadSequenceRef.current += 1; + }; + }); - const handleRefresh = useCallback(() => { + const handleRefresh = () => { if (isRefreshing) return; refreshControllerRef.current?.abort(); const controller = new AbortController(); refreshControllerRef.current = controller; loadFirstPage(controller.signal, 'refresh'); - }, [isRefreshing, loadFirstPage]); + }; - const loadMoreNotifications = useCallback(async () => { + const loadMoreNotifications = async () => { if ( loadingMoreRef.current || isInitialLoading || @@ -309,9 +298,10 @@ export function NotificationsScreen() { }); if (!page || controller.signal.aborted || sequence !== loadSequenceRef.current) return; - const newKeys = page.notifications - .map(notificationDedupeKey) - .filter((key) => !seenKeysRef.current.has(key)); + const newKeys = page.notifications.flatMap((notification) => { + const key = notificationDedupeKey(notification); + return seenKeysRef.current.has(key) ? [] : [key]; + }); const advanced = page.paginationUntil > 0 && page.paginationUntil < cursor; // Stop only when a page adds nothing new or the cursor can't advance — // grouping makes the raw item count an unreliable "has more" signal. @@ -329,26 +319,23 @@ export function NotificationsScreen() { loadingMoreRef.current = false; setIsLoadingMore(false); } - }, [fetchNotificationsPage, isInitialLoading, isRefreshing, viewerPubkey]); - - const selectTab = useCallback( - (tab: NotificationTab) => { - if (tab === activeTab) return; - paginationUntilRef.current = 0; - hasMoreRef.current = false; - seenKeysRef.current = new Set(); - setResult(null); - setErrorMessage(null); - setIsLoadingMore(false); - setIsInitialLoading(true); - setActiveTab(tab); - }, - [activeTab] - ); + }; + + const selectTab = (tab: NotificationTab) => { + if (tab === activeTab) return; + paginationUntilRef.current = 0; + hasMoreRef.current = false; + seenKeysRef.current = new Set(); + setResult(null); + setErrorMessage(null); + setIsLoadingMore(false); + setIsInitialLoading(true); + setActiveTab(tab); + }; // Direct/Thread reply-scope picker for the Mentions tab. Mirrors the Following // feed tab: the Mentions pill shows a chevron and opens this popup when active. - const openReplyScopeMenu = useCallback(() => { + const openReplyScopeMenu = () => { actionMenuPopup({ title: 'Mentions', buttons: (['DIRECT', 'THREAD'] as const).map((scope) => ({ @@ -360,67 +347,55 @@ export function NotificationsScreen() { }, })), }); - }, [replyScope, setReplyScope]); + }; // Tapping Mentions when it's already active opens the scope popup (like // Following); otherwise it just switches tabs. - const handleNotificationTabPress = useCallback( - (tab: NotificationTab) => { - if (tab === 'MENTIONS' && activeTab === 'MENTIONS') { - openReplyScopeMenu(); - return; - } - selectTab(tab); - }, - [activeTab, openReplyScopeMenu, selectTab] - ); + const handleNotificationTabPress = (tab: NotificationTab) => { + if (tab === 'MENTIONS' && activeTab === 'MENTIONS') { + openReplyScopeMenu(); + return; + } + selectTab(tab); + }; - const threadContext = useMemo( - () => ({ - profiles: result?.profilesMap ?? new Map(), - metrics: result?.metricsMap ?? new Map(), - quotedEvents: result?.quotedEventsMap ?? new Map(), - }), - [result] - ); + const threadContext = { + profiles: result?.profilesMap ?? new Map(), + metrics: result?.metricsMap ?? new Map(), + quotedEvents: result?.quotedEventsMap ?? new Map(), + }; - const openNotification = useCallback( - (notification: FeedNotification) => { - if (notification.reason === 'follow') { - router.push({ - pathname: '/(user-flow)/profile', - params: { pubkey: notification.event.pubkey }, - }); - return; - } - const openEventId = notificationOpenEventId(notification); - const allEvents = new Map([[notification.event.id, notification.event]]); - if (notification.targetEvent) - allEvents.set(notification.targetEvent.id, notification.targetEvent); - seedThread(openEventId, { - allEvents, - profiles: threadContext.profiles, - metrics: threadContext.metrics, - quotedEvents: threadContext.quotedEvents, - }); + const openNotification = (notification: FeedNotification) => { + if (notification.reason === 'follow') { router.push({ - pathname: '/(user-flow)/thread', - params: { eventId: openEventId }, + pathname: '/(user-flow)/profile', + params: { pubkey: notification.event.pubkey }, }); - }, - [threadContext] - ); + return; + } + const openEventId = notificationOpenEventId(notification); + const allEvents = new Map([[notification.event.id, notification.event]]); + if (notification.targetEvent) + allEvents.set(notification.targetEvent.id, notification.targetEvent); + seedThread(openEventId, { + allEvents, + profiles: threadContext.profiles, + metrics: threadContext.metrics, + quotedEvents: threadContext.quotedEvents, + }); + router.push({ + pathname: '/(user-flow)/thread', + params: { eventId: openEventId }, + }); + }; - const openFollowGroup = useCallback( - (notifications: FeedNotification[]) => { - const seedId = seedNotificationFollowers({ notifications, result }); - router.push({ - pathname: '/(drawer)/(tabs)/notifications/followers', - params: { seedId }, - }); - }, - [result] - ); + const openFollowGroup = (notifications: FeedNotification[]) => { + const seedId = seedNotificationFollowers({ notifications, result }); + router.push({ + pathname: '/(drawer)/(tabs)/notifications/followers', + params: { seedId }, + }); + }; const seedCreatedAt = useWalletLifecycleStore((s) => s.seedCreatedAt); const termsDate = useSettingsStore((s) => s.termsAccepted?.date ?? null); @@ -473,7 +448,7 @@ export function NotificationsScreen() { for (const [pk, meta] of cachedProfiles) { const existing = profilesMap.get(pk); // Fill a missing actor, or upgrade a name-only tier entry that lacks a picture. - if (existing && existing.picture) continue; + if (existing?.picture) continue; const name = meta.displayName || meta.name || existing?.name; const picture = meta.picture ?? existing?.picture; if (!name && !picture) continue; @@ -512,70 +487,54 @@ export function NotificationsScreen() { rowLabel: item.type, }), }); - const reportNotificationListMetrics = useCallback( - (reason: string) => { - const metrics = notificationListMetricsRef.current; - onVisualListMetricsChange({ - reason, - size: metrics.size, - scroll: metrics.scroll, - scrollLength: metrics.size, - contentLength: metrics.contentLength, - }); - }, - [onVisualListMetricsChange] - ); - const handleListLayout = useCallback( - (event: LayoutChangeEvent) => { - notificationListMetricsRef.current.size = event.nativeEvent.layout.height; - reportNotificationListMetrics('layout'); - }, - [reportNotificationListMetrics] - ); - const handleContentSizeChange = useCallback( - (_width: number, height: number) => { - notificationListMetricsRef.current.contentLength = height; - reportNotificationListMetrics('content-size'); - }, - [reportNotificationListMetrics] - ); - const handleListViewableItemsChanged = useCallback( - ({ viewableItems, changed }: { viewableItems: ViewToken[]; changed: ViewToken[] }) => { - onVisualViewableItemsChanged({ - ...visualViewabilityRange([...viewableItems, ...changed]), - viewableItems: viewableItems.map(notificationVisualToken), - changed: changed.map(notificationVisualToken), - }); - }, - [onVisualViewableItemsChanged] - ); - const handleListScroll = useCallback( - (event: NativeSyntheticEvent) => { - const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; - notificationListMetricsRef.current = { - contentLength: contentSize.height, - scroll: contentOffset.y, - size: layoutMeasurement.height, - }; - reportNotificationListMetrics('scroll'); - remeasureVisualLayoutScope(notificationsVisualScope, 'scroll', { - extra: { - tab: activeTab, - phase: visualPhase, - items: notificationItems.length, - loadingMore: isLoadingMore, - }, - }); - }, - [ - activeTab, - isLoadingMore, - notificationItems.length, - notificationsVisualScope, - reportNotificationListMetrics, - visualPhase, - ] - ); + const reportNotificationListMetrics = (reason: string) => { + const metrics = notificationListMetricsRef.current; + onVisualListMetricsChange({ + reason, + size: metrics.size, + scroll: metrics.scroll, + scrollLength: metrics.size, + contentLength: metrics.contentLength, + }); + }; + const handleListLayout = (event: LayoutChangeEvent) => { + notificationListMetricsRef.current.size = event.nativeEvent.layout.height; + reportNotificationListMetrics('layout'); + }; + const handleContentSizeChange = (_width: number, height: number) => { + notificationListMetricsRef.current.contentLength = height; + reportNotificationListMetrics('content-size'); + }; + const handleListViewableItemsChanged = ({ + viewableItems, + changed, + }: { + viewableItems: ViewToken[]; + changed: ViewToken[]; + }) => { + onVisualViewableItemsChanged({ + ...visualViewabilityRange([...viewableItems, ...changed]), + viewableItems: viewableItems.map(notificationVisualToken), + changed: changed.map(notificationVisualToken), + }); + }; + const handleListScroll = (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + notificationListMetricsRef.current = { + contentLength: contentSize.height, + scroll: contentOffset.y, + size: layoutMeasurement.height, + }; + reportNotificationListMetrics('scroll'); + remeasureVisualLayoutScope(notificationsVisualScope, 'scroll', { + extra: { + tab: activeTab, + phase: visualPhase, + items: notificationItems.length, + loadingMore: isLoadingMore, + }, + }); + }; // Render boundary for notifications: result rows → rendered list items, and // whether the screen is empty. Cross-check with feed.notifications.fetch.done @@ -1261,6 +1220,10 @@ function notificationIcon(reason: string): string { } function notificationTone(reason: string): string { + /* eslint-disable no-restricted-syntax -- fixed notification-type accent palette + (Twitter/X-derived), intentionally theme-invariant so each reason reads with the + same colour on every theme; a named palette home isn't warranted for a + single-consumer switch. */ switch (reason) { case 'follow': case 'reply': @@ -1276,6 +1239,7 @@ function notificationTone(reason: string): string { default: return '#71767B'; } + /* eslint-enable no-restricted-syntax */ } function EmptyNotifications({ diff --git a/features/map/components/StatsCard.tsx b/features/map/components/StatsCard.tsx index f49c02058..e9d126f58 100644 --- a/features/map/components/StatsCard.tsx +++ b/features/map/components/StatsCard.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useRef } from 'react'; +import { memo, useRef } from 'react'; import { LiquidGlassMenu } from 'liquid-glass-menu'; import { Menu as HeroMenu, type MenuTriggerRef } from 'heroui-native'; import { ActionSheetIOS, Platform, StyleSheet, Text } from 'react-native'; @@ -55,9 +55,9 @@ export const StatsCard = memo(function StatsCard({ const { liquidGlass } = useCapabilities(); const colorScheme = useColorScheme(); const menuTriggerRef = useRef(null); - const openCategoryMenu = useCallback(() => { + const openCategoryMenu = () => { setTimeout(() => menuTriggerRef.current?.open(), 0); - }, []); + }; const visibleText = loading ? '...' : `${visibleCount.toLocaleString()} visible`; const totalText = loading diff --git a/features/map/hooks/useMapCamera.ts b/features/map/hooks/useMapCamera.ts index dc5b7706f..5f3487e4f 100644 --- a/features/map/hooks/useMapCamera.ts +++ b/features/map/hooks/useMapCamera.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { InteractionManager, Platform } from 'react-native'; import type { CameraPosition } from 'expo-maps'; @@ -48,7 +48,7 @@ export function useMapCamera({ }; }, []); - const setCamera = useCallback((next: MapCamera) => { + const setCamera = (next: MapCamera) => { cameraRef.current = next; // Android's GoogleMaps.View.setCameraPosition accepts an optional `duration` @@ -66,48 +66,47 @@ export function useMapCamera({ zoom: next.zoom, }); } - }, []); - - const getCamera = useCallback(() => cameraRef.current, []); - - const handleCameraChange = useCallback( - (event: { coordinates: { latitude?: number; longitude?: number }; zoom: number }) => { - const prev = cameraRef.current; - const newLat = event.coordinates.latitude ?? prev.lat; - const newLon = event.coordinates.longitude ?? prev.lon; - const newZoom = event.zoom; + }; - cameraRef.current = { lat: newLat, lon: newLon, zoom: newZoom }; + const getCamera = () => cameraRef.current; - // Debounce marker queries and skip tiny movements within the current zoom bucket - const zoomFloor = Math.floor(newZoom); - const last = lastMarkerQueryRef.current; - const span = 360 / Math.pow(2, Math.max(newZoom, 0)); - const latThreshold = span * 0.12; - const lonThreshold = span * aspectRatio * 0.12; - const shouldSkip = - last && - last.zoomFloor === zoomFloor && - Math.abs(newLat - last.lat) < latThreshold && - Math.abs(newLon - last.lon) < lonThreshold; - - if (shouldSkip) return; - - if (markerUpdateTimerRef.current) { - clearTimeout(markerUpdateTimerRef.current); - } + const handleCameraChange = (event: { + coordinates: { latitude?: number; longitude?: number }; + zoom: number; + }) => { + const prev = cameraRef.current; + const newLat = event.coordinates.latitude ?? prev.lat; + const newLon = event.coordinates.longitude ?? prev.lon; + const newZoom = event.zoom; + + cameraRef.current = { lat: newLat, lon: newLon, zoom: newZoom }; + + // Debounce marker queries and skip tiny movements within the current zoom bucket + const zoomFloor = Math.floor(newZoom); + const last = lastMarkerQueryRef.current; + const span = 360 / Math.pow(2, Math.max(newZoom, 0)); + const latThreshold = span * 0.12; + const lonThreshold = span * aspectRatio * 0.12; + const shouldSkip = + last?.zoomFloor === zoomFloor && + Math.abs(newLat - last.lat) < latThreshold && + Math.abs(newLon - last.lon) < lonThreshold; + + if (shouldSkip) return; + + if (markerUpdateTimerRef.current) { + clearTimeout(markerUpdateTimerRef.current); + } - markerUpdateTimerRef.current = setTimeout(() => { - lastMarkerQueryRef.current = { lat: newLat, lon: newLon, zoomFloor }; - // Ensure marker recalculation doesn't compete with gestures/animations - if (markerUpdateTaskRef.current) markerUpdateTaskRef.current.cancel(); - markerUpdateTaskRef.current = InteractionManager.runAfterInteractions(() => { - onCameraSettle(newLat, newLon, newZoom); - }); - }, 250); - }, - [aspectRatio, onCameraSettle] - ); + markerUpdateTimerRef.current = setTimeout(() => { + lastMarkerQueryRef.current = { lat: newLat, lon: newLon, zoomFloor }; + // Ensure marker recalculation doesn't compete with gestures/animations + if (markerUpdateTaskRef.current) markerUpdateTaskRef.current.cancel(); + markerUpdateTaskRef.current = InteractionManager.runAfterInteractions(() => { + onCameraSettle(newLat, newLon, newZoom); + }); + }, 250); + }; return { mapRef, getCamera, setCamera, handleCameraChange }; } diff --git a/features/map/hooks/useMapMarkers.ts b/features/map/hooks/useMapMarkers.ts index ad6b31079..0ac0b8504 100644 --- a/features/map/hooks/useMapMarkers.ts +++ b/features/map/hooks/useMapMarkers.ts @@ -50,7 +50,7 @@ export function useMapMarkers({ setError: s.setError, })) ); - const places = useMemo(() => placesCache?.data ?? [], [placesCache]); + const places = placesCache?.data ?? []; const [isClusteringReady, setIsClusteringReady] = useState(false); const [markers, setMarkers] = useState([]); @@ -80,7 +80,7 @@ export function useMapMarkers({ const updateMarkersForCamera = useCallback( (lat: number, lon: number, z: number) => { const manager = clusterManagerRef.current; - if (!manager || !manager.isLoaded()) { + if (!manager?.isLoaded()) { setMarkers([]); setVisibleCount(0); lastRenderedMarkersRef.current = []; @@ -180,10 +180,7 @@ export function useMapMarkers({ return () => handle.cancel(); }, [isMapReady, filteredPoints, clusterCacheKey, getCamera, updateMarkersForCamera]); - const resolveMarker = useCallback( - (id: string) => markersRef.current.find((m) => m.id === id), - [] - ); + const resolveMarker = (id: string) => markersRef.current.find((m) => m.id === id); return { markers, diff --git a/features/map/screens/MapScreen.tsx b/features/map/screens/MapScreen.tsx index e090cce54..6ff161a7f 100644 --- a/features/map/screens/MapScreen.tsx +++ b/features/map/screens/MapScreen.tsx @@ -22,9 +22,9 @@ import { AppleMaps, GoogleMaps } from 'expo-maps'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; import opacity from 'hex-color-opacity'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { InteractionManager, Platform, StyleSheet, useWindowDimensions } from 'react-native'; -import { BITCOIN_ACCENT } from '@/shared/lib/brandColors'; +import { BITCOIN_ACCENT, INVARIANT_WHITE } from '@/shared/lib/brandColors'; import { applySafetyOffset } from '@/shared/lib/map/locationPrivacy'; import { CircleActionButton } from '@/shared/ui/composed/CircleActionButton'; import { Spinner } from '@/shared/ui/primitives/Spinner'; @@ -68,9 +68,9 @@ export function MapScreen() { // updateMarkersForCamera during render. The ref mutation is safe because // no settle callback fires before the first paint. const onCameraSettleRef = useRef<(lat: number, lon: number, zoom: number) => void>(() => {}); - const onCameraSettle = useCallback((lat: number, lon: number, zoom: number) => { + const onCameraSettle = (lat: number, lon: number, zoom: number) => { onCameraSettleRef.current(lat, lon, zoom); - }, []); + }; const mapCamera = useMapCamera({ initial: { lat: DEFAULT_LAT, lon: DEFAULT_LON, zoom: DEFAULT_ZOOM }, @@ -139,7 +139,7 @@ export function MapScreen() { return () => task.cancel(); }, [mapCamera, updateMarkersForCamera]); - const handleMyLocation = useCallback(async () => { + const handleMyLocation = async () => { try { const loc = await Location.getCurrentPositionAsync({}); const safe = applySafetyOffset(loc.coords.latitude, loc.coords.longitude); @@ -148,55 +148,52 @@ export function MapScreen() { } catch (err) { log.error('map.location.error', { error: err }); } - }, [mapCamera, updateMarkersForCamera]); + }; - const handleZoomIn = useCallback(() => { + const handleZoomIn = () => { const { lat, lon, zoom } = mapCamera.getCamera(); const newZoom = Math.min(zoom + 2, 20); mapCamera.setCamera({ lat, lon, zoom: newZoom }); updateMarkersForCamera(lat, lon, newZoom); - }, [mapCamera, updateMarkersForCamera]); + }; - const handleZoomOut = useCallback(() => { + const handleZoomOut = () => { const { lat, lon, zoom } = mapCamera.getCamera(); const newZoom = Math.max(zoom - 2, 1); mapCamera.setCamera({ lat, lon, zoom: newZoom }); updateMarkersForCamera(lat, lon, newZoom); - }, [mapCamera, updateMarkersForCamera]); - - const handleMarkerClick = useCallback( - async (marker: { id?: string }) => { - if (!marker.id) return; - - const clusterMarker = resolveMarker(marker.id); - if (!clusterMarker) return; - - if (clusterMarker.type === 'cluster' && clusterMarker.clusterId !== undefined) { - const manager = clusterManagerRef.current; - if (manager) { - // Supercluster's getClusterExpansionZoom returns the zoom at which - // this cluster's children become individually visible. We zoom one - // step past that so the children actually separate in the viewport - // instead of re-clustering at the threshold; capped at 18 to stay - // within Supercluster's maxZoom + 1. - const expansionZoom = manager.getClusterExpansionZoom(clusterMarker.clusterId); - const newZoom = Math.min(expansionZoom + 1, 18); - mapCamera.setCamera({ - lat: clusterMarker.latitude, - lon: clusterMarker.longitude, - zoom: newZoom, - }); - updateMarkersForCamera(clusterMarker.latitude, clusterMarker.longitude, newZoom); - } - } else if (clusterMarker.placeId) { - router.navigate({ - pathname: '/(map-flow)/detail', - params: { placeId: clusterMarker.placeId.toString() }, + }; + + const handleMarkerClick = async (marker: { id?: string }) => { + if (!marker.id) return; + + const clusterMarker = resolveMarker(marker.id); + if (!clusterMarker) return; + + if (clusterMarker.type === 'cluster' && clusterMarker.clusterId !== undefined) { + const manager = clusterManagerRef.current; + if (manager) { + // Supercluster's getClusterExpansionZoom returns the zoom at which + // this cluster's children become individually visible. We zoom one + // step past that so the children actually separate in the viewport + // instead of re-clustering at the threshold; capped at 18 to stay + // within Supercluster's maxZoom + 1. + const expansionZoom = manager.getClusterExpansionZoom(clusterMarker.clusterId); + const newZoom = Math.min(expansionZoom + 1, 18); + mapCamera.setCamera({ + lat: clusterMarker.latitude, + lon: clusterMarker.longitude, + zoom: newZoom, }); + updateMarkersForCamera(clusterMarker.latitude, clusterMarker.longitude, newZoom); } - }, - [resolveMarker, clusterManagerRef, mapCamera, updateMarkersForCamera] - ); + } else if (clusterMarker.placeId) { + router.navigate({ + pathname: '/(map-flow)/detail', + params: { placeId: clusterMarker.placeId.toString() }, + }); + } + }; const mapUnavailableOnAndroid = Platform.OS === 'android' && !HAS_ANDROID_GOOGLE_MAPS_KEY; @@ -213,7 +210,7 @@ export function MapScreen() { router.back() : () => setError(null)} style={[styles.retryButton, { backgroundColor: accent }]}> - + {mapUnavailableOnAndroid ? 'Go back' : 'Retry'} diff --git a/features/map/screens/MerchantDetailScreen.tsx b/features/map/screens/MerchantDetailScreen.tsx index 11b5465e8..7a325b67e 100644 --- a/features/map/screens/MerchantDetailScreen.tsx +++ b/features/map/screens/MerchantDetailScreen.tsx @@ -3,7 +3,7 @@ * Used from map flow when a marker is tapped. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { ScrollView, StyleSheet } from 'react-native'; import { useNavigation } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -25,7 +25,7 @@ import opacity from 'hex-color-opacity'; import { Log, log, useLifecycleLogger } from '@/shared/lib/logger'; import { useRouteParams } from '@/shared/lib/nav/useRouteParams'; import { getMarkerColor } from '@/shared/lib/map/categories'; -import { BITCOIN_ACCENT } from '@/shared/lib/brandColors'; +import { BITCOIN_ACCENT, INVARIANT_WHITE } from '@/shared/lib/brandColors'; import { isAbortError } from '@/shared/lib/apiClient'; import { openExternalUrl } from '@/shared/lib/url'; import { staticPopup } from '@/shared/lib/popup'; @@ -35,6 +35,44 @@ const ParamsSchema = z.object({ placeId: z.string().regex(/^\d{1,15}$/, 'placeId must be a positive integer'), }); +const handleOpenURL = async (url: string) => { + const result = await openExternalUrl(url); + if (result.isErr()) { + log.warn('map.merchant.open_link.failed', { url, reason: result.error.type }); + staticPopup('open-link-failed'); + } +}; + +const handleCall = async (phone: string) => { + // Strip everything but digits and a leading + so user-supplied formatting + // (spaces, dashes, parens) doesn't fail URL parsing. + const sanitized = phone.replace(/[^\d+]/g, ''); + await handleOpenURL(`tel:${sanitized}`); +}; + +const handleEmail = async (email: string) => handleOpenURL(`mailto:${email.trim()}`); + +const handleContactPress = (method: string, info: string, fullInfo?: string) => { + switch (method) { + case 'phone': + void handleCall(info); + break; + case 'website': + const url = fullInfo || info; + void handleOpenURL(url.startsWith('http') ? url : `https://${url}`); + break; + case 'email': + void handleEmail(info); + break; + case 'instagram': + void handleOpenURL(`https://instagram.com/${info.replace('@', '')}`); + break; + case 'twitter': + void handleOpenURL(`https://x.com/${info.replace('@', '')}`); + break; + } +}; + export function MerchantDetailScreen() { useLifecycleLogger('MerchantDetailScreen'); const navigation = useNavigation(); @@ -101,29 +139,6 @@ export function MerchantDetailScreen() { } }, [place?.name, navigation]); - const handleOpenURL = useCallback(async (url: string) => { - const result = await openExternalUrl(url); - if (result.isErr()) { - log.warn('map.merchant.open_link.failed', { url, reason: result.error.type }); - staticPopup('open-link-failed'); - } - }, []); - - const handleCall = useCallback( - async (phone: string) => { - // Strip everything but digits and a leading + so user-supplied formatting - // (spaces, dashes, parens) doesn't fail URL parsing. - const sanitized = phone.replace(/[^\d+]/g, ''); - await handleOpenURL(`tel:${sanitized}`); - }, - [handleOpenURL] - ); - - const handleEmail = useCallback( - async (email: string) => handleOpenURL(`mailto:${email.trim()}`), - [handleOpenURL] - ); - const supportsOnchain = place?.['osm:payment:onchain'] === 'yes'; const supportsLightning = place?.['osm:payment:lightning'] === 'yes'; const supportsContactless = place?.['osm:payment:lightning_contactless'] === 'yes'; @@ -151,30 +166,6 @@ export function MerchantDetailScreen() { return items; }, [phone, website, email, instagram, twitter]); - const handleContactPress = useCallback( - (method: string, info: string, fullInfo?: string) => { - switch (method) { - case 'phone': - void handleCall(info); - break; - case 'website': - const url = fullInfo || info; - void handleOpenURL(url.startsWith('http') ? url : `https://${url}`); - break; - case 'email': - void handleEmail(info); - break; - case 'instagram': - void handleOpenURL(`https://instagram.com/${info.replace('@', '')}`); - break; - case 'twitter': - void handleOpenURL(`https://x.com/${info.replace('@', '')}`); - break; - } - }, - [handleCall, handleOpenURL, handleEmail] - ); - if (isLoading) { return ( @@ -213,7 +204,7 @@ export function MerchantDetailScreen() { showsVerticalScrollIndicator={false}> - + diff --git a/features/mint/components/MintCurrencyTabs.tsx b/features/mint/components/MintCurrencyTabs.tsx index dd8cf5256..745ffe663 100644 --- a/features/mint/components/MintCurrencyTabs.tsx +++ b/features/mint/components/MintCurrencyTabs.tsx @@ -5,7 +5,7 @@ * large (expanded) and small (compact) sizes based on scroll position. */ -import React, { useCallback } from 'react'; +import React from 'react'; import { ScrollView } from 'react-native'; import Animated, { useAnimatedStyle, @@ -217,13 +217,10 @@ export function MintCurrencyTabs({ 'surface', ] as const); - const handleCurrencyChange = useCallback( - (currency: string) => { - cashuLog.info('mint.currency.tab.select', { currency }); - onCurrencyChange(currency); - }, - [onCurrencyChange] - ); + const handleCurrencyChange = (currency: string) => { + cashuLog.info('mint.currency.tab.select', { currency }); + onCurrencyChange(currency); + }; // Animated gap between items const animatedListGapStyle = useAnimatedStyle(() => { diff --git a/features/mint/components/distribution/DistributionBar.tsx b/features/mint/components/distribution/DistributionBar.tsx index 8b812b5ba..cb040e25b 100644 --- a/features/mint/components/distribution/DistributionBar.tsx +++ b/features/mint/components/distribution/DistributionBar.tsx @@ -57,7 +57,7 @@ const AnimatedSegment: React.FC = ({ }; } const mainColor = baseColors[1] || baseColor; - if (FALLBACK_COLORS.includes(mainColor as any)) { + if ((FALLBACK_COLORS as readonly string[]).includes(mainColor)) { return { gradientColors: [defaultColor, surfaceTertiary] as const, borderColor: surfaceTertiary, diff --git a/features/mint/components/distribution/DistributionSlider.tsx b/features/mint/components/distribution/DistributionSlider.tsx index 41b864149..2d16e0f38 100644 --- a/features/mint/components/distribution/DistributionSlider.tsx +++ b/features/mint/components/distribution/DistributionSlider.tsx @@ -101,38 +101,17 @@ export const DistributionSlider: FC = ({ [onValueCommit] ); - const gesture = useMemo(() => { - return Gesture.Pan() - .enabled(!disabled) - .onBegin((event) => { - 'worklet'; - isActive.value = true; - const tapX = event.x; - - const tappedStepIndex = Math.round(tapX / stepWidth); - const clampedStepIndex = Math.max(0, Math.min(tappedStepIndex, TOTAL_STEPS - 1)); - - const newProgress = (clampedStepIndex / (TOTAL_STEPS - 1)) * width; - progress.value = newProgress; - - const newBp = Math.round(clampedStepIndex * BP_PER_STEP); - value.value = newBp; - - lastStepIndex.value = clampedStepIndex; - - runOnJS(fireHaptic)(); - runOnJS(notifyValueChange)(newBp); - runOnJS(notifyValueCommit)(newBp); - }) - .onChange((event) => { - 'worklet'; - const currentX = event.x; - - const currentStepIndex = Math.round(currentX / stepWidth); - const clampedStepIndex = Math.max(0, Math.min(currentStepIndex, TOTAL_STEPS - 1)); - - if (clampedStepIndex !== lastStepIndex.value) { - lastStepIndex.value = clampedStepIndex; + const gesture = useMemo( + () => + Gesture.Pan() + .enabled(!disabled) + .onBegin((event) => { + 'worklet'; + isActive.value = true; + const tapX = event.x; + + const tappedStepIndex = Math.round(tapX / stepWidth); + const clampedStepIndex = Math.max(0, Math.min(tappedStepIndex, TOTAL_STEPS - 1)); const newProgress = (clampedStepIndex / (TOTAL_STEPS - 1)) * width; progress.value = newProgress; @@ -140,33 +119,56 @@ export const DistributionSlider: FC = ({ const newBp = Math.round(clampedStepIndex * BP_PER_STEP); value.value = newBp; + lastStepIndex.value = clampedStepIndex; + runOnJS(fireHaptic)(); runOnJS(notifyValueChange)(newBp); - } - }) - .onFinalize(() => { - 'worklet'; - isActive.value = false; - - const stepIndex = Math.round(value.value / BP_PER_STEP); - const clampedStepIndex = Math.max(0, Math.min(stepIndex, TOTAL_STEPS - 1)); - const finalProgress = (clampedStepIndex / (TOTAL_STEPS - 1)) * width; - progress.value = finalProgress; - - runOnJS(notifyValueCommit)(value.value); - }); - }, [ - disabled, - stepWidth, - width, - progress, - value, - lastStepIndex, - isActive, - fireHaptic, - notifyValueChange, - notifyValueCommit, - ]); + runOnJS(notifyValueCommit)(newBp); + }) + .onChange((event) => { + 'worklet'; + const currentX = event.x; + + const currentStepIndex = Math.round(currentX / stepWidth); + const clampedStepIndex = Math.max(0, Math.min(currentStepIndex, TOTAL_STEPS - 1)); + + if (clampedStepIndex !== lastStepIndex.value) { + lastStepIndex.value = clampedStepIndex; + + const newProgress = (clampedStepIndex / (TOTAL_STEPS - 1)) * width; + progress.value = newProgress; + + const newBp = Math.round(clampedStepIndex * BP_PER_STEP); + value.value = newBp; + + runOnJS(fireHaptic)(); + runOnJS(notifyValueChange)(newBp); + } + }) + .onFinalize(() => { + 'worklet'; + isActive.value = false; + + const stepIndex = Math.round(value.value / BP_PER_STEP); + const clampedStepIndex = Math.max(0, Math.min(stepIndex, TOTAL_STEPS - 1)); + const finalProgress = (clampedStepIndex / (TOTAL_STEPS - 1)) * width; + progress.value = finalProgress; + + runOnJS(notifyValueCommit)(value.value); + }), + [ + disabled, + stepWidth, + width, + progress, + value, + lastStepIndex, + isActive, + fireHaptic, + notifyValueChange, + notifyValueCommit, + ] + ); useAnimatedReaction( () => value.value, diff --git a/features/mint/components/distribution/MintDistributionItem.tsx b/features/mint/components/distribution/MintDistributionItem.tsx index e67cae639..0652348cf 100644 --- a/features/mint/components/distribution/MintDistributionItem.tsx +++ b/features/mint/components/distribution/MintDistributionItem.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useMemo, useState } from 'react'; +import React, { FC, useMemo, useState } from 'react'; import { LayoutChangeEvent, StyleSheet, View } from 'react-native'; import { useSharedValue } from 'react-native-reanimated'; import opacity from 'hex-color-opacity'; @@ -58,8 +58,8 @@ export const MintDistributionItem: FC = ({ 'surface-secondary', ] as const); const primaryColor0 = foreground; - const primaryColor50 = useMemo(() => opacity(foreground, 0.9), [foreground]); - const primaryColor300 = useMemo(() => opacity(foreground, 0.5), [foreground]); + const primaryColor50 = opacity(foreground, 0.9); + const primaryColor300 = opacity(foreground, 0.5); const extractedColors = useExtractedColors(mintInfo?.icon_url); @@ -108,9 +108,9 @@ export const MintDistributionItem: FC = ({ const [previewBp, setPreviewBp] = useState(null); const [sliderWidth, setSliderWidth] = useState(0); - const handleSliderLayout = useCallback((event: LayoutChangeEvent) => { + const handleSliderLayout = (event: LayoutChangeEvent) => { setSliderWidth(event.nativeEvent.layout.width); - }, []); + }; // Sync slider shared value when prop changes (only when not previewing) React.useEffect(() => { @@ -119,25 +119,22 @@ export const MintDistributionItem: FC = ({ } }, [distributionBp, sliderValue, previewBp]); - const handleSliderChange = useCallback((bp: number) => { + const handleSliderChange = (bp: number) => { setPreviewBp(bp); - }, []); + }; - const handleSliderCommit = useCallback( - (bp: number) => { - setPreviewBp(null); - onDistributionChange(mintUrl, bp); - }, - [mintUrl, onDistributionChange] - ); + const handleSliderCommit = (bp: number) => { + setPreviewBp(null); + onDistributionChange(mintUrl, bp); + }; - const handleMax = useCallback(() => { + const handleMax = () => { onMax(mintUrl); - }, [mintUrl, onMax]); + }; - const handleMin = useCallback(() => { + const handleMin = () => { onMin(mintUrl); - }, [mintUrl, onMin]); + }; const displayName = mintInfo?.name || extractDomain(mintUrl) || 'Unknown Mint'; diff --git a/features/mint/components/rebalance/RebalanceStepRow.tsx b/features/mint/components/rebalance/RebalanceStepRow.tsx index bea8ff646..3d0bde391 100644 --- a/features/mint/components/rebalance/RebalanceStepRow.tsx +++ b/features/mint/components/rebalance/RebalanceStepRow.tsx @@ -110,8 +110,8 @@ export const RebalanceStepRow: React.FC = ({ }) => { const [foreground, surfaceTertiary] = useThemeColor(['foreground', 'surface-tertiary'] as const); const primaryColor0 = foreground; - const primaryColor300 = useMemo(() => opacity(foreground, 0.5), [foreground]); - const primaryColor400 = useMemo(() => opacity(foreground, 0.4), [foreground]); + const primaryColor300 = opacity(foreground, 0.5); + const primaryColor400 = opacity(foreground, 0.4); const primaryColor700 = surfaceTertiary; const fromName = getMintDisplayName(fromMintUrl, fromMintInfo); diff --git a/features/mint/components/rebalance/groupSteps.ts b/features/mint/components/rebalance/groupSteps.ts index a68eed5ef..09fcd9345 100644 --- a/features/mint/components/rebalance/groupSteps.ts +++ b/features/mint/components/rebalance/groupSteps.ts @@ -57,7 +57,7 @@ export function groupStepsForDisplay( if (step.chainId) { const last = groups[groups.length - 1]; - if (last && last.chainId === step.chainId) { + if (last?.chainId === step.chainId) { last.steps.push(step); continue; } diff --git a/features/mint/components/rebalance/index.ts b/features/mint/components/rebalance/index.ts index 8135dd251..2d54b633a 100644 --- a/features/mint/components/rebalance/index.ts +++ b/features/mint/components/rebalance/index.ts @@ -1,4 +1,4 @@ -export { RebalanceStepRow, type StepStatus } from './RebalanceStepRow'; +export { RebalanceStepRow } from './RebalanceStepRow'; export { RebalanceChainCard } from './RebalanceChainCard'; export { groupStepsForDisplay, type StepState } from './groupSteps'; export { @@ -14,9 +14,4 @@ export { addLocalHistoryEdges, getLocalCandidatesForDestination, } from './routing'; -export { - releaseTrustWindow, - formatStrandedRoutingDetail, - type StrandedMint, - type ReleaseTrustWindowResult, -} from './releaseTrustWindow'; +export { releaseTrustWindow, formatStrandedRoutingDetail } from './releaseTrustWindow'; diff --git a/features/mint/components/rebalance/rebalancePlanner.ts b/features/mint/components/rebalance/rebalancePlanner.ts index 99dff1a39..5ab5f190b 100644 --- a/features/mint/components/rebalance/rebalancePlanner.ts +++ b/features/mint/components/rebalance/rebalancePlanner.ts @@ -68,13 +68,10 @@ function computeTargetBalances( if (total === 0) { // No balance to distribute - return mintUrls.reduce( - (acc, url) => { - acc[url] = 0; - return acc; - }, - {} as Record - ); + return mintUrls.reduce>((acc, url) => { + acc[url] = 0; + return acc; + }, {}); } // Calculate raw targets and track remainders for rounding diff --git a/features/mint/components/rebalance/releaseTrustWindow.ts b/features/mint/components/rebalance/releaseTrustWindow.ts index 697c3095b..0e392c6fd 100644 --- a/features/mint/components/rebalance/releaseTrustWindow.ts +++ b/features/mint/components/rebalance/releaseTrustWindow.ts @@ -1,13 +1,7 @@ import { extractDomain } from '@/shared/lib/url'; import { amountToNumber, type AmountValue } from '@/shared/lib/cashu/amount'; import { cashuLog } from '@/shared/lib/logger'; - -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; type BalanceMap = Record; @@ -16,12 +10,12 @@ interface ManagerLike { mint: { untrustMint: (url: string) => Promise }; } -export interface StrandedMint { +interface StrandedMint { url: string; balance: number; } -export interface ReleaseTrustWindowResult { +interface ReleaseTrustWindowResult { stranded: StrandedMint[]; untrustErrors: { url: string; error: unknown }[]; } @@ -51,12 +45,12 @@ export async function releaseTrustWindow( temporarilyTrustedCount: temporarilyTrusted.length, mintDomains: temporarilyTrusted.map(extractDomain), }); - const balances = await manager.wallet.balances.byMint().catch((error) => { + const balances = await manager.wallet.balances.byMint().catch((error): BalanceMap => { cashuLog.warn('mint.rebalance.trust_window.balance_failed', { temporarilyTrustedCount: temporarilyTrusted.length, errorName: error instanceof Error ? error.name : typeof error, }); - return {} as BalanceMap; + return {}; }); const stranded: StrandedMint[] = []; const untrustErrors: { url: string; error: unknown }[] = []; diff --git a/features/mint/hooks/useAuditedMint.ts b/features/mint/hooks/useAuditedMint.ts deleted file mode 100644 index 6d6713af2..000000000 --- a/features/mint/hooks/useAuditedMint.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useState, useEffect } from 'react'; - -// TODO: re-export GetInfoResponse (or MintInfo alias) from coco-cashu-core -import type { GetInfoResponse } from '@cashu/cashu-ts'; - -import { auditMint, fetchMintInfo } from '@/shared/lib/apiClient'; -import { cashuLog } from '@/shared/lib/logger'; -import { useAuditMintStore } from '@/shared/stores/global/auditMintStore'; -import { transformAuditData, type AuditInfo } from '../lib/auditInfo'; - -interface UseAuditedMintResult { - auditInfo?: AuditInfo; - mintInfo?: GetInfoResponse; - loading: boolean; - error?: string; -} - -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - -export const useAuditedMint = (mintUrl?: string): UseAuditedMintResult => { - const [auditInfo, setAuditInfo] = useState(); - const [mintInfo, setMintInfo] = useState(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); - - const getCached = useAuditMintStore((state) => state.getCached); - const setCached = useAuditMintStore((state) => state.setCached); - const isStale = useAuditMintStore((state) => state.isStale); - - useEffect(() => { - if (!mintUrl) { - setAuditInfo(undefined); - setMintInfo(undefined); - setLoading(false); - setError(undefined); - return; - } - - const controller = new AbortController(); - const loadMint = async () => { - try { - setLoading(true); - setError(undefined); - - // Check cache first - const cached = getCached(mintUrl); - const stale = isStale(mintUrl); - - if (cached && !stale) { - cashuLog.debug('mint.audit.cache.hit', { ...mintUrlLogFields(mintUrl) }); - setAuditInfo(transformAuditData(cached.auditData)); - setMintInfo(cached.mintInfo); - setLoading(false); - return; - } - cashuLog.debug('mint.audit.cache.miss', { ...mintUrlLogFields(mintUrl) }); - - // Fetch audit data directly from API - cashuLog.info('mint.audit.fetch', { ...mintUrlLogFields(mintUrl) }); - const auditResult = await auditMint({ mintUrl, signal: controller.signal }); - if (controller.signal.aborted) return; - if (auditResult.isOk()) { - setAuditInfo(transformAuditData(auditResult.value)); - } else { - setAuditInfo(undefined); - } - - // Fetch mint info - const mintInfoResult = await fetchMintInfo(mintUrl, { signal: controller.signal }); - if (controller.signal.aborted) return; - if (mintInfoResult.isOk()) { - const mintInfoData = mintInfoResult.value; - setMintInfo(mintInfoData); - - // Cache both audit data and mint info if both succeeded - if (auditResult.isOk()) { - cashuLog.info('mint.audit.complete', { - ...mintUrlLogFields(mintUrl), - score: transformAuditData(auditResult.value).score, - }); - setCached(mintUrl, auditResult.value, mintInfoData); - } - } else { - setMintInfo(undefined); - } - } catch (err) { - if (controller.signal.aborted) return; - cashuLog.error('mint.audit.error', { - ...mintUrlLogFields(mintUrl), - error: err instanceof Error ? err : new Error(String(err)), - }); - setError('Failed to load mint information'); - setAuditInfo(undefined); - setMintInfo(undefined); - } finally { - if (!controller.signal.aborted) setLoading(false); - } - }; - - void loadMint(); - return () => controller.abort(); - }, [mintUrl, getCached, setCached, isStale]); - - return { auditInfo, mintInfo, loading, error }; -}; diff --git a/features/mint/hooks/useAuditedMints.ts b/features/mint/hooks/useAuditedMints.ts deleted file mode 100644 index 2f9865232..000000000 --- a/features/mint/hooks/useAuditedMints.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; - -import { auditMint, fetchMintInfo } from '@/shared/lib/apiClient'; -import type { GetInfoResponse } from '@cashu/cashu-ts'; -import { normalizeMintUrlKey } from '@/shared/lib/url'; -import { useAuditMintStore } from '@/shared/stores/global/auditMintStore'; -import { cashuLog } from '@/shared/lib/logger'; -import { transformAuditData, type AuditInfo } from '../lib/auditInfo'; - -export interface AuditedMintData { - auditInfo?: AuditInfo; - mintInfo?: GetInfoResponse; - loading: boolean; - error?: string; -} - -interface UseAuditedMintsResult { - data: Record; - loading: boolean; - getAuditData: (mintUrl: string) => AuditedMintData; -} - -const CONCURRENT_LIMIT = 5; - -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - -/** - * Batch hook for loading audit data for multiple mints at once. - * - * - Loads from cache first for instant display - * - Fetches missing/stale data in batches with concurrency limit - * - Deduplicates in-flight requests - * - Caches results in the store even if component unmounts mid-fetch - */ -export const useAuditedMints = (mintUrls: string[]): UseAuditedMintsResult => { - const [data, setData] = useState>({}); - const [loading, setLoading] = useState(true); - const fetchingRef = useRef(new Set()); - const mountedRef = useRef(true); - - const getCached = useAuditMintStore((state) => state.getCached); - const setCached = useAuditMintStore((state) => state.setCached); - const isStale = useAuditMintStore((state) => state.isStale); - - const mintUrlsKey = useMemo(() => mintUrls.join(','), [mintUrls]); - - useEffect(() => { - if (mintUrls.length === 0) { - setData({}); - setLoading(false); - return; - } - - const initialData: Record = {}; - const urlsToFetch: { normalized: string; original: string }[] = []; - - let cacheHits = 0; - mintUrls.forEach((url) => { - const normalized = normalizeMintUrlKey(url); - const cached = getCached(normalized); - const stale = isStale(normalized); - - if (cached && !stale) { - cacheHits++; - initialData[normalized] = { - auditInfo: transformAuditData(cached.auditData), - mintInfo: cached.mintInfo, - loading: false, - }; - } else { - initialData[normalized] = { loading: true }; - urlsToFetch.push({ normalized, original: url }); - } - }); - cashuLog.debug('mint.audit.batch.init', { - total: mintUrls.length, - cacheHits, - toFetch: urlsToFetch.length, - }); - - setData(initialData); - - if (urlsToFetch.length === 0) { - setLoading(false); - return; - } - - // One controller for this batch — aborting on cleanup releases every - // queued request whose mint hasn't been polled yet, plus whichever - // requests are mid-flight at the worker concurrency limit. - const controller = new AbortController(); - let activeCount = 0; - let queueIndex = 0; - - const fetchNext = async () => { - if (controller.signal.aborted) return; - if (queueIndex >= urlsToFetch.length) { - if (activeCount === 0 && mountedRef.current) { - setLoading(false); - } - return; - } - - const { normalized, original } = urlsToFetch[queueIndex++]!; - - if (fetchingRef.current.has(normalized)) { - void fetchNext(); - return; - } - - fetchingRef.current.add(normalized); - activeCount++; - - try { - let auditInfo: AuditInfo | undefined; - let mintInfo: GetInfoResponse | undefined; - - const apiUrl = original.startsWith('http') ? original : `https://${original}`; - - const auditResult = await auditMint({ mintUrl: apiUrl, signal: controller.signal }); - if (auditResult.isOk()) { - auditInfo = transformAuditData(auditResult.value); - } - - const mintInfoResult = await fetchMintInfo(apiUrl, { signal: controller.signal }); - if (mintInfoResult.isOk()) { - mintInfo = mintInfoResult.value; - } - - if (auditResult.isOk() && mintInfo) { - setCached(normalized, auditResult.value, mintInfo); - } - - cashuLog.debug('mint.audit.fetch.success', { - ...mintUrlLogFields(normalized), - hasAudit: !!auditInfo, - hasMintInfo: !!mintInfo, - }); - - if (mountedRef.current && !controller.signal.aborted) { - setData((prev) => ({ - ...prev, - [normalized]: { auditInfo, mintInfo, loading: false }, - })); - } - } catch { - if (controller.signal.aborted) return; - cashuLog.warn('mint.audit.fetch.error', { ...mintUrlLogFields(normalized) }); - if (mountedRef.current) { - setData((prev) => ({ - ...prev, - [normalized]: { loading: false, error: 'Failed to load' }, - })); - } - } finally { - fetchingRef.current.delete(normalized); - activeCount--; - void fetchNext(); - } - }; - - for (let i = 0; i < Math.min(CONCURRENT_LIMIT, urlsToFetch.length); i++) { - void fetchNext(); - } - - return () => controller.abort(); - }, [mintUrlsKey, mintUrls, getCached, setCached, isStale]); - - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - - const getAuditData = useCallback( - (mintUrl: string): AuditedMintData => { - const normalized = normalizeMintUrlKey(mintUrl); - return data[normalized] || { loading: true }; - }, - [data] - ); - - return { data, loading, getAuditData }; -}; diff --git a/features/mint/hooks/useDebouncedMintValidation.ts b/features/mint/hooks/useDebouncedMintValidation.ts index 8e12c9169..495f34dae 100644 --- a/features/mint/hooks/useDebouncedMintValidation.ts +++ b/features/mint/hooks/useDebouncedMintValidation.ts @@ -1,10 +1,11 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useRef } from 'react'; import type { GetInfoResponse } from '@cashu/cashu-ts'; import { fetchMintInfo } from '@/shared/lib/apiClient'; import { normalizeUrlForApi } from '@/shared/lib/url'; import { log } from '@/shared/lib/logger'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; interface ValidationState { isValid: boolean | null; @@ -12,13 +13,6 @@ interface ValidationState { error: string | null; } -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - /** * Debounced mint URL validation via fetchMintInfo. * Marks a mint as valid only when its /v1/info endpoint responds successfully. @@ -37,7 +31,7 @@ export function useDebouncedMintValidation(debounceMs: number = 800) { // can't overwrite the result for the current URL. const inFlightRef = useRef(null); - const validateUrl = useCallback(async (mintUrl: string) => { + const validateUrl = async (mintUrl: string) => { if (!mintUrl.trim()) { setValidationState({ isValid: null, isLoading: false, error: null }); setMintInfo(null); @@ -86,32 +80,29 @@ export function useDebouncedMintValidation(debounceMs: number = 800) { }); setMintInfo(mintInfoResult.value); } - }, []); + }; - const debouncedValidate = useCallback( - (mintUrl: string) => { - setUrl(mintUrl); + const debouncedValidate = (mintUrl: string) => { + setUrl(mintUrl); - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } - if (!mintUrl.trim()) { - inFlightRef.current?.abort(); - inFlightRef.current = null; - setValidationState({ isValid: null, isLoading: false, error: null }); - setMintInfo(null); - return; - } + if (!mintUrl.trim()) { + inFlightRef.current?.abort(); + inFlightRef.current = null; + setValidationState({ isValid: null, isLoading: false, error: null }); + setMintInfo(null); + return; + } - setValidationState((prev) => ({ ...prev, isLoading: true, error: null })); + setValidationState((prev) => ({ ...prev, isLoading: true, error: null })); - debounceTimeoutRef.current = setTimeout(() => { - void validateUrl(mintUrl); - }, debounceMs); - }, - [validateUrl, debounceMs] - ); + debounceTimeoutRef.current = setTimeout(() => { + void validateUrl(mintUrl); + }, debounceMs); + }; useEffect(() => { return () => { @@ -122,7 +113,7 @@ export function useDebouncedMintValidation(debounceMs: number = 800) { }; }, []); - const reset = useCallback(() => { + const reset = () => { setUrl(''); setValidationState({ isValid: null, isLoading: false, error: null }); setMintInfo(null); @@ -131,7 +122,7 @@ export function useDebouncedMintValidation(debounceMs: number = 800) { } inFlightRef.current?.abort(); inFlightRef.current = null; - }, []); + }; return { url, diff --git a/features/mint/hooks/useMintCatalog.ts b/features/mint/hooks/useMintCatalog.ts index 6d0a38dee..22d44006d 100644 --- a/features/mint/hooks/useMintCatalog.ts +++ b/features/mint/hooks/useMintCatalog.ts @@ -9,7 +9,7 @@ * profile still resolves. */ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useManager } from '@cashu/coco-react'; import type { MintCatalogEntry } from '@sovranbitcoin/colada'; @@ -25,7 +25,7 @@ export function useMintCatalog(mintUrls: string[]): Record [...mintUrls].sort().join('|'), [mintUrls]); + const key = [...mintUrls].sort().join('|'); useEffect(() => { if (mintUrls.length === 0 || !manager) { diff --git a/features/mint/hooks/useMintManagement.ts b/features/mint/hooks/useMintManagement.ts index b00a32b2a..096ee3ad2 100644 --- a/features/mint/hooks/useMintManagement.ts +++ b/features/mint/hooks/useMintManagement.ts @@ -4,6 +4,7 @@ import type { Mint } from '@cashu/coco-core'; import { useManager } from '@cashu/coco-react'; import { log } from '@/shared/lib/logger'; import { getCachedMintInfo } from '@/shared/stores/global/mintInfoCache'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; // Module-level in-flight dedupe. Multiple components that use this hook // (ContactsScreen, settings recovery, mint screens) each kick off their @@ -14,13 +15,6 @@ import { getCachedMintInfo } from '@/shared/stores/global/mintInfoCache'; // reads from the shared result. let inflightLoad: Promise | null = null; -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - /** * Subscribes to the trusted-mints list and re-exposes `getMintInfo` behind * loading state. Stays reactive to coco's mint:* events so consumers see @@ -68,21 +62,23 @@ export function useMintManagement() { [manager] ); + // Kept as a useCallback (NOT removed by the React-Compiler memo sweep): + // `getMintInfo` is consumed as a useEffect dependency in CONSUMER hooks + // (useMintInfo.ts, useMintContacts.ts). A fresh identity each render would + // re-fire those effects every render, re-polling the SWR cache. This is the + // effect-dependency escape hatch React's compiler guidance says to keep. const getMintInfo = useCallback( async (mintUrl: string) => { try { - log.debug('mint.info.fetch.start', { ...mintUrlLogFields(mintUrl) }); // SWR through `mintInfoCache`: cached fresh resolves instantly, stale // resolves with the prior value and refreshes in the background, miss // awaits coco's `getMintInfo` (which itself blocks on HTTP only when - // its own 5-minute window has expired). - const info = await getCachedMintInfo((url) => manager.mint.getMintInfo(url), mintUrl); - log.debug('mint.info.fetch.success', { - ...mintUrlLogFields(mintUrl), - hasName: typeof info.name === 'string' && info.name.length > 0, - hasNuts: !!info.nuts, - }); - return info; + // its own 5-minute window has expired). The cache layer already logs + // the hit/miss/fetch outcome (`store.mint_info.*`); we don't re-log + // start/success per call here — across many mint rows it was ~31% of + // all log volume (a fresh cache read resolves instantly yet still + // logged "success"). Only a genuine failure is worth recording. + return await getCachedMintInfo((url) => manager.mint.getMintInfo(url), mintUrl); } catch (err) { const error = err instanceof Error ? err : new Error('Failed to get mint info'); log.error('mint.info.fetch.error', { ...mintUrlLogFields(mintUrl), error }); diff --git a/features/mint/hooks/useMintProfiles.ts b/features/mint/hooks/useMintProfiles.ts index b994739db..abf164cc6 100644 --- a/features/mint/hooks/useMintProfiles.ts +++ b/features/mint/hooks/useMintProfiles.ts @@ -16,19 +16,13 @@ import { import { useMintProfileStore } from '@/shared/stores/global/mintProfileStore'; import { normalizeMintUrlKey } from '@/shared/lib/url'; import { cashuLog } from '@/shared/lib/logger'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; interface MintWithInfo { url: string; mintInfo?: MintInfoForNostr; } -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - /** * For a list of mints with their NUT-06 info, fetch Nostr profiles for any * mint operator that has a Nostr pubkey in their contacts. Populates diff --git a/features/mint/hooks/useMintRebalanceOrchestrator.ts b/features/mint/hooks/useMintRebalanceOrchestrator.ts index 9017bfd91..56e741c24 100644 --- a/features/mint/hooks/useMintRebalanceOrchestrator.ts +++ b/features/mint/hooks/useMintRebalanceOrchestrator.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { GetInfoResponse, Proof } from '@cashu/cashu-ts'; import { useManager } from '@cashu/coco-react'; @@ -7,6 +7,7 @@ import { getReadyProofs, getWallet } from '@/shared/lib/cashu/managerInternals'; import { amountToNumber, toSafeSatAmount } from '@/shared/lib/cashu/amount'; import { prepareBolt11MeltQuote, prepareBolt11MintQuote } from '@/shared/lib/cashu/cocoOperations'; import { auditMint, type AuditMintResponse } from '@/shared/lib/apiClient'; +import { computeRouteSuggestion as computeRouteSuggestionPure } from '@/features/mint/lib/rebalanceRouting'; import { extractDomain } from '@/shared/lib/url'; import { mintLocalId } from '@/shared/lib/id'; import { cashuLog } from '@/shared/lib/logger'; @@ -20,9 +21,6 @@ import { useSwapStatusStore } from '@/shared/stores/runtime/swapStatusStore'; import type { MiddlemanRoutingSettings } from '@/shared/stores/global/settingsStore'; import { MIN_FEE_RESERVE } from '@/features/mint/components/rebalance'; import { - buildSwapGraph, - pickIntermediaryPath, - addLocalHistoryEdges, getLocalCandidatesForDestination, releaseTrustWindow, formatStrandedRoutingDetail, @@ -43,21 +41,15 @@ import { normalizeRebalanceTransferError, resetFailedStepStates, } from '@/features/mint/lib/rebalanceRunState'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - -export type RebalanceRunStatus = 'idle' | 'running' | 'finished' | 'cancelled'; +type RebalanceRunStatus = 'idle' | 'running' | 'finished' | 'cancelled'; -export interface MintLite { +interface MintLite { mintUrl: string; } -export interface UseMintRebalanceOrchestratorArgs { +interface UseMintRebalanceOrchestratorArgs { unit: string; computedPlan: RebalancePlan; trustedMints: MintLite[]; @@ -66,7 +58,7 @@ export interface UseMintRebalanceOrchestratorArgs { minTransferThreshold: number; } -export interface UseMintRebalanceOrchestratorResult { +interface UseMintRebalanceOrchestratorResult { plan: RebalancePlan; runPlan: RebalancePlan | null; stepStates: Record; @@ -114,7 +106,7 @@ export function useMintRebalanceOrchestrator({ cashuLog.debug('mint.rebalance.step', entry); }, []); - const plan = useMemo(() => runPlan ?? computedPlan, [runPlan, computedPlan]); + const plan = runPlan ?? computedPlan; useEffect(() => { stepStatesRef.current = stepStates; @@ -147,53 +139,16 @@ export function useMintRebalanceOrchestrator({ const computeRouteSuggestion = useCallback( async (fromMintUrl: string, toMintUrl: string) => { if (!runPlan) return null; - - // Start with mints in the run plan (fast), then optionally widen to a small set of trusted mints. - // This improves the chance of finding an intermediary without exploding API calls. - const planMints = runPlan.steps.flatMap((s) => [s.fromMintUrl, s.toMintUrl]); - const trustedUrls = trustedMints.map((m) => m.mintUrl); - - // Also include mints from local swap history that have reached the destination - const allGroups = Object.values(useSwapTransactionsStore.getState().groups); - const localCandidateMints = getLocalCandidatesForDestination( - allGroups, + return computeRouteSuggestionPure({ + fromMintUrl, toMintUrl, - fromMintUrl - ); - - /** - * Keep this bounded: - * - Each mint candidate can require an auditor call. - * - This runs after a failure, so we want a quick suggestion, not a full graph crawl. - */ - const candidates = Array.from( - new Set([...planMints, ...trustedUrls, ...localCandidateMints, fromMintUrl, toMintUrl]) - ).slice(0, 12); - - const audits: AuditMintResponse[] = []; - for (const url of candidates) { - const a = await fetchAudit(url); - if (a) audits.push(a); - } - - const graph = buildSwapGraph(audits); - - // Merge our own local swap history into the graph so personally observed - // routes (e.g. "minibits → sovran worked last week") supplement auditor data - addLocalHistoryEdges(graph, allGroups); - - const trustedMintUrls = new Set(trustedUrls); - const result = pickIntermediaryPath({ - from: fromMintUrl, - to: toMintUrl, - graph, - settings: middlemanRouting, - trustedMintUrls, + planMintUrls: runPlan.steps.flatMap((s) => [s.fromMintUrl, s.toMintUrl]), + trustedMintUrls: trustedMints.map((m) => m.mintUrl), + mintInfoMap, + middlemanRouting, + groups: Object.values(useSwapTransactionsStore.getState().groups), + fetchAudit, }); - if (!result.path) return null; - - const pathNames = result.path.map((url) => mintInfoMap[url]?.name || url); - return { path: result.path, pathNames }; }, [runPlan, fetchAudit, mintInfoMap, trustedMints, middlemanRouting] ); @@ -1265,7 +1220,7 @@ export function useMintRebalanceOrchestrator({ [executeStep] ); - const handleStart = useCallback(() => { + const handleStart = () => { // Per-instance ref-based guard (this screen mount). if (isRunningRef.current) return; if (runStatus === 'running') return; @@ -1318,44 +1273,38 @@ export function useMintRebalanceOrchestrator({ // Kick off the runner (do not await; keep UI responsive) // Use the snapshot steps (stable), not any live recomputed list. void runStepsSequentially(snapshot.steps, runId); - }, [computedPlan, runStatus, runStepsSequentially, unit]); - - const handleRetry = useCallback( - async (step: TransferStep) => { - // Use ref-based guard to prevent race conditions - if (isRunningRef.current) return; - if (runStatus === 'running') return; - - isRunningRef.current = true; - abortRef.current = false; - const runId = (runIdRef.current += 1); - setRunStatus('running'); - setCurrentStepId(step.id); - updateStepState(step.id, { - status: 'pending', - errorMessage: undefined, - routeSuggestion: undefined, - }); - try { - await executeStep(step, runId); - } finally { - isRunningRef.current = false; - } - setCurrentStepId(null); - setRunStatus('finished'); - }, - [executeStep, updateStepState, runStatus] - ); + }; - const handleSkip = useCallback( - (step: TransferStep) => { - if (runStatus === 'running') return; - updateStepState(step.id, { status: 'skipped' }); - }, - [updateStepState, runStatus] - ); + const handleRetry = async (step: TransferStep) => { + // Use ref-based guard to prevent race conditions + if (isRunningRef.current) return; + if (runStatus === 'running') return; + + isRunningRef.current = true; + abortRef.current = false; + const runId = (runIdRef.current += 1); + setRunStatus('running'); + setCurrentStepId(step.id); + updateStepState(step.id, { + status: 'pending', + errorMessage: undefined, + routeSuggestion: undefined, + }); + try { + await executeStep(step, runId); + } finally { + isRunningRef.current = false; + } + setCurrentStepId(null); + setRunStatus('finished'); + }; + + const handleSkip = (step: TransferStep) => { + if (runStatus === 'running') return; + updateStepState(step.id, { status: 'skipped' }); + }; - const handleRetryFailed = useCallback(async () => { + const handleRetryFailed = async () => { if (!runPlan) return; // Use ref-based guard to prevent race conditions if (isRunningRef.current) return; @@ -1370,88 +1319,85 @@ export function useMintRebalanceOrchestrator({ setStepStates((prev) => resetFailedStepStates(prev, runPlan.steps)); await runStepsSequentially(runPlan.steps, runId); - }, [runPlan, runStatus, runStepsSequentially]); + }; - const handleRouteThrough = useCallback( - async (step: TransferStep) => { - if (!runPlan) return; - // Use ref-based guard to prevent race conditions - if (isRunningRef.current) return; - if (runStatus === 'running') return; + const handleRouteThrough = async (step: TransferStep) => { + if (!runPlan) return; + // Use ref-based guard to prevent race conditions + if (isRunningRef.current) return; + if (runStatus === 'running') return; - const suggestion = stepStatesRef.current[step.id]?.routeSuggestion; - if (!suggestion || suggestion.status !== 'found' || !suggestion.path) return; + const suggestion = stepStatesRef.current[step.id]?.routeSuggestion; + if (suggestion?.status !== 'found' || !suggestion.path) return; - const chainPath = suggestion.path; - if (chainPath.length < 3) return; // Need at least A → via → B + const chainPath = suggestion.path; + if (chainPath.length < 3) return; // Need at least A → via → B - isRunningRef.current = true; + isRunningRef.current = true; - // ── Temporary trust for untrusted intermediary mints ── - // In `allow_untrusted` mode, coco requires mints to be trusted for wallet - // operations. We temporarily trust any intermediary mint the user hasn't - // explicitly trusted, then untrust it after the chain finishes. - const trustedUrls = new Set(trustedMints.map((m) => m.mintUrl)); - const intermediaries = chainPath.slice(1, -1); - const temporarilyTrusted: string[] = []; + // ── Temporary trust for untrusted intermediary mints ── + // In `allow_untrusted` mode, coco requires mints to be trusted for wallet + // operations. We temporarily trust any intermediary mint the user hasn't + // explicitly trusted, then untrust it after the chain finishes. + const trustedUrls = new Set(trustedMints.map((m) => m.mintUrl)); + const intermediaries = chainPath.slice(1, -1); + const temporarilyTrusted: string[] = []; - for (const url of intermediaries) { - if (!trustedUrls.has(url)) { - try { - await manager.mint.addMint(url, { trusted: true }); - temporarilyTrusted.push(url); - } catch (err) { - cashuLog.warn('mint.rebalance.trust_failed', { ...mintUrlLogFields(url), error: err }); - } + for (const url of intermediaries) { + if (!trustedUrls.has(url)) { + try { + await manager.mint.addMint(url, { trusted: true }); + temporarilyTrusted.push(url); + } catch (err) { + cashuLog.warn('mint.rebalance.trust_failed', { ...mintUrlLogFields(url), error: err }); } } + } - const afterId = step.id; - const chainId = mintLocalId('chain'); + const afterId = step.id; + const chainId = mintLocalId('chain'); - const rerouteSteps = createChainSteps({ - baseStep: step, - chainPath, - chainId, - idPrefix: `reroute-${afterId}`, - makeId: mintLocalId, - }); + const rerouteSteps = createChainSteps({ + baseStep: step, + chainPath, + chainId, + idPrefix: `reroute-${afterId}`, + makeId: mintLocalId, + }); - const nextSteps = insertStepsAfter(runPlan.steps, afterId, rerouteSteps); - setRunPlan((prev) => (prev ? { ...prev, steps: nextSteps } : prev)); - - /** - * We keep the original step visible (marked skipped) so the user can see what happened. - * New chain steps are inserted immediately after it. - * - * Important: update `stepStatesRef` immediately so the runner (which reads the ref) sees the new steps. - */ - const nextStates = applyInsertedChainStates(stepStatesRef.current, afterId, rerouteSteps); - stepStatesRef.current = nextStates; - setStepStates(nextStates); - - // Immediately execute pending steps (Start once behavior) - abortRef.current = false; - const runId = (runIdRef.current += 1); - setRunStatus('running'); - setCurrentStepId(null); + const nextSteps = insertStepsAfter(runPlan.steps, afterId, rerouteSteps); + setRunPlan((prev) => (prev ? { ...prev, steps: nextSteps } : prev)); - try { - await runStepsSequentially(nextSteps, runId); - } finally { - // Always revoke temporary trust we acquired for intermediaries — the - // trust window must not outlive the operation. If funds remain on an - // intermediary after a mid-chain failure, surface that to the user via - // the step's routingDetail and a louder log; the mint URL stays in the - // wallet (untrust does not delete proofs), and the user can re-trust - // manually to recover. - await releaseTemporaryTrust(temporarilyTrusted, rerouteSteps[rerouteSteps.length - 1]?.id); - } - }, - [runPlan, runStatus, runStepsSequentially, trustedMints, manager, releaseTemporaryTrust] - ); + /** + * We keep the original step visible (marked skipped) so the user can see what happened. + * New chain steps are inserted immediately after it. + * + * Important: update `stepStatesRef` immediately so the runner (which reads the ref) sees the new steps. + */ + const nextStates = applyInsertedChainStates(stepStatesRef.current, afterId, rerouteSteps); + stepStatesRef.current = nextStates; + setStepStates(nextStates); + + // Immediately execute pending steps (Start once behavior) + abortRef.current = false; + const runId = (runIdRef.current += 1); + setRunStatus('running'); + setCurrentStepId(null); + + try { + await runStepsSequentially(nextSteps, runId); + } finally { + // Always revoke temporary trust we acquired for intermediaries — the + // trust window must not outlive the operation. If funds remain on an + // intermediary after a mid-chain failure, surface that to the user via + // the step's routingDetail and a louder log; the mint URL stays in the + // wallet (untrust does not delete proofs), and the user can re-trust + // manually to recover. + await releaseTemporaryTrust(temporarilyTrusted, rerouteSteps[rerouteSteps.length - 1]?.id); + } + }; - const handleCancelRun = useCallback(() => { + const handleCancelRun = () => { // Best-effort abort: we can't cancel an in-flight melt, but we can stop scheduling new steps. abortRef.current = true; runIdRef.current += 1; @@ -1467,7 +1413,7 @@ export function useMintRebalanceOrchestrator({ // own — the runner's tail at runStepsSequentially returns early on // abortRef so its complete()/fail() never fires either. useSwapStatusStore.getState().cancel(); - }, []); + }; return { plan, diff --git a/features/mint/hooks/useNostrDiscoveredMints.ts b/features/mint/hooks/useNostrDiscoveredMints.ts deleted file mode 100644 index ccadbc674..000000000 --- a/features/mint/hooks/useNostrDiscoveredMints.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; - -import { useSubscribe } from '@nostr-dev-kit/ndk-mobile'; -import type { GetInfoResponse } from '@cashu/cashu-ts'; - -import { fetchMintInfo } from '@/shared/lib/apiClient'; -import { cashuLog } from '@/shared/lib/logger'; -import { - isCashuRecommendationEvent, - extractMintUrlFromEvent, - parseRecommendation, - type NostrEvent, -} from '@/shared/lib/nostr/client'; -import { normalizeMintUrlKey, normalizeUrlForApi } from '@/shared/lib/url'; - -import type { MintRecommendation } from '@/shared/lib/apiClient'; -import { useMintManagement } from './useMintManagement'; - -interface NostrDiscoveredMintData { - url: string; - score: number; - recommendations: MintRecommendation[]; - mintInfo: GetInfoResponse | null; -} - -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - -interface UseNostrDiscoveredMintsResult { - mints: NostrDiscoveredMintData[]; - loading: boolean; - error: string | null; - retry: () => void; -} - -/** - * Appends a mint to state if not already present (by normalized URL). - * Shared by the parallel per-URL fetch callbacks to avoid race-condition duplicates. - */ -function appendMintIfNew( - setter: React.Dispatch>, - result: NostrDiscoveredMintData -) { - setter((prev) => { - const existingUrls = new Set(prev.map((m) => m.url)); - if (existingUrls.has(result.url)) return prev; - return [...prev, result]; - }); -} - -/** Calculates average score from a list of recommendations */ -function averageScore(recommendations: MintRecommendation[]): number { - const sum = recommendations.reduce((acc, r) => acc + r.score, 0); - return Number((sum / recommendations.length).toFixed(2)); -} - -/** - * Discovers mints via Nostr kind 38000 recommendation events. - * Uses nostrClient helpers for event parsing and normalizeMintUrlKey for URL dedup. - */ -export const useNostrDiscoveredMints = (): UseNostrDiscoveredMintsResult => { - const [mints, setMints] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [retryCount, setRetryCount] = useState(0); - const processedUrls = useRef(new Set()); - - const { mints: knownMints } = useMintManagement(); - - const filters = useMemo(() => [{ kinds: [38000], limit: 100 }], []); - const { events, eose } = useSubscribe({ filters }); - - useEffect(() => { - if (eose) setLoading(false); - }, [eose]); - - useEffect(() => { - if (!events || events.length === 0) { - if (eose) setLoading(false); - return; - } - - const controller = new AbortController(); - try { - setError(null); - - const knownMintUrls = new Set(knownMints.map((mint) => normalizeMintUrlKey(mint.mintUrl))); - - // Map normalized key → { fullUrl, recommendations } - const recommendationsByKey = new Map< - string, - { fullUrl: string; recs: MintRecommendation[] } - >(); - - events.forEach((event) => { - if (!isCashuRecommendationEvent(event as NostrEvent)) return; - const mintUrl = extractMintUrlFromEvent(event as NostrEvent); - if (!mintUrl) return; - - const key = normalizeMintUrlKey(mintUrl); - if (knownMintUrls.has(key)) return; - - const recommendation = parseRecommendation(event.content); - if (!recommendation) return; - - // NDKEvent.created_at is optional; MintRecommendation requires it. - // Drop events without one rather than coerce to 0/Date.now() — - // they'd misorder downstream sort-by-recency. - if (typeof event.created_at !== 'number') return; - - const entry = recommendationsByKey.get(key) ?? { - fullUrl: normalizeUrlForApi(mintUrl), - recs: [], - }; - entry.recs.push({ - score: recommendation.score, - comment: recommendation.comment, - pubkey: event.pubkey, - eventId: event.id, - created_at: event.created_at, - }); - recommendationsByKey.set(key, entry); - }); - - const urlsToProcess: { key: string; fullUrl: string; recs: MintRecommendation[] }[] = []; - recommendationsByKey.forEach((entry, key) => { - if (processedUrls.current.has(key)) return; - processedUrls.current.add(key); - urlsToProcess.push({ key, ...entry }); - }); - - if (urlsToProcess.length === 0) return; - - cashuLog.info('mint.nostr.discovered', { - newUrls: urlsToProcess.length, - totalProcessed: processedUrls.current.size, - }); - urlsToProcess.forEach(async ({ fullUrl: url, recs: recommendations }) => { - const score = averageScore(recommendations); - - try { - const mintInfoResult = await fetchMintInfo(url, { signal: controller.signal }); - if (controller.signal.aborted) return; - const info = mintInfoResult.isOk() ? mintInfoResult.value : null; - cashuLog.debug('mint.nostr.info.resolved', { - ...mintUrlLogFields(url), - hasInfo: !!info, - hasIcon: !!info?.icon_url, - name: info?.name, - }); - appendMintIfNew(setMints, { - url, - score, - recommendations, - mintInfo: info, - }); - } catch (err) { - if (controller.signal.aborted) return; - cashuLog.warn('mint.nostr.info.error', { - ...mintUrlLogFields(url), - error: err instanceof Error ? err : new Error(String(err)), - }); - appendMintIfNew(setMints, { url, score, recommendations, mintInfo: null }); - } - }); - } catch (err) { - cashuLog.error('mint.nostr.error', { - error: err instanceof Error ? err : new Error(String(err)), - }); - setError('Failed to process mint recommendations. Please try again.'); - } - return () => controller.abort(); - }, [events, knownMints, eose]); - - useEffect(() => { - if (retryCount > 0) { - cashuLog.info('mint.nostr.retry', { retryCount }); - setMints([]); - setError(null); - setLoading(true); - processedUrls.current.clear(); - } - }, [retryCount]); - - const retry = () => setRetryCount((prev) => prev + 1); - - return { mints, loading, error, retry }; -}; diff --git a/features/mint/hooks/useSovranDiscoveredMints.ts b/features/mint/hooks/useSovranDiscoveredMints.ts deleted file mode 100644 index 635eeb9cb..000000000 --- a/features/mint/hooks/useSovranDiscoveredMints.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; - -import type { GetInfoResponse } from '@cashu/cashu-ts'; - -import { fetchJson, fetchMintInfo } from '@/shared/lib/apiClient'; -import { cashuLog } from '@/shared/lib/logger'; -import { normalizeMintUrlKey, normalizeUrlForApi } from '@/shared/lib/url'; -import { MintListResponse, parseWith } from '@sovranbitcoin/schemas'; - -import { useMintManagement } from './useMintManagement'; - -const parseMintList = parseWith(MintListResponse, 'cashu/mints'); - -interface SovranDiscoveredMintData { - url: string; - score: number; - recommendations: []; - mintInfo: GetInfoResponse | null; -} - -interface UseSovranDiscoveredMintsResult { - mints: SovranDiscoveredMintData[]; - loading: boolean; - error: string | null; - retry: () => void; -} - -const SOVRAN_MINTS_API_URL = 'https://api.sovran.money/api/cashu/mints'; - -const CONCURRENT_LIMIT = 5; - -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - -/** - * Appends a mint to state if not already present (by normalized URL). - */ -function appendMintIfNew( - setter: React.Dispatch>, - result: SovranDiscoveredMintData -) { - setter((prev) => { - const existingUrls = new Set(prev.map((m) => normalizeMintUrlKey(m.url))); - if (existingUrls.has(normalizeMintUrlKey(result.url))) return prev; - return [...prev, result]; - }); -} - -/** - * Discovers mints via the Sovran API endpoint. - * Uses normalizeMintUrlKey for URL dedup and filters out already-known mints. - */ -export const useSovranDiscoveredMints = (): UseSovranDiscoveredMintsResult => { - const [discovered, setDiscovered] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [retryCount, setRetryCount] = useState(0); - const processedUrls = useRef(new Set()); - - const { mints: knownMints } = useMintManagement(); - - // Deps intentionally exclude `knownMints` — the Sovran catalogue is - // refetched only on mount or explicit retry. Trust changes filter the - // already-fetched list client-side via `mints` below. - useEffect(() => { - const controller = new AbortController(); - const fetchMints = async () => { - try { - setLoading(true); - setError(null); - processedUrls.current.clear(); - - const result = await fetchJson( - SOVRAN_MINTS_API_URL, - parseMintList, - 'cashu/mints', - undefined, - { signal: controller.signal } - ); - if (controller.signal.aborted) return; - if (result.isErr()) throw result.error; - const mintUrls = result.value; - cashuLog.info('mint.sovran.fetched', { mintCount: mintUrls.length }); - - if (mintUrls.length === 0) { - setDiscovered([]); - setLoading(false); - return; - } - - // Deduplicate by normalized key but keep full URLs for fetching. - const seenKeys = new Set(); - const urlsToProcess: string[] = []; - for (const rawUrl of mintUrls) { - const key = normalizeMintUrlKey(rawUrl); - if (processedUrls.current.has(key) || seenKeys.has(key)) continue; - seenKeys.add(key); - processedUrls.current.add(key); - urlsToProcess.push(normalizeUrlForApi(rawUrl)); - } - - if (urlsToProcess.length === 0) { - cashuLog.debug('mint.sovran.noop', { reason: 'all mints already processed' }); - setDiscovered([]); - setLoading(false); - return; - } - - cashuLog.info('mint.sovran.discovered', { newUrls: urlsToProcess.length }); - - // Bounded-concurrency worker pool — matches useAuditedMints.ts's - // CONCURRENT_LIMIT pattern. An unbounded Promise.all on dozens of - // mints stampedes them simultaneously and blocks the rest of the - // network behind the handshake fanout. - let queueIndex = 0; - let resolved = 0; - const total = urlsToProcess.length; - - const fetchNext = async (): Promise => { - if (controller.signal.aborted) return; - if (queueIndex >= total) return; - const url = urlsToProcess[queueIndex++]!; - const base: Omit = { - url, - score: 0, - recommendations: [], - }; - try { - const mintInfoResult = await fetchMintInfo(url, { signal: controller.signal }); - if (controller.signal.aborted) return; - const info = mintInfoResult.isOk() ? mintInfoResult.value : null; - cashuLog.debug('mint.sovran.info.resolved', { - ...mintUrlLogFields(url), - hasInfo: !!info, - hasIcon: !!info?.icon_url, - name: info?.name, - progress: `${++resolved}/${total}`, - }); - appendMintIfNew(setDiscovered, { ...base, mintInfo: info }); - } catch (err) { - if (controller.signal.aborted) return; - cashuLog.warn('mint.sovran.info.error', { - ...mintUrlLogFields(url), - error: err instanceof Error ? err : new Error(String(err)), - progress: `${++resolved}/${total}`, - }); - appendMintIfNew(setDiscovered, { ...base, mintInfo: null }); - } - await fetchNext(); - }; - - const workers = Array.from({ length: Math.min(CONCURRENT_LIMIT, total) }, () => - fetchNext() - ); - await Promise.all(workers); - if (controller.signal.aborted) return; - setLoading(false); - } catch (err) { - if (controller.signal.aborted) return; - cashuLog.error('mint.sovran.error', { - error: err instanceof Error ? err : new Error(String(err)), - }); - setError(err instanceof Error ? err.message : 'Failed to fetch mints from Sovran API'); - setLoading(false); - } - }; - - void fetchMints(); - return () => controller.abort(); - }, [retryCount]); - - // Filter against the live trusted-mint set on every render so newly - // trusted mints disappear from the discovery list without a refetch. - const mints = useMemo(() => { - const knownKeys = new Set(knownMints.map((m) => normalizeMintUrlKey(m.mintUrl))); - return discovered.filter((m) => !knownKeys.has(normalizeMintUrlKey(m.url))); - }, [discovered, knownMints]); - - const retry = () => setRetryCount((prev) => prev + 1); - - return { mints, loading, error, retry }; -}; diff --git a/features/mint/hooks/useStickyMintSelectorItems.ts b/features/mint/hooks/useStickyMintSelectorItems.ts index d9f498a5f..9df7981cd 100644 --- a/features/mint/hooks/useStickyMintSelectorItems.ts +++ b/features/mint/hooks/useStickyMintSelectorItems.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import type { MintListItem } from '@sovranbitcoin/colada'; import { cashuLog } from '@/shared/lib/logger'; @@ -71,13 +71,9 @@ export function useStickyMintSelectorItems( } }, [liveItems]); - return useMemo( - () => - resolveStickyMintSelectorItems({ - liveItems, - entryItems, - previousLiveItems: previousLiveItemsRef.current, - }), - [entryItems, liveItems] - ); + return resolveStickyMintSelectorItems({ + liveItems, + entryItems, + previousLiveItems: previousLiveItemsRef.current, + }); } diff --git a/features/mint/index.ts b/features/mint/index.ts index 012575799..5491c9a90 100644 --- a/features/mint/index.ts +++ b/features/mint/index.ts @@ -6,26 +6,5 @@ export { MintInfoScreen } from './screens/MintInfoScreen'; export { MintDistributionScreen } from './screens/MintDistributionScreen'; export { MintRebalancePlanScreen } from './screens/MintRebalancePlanScreen'; export { MintReviewsScreen } from './screens/MintReviewsScreen'; -export { MintCurrencyTabs } from './components/MintCurrencyTabs'; -export { MintDistributionItem, DistributionBar } from './components/distribution'; -export { - RebalanceStepRow, - RebalanceChainCard, - groupStepsForDisplay, - computeRebalancePlan, - isAlreadyBalanced, - MIN_FEE_RESERVE, - buildSwapGraph, - pickIntermediaryPath, - addLocalHistoryEdges, - getLocalCandidatesForDestination, -} from './components/rebalance'; -export type { StepStatus, StepState, TransferStep, RebalancePlan } from './components/rebalance'; export { useMintManagement } from './hooks/useMintManagement'; -export type { MintRecommendation } from '@/shared/lib/apiClient'; -export { useAuditedMint } from './hooks/useAuditedMint'; -export { useAuditedMints, type AuditedMintData } from './hooks/useAuditedMints'; -export { useDebouncedMintValidation } from './hooks/useDebouncedMintValidation'; -export { useNostrDiscoveredMints } from './hooks/useNostrDiscoveredMints'; -export { useSovranDiscoveredMints } from './hooks/useSovranDiscoveredMints'; export { useStickyMintSelectorItems } from './hooks/useStickyMintSelectorItems'; diff --git a/features/mint/lib/auditInfo.ts b/features/mint/lib/auditInfo.ts index 242a3b22d..a9926f7ef 100644 --- a/features/mint/lib/auditInfo.ts +++ b/features/mint/lib/auditInfo.ts @@ -1,6 +1,6 @@ import type { AuditMintResponse } from '@/shared/lib/apiClient'; -export interface AuditInfo { +interface AuditInfo { url: string; name: string; state: string; diff --git a/features/mint/lib/mintInfoContacts.ts b/features/mint/lib/mintInfoContacts.ts index 02f294689..822b117a7 100644 --- a/features/mint/lib/mintInfoContacts.ts +++ b/features/mint/lib/mintInfoContacts.ts @@ -3,12 +3,12 @@ import { nip19 } from 'nostr-tools'; import { extractMintNostrPubkey } from '@/shared/lib/nostr/extractMintNostrPubkey'; import { truncateMiddle } from '@/shared/lib/strings'; -export type MintInfoContactInput = { +type MintInfoContactInput = { method: string; info: string | { toString(): string }; }; -export type MintInfoContactRow = { +type MintInfoContactRow = { method: string; info: string; isNostr: boolean; diff --git a/features/mint/lib/rebalanceRouting.ts b/features/mint/lib/rebalanceRouting.ts new file mode 100644 index 000000000..afdcd8bc6 --- /dev/null +++ b/features/mint/lib/rebalanceRouting.ts @@ -0,0 +1,86 @@ +/** + * Reroute suggestion for a failed rebalance leg. + * + * Pure orchestration over the rebalance graph helpers: given the mints already + * in play plus local swap history, gather a bounded candidate set, build the + * trust/route graph (auditor data + personally-observed edges), and pick an + * intermediary path. The auditor lookup is an injected port (`fetchAudit`) so + * this is testable without network or store access — it runs on the + * failure-recovery path, not the live melt-execution path. + */ +import type { GetInfoResponse } from '@cashu/cashu-ts'; + +import { + addLocalHistoryEdges, + buildSwapGraph, + getLocalCandidatesForDestination, + pickIntermediaryPath, +} from '@/features/mint/components/rebalance'; +import type { AuditMintResponse } from '@/shared/lib/apiClient'; +import type { MiddlemanRoutingSettings } from '@/shared/stores/global/settingsStore'; +import type { SwapGroup } from '@/shared/stores/profile/swapTransactionsStore'; + +/** Cap on candidate mints probed per suggestion — each can cost an auditor call, + * and this runs after a failure where a quick answer beats a full graph crawl. */ +const MAX_ROUTE_CANDIDATES = 12; + +interface RouteSuggestionInput { + fromMintUrl: string; + toMintUrl: string; + /** Mints already referenced by the run plan's steps (from + to of each). */ + planMintUrls: string[]; + trustedMintUrls: string[]; + mintInfoMap: Record; + middlemanRouting: MiddlemanRoutingSettings; + /** All local swap-history groups (Object.values of the store's groups). */ + groups: SwapGroup[]; + /** Injected auditor lookup; returns null when a mint can't be audited. */ + fetchAudit: (mintUrl: string) => Promise; +} + +interface RouteSuggestion { + path: string[]; + pathNames: string[]; +} + +export async function computeRouteSuggestion( + input: RouteSuggestionInput +): Promise { + const { fromMintUrl, toMintUrl, planMintUrls, trustedMintUrls, mintInfoMap, middlemanRouting } = + input; + + // Also include mints from local swap history that have reached the destination. + const localCandidateMints = getLocalCandidatesForDestination( + input.groups, + toMintUrl, + fromMintUrl + ); + + const candidates = Array.from( + new Set([...planMintUrls, ...trustedMintUrls, ...localCandidateMints, fromMintUrl, toMintUrl]) + ).slice(0, MAX_ROUTE_CANDIDATES); + + const audits: AuditMintResponse[] = []; + for (const url of candidates) { + const a = await input.fetchAudit(url); + if (a) audits.push(a); + } + + const graph = buildSwapGraph(audits); + + // Merge our own local swap history into the graph so personally observed + // routes (e.g. "minibits → sovran worked last week") supplement auditor data. + addLocalHistoryEdges(graph, input.groups); + + const result = pickIntermediaryPath({ + from: fromMintUrl, + to: toMintUrl, + graph, + settings: middlemanRouting, + trustedMintUrls: new Set(trustedMintUrls), + }); + if (!result.path) return null; + + const pathNames = result.path.map((url) => mintInfoMap[url]?.name || url); + return { path: result.path, pathNames }; +} diff --git a/features/mint/screens/MintAddScreen.tsx b/features/mint/screens/MintAddScreen.tsx index bdbfb18a6..72573be0b 100644 --- a/features/mint/screens/MintAddScreen.tsx +++ b/features/mint/screens/MintAddScreen.tsx @@ -42,19 +42,13 @@ import opacity from 'hex-color-opacity'; import { log, cashuLog, useLifecycleLogger } from '@/shared/lib/logger'; import { getHeaderTitleWidthFromWidth } from '@/features/wallet/lib/walletHeader'; import type { GetInfoResponse } from '@cashu/cashu-ts'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; // Height constant for currency tabs (same as MintListScreen) const CURRENCY_TABS_HEIGHT = 48; // MintStatCell removed — stats now rendered inline -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - interface PseudoMint { url: string; isPseudoMint: true; @@ -243,7 +237,7 @@ const MintItem = memo(function MintItem({ loading?: boolean; }) { const mintInfo = 'mintInfo' in mint ? mint.mintInfo : undefined; - const displayName = useMemo(() => getMintDisplayName(mint.url, mintInfo), [mint.url, mintInfo]); + const displayName = getMintDisplayName(mint.url, mintInfo); // Search-result preview only: the search endpoint returns `serverStats` // (`n_mints`/`n_melts`/`n_errors`) without the per-swap array, so we can't @@ -674,55 +668,46 @@ export function MintAddScreen() { [isSearching, onCloseSearch, onOpenSearch, foreground] ); - const screenOptions = useMemo( - () => ({ - headerTransparent: true as const, - // Declares the page background (bgColor={surface} below) so the Android - // sheet header's scrim fades from the page's color, not the darker - // theme background. FlowSheetHeader reads this; iOS ignores it under a - // transparent header. - headerStyle: { backgroundColor: surface }, - headerTitle: renderHeaderTitle, - headerRight: renderHeaderRight, - }), - [renderHeaderTitle, renderHeaderRight, surface] - ); + const screenOptions = { + headerTransparent: true as const, + // Declares the page background (bgColor={surface} below) so the Android + // sheet header's scrim fades from the page's color, not the darker + // theme background. FlowSheetHeader reads this; iOS ignores it under a + // transparent header. + headerStyle: { backgroundColor: surface }, + headerTitle: renderHeaderTitle, + headerRight: renderHeaderRight, + }; // ── Sticky content & bottom ──────────────────────────────────────────── - const currencyTabs = useMemo( - () => ( - - ), - [availableCurrencies, selectedCurrency, setSelectedCurrency, scrollY] + const currencyTabs = ( + ); - const bottomButtons = useMemo( - () => ( - - router.back(), - }, - ]} - /> - - ), - [isAdding, selectedMints.size, handleSave] + const bottomButtons = ( + + router.back(), + }, + ]} + /> + ); const listHeader = useMemo( diff --git a/features/mint/screens/MintDistributionScreen.tsx b/features/mint/screens/MintDistributionScreen.tsx index 7aa72e2e9..f03bd9649 100644 --- a/features/mint/screens/MintDistributionScreen.tsx +++ b/features/mint/screens/MintDistributionScreen.tsx @@ -29,6 +29,7 @@ import { } from '@/shared/stores/profile/mintDistributionStore'; import opacity from 'hex-color-opacity'; import { log, useLifecycleLogger } from '@/shared/lib/logger'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; const DISTRIBUTION_BAR_HEIGHT = 48; const CURRENCY_TABS_HEIGHT = 48; @@ -38,13 +39,6 @@ const ParamsSchema = z.object({ unit: z.string().max(16).optional(), }); -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - export function MintDistributionScreen() { useLifecycleLogger('MintDistributionScreen'); const [foreground, background, danger] = useThemeColor([ @@ -58,7 +52,9 @@ export function MintDistributionScreen() { const { balances: liveBalanceCtx } = useBalanceContext(); const liveBalances = liveBalanceCtx.byMint; const { getMintInfo } = useMintManagement(); - const [mintInfoMap, setMintInfoMap] = useState>({}); + const [mintInfoMap, setMintInfoMap] = useState< + Record> | null> + >({}); const routeCurrency = useMemo(() => { const raw = params?.unit; @@ -127,7 +123,7 @@ export function MintDistributionScreen() { }); }, [trustedMints, selectedCurrency]); - const mintUrls = useMemo(() => mintsForCurrency.map((m) => m.mintUrl), [mintsForCurrency]); + const mintUrls = mintsForCurrency.map((m) => m.mintUrl); useEffect(() => { if (mintUrls.length > 0) { @@ -149,10 +145,10 @@ export function MintDistributionScreen() { trustedMints.map((mint) => getMintInfo(mint.mintUrl)) ); if (!mountedRef.current) return; - const infoMap: Record = {}; + const infoMap: Record> | null> = {}; trustedMints.forEach((mint, i) => { const r = settled[i]; - infoMap[mint.mintUrl] = r && r.status === 'fulfilled' ? r.value : mint.mintInfo || null; + infoMap[mint.mintUrl] = r?.status === 'fulfilled' ? r.value : mint.mintInfo || null; }); setMintInfoMap(infoMap); }; diff --git a/features/mint/screens/MintInfoScreen.tsx b/features/mint/screens/MintInfoScreen.tsx index df03560e7..497c10636 100644 --- a/features/mint/screens/MintInfoScreen.tsx +++ b/features/mint/screens/MintInfoScreen.tsx @@ -495,7 +495,7 @@ export function MintInfoScreen() { const contact = entry?.contact as | { method: string; info: import('@sovranbitcoin/colada').FormattedString }[] | undefined; - const contactRows = useMemo(() => getSortedMintInfoContacts(contact), [contact]); + const contactRows = getSortedMintInfoContacts(contact); const nostrContactPubkey = useMemo( () => getMintInfoNostrContactPubkey(contactRows), [contactRows] diff --git a/features/mint/screens/MintListScreen.tsx b/features/mint/screens/MintListScreen.tsx index 90ecf1205..3eb1a893c 100644 --- a/features/mint/screens/MintListScreen.tsx +++ b/features/mint/screens/MintListScreen.tsx @@ -31,6 +31,7 @@ import { ButtonHandler } from '@/shared/ui/composed/ButtonHandler'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { cashuLog, useLifecycleLogger } from '@/shared/lib/logger'; import { zIndex } from '@/shared/styles/tokens'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; // Inspect-button shape mirrors QRButton (rounded-square with continuous border // curve, borderRadius ≈ size × 0.18), but in a neutral surface color so it @@ -41,13 +42,6 @@ const INSPECT_BUTTON_RADIUS = Math.round(INSPECT_BUTTON_SIZE * 0.18); const CURRENCY_TABS_HEIGHT = 48; -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - interface MintListScreenProps { /** Pre-built mint rows from buildMintListItems(). Already sorted and availability-annotated. */ items: MintListItem[]; diff --git a/features/mint/screens/MintRebalancePlanScreen.tsx b/features/mint/screens/MintRebalancePlanScreen.tsx index ea9744503..5a861f6b4 100644 --- a/features/mint/screens/MintRebalancePlanScreen.tsx +++ b/features/mint/screens/MintRebalancePlanScreen.tsx @@ -91,7 +91,7 @@ export function MintRebalancePlanScreen() { }); }, [trustedMints, unit]); - const mintUrls = useMemo(() => mintsForUnit.map((m) => m.mintUrl), [mintsForUnit]); + const mintUrls = mintsForUnit.map((m) => m.mintUrl); useEffect(() => { let cancelled = false; diff --git a/features/mint/screens/MintReviewsScreen.tsx b/features/mint/screens/MintReviewsScreen.tsx index 52cfb16d9..842d79339 100644 --- a/features/mint/screens/MintReviewsScreen.tsx +++ b/features/mint/screens/MintReviewsScreen.tsx @@ -25,6 +25,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import opacity from 'hex-color-opacity'; import { cashuLog, Log, redactError, useLifecycleLogger } from '@/shared/lib/logger'; import { formatDate } from '@/shared/lib/date'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; const ParamsSchema = z.object({ mintUrl: z @@ -34,13 +35,6 @@ const ParamsSchema = z.object({ .regex(/^https?:\/\//, 'mintUrl must be http(s)'), }); -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - const StarRating = React.memo(function StarRating({ score, size = 16, diff --git a/features/nearPay/components/LightningStrike.tsx b/features/nearPay/components/LightningStrike.tsx index 82597c8da..4b0502340 100644 --- a/features/nearPay/components/LightningStrike.tsx +++ b/features/nearPay/components/LightningStrike.tsx @@ -152,23 +152,17 @@ export function LightningStrike({ }: LightningStrikeProps) { const { core, innerGlow, outerGlow, rim } = LIGHTNING_PALETTES[palette]; const frameScale = frameSize / AVATAR_FRAME_SIZE; - const canvasStyle = useMemo( - () => ({ - position: 'absolute' as const, - width: BOLT_CANVAS_SIZE * frameScale, - height: BOLT_CANVAS_SIZE * frameScale, - left: -CANVAS_OFFSET * frameScale, - top: -CANVAS_OFFSET * frameScale, - }), - [frameScale] - ); - const frameTransform = useMemo(() => [{ scale: frameScale }], [frameScale]); - const haloGradientColors = useMemo( - () => [opacity(outerGlow, op(0.45)), opacity(outerGlow, 0)], - [outerGlow] - ); + const canvasStyle = { + position: 'absolute' as const, + width: BOLT_CANVAS_SIZE * frameScale, + height: BOLT_CANVAS_SIZE * frameScale, + left: -CANVAS_OFFSET * frameScale, + top: -CANVAS_OFFSET * frameScale, + }; + const frameTransform = [{ scale: frameScale }]; + const haloGradientColors = [opacity(outerGlow, op(0.45)), opacity(outerGlow, 0)]; const variants = useMemo(() => generateStrikeVariants(seed), [seed]); - const paths = useMemo(() => variants.map(variantToSkPath), [variants]); + const paths = variants.map(variantToSkPath); const faceClip = useMemo(() => { const clip = Skia.Path.Make(); clip.addCircle(BOLT_CANVAS_CENTER, BOLT_CANVAS_CENTER, FACE_CLIP_RADIUS); diff --git a/features/nearPay/components/NutDropCelebrationCanvas.tsx b/features/nearPay/components/NutDropCelebrationCanvas.tsx index 27d38d2b6..1472877f2 100644 --- a/features/nearPay/components/NutDropCelebrationCanvas.tsx +++ b/features/nearPay/components/NutDropCelebrationCanvas.tsx @@ -93,19 +93,15 @@ export function NutDropCelebrationCanvas({ palette: LightningPalette; }) { const { core, innerGlow, outerGlow } = LIGHTNING_PALETTES[palette]; - const bolts = useMemo( - () => - generateSkyBolts(seed, { - width: fieldSize.width, - height: fieldSize.height, - targetX, - targetY, - targetRadius: avatarRadius + 6, - count: SKY_BOLT_COUNT, - }), - [avatarRadius, fieldSize.height, fieldSize.width, seed, targetX, targetY] - ); - const paths = useMemo(() => bolts.map(variantToSkPath), [bolts]); + const bolts = generateSkyBolts(seed, { + width: fieldSize.width, + height: fieldSize.height, + targetX, + targetY, + targetRadius: avatarRadius + 6, + count: SKY_BOLT_COUNT, + }); + const paths = bolts.map(variantToSkPath); const faceClip = useMemo(() => { const clip = Skia.Path.Make(); clip.addCircle(targetX, targetY, avatarRadius - 1); @@ -232,10 +228,7 @@ export function NutDropCelebrationCanvas({ const boltOpacities = [boltPrimary, boltSecondary]; const bloomRadius = Math.max(fieldSize.width, fieldSize.height) * 0.75; - const bloomGradientColors = useMemo( - () => [opacity(outerGlow, op(0.5)), opacity(outerGlow, 0)], - [outerGlow] - ); + const bloomGradientColors = [opacity(outerGlow, op(0.5)), opacity(outerGlow, 0)]; if (fieldSize.width <= 0 || fieldSize.height <= 0) return null; diff --git a/features/nearPay/components/NutDropCelebrationOverlay.tsx b/features/nearPay/components/NutDropCelebrationOverlay.tsx index e2c13dad4..f41b42471 100644 --- a/features/nearPay/components/NutDropCelebrationOverlay.tsx +++ b/features/nearPay/components/NutDropCelebrationOverlay.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { StyleSheet } from 'react-native'; import Animated, { cancelAnimation, @@ -235,27 +235,20 @@ export function NutDropCelebrationOverlay({ transform: [{ translateY: labelTranslateY.get() }], })); - const scrimCombinedStyle = useMemo( - () => [styles.scrim, { backgroundColor: background }, scrimStyle], - [background, scrimStyle] - ); - const avatarCombinedStyle = useMemo(() => [styles.avatar, avatarStyle], [avatarStyle]); - const labelContainerStyle = useMemo( - () => - centerRect - ? [ - styles.labelContainer, - { top: centerRect.y + CELEBRATION_AVATAR_SIZE + spacing.lg }, - labelStyle, - ] - : null, - [centerRect, labelStyle] - ); - const receivedTextStyle = useMemo(() => ({ color: foreground }), [foreground]); - const fromTextStyle = useMemo(() => ({ color: opacity(foreground, alpha.muted) }), [foreground]); - const handleSkip = useCallback(() => { + const scrimCombinedStyle = [styles.scrim, { backgroundColor: background }, scrimStyle]; + const avatarCombinedStyle = [styles.avatar, avatarStyle]; + const labelContainerStyle = centerRect + ? [ + styles.labelContainer, + { top: centerRect.y + CELEBRATION_AVATAR_SIZE + spacing.lg }, + labelStyle, + ] + : null; + const receivedTextStyle = { color: foreground }; + const fromTextStyle = { color: opacity(foreground, alpha.muted) }; + const handleSkip = () => { onSkip(); - }, [onSkip]); + }; if (!centerRect) return null; diff --git a/features/nearPay/hooks/useNutDropAutoRedeem.ts b/features/nearPay/hooks/useNutDropAutoRedeem.ts index 69ba46812..a80ca6699 100644 --- a/features/nearPay/hooks/useNutDropAutoRedeem.ts +++ b/features/nearPay/hooks/useNutDropAutoRedeem.ts @@ -13,13 +13,7 @@ import { useNostrKeysContext } from '@/shared/providers/NostrKeysProvider'; import { useOfflineStatus } from '@/shared/providers/OfflineProvider'; import { useNutDropRedeemQueueStore } from '@/shared/stores/profile/nutDropRedeemQueueStore'; import { extractCashuToken } from '@/shared/ui/composed/chat/extractCashuToken'; - -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; /** * App-wide Nut Drop receive pipeline. Mounted in BitchatBLEProvider (inside diff --git a/features/nearPay/hooks/useNutDropCelebration.ts b/features/nearPay/hooks/useNutDropCelebration.ts index 95971c15f..d94a540f0 100644 --- a/features/nearPay/hooks/useNutDropCelebration.ts +++ b/features/nearPay/hooks/useNutDropCelebration.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useReducer, useRef } from 'react'; +import { useEffect, useReducer, useRef } from 'react'; import { useReducedMotion } from 'react-native-reanimated'; import { @@ -190,13 +190,13 @@ export function useNutDropCelebration({ return () => clearTimeout(timer); }, [abbreviated, celebration.phase]); - const skip = useCallback(() => { + const skip = () => { paymentLog.info('near_pay.celebration.skipped', { phase: celebration.phase, peerID: celebration.current?.peerID ?? null, }); dispatch({ type: 'skip', now: Date.now() }); - }, [celebration.current?.peerID, celebration.phase]); + }; return { celebration, skip }; } diff --git a/features/nearPay/hooks/useNutDropStrike.ts b/features/nearPay/hooks/useNutDropStrike.ts index 42261fceb..372f9acfb 100644 --- a/features/nearPay/hooks/useNutDropStrike.ts +++ b/features/nearPay/hooks/useNutDropStrike.ts @@ -113,8 +113,7 @@ function strikeMapsEqual( for (const [peerID, state] of a) { const other = b.get(peerID); if ( - !other || - other.status !== state.status || + other?.status !== state.status || other.entrance !== state.entrance || // A coalesced second redemption must propagate to the celebration's // amount reveal even though the status stays 'success'. diff --git a/features/nearPay/lib/nutDropAutoRedeem.ts b/features/nearPay/lib/nutDropAutoRedeem.ts index d8127822d..74eb67c5f 100644 --- a/features/nearPay/lib/nutDropAutoRedeem.ts +++ b/features/nearPay/lib/nutDropAutoRedeem.ts @@ -1,6 +1,9 @@ import { AppState } from 'react-native'; -import { createMeshRedeemOrchestrator, type MeshRedeemOrchestrator } from '@sovranbitcoin/colada'; -import type { TransactionAnnotation } from '@sovranbitcoin/colada'; +import { + createMeshRedeemOrchestrator, + type MeshRedeemOrchestrator, + TransactionAnnotation, +} from '@sovranbitcoin/colada'; import { createDefaultOperations } from '@sovranbitcoin/colada/operations'; import { getBLEPeers } from 'bitchat-module'; @@ -14,6 +17,7 @@ import { setTransactionAnnotation } from '@/shared/stores/profile/transactionAnn import { usePaymentStatusStore } from '@/shared/stores/runtime/paymentStatusStore'; import { useWalletLifecycleStore } from '@/shared/stores/global/walletLifecycleStore'; import { paymentLog } from '@/shared/lib/logger'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; /** * Drains the persisted Nut Drop redeem queue through colada's mesh redeem @@ -36,13 +40,6 @@ function getManager() { return CocoManager.isInitialized() ? CocoManager.getInstance() : null; } -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - let orchestrator: MeshRedeemOrchestrator | null = null; function getOrchestrator(): MeshRedeemOrchestrator { diff --git a/features/nearPay/lib/nutDropCelebration.ts b/features/nearPay/lib/nutDropCelebration.ts index c9a02e747..fb0e49943 100644 --- a/features/nearPay/lib/nutDropCelebration.ts +++ b/features/nearPay/lib/nutDropCelebration.ts @@ -168,7 +168,7 @@ export function celebrationReducer( // Same sender mid-ceremony: confirm/extend the displayed amount. From // 'awaiting' the impact fires immediately; from 'centering' it fires // on arrival (the beat clock sees the amount and goes to 'held'). - if (state.current && state.current.peerID === event.peerID) { + if (state.current?.peerID === event.peerID) { if (state.phase === 'centering' || state.phase === 'awaiting' || state.phase === 'held') { return { ...state, @@ -200,7 +200,7 @@ export function celebrationReducer( } case 'strike-waiting': { - if (state.current && state.current.peerID === event.peerID) { + if (state.current?.peerID === event.peerID) { if (state.phase === 'centering' || state.phase === 'awaiting' || state.phase === 'held') { return { ...state, diff --git a/features/nearPay/screens/NearPayPeerListScreen.tsx b/features/nearPay/screens/NearPayPeerListScreen.tsx index 1240519d0..396aaf54a 100644 --- a/features/nearPay/screens/NearPayPeerListScreen.tsx +++ b/features/nearPay/screens/NearPayPeerListScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { StyleSheet } from 'react-native'; import { useHeaderHeight } from '@react-navigation/elements'; import { Stack } from 'expo-router'; @@ -52,11 +52,10 @@ function peerHasValidCreq(peer: Pick): boole /** Trailing pill for peers that have not advertised the creq capability yet. */ function WaitingTag() { const [foreground] = useThemeColor(['foreground'] as const); - const tagStyle = useMemo( - () => [styles.bearerTag, { backgroundColor: opacity(foreground, 0.08) }], - [foreground] - ); - const tagTextStyle = useMemo(() => ({ color: opacity(foreground, 0.55) }), [foreground]); + const tagStyle = [styles.bearerTag, { backgroundColor: opacity(foreground, 0.08) }]; + const tagTextStyle = { + color: opacity(foreground, 0.55), + }; return ( @@ -69,19 +68,13 @@ function WaitingTag() { } function NearPayPeerRow({ peer, onSelect }: NearPayPeerRowProps) { - const handlePress = useCallback(() => onSelect(peer), [onSelect, peer]); + const handlePress = () => onSelect(peer); // Seed identity from the favorite-exchanged Nostr key when the peer is a // Sovran client; stock peers fall back to the noise-key pseudonym. The real // face resolves on the radar; this list stays BLE-name. - const identity = useMemo( - () => bleIdentity({ ...peer, identitySeed: peerIdentitySeed(peer) }), - [peer] - ); - const trailing = useMemo( - () => - lockableMintsFromCreq(peer.creq, peer.nostrPubkeyHex) !== null ? undefined : , - [peer.creq, peer.nostrPubkeyHex] - ); + const identity = bleIdentity({ ...peer, identitySeed: peerIdentitySeed(peer) }); + const trailing = + lockableMintsFromCreq(peer.creq, peer.nostrPubkeyHex) !== null ? undefined : ; return ( peer.peerID; + export function NearPayPeerListScreen() { useLifecycleLogger('NearPayPeerListScreen', paymentLog); const headerHeight = useHeaderHeight(); @@ -110,28 +105,23 @@ export function NearPayPeerListScreen() { const interval = setInterval(() => setPeerFreshnessNow(Date.now()), BLE_PEER_FRESHNESS_TICK_MS); return () => clearInterval(interval); }, []); - const peers = useMemo( - () => filterFreshBLEPeers(blePeers, peerFreshnessNow), - [blePeers, peerFreshnessNow] - ); + const peers = filterFreshBLEPeers(blePeers, peerFreshnessNow); // Eagerly favorite nearby peers so Sovran peers reciprocate their Nostr // identity (bitchat's only native mesh identity channel) and become lockable. useEagerPeerFavorite(peers); // Every bitchat peer is listed so the favorite exchange can run, but token DMs // are only enabled after the peer advertises a valid creq capability. - const connectedCount = useMemo(() => peers.filter((peer) => peer.isConnected).length, [peers]); - const directLinkCount = useMemo(() => peers.filter((peer) => peer.hasDirectLink).length, [peers]); - const sortedPeers = useMemo(() => { - return [...peers].sort((a, b) => { - const aLockable = peerHasValidCreq(a); - const bLockable = peerHasValidCreq(b); - if (aLockable !== bLockable) return aLockable ? -1 : 1; - if (a.hasDirectLink !== b.hasDirectLink) return a.hasDirectLink ? -1 : 1; - if (a.isConnected !== b.isConnected) return a.isConnected ? -1 : 1; - return b.lastSeen - a.lastSeen; - }); - }, [peers]); + const connectedCount = peers.filter((peer) => peer.isConnected).length; + const directLinkCount = peers.filter((peer) => peer.hasDirectLink).length; + const sortedPeers = [...peers].sort((a, b) => { + const aLockable = peerHasValidCreq(a); + const bLockable = peerHasValidCreq(b); + if (aLockable !== bLockable) return aLockable ? -1 : 1; + if (a.hasDirectLink !== b.hasDirectLink) return a.hasDirectLink ? -1 : 1; + if (a.isConnected !== b.isConnected) return a.isConnected ? -1 : 1; + return b.lastSeen - a.lastSeen; + }); const subtitleText = useMemo(() => { if (peers.length === 0) return 'Scanning for nearby people...'; @@ -142,118 +132,106 @@ export function NearPayPeerListScreen() { return `${directLinkCount} direct · ${connectedCount - directLinkCount} mesh · ${peers.length} nearby`; }, [connectedCount, directLinkCount, peers.length]); - const rootStyle = useMemo(() => [styles.root, { backgroundColor: background }], [background]); - const contentStyle = useMemo( - () => [styles.content, { paddingTop: headerHeight }], - [headerHeight] - ); - const summaryStyle = useMemo( - () => [styles.summary, { borderBottomColor: opacity(foreground, 0.08) }], - [foreground] - ); - const summaryTextStyle = useMemo(() => ({ color: opacity(foreground, 0.6) }), [foreground]); - const emptyIconColor = useMemo(() => opacity(foreground, 0.3), [foreground]); - const emptyTitleStyle = useMemo( - () => ({ color: opacity(foreground, 0.5), textAlign: 'center' as const }), - [foreground] - ); - const emptyTextStyle = useMemo( - () => ({ color: opacity(foreground, 0.35), textAlign: 'center' as const }), - [foreground] - ); + const rootStyle = [styles.root, { backgroundColor: background }]; + const contentStyle = [styles.content, { paddingTop: headerHeight }]; + const summaryStyle = [styles.summary, { borderBottomColor: opacity(foreground, 0.08) }]; + const summaryTextStyle = { + color: opacity(foreground, 0.6), + }; + const emptyIconColor = opacity(foreground, 0.3); + const emptyTitleStyle = { + color: opacity(foreground, 0.5), + textAlign: 'center' as const, + }; + const emptyTextStyle = { + color: opacity(foreground, 0.35), + textAlign: 'center' as const, + }; - const handleSelectPeer = useCallback( - async (peer: BLEPeer) => { - const displayName = peerDisplayName(peer); - // Decide lock vs offline bearer from the peer's creq (accepted mints + - // lock key), our trusted mints, and online status. Delivery is always a - // private DM, but only after a valid creq proved the peer is patched. - const plan = planNearPaySend({ - peer, - ourMints: walletContext.trustedMintUrls, - isOffline, - }); - paymentLog.info('near_pay.peer.tap', { - peerID: peer.peerID, - source: 'peer-list', - mode: plan.mode, - // Did we decode the receiver's creq, and which mints did we get? - ...creqParseDiagnostics(peer), - ourMints: walletContext.trustedMintUrls, - allowedMints: plan.mode === 'block' ? null : plan.allowedMints, - isOffline, - hasDirectLink: peer.hasDirectLink, - isConnected: peer.isConnected, - }); - // No valid creq ⇒ not confirmed patched; no mint in common ⇒ the - // recipient couldn't redeem. Block before any session/navigation state. - if (plan.mode === 'block') { - if (plan.reason === 'no-shared-mint') { - await notifyNoSharedMint(displayName); - } else { - await notifyNutDropPeerNotReady(displayName); - } - return; + const handleSelectPeer = async (peer: BLEPeer) => { + const displayName = peerDisplayName(peer); + // Decide lock vs offline bearer from the peer's creq (accepted mints + + // lock key), our trusted mints, and online status. Delivery is always a + // private DM, but only after a valid creq proved the peer is patched. + const plan = planNearPaySend({ + peer, + ourMints: walletContext.trustedMintUrls, + isOffline, + }); + paymentLog.info('near_pay.peer.tap', { + peerID: peer.peerID, + source: 'peer-list', + mode: plan.mode, + // Did we decode the receiver's creq, and which mints did we get? + ...creqParseDiagnostics(peer), + ourMints: walletContext.trustedMintUrls, + allowedMints: plan.mode === 'block' ? null : plan.allowedMints, + isOffline, + hasDirectLink: peer.hasDirectLink, + isConnected: peer.isConnected, + }); + // No valid creq ⇒ not confirmed patched; no mint in common ⇒ the + // recipient couldn't redeem. Block before any session/navigation state. + if (plan.mode === 'block') { + if (plan.reason === 'no-shared-mint') { + await notifyNoSharedMint(displayName); + } else { + await notifyNutDropPeerNotReady(displayName); } - const delivery: NearPayDelivery = { locked: plan.mode === 'lock' }; - useNearPaySessionStore.getState().start({ - peerID: peer.peerID, - nickname: displayName, - hasDirectLink: peer.hasDirectLink, - lastSeen: peer.lastSeen, - creq: peer.creq, - delivery, - }); - // Failure paths must only unwind THIS selection — a newer session - // started meanwhile must survive a stale failure. - const sessionId = useNearPaySessionStore.getState().active?.id ?? null; - const clearOwnSession = () => { - if (useNearPaySessionStore.getState().active?.id === sessionId) { - useNearPaySessionStore.getState().clear(); - } - }; - router.back(); - // The sendComplete handler delivers the finished token as a private Noise - // DM to the recipient peer (no public mesh). A locked token is P2PK-locked - // to the peer's key + minted from a mint they accept; offline fallback is - // bearer from a shared mint. `allowedMints` constrains the source mint. - void machine - .startSendEcash({ - reset: true, - ...(plan.mode === 'lock' - ? { p2pkLockPubkey: plan.lockPubkey, recipientPubkey: plan.recipientPubkey } - : {}), - ...(plan.allowedMints ? { allowedMints: plan.allowedMints } : {}), - recipientProfile: { displayName, avatarUrl: null, nip05: null }, - }) - .catch((err) => { - clearOwnSession(); - paymentLog.error('near_pay.peer.list_start_send_failed', { - error: err instanceof Error ? err.message : String(err), - }); + return; + } + const delivery: NearPayDelivery = { locked: plan.mode === 'lock' }; + useNearPaySessionStore.getState().start({ + peerID: peer.peerID, + nickname: displayName, + hasDirectLink: peer.hasDirectLink, + lastSeen: peer.lastSeen, + creq: peer.creq, + delivery, + }); + // Failure paths must only unwind THIS selection — a newer session + // started meanwhile must survive a stale failure. + const sessionId = useNearPaySessionStore.getState().active?.id ?? null; + const clearOwnSession = () => { + if (useNearPaySessionStore.getState().active?.id === sessionId) { + useNearPaySessionStore.getState().clear(); + } + }; + router.back(); + // The sendComplete handler delivers the finished token as a private Noise + // DM to the recipient peer (no public mesh). A locked token is P2PK-locked + // to the peer's key + minted from a mint they accept; offline fallback is + // bearer from a shared mint. `allowedMints` constrains the source mint. + void machine + .startSendEcash({ + reset: true, + ...(plan.mode === 'lock' + ? { p2pkLockPubkey: plan.lockPubkey, recipientPubkey: plan.recipientPubkey } + : {}), + ...(plan.allowedMints ? { allowedMints: plan.allowedMints } : {}), + recipientProfile: { displayName, avatarUrl: null, nip05: null }, + }) + .catch((err) => { + clearOwnSession(); + paymentLog.error('near_pay.peer.list_start_send_failed', { + error: err instanceof Error ? err.message : String(err), }); - }, - [machine, walletContext.trustedMintUrls, isOffline] - ); + }); + }; - const keyExtractor = useCallback((peer: BLEPeer) => peer.peerID, []); - const renderItem = useCallback( - ({ item }: { item: BLEPeer }) => , - [handleSelectPeer] + const renderItem = ({ item }: { item: BLEPeer }) => ( + ); - const emptyContent = useMemo( - () => ( - - - - No one nearby - - - Keep the app open and nearby BitChat users will appear here. - - - ), - [emptyIconColor, emptyTextStyle, emptyTitleStyle] + const emptyContent = ( + + + + No one nearby + + + Keep the app open and nearby BitChat users will appear here. + + ); return ( diff --git a/features/nearPay/screens/NearPayScreen.tsx b/features/nearPay/screens/NearPayScreen.tsx index 5e5485d8d..cb27885f1 100644 --- a/features/nearPay/screens/NearPayScreen.tsx +++ b/features/nearPay/screens/NearPayScreen.tsx @@ -274,11 +274,8 @@ const HeaderBadge = React.memo(function HeaderBadge({ onPress: () => void; }) { const [foreground, shade400, accent, accentForeground] = useThemeColor(HEADER_BADGE_THEME_KEYS); - const badgeStyle = useMemo(() => [styles.headerBadge, { backgroundColor: accent }], [accent]); - const badgeTextStyle = useMemo( - () => [styles.headerBadgeText, { color: accentForeground }], - [accentForeground] - ); + const badgeStyle = [styles.headerBadge, { backgroundColor: accent }]; + const badgeTextStyle = [styles.headerBadgeText, { color: accentForeground }]; return ( [nodeStyles.peerNode, animatedStyle], - [animatedStyle, nodeStyles] - ); - const peerAvatarNameLabelStyle = useMemo( - () => [nodeStyles.peerAvatarNameLabel, labelAnimatedStyle], - [labelAnimatedStyle, nodeStyles] - ); + const nodeStyle = [nodeStyles.peerNode, animatedStyle]; + const peerAvatarNameLabelStyle = [nodeStyles.peerAvatarNameLabel, labelAnimatedStyle]; const peerPressableStyle = hideSharedElementSource ? nodeStyles.peerPressableHidden : nodeStyles.peerPressable; - const peerAvatarNameStyle = useMemo( - () => [styles.peerAvatarName, { color: opacity(foreground, alpha.prominent) }], - [foreground] - ); - const bearerBadgeStyle = useMemo( - () => [nodeStyles.bearerBadge, { backgroundColor: surface }], - [nodeStyles, surface] - ); + const peerAvatarNameStyle = [ + styles.peerAvatarName, + { color: opacity(foreground, alpha.prominent) }, + ]; + const bearerBadgeStyle = [nodeStyles.bearerBadge, { backgroundColor: surface }]; - const handlePress = useCallback(() => { + const handlePress = () => { if (target.phase === 'exiting') return; const presentation = getPeerViewportPresentation(target, fieldSize, layoutConfig, { x: panX.get(), @@ -645,7 +633,7 @@ const PeerNode = React.memo(function PeerNode({ target.peer, getScaledAvatarRect(target, fieldSize, { x: panX.get(), y: panY.get() }, layoutConfig) ); - }, [fieldSize, layoutConfig, onSelect, panX, panY, target]); + }; return ( @@ -799,11 +787,11 @@ const NearPayAmountHeader = React.memo(function NearPayAmountHeader({ hideAvatar: boolean; }) { const foreground = useThemeColor('foreground'); - const titleStyle = useMemo(() => ({ color: foreground }), [foreground]); - const avatarSlotStyle = useMemo( - () => [styles.amountHeaderAvatarSlot, hideAvatar ? styles.sharedElementHidden : null], - [hideAvatar] - ); + const titleStyle = { color: foreground }; + const avatarSlotStyle = [ + styles.amountHeaderAvatarSlot, + hideAvatar ? styles.sharedElementHidden : null, + ]; return ( @@ -867,10 +855,7 @@ const NearPayPeerField = React.memo(function NearPayPeerField({ // placement/zoom-fit avoidance must grow by the same amount. const insets = useSafeAreaInsets(); const actionRowBottom = NEAR_PAY_ACTION_ROW_BOTTOM + insets.bottom; - const actionRowStyle = useMemo( - () => [styles.nearPayActionRow, { bottom: actionRowBottom }], - [actionRowBottom] - ); + const actionRowStyle = [styles.nearPayActionRow, { bottom: actionRowBottom }]; const actionAvoidance = NEAR_PAY_ACTION_ROW_HEIGHT + NEAR_PAY_ACTION_ROW_BOTTOM + spacing.lg + insets.bottom; const peerLayoutConfig = useMemo( @@ -905,19 +890,16 @@ const NearPayPeerField = React.memo(function NearPayPeerField({ ], }; }, [overviewScale, overviewTranslateX, overviewTranslateY, panX, panY]); - const dotFieldLayerStyle = useMemo( - () => [styles.dotFieldLayer, dotFieldAnimatedStyle], - [dotFieldAnimatedStyle] - ); + const dotFieldLayerStyle = [styles.dotFieldLayer, dotFieldAnimatedStyle]; - const handleLayout = useCallback((event: LayoutChangeEvent) => { + const handleLayout = (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setFieldSize((current) => Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1 ? current : { width, height } ); - }, []); + }; // Resolve real Nostr profiles for every Sovran peer (those announcing a // P2PK key) so the radar shows real faces/names BEFORE any tap. The x-only @@ -1131,7 +1113,7 @@ const NearPayPeerField = React.memo(function NearPayPeerField({ const targetCount = targets.length; - const handleFocus = useCallback(() => { + const handleFocus = () => { paymentLog.debug('near_pay.perf.focus_start', { targetCount, panX: roundMetric(panX.get()), @@ -1166,22 +1148,11 @@ const NearPayPeerField = React.memo(function NearPayPeerField({ isPanning.set(false); }) ); - }, [ - isPanSettling, - isPanning, - panBounds.maxX, - panBounds.maxY, - panBounds.minX, - panBounds.minY, - panSettleRemaining, - panX, - panY, - targetCount, - ]); + }; const hasSelectablePeer = targets.some((target) => target.phase !== 'exiting'); - const handleOverviewPressIn = useCallback(() => { + const handleOverviewPressIn = () => { const startedAt = nowMs(); const pan = { x: panX.get(), y: panY.get() }; const overview = getPeerLayoutOverviewTransform( @@ -1214,29 +1185,18 @@ const NearPayPeerField = React.memo(function NearPayPeerField({ overviewScale.set(withTiming(overview.scale, PEER_OVERVIEW_TIMING)); overviewTranslateX.set(withTiming(overview.translateX, PEER_OVERVIEW_TIMING)); overviewTranslateY.set(withTiming(overview.translateY, PEER_OVERVIEW_TIMING)); - }, [ - actionAvoidance, - fieldSize, - overviewScale, - overviewTranslateX, - overviewTranslateY, - panX, - panY, - peerLayoutConfig, - targetCount, - targets, - ]); + }; - const handleOverviewPressOut = useCallback(() => { + const handleOverviewPressOut = () => { cancelAnimation(overviewScale); cancelAnimation(overviewTranslateX); cancelAnimation(overviewTranslateY); overviewScale.set(withTiming(1, PEER_OVERVIEW_TIMING)); overviewTranslateX.set(withTiming(0, PEER_OVERVIEW_TIMING)); overviewTranslateY.set(withTiming(0, PEER_OVERVIEW_TIMING)); - }, [overviewScale, overviewTranslateX, overviewTranslateY]); + }; - const handleRandomPeer = useCallback(() => { + const handleRandomPeer = () => { const startedAt = nowMs(); const pan = { x: panX.get(), y: panY.get() }; const selectableTargets = targets.filter((target) => { @@ -1275,7 +1235,7 @@ const NearPayPeerField = React.memo(function NearPayPeerField({ }); onSelect(target.peer, getScaledAvatarRect(target, fieldSize, pan, peerLayoutConfig)); - }, [fieldSize, onSelect, panX, panY, peerLayoutConfig, targetCount, targets]); + }; const logPanEnd = useCallback((payload: Record) => { paymentLog.debug('near_pay.perf.pan_end', payload); @@ -1521,10 +1481,7 @@ export function NearPayScreen() { const sharedAvatarTransitionSpanRef = useRef(null); const sharedAvatarStartFrameRef = useRef(null); const sharedAvatarStartTimerRef = useRef | null>(null); - const reachableCount = useMemo( - () => peers.filter((peer) => peer.hasDirectLink || peer.isConnected).length, - [peers] - ); + const reachableCount = peers.filter((peer) => peer.hasDirectLink || peer.isConnected).length; const pickerPeersRef = useRef(peers); useEffect(() => { @@ -1609,9 +1566,9 @@ export function NearPayScreen() { celebration.current ); - const handleRegistryCountChange = useCallback((count: number) => { + const handleRegistryCountChange = (count: number) => { setRadarRegistryCount(count); - }, []); + }; useEffect(() => { paymentLog.debug('near_pay.perf.session_state', { @@ -1681,14 +1638,14 @@ export function NearPayScreen() { }); }, [containerSize.width]); - const handleContainerLayout = useCallback((event: LayoutChangeEvent) => { + const handleContainerLayout = (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setContainerSize((current) => Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1 ? current : { width, height } ); - }, []); + }; const endSharedAvatarTransitionSpan = useCallback((params: Record) => { sharedAvatarTransitionSpanRef.current?.end(params); @@ -1796,153 +1753,136 @@ export function NearPayScreen() { stopSharedElementAnimations, ]); - const handleSelectPeer = useCallback( - async (peer: NearPayLayoutPeer, avatarRect: AvatarRect) => { - // Decide lock vs offline bearer from the peer's creq (accepted mints + - // lock key), our trusted mints, and online status. Delivery is always a - // private DM, but only after a valid creq proved the peer is patched. - const plan = planNearPaySend({ - peer, - ourMints: walletContext.trustedMintUrls, - isOffline, - }); - paymentLog.info('near_pay.peer.tap', { - peerID: peer.peerID, - mode: plan.mode, - // Did we decode the receiver's creq, and which mints did we get? - ...creqParseDiagnostics(peer), - ourMints: walletContext.trustedMintUrls, - allowedMints: plan.mode === 'block' ? null : plan.allowedMints, - isOffline, - hasDirectLink: peer.hasDirectLink, - isConnected: peer.isConnected, - }); - // No valid creq ⇒ not confirmed patched; no mint in common ⇒ the - // recipient couldn't redeem. Block BEFORE any session/transition state - // (leaves the radar exactly as it was; also covers the Random button). - if (plan.mode === 'block') { - paymentLog.info( - plan.reason === 'no-shared-mint' - ? 'near_pay.peer.no_shared_mint' - : 'near_pay.peer.not_ready', - { peerID: peer.peerID, reason: plan.reason } - ); - if (plan.reason === 'no-shared-mint') { - await notifyNoSharedMint(peer.name); - } else { - await notifyNutDropPeerNotReady(peer.name); - } - return; + const handleSelectPeer = async (peer: NearPayLayoutPeer, avatarRect: AvatarRect) => { + // Decide lock vs offline bearer from the peer's creq (accepted mints + + // lock key), our trusted mints, and online status. Delivery is always a + // private DM, but only after a valid creq proved the peer is patched. + const plan = planNearPaySend({ + peer, + ourMints: walletContext.trustedMintUrls, + isOffline, + }); + paymentLog.info('near_pay.peer.tap', { + peerID: peer.peerID, + mode: plan.mode, + // Did we decode the receiver's creq, and which mints did we get? + ...creqParseDiagnostics(peer), + ourMints: walletContext.trustedMintUrls, + allowedMints: plan.mode === 'block' ? null : plan.allowedMints, + isOffline, + hasDirectLink: peer.hasDirectLink, + isConnected: peer.isConnected, + }); + // No valid creq ⇒ not confirmed patched; no mint in common ⇒ the + // recipient couldn't redeem. Block BEFORE any session/transition state + // (leaves the radar exactly as it was; also covers the Random button). + if (plan.mode === 'block') { + paymentLog.info( + plan.reason === 'no-shared-mint' + ? 'near_pay.peer.no_shared_mint' + : 'near_pay.peer.not_ready', + { peerID: peer.peerID, reason: plan.reason } + ); + if (plan.reason === 'no-shared-mint') { + await notifyNoSharedMint(peer.name); + } else { + await notifyNutDropPeerNotReady(peer.name); } - const delivery: NearPayDelivery = { locked: plan.mode === 'lock' }; - paymentLog.info('near_pay.peer.select', { + return; + } + const delivery: NearPayDelivery = { locked: plan.mode === 'lock' }; + paymentLog.info('near_pay.peer.select', { + peerID: peer.peerID, + hasDirectLink: peer.hasDirectLink, + isConnected: peer.isConnected, + locked: delivery.locked, + }); + setSelectedPeer(peer); + setSelectedPeerRect(avatarRect); + stopSharedElementAnimations(); + amountContentOpacity.set(0); + amountContentTranslateY.set(AMOUNT_CONTENT_ENTER_OFFSET); + setSharedAvatarPeer(peer); + amountPanelTranslateX.set(0); + amountPanelTranslateY.set(0); + const sharedAvatarStart = getSharedAvatarTransform(avatarRect, fieldSizing.avatarSize); + sharedAvatarX.set(sharedAvatarStart.x); + sharedAvatarY.set(sharedAvatarStart.y); + sharedAvatarScale.set(sharedAvatarStart.scale); + sharedAvatarOpacity.set(1); + useNearPaySessionStore.getState().start({ + peerID: peer.peerID, + nickname: peer.name, + hasDirectLink: peer.hasDirectLink, + lastSeen: peer.lastSeen, + creq: peer.creq, + delivery, + }); + // Failure paths must only unwind THIS selection — the radar stays + // tappable while a send is in flight, so the session-id guard ensures a + // slow attempt's failure can't clear a newer session started meanwhile. + const sessionId = useNearPaySessionStore.getState().active?.id ?? null; + const startSendSpan = paymentLog + .child({ flowId: `near-pay-start-send-${Date.now()}` }) + .startSpan( + 'near_pay.perf.start_send_ecash', + { + peerID: peer.peerID, + hasAvatar: !!peer.avatarUrl, + hasDirectLink: peer.hasDirectLink, + isConnected: peer.isConnected, + }, + { + warnAtMs: 250, + errorAtMs: 1000, + } + ); + try { + // One path for every peer: enter the amount flow, then the + // sendComplete handler delivers the finished token as a private Noise + // DM to the recipient peer (no public mesh). A locked token is + // P2PK-locked to the peer's key + minted from a mint they accept; + // offline fallback is bearer from a shared mint. `allowedMints` + // constrains the source mint. recipientPubkey seeds their real profile. + paymentLog.info('near_pay.start_send', { peerID: peer.peerID, - hasDirectLink: peer.hasDirectLink, - isConnected: peer.isConnected, locked: delivery.locked, }); - setSelectedPeer(peer); - setSelectedPeerRect(avatarRect); - stopSharedElementAnimations(); - amountContentOpacity.set(0); - amountContentTranslateY.set(AMOUNT_CONTENT_ENTER_OFFSET); - setSharedAvatarPeer(peer); + await machine.startSendEcash({ + reset: true, + ...(plan.mode === 'lock' + ? { p2pkLockPubkey: plan.lockPubkey, recipientPubkey: plan.recipientPubkey } + : {}), + ...(plan.allowedMints ? { allowedMints: plan.allowedMints } : {}), + recipientProfile: { + displayName: peer.name, + avatarUrl: peer.avatarUrl ?? null, + nip05: null, + }, + }); + startSendSpan.end({ + completed: true, + }); + } catch (err) { + startSendSpan.end({ + completed: false, + error: err instanceof Error ? err.message : String(err), + }); + setSelectedPeer(null); + setSelectedPeerRect(null); + setSharedAvatarPeer(null); amountPanelTranslateX.set(0); amountPanelTranslateY.set(0); - const sharedAvatarStart = getSharedAvatarTransform(avatarRect, fieldSizing.avatarSize); - sharedAvatarX.set(sharedAvatarStart.x); - sharedAvatarY.set(sharedAvatarStart.y); - sharedAvatarScale.set(sharedAvatarStart.scale); - sharedAvatarOpacity.set(1); - useNearPaySessionStore.getState().start({ - peerID: peer.peerID, - nickname: peer.name, - hasDirectLink: peer.hasDirectLink, - lastSeen: peer.lastSeen, - creq: peer.creq, - delivery, - }); - // Failure paths must only unwind THIS selection — the radar stays - // tappable while a send is in flight, so the session-id guard ensures a - // slow attempt's failure can't clear a newer session started meanwhile. - const sessionId = useNearPaySessionStore.getState().active?.id ?? null; - const startSendSpan = paymentLog - .child({ flowId: `near-pay-start-send-${Date.now()}` }) - .startSpan( - 'near_pay.perf.start_send_ecash', - { - peerID: peer.peerID, - hasAvatar: !!peer.avatarUrl, - hasDirectLink: peer.hasDirectLink, - isConnected: peer.isConnected, - }, - { - warnAtMs: 250, - errorAtMs: 1000, - } - ); - try { - // One path for every peer: enter the amount flow, then the - // sendComplete handler delivers the finished token as a private Noise - // DM to the recipient peer (no public mesh). A locked token is - // P2PK-locked to the peer's key + minted from a mint they accept; - // offline fallback is bearer from a shared mint. `allowedMints` - // constrains the source mint. recipientPubkey seeds their real profile. - paymentLog.info('near_pay.start_send', { - peerID: peer.peerID, - locked: delivery.locked, - }); - await machine.startSendEcash({ - reset: true, - ...(plan.mode === 'lock' - ? { p2pkLockPubkey: plan.lockPubkey, recipientPubkey: plan.recipientPubkey } - : {}), - ...(plan.allowedMints ? { allowedMints: plan.allowedMints } : {}), - recipientProfile: { - displayName: peer.name, - avatarUrl: peer.avatarUrl ?? null, - nip05: null, - }, - }); - startSendSpan.end({ - completed: true, - }); - } catch (err) { - startSendSpan.end({ - completed: false, - error: err instanceof Error ? err.message : String(err), - }); - setSelectedPeer(null); - setSelectedPeerRect(null); - setSharedAvatarPeer(null); - amountPanelTranslateX.set(0); - amountPanelTranslateY.set(0); - amountContentOpacity.set(0); - amountContentTranslateY.set(AMOUNT_CONTENT_ENTER_OFFSET); - if (useNearPaySessionStore.getState().active?.id === sessionId) { - useNearPaySessionStore.getState().clear(); - } - paymentLog.error('near_pay.peer.start_send_failed', { - error: err instanceof Error ? err.message : String(err), - }); + amountContentOpacity.set(0); + amountContentTranslateY.set(AMOUNT_CONTENT_ENTER_OFFSET); + if (useNearPaySessionStore.getState().active?.id === sessionId) { + useNearPaySessionStore.getState().clear(); } - }, - [ - machine, - walletContext.trustedMintUrls, - isOffline, - amountContentOpacity, - amountContentTranslateY, - amountPanelTranslateX, - amountPanelTranslateY, - fieldSizing.avatarSize, - sharedAvatarOpacity, - sharedAvatarScale, - sharedAvatarX, - sharedAvatarY, - stopSharedElementAnimations, - ] - ); + paymentLog.error('near_pay.peer.start_send_failed', { + error: err instanceof Error ? err.message : String(err), + }); + } + }; useEffect(() => { if (!inlineAmountEntry) { @@ -2097,18 +2037,9 @@ export function NearPayScreen() { { scale: sharedAvatarScale.get() }, ], })); - const pickerPanelCombinedStyle = useMemo( - () => [styles.panel, pickerPanelStyle], - [pickerPanelStyle] - ); - const amountPanelCombinedStyle = useMemo( - () => [styles.panel, styles.amountPanel, amountPanelStyle], - [amountPanelStyle] - ); - const amountContentCombinedStyle = useMemo( - () => [styles.inlineAmountBody, amountContentStyle], - [amountContentStyle] - ); + const pickerPanelCombinedStyle = [styles.panel, pickerPanelStyle]; + const amountPanelCombinedStyle = [styles.panel, styles.amountPanel, amountPanelStyle]; + const amountContentCombinedStyle = [styles.inlineAmountBody, amountContentStyle]; const sharedAvatarBaseStyle = useMemo( () => ({ position: 'absolute' as const, @@ -2120,18 +2051,12 @@ export function NearPayScreen() { }), [fieldSizing.avatarSize] ); - const sharedAvatarFrameStyle = useMemo( - () => ({ - position: 'relative' as const, - width: fieldSizing.avatarSize, - height: fieldSizing.avatarSize, - }), - [fieldSizing.avatarSize] - ); - const sharedAvatarCombinedStyle = useMemo( - () => [sharedAvatarBaseStyle, sharedAvatarStyle], - [sharedAvatarBaseStyle, sharedAvatarStyle] - ); + const sharedAvatarFrameStyle = { + position: 'relative' as const, + width: fieldSizing.avatarSize, + height: fieldSizing.avatarSize, + }; + const sharedAvatarCombinedStyle = [sharedAvatarBaseStyle, sharedAvatarStyle]; const bluetooth = useBluetoothState(); // 'unknown' stays on the scanning path — iOS reports a real state only after @@ -2147,19 +2072,16 @@ export function NearPayScreen() { () => [styles.emptyText, { color: foregroundMuted }], [foregroundMuted] ); - const emptyContent = useMemo( - () => ( - - - - Scanning nearby - - - Keep Sovran open and nearby BitChat users will appear as fallback avatars. - - - ), - [emptyTextStyle, emptyTitleStyle, foregroundSoft] + const emptyContent = ( + + + + Scanning nearby + + + Keep Sovran open and nearby BitChat users will appear as fallback avatars. + + ); const renderHeaderLeft = useCallback( () => , @@ -2173,24 +2095,21 @@ export function NearPayScreen() { () => , [headerBadgeCount, openPeerList] ); - const stackOptions = useMemo( - () => ({ - title: amountActive ? '' : 'Nut Drop', - headerShadowVisible: false, - headerTransparent: true, - headerTitle: amountActive ? renderEmptyHeader : undefined, - headerBackVisible: false, - headerLeft: amountActive ? renderHeaderLeft : renderEmptyHeader, - headerRight: amountActive ? renderEmptyHeader : renderHeaderRight, - // The send flow's shared-element avatar lands inside the header band, - // and Android's sheet header (FlowSheetHeader) composites its scrim - // gradient ABOVE screen content — the avatar ended up underneath it. - // A null headerBackground is the sanctioned per-screen scrim opt-out; - // the radar's faint dot field doesn't need the legibility fade. - ...(Platform.OS === 'android' ? { headerBackground: renderNullHeaderBackground } : {}), - }), - [amountActive, renderEmptyHeader, renderHeaderLeft, renderHeaderRight] - ); + const stackOptions = { + title: amountActive ? '' : 'Nut Drop', + headerShadowVisible: false, + headerTransparent: true, + headerTitle: amountActive ? renderEmptyHeader : undefined, + headerBackVisible: false, + headerLeft: amountActive ? renderHeaderLeft : renderEmptyHeader, + headerRight: amountActive ? renderEmptyHeader : renderHeaderRight, + // The send flow's shared-element avatar lands inside the header band, + // and Android's sheet header (FlowSheetHeader) composites its scrim + // gradient ABOVE screen content — the avatar ended up underneath it. + // A null headerBackground is the sanctioned per-screen scrim opt-out; + // the radar's faint dot field doesn't need the legibility fade. + ...(Platform.OS === 'android' ? { headerBackground: renderNullHeaderBackground } : {}), + }; return ( <> diff --git a/features/nostrSigner/components/PermissionGestureDemo.tsx b/features/nostrSigner/components/PermissionGestureDemo.tsx index d84429164..ce4c385f3 100644 --- a/features/nostrSigner/components/PermissionGestureDemo.tsx +++ b/features/nostrSigner/components/PermissionGestureDemo.tsx @@ -20,7 +20,7 @@ * starts at all under Reduce Motion (the t=0 idle frame + caption stand in). */ -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { useFocusEffect } from 'expo-router'; import opacity from 'hex-color-opacity'; import Animated, { @@ -311,21 +311,19 @@ export function PermissionGestureDemo(): React.ReactElement { const reducedMotion = useReducedMotion(); const t = useSharedValue(0); - useFocusEffect( - useCallback(() => { - if (reducedMotion) return undefined; + useFocusEffect(() => { + if (reducedMotion) return undefined; + t.value = 0; + t.value = withRepeat( + withTiming(CYCLE_MS, { duration: CYCLE_MS, easing: Easing.linear }), + -1, + false + ); + return () => { + cancelAnimation(t); t.value = 0; - t.value = withRepeat( - withTiming(CYCLE_MS, { duration: CYCLE_MS, easing: Easing.linear }), - -1, - false - ); - return () => { - cancelAnimation(t); - t.value = 0; - }; - }, [reducedMotion, t]) - ); + }; + }); // Theme-derived plain strings, captured by the worklets below. const rowFill = opacity(foreground, 0.06); @@ -450,55 +448,36 @@ export function PermissionGestureDemo(): React.ReactElement { () => [ROW_PLANE_BASE, { backgroundColor: rowFill }, rowPlaneStyle], [rowFill, rowPlaneStyle] ); - const rowContentComposed = useMemo(() => [ROW_CONTENT_STYLE, rowContentStyle], [rowContentStyle]); - const titleDangerComposed = useMemo( - () => [STATUS_ABS_STYLE, titleDangerStyle], - [titleDangerStyle] - ); - const statusAskComposed = useMemo(() => [STATUS_ABS_STYLE, statusAskStyle], [statusAskStyle]); - const statusAlwaysComposed = useMemo( - () => [STATUS_ABS_STYLE, statusAlwaysStyle], - [statusAlwaysStyle] - ); - const statusBlockedComposed = useMemo( - () => [STATUS_ABS_STYLE, statusBlockedStyle], - [statusBlockedStyle] - ); - const switchTrackComposed = useMemo( - () => [SWITCH_TRACK_STYLE, switchTrackStyle], - [switchTrackStyle] - ); - const knobComposed = useMemo( - () => [SWITCH_KNOB_BASE, { backgroundColor: background, borderColor: knobBorder }, knobStyle], - [background, knobBorder, knobStyle] - ); - const shadowComposed = useMemo( - () => [SHADOW_BASE, { backgroundColor: foreground }, shadowStyle], - [foreground, shadowStyle] - ); - const ringWrapComposed = useMemo(() => [RING_WRAP_BASE, ringWrapStyle], [ringWrapStyle]); - const menuComposed = useMemo( - () => [MENU_BASE, { backgroundColor: background, borderColor: menuBorder }, menuStyle], - [background, menuBorder, menuStyle] - ); - const menuRowComposed = useMemo( - () => [ - [MENU_ROW_STYLE, menuRow0], - [MENU_ROW_STYLE, menuRow1], - [MENU_ROW_STYLE, menuRow2], - ], - [menuRow0, menuRow1, menuRow2] - ); - const menuBarComposed = useMemo(() => [MENU_BAR_STYLE, { backgroundColor: menuBar }], [menuBar]); - const blockFlashComposed = useMemo( - () => [BLOCK_FLASH_BASE, { backgroundColor: danger }, blockFlashStyle], - [danger, blockFlashStyle] - ); - const gloveComposed = useMemo(() => [GLOVE_BASE, gloveStyle], [gloveStyle]); - const wordTap1Composed = useMemo(() => [WORD_BASE, wordTap1Style], [wordTap1Style]); - const wordTap2Composed = useMemo(() => [WORD_BASE, wordTap2Style], [wordTap2Style]); - const wordHoldComposed = useMemo(() => [WORD_BASE, wordHoldStyle], [wordHoldStyle]); - const wordBlockComposed = useMemo(() => [WORD_BASE, wordBlockStyle], [wordBlockStyle]); + const rowContentComposed = [ROW_CONTENT_STYLE, rowContentStyle]; + const titleDangerComposed = [STATUS_ABS_STYLE, titleDangerStyle]; + const statusAskComposed = [STATUS_ABS_STYLE, statusAskStyle]; + const statusAlwaysComposed = [STATUS_ABS_STYLE, statusAlwaysStyle]; + const statusBlockedComposed = [STATUS_ABS_STYLE, statusBlockedStyle]; + const switchTrackComposed = [SWITCH_TRACK_STYLE, switchTrackStyle]; + const knobComposed = [ + SWITCH_KNOB_BASE, + { backgroundColor: background, borderColor: knobBorder }, + knobStyle, + ]; + const shadowComposed = [SHADOW_BASE, { backgroundColor: foreground }, shadowStyle]; + const ringWrapComposed = [RING_WRAP_BASE, ringWrapStyle]; + const menuComposed = [ + MENU_BASE, + { backgroundColor: background, borderColor: menuBorder }, + menuStyle, + ]; + const menuRowComposed = [ + [MENU_ROW_STYLE, menuRow0], + [MENU_ROW_STYLE, menuRow1], + [MENU_ROW_STYLE, menuRow2], + ]; + const menuBarComposed = [MENU_BAR_STYLE, { backgroundColor: menuBar }]; + const blockFlashComposed = [BLOCK_FLASH_BASE, { backgroundColor: danger }, blockFlashStyle]; + const gloveComposed = [GLOVE_BASE, gloveStyle]; + const wordTap1Composed = [WORD_BASE, wordTap1Style]; + const wordTap2Composed = [WORD_BASE, wordTap2Style]; + const wordHoldComposed = [WORD_BASE, wordHoldStyle]; + const wordBlockComposed = [WORD_BASE, wordBlockStyle]; return ( { + const openMenu = () => { const checkSuffix = ; actionMenuPopup({ title: label, @@ -122,9 +122,9 @@ export function PermissionSwitchRow({ }, ], }); - }, [label, state, allowEligible, onChange, muted]); + }; - const onTap = useCallback(() => { + const onTap = () => { if (state === 'block') { onChange('ask'); return; @@ -134,7 +134,7 @@ export function PermissionSwitchRow({ return; } onChange(state === 'allow' ? 'ask' : 'allow'); - }, [state, allowEligible, onChange, openMenu]); + }; const status = state === 'ask' && sessionStatus !== undefined diff --git a/features/nostrSigner/components/SignerApprovalSheetContent.tsx b/features/nostrSigner/components/SignerApprovalSheetContent.tsx index afd527f54..104b11fb2 100644 --- a/features/nostrSigner/components/SignerApprovalSheetContent.tsx +++ b/features/nostrSigner/components/SignerApprovalSheetContent.tsx @@ -150,10 +150,7 @@ const UNDERLINE_TEXT_STYLE = { textDecorationLine: 'underline' } as const; /** Bounded content preview + the shared JSON inspector, in a card. */ function EventPreviewCard({ event }: { event: UnsignedEvent }) { const [foreground] = useThemeColor(['foreground'] as const); - const contentPreview = useMemo( - () => boundDisplay(event.content.trim(), CONTENT_PREVIEW_MAX_CHARS), - [event.content] - ); + const contentPreview = boundDisplay(event.content.trim(), CONTENT_PREVIEW_MAX_CHARS); return ( @@ -186,7 +183,7 @@ export function SignerApprovalSheetContent({ ] as const); // Consolidated view: one DECISION per group of identical spam requests. - const groups = useMemo(() => consolidatePending(pending), [pending]); + const groups = consolidatePending(pending); const headGroup: Nip46RequestGroup | null = groups.length > 0 ? groups[0] : null; const head: Nip46PendingRequest | null = headGroup?.requests[0] ?? null; const headGroupKey = headGroup?.key ?? null; @@ -253,56 +250,51 @@ export function SignerApprovalSheetContent({ if (head === null && expiredNotice === null) finish(); }, [head, expiredNotice, finish]); - const submitVerdict = useSingleFlight( - useCallback( - async (action: Nip46DecisionAction) => { - if (headGroup === null) return; - // Snapshot ids + mark resolved + tally BEFORE the first await, so the - // departure effect (which fires as the store updates mid-loop) reads - // a fully-resolved group and never flashes the expired notice. - const ids = verdictIdsForGroup(action, headGroup); - if (action === 'block') { - // The engine flushes EVERY pending request from this app — mark - // them all resolved so no sibling group flashes "expired". - const appPubkey = headGroup.requests[0]!.clientPubkey; - const flushed = useNip46RequestsStore - .getState() - .pending.filter((request) => request.clientPubkey === appPubkey); - for (const request of flushed) resolvedIdsRef.current.add(request.id); - tallyRef.current.denied += flushed.length; + const submitVerdict = useSingleFlight(async (action: Nip46DecisionAction) => { + if (headGroup === null) return; + // Snapshot ids + mark resolved + tally BEFORE the first await, so the + // departure effect (which fires as the store updates mid-loop) reads + // a fully-resolved group and never flashes the expired notice. + const ids = verdictIdsForGroup(action, headGroup); + if (action === 'block') { + // The engine flushes EVERY pending request from this app — mark + // them all resolved so no sibling group flashes "expired". + const appPubkey = headGroup.requests[0]!.clientPubkey; + const flushed = useNip46RequestsStore + .getState() + .pending.filter((request) => request.clientPubkey === appPubkey); + for (const request of flushed) resolvedIdsRef.current.add(request.id); + tallyRef.current.denied += flushed.length; + } else { + for (const request of headGroup.requests) resolvedIdsRef.current.add(request.id); + if (action === 'deny_once') { + tallyRef.current.denied += headGroup.requests.length; + } else { + tallyRef.current.allowed += headGroup.requests.length; + } + } + for (const id of ids) { + const resolved = await nip46Engine.resolveRequest(id, { action }); + if (resolved.isErr()) { + if (resolved.error.type === 'unknown-request') { + // A member expired (sweep) or was flushed (block) mid-loop. + nostrLog.debug('nostr.signer.approval_resolve_raced'); } else { - for (const request of headGroup.requests) resolvedIdsRef.current.add(request.id); - if (action === 'deny_once') { - tallyRef.current.denied += headGroup.requests.length; - } else { - tallyRef.current.allowed += headGroup.requests.length; - } + nostrLog.warn('nostr.signer.approval_resolve_failed', { + error: resolved.error.type, + }); } - for (const id of ids) { - const resolved = await nip46Engine.resolveRequest(id, { action }); - if (resolved.isErr()) { - if (resolved.error.type === 'unknown-request') { - // A member expired (sweep) or was flushed (block) mid-loop. - nostrLog.debug('nostr.signer.approval_resolve_raced'); - } else { - nostrLog.warn('nostr.signer.approval_resolve_failed', { - error: resolved.error.type, - }); - } - } - } - }, - [headGroup] - ) - ); + } + } + }); - const viewAll = useCallback(() => { + const viewAll = () => { // Navigating to the queue is not a dismissal — skip the deferred-batch // toast (the controller still parks the batch so it won't auto-reopen). suppressSignerDeferToastOnce(); close(); router.push('/(signer-flow)/requests' as never); - }, [close]); + }; // ── Derived presentation ────────────────────────────────────── const preview = head?.paramsPreview ?? NONE_PREVIEW; @@ -406,7 +398,7 @@ export function SignerApprovalSheetContent({ ? peerLabel !== undefined : lookupForAlways !== null && alwaysAllowEligible(lookupForAlways)); - const confirmBlock = useCallback(() => { + const confirmBlock = () => { actionMenuPopup({ title: blockAppConfirmTitle(appName), buttons: [ @@ -421,20 +413,20 @@ export function SignerApprovalSheetContent({ { text: 'Cancel', variant: 'secondary', onPress: (menuClose) => menuClose() }, ], }); - }, [appName, submitVerdict]); + }; // Strict mode approves once, like self-decrypt — a session grant would be // inert under strict and the label would overpromise. const approveOnceOnly = isSelfDecrypt || strictMode; - const approvePrimary = useCallback(() => { + const approvePrimary = () => { void submitVerdict(approveOnceOnly ? 'approve_once' : 'approve_session'); - }, [approveOnceOnly, submitVerdict]); - const approveAlways = useCallback(() => { + }; + const approveAlways = () => { void submitVerdict('always'); - }, [submitVerdict]); - const denyOnce = useCallback(() => { + }; + const denyOnce = () => { void submitVerdict('deny_once'); - }, [submitVerdict]); + }; // Counts DECISIONS (consolidated groups), not raw spam requests. const totalInBatch = advanceCount + groups.length; diff --git a/features/nostrSigner/components/SignerConnectSheetContent.tsx b/features/nostrSigner/components/SignerConnectSheetContent.tsx index b5a8c3631..0f7b28ae3 100644 --- a/features/nostrSigner/components/SignerConnectSheetContent.tsx +++ b/features/nostrSigner/components/SignerConnectSheetContent.tsx @@ -25,7 +25,7 @@ * retry toast; the sheet only closes once the intent write is durable. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'; import { @@ -281,7 +281,9 @@ function ReviewSwitchRow({ selected: boolean; onToggle: () => void; }) { - const switchA11yState = useMemo(() => ({ checked: selected }), [selected]); + const switchA11yState = { + checked: selected, + }; return ( (eligibleUriRows.map((row) => row.grantKey)); const extra: PermRow[] = []; for (const [grantKey, grant] of Object.entries(previous.grants)) { - if (!grant || grant.verdict !== 'always') continue; + if (grant?.verdict !== 'always') continue; if (covered.has(grantKey)) continue; if (!isGrantKey(grantKey)) continue; const lookup = parseGrantKey(grantKey); @@ -444,7 +446,7 @@ interface SignerConnectContentProps extends CustomSheetSharedProps { } export function SignerConnectSheetContent(props: SignerConnectContentProps): React.ReactElement { - const parsedResult = useMemo(() => parseNostrconnectUri(props.payload.uri), [props.payload.uri]); + const parsedResult = parseNostrconnectUri(props.payload.uri); if (parsedResult.isErr()) { return ; } @@ -503,10 +505,7 @@ function ConnectReview({ const isUpdate = connection !== undefined; // Same app, NEW ephemeral client key? (Exact-pubkey update takes precedence; // the matcher is only a UX offer — the engine re-validates it at completion.) - const match = useMemo( - () => (isUpdate ? ({ kind: 'none' } as const) : findPreviousConnection(apps, parsed)), - [apps, isUpdate, parsed] - ); + const match = isUpdate ? ({ kind: 'none' } as const) : findPreviousConnection(apps, parsed); const variant: 'update' | 'reconnect' | 'blocked-fresh' | 'fresh' = isUpdate ? 'update' : match.kind === 'active' @@ -519,19 +518,13 @@ function ConnectReview({ const appName = appDisplayName({ ...(parsed.name !== undefined && { name: parsed.name }) }); const appDomain = parsed.url !== undefined ? safeHostname(parsed.url).unwrapOr(null) : null; - const permRows = useMemo(() => permRowsFor(parsed), [parsed]); + const permRows = permRowsFor(parsed); // Reconnect hides the preset (the previous config replaces preset defaults). - const presetRows = useMemo( - () => (variant === 'reconnect' ? [] : presetRowsFor(permRows)), - [variant, permRows] - ); - const reviewRows = useMemo( - () => - variant === 'reconnect' && previousConnection !== undefined - ? reconnectRowsFor(permRows, previousConnection) - : permRows, - [variant, previousConnection, permRows] - ); + const presetRows = variant === 'reconnect' ? [] : presetRowsFor(permRows); + const reviewRows = + variant === 'reconnect' && previousConnection !== undefined + ? reconnectRowsFor(permRows, previousConnection) + : permRows; const cacheKey = checkedCacheKey(parsed); const [checked, setChecked] = useState>(() => { const cached = checkedStateCache.get(cacheKey); @@ -551,17 +544,19 @@ function ConnectReview({ const [isConnecting, setIsConnecting] = useState(false); const [failure, setFailure] = useState(null); - const reviewToggleA11yState = useMemo(() => ({ expanded: reviewExpanded }), [reviewExpanded]); - const togglePresetExpanded = useCallback(() => setPresetExpanded((value) => !value), []); + const reviewToggleA11yState = { + expanded: reviewExpanded, + }; + const togglePresetExpanded = () => setPresetExpanded((value) => !value); - const toggleReview = useCallback(() => { + const toggleReview = () => { setReviewExpanded((value) => !value); setReviewPresented(true); setChecked((current) => { checkedStateCache.set(cacheKey, { checked: current, reviewPresented: true }); return current; }); - }, [cacheKey]); + }; // Hot + awaited pairing for the life of the SHEET. Registration is // idempotent, so the resumed-pairing path (already registered at boot) and @@ -577,28 +572,22 @@ function ConnectReview({ return () => releaseIfSheetClosed(parsed); }, [parsed]); - const cacheChecked = useCallback( - (next: Record) => { - const cached = checkedStateCache.get(cacheKey); - checkedStateCache.set(cacheKey, { - checked: next, - reviewPresented: cached?.reviewPresented ?? false, - }); - }, - [cacheKey] - ); + const cacheChecked = (next: Record) => { + const cached = checkedStateCache.get(cacheKey); + checkedStateCache.set(cacheKey, { + checked: next, + reviewPresented: cached?.reviewPresented ?? false, + }); + }; - const toggleRow = useCallback( - (row: PermRow) => { - if (!row.eligible) return; - setChecked((current) => { - const next = { ...current, [row.grantKey]: !current[row.grantKey] }; - cacheChecked(next); - return next; - }); - }, - [cacheChecked] - ); + const toggleRow = (row: PermRow) => { + if (!row.eligible) return; + setChecked((current) => { + const next = { ...current, [row.grantKey]: !current[row.grantKey] }; + cacheChecked(next); + return next; + }); + }; // Review list speaks the editor's bundle vocabulary: one switch per // capability bundle (toggling covers every member key in the review set), @@ -616,22 +605,21 @@ function ConnectReview({ return { bundles, others }; }, [reviewRows]); - const toggleReviewBundle = useCallback( - (rows: readonly PermRow[]) => { - setChecked((current) => { - const allOn = rows.every((row) => current[row.grantKey] === true); - const next = { ...current }; - for (const row of rows) next[row.grantKey] = !allOn; - cacheChecked(next); - return next; - }); - }, - [cacheChecked] - ); + const toggleReviewBundle = (rows: readonly PermRow[]) => { + setChecked((current) => { + const allOn = rows.every((row) => current[row.grantKey] === true); + const next = { ...current }; + for (const row of rows) next[row.grantKey] = !allOn; + cacheChecked(next); + return next; + }); + }; const presetAllChecked = presetRows.every((row) => checked[row.grantKey] === true); - const presetA11yState = useMemo(() => ({ checked: presetAllChecked }), [presetAllChecked]); - const togglePresetAll = useCallback(() => { + const presetA11yState = { + checked: presetAllChecked, + }; + const togglePresetAll = () => { setChecked((current) => { const allOn = presetRows.every((row) => current[row.grantKey] === true); const next = { ...current }; @@ -639,65 +627,51 @@ function ConnectReview({ cacheChecked(next); return next; }); - }, [cacheChecked, presetRows]); - - const connect = useSingleFlight( - useCallback(async () => { - setFailure(null); - setIsConnecting(true); - // Reconnect: grants arrive via ADOPTION, so a never-opened checklist - // presents/accepts nothing (nothing can downgrade). Once the user has - // opened "Review permissions" (sticky), the reviewed rows are the - // contract — unchecking an inherited grant downgrades it post-adoption. - // Other variants: URI rows + preset rows, one accepted/presented union. - const eligibleRows = - variant === 'reconnect' - ? reviewPresented - ? reviewRows - : [] - : [...permRows.filter((row) => row.eligible), ...presetRows]; - const acceptedGrantKeys = eligibleRows - .filter((row) => checked[row.grantKey] === true) - .map((row) => row.grantKey); - const presentedGrantKeys = eligibleRows.map((row) => row.grantKey); - const outcome = await completePairingWhenHot({ - parsed, - acceptedGrantKeys, - presentedGrantKeys, - ...(previousConnection !== undefined && { - replacesClientPubkey: previousConnection.clientPubkey, - }), - }); - setIsConnecting(false); - if (outcome.isErr()) { - nostrLog.warn('nostr.signer.connect_sheet_pairing_failed', { - error: outcome.error.type, - }); - setFailure(failureFor(outcome.error)); - return; - } - checkedStateCache.delete(cacheKey); - const toast = connectedToastCopy(appName); - popup({ message: toast.label, text: toast.description, type: 'success' }); - close(); - }, [ - appName, - cacheKey, - checked, - close, + }; + + const connect = useSingleFlight(async () => { + setFailure(null); + setIsConnecting(true); + // Reconnect: grants arrive via ADOPTION, so a never-opened checklist + // presents/accepts nothing (nothing can downgrade). Once the user has + // opened "Review permissions" (sticky), the reviewed rows are the + // contract — unchecking an inherited grant downgrades it post-adoption. + // Other variants: URI rows + preset rows, one accepted/presented union. + const eligibleRows = + variant === 'reconnect' + ? reviewPresented + ? reviewRows + : [] + : [...permRows.filter((row) => row.eligible), ...presetRows]; + const acceptedGrantKeys = eligibleRows + .filter((row) => checked[row.grantKey] === true) + .map((row) => row.grantKey); + const presentedGrantKeys = eligibleRows.map((row) => row.grantKey); + const outcome = await completePairingWhenHot({ parsed, - permRows, - presetRows, - previousConnection, - reviewPresented, - reviewRows, - variant, - ]) - ); + acceptedGrantKeys, + presentedGrantKeys, + ...(previousConnection !== undefined && { + replacesClientPubkey: previousConnection.clientPubkey, + }), + }); + setIsConnecting(false); + if (outcome.isErr()) { + nostrLog.warn('nostr.signer.connect_sheet_pairing_failed', { + error: outcome.error.type, + }); + setFailure(failureFor(outcome.error)); + return; + } + checkedStateCache.delete(cacheKey); + const toast = connectedToastCopy(appName); + popup({ message: toast.label, text: toast.description, type: 'success' }); + close(); + }); - const openProfilePicker = useCallback(() => { + const openProfilePicker = () => { pushCustomPage('signer-profile-picker', { parsed }); - }, [parsed, pushCustomPage]); + }; const profileDisplayName = resolveIdentityName({ pubkey: activeProfile?.pubkey ?? keys?.pubkey ?? null, @@ -1050,37 +1024,32 @@ export function SignerProfilePickerContent({ // a real close (including after Switch & Connect) releases it. useEffect(() => () => releaseIfSheetClosed(parsed), [parsed]); - const selectProfile = useCallback( - (profile: ProfileEntry) => { - if (profile.accountIndex === activeIndex) { - // Picking the current profile is "never mind" — back to the review. - setSelectedIndex(activeIndex); - popCustomPage(); - return; - } - setSelectedIndex(profile.accountIndex); - }, - [activeIndex, popCustomPage] - ); + const selectProfile = (profile: ProfileEntry) => { + if (profile.accountIndex === activeIndex) { + // Picking the current profile is "never mind" — back to the review. + setSelectedIndex(activeIndex); + popCustomPage(); + return; + } + setSelectedIndex(profile.accountIndex); + }; - const switchAndConnect = useSingleFlight( - useCallback(async () => { - const target = profiles.find((profile) => profile.accountIndex === selectedIndex); - if (target === undefined || target.accountIndex === activeIndex) return; - // The seam orders the teardown: intent persisted (awaited) → sheet - // closed → restart-based switch. On an abort it shows the retry toast - // and the sheet stays open for another attempt. - await switchProfileAndPair( - parsed, - { accountIndex: target.accountIndex, pubkey: target.pubkey }, - close - ); - }, [activeIndex, close, parsed, profiles, selectedIndex]) - ); + const switchAndConnect = useSingleFlight(async () => { + const target = profiles.find((profile) => profile.accountIndex === selectedIndex); + if (target === undefined || target.accountIndex === activeIndex) return; + // The seam orders the teardown: intent persisted (awaited) → sheet + // closed → restart-based switch. On an abort it shows the retry toast + // and the sheet stays open for another attempt. + await switchProfileAndPair( + parsed, + { accountIndex: target.accountIndex, pubkey: target.pubkey }, + close + ); + }); - const onSwitchAndConnect = useCallback(() => { + const onSwitchAndConnect = () => { void switchAndConnect(); - }, [switchAndConnect]); + }; const showRestartWarning = selectedIndex !== activeIndex; diff --git a/features/nostrSigner/components/SummaryPreviewCards.tsx b/features/nostrSigner/components/SummaryPreviewCards.tsx index 0003ceac4..f3db5669f 100644 --- a/features/nostrSigner/components/SummaryPreviewCards.tsx +++ b/features/nostrSigner/components/SummaryPreviewCards.tsx @@ -12,7 +12,7 @@ * length-bounded before display and never logged. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { Platform } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import { Button as HerouiButton } from 'heroui-native'; @@ -217,10 +217,10 @@ export function FollowDiffCard({ 'danger', 'warning', ] as const); - const shown = useMemo( - () => [...detail.added.slice(0, FOLLOW_ROW_CAP), ...detail.removed.slice(0, FOLLOW_ROW_CAP)], - [detail.added, detail.removed] - ); + const shown = [ + ...detail.added.slice(0, FOLLOW_ROW_CAP), + ...detail.removed.slice(0, FOLLOW_ROW_CAP), + ]; // Warm the kind-0 cache for every shown row in one batched subscription. useNostrProfileMetadataMany(shown); @@ -370,17 +370,14 @@ export function ExpandableEventJson({ }) { const [expanded, setExpanded] = useState(false); const [foreground, muted] = useThemeColor(['foreground', 'muted'] as const); - const fullJson = useMemo(() => JSON.stringify(event, null, 2), [event]); - const displayJson = useMemo( - () => boundDisplay(fullJson, FULL_EVENT_DISPLAY_MAX_CHARS), - [fullJson] - ); + const fullJson = JSON.stringify(event, null, 2); + const displayJson = boundDisplay(fullJson, FULL_EVENT_DISPLAY_MAX_CHARS); - const toggle = useCallback(() => setExpanded((value) => !value), []); - const copyJson = useCallback(() => { + const toggle = () => setExpanded((value) => !value); + const copyJson = () => { void Clipboard.setStringAsync(fullJson); popup({ message: 'Copied', type: 'success', variant: 'toast', duration: 1500 }); - }, [fullJson]); + }; return ( diff --git a/features/nostrSigner/hooks/useReferencedEventPreview.ts b/features/nostrSigner/hooks/useReferencedEventPreview.ts index ea5ba8dbf..2c5eca70f 100644 --- a/features/nostrSigner/hooks/useReferencedEventPreview.ts +++ b/features/nostrSigner/hooks/useReferencedEventPreview.ts @@ -115,7 +115,7 @@ export function useReferencedEventPreview(eventId: string | undefined): Referenc const { metadata } = useNostrProfileMetadata(fallbackAuthorPubkey); if (eventId === undefined) return { status: 'idle' }; - if (resolved === null || resolved.id !== eventId) return { status: 'loading' }; + if (resolved?.id !== eventId) return { status: 'loading' }; if (resolved.entry === 'missing') return { status: 'unavailable' }; const author = resolved.entry.author ?? diff --git a/features/nostrSigner/lib/nip46Engine.ts b/features/nostrSigner/lib/nip46Engine.ts index a9627597a..f3c102b9d 100644 --- a/features/nostrSigner/lib/nip46Engine.ts +++ b/features/nostrSigner/lib/nip46Engine.ts @@ -62,6 +62,7 @@ import { type BunkerSecretsError, } from '@/features/nostrSigner/lib/bunkerSecrets'; import { safeJsonParse } from '@/features/nostrSigner/lib/json'; +import { parseMethodParams } from '@/features/nostrSigner/lib/nip46RequestParse'; import { extractSignedEventId, isExecutableMethod, @@ -78,7 +79,6 @@ import { REQUEST_TTL_MS, RpcRequestSchema, SUMMARY_MAX_LENGTH, - UnsignedEventSchema, type ActivityVerdict, type GrantKey, type Nip46Method, @@ -214,12 +214,6 @@ interface EngineState { signer: NDKPrivateKeySigner; } -interface ParsedSignParams { - unsigned: UnsignedEvent | null; - kind: number | undefined; - preview: Nip46ParamsPreview; -} - export interface Nip46Engine { readonly isStarted: boolean; start(config: Nip46EngineStartConfig): Result; @@ -514,51 +508,6 @@ export function createNip46Engine(overrides: Partial = {}): Nip } } - // ── Request-shape validation (per-method params → kind/preview) ─ - - function parseMethodParams(request: RpcRequest): Result { - if (request.method === 'sign_event') { - const raw = request.params[0]; - if (raw === undefined) return err('malformed'); - const json = safeJsonParse(raw); - if (json.isErr()) return err('malformed'); - const unsigned = UnsignedEventSchema.safeParse(json.value); - if (!unsigned.success) return err('malformed'); - return ok({ - unsigned: unsigned.data, - kind: unsigned.data.kind, - preview: { type: 'sign_event', event: unsigned.data }, - }); - } - if (request.method === 'nip04_encrypt' || request.method === 'nip44_encrypt') { - const [peer, plaintext] = request.params; - if (peer === undefined || plaintext === undefined || !isNostrPubkeyHex(peer)) { - return err('malformed'); - } - return ok({ - unsigned: null, - kind: undefined, - preview: { type: 'encrypt', peerPubkey: peer.toLowerCase(), plaintext }, - }); - } - if (request.method === 'nip04_decrypt' || request.method === 'nip44_decrypt') { - const [peer, ciphertext] = request.params; - if (peer === undefined || ciphertext === undefined || !isNostrPubkeyHex(peer)) { - return err('malformed'); - } - return ok({ - unsigned: null, - kind: undefined, - preview: { - type: 'decrypt', - peerPubkey: peer.toLowerCase(), - ciphertextLength: ciphertext.length, - }, - }); - } - return ok({ unsigned: null, kind: undefined, preview: { type: 'none' } }); - } - // ── Inbound pipeline ────────────────────────────────────────── async function processEvent(event: NDKEvent): Promise { diff --git a/features/nostrSigner/lib/nip46RequestParse.ts b/features/nostrSigner/lib/nip46RequestParse.ts new file mode 100644 index 000000000..723a40ab0 --- /dev/null +++ b/features/nostrSigner/lib/nip46RequestParse.ts @@ -0,0 +1,69 @@ +/** + * NIP-46 request-shape parsing — the pure pre-gate step. + * + * Turns a decoded `RpcRequest` into a `{ unsigned, kind, preview }` shape: it + * validates per-method params and builds the approval preview. It is a pure + * function of its argument — no engine state, no key access, no I/O — and makes + * NO authorization decision (the policy gates run later in the engine pipeline). + * Kept out of `nip46Engine` so the security-critical inbound pipeline there + * stays a single, source-ordered read of the gates. + */ +import { err, ok, Result } from 'neverthrow'; + +import type { Nip46ParamsPreview } from '@/features/nostrSigner/data/nip46RequestsStore'; +import { safeJsonParse } from '@/features/nostrSigner/lib/json'; +import { + UnsignedEventSchema, + type RpcRequest, + type UnsignedEvent, +} from '@/features/nostrSigner/lib/nip46Types'; +import { isNostrPubkeyHex } from '@/shared/lib/nostr/secureStorage'; + +interface ParsedSignParams { + unsigned: UnsignedEvent | null; + kind: number | undefined; + preview: Nip46ParamsPreview; +} + +export function parseMethodParams(request: RpcRequest): Result { + if (request.method === 'sign_event') { + const raw = request.params[0]; + if (raw === undefined) return err('malformed'); + const json = safeJsonParse(raw); + if (json.isErr()) return err('malformed'); + const unsigned = UnsignedEventSchema.safeParse(json.value); + if (!unsigned.success) return err('malformed'); + return ok({ + unsigned: unsigned.data, + kind: unsigned.data.kind, + preview: { type: 'sign_event', event: unsigned.data }, + }); + } + if (request.method === 'nip04_encrypt' || request.method === 'nip44_encrypt') { + const [peer, plaintext] = request.params; + if (peer === undefined || plaintext === undefined || !isNostrPubkeyHex(peer)) { + return err('malformed'); + } + return ok({ + unsigned: null, + kind: undefined, + preview: { type: 'encrypt', peerPubkey: peer.toLowerCase(), plaintext }, + }); + } + if (request.method === 'nip04_decrypt' || request.method === 'nip44_decrypt') { + const [peer, ciphertext] = request.params; + if (peer === undefined || ciphertext === undefined || !isNostrPubkeyHex(peer)) { + return err('malformed'); + } + return ok({ + unsigned: null, + kind: undefined, + preview: { + type: 'decrypt', + peerPubkey: peer.toLowerCase(), + ciphertextLength: ciphertext.length, + }, + }); + } + return ok({ unsigned: null, kind: undefined, preview: { type: 'none' } }); +} diff --git a/features/nostrSigner/lib/permissionPolicy.ts b/features/nostrSigner/lib/permissionPolicy.ts index 721dbf9f1..b5eb6fa65 100644 --- a/features/nostrSigner/lib/permissionPolicy.ts +++ b/features/nostrSigner/lib/permissionPolicy.ts @@ -308,7 +308,7 @@ export function evaluate(input: EvaluateInput): PolicyDecision { }; } const peerGrant = connection.peerDecryptGrants?.[decryptPeer]; - if (peerGrant !== undefined && peerGrant.methods.includes(request.method)) { + if (peerGrant?.methods.includes(request.method)) { return { verdict: 'allow', reason: 'peer_grant_always', diff --git a/features/nostrSigner/screens/ShareSignerScreen.tsx b/features/nostrSigner/screens/ShareSignerScreen.tsx index 3a093cb45..f4661b329 100644 --- a/features/nostrSigner/screens/ShareSignerScreen.tsx +++ b/features/nostrSigner/screens/ShareSignerScreen.tsx @@ -18,7 +18,7 @@ * green "Connected" badge there would be misleading exactly when it matters. */ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import * as Clipboard from 'expo-clipboard'; import { useFocusEffect } from 'expo-router'; import { Button as HerouiButton, ListGroup, Separator } from 'heroui-native'; @@ -69,64 +69,57 @@ export function ShareSignerScreen(): React.ReactElement { const { keys } = useNostrKeysContext(); const [state, setState] = useState({ status: 'loading' }); - const regenerate = useCallback( - (rotate: boolean) => { - const pubkey = keys?.pubkey; - if (pubkey === undefined) return; // keys still deriving — refires below - setState({ status: 'loading' }); - const minted = rotate - ? clearSecrets(pubkey).andThen(() => mintSecret(pubkey)) - : mintSecret(pubkey); - void minted.match( - (secret) => { - setState({ - status: 'ready', - uri: buildBunkerUri({ - signerPubkey: pubkey, - relays: [...defaultSignerRelays], - secret, - }), - }); - }, - (error) => { - // Error type only — the secret is a bearer credential. - nostrLog.error('nostr.signer.share_mint_failed', { error: error.type }); - setState({ status: 'error' }); - } - ); - }, - [keys?.pubkey] - ); + const regenerate = (rotate: boolean) => { + const pubkey = keys?.pubkey; + if (pubkey === undefined) return; // keys still deriving — refires below + setState({ status: 'loading' }); + const minted = rotate + ? clearSecrets(pubkey).andThen(() => mintSecret(pubkey)) + : mintSecret(pubkey); + void minted.match( + (secret) => { + setState({ + status: 'ready', + uri: buildBunkerUri({ + signerPubkey: pubkey, + relays: [...defaultSignerRelays], + secret, + }), + }); + }, + (error) => { + // Error type only — the secret is a bearer credential. + nostrLog.error('nostr.signer.share_mint_failed', { error: error.type }); + setState({ status: 'error' }); + } + ); + }; // Fresh secret on every focus; hot engine while visible so the incoming // `connect` is answered without a cold start. The callback identity changes // when keys finish deriving, which re-runs the focus effect — that is the // keys-arrived-while-focused path. - useFocusEffect( - useCallback(() => { - useNip46RequestsStore.getState().setServiceHotRequested(true); - regenerate(false); - return () => { - useNip46RequestsStore.getState().setServiceHotRequested(false); - }; - }, [regenerate]) - ); - - const copyLink = useSingleFlight( - useCallback(async () => { - if (state.status !== 'ready') return; - await Clipboard.setStringAsync(state.uri); - popup({ message: 'Copied', type: 'success', variant: 'toast', duration: 1500 }); - }, [state]) - ); - - const confirmRotate = useCallback(() => { + useFocusEffect(() => { + useNip46RequestsStore.getState().setServiceHotRequested(true); + regenerate(false); + return () => { + useNip46RequestsStore.getState().setServiceHotRequested(false); + }; + }); + + const copyLink = useSingleFlight(async () => { + if (state.status !== 'ready') return; + await Clipboard.setStringAsync(state.uri); + popup({ message: 'Copied', type: 'success', variant: 'toast', duration: 1500 }); + }); + + const confirmRotate = () => { popup({ message: ROTATE_CONFIRM_TITLE, text: ROTATE_CONFIRM_BODY, buttons: [{ text: ROTATE_SECRET_LABEL, onPress: () => regenerate(true) }, { text: 'Cancel' }], }); - }, [regenerate]); + }; return ( @@ -166,7 +159,6 @@ export function ShareSignerScreen(): React.ReactElement { )} - {state.status === 'error' ? ( @@ -193,7 +185,6 @@ export function ShareSignerScreen(): React.ReactElement { )} -
{defaultSignerRelays.map((relay, index) => ( @@ -208,7 +199,6 @@ export function ShareSignerScreen(): React.ReactElement { ))}
-
@@ -227,7 +217,6 @@ export function ShareSignerScreen(): React.ReactElement {
- ); diff --git a/features/nostrSigner/screens/SignerActivityDetailScreen.tsx b/features/nostrSigner/screens/SignerActivityDetailScreen.tsx index cc2e1385d..1b4dfd55f 100644 --- a/features/nostrSigner/screens/SignerActivityDetailScreen.tsx +++ b/features/nostrSigner/screens/SignerActivityDetailScreen.tsx @@ -15,7 +15,7 @@ * Route params: `id` — the activity entry id. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import * as Clipboard from 'expo-clipboard'; import { useLocalSearchParams } from 'expo-router'; import { ListGroup, Separator } from 'heroui-native'; @@ -83,10 +83,10 @@ function isEncryptionMethod(method: Nip46Method): boolean { function CopyableEventId({ eventId }: { eventId: string }) { const [foreground, muted] = useThemeColor(['foreground', 'muted'] as const); - const copy = useCallback(() => { + const copy = () => { void Clipboard.setStringAsync(eventId); popup({ message: 'Copied', type: 'success', variant: 'toast', duration: 1500 }); - }, [eventId]); + }; return ( { + router.push(`/(signer-flow)/activity-detail?id=${encodeURIComponent(entryId)}` as never); +}; +const keyExtractor = (item: Nip46ActivityEntry) => item.id; + export function SignerActivityScreen(): React.ReactElement { const params = useLocalSearchParams<{ clientPubkey?: string }>(); const entries = useNip46ActivityStore((s) => s.entries); @@ -141,67 +146,55 @@ export function SignerActivityScreen(): React.ReactElement { }, [params.clientPubkey]); const [filter, setFilter] = useState(initialFilter); - const toneColors = useMemo( - () => ({ success, danger, warning }) as const, - [success, danger, warning] - ); + const toneColors = { success, danger, warning } as const; - const clientPubkeys = useMemo(() => distinctClients(entries, apps), [entries, apps]); + const clientPubkeys = distinctClients(entries, apps); // Per-app filter allowed set: the live key plus every key it replaced. const appFilterKeys = useMemo(() => { if (filter.kind !== 'app') return null; const live = apps[filter.clientPubkey]; return new Set([filter.clientPubkey, ...(live?.previousClientPubkeys ?? [])]); }, [filter, apps]); - const filtered = useMemo( - () => entries.filter((entry) => entryMatchesFilter(entry, filter, appFilterKeys)), - [entries, filter, appFilterKeys] - ); + const filtered = entries.filter((entry) => entryMatchesFilter(entry, filter, appFilterKeys)); // Content scrolls UNDER the transparent blur header (thread-page style). const headerHeight = useHeaderHeight(); const insets = useSafeAreaInsets(); - const listContentStyle = useMemo( - () => ({ paddingTop: headerHeight, paddingBottom: insets.bottom }), - [headerHeight, insets.bottom] - ); - const indicatorInsets = useMemo(() => ({ top: headerHeight }), [headerHeight]); - - const openDetail = useCallback((entryId: string) => { - router.push(`/(signer-flow)/activity-detail?id=${encodeURIComponent(entryId)}` as never); - }, []); - const keyExtractor = useCallback((item: Nip46ActivityEntry) => item.id, []); + const listContentStyle = { + paddingTop: headerHeight, + paddingBottom: insets.bottom, + }; + const indicatorInsets = { + top: headerHeight, + }; - const renderItem = useCallback( - ({ item }: { item: Nip46ActivityEntry }) => { - const display = ACTIVITY_VERDICT_DISPLAY[item.verdict]; - const entry = permissionEntryFor({ - method: item.method, - ...(item.kind !== undefined && { kind: item.kind }), - }); - const appName = appDisplayName(connectionForClient(apps, item.clientPubkey)); - return ( - - {display.accentLine} - - ) : undefined - } - trailing={} - onPress={() => openDetail(item.id)} - accessibilityHint="Opens the request details" - /> - ); - }, - [apps, muted, openDetail, toneColors] - ); + const renderItem = ({ item }: { item: Nip46ActivityEntry }) => { + const display = ACTIVITY_VERDICT_DISPLAY[item.verdict]; + const entry = permissionEntryFor({ + method: item.method, + ...(item.kind !== undefined && { kind: item.kind }), + }); + const appName = appDisplayName(connectionForClient(apps, item.clientPubkey)); + return ( + + {display.accentLine} + + ) : undefined + } + trailing={} + onPress={() => openDetail(item.id)} + accessibilityHint="Opens the request details" + /> + ); + }; const chips = ( [HEADER_IDENTITY_ROW_STYLE, fadeStyle], [fadeStyle]); + const composed = [HEADER_IDENTITY_ROW_STYLE, fadeStyle]; return ( ({ paddingTop: headerHeight, paddingBottom: 32 + insets.bottom }), - [headerHeight, insets.bottom] - ); - const indicatorInsets = useMemo(() => ({ top: headerHeight }), [headerHeight]); + const scrollContentStyle = { paddingTop: headerHeight, paddingBottom: 32 + insets.bottom }; + const indicatorInsets = { top: headerHeight }; // ── Scroll-linked identity handoff (content ↔ header) ──────── const flipProgress = useSharedValue(0); @@ -324,10 +321,7 @@ export function SignerAppDetailScreen(): React.ReactElement { const contentIdentityFade = useAnimatedStyle(() => ({ opacity: interpolate(flipProgress.value, CONTENT_FADE_PHASE, [1, 0], Extrapolation.CLAMP), })); - const contentIdentityComposed = useMemo( - () => [CONTENT_IDENTITY_STYLE, contentIdentityFade], - [contentIdentityFade] - ); + const contentIdentityComposed = [CONTENT_IDENTITY_STYLE, contentIdentityFade]; const appImage = app?.image; useScreenOptions( () => @@ -346,7 +340,7 @@ export function SignerAppDetailScreen(): React.ReactElement { // flipProgress is a stable shared-value ref; app presence tracked via appImage/appName. [appName, appImage, clientPubkey, app === undefined] ); - const dangerTextStyle = useMemo(() => ({ color: danger }), [danger]); + const dangerTextStyle = { color: danger }; // Top-level groups: capability bundles (one human concept per row) plus // the locked per-key rows (deletion / decrypt / wallet). Per-action @@ -379,13 +373,10 @@ export function SignerAppDetailScreen(): React.ReactElement { // hides session affordances on strict apps), so the persisted labels are // the truthful ones. const sessionAllows = useNip46RequestsStore((s) => s.sessionAllows); - const appSessionAllows = useMemo( - () => - clientPubkey === undefined - ? [] - : sessionAllows.filter((allow) => allow.clientPubkey === clientPubkey), - [sessionAllows, clientPubkey] - ); + const appSessionAllows = + clientPubkey === undefined + ? [] + : sessionAllows.filter((allow) => allow.clientPubkey === clientPubkey); const decryptAccessRows = useMemo(() => { const rows = new Map(); @@ -416,25 +407,19 @@ export function SignerAppDetailScreen(): React.ReactElement { return [...rows.values()].sort((a, b) => a.peer.localeCompare(b.peer)); }, [app?.peerDecryptGrants, appSessionGrants]); - const openPerson = useCallback( - (peer: string) => { - if (clientPubkey === undefined) return; - router.push(`/(signer-flow)/app-person?clientPubkey=${clientPubkey}&peer=${peer}` as never); - }, - [clientPubkey] - ); + const openPerson = (peer: string) => { + if (clientPubkey === undefined) return; + router.push(`/(signer-flow)/app-person?clientPubkey=${clientPubkey}&peer=${peer}` as never); + }; // Master switch over the whole people list — confirmed before applying, // because the change cascades to everyone below. const everyoneAllowed = decryptAccessRows.length > 0 && decryptAccessRows.every((row) => app?.peerDecryptGrants[row.peer] !== undefined); - const everyoneA11yValue = useMemo( - () => ({ text: everyoneAllowed ? 'Always' : 'Ask' }), - [everyoneAllowed] - ); + const everyoneA11yValue = { text: everyoneAllowed ? 'Always' : 'Ask' }; - const onToggleEveryone = useCallback(() => { + const onToggleEveryone = () => { if (clientPubkey === undefined) return; const peers = decryptAccessRows.map((row) => row.peer); const count = peers.length; @@ -516,16 +501,7 @@ export function SignerAppDetailScreen(): React.ReactElement { { text: 'Cancel', variant: 'secondary', onPress: (close) => close() }, ], }); - }, [ - clientPubkey, - decryptAccessRows, - everyoneAllowed, - appName, - app?.peerDecryptGrants, - setPeerDecryptGrant, - revokePeerDecryptGrant, - revokeSessionGrant, - ]); + }; // Back to the SAME defaults the connect sheet applies: clear everything, // then re-grant the common-social-actions preset. @@ -564,8 +540,8 @@ export function SignerAppDetailScreen(): React.ReactElement { if (clientPubkey === undefined) return; setMode(clientPubkey, strictModeOn ? 'standard' : 'strict'); }, [clientPubkey, setMode, strictModeOn]); - const strictA11yState = useMemo(() => ({ checked: strictModeOn }), [strictModeOn]); - const openStrictMenu = useCallback(() => { + const strictA11yState = { checked: strictModeOn }; + const openStrictMenu = () => { if (clientPubkey === undefined) return; const checkSuffix = ; actionMenuPopup({ @@ -594,19 +570,16 @@ export function SignerAppDetailScreen(): React.ReactElement { }, ], }); - }, [clientPubkey, muted, setMode, strictModeOn]); + }; - const onSelectTriState = useCallback( - (grantKey: GrantKey, state: TriState) => { - if (clientPubkey === undefined) return; - const verdict = state === 'ask' ? null : state === 'allow' ? 'always' : 'deny'; - const result = setGrant(clientPubkey, grantKey, verdict); - if (result.isErr()) { - nostrLog.warn('nostr.signer.app_detail.set_grant_failed', { error: result.error }); - } - }, - [clientPubkey, setGrant] - ); + const onSelectTriState = (grantKey: GrantKey, state: TriState) => { + if (clientPubkey === undefined) return; + const verdict = state === 'ask' ? null : state === 'allow' ? 'always' : 'deny'; + const result = setGrant(clientPubkey, grantKey, verdict); + if (result.isErr()) { + nostrLog.warn('nostr.signer.app_detail.set_grant_failed', { error: result.error }); + } + }; // One tap covers the whole bundle; a mixed bundle self-heals to the choice. const onSelectBundleState = useCallback( @@ -628,17 +601,14 @@ export function SignerAppDetailScreen(): React.ReactElement { [clientPubkey, setGrant] ); - const openFineGrained = useCallback( - (group: PermissionEditorGroup) => { - if (clientPubkey === undefined) return; - router.push( - `/(signer-flow)/app-permissions?clientPubkey=${clientPubkey}&group=${group}` as never - ); - }, - [clientPubkey] - ); + const openFineGrained = (group: PermissionEditorGroup) => { + if (clientPubkey === undefined) return; + router.push( + `/(signer-flow)/app-permissions?clientPubkey=${clientPubkey}&group=${group}` as never + ); + }; - const openRename = useCallback(() => { + const openRename = () => { if (clientPubkey === undefined) return; actionMenuPopup({ title: 'Rename App', @@ -660,14 +630,14 @@ export function SignerAppDetailScreen(): React.ReactElement { }, }, }); - }, [app?.name, appName, clientPubkey, renameApp]); + }; - const openActivity = useCallback(() => { + const openActivity = () => { if (clientPubkey === undefined) return; router.push(`/(signer-flow)/activity?clientPubkey=${clientPubkey}` as never); - }, [clientPubkey]); + }; - const confirmDisconnect = useCallback(() => { + const confirmDisconnect = () => { if (clientPubkey === undefined) return; const nameAtConfirm = appName; actionMenuPopup({ @@ -700,7 +670,7 @@ export function SignerAppDetailScreen(): React.ReactElement { { text: 'Cancel', variant: 'secondary', onPress: (close) => close() }, ], }); - }, [appName, clientPubkey, disconnectApp, revokeSessionGrant, revokeSessionAllows]); + }; // Unknown pubkey, or the app was just disconnected — transient empty frame. if (clientPubkey === undefined || app === undefined) { diff --git a/features/nostrSigner/screens/SignerAppPersonScreen.tsx b/features/nostrSigner/screens/SignerAppPersonScreen.tsx index b1a2185b7..9d6465171 100644 --- a/features/nostrSigner/screens/SignerAppPersonScreen.tsx +++ b/features/nostrSigner/screens/SignerAppPersonScreen.tsx @@ -10,7 +10,7 @@ * Route params: `clientPubkey` + `peer` (both 64-hex). */ -import React, { useCallback, useMemo } from 'react'; +import React from 'react'; import { router, useLocalSearchParams } from 'expo-router'; import { ListGroup, PressableFeedback, Switch as HeroSwitch } from 'heroui-native'; import { z } from 'zod'; @@ -63,44 +63,38 @@ export function SignerAppPersonScreen(): React.ReactElement { const person = useNostrPersonDisplay(peer); const [foreground, muted, danger] = useThemeColor(['foreground', 'muted', 'danger'] as const); - const hasSessionAccess = useMemo( - () => - clientPubkey !== undefined && - peer !== undefined && - sessionGrants.some((g) => g.clientPubkey === clientPubkey && g.peerPubkey === peer), - [sessionGrants, clientPubkey, peer] - ); + const hasSessionAccess = + clientPubkey !== undefined && + peer !== undefined && + sessionGrants.some((g) => g.clientPubkey === clientPubkey && g.peerPubkey === peer); const hasPersistentGrant = peer !== undefined && app?.peerDecryptGrants[peer] !== undefined; const allowDescription = !hasPersistentGrant && hasSessionAccess ? SESSION_DESCRIPTION : ALLOW_DESCRIPTION; - const onToggleAllow = useCallback( - (selected: boolean) => { - if (clientPubkey === undefined || peer === undefined) return; - if (!selected) { - revokePeerDecryptGrant(clientPubkey, peer); - return; - } - let failed = 0; - for (const method of ['nip04_decrypt', 'nip44_decrypt'] as const) { - const result = setPeerDecryptGrant(clientPubkey, peer, method, { peerIsSelf: false }); - if (result.isErr()) failed += 1; - } - if (failed > 0) { - nostrLog.warn('nostr.signer.app_person.grant_failed', { failed }); - } - }, - [clientPubkey, peer, setPeerDecryptGrant, revokePeerDecryptGrant] - ); - - const revokeAll = useCallback(() => { + const onToggleAllow = (selected: boolean) => { + if (clientPubkey === undefined || peer === undefined) return; + if (!selected) { + revokePeerDecryptGrant(clientPubkey, peer); + return; + } + let failed = 0; + for (const method of ['nip04_decrypt', 'nip44_decrypt'] as const) { + const result = setPeerDecryptGrant(clientPubkey, peer, method, { peerIsSelf: false }); + if (result.isErr()) failed += 1; + } + if (failed > 0) { + nostrLog.warn('nostr.signer.app_person.grant_failed', { failed }); + } + }; + + const revokeAll = () => { if (clientPubkey === undefined || peer === undefined) return; revokePeerDecryptGrant(clientPubkey, peer); revokeSessionGrant(clientPubkey, undefined, peer); popup({ message: REVOKED_TOAST, type: 'success', variant: 'toast', duration: 1500 }); router.back(); - }, [clientPubkey, peer, revokePeerDecryptGrant, revokeSessionGrant]); + }; if (clientPubkey === undefined || peer === undefined || app === undefined) { return ( diff --git a/features/nostrSigner/screens/SignerHubScreen.tsx b/features/nostrSigner/screens/SignerHubScreen.tsx index 68ef21af2..cd62fcae3 100644 --- a/features/nostrSigner/screens/SignerHubScreen.tsx +++ b/features/nostrSigner/screens/SignerHubScreen.tsx @@ -23,7 +23,7 @@ * logged. */ -import React, { useCallback, useMemo } from 'react'; +import React from 'react'; import { ListGroup, Separator } from 'heroui-native'; import Icon from 'assets/icons'; @@ -100,6 +100,65 @@ function connectionSubtitle(connection: Nip46Connection): string { // ── Screen ────────────────────────────────────────────────────── +// ── Navigation ──────────────────────────────────────────────── + +const openRequests = () => { + router.push('/(signer-flow)/requests' as never); +}; + +const openAppDetail = (clientPubkey: string) => { + // Hex pubkey — URL-safe by construction, no encoding needed. + router.push(`/(signer-flow)/app?clientPubkey=${clientPubkey}` as never); +}; + +const openScan = () => { + // signer-pair mode: the standalone camera only accepts NIP-46 URIs. + router.navigate({ pathname: '/camera', params: { action: 'signer-pair' } }); +}; + +const openShare = () => { + router.push('/(signer-flow)/share' as never); +}; + +const openActivity = () => { + router.push('/(signer-flow)/activity' as never); +}; + +// ── Paste Connection Link ───────────────────────────────────── + +const openPasteLink = () => { + actionMenuPopup({ + title: PASTE_LINK_LABEL, + inputs: [ + { + id: 'uri', + placeholder: 'nostrconnect://…', + description: PASTE_PROMPT_BODY, + autoCapitalize: 'none', + autoCorrect: false, + }, + ], + primaryAction: { + text: PASTE_CONNECT_LABEL, + isDisabled: (values) => !values.uri || values.uri.trim().length === 0, + onPress: (values, { setError, close }) => { + const raw = (values.uri ?? '').trim(); + // The URI embeds the pairing secret — never log it. Shared Layer-4 + // dispatch: validate → hot flag → engine pairing → close the menu + // (beforeOpen) → connect sheet. + const opened = openPairingFromUri(raw, { beforeOpen: close }); + if (opened.isErr()) { + setError( + opened.error.type === 'bunker-unsupported' + ? PAIRING_ERROR_BUNKER + : PAIRING_ERROR_INVALID_LINK + ); + } + }, + }, + }); +}; + export function SignerHubScreen(): React.ReactElement { useLifecycleLogger('SignerHubScreen'); const pendingCount = useNip46RequestsStore((s) => s.pending.length); @@ -111,78 +170,17 @@ export function SignerHubScreen(): React.ReactElement { 'danger', 'muted', ] as const); - const dangerTextStyle = useMemo(() => ({ color: danger }), [danger]); + const dangerTextStyle = { + color: danger, + }; - const connections = useMemo( - () => - Object.values(apps).sort( - (a, b) => (b.lastUsedAt ?? b.pairedAt) - (a.lastUsedAt ?? a.pairedAt) - ), - [apps] + const connections = Object.values(apps).sort( + (a, b) => (b.lastUsedAt ?? b.pairedAt) - (a.lastUsedAt ?? a.pairedAt) ); - // ── Navigation ──────────────────────────────────────────────── - - const openRequests = useCallback(() => { - router.push('/(signer-flow)/requests' as never); - }, []); - - const openAppDetail = useCallback((clientPubkey: string) => { - // Hex pubkey — URL-safe by construction, no encoding needed. - router.push(`/(signer-flow)/app?clientPubkey=${clientPubkey}` as never); - }, []); - - const openScan = useCallback(() => { - // signer-pair mode: the standalone camera only accepts NIP-46 URIs. - router.navigate({ pathname: '/camera', params: { action: 'signer-pair' } }); - }, []); - - const openShare = useCallback(() => { - router.push('/(signer-flow)/share' as never); - }, []); - - const openActivity = useCallback(() => { - router.push('/(signer-flow)/activity' as never); - }, []); - - // ── Paste Connection Link ───────────────────────────────────── - - const openPasteLink = useCallback(() => { - actionMenuPopup({ - title: PASTE_LINK_LABEL, - inputs: [ - { - id: 'uri', - placeholder: 'nostrconnect://…', - description: PASTE_PROMPT_BODY, - autoCapitalize: 'none', - autoCorrect: false, - }, - ], - primaryAction: { - text: PASTE_CONNECT_LABEL, - isDisabled: (values) => !values.uri || values.uri.trim().length === 0, - onPress: (values, { setError, close }) => { - const raw = (values.uri ?? '').trim(); - // The URI embeds the pairing secret — never log it. Shared Layer-4 - // dispatch: validate → hot flag → engine pairing → close the menu - // (beforeOpen) → connect sheet. - const opened = openPairingFromUri(raw, { beforeOpen: close }); - if (opened.isErr()) { - setError( - opened.error.type === 'bunker-unsupported' - ? PAIRING_ERROR_BUNKER - : PAIRING_ERROR_INVALID_LINK - ); - } - }, - }, - }); - }, []); - // ── Reset Remote Login ──────────────────────────────────────── - const confirmReset = useCallback(() => { + const confirmReset = () => { const activePubkey = keys?.pubkey; actionMenuPopup({ title: RESET_SIGNER_LABEL, @@ -208,7 +206,7 @@ export function SignerHubScreen(): React.ReactElement { { text: 'Cancel', variant: 'secondary', onPress: (close) => close() }, ], }); - }, [keys?.pubkey]); + }; // ── Render ──────────────────────────────────────────────────── diff --git a/features/nostrSigner/screens/SignerRequestsScreen.tsx b/features/nostrSigner/screens/SignerRequestsScreen.tsx index 2c4613a42..aab711fd2 100644 --- a/features/nostrSigner/screens/SignerRequestsScreen.tsx +++ b/features/nostrSigner/screens/SignerRequestsScreen.tsx @@ -22,7 +22,7 @@ * none of it is ever logged. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { useFocusEffect } from 'expo-router'; import { Button as HerouiButton } from 'heroui-native'; import Animated, { FadeOut, LinearTransition } from 'react-native-reanimated'; @@ -170,6 +170,11 @@ function tierIconColor( // ── Screen ────────────────────────────────────────────────────── +const reviewRequest = (id: string) => { + useNip46RequestsStore.getState().promote(id); + showActionSheet('signer-approval', {}); +}; + export function SignerRequestsScreen(): React.ReactElement { useLifecycleLogger('SignerRequestsScreen'); const pending = useNip46RequestsStore((s) => s.pending); @@ -184,28 +189,18 @@ export function SignerRequestsScreen(): React.ReactElement { // 1s ticker, focused-only: drives the countdowns and the local "hide at // expiry" filter (the engine sweep deletes for real within 10s). const [now, setNow] = useState(() => Date.now()); - useFocusEffect( - useCallback(() => { - setNow(Date.now()); - const timer = setInterval(() => setNow(Date.now()), TICK_MS); - return () => clearInterval(timer); - }, []) - ); + useFocusEffect(() => { + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), TICK_MS); + return () => clearInterval(timer); + }); - const groups = useMemo( - () => groupByApp(pending.filter((request) => request.expiresAt > now)), - [pending, now] - ); + const groups = groupByApp(pending.filter((request) => request.expiresAt > now)); // ── Verdict plumbing ────────────────────────────────────────── - const reviewRequest = useCallback((id: string) => { - useNip46RequestsStore.getState().promote(id); - showActionSheet('signer-approval', {}); - }, []); - const resolveBatch = useSingleFlight( - useCallback(async (clientPubkey: string, action: 'approve_once' | 'deny_once') => { + async (clientPubkey: string, action: 'approve_once' | 'deny_once') => { // Re-derive from live state: requests may have resolved/expired since // the chip was tapped, and approvals stay fail-closed to the standard // tier even if a sensitive request slipped in after the confirm. @@ -224,34 +219,31 @@ export function SignerRequestsScreen(): React.ReactElement { }); } } - }, []) + } ); - const confirmAllowAll = useCallback( - (clientPubkey: string, appName: string, count: number) => { - actionMenuPopup({ - title: batchConfirmTitle(count, appName), - header: ( - - - {BATCH_CONFIRM_BODY} - - - ), - buttons: [ - { - text: allowAllLabel(count), - onPress: (close) => { - close(); - void resolveBatch(clientPubkey, 'approve_once'); - }, + const confirmAllowAll = (clientPubkey: string, appName: string, count: number) => { + actionMenuPopup({ + title: batchConfirmTitle(count, appName), + header: ( + + + {BATCH_CONFIRM_BODY} + + + ), + buttons: [ + { + text: allowAllLabel(count), + onPress: (close) => { + close(); + void resolveBatch(clientPubkey, 'approve_once'); }, - { text: BATCH_CANCEL_LABEL, variant: 'secondary' }, - ], - }); - }, - [muted, resolveBatch] - ); + }, + { text: BATCH_CANCEL_LABEL, variant: 'secondary' }, + ], + }); + }; // ── Render ──────────────────────────────────────────────────── diff --git a/features/onboarding/components/OnboardingInnerCarousel.tsx b/features/onboarding/components/OnboardingInnerCarousel.tsx index e394a515d..e950e0721 100644 --- a/features/onboarding/components/OnboardingInnerCarousel.tsx +++ b/features/onboarding/components/OnboardingInnerCarousel.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useRef, useState } from 'react'; import { Platform, useWindowDimensions, ViewToken } from 'react-native'; import Animated, { @@ -33,38 +33,32 @@ const OnboardingInnerCarousel: React.FC = ({ }) => { const [isHorizontalScrollEnabled, setIsHorizontalScrollEnabled] = useState(true); - const data = useMemo(() => [...slides, slides[0]], [slides]); + const data = [...slides, slides[0]]; const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const insets = useSafeAreaInsets(); - const onViewableItemsChanged = useCallback( - ({ viewableItems }: { viewableItems: ViewToken[] }) => { - if (viewableItems.length > 0) { - const viewableItem = viewableItems[0]; - if (viewableItem && viewableItem.index !== null) { - log.info('onboarding.slide.change', { slideIndex: viewableItem.index }); - setCurrentSlideIndex(viewableItem.index); - } + const onViewableItemsChanged = ({ viewableItems }: { viewableItems: ViewToken[] }) => { + if (viewableItems.length > 0) { + const viewableItem = viewableItems[0]; + if (viewableItem && viewableItem.index !== null) { + log.info('onboarding.slide.change', { slideIndex: viewableItem.index }); + setCurrentSlideIndex(viewableItem.index); } - }, - [setCurrentSlideIndex] - ); + } + }; const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 100, minimumViewTime: 0, }).current; - const handleScrollToIndex = useCallback( - (index: number) => { - horizontalListRef.current?.scrollToIndex({ - index, - animated: true, - }); - }, - [horizontalListRef] - ); + const handleScrollToIndex = (index: number) => { + horizontalListRef.current?.scrollToIndex({ + index, + animated: true, + }); + }; const containerStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.get() }], diff --git a/features/onboarding/components/OnboardingScreen.tsx b/features/onboarding/components/OnboardingScreen.tsx index a1ff4eeaf..117c68239 100644 --- a/features/onboarding/components/OnboardingScreen.tsx +++ b/features/onboarding/components/OnboardingScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useRef, useState } from 'react'; import { View, useWindowDimensions } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { PressableFeedback } from 'heroui-native'; @@ -110,12 +110,12 @@ const OnboardingScreen: React.FC = ({ onComplete }) => { }, }); - const handleScrollToIndex = useCallback((index: number) => { + const handleScrollToIndex = (index: number) => { horizontalListRef.current?.scrollToIndex({ index, animated: true, }); - }, []); + }; const singleTap = Gesture.Tap() .maxDuration(250) diff --git a/features/onboarding/components/types.ts b/features/onboarding/components/types.ts index b6fd44db0..98325537d 100644 --- a/features/onboarding/components/types.ts +++ b/features/onboarding/components/types.ts @@ -1,4 +1,4 @@ -import { SharedValue } from 'react-native-reanimated'; +import { SharedValue, useAnimatedScrollHandler } from 'react-native-reanimated'; import { FlatList } from 'react-native-gesture-handler'; export type OnboardingSlide = { @@ -16,7 +16,7 @@ export type OnboardingCarouselProps = { currentSlideIndex: number; setCurrentSlideIndex: (index: number) => void; horizontalListRef: React.RefObject | null>; - scrollHandler: (event: any) => void; + scrollHandler: ReturnType; translateY: SharedValue; scrollOffsetX: SharedValue; isDragging: SharedValue; diff --git a/features/payments/data/dmDecryptPipeline.ts b/features/payments/data/dmDecryptPipeline.ts index 814885058..323157967 100644 --- a/features/payments/data/dmDecryptPipeline.ts +++ b/features/payments/data/dmDecryptPipeline.ts @@ -11,7 +11,7 @@ import { giftWrapCache } from '@/shared/lib/nostr/giftWrapCache'; import { unwrapGiftWrap } from '@/shared/lib/nostr/nip17'; import { decryptNip04 } from '@/shared/lib/nostr/nip04'; import { nip04Cache } from '@/shared/lib/nostr/nip04Cache'; -import type { DmEnvelope } from './dmEnvelopeClient'; +import type { DmEnvelope } from './dmEnvelopeTypes'; export type DmProtocol = 'nip04' | 'nip17'; diff --git a/features/payments/data/dmEnvelopeClient.ts b/features/payments/data/dmEnvelopeClient.ts index 52809a186..538c46692 100644 --- a/features/payments/data/dmEnvelopeClient.ts +++ b/features/payments/data/dmEnvelopeClient.ts @@ -18,6 +18,7 @@ import { dmConversationAppView } from '@sovranbitcoin/nagg-ts/recipes'; import { backendConfig } from '@/shared/config/backend'; import { paymentLog } from '@/shared/lib/logger'; import { buildNostrDataLayer } from '@/shared/lib/nostr/buildNostrDataLayer'; +import type { DmEnvelopePage } from './dmEnvelopeTypes'; import { resolvedDmEnvelopesToPage, toFacadeDmEnvelopesRequest } from './facadeDmAdapter'; const DM_TIMEOUT_MS = 12_000; @@ -27,23 +28,6 @@ const client = createNaggClient({ defaultTimeoutMs: DM_TIMEOUT_MS, }); -/** Raw DM envelope event as returned by nagg (still encrypted). */ -export interface DmEnvelope { - id: string; - pubkey: string; - kind: number; - createdAt: string | number | Date; - content: string; - tags: string[][]; - sig?: string; -} - -export interface DmEnvelopePage { - envelopes: DmEnvelope[]; - endCursor?: string; - hasNextPage: boolean; -} - const EMPTY_PAGE: DmEnvelopePage = { envelopes: [], hasNextPage: false }; function toPage(connection: NaggEventConnection): DmEnvelopePage { diff --git a/features/payments/data/dmEnvelopeTypes.ts b/features/payments/data/dmEnvelopeTypes.ts new file mode 100644 index 000000000..a80bf7dbd --- /dev/null +++ b/features/payments/data/dmEnvelopeTypes.ts @@ -0,0 +1,27 @@ +/** + * Shared DM envelope shapes. Kept in their own module so the envelope client + * (`dmEnvelopeClient`) and the facade adapter (`facadeDmAdapter`) can both + * depend on them without importing each other — the adapter maps into these + * types, the client returns them. + * + * An envelope is a raw, still-encrypted event as returned by nagg (NIP-17 gift + * wrap kind 1059, or legacy NIP-04 kind 4); decryption happens client-side in + * `dmDecryptPipeline`. + */ + +/** Raw DM envelope event as returned by nagg (still encrypted). */ +export interface DmEnvelope { + id: string; + pubkey: string; + kind: number; + createdAt: string | number | Date; + content: string; + tags: string[][]; + sig?: string; +} + +export interface DmEnvelopePage { + envelopes: DmEnvelope[]; + endCursor?: string; + hasNextPage: boolean; +} diff --git a/features/payments/data/dmPagination.ts b/features/payments/data/dmPagination.ts index 6764cf466..f9d6fb550 100644 --- a/features/payments/data/dmPagination.ts +++ b/features/payments/data/dmPagination.ts @@ -5,7 +5,7 @@ * nagg orders/filters by the wrap timestamp. We over-fetch by `CURSOR_SLACK_SECONDS` * and rely on wrap-id dedup to converge. */ -import type { DmEnvelope, DmEnvelopePage } from './dmEnvelopeClient'; +import type { DmEnvelope, DmEnvelopePage } from './dmEnvelopeTypes'; export const CURSOR_SLACK_SECONDS = 2 * 24 * 60 * 60; diff --git a/features/payments/data/facadeDmAdapter.ts b/features/payments/data/facadeDmAdapter.ts index 225ae76cc..870c87112 100644 --- a/features/payments/data/facadeDmAdapter.ts +++ b/features/payments/data/facadeDmAdapter.ts @@ -10,7 +10,7 @@ */ import type { facade } from '@sovranbitcoin/nagg-ts'; -import type { DmEnvelope, DmEnvelopePage } from './dmEnvelopeClient'; +import type { DmEnvelope, DmEnvelopePage } from './dmEnvelopeTypes'; /** Translate the app's `until` (wrap arrival seconds) into the facade's cursor. * Only the nagg tier honours it; the relay floor fetches the whole inbox. */ diff --git a/features/payments/hooks/recentContactTypes.ts b/features/payments/hooks/recentContactTypes.ts new file mode 100644 index 000000000..26d37aad5 --- /dev/null +++ b/features/payments/hooks/recentContactTypes.ts @@ -0,0 +1,19 @@ +/** + * Unified recent-contact row shape. Lives in its own module so the + * `useNip17RecentContacts` hook and the mock-data store can both depend on it + * without importing each other (the hook produces these rows; the store seeds + * mock ones). Kept identical to the legacy hook so `ContactsScreen`, the + * split-bill picker, and `mockDataStore` are unaffected. + */ +import type { DmProtocol } from '../data/dmDecryptPipeline'; + +export interface RecentContact { + type: 'contact'; + dmEvent: { content: string } | null | undefined; + nip17Content: string | undefined; + pubkey: string; + timestamp: number; + isDefault?: boolean; + /** Which protocol this conversation is on. Absent rows default to NIP-17. */ + protocol?: DmProtocol | 'whitenoise'; +} diff --git a/features/payments/hooks/useDmConversations.ts b/features/payments/hooks/useDmConversations.ts index 63c91f7d0..da95f0c0e 100644 --- a/features/payments/hooks/useDmConversations.ts +++ b/features/payments/hooks/useDmConversations.ts @@ -8,7 +8,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { giftWrapCache } from '@/shared/lib/nostr/giftWrapCache'; import { nip04Cache } from '@/shared/lib/nostr/nip04Cache'; import { paymentLog } from '@/shared/lib/logger'; -import { fetchDmEnvelopes, type DmEnvelopePage } from '../data/dmEnvelopeClient'; +import { fetchDmEnvelopes } from '../data/dmEnvelopeClient'; +import type { DmEnvelopePage } from '../data/dmEnvelopeTypes'; import { decryptDmEnvelopes, type DmProtocol } from '../data/dmDecryptPipeline'; import { CURSOR_SLACK_SECONDS, pageOldestWrapTs } from '../data/dmPagination'; @@ -130,7 +131,7 @@ export function useDmConversations(viewerPubkey?: string, viewerPrivateKey?: Uin return () => controller.abort(); }, [viewerPubkey, viewerPrivateKey, refreshKey, ingest]); - const loadMore = useCallback(async () => { + const loadMore = async () => { if ( loadingMoreRef.current || !hasMore || @@ -158,9 +159,9 @@ export function useDmConversations(viewerPubkey?: string, viewerPrivateKey?: Uin } finally { loadingMoreRef.current = false; } - }, [hasMore, viewerPubkey, viewerPrivateKey, ingest]); + }; - const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); + const refresh = () => setRefreshKey((k) => k + 1); return { conversations, loading, hasLoadedOnce, hasMore, loadMore, refresh, error }; } diff --git a/features/payments/hooks/useDmThread.ts b/features/payments/hooks/useDmThread.ts index 491bcbb5b..745937867 100644 --- a/features/payments/hooks/useDmThread.ts +++ b/features/payments/hooks/useDmThread.ts @@ -10,7 +10,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { giftWrapCache } from '@/shared/lib/nostr/giftWrapCache'; import { nip04Cache } from '@/shared/lib/nostr/nip04Cache'; import { paymentLog } from '@/shared/lib/logger'; -import { fetchDmConversation, type DmEnvelopePage } from '../data/dmEnvelopeClient'; +import { fetchDmConversation } from '../data/dmEnvelopeClient'; +import type { DmEnvelopePage } from '../data/dmEnvelopeTypes'; import { decryptDmEnvelopes, type DecryptedDm, type DmProtocol } from '../data/dmDecryptPipeline'; import { CURSOR_SLACK_SECONDS, pageOldestWrapTs } from '../data/dmPagination'; @@ -138,7 +139,7 @@ export function useDmThread( ingestMessages, ]); - const loadMore = useCallback(async () => { + const loadMore = async () => { if ( loadingMoreRef.current || !hasMore || @@ -169,17 +170,9 @@ export function useDmThread( } finally { loadingMoreRef.current = false; } - }, [ - hasMore, - viewerPubkey, - viewerPrivateKey, - counterparty, - protocol, - trackEnvelopes, - ingestMessages, - ]); + }; - const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); + const refresh = () => setRefreshKey((k) => k + 1); return { messages, loading, hasMore, loadMore, refresh, error }; } diff --git a/features/payments/hooks/useMintContacts.ts b/features/payments/hooks/useMintContacts.ts index ddcab0f83..efc80e3fd 100644 --- a/features/payments/hooks/useMintContacts.ts +++ b/features/payments/hooks/useMintContacts.ts @@ -5,6 +5,7 @@ import { paymentLog } from '@/shared/lib/logger'; import { npubToPubkey } from '@/shared/lib/nostr/client'; import { prefetchImages } from '@/shared/lib/imageCache'; import type { DmConversation } from './useDmConversations'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; interface NostrKeys { pubkey?: string; @@ -16,13 +17,6 @@ interface MintWithInfo { mintInfo: GetInfoResponse; } -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - export interface MintContact { type: 'mint'; pubkey: string | null; @@ -47,14 +41,10 @@ export function useMintContacts( // event (mint:added / mint:updated / mint:trusted / mint:untrusted), even // when the trusted-set is unchanged. Key the load on the sorted url-set so a // no-op refresh does not retrigger the Promise.all(getMintInfo) waterfall. - const mintUrlsKey = useMemo( - () => - mints - .map((m) => m.mintUrl) - .sort() - .join('|'), - [mints] - ); + const mintUrlsKey = mints + .map((m) => m.mintUrl) + .sort() + .join('|'); // Load mint info and filter for those with nostr contacts useEffect(() => { @@ -150,10 +140,7 @@ export function useMintContacts( }); }, [mintsWithInfo, conversations]); - const mintPubkeys = useMemo( - () => displayMints.map((m) => m.pubkey).filter((p): p is string => !!p), - [displayMints] - ); + const mintPubkeys = displayMints.map((m) => m.pubkey).filter((p): p is string => !!p); return { displayMints, mintPubkeys, mintInfoLoading }; } diff --git a/features/payments/hooks/useNip17RecentContacts.ts b/features/payments/hooks/useNip17RecentContacts.ts index a1ad194cc..44db00300 100644 --- a/features/payments/hooks/useNip17RecentContacts.ts +++ b/features/payments/hooks/useNip17RecentContacts.ts @@ -10,7 +10,7 @@ import { useMemo } from 'react'; import { PUBLIC_KEYS } from '@/shared/lib/constants'; import { useSettingsStore } from '@/shared/stores/global/settingsStore'; import { getMockContacts, MOCK_ALLOWED_PUBKEYS_HEX } from '@/shared/stores/runtime/mockDataStore'; -import type { DmProtocol } from '../data/dmDecryptPipeline'; +import type { RecentContact } from './recentContactTypes'; import { useDmConversations } from './useDmConversations'; const DEFAULT_CONTACTS = [{ pubkey: PUBLIC_KEYS.SUPPORT, label: 'Sovran' }] as const; @@ -20,19 +20,6 @@ interface NostrKeys { privateKey?: Uint8Array; } -/** Unified recent-contact row shape (kept identical to the legacy hook so - * `ContactsScreen`, the split-bill picker, and `mockDataStore` are unaffected). */ -export interface RecentContact { - type: 'contact'; - dmEvent: { content: string } | null | undefined; - nip17Content: string | undefined; - pubkey: string; - timestamp: number; - isDefault?: boolean; - /** Which protocol this conversation is on. Absent rows default to NIP-17. */ - protocol?: DmProtocol | 'whitenoise'; -} - export function useNip17RecentContacts(nostrKeys: NostrKeys | null) { const mockMode = useSettingsStore((s) => s.mockMode); const { conversations, loading, hasLoadedOnce, hasMore, loadMore, refresh, error } = @@ -80,10 +67,7 @@ export function useNip17RecentContacts(nostrKeys: NostrKeys | null) { return [...mocks.filter((m) => !realKeys.has(m.pubkey)), ...allowlistRows, ...base]; }, [conversations, mockMode]); - const contactPubkeys = useMemo( - () => displayContacts.map((c) => c.pubkey).filter(Boolean), - [displayContacts] - ); + const contactPubkeys = displayContacts.map((c) => c.pubkey).filter(Boolean); return { displayContacts, diff --git a/features/receive/screens/LightningReceiveRoute.tsx b/features/receive/screens/LightningReceiveRoute.tsx index 4651df509..c081b8a44 100644 --- a/features/receive/screens/LightningReceiveRoute.tsx +++ b/features/receive/screens/LightningReceiveRoute.tsx @@ -14,7 +14,7 @@ * via `useScreenActions`. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { Stack } from 'expo-router'; import { z } from 'zod'; import { LightningReceiveScreen } from './LightningReceiveScreen'; @@ -39,7 +39,9 @@ interface LightningReceiveRouteProps { export function LightningReceiveRoute({ where, onRequestMintList }: LightningReceiveRouteProps) { const params = useRouteParams(ParamsSchema, { where }); - const screenOptions = useMemo(() => ({ title: 'Receive Lightning' }), []); + const screenOptions = { + title: 'Receive Lightning', + }; if (!params) return null; paymentLog.info('receive.lightning.route_ready', { where, diff --git a/features/receive/screens/OnchainReceiveRoute.tsx b/features/receive/screens/OnchainReceiveRoute.tsx index bb6002c67..1802c6f27 100644 --- a/features/receive/screens/OnchainReceiveRoute.tsx +++ b/features/receive/screens/OnchainReceiveRoute.tsx @@ -6,7 +6,7 @@ * rail-specific screen. */ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; import { Stack } from 'expo-router'; import { z } from 'zod'; import { useScreenActions, type BoundAction } from '@sovranbitcoin/colada/react'; @@ -64,7 +64,9 @@ function OnchainReceiveRouteContent({ extraButtons: ButtonHandlerButton[]; onRequestMintList?: () => void; }) { - const screenOptions = useMemo(() => ({ title: 'Receive onchain' }), []); + const screenOptions = { + title: 'Receive onchain', + }; const { entry, error, actions, source, mintUrl } = useScreenActions( 'mintQuote', mintHistoryEntry diff --git a/features/receive/screens/OnchainReceiveScreen.tsx b/features/receive/screens/OnchainReceiveScreen.tsx index 0cf6e9a9c..27dd673f5 100644 --- a/features/receive/screens/OnchainReceiveScreen.tsx +++ b/features/receive/screens/OnchainReceiveScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; import { useWindowDimensions } from 'react-native'; import type { MintInfo } from '@cashu/cashu-ts'; @@ -76,14 +76,11 @@ export function OnchainReceiveScreen({ mempool.summary, requiredConfirmations ); - const onchainConfirmationProgress = useMemo( - () => - observedConfirmationProgress ?? - (isPaid - ? buildSatisfiedOnchainConfirmationProgress(requiredConfirmations) - : buildOnchainRequiredConfirmationProgress(requiredConfirmations)), - [isPaid, observedConfirmationProgress, requiredConfirmations] - ); + const onchainConfirmationProgress = + observedConfirmationProgress ?? + (isPaid + ? buildSatisfiedOnchainConfirmationProgress(requiredConfirmations) + : buildOnchainRequiredConfirmationProgress(requiredConfirmations)); const paymentInfoValue = getMintQuotePaymentValue(entry as unknown as HistoryEntry) ?? entry.paymentRequest; diff --git a/features/receive/screens/ReceiveScreen.tsx b/features/receive/screens/ReceiveScreen.tsx index f0826c6ad..82690a813 100644 --- a/features/receive/screens/ReceiveScreen.tsx +++ b/features/receive/screens/ReceiveScreen.tsx @@ -7,7 +7,7 @@ * usePaymentFlowMachine after entry is available). */ -import React, { memo, useEffect, useMemo, useState } from 'react'; +import React, { memo, useEffect, useState } from 'react'; import { StyleSheet, useWindowDimensions } from 'react-native'; import type { GetInfoResponse } from '@cashu/cashu-ts'; @@ -180,10 +180,7 @@ const QR_PLACEHOLDER_HORIZONTAL_INSET = 32; function ReceiveHubPlaceholder() { const { width } = useWindowDimensions(); const qrFrameSize = Math.max(0, Math.min(width, 600) - QR_PLACEHOLDER_HORIZONTAL_INSET); - const qrPlaceholderStyle = useMemo( - () => [styles.qrPlaceholder, { width: qrFrameSize, height: qrFrameSize }], - [qrFrameSize] - ); + const qrPlaceholderStyle = [styles.qrPlaceholder, { width: qrFrameSize, height: qrFrameSize }]; return ( <> diff --git a/features/send/lib/createSovranScreenActionsBridge.ts b/features/send/lib/createSovranScreenActionsBridge.ts index b06c3b21d..ddb115bfb 100644 --- a/features/send/lib/createSovranScreenActionsBridge.ts +++ b/features/send/lib/createSovranScreenActionsBridge.ts @@ -28,16 +28,10 @@ import { useNpcMintStore } from '@/shared/stores/profile/npcMintStore'; import { useScanHistoryStore } from '@/shared/stores/profile/scanHistoryStore'; import { useTransactionDistributionStore } from '@/shared/stores/profile/transactionDistributionStore'; import { setDistributionAnnotation } from '@/shared/stores/profile/transactionAnnotationStore'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; type EntryRecord = Record; -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - const HISTORY_TYPE_BY_SCREEN: Partial> = { meltQuote: 'melt', mintQuote: 'mint', diff --git a/features/send/lib/sovranHandlers.ts b/features/send/lib/sovranHandlers.ts new file mode 100644 index 000000000..7c4d48b89 --- /dev/null +++ b/features/send/lib/sovranHandlers.ts @@ -0,0 +1,546 @@ +/** + * @fileoverview Sovran payment-machine step handlers for colada. + * + * createSovranHandlers maps colada step codes to Sovran behavior: navigation, + * popups, dismissals, Near Pay / Routstr / NFC delivery, scan-history + location + * stamping, and transaction-annotation linking. Sibling concerns live in + * sovranNotifications, sovranScreenActions, sovranScanSources, and + * sovranPaymentOperations. + */ + +import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; +import { paymentLog } from '@/shared/lib/logger'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; +import { mintLocalId } from '@/shared/lib/id'; + +import { getEncodedToken } from '@cashu/cashu-ts'; +import type { HistoryEntry, Manager, MeltHistoryEntry } from '@cashu/coco-core'; +import { type PaymentMachine, type StepHandlerMap } from '@sovranbitcoin/colada'; + +import { buildReceiveHistoryEntry } from '@/shared/lib/cashu/utils'; +import { amountToNumber } from '@/shared/lib/cashu/amount'; +import { getOnchainMintAddress } from '@/shared/lib/cashu/onchainMint'; +import { resolvePrimaryReceiveP2PKPublicKey } from '@sovranbitcoin/coco-cashu-plugin-p2pk-import'; +import { buildModalProfileHref } from '@/shared/lib/nav/profileRoutes'; +import { + paymentFallbackPopup, + paymentOptionsPopup, + proofSelectorPopup, + sendMemoPopup, + staticPopup, + paramPopup, +} from '@/shared/lib/popup'; +import { executeRoutstrTopUp, formatRoutstrBalance } from '@/shared/lib/routstr/topUp'; +import { sendBLEPrivateMessageWhole } from '@/features/bitchat/lib/blePrivateDelivery'; +import { getBitchatNickname } from '@/features/bitchat/hooks/useBitchatNickname'; +import { getBitchatProfileScope } from '@/features/bitchat/lib/profileScope'; +import type { BitchatBLEIdentityMaterial } from 'bitchat-module'; +import { useRoutstrTopUpStore } from '@/shared/stores/runtime/routstrTopUpStore'; +import { useNearPaySessionStore } from '@/shared/stores/runtime/nearPayStore'; +import { useNpcMintStore } from '@/shared/stores/profile/npcMintStore'; +import { getNpcAddress } from '@/shared/lib/cashu/npc'; +import { setTransactionAnnotation } from '@/shared/stores/profile/transactionAnnotationStore'; +import { useSendReachabilityStore } from '@/shared/stores/profile/sendReachabilityStore'; + +// ============================================================================= +// createSovranHandlers +// ============================================================================= + +interface CreateSovranHandlersConfig { + machine: PaymentMachine; + onOptionDismiss?: () => void; + getManager: () => Manager | null; + getNpub?: () => string | undefined; + getBitchatIdentityMaterial?: () => BitchatBLEIdentityMaterial | null; +} + +function getEncodedEcashTokenFromSendHistoryEntry(historyEntry: string): string | null { + try { + const parsed = JSON.parse(historyEntry) as { + token?: Parameters[0]; + tokenString?: unknown; + metadata?: { rawToken?: unknown }; + }; + if (typeof parsed.tokenString === 'string' && parsed.tokenString.length > 0) { + return parsed.tokenString; + } + if (typeof parsed.metadata?.rawToken === 'string' && parsed.metadata.rawToken.length > 0) { + return parsed.metadata.rawToken; + } + if (parsed.token) { + return getEncodedToken(parsed.token); + } + } catch (err) { + paymentLog.warn('near_pay.token.extract_failed', { + error: err instanceof Error ? err.message : String(err), + }); + } + return null; +} + +async function deliverNearPayIfActive( + historyEntry: string, + getBitchatIdentityMaterial?: () => BitchatBLEIdentityMaterial | null +): Promise { + const active = useNearPaySessionStore.getState().active; + if (!active) return; + + // Every Nut Drop send is delivered as a SINGLE private Noise DM to a + // creq-confirmed Sovran peer — encrypted to them, so a locked OR offline + // bearer token stays private (no public-mesh broadcast of payment metadata). + // The whole multi-KB token fits one message thanks to the extended + // PrivateMessagePacket length. Stock clients can't decode extended DMs, so + // active sessions must carry a creq capability proof before we transmit. + + try { + const encodedToken = getEncodedEcashTokenFromSendHistoryEntry(historyEntry); + if (!encodedToken) throw new Error('Created send entry did not contain an ecash token'); + if (!active.recipient.creq) { + throw new Error('Nut Drop recipient has not advertised a creq capability'); + } + + const profileScope = getBitchatProfileScope(); + const identityMaterial = getBitchatIdentityMaterial?.() ?? null; + const nickname = getBitchatNickname() || 'sovran'; + const result = await sendBLEPrivateMessageWhole({ + peerID: active.recipient.peerID, + content: encodedToken, + nickname, + profileScope, + identityMaterial, + }); + + paymentLog.info('near_pay.delivery.sent', { + peerID: active.recipient.peerID, + tokenBytes: encodedToken.length, + hasDirectLink: active.recipient.hasDirectLink, + startupMs: Math.round(result.startupMs * 100) / 100, + handshakeMs: Math.round(result.handshakeMs * 100) / 100, + sendMs: Math.round(result.sendMs * 100) / 100, + ...(result.handshakeError ? { handshakeError: result.handshakeError } : {}), + }); + + // Delivered over the BLE/bitchat mesh — stamp a bluetooth source badge on + // the resulting send transaction. + try { + const entry = JSON.parse(historyEntry) as { id?: unknown }; + if (typeof entry.id === 'string') { + setTransactionAnnotation(`id:${entry.id}`, { scan: { method: 'ble' } }); + } + } catch (e) { + paymentLog.warn('near_pay.delivery.ble_source_annotation_failed', { + error: e instanceof Error ? e.message : String(e), + }); + } + } catch (err) { + paymentLog.error('near_pay.delivery.failed', { + peerID: active.recipient.peerID, + error: err instanceof Error ? err.message : String(err), + }); + } finally { + useNearPaySessionStore.getState().complete(); + } +} + +export function createSovranHandlers({ + machine, + onOptionDismiss, + getManager, + getNpub, + getBitchatIdentityMaterial, +}: CreateSovranHandlersConfig): StepHandlerMap { + paymentLog.debug('payment.handlers.created'); + + return { + receiveToken: ({ token }) => { + paymentLog.info('payment.step.receive_token'); + router.navigate({ + pathname: '/(receive-flow)/receiveToken', + params: { receiveHistoryEntry: JSON.stringify(buildReceiveHistoryEntry(token)) }, + }); + }, + + sendComplete: async ({ + historyEntry, + createdOffline, + mintWasOffline, + recipientPubkey, + recipientProfile, + p2pkLockPubkey, + }) => { + paymentLog.info('payment.step.send_complete', { + createdOffline: !!createdOffline, + mintWasOffline: !!mintWasOffline, + recipientPubkeyPresent: !!recipientPubkey, + p2pkLocked: !!p2pkLockPubkey, + }); + + // Routstr top-up: intercept the token and send it to the Routstr API + const topUpState = useRoutstrTopUpStore.getState(); + if (topUpState.active) { + try { + const entry = JSON.parse(historyEntry); + const encodedToken = getEncodedToken(entry.token); + const result = await executeRoutstrTopUp(encodedToken); + + if (result.success) { + const balanceStr = formatRoutstrBalance(result.balance); + if (result.isNewWallet) { + paramPopup('routstr-wallet-created', { balance: balanceStr }); + } else { + paramPopup('routstr-top-up-success', { balance: balanceStr }); + } + useRoutstrTopUpStore.getState().complete('success'); + } else { + staticPopup('routstr-transaction-failed', { text: result.error }); + useRoutstrTopUpStore.getState().complete('failed'); + } + } catch (e) { + paymentLog.error('payment.routstr_topup.error', { + error: e instanceof Error ? e.message : String(e), + }); + staticPopup('routstr-transaction-failed', { text: 'Failed to process top-up' }); + useRoutstrTopUpStore.getState().complete('failed'); + } + router.dismiss(); + return; + } + + // Inject recipientPubkey into the executed history entry's metadata + // so SendTokenScreen can render the recipient identity. Operations + // build the entry; we attach identity at the screen-handler seam. + const enrichedHistoryEntry = recipientPubkey + ? injectRecipientPubkey(historyEntry, recipientPubkey) + : historyEntry; + + // Persist the recipient's nostr identity as a counterparty annotation so + // the transactions row + detail show their avatar (the transient + // metadata injection above only survives this navigation). + if (recipientPubkey) { + try { + const entry = JSON.parse(enrichedHistoryEntry) as { id?: unknown }; + if (typeof entry.id === 'string') { + setTransactionAnnotation(`id:${entry.id}`, { + counterparty: { + pubkey: recipientPubkey, + direction: 'recipient', + ...(recipientProfile?.displayName + ? { displayName: recipientProfile.displayName } + : {}), + ...(recipientProfile?.avatarUrl ? { avatarUrl: recipientProfile.avatarUrl } : {}), + ...(recipientProfile?.nip05 ? { nip05: recipientProfile.nip05 } : {}), + }, + }); + } + } catch (e) { + paymentLog.warn('payment.send_complete.counterparty_annotation_failed', { + error: e instanceof Error ? e.message : String(e), + }); + } + } + + if (createdOffline) { + try { + const entry = JSON.parse(enrichedHistoryEntry) as { id?: unknown; mintUrl?: unknown }; + if (typeof entry.id === 'string' && typeof entry.mintUrl === 'string') { + useSendReachabilityStore.getState().markChecking(entry.id, entry.mintUrl); + useSendReachabilityStore.getState().pruneOld(); + } + } catch (e) { + paymentLog.warn('payment.send_complete.reachability_seed_failed', { + error: e instanceof Error ? e.message : String(e), + }); + } + } + + await deliverNearPayIfActive(enrichedHistoryEntry, getBitchatIdentityMaterial); + + router.navigate({ + pathname: '/(send-flow)/sendToken', + params: { + sendHistoryEntry: enrichedHistoryEntry, + ...(createdOffline ? { createdOffline: 'true' } : {}), + ...(mintWasOffline ? { mintWasOffline: 'true' } : {}), + }, + }); + }, + + navigateToPaymentRequest: ({ mintUrl, paymentRequest, amount, unit, recipientPubkey }) => { + paymentLog.info('payment.step.navigate_payment_request', { + ...mintUrlLogFields(mintUrl), + amount, + unit, + recipientPubkeyPresent: !!recipientPubkey, + paymentRequestLength: paymentRequest.length, + }); + const entry = { + id: mintLocalId('pr-preview'), + type: 'send', + createdAt: Date.now(), + mintUrl, + amount, + unit, + state: 'prepared', + metadata: { + paymentRequest, + phase: 'preview', + ...(recipientPubkey ? { recipientPubkey } : {}), + }, + }; + const isFallback = (machine.getContext().failedOptionValues?.length ?? 0) > 0; + const nav = isFallback ? router.replace : router.navigate; + nav({ + pathname: '/(send-flow)/paymentRequest', + params: { paymentRequestEntry: JSON.stringify(entry) }, + }); + }, + + navigateToMeltPreview: ({ + mintUrl, + meltTarget, + amount, + unit, + recipientPubkey, + recipientProfile, + }) => { + paymentLog.info('payment.step.navigate_melt_preview', { + ...mintUrlLogFields(mintUrl), + amount, + unit, + meltTargetLength: meltTarget.length, + recipientPubkeyPresent: !!recipientPubkey, + recipientProfilePresent: !!recipientProfile, + recipientProfileDisplayName: recipientProfile?.displayName ?? null, + recipientProfileAvatarUrlPresent: !!recipientProfile?.avatarUrl, + }); + // `MeltHistoryEntry.metadata` is typed `Record` upstream + // in `@cashu/coco-core`, so the resolved profile is flattened into + // individual string keys instead of stored as a nested object. + // `LightningSendScreen` re-assembles them on read. + const id = mintLocalId('melt-preview'); + const now = Date.now(); + const entry: MeltHistoryEntry & { + source: 'legacy'; + legacyHistoryId: string; + updatedAt: number; + } = { + id, + type: 'melt', + source: 'legacy', + legacyHistoryId: id, + createdAt: now, + updatedAt: now, + mintUrl, + unit: unit ?? 'sat', + quoteId: '', + state: 'UNPAID', + amount: amountToNumber(amount), + metadata: { + phase: 'preview', + meltTarget, + ...(recipientPubkey ? { recipientPubkey } : {}), + ...(recipientProfile?.displayName + ? { recipientDisplayName: recipientProfile.displayName } + : {}), + ...(recipientProfile?.avatarUrl + ? { recipientAvatarUrl: recipientProfile.avatarUrl } + : {}), + ...(recipientProfile?.nip05 ? { recipientNip05: recipientProfile.nip05 } : {}), + }, + }; + const isFallback = (machine.getContext().failedOptionValues?.length ?? 0) > 0; + const nav = isFallback ? router.replace : router.navigate; + nav({ + pathname: '/(send-flow)/lightningSend', + params: { meltHistoryEntry: JSON.stringify(entry) }, + }); + }, + + mintQuoteCreated: ({ historyEntry, unit }) => { + let pathname: '/(receive-flow)/lightningReceive' | '/(receive-flow)/onchainReceive' = + '/(receive-flow)/lightningReceive'; + try { + pathname = getOnchainMintAddress(JSON.parse(historyEntry) as HistoryEntry) + ? '/(receive-flow)/onchainReceive' + : '/(receive-flow)/lightningReceive'; + } catch { + pathname = '/(receive-flow)/lightningReceive'; + } + router.replace({ + pathname, + params: { mintHistoryEntry: historyEntry, unit }, + }); + }, + + reviewMint: ({ mintUrl, token, mintInfo }) => { + const entry = { + ...(mintInfo ?? {}), + mintUrl, + fromAccepter: true, + token, + }; + router.navigate({ + pathname: '/(mint-flow)/info', + params: { mintInfoEntry: JSON.stringify(entry) }, + }); + }, + + openMint: ({ url, mintInfo }) => { + const entry = { + ...(mintInfo ?? {}), + mintUrl: url, + fromScan: true, + }; + router.navigate({ + pathname: '/(mint-flow)/info', + params: { mintInfoEntry: JSON.stringify(entry) }, + }); + }, + + openProfile: ({ npub }) => { + router.navigate(buildModalProfileHref({ npub })); + }, + + navigateToReceive: async ({ unit, methodContext }) => { + const t0 = performance.now(); + const npub = getNpub?.(); + const selectedMintUrl = useNpcMintStore.getState().getActiveMintUrl(); + + let p2pkKey: string | undefined; + const currentMgr = getManager(); + if (currentMgr) { + try { + p2pkKey = await resolvePrimaryReceiveP2PKPublicKey(currentMgr); + } catch { + /* ignore */ + } + } + + const entry = { + type: 'receive', + id: 'receive-hub', + createdAt: Date.now(), + mintUrl: selectedMintUrl ?? '', + npcAddress: npub ? getNpcAddress(undefined, npub) : undefined, + p2pkKey, + selectedMintUrl, + ...(methodContext ? { methodContext } : {}), + unit, + }; + router.navigate({ + pathname: '/(receive-flow)/receive', + params: { receiveEntry: JSON.stringify(entry), unit }, + }); + paymentLog.info('navigate.receive.done', { duration_ms: performance.now() - t0 }); + }, + + enterAmount: ({ unit, preselectedMintUrl, constraints }) => { + const t0 = performance.now(); + const entry = { + destination: constraints.destination, + unit, + selectedMintUrl: preselectedMintUrl ?? '', + ...(constraints.paymentRequest ? { paymentRequest: constraints.paymentRequest } : {}), + ...(constraints.meltTarget ? { meltTarget: constraints.meltTarget } : {}), + ...(constraints.methodContext ? { methodContext: constraints.methodContext } : {}), + // Snapshot the machine-resolved recipient identity onto the entry so + // the amount screen renders "Pay " + avatar on first paint + // when the resolver beat the navigation. AmountFlowScreen also + // subscribes to the live ctx for the case where the resolver lands + // after navigation. + ...(constraints.recipientPubkey ? { recipientPubkey: constraints.recipientPubkey } : {}), + ...(constraints.recipientProfile ? { recipientProfile: constraints.recipientProfile } : {}), + }; + const params = { amountEntry: JSON.stringify(entry) }; + const nearPaySessionStore = useNearPaySessionStore.getState(); + // Radar-launched sends stay inline on the radar: the vanilla ladder + // arrives as destination 'sendEcash', mesh sends as 'paymentRequest' + // (the solicited creq rides the payment-request machinery). + if ( + nearPaySessionStore.active && + (constraints.destination === 'sendEcash' || constraints.destination === 'paymentRequest') + ) { + nearPaySessionStore.setAmountEntry(params.amountEntry); + paymentLog.info('navigate.enterAmount.near_pay_inline', { + destination: constraints.destination, + duration_ms: performance.now() - t0, + }); + return; + } + router.navigate( + constraints.destination === 'mintQuote' + ? { pathname: '/(receive-flow)/amount', params } + : { pathname: '/(send-flow)/amount', params } + ); + paymentLog.info('navigate.enterAmount.done', { duration_ms: performance.now() - t0 }); + }, + + selectMint: ({ + candidates: _candidates, + supportedMintUrls: _supportedMintUrls, + amount: _amount, + unit, + paymentRequest: _paymentRequest, + meltTarget: _meltTarget, + destination, + mintQuoteMethod, + meltQuoteMethod, + methodRequirement, + mintListItems, + scope, + }) => { + const entry = { + items: mintListItems ?? [], + scope: scope ?? 'selected', + destination, + ...(mintQuoteMethod ? { mintQuoteMethod } : {}), + ...(meltQuoteMethod ? { meltQuoteMethod } : {}), + ...(methodRequirement ? { methodRequirement } : {}), + unit, + }; + + const params = { mintSelectorEntry: JSON.stringify(entry) }; + router.navigate( + destination === 'mintQuote' || scope === 'npc' + ? { pathname: '/(receive-flow)/mintSelect', params } + : { pathname: '/(send-flow)/mintSelect', params } + ); + }, + + chooseOption: (stepData) => { + paymentOptionsPopup({ ...stepData, machine, onDismiss: onOptionDismiss }); + }, + + chooseFallbackOption: (stepData) => { + paymentFallbackPopup({ ...stepData, machine, onDismiss: onOptionDismiss }); + }, + + chooseProofs: (stepData) => { + proofSelectorPopup({ ...stepData, machine }); + }, + + enterSendMemo: (stepData) => { + sendMemoPopup({ ...stepData, machine }); + }, + + dismiss: () => { + router.back(); + }, + }; +} + +/** + * Re-serialize a JSON-encoded coco history entry with `recipientPubkey` + * added to its metadata. Returns the input unchanged if it can't be parsed + * — operations build the entry, this only attaches identity at the seam. + */ +function injectRecipientPubkey(historyEntry: string, recipientPubkey: string): string { + try { + const parsed = JSON.parse(historyEntry) as { metadata?: Record }; + parsed.metadata = { ...(parsed.metadata ?? {}), recipientPubkey }; + return JSON.stringify(parsed); + } catch { + paymentLog.warn('payment.recipient_pubkey.inject_failed'); + return historyEntry; + } +} diff --git a/features/send/lib/sovranNotifications.ts b/features/send/lib/sovranNotifications.ts new file mode 100644 index 000000000..e419d6417 --- /dev/null +++ b/features/send/lib/sovranNotifications.ts @@ -0,0 +1,510 @@ +/** + * @fileoverview Sovran notification handlers for the colada payment machine. + * + * Maps colada payment-machine notification codes to Sovran UI (popups, toasts, + * payment-status store updates) and side effects (P2PK key refresh, location + * capture, scan-history linking). One handler per notification code. + */ +import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; +import { paymentLog } from '@/shared/lib/logger'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; + +import type { Manager } from '@cashu/coco-core'; +import { + isSendTokenCancelled, + isSendTokenComplete, + type NotificationHandlerMap, + rawAnnotationKey, +} from '@sovranbitcoin/colada'; + +import { getP2PKImportExtension } from '@sovranbitcoin/coco-cashu-plugin-p2pk-import'; +import { useNfcTapStore } from '@/shared/stores/runtime/nfcTapStore'; +import { + copyPopup, + paymentCancelledPopup, + paymentStatusPopup, + staticPopup, + paramPopup, +} from '@/shared/lib/popup'; +import { RECEIVE_PENDING_TOAST_COPY } from '@/shared/lib/popup/paymentStatusCopy'; +import { captureAndStoreLocation } from '@/shared/hooks/useTransactionLocation'; +import { useMintStore } from '@/shared/stores/profile/mintStore'; +import { useNpcMintStore } from '@/shared/stores/profile/npcMintStore'; +import { usePaymentStatusStore } from '@/shared/stores/runtime/paymentStatusStore'; +import { useSettingsStore } from '@/shared/stores/global/settingsStore'; +import { useScanHistoryStore } from '@/shared/stores/profile/scanHistoryStore'; +import { + linkTransactionAnnotation, + setTransactionAnnotation, +} from '@/shared/stores/profile/transactionAnnotationStore'; + +interface CreateSovranNotificationsConfig { + getPubkey?: () => string | undefined; + getPrivateKey?: () => Uint8Array | undefined; + getManager?: () => Manager | null; + onP2pkKeyRefreshed?: (newPublicKeyHex: string | null) => void; +} + +export function createSovranNotifications( + config?: CreateSovranNotificationsConfig +): NotificationHandlerMap { + paymentLog.debug('payment.notifications.created'); + return { + NO_AMOUNT: ({ code: _code, message: _message, data: _data }) => { + paymentLog.warn('payment.notification.no_amount'); + staticPopup('no-amount'); + }, + NO_VALID_MINT: ({ code: _code, message, data: _data }) => { + staticPopup('no-valid-mint', { text: message }); + }, + INSUFFICIENT_BALANCE: ({ code: _code, message, data: _data }) => { + staticPopup('balance-too-low', { text: message }); + }, + NO_BALANCE: ({ code: _code, message, data: _data }) => { + staticPopup('balance-too-low', { text: message }); + }, + UNSUPPORTED_INPUT: ({ code: _code, message, data: _data }) => { + staticPopup('unsupported-input', { text: message }); + }, + UNSUPPORTED_PAYMENT_METHOD: ({ code: _code, message, data: _data }) => { + staticPopup('unsupported-payment-method', { text: message }); + }, + ALL_OPTIONS_DISABLED: ({ code: _code, message: _message, data: _data }) => { + staticPopup('all-options-disabled'); + }, + MISSING_MELT_TARGET: ({ code: _code, message: _message, data: _data }) => { + staticPopup('missing-melt-target'); + }, + SEND_FAILED: ({ code: _code, message, data }) => { + paymentLog.error('payment.notification.send_failed', { message }); + if (data?.mintUnreachable) { + staticPopup('mint-unreachable'); + } else { + staticPopup('general-error', { text: message }); + } + }, + MINT_QUOTE_FAILED: ({ code: _code, message, data }) => { + if (data?.mintUnreachable) { + staticPopup('mint-unreachable'); + } else { + staticPopup('general-error', { text: message }); + } + }, + MELT_FAILED: ({ code: _code, message, data }) => { + paymentLog.error('payment.notification.melt_failed', { message }); + if (data?.mintUnreachable) { + staticPopup('mint-unreachable'); + } else { + staticPopup('general-error', { text: message }); + } + }, + PAYMENT_REQUEST_FAILED: ({ code: _code, message, data: _data }) => { + staticPopup('send-payment-failed', { text: message }); + }, + NFC_WRITE_FAILED: ({ code: _code, message, data: _data }) => { + paramPopup('nfc-error', { title: 'NFC Write Failed', message }); + }, + NFC_SESSION_LOST: ({ code: _code, message, data: _data }) => { + paramPopup('nfc-error', { title: 'NFC Connection Lost', message }); + }, + NFC_READ_FAILED: ({ code: _code, message, data: _data }) => { + paramPopup('nfc-error', { title: 'NFC Read Failed', message }); + }, + onPaymentProcessing: (data) => { + const store = usePaymentStatusStore.getState(); + const variant = data.variant === 'paymentRequest' ? 'payment-request' : data.variant; + const id = `${data.variant}-${Date.now()}`; + paymentLog.info('payment.processing', { + variant: data.variant, + statusVariant: variant, + id, + ...mintUrlLogFields(data.mintUrl), + amount: data.amount, + unit: data.unit, + activeId: store.active?.id ?? null, + activeState: store.active?.state ?? null, + expectedNext: 'processing_to_terminal_or_delivered', + }); + store.setActive({ + variant, + id, + mintUrl: data.mintUrl, + amount: data.amount, + unit: data.unit, + state: 'processing', + }); + paymentStatusPopup({ + variant, + id, + mintUrl: data.mintUrl, + amount: data.amount, + unit: data.unit, + }); + }, + onPaymentConfirmed: (data) => { + const store = usePaymentStatusStore.getState(); + paymentLog.info('payment.confirmed', { + variant: data.variant, + ...mintUrlLogFields(data.mintUrl), + amount: data.amount, + unit: data.unit, + activeId: store.active?.id ?? null, + activeState: store.active?.state ?? null, + historyEntryLength: data.historyEntry.length, + }); + if (!store.active) { + paymentLog.warn('payment.confirmed.skipped', { + variant: data.variant, + reason: 'no_active_status_toast', + }); + return; + } + + if (data.variant === 'paymentRequest') { + paymentLog.info('payment.confirmed.route', { + variant: data.variant, + action: 'set_delivered', + activeId: store.active.id, + activeState: store.active.state, + expectedNext: 'delivered_then_later_confirmed', + }); + store.setDelivered(store.active.id); + } else if (data.variant === 'melt' && data.historyEntry) { + try { + const parsed = JSON.parse(data.historyEntry); + if (parsed.state === 'PENDING') { + paymentLog.info('payment.confirmed.skipped', { + variant: data.variant, + reason: 'melt_history_pending', + activeId: store.active.id, + activeState: store.active.state, + }); + return; + } + } catch (error) { + paymentLog.warn('payment.confirmed.history_parse_failed', { + variant: data.variant, + historyEntryLength: data.historyEntry.length, + error: error instanceof Error ? error.message : String(error), + }); + } + paymentLog.info('payment.confirmed.route', { + variant: data.variant, + action: 'set_confirmed', + activeId: store.active.id, + activeState: store.active.state, + }); + store.setConfirmed(store.active.id); + } else { + paymentLog.info('payment.confirmed.route', { + variant: data.variant, + action: 'set_confirmed', + activeId: store.active.id, + activeState: store.active.state, + }); + store.setConfirmed(store.active.id); + } + }, + onPaymentFailed: (data) => { + const store = usePaymentStatusStore.getState(); + paymentLog.error('payment.failed', { + variant: data.variant, + ...mintUrlLogFields(data.mintUrl), + amount: data.amount, + unit: data.unit, + message: data.message, + rolledBack: data.rolledBack, + activeId: store.active?.id ?? null, + activeState: store.active?.state ?? null, + }); + if (!store.active) { + paymentLog.warn('payment.failed.skipped', { + variant: data.variant, + reason: 'no_active_status_toast', + }); + return; + } + const msg = data.rolledBack + ? `${data.message}\nYour funds have been returned.` + : data.message; + paymentLog.info('payment.failed.route', { + variant: data.variant, + action: 'set_failed', + activeId: store.active.id, + activeState: store.active.state, + rolledBack: data.rolledBack, + }); + store.setFailed(store.active.id, new Error(msg)); + }, + onScanEmpty: (source) => { + if (source === 'clipboard') staticPopup('no-clipboard-address'); + else if (source === 'gallery') staticPopup('no-qr-code-found'); + }, + onScanError: (source, err) => { + if (source === 'gallery') staticPopup('qr-scan-failed'); + else staticPopup('general-error', { text: err.message }); + }, + onMissingMintForAmount: () => { + staticPopup('no-mint-selected'); + }, + onCopied: (target) => { + copyPopup(target as Parameters[0]); + }, + onScanResolved: ({ rawInput, parsedType, intentType, source, container, optionKinds }) => { + const typeMap: Record< + string, + 'ecash' | 'lightning' | 'npub' | 'mint' | 'paymentRequest' | 'unknown' + > = { + receiveToken: 'ecash', + meltLightningInvoice: 'lightning', + meltLightningAddress: 'lightning', + meltLnurlp: 'lightning', + sendPaymentRequest: 'paymentRequest', + openMint: 'mint', + openProfile: 'npub', + }; + const scanType = typeMap[intentType] ?? 'unknown'; + const sourceMap: Record = { + clipboard: 'paste', + gallery: 'qr', + qr: 'qr', + nfc: 'nfc', + deeplink: 'deeplink', + }; + const scanSource = sourceMap[source ?? ''] ?? 'qr'; + useScanHistoryStore + .getState() + .addScan(rawInput, scanType, scanSource, parsedType, container, optionKinds); + // Annotation: stash the scan under a raw key now; bridged onto the final + // transaction id in onTransactionCreated (colada owns the read model). + setTransactionAnnotation(rawAnnotationKey(rawInput), { + scan: { + method: scanSource, + raw: rawInput, + container, + optionKinds, + inputType: parsedType, + }, + }); + }, + onNfcWriteFailed: ({ message, rolledBack }) => { + const errorMsg = rolledBack ? `${message} Your funds have been returned.` : message; + paramPopup('nfc-error', { title: 'NFC Write Failed', message: errorMsg }); + }, + // Drives the Android tap-to-pay sheet's phase text ('Preparing payment…' + // etc.). 'creating'/'writing' only fire once executeNfcSend write-back + // is wired; harmless to map them now. + onNfcPaymentProgress: ({ phase }) => { + useNfcTapStore.getState().setPhase(phase); + }, + + // ── Screen action notifications ───────────────────────────────── + + onSendStatusChecked: ({ operationId: _operationId, state, redeemed }) => { + if (redeemed || isSendTokenComplete({ state })) { + staticPopup('token-redeemed-by-recipient'); + } else if (isSendTokenCancelled({ state })) { + staticPopup('transaction-already-cancelled'); + } else if (state === 'not_found') { + staticPopup('operation-not-found'); + } else if (state !== 'pending') { + paramPopup('operation-invalid-state', { state }); + } else { + staticPopup('token-pending-not-redeemed'); + } + }, + + onSendCancelled: (_data) => { + staticPopup('transaction-cancelled'); + }, + + onSendCancelFailed: ({ message, mintUnreachable, offline }) => { + if (offline) { + staticPopup('cancel-transaction-offline'); + } else if (mintUnreachable) { + staticPopup('mint-unreachable'); + } else { + staticPopup('cancel-transaction-failed', { text: message }); + } + }, + + onReceiveProcessing: ({ id, mintUrl, amount, unit }) => { + const store = usePaymentStatusStore.getState(); + paymentLog.info('payment.receive.processing', { + id, + ...mintUrlLogFields(mintUrl), + amount, + unit, + activeId: store.active?.id ?? null, + activeState: store.active?.state ?? null, + expectedNext: 'processing_to_confirmed_or_waiting_or_failed', + }); + if (store.active?.id === id && store.active?.state === 'failed') { + store.setActive(null); + } + store.setActive({ + variant: 'receive-ecash', + id, + mintUrl, + amount, + unit, + state: 'processing', + }); + paymentStatusPopup({ + variant: 'receive-ecash', + id, + mintUrl, + amount, + unit, + }); + }, + + onReceivePending: ({ id, mintUrl, amount, unit, operationId, message }) => { + const store = usePaymentStatusStore.getState(); + const wasActive = store.active?.id === id; + paymentLog.info('payment.receive.pending', { + id, + ...mintUrlLogFields(mintUrl), + amount, + unit, + operationId, + activeId: store.active?.id ?? null, + activeState: store.active?.state ?? null, + wasActive, + toastPolicy: 'terminal_warning_then_new_success', + expectedNext: 'coco_recovery_then_success_toast', + }); + if (!wasActive) { + store.setActive({ + variant: 'receive-ecash', + id, + mintUrl, + amount, + unit, + state: 'processing', + }); + paymentStatusPopup({ + variant: 'receive-ecash', + id, + mintUrl, + amount, + unit, + }); + } + store.setWaiting(id, { + title: RECEIVE_PENDING_TOAST_COPY.title, + subtitle: message ?? RECEIVE_PENDING_TOAST_COPY.subtitle, + }); + }, + + onReceiveConfirmed: async ({ id, historyEntry }) => { + const store = usePaymentStatusStore.getState(); + paymentLog.info('payment.receive.confirmed', { + id, + activeId: store.active?.id ?? null, + activeState: store.active?.state ?? null, + expectedNext: + store.active?.state === 'waiting' + ? 'listener_mounts_new_success_toast' + : 'active_toast_confirmed', + }); + if (store.active?.id === id) { + store.setConfirmed(id); + } + try { + const entry = JSON.parse(historyEntry); + if (entry.id) { + await captureAndStoreLocation(entry.id); + } + } catch { + // Non-critical — skip location capture + } + }, + + onReceiveFailed: ({ id, message }) => { + const store = usePaymentStatusStore.getState(); + paymentLog.error('payment.receive.failed', { + id, + message, + activeId: store.active?.id ?? null, + activeState: store.active?.state ?? null, + expectedNext: + store.active?.id === id && store.active?.state === 'processing' + ? 'active_toast_failed' + : 'static_receive_failed_popup', + }); + if (store.active?.id === id && store.active?.state === 'processing') { + store.setFailed(id, new Error(message)); + } else { + staticPopup('receive-failed', { text: message }); + } + }, + + onMeltCancelled: (_data) => { + paymentCancelledPopup(); + }, + + onMeltCancelFailed: ({ message, mintUnreachable }) => { + if (mintUnreachable) { + staticPopup('mint-unreachable'); + } else { + staticPopup('could-not-cancel', { text: message }); + } + }, + + onUnsupportedTokenUnit: ({ unit }) => { + paramPopup('unsupported-token-unit', { unit }); + }, + + onMintTrustedFromScreen: ({ fromAccepter }) => { + if (fromAccepter) { + router.dismiss(); + } else { + router.back(); + } + }, + + // ── State change notifications ────────────────────────────────── + + onPreferredMintChanged: ({ mintUrl }) => { + useMintStore.getState().setSelectedMint(mintUrl); + }, + + onNpcMintChanged: async ({ mintUrl }) => { + const pk = config?.getPrivateKey?.(); + if (pk) { + const ok = await useNpcMintStore.getState().updateServerMint(mintUrl, pk); + if (ok) staticPopup('receive-mint-updated'); + else staticPopup('receive-mint-update-failed'); + } + }, + + onTransactionCreated: async ({ transactionId, rawInput }) => { + if (rawInput) { + useScanHistoryStore.getState().linkTransaction(rawInput, transactionId); + // Bridge the scan annotation from its raw key onto the final entry id. + linkTransactionAnnotation(rawAnnotationKey(rawInput), `id:${transactionId}`); + } + await captureAndStoreLocation(transactionId); + }, + + onP2PKReceiveCompleted: async ({ hadP2PKProofs }) => { + if (hadP2PKProofs && useSettingsStore.getState().regenerateP2PKOnReceive) { + const mgr = config?.getManager?.(); + if (mgr) { + if ((getP2PKImportExtension(mgr)?.getPublicKeys() ?? []).length > 0) { + return; + } + + try { + await mgr.keyring.generateKeyPair(); + const keypair = await mgr.keyring.getLatestKeyPair(); + config?.onP2pkKeyRefreshed?.(keypair?.publicKeyHex ?? null); + } catch { + // Non-critical — skip P2PK key regeneration + } + } + } + }, + }; +} diff --git a/features/send/lib/sovranPaymentConfig.ts b/features/send/lib/sovranPaymentConfig.ts deleted file mode 100644 index 46e7466e0..000000000 --- a/features/send/lib/sovranPaymentConfig.ts +++ /dev/null @@ -1,1868 +0,0 @@ -/** - * @fileoverview Sovran payment flow config — single source for colada glue - * - * Factory functions that inject Sovran-specific behavior into colada: - * - createSovranNotifications: error/notification popups + state updates - * - createSovranHandlers: step handlers (navigation, popups, dismiss) - * - createSovranScreenActionHandlers: post-terminal actions (NFC, emoji token picker) - * - createSovranScanSources: scan input sources (clipboard, gallery, NFC) - * - * Operations (executeSend, executeMelt, buildMintListItems, etc.) are now built-in - * via createColada in the library. - */ - -import { Share } from 'react-native'; -import * as Clipboard from 'expo-clipboard'; -import { scanFromURLAsync } from 'expo-camera'; -import * as ImagePicker from 'expo-image-picker'; -import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; -import { paymentLog } from '@/shared/lib/logger'; -import { mintLocalId } from '@/shared/lib/id'; - -import { getEncodedToken, getTokenMetadata } from '@cashu/cashu-ts'; -import type { - HistoryEntry, - Manager, - MeltHistoryEntry, - SendHistoryEntry, - MintHistoryEntry, - ReceiveHistoryEntry, -} from '@cashu/coco-core'; -import { - classifyMeshRedeemError, - isSendTokenCancelled, - isSendTokenComplete, - withTimeout, - type MachineOperations, - type NotificationHandlerMap, - type PaymentMachine, - type ScanSources, - type ScreenActionContext, - type ScreenActionHandlerMap, - type StepHandlerMap, - type NfcIOAdapter, - rawAnnotationKey, -} from '@sovranbitcoin/colada'; - -import { buildReceiveHistoryEntry } from '@/shared/lib/cashu/utils'; -import { amountToNumber, type AmountValue } from '@/shared/lib/cashu/amount'; -import { prepareBolt11MintQuote } from '@/shared/lib/cashu/cocoOperations'; -import { getMintQuotePaymentValue, getOnchainMintAddress } from '@/shared/lib/cashu/onchainMint'; -import { - getP2PKImportExtension, - resolvePrimaryReceiveP2PKPublicKey, -} from '@sovranbitcoin/coco-cashu-plugin-p2pk-import'; -import { decode, isEncoded } from '@/shared/lib/third-party/emoji'; -import { writeTokenToNFC, NfcError, isUserCancelError, isAmbientNfcCycle } from '@/shared/lib/nfc'; -import { useNfcTapStore } from '@/shared/stores/runtime/nfcTapStore'; -import { usePopupStore } from '@/shared/stores/runtime/popupStore'; -import { buildModalProfileHref } from '@/shared/lib/nav/profileRoutes'; -import { - copyPopup, - emojiPickerPopup, - nfcConnectionLostPopup, - nfcEcashSharedPopup, - nfcSendFailedPopup, - paymentCancelledPopup, - paymentFallbackPopup, - paymentOptionsPopup, - paymentStatusPopup, - proofSelectorPopup, - sendMemoPopup, - staticPopup, - paramPopup, -} from '@/shared/lib/popup'; -import { RECEIVE_PENDING_TOAST_COPY } from '@/shared/lib/popup/paymentStatusCopy'; -import { captureAndStoreLocation } from '@/shared/hooks/useTransactionLocation'; -import { executeRoutstrTopUp, formatRoutstrBalance } from '@/shared/lib/routstr/topUp'; -import { sendBLEPrivateMessageWhole } from '@/features/bitchat/lib/blePrivateDelivery'; -import { getBitchatNickname } from '@/features/bitchat/hooks/useBitchatNickname'; -import { getBitchatProfileScope } from '@/features/bitchat/lib/profileScope'; -import type { BitchatBLEIdentityMaterial } from 'bitchat-module'; -import { useRoutstrTopUpStore } from '@/shared/stores/runtime/routstrTopUpStore'; -import { useNearPaySessionStore } from '@/shared/stores/runtime/nearPayStore'; -import { useMintStore } from '@/shared/stores/profile/mintStore'; -import { useNpcMintStore } from '@/shared/stores/profile/npcMintStore'; -import { getNpcAddress } from '@/shared/lib/cashu/npc'; -import { usePaymentStatusStore } from '@/shared/stores/runtime/paymentStatusStore'; -import { useSettingsStore } from '@/shared/stores/global/settingsStore'; -import { useScanHistoryStore } from '@/shared/stores/profile/scanHistoryStore'; -import { - linkTransactionAnnotation, - setDistributionAnnotation, - setTransactionAnnotation, -} from '@/shared/stores/profile/transactionAnnotationStore'; -import { useSendReachabilityStore } from '@/shared/stores/profile/sendReachabilityStore'; -import { useTransactionDistributionStore } from '@/shared/stores/profile/transactionDistributionStore'; - -// ============================================================================= -// createSovranExecuteReceive -// ============================================================================= - -type ReceiveOperationLike = { - id: string; - mintUrl: string; - unit?: string; - amount?: AmountValue; - state: string; - createdAt?: number; - updatedAt?: number; -}; - -function isRecoverableReceiveError(err: unknown): boolean { - const kind = classifyMeshRedeemError(err); - return kind === 'network' || kind === 'retryable'; -} - -function receiveHistoryId(operationId: string): string { - return `receive:${operationId}`; -} - -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - -async function findReceiveHistoryEntryForOperation( - manager: Manager, - operationId: string, - mintUrl: string, - beforeIds: Set -): Promise { - const direct = await manager.history.getHistoryEntryById(receiveHistoryId(operationId)); - if (direct?.type === 'receive') return direct as ReceiveHistoryEntry; - - const after = await manager.history.getPaginatedHistory(0, 100); - return ( - after.find((h): h is ReceiveHistoryEntry => { - if (h.type !== 'receive' || h.mintUrl !== mintUrl) return false; - if (h.operationId === operationId) return true; - return !beforeIds.has(h.id); - }) ?? null - ); -} - -async function waitForReceiveHistoryEntry( - manager: Manager, - operationId: string, - mintUrl: string, - beforeIds: Set -): Promise { - const MAX_ATTEMPTS = 50; // ~10s of polling at 200ms intervals - const DELAY_MS = 200; - for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - try { - const entry = await findReceiveHistoryEntryForOperation( - manager, - operationId, - mintUrl, - beforeIds - ); - if (entry) { - paymentLog.info('payment.execute_receive.found', { - ...mintUrlLogFields(mintUrl), - realId: entry.id, - operationId, - attempts: attempt + 1, - }); - return entry; - } - } catch (e) { - paymentLog.warn('payment.execute_receive.poll_failed', { - attempt, - operationId, - error: e instanceof Error ? e.message : String(e), - }); - } - await new Promise((r) => setTimeout(r, DELAY_MS)); - } - return null; -} - -function buildPendingReceiveEntry( - operation: ReceiveOperationLike, - tokenString: string, - fallbackMintUrl: string, - fallbackAmount: number -) { - const now = Date.now(); - const mintUrl = operation.mintUrl || fallbackMintUrl; - const amount = amountToNumber(operation.amount ?? fallbackAmount); - const unit = operation.unit ?? 'sat'; - - return { - id: `receive-${operation.id}`, - type: 'receive' as const, - createdAt: operation.createdAt ?? now, - updatedAt: operation.updatedAt ?? now, - mintUrl, - unit, - amount, - state: 'executing', - operationId: operation.id, - metadata: { - rawToken: tokenString, - operationId: operation.id, - pendingReason: 'network', - }, - }; -} - -function buildFallbackFinalizedReceiveEntry( - tokenString: string, - mintUrl: string, - amount: number, - operationId?: string -) { - let tokenAmount = amount; - let tokenUnit = 'sat'; - try { - const metadata = getTokenMetadata(tokenString); - tokenAmount = amountToNumber(metadata.amount); - tokenUnit = metadata.unit ?? 'sat'; - } catch (error) { - paymentLog.warn('payment.execute_receive.fallback_decode_failed', { - ...mintUrlLogFields(mintUrl), - operationId: operationId ?? null, - tokenLength: tokenString.length, - error: error instanceof Error ? error.message : String(error), - }); - } - const now = Date.now(); - return { - id: mintLocalId('redeemed'), - type: 'receive' as const, - source: 'legacy' as const, - legacyHistoryId: mintLocalId('redeemed'), - createdAt: now, - updatedAt: now, - mintUrl, - unit: tokenUnit, - amount: tokenAmount, - state: 'finalized', - ...(operationId ? { operationId } : {}), - metadata: { rawToken: tokenString, ...(operationId ? { operationId } : {}) }, - }; -} - -/** - * Sovran-side override for colada's default `executeReceive`. - * - * Why this exists: - * - * colada's default `executeReceive` calls `mgr.wallet.receive(token)`, which - * hides Coco's operation lifecycle behind a single promise. That makes a - * recoverable offline receive look like a terminal receive failure to the UI. - * - * We still need the older real-id safeguard too: if the receive finalizes - * immediately, never let a synthesized id flow through setEntry, - * linkTransaction, onReceiveConfirmed, or onTransactionCreated. - * - * The fix is to use Coco receive ops directly. If the operation reaches - * `executing` and the mint/network is unreachable, we return Colada's - * pending result so the screen can wait for recovery. If it finalizes - * immediately, we still poll Coco history and use its real persisted id. - */ -export function createSovranExecuteReceive( - getManager: () => Manager | null -): NonNullable { - return async (tokenString, mintUrl, _amount) => { - const manager = getManager(); - if (!manager) { - paymentLog.error('payment.execute_receive.no_manager'); - throw new Error('Wallet manager is not available'); - } - - // Snapshot existing receive ids for this mint so we can identify the - // newly-persisted entry by set difference after the receive completes. - let beforeIds: Set; - try { - const beforeHistory = await manager.history.getPaginatedHistory(0, 100); - beforeIds = new Set( - (beforeHistory as readonly Record[]) - .filter((h) => h.type === 'receive' && h.mintUrl === mintUrl) - .map((h) => (typeof h.id === 'string' ? h.id : '')) - .filter((id) => id.length > 0) - ); - } catch (e) { - paymentLog.warn('payment.execute_receive.snapshot_failed', { - ...mintUrlLogFields(mintUrl), - error: e instanceof Error ? e.message : String(e), - }); - beforeIds = new Set(); - } - - // P2PK detection — preserved from coco's default so the downstream - // onP2PKReceiveCompleted notification still fires for users with - // `regenerateP2PKOnReceive` enabled. - let hadP2PKProofs = false; - try { - const metadata = getTokenMetadata(tokenString); - hadP2PKProofs = metadata.incompleteProofs.some((p) => { - try { - const parsed = JSON.parse(p.secret); - return Array.isArray(parsed) && parsed[0] === 'P2PK'; - } catch { - return false; - } - }); - } catch (error) { - paymentLog.warn('payment.execute_receive.p2pk_detection_decode_failed', { - ...mintUrlLogFields(mintUrl), - tokenLength: tokenString.length, - error: error instanceof Error ? error.message : String(error), - }); - // proof decode failure — fall back to false (no regression) - } - - paymentLog.info('payment.execute_receive.start', { - ...mintUrlLogFields(mintUrl), - beforeCount: beforeIds.size, - tokenLength: tokenString.length, - hadP2PKProofs, - expectedNext: 'prepare_receive_operation', - }); - - const prepared = await manager.ops.receive.prepare({ token: tokenString }); - paymentLog.info('payment.execute_receive.prepared', { - ...mintUrlLogFields(mintUrl), - operationId: prepared.id, - expectedNext: 'execute_or_mark_pending', - }); - - try { - const finalized = await manager.ops.receive.execute(prepared); - paymentLog.info('payment.execute_receive.executed', { - ...mintUrlLogFields(finalized.mintUrl), - operationId: finalized.id, - state: finalized.state, - expectedNext: 'history_entry_link', - }); - const entry = await waitForReceiveHistoryEntry( - manager, - finalized.id, - finalized.mintUrl, - beforeIds - ); - if (entry) { - paymentLog.info('payment.execute_receive.finalized', { - ...mintUrlLogFields(entry.mintUrl), - operationId: finalized.id, - receiveEntryId: entry.id, - hadP2PKProofs, - expectedNext: 'colada_receive_success', - }); - return { - status: 'finalized', - historyEntry: JSON.stringify(entry), - hadP2PKProofs, - }; - } - - paymentLog.error('payment.execute_receive.timeout_fallback', { - ...mintUrlLogFields(mintUrl), - operationId: finalized.id, - polledMs: 10_000, - }); - const fallbackEntry = buildFallbackFinalizedReceiveEntry( - tokenString, - finalized.mintUrl, - amountToNumber(finalized.amount), - finalized.id - ); - return { - status: 'finalized', - historyEntry: JSON.stringify(fallbackEntry), - hadP2PKProofs, - }; - } catch (err) { - let latest: ReceiveOperationLike | null = null; - try { - latest = (await manager.ops.receive.get(prepared.id)) as ReceiveOperationLike | null; - } catch (getErr) { - paymentLog.warn('payment.execute_receive.get_after_error_failed', { - operationId: prepared.id, - error: getErr instanceof Error ? getErr.message : String(getErr), - }); - } - - if (latest?.state === 'finalized') { - const entry = await waitForReceiveHistoryEntry( - manager, - latest.id, - latest.mintUrl, - beforeIds - ); - if (entry) { - paymentLog.info('payment.execute_receive.finalized_after_error', { - ...mintUrlLogFields(entry.mintUrl), - operationId: latest.id, - receiveEntryId: entry.id, - hadP2PKProofs, - expectedNext: 'colada_receive_success', - }); - return { - status: 'finalized', - historyEntry: JSON.stringify(entry), - hadP2PKProofs, - }; - } - } - - if (latest?.state === 'executing' && isRecoverableReceiveError(err)) { - paymentLog.info('payment.execute_receive.pending_recovery', { - ...mintUrlLogFields(latest.mintUrl), - operationId: latest.id, - hadP2PKProofs, - pendingReason: 'network', - expectedNext: 'coco_recovery_then_success_toast', - error: err instanceof Error ? err.message : String(err), - }); - const pendingEntry = buildPendingReceiveEntry(latest, tokenString, mintUrl, _amount); - return { - status: 'pending', - operationId: latest.id, - historyEntry: JSON.stringify(pendingEntry), - pendingReason: 'network', - message: RECEIVE_PENDING_TOAST_COPY.subtitle, - hadP2PKProofs, - }; - } - - paymentLog.error('payment.execute_receive.failed_terminal', { - ...mintUrlLogFields(mintUrl), - operationId: prepared.id, - latestState: latest?.state ?? null, - hadP2PKProofs, - error: err instanceof Error ? err.message : String(err), - expectedNext: 'colada_receive_failed', - }); - throw err; - } - }; -} - -// ============================================================================= -// createSovranExecuteMintQuote -// ============================================================================= - -const MINT_QUOTE_PREPARE_TIMEOUT_MS = 10_000; - -/** - * Sovran-side override for colada's default `executeMintQuote`. - * - * colada's default (defaultOperations.ts:275) calls - * `mgr.ops.mint.prepare(...)` and constructs the history entry directly from - * the returned operation, using `mintOp.id` as the entry id. The comment at - * defaultOperations.ts:291 acknowledges the race: it builds from the - * operation result *to avoid a race where getPaginatedHistory runs before - * HistoryService persists the row*. The unspoken risk is that coco's - * persisted row may end up with a different id (or different field shape) - * than `mintOp.id`. When that happens, the location stamp captured at - * onTransactionCreated (under `mintOp.id`) and the scan history link end up - * keyed to an id that doesn't match what `usePaginatedHistory` returns - * later — same class of bug as the receive case. - * - * Fix: snapshot mint ids for this mintUrl before prepare, then poll coco - * history for the persisted row by `quoteId` (deterministic — no race) and - * fall back to set-difference. Use coco's persisted row as authoritative for - * the id, while preserving `paymentRequest` from the operation result so - * the LightningReceiveScreen still has a lightning invoice to display. - */ -export function createSovranExecuteMintQuote( - getManager: () => Manager | null -): NonNullable { - return async (mintUrl, amount, _unit, method = 'bolt11') => { - const manager = getManager(); - if (!manager) { - paymentLog.error('payment.execute_mint_quote.no_manager'); - throw new Error('Wallet manager is not available'); - } - - if (method === 'onchain') { - throw new Error('Onchain mint quotes are not supported by @cashu/coco-core 1.0.1'); - } - - // Snapshot existing mint ids for this mint URL so we can detect the - // newly-persisted row by set difference if quoteId matching fails. - let beforeIds: Set; - try { - const beforeHistory: HistoryEntry[] = await manager.history.getPaginatedHistory(0, 100); - beforeIds = new Set( - beforeHistory.filter((h) => h.type === 'mint' && h.mintUrl === mintUrl).map((h) => h.id) - ); - } catch (e) { - paymentLog.warn('payment.execute_mint_quote.snapshot_failed', { - error: e instanceof Error ? e.message : String(e), - }); - beforeIds = new Set(); - } - - paymentLog.info('payment.execute_mint_quote.start', { - ...mintUrlLogFields(mintUrl), - amount, - }); - const mintOp = await withTimeout( - prepareBolt11MintQuote(manager, mintUrl, amount, _unit), - MINT_QUOTE_PREPARE_TIMEOUT_MS, - 'executeMintQuote.prepare' - ); - paymentLog.info('payment.execute_mint_quote.prepared', { - operationId: mintOp.id, - quoteId: mintOp.quoteId, - }); - - // Constructed entry — used as a fallback (same shape as coco's default - // executeMintQuote) when polling can't find coco's persisted row in time. - const constructedEntry: MintHistoryEntry = { - id: mintOp.id, - type: 'mint', - operationId: mintOp.id, - createdAt: mintOp.createdAt, - mintUrl: mintOp.mintUrl, - unit: mintOp.unit, - quoteId: mintOp.quoteId, - state: mintOp.lastObservedRemoteState ?? 'UNPAID', - amount: mintOp.amount, - paymentRequest: mintOp.request, - metadata: { operationId: mintOp.id }, - }; - - // Poll for coco's persisted row. Prefer quoteId match (deterministic); - // fall back to id-diff against the pre-prepare snapshot. - const MAX_ATTEMPTS = 50; // ~10s of polling at 200ms intervals - const DELAY_MS = 200; - for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - try { - const after: HistoryEntry[] = await manager.history.getPaginatedHistory(0, 100); - const persisted = after.find((h): h is MintHistoryEntry => { - if (h.type !== 'mint' || h.mintUrl !== mintUrl) return false; - // Preferred: deterministic quoteId match - if (mintOp.quoteId && h.quoteId === mintOp.quoteId) return true; - // Fallback: set difference on ids - return !beforeIds.has(h.id); - }); - if (persisted) { - paymentLog.info('payment.execute_mint_quote.found', { - ...mintUrlLogFields(mintUrl), - persistedId: persisted.id, - constructedId: mintOp.id, - matchedById: persisted.id === mintOp.id, - attempts: attempt + 1, - }); - // Coco's persisted row is authoritative — its `id` is what flows - // downstream to onTransactionCreated and the scan-history link. - return { historyEntry: JSON.stringify(persisted) }; - } - } catch (e) { - paymentLog.warn('payment.execute_mint_quote.poll_failed', { - attempt, - error: e instanceof Error ? e.message : String(e), - }); - } - await new Promise((r) => setTimeout(r, DELAY_MS)); - } - - // Last resort: same fallback as coco's default. Logged loudly so we know - // the polling didn't catch the persisted row. - paymentLog.error('payment.execute_mint_quote.timeout_fallback', { - ...mintUrlLogFields(mintUrl), - operationId: mintOp.id, - polledMs: MAX_ATTEMPTS * DELAY_MS, - }); - return { historyEntry: JSON.stringify(constructedEntry) }; - }; -} - -// ============================================================================= -// createSovranNotifications -// ============================================================================= - -interface CreateSovranNotificationsConfig { - getPubkey?: () => string | undefined; - getPrivateKey?: () => Uint8Array | undefined; - getManager?: () => Manager | null; - onP2pkKeyRefreshed?: (newPublicKeyHex: string | null) => void; -} - -export function createSovranNotifications( - config?: CreateSovranNotificationsConfig -): NotificationHandlerMap { - paymentLog.debug('payment.notifications.created'); - return { - NO_AMOUNT: ({ code: _code, message: _message, data: _data }) => { - paymentLog.warn('payment.notification.no_amount'); - staticPopup('no-amount'); - }, - NO_VALID_MINT: ({ code: _code, message, data: _data }) => { - staticPopup('no-valid-mint', { text: message }); - }, - INSUFFICIENT_BALANCE: ({ code: _code, message, data: _data }) => { - staticPopup('balance-too-low', { text: message }); - }, - NO_BALANCE: ({ code: _code, message, data: _data }) => { - staticPopup('balance-too-low', { text: message }); - }, - UNSUPPORTED_INPUT: ({ code: _code, message, data: _data }) => { - staticPopup('unsupported-input', { text: message }); - }, - UNSUPPORTED_PAYMENT_METHOD: ({ code: _code, message, data: _data }) => { - staticPopup('unsupported-payment-method', { text: message }); - }, - ALL_OPTIONS_DISABLED: ({ code: _code, message: _message, data: _data }) => { - staticPopup('all-options-disabled'); - }, - MISSING_MELT_TARGET: ({ code: _code, message: _message, data: _data }) => { - staticPopup('missing-melt-target'); - }, - SEND_FAILED: ({ code: _code, message, data }) => { - paymentLog.error('payment.notification.send_failed', { message }); - if (data?.mintUnreachable) { - staticPopup('mint-unreachable'); - } else { - staticPopup('general-error', { text: message }); - } - }, - MINT_QUOTE_FAILED: ({ code: _code, message, data }) => { - if (data?.mintUnreachable) { - staticPopup('mint-unreachable'); - } else { - staticPopup('general-error', { text: message }); - } - }, - MELT_FAILED: ({ code: _code, message, data }) => { - paymentLog.error('payment.notification.melt_failed', { message }); - if (data?.mintUnreachable) { - staticPopup('mint-unreachable'); - } else { - staticPopup('general-error', { text: message }); - } - }, - PAYMENT_REQUEST_FAILED: ({ code: _code, message, data: _data }) => { - staticPopup('send-payment-failed', { text: message }); - }, - NFC_WRITE_FAILED: ({ code: _code, message, data: _data }) => { - paramPopup('nfc-error', { title: 'NFC Write Failed', message }); - }, - NFC_SESSION_LOST: ({ code: _code, message, data: _data }) => { - paramPopup('nfc-error', { title: 'NFC Connection Lost', message }); - }, - NFC_READ_FAILED: ({ code: _code, message, data: _data }) => { - paramPopup('nfc-error', { title: 'NFC Read Failed', message }); - }, - onPaymentProcessing: (data) => { - const store = usePaymentStatusStore.getState(); - const variant = data.variant === 'paymentRequest' ? 'payment-request' : data.variant; - const id = `${data.variant}-${Date.now()}`; - paymentLog.info('payment.processing', { - variant: data.variant, - statusVariant: variant, - id, - ...mintUrlLogFields(data.mintUrl), - amount: data.amount, - unit: data.unit, - activeId: store.active?.id ?? null, - activeState: store.active?.state ?? null, - expectedNext: 'processing_to_terminal_or_delivered', - }); - store.setActive({ - variant, - id, - mintUrl: data.mintUrl, - amount: data.amount, - unit: data.unit, - state: 'processing', - }); - paymentStatusPopup({ - variant, - id, - mintUrl: data.mintUrl, - amount: data.amount, - unit: data.unit, - }); - }, - onPaymentConfirmed: (data) => { - const store = usePaymentStatusStore.getState(); - paymentLog.info('payment.confirmed', { - variant: data.variant, - ...mintUrlLogFields(data.mintUrl), - amount: data.amount, - unit: data.unit, - activeId: store.active?.id ?? null, - activeState: store.active?.state ?? null, - historyEntryLength: data.historyEntry.length, - }); - if (!store.active) { - paymentLog.warn('payment.confirmed.skipped', { - variant: data.variant, - reason: 'no_active_status_toast', - }); - return; - } - - if (data.variant === 'paymentRequest') { - paymentLog.info('payment.confirmed.route', { - variant: data.variant, - action: 'set_delivered', - activeId: store.active.id, - activeState: store.active.state, - expectedNext: 'delivered_then_later_confirmed', - }); - store.setDelivered(store.active.id); - } else if (data.variant === 'melt' && data.historyEntry) { - try { - const parsed = JSON.parse(data.historyEntry); - if (parsed.state === 'PENDING') { - paymentLog.info('payment.confirmed.skipped', { - variant: data.variant, - reason: 'melt_history_pending', - activeId: store.active.id, - activeState: store.active.state, - }); - return; - } - } catch (error) { - paymentLog.warn('payment.confirmed.history_parse_failed', { - variant: data.variant, - historyEntryLength: data.historyEntry.length, - error: error instanceof Error ? error.message : String(error), - }); - } - paymentLog.info('payment.confirmed.route', { - variant: data.variant, - action: 'set_confirmed', - activeId: store.active.id, - activeState: store.active.state, - }); - store.setConfirmed(store.active.id); - } else { - paymentLog.info('payment.confirmed.route', { - variant: data.variant, - action: 'set_confirmed', - activeId: store.active.id, - activeState: store.active.state, - }); - store.setConfirmed(store.active.id); - } - }, - onPaymentFailed: (data) => { - const store = usePaymentStatusStore.getState(); - paymentLog.error('payment.failed', { - variant: data.variant, - ...mintUrlLogFields(data.mintUrl), - amount: data.amount, - unit: data.unit, - message: data.message, - rolledBack: data.rolledBack, - activeId: store.active?.id ?? null, - activeState: store.active?.state ?? null, - }); - if (!store.active) { - paymentLog.warn('payment.failed.skipped', { - variant: data.variant, - reason: 'no_active_status_toast', - }); - return; - } - const msg = data.rolledBack - ? `${data.message}\nYour funds have been returned.` - : data.message; - paymentLog.info('payment.failed.route', { - variant: data.variant, - action: 'set_failed', - activeId: store.active.id, - activeState: store.active.state, - rolledBack: data.rolledBack, - }); - store.setFailed(store.active.id, new Error(msg)); - }, - onScanEmpty: (source) => { - if (source === 'clipboard') staticPopup('no-clipboard-address'); - else if (source === 'gallery') staticPopup('no-qr-code-found'); - }, - onScanError: (source, err) => { - if (source === 'gallery') staticPopup('qr-scan-failed'); - else staticPopup('general-error', { text: err.message }); - }, - onMissingMintForAmount: () => { - staticPopup('no-mint-selected'); - }, - onCopied: (target) => { - copyPopup(target as Parameters[0]); - }, - onScanResolved: ({ rawInput, parsedType, intentType, source, container, optionKinds }) => { - const typeMap: Record< - string, - 'ecash' | 'lightning' | 'npub' | 'mint' | 'paymentRequest' | 'unknown' - > = { - receiveToken: 'ecash', - meltLightningInvoice: 'lightning', - meltLightningAddress: 'lightning', - meltLnurlp: 'lightning', - sendPaymentRequest: 'paymentRequest', - openMint: 'mint', - openProfile: 'npub', - }; - const scanType = typeMap[intentType] ?? 'unknown'; - const sourceMap: Record = { - clipboard: 'paste', - gallery: 'qr', - qr: 'qr', - nfc: 'nfc', - deeplink: 'deeplink', - }; - const scanSource = sourceMap[source ?? ''] ?? 'qr'; - useScanHistoryStore - .getState() - .addScan(rawInput, scanType, scanSource, parsedType, container, optionKinds); - // Annotation: stash the scan under a raw key now; bridged onto the final - // transaction id in onTransactionCreated (colada owns the read model). - setTransactionAnnotation(rawAnnotationKey(rawInput), { - scan: { - method: scanSource, - raw: rawInput, - container, - optionKinds, - inputType: parsedType, - }, - }); - }, - onNfcWriteFailed: ({ message, rolledBack }) => { - const errorMsg = rolledBack ? `${message} Your funds have been returned.` : message; - paramPopup('nfc-error', { title: 'NFC Write Failed', message: errorMsg }); - }, - // Drives the Android tap-to-pay sheet's phase text ('Preparing payment…' - // etc.). 'creating'/'writing' only fire once executeNfcSend write-back - // is wired; harmless to map them now. - onNfcPaymentProgress: ({ phase }) => { - useNfcTapStore.getState().setPhase(phase); - }, - - // ── Screen action notifications ───────────────────────────────── - - onSendStatusChecked: ({ operationId: _operationId, state, redeemed }) => { - if (redeemed || isSendTokenComplete({ state })) { - staticPopup('token-redeemed-by-recipient'); - } else if (isSendTokenCancelled({ state })) { - staticPopup('transaction-already-cancelled'); - } else if (state === 'not_found') { - staticPopup('operation-not-found'); - } else if (state !== 'pending') { - paramPopup('operation-invalid-state', { state }); - } else { - staticPopup('token-pending-not-redeemed'); - } - }, - - onSendCancelled: (_data) => { - staticPopup('transaction-cancelled'); - }, - - onSendCancelFailed: ({ message, mintUnreachable, offline }) => { - if (offline) { - staticPopup('cancel-transaction-offline'); - } else if (mintUnreachable) { - staticPopup('mint-unreachable'); - } else { - staticPopup('cancel-transaction-failed', { text: message }); - } - }, - - onReceiveProcessing: ({ id, mintUrl, amount, unit }) => { - const store = usePaymentStatusStore.getState(); - paymentLog.info('payment.receive.processing', { - id, - ...mintUrlLogFields(mintUrl), - amount, - unit, - activeId: store.active?.id ?? null, - activeState: store.active?.state ?? null, - expectedNext: 'processing_to_confirmed_or_waiting_or_failed', - }); - if (store.active?.id === id && store.active?.state === 'failed') { - store.setActive(null); - } - store.setActive({ - variant: 'receive-ecash', - id, - mintUrl, - amount, - unit, - state: 'processing', - }); - paymentStatusPopup({ - variant: 'receive-ecash', - id, - mintUrl, - amount, - unit, - }); - }, - - onReceivePending: ({ id, mintUrl, amount, unit, operationId, message }) => { - const store = usePaymentStatusStore.getState(); - const wasActive = store.active?.id === id; - paymentLog.info('payment.receive.pending', { - id, - ...mintUrlLogFields(mintUrl), - amount, - unit, - operationId, - activeId: store.active?.id ?? null, - activeState: store.active?.state ?? null, - wasActive, - toastPolicy: 'terminal_warning_then_new_success', - expectedNext: 'coco_recovery_then_success_toast', - }); - if (!wasActive) { - store.setActive({ - variant: 'receive-ecash', - id, - mintUrl, - amount, - unit, - state: 'processing', - }); - paymentStatusPopup({ - variant: 'receive-ecash', - id, - mintUrl, - amount, - unit, - }); - } - store.setWaiting(id, { - title: RECEIVE_PENDING_TOAST_COPY.title, - subtitle: message ?? RECEIVE_PENDING_TOAST_COPY.subtitle, - }); - }, - - onReceiveConfirmed: async ({ id, historyEntry }) => { - const store = usePaymentStatusStore.getState(); - paymentLog.info('payment.receive.confirmed', { - id, - activeId: store.active?.id ?? null, - activeState: store.active?.state ?? null, - expectedNext: - store.active?.state === 'waiting' - ? 'listener_mounts_new_success_toast' - : 'active_toast_confirmed', - }); - if (store.active?.id === id) { - store.setConfirmed(id); - } - try { - const entry = JSON.parse(historyEntry); - if (entry.id) { - await captureAndStoreLocation(entry.id); - } - } catch { - // Non-critical — skip location capture - } - }, - - onReceiveFailed: ({ id, message }) => { - const store = usePaymentStatusStore.getState(); - paymentLog.error('payment.receive.failed', { - id, - message, - activeId: store.active?.id ?? null, - activeState: store.active?.state ?? null, - expectedNext: - store.active?.id === id && store.active?.state === 'processing' - ? 'active_toast_failed' - : 'static_receive_failed_popup', - }); - if (store.active?.id === id && store.active?.state === 'processing') { - store.setFailed(id, new Error(message)); - } else { - staticPopup('receive-failed', { text: message }); - } - }, - - onMeltCancelled: (_data) => { - paymentCancelledPopup(); - }, - - onMeltCancelFailed: ({ message, mintUnreachable }) => { - if (mintUnreachable) { - staticPopup('mint-unreachable'); - } else { - staticPopup('could-not-cancel', { text: message }); - } - }, - - onUnsupportedTokenUnit: ({ unit }) => { - paramPopup('unsupported-token-unit', { unit }); - }, - - onMintTrustedFromScreen: ({ fromAccepter }) => { - if (fromAccepter) { - router.dismiss(); - } else { - router.back(); - } - }, - - // ── State change notifications ────────────────────────────────── - - onPreferredMintChanged: ({ mintUrl }) => { - useMintStore.getState().setSelectedMint(mintUrl); - }, - - onNpcMintChanged: async ({ mintUrl }) => { - const pk = config?.getPrivateKey?.(); - if (pk) { - const ok = await useNpcMintStore.getState().updateServerMint(mintUrl, pk); - if (ok) staticPopup('receive-mint-updated'); - else staticPopup('receive-mint-update-failed'); - } - }, - - onTransactionCreated: async ({ transactionId, rawInput }) => { - if (rawInput) { - useScanHistoryStore.getState().linkTransaction(rawInput, transactionId); - // Bridge the scan annotation from its raw key onto the final entry id. - linkTransactionAnnotation(rawAnnotationKey(rawInput), `id:${transactionId}`); - } - await captureAndStoreLocation(transactionId); - }, - - onP2PKReceiveCompleted: async ({ hadP2PKProofs }) => { - if (hadP2PKProofs && useSettingsStore.getState().regenerateP2PKOnReceive) { - const mgr = config?.getManager?.(); - if (mgr) { - if ((getP2PKImportExtension(mgr)?.getPublicKeys() ?? []).length > 0) { - return; - } - - try { - await mgr.keyring.generateKeyPair(); - const keypair = await mgr.keyring.getLatestKeyPair(); - config?.onP2pkKeyRefreshed?.(keypair?.publicKeyHex ?? null); - } catch { - // Non-critical — skip P2PK key regeneration - } - } - } - }, - }; -} - -// ============================================================================= -// createSovranScanSources -// ============================================================================= - -export function createSovranScanSources(nfcAdapter?: NfcIOAdapter): ScanSources { - paymentLog.debug('payment.scan_sources.created', { hasNfc: !!nfcAdapter }); - return { - clipboard: async () => { - paymentLog.debug('payment.scan.clipboard.start'); - const rawText = (await Clipboard.getStringAsync()).trim(); - const decodedText = isEncoded(rawText) ? decode(rawText) : rawText; - if (!decodedText) { - paymentLog.debug('payment.scan.clipboard.empty'); - return { empty: true }; - } - paymentLog.info('payment.scan.clipboard.found', { chars: decodedText.length }); - return { data: decodedText }; - }, - gallery: async () => { - paymentLog.debug('payment.scan.gallery.start'); - try { - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ['images'], - allowsEditing: false, - quality: 1, - }); - - if (result.canceled || !result.assets?.[0]?.uri) { - paymentLog.debug('payment.scan.gallery.canceled'); - return { canceled: true }; - } - - const scannedCodes = await scanFromURLAsync(result.assets[0].uri, ['qr']); - - if (scannedCodes.length === 0) { - paymentLog.debug('payment.scan.gallery.no_qr'); - return { empty: true }; - } - - paymentLog.info('payment.scan.gallery.found', { dataLen: scannedCodes[0].data.length }); - return { data: scannedCodes[0].data }; - } catch (err) { - paymentLog.error('payment.scan.gallery.failed', { - error: err instanceof Error ? err : new Error(String(err)), - }); - return { error: err instanceof Error ? err : new Error(String(err)) }; - } - }, - nfc: nfcAdapter - ? async () => { - // Ambient cycles (wallet-screen listening loop) re-arm every ~30s, - // so their per-cycle failures must stay quiet; explicit presses - // keep the popups. - const ambient = isAmbientNfcCycle(); - const closeTapSheet = () => { - const popup = usePopupStore.getState(); - if ( - popup.current && - 'sheetId' in popup.current && - popup.current.sheetId === 'nfc-tap' - ) { - popup.close(); - } - }; - try { - const data = await nfcAdapter.readPaymentRequest(); - // The flow navigates to the payment screen now — don't leave the - // tap sheet floating over it. - closeTapSheet(); - return { data }; - } catch (err) { - // User dismissed the system NFC sheet — treat as a no-op, - // not an error (suppresses the `general-error` popup). - if (isUserCancelError(err)) return { empty: true }; - // Preflight failures from acquireSession get their own popups — - // before this, an Android tap with NFC off hung forever silently. - if (err instanceof NfcError && err.code === 'NOT_ENABLED') { - if (!ambient) { - paramPopup('nfc-error', { - title: 'NFC is turned off', - message: 'Turn on NFC in system settings to scan.', - }); - } - return { empty: true }; - } - if (err instanceof NfcError && err.code === 'NOT_SUPPORTED') { - if (!ambient) { - paramPopup('nfc-error', { - title: 'NFC not supported', - message: 'This device has no NFC hardware.', - }); - } - return { empty: true }; - } - if (err instanceof NfcError && err.code === 'TIMEOUT') { - // Nothing was tapped within the window — quiet no-op. - return { empty: true }; - } - if (ambient) { - // A garbled ambient read (non-payment tag, partial APDU) must - // not surface the general-error popup; the loop just re-arms. - paymentLog.debug('nfc.ambient.read_failed', { - error: err instanceof Error ? err.message : String(err), - }); - return { empty: true }; - } - return { error: err instanceof Error ? err : new Error(String(err)) }; - } finally { - // Timeouts deliberately leave the sheet up: the ambient loop - // re-arms immediately and the sheet should read as continuous - // listening, not blink every 30s. Error popups replace the sheet - // through the popup store; the success path closed it above. - useNfcTapStore.getState().setPhase('armed'); - } - } - : undefined, - }; -} - -// ============================================================================= -// createSovranHandlers -// ============================================================================= - -interface CreateSovranHandlersConfig { - machine: PaymentMachine; - onOptionDismiss?: () => void; - getManager: () => Manager | null; - getNpub?: () => string | undefined; - getBitchatIdentityMaterial?: () => BitchatBLEIdentityMaterial | null; -} - -function getEncodedEcashTokenFromSendHistoryEntry(historyEntry: string): string | null { - try { - const parsed = JSON.parse(historyEntry) as { - token?: Parameters[0]; - tokenString?: unknown; - metadata?: { rawToken?: unknown }; - }; - if (typeof parsed.tokenString === 'string' && parsed.tokenString.length > 0) { - return parsed.tokenString; - } - if (typeof parsed.metadata?.rawToken === 'string' && parsed.metadata.rawToken.length > 0) { - return parsed.metadata.rawToken; - } - if (parsed.token) { - return getEncodedToken(parsed.token); - } - } catch (err) { - paymentLog.warn('near_pay.token.extract_failed', { - error: err instanceof Error ? err.message : String(err), - }); - } - return null; -} - -async function deliverNearPayIfActive( - historyEntry: string, - getBitchatIdentityMaterial?: () => BitchatBLEIdentityMaterial | null -): Promise { - const active = useNearPaySessionStore.getState().active; - if (!active) return; - - // Every Nut Drop send is delivered as a SINGLE private Noise DM to a - // creq-confirmed Sovran peer — encrypted to them, so a locked OR offline - // bearer token stays private (no public-mesh broadcast of payment metadata). - // The whole multi-KB token fits one message thanks to the extended - // PrivateMessagePacket length. Stock clients can't decode extended DMs, so - // active sessions must carry a creq capability proof before we transmit. - - try { - const encodedToken = getEncodedEcashTokenFromSendHistoryEntry(historyEntry); - if (!encodedToken) throw new Error('Created send entry did not contain an ecash token'); - if (!active.recipient.creq) { - throw new Error('Nut Drop recipient has not advertised a creq capability'); - } - - const profileScope = getBitchatProfileScope(); - const identityMaterial = getBitchatIdentityMaterial?.() ?? null; - const nickname = getBitchatNickname() || 'sovran'; - const result = await sendBLEPrivateMessageWhole({ - peerID: active.recipient.peerID, - content: encodedToken, - nickname, - profileScope, - identityMaterial, - }); - - paymentLog.info('near_pay.delivery.sent', { - peerID: active.recipient.peerID, - tokenBytes: encodedToken.length, - hasDirectLink: active.recipient.hasDirectLink, - startupMs: Math.round(result.startupMs * 100) / 100, - handshakeMs: Math.round(result.handshakeMs * 100) / 100, - sendMs: Math.round(result.sendMs * 100) / 100, - ...(result.handshakeError ? { handshakeError: result.handshakeError } : {}), - }); - - // Delivered over the BLE/bitchat mesh — stamp a bluetooth source badge on - // the resulting send transaction. - try { - const entry = JSON.parse(historyEntry) as { id?: unknown }; - if (typeof entry.id === 'string') { - setTransactionAnnotation(`id:${entry.id}`, { scan: { method: 'ble' } }); - } - } catch (e) { - paymentLog.warn('near_pay.delivery.ble_source_annotation_failed', { - error: e instanceof Error ? e.message : String(e), - }); - } - } catch (err) { - paymentLog.error('near_pay.delivery.failed', { - peerID: active.recipient.peerID, - error: err instanceof Error ? err.message : String(err), - }); - } finally { - useNearPaySessionStore.getState().complete(); - } -} - -export function createSovranHandlers({ - machine, - onOptionDismiss, - getManager, - getNpub, - getBitchatIdentityMaterial, -}: CreateSovranHandlersConfig): StepHandlerMap { - paymentLog.debug('payment.handlers.created'); - - return { - receiveToken: ({ token }) => { - paymentLog.info('payment.step.receive_token'); - router.navigate({ - pathname: '/(receive-flow)/receiveToken', - params: { receiveHistoryEntry: JSON.stringify(buildReceiveHistoryEntry(token)) }, - }); - }, - - sendComplete: async ({ - historyEntry, - createdOffline, - mintWasOffline, - recipientPubkey, - recipientProfile, - p2pkLockPubkey, - }) => { - paymentLog.info('payment.step.send_complete', { - createdOffline: !!createdOffline, - mintWasOffline: !!mintWasOffline, - recipientPubkeyPresent: !!recipientPubkey, - p2pkLocked: !!p2pkLockPubkey, - }); - - // Routstr top-up: intercept the token and send it to the Routstr API - const topUpState = useRoutstrTopUpStore.getState(); - if (topUpState.active) { - try { - const entry = JSON.parse(historyEntry); - const encodedToken = getEncodedToken(entry.token); - const result = await executeRoutstrTopUp(encodedToken); - - if (result.success) { - const balanceStr = formatRoutstrBalance(result.balance); - if (result.isNewWallet) { - paramPopup('routstr-wallet-created', { balance: balanceStr }); - } else { - paramPopup('routstr-top-up-success', { balance: balanceStr }); - } - useRoutstrTopUpStore.getState().complete('success'); - } else { - staticPopup('routstr-transaction-failed', { text: result.error }); - useRoutstrTopUpStore.getState().complete('failed'); - } - } catch (e) { - paymentLog.error('payment.routstr_topup.error', { - error: e instanceof Error ? e.message : String(e), - }); - staticPopup('routstr-transaction-failed', { text: 'Failed to process top-up' }); - useRoutstrTopUpStore.getState().complete('failed'); - } - router.dismiss(); - return; - } - - // Inject recipientPubkey into the executed history entry's metadata - // so SendTokenScreen can render the recipient identity. Operations - // build the entry; we attach identity at the screen-handler seam. - const enrichedHistoryEntry = recipientPubkey - ? injectRecipientPubkey(historyEntry, recipientPubkey) - : historyEntry; - - // Persist the recipient's nostr identity as a counterparty annotation so - // the transactions row + detail show their avatar (the transient - // metadata injection above only survives this navigation). - if (recipientPubkey) { - try { - const entry = JSON.parse(enrichedHistoryEntry) as { id?: unknown }; - if (typeof entry.id === 'string') { - setTransactionAnnotation(`id:${entry.id}`, { - counterparty: { - pubkey: recipientPubkey, - direction: 'recipient', - ...(recipientProfile?.displayName - ? { displayName: recipientProfile.displayName } - : {}), - ...(recipientProfile?.avatarUrl ? { avatarUrl: recipientProfile.avatarUrl } : {}), - ...(recipientProfile?.nip05 ? { nip05: recipientProfile.nip05 } : {}), - }, - }); - } - } catch (e) { - paymentLog.warn('payment.send_complete.counterparty_annotation_failed', { - error: e instanceof Error ? e.message : String(e), - }); - } - } - - if (createdOffline) { - try { - const entry = JSON.parse(enrichedHistoryEntry) as { id?: unknown; mintUrl?: unknown }; - if (typeof entry.id === 'string' && typeof entry.mintUrl === 'string') { - useSendReachabilityStore.getState().markChecking(entry.id, entry.mintUrl); - useSendReachabilityStore.getState().pruneOld(); - } - } catch (e) { - paymentLog.warn('payment.send_complete.reachability_seed_failed', { - error: e instanceof Error ? e.message : String(e), - }); - } - } - - await deliverNearPayIfActive(enrichedHistoryEntry, getBitchatIdentityMaterial); - - router.navigate({ - pathname: '/(send-flow)/sendToken', - params: { - sendHistoryEntry: enrichedHistoryEntry, - ...(createdOffline ? { createdOffline: 'true' } : {}), - ...(mintWasOffline ? { mintWasOffline: 'true' } : {}), - }, - }); - }, - - navigateToPaymentRequest: ({ mintUrl, paymentRequest, amount, unit, recipientPubkey }) => { - paymentLog.info('payment.step.navigate_payment_request', { - ...mintUrlLogFields(mintUrl), - amount, - unit, - recipientPubkeyPresent: !!recipientPubkey, - paymentRequestLength: paymentRequest.length, - }); - const entry = { - id: mintLocalId('pr-preview'), - type: 'send', - createdAt: Date.now(), - mintUrl, - amount, - unit, - state: 'prepared', - metadata: { - paymentRequest, - phase: 'preview', - ...(recipientPubkey ? { recipientPubkey } : {}), - }, - }; - const isFallback = (machine.getContext().failedOptionValues?.length ?? 0) > 0; - const nav = isFallback ? router.replace : router.navigate; - nav({ - pathname: '/(send-flow)/paymentRequest', - params: { paymentRequestEntry: JSON.stringify(entry) }, - }); - }, - - navigateToMeltPreview: ({ - mintUrl, - meltTarget, - amount, - unit, - recipientPubkey, - recipientProfile, - }) => { - paymentLog.info('payment.step.navigate_melt_preview', { - ...mintUrlLogFields(mintUrl), - amount, - unit, - meltTargetLength: meltTarget.length, - recipientPubkeyPresent: !!recipientPubkey, - recipientProfilePresent: !!recipientProfile, - recipientProfileDisplayName: recipientProfile?.displayName ?? null, - recipientProfileAvatarUrlPresent: !!recipientProfile?.avatarUrl, - }); - // `MeltHistoryEntry.metadata` is typed `Record` upstream - // in `@cashu/coco-core`, so the resolved profile is flattened into - // individual string keys instead of stored as a nested object. - // `LightningSendScreen` re-assembles them on read. - const id = mintLocalId('melt-preview'); - const now = Date.now(); - const entry: MeltHistoryEntry & { - source: 'legacy'; - legacyHistoryId: string; - updatedAt: number; - } = { - id, - type: 'melt', - source: 'legacy', - legacyHistoryId: id, - createdAt: now, - updatedAt: now, - mintUrl, - unit: unit ?? 'sat', - quoteId: '', - state: 'UNPAID', - amount: amountToNumber(amount), - metadata: { - phase: 'preview', - meltTarget, - ...(recipientPubkey ? { recipientPubkey } : {}), - ...(recipientProfile?.displayName - ? { recipientDisplayName: recipientProfile.displayName } - : {}), - ...(recipientProfile?.avatarUrl - ? { recipientAvatarUrl: recipientProfile.avatarUrl } - : {}), - ...(recipientProfile?.nip05 ? { recipientNip05: recipientProfile.nip05 } : {}), - }, - }; - const isFallback = (machine.getContext().failedOptionValues?.length ?? 0) > 0; - const nav = isFallback ? router.replace : router.navigate; - nav({ - pathname: '/(send-flow)/lightningSend', - params: { meltHistoryEntry: JSON.stringify(entry) }, - }); - }, - - mintQuoteCreated: ({ historyEntry, unit }) => { - let pathname: '/(receive-flow)/lightningReceive' | '/(receive-flow)/onchainReceive' = - '/(receive-flow)/lightningReceive'; - try { - pathname = getOnchainMintAddress(JSON.parse(historyEntry) as HistoryEntry) - ? '/(receive-flow)/onchainReceive' - : '/(receive-flow)/lightningReceive'; - } catch { - pathname = '/(receive-flow)/lightningReceive'; - } - router.replace({ - pathname, - params: { mintHistoryEntry: historyEntry, unit }, - }); - }, - - reviewMint: ({ mintUrl, token, mintInfo }) => { - const entry = { - ...(mintInfo ?? {}), - mintUrl, - fromAccepter: true, - token, - }; - router.navigate({ - pathname: '/(mint-flow)/info', - params: { mintInfoEntry: JSON.stringify(entry) }, - }); - }, - - openMint: ({ url, mintInfo }) => { - const entry = { - ...(mintInfo ?? {}), - mintUrl: url, - fromScan: true, - }; - router.navigate({ - pathname: '/(mint-flow)/info', - params: { mintInfoEntry: JSON.stringify(entry) }, - }); - }, - - openProfile: ({ npub }) => { - router.navigate(buildModalProfileHref({ npub })); - }, - - navigateToReceive: async ({ unit, methodContext }) => { - const t0 = performance.now(); - const npub = getNpub?.(); - const selectedMintUrl = useNpcMintStore.getState().getActiveMintUrl(); - - let p2pkKey: string | undefined; - const currentMgr = getManager(); - if (currentMgr) { - try { - p2pkKey = await resolvePrimaryReceiveP2PKPublicKey(currentMgr); - } catch { - /* ignore */ - } - } - - const entry = { - type: 'receive', - id: 'receive-hub', - createdAt: Date.now(), - mintUrl: selectedMintUrl ?? '', - npcAddress: npub ? getNpcAddress(undefined, npub) : undefined, - p2pkKey, - selectedMintUrl, - ...(methodContext ? { methodContext } : {}), - unit, - }; - router.navigate({ - pathname: '/(receive-flow)/receive', - params: { receiveEntry: JSON.stringify(entry), unit }, - }); - paymentLog.info('navigate.receive.done', { duration_ms: performance.now() - t0 }); - }, - - enterAmount: ({ unit, preselectedMintUrl, constraints }) => { - const t0 = performance.now(); - const entry = { - destination: constraints.destination, - unit, - selectedMintUrl: preselectedMintUrl ?? '', - ...(constraints.paymentRequest ? { paymentRequest: constraints.paymentRequest } : {}), - ...(constraints.meltTarget ? { meltTarget: constraints.meltTarget } : {}), - ...(constraints.methodContext ? { methodContext: constraints.methodContext } : {}), - // Snapshot the machine-resolved recipient identity onto the entry so - // the amount screen renders "Pay " + avatar on first paint - // when the resolver beat the navigation. AmountFlowScreen also - // subscribes to the live ctx for the case where the resolver lands - // after navigation. - ...(constraints.recipientPubkey ? { recipientPubkey: constraints.recipientPubkey } : {}), - ...(constraints.recipientProfile ? { recipientProfile: constraints.recipientProfile } : {}), - }; - const params = { amountEntry: JSON.stringify(entry) }; - const nearPaySessionStore = useNearPaySessionStore.getState(); - // Radar-launched sends stay inline on the radar: the vanilla ladder - // arrives as destination 'sendEcash', mesh sends as 'paymentRequest' - // (the solicited creq rides the payment-request machinery). - if ( - nearPaySessionStore.active && - (constraints.destination === 'sendEcash' || constraints.destination === 'paymentRequest') - ) { - nearPaySessionStore.setAmountEntry(params.amountEntry); - paymentLog.info('navigate.enterAmount.near_pay_inline', { - destination: constraints.destination, - duration_ms: performance.now() - t0, - }); - return; - } - router.navigate( - constraints.destination === 'mintQuote' - ? { pathname: '/(receive-flow)/amount', params } - : { pathname: '/(send-flow)/amount', params } - ); - paymentLog.info('navigate.enterAmount.done', { duration_ms: performance.now() - t0 }); - }, - - selectMint: ({ - candidates: _candidates, - supportedMintUrls: _supportedMintUrls, - amount: _amount, - unit, - paymentRequest: _paymentRequest, - meltTarget: _meltTarget, - destination, - mintQuoteMethod, - meltQuoteMethod, - methodRequirement, - mintListItems, - scope, - }) => { - const entry = { - items: mintListItems ?? [], - scope: scope ?? 'selected', - destination, - ...(mintQuoteMethod ? { mintQuoteMethod } : {}), - ...(meltQuoteMethod ? { meltQuoteMethod } : {}), - ...(methodRequirement ? { methodRequirement } : {}), - unit, - }; - - const params = { mintSelectorEntry: JSON.stringify(entry) }; - router.navigate( - destination === 'mintQuote' || scope === 'npc' - ? { pathname: '/(receive-flow)/mintSelect', params } - : { pathname: '/(send-flow)/mintSelect', params } - ); - }, - - chooseOption: (stepData) => { - paymentOptionsPopup({ ...stepData, machine, onDismiss: onOptionDismiss }); - }, - - chooseFallbackOption: (stepData) => { - paymentFallbackPopup({ ...stepData, machine, onDismiss: onOptionDismiss }); - }, - - chooseProofs: (stepData) => { - proofSelectorPopup({ ...stepData, machine }); - }, - - enterSendMemo: (stepData) => { - sendMemoPopup({ ...stepData, machine }); - }, - - dismiss: () => { - router.back(); - }, - }; -} - -// ============================================================================= -// createSovranScreenActionHandlers -// ============================================================================= - -type Ctx = ScreenActionContext & { manager: Manager }; - -/** - * Re-serialize a JSON-encoded coco history entry with `recipientPubkey` - * added to its metadata. Returns the input unchanged if it can't be parsed - * — operations build the entry, this only attaches identity at the seam. - */ -function injectRecipientPubkey(historyEntry: string, recipientPubkey: string): string { - try { - const parsed = JSON.parse(historyEntry) as { metadata?: Record }; - parsed.metadata = { ...(parsed.metadata ?? {}), recipientPubkey }; - return JSON.stringify(parsed); - } catch { - paymentLog.warn('payment.recipient_pubkey.inject_failed'); - return historyEntry; - } -} - -function sendCtx(ctx: ScreenActionContext): Ctx { - return ctx as Ctx; -} - -function mintQuoteCtx(ctx: ScreenActionContext): Ctx { - return ctx as Ctx; -} - -/** - * App-specific screen action overrides. Only actions that require platform - * primitives not available in colada (NFC writer, emoji picker). - * All other actions are handled by the built-in default handlers. - */ -export function createSovranScreenActionHandlers(): ScreenActionHandlerMap { - return { - sendToken: { - nfc: async (rawCtx) => { - paymentLog.info('payment.screen_action.nfc.start'); - const { entry, manager } = sendCtx(rawCtx); - if (!entry.token) { - paymentLog.warn('payment.screen_action.nfc.no_token'); - return; - } - - try { - await writeTokenToNFC(getEncodedToken(entry.token)); - paymentLog.info('payment.screen_action.nfc.success'); - nfcEcashSharedPopup(); - return; - } catch (rawError) { - // User dismissed the system NFC sheet — no popup, no rollback; - // the send op was never committed to the wire. - if (isUserCancelError(rawError)) { - paymentLog.info('payment.screen_action.nfc.user_cancel'); - return; - } - const code = rawError instanceof NfcError ? rawError.code : 'WRITE_FAILED'; - const message = - rawError instanceof Error ? rawError.message : 'Unable to write token via NFC.'; - const lostConnection = code === 'TAG_LOST' || code === 'TRANSCEIVE_FAILED'; - - if (lostConnection && entry.operationId) { - paymentLog.warn('payment.screen_action.nfc.connection_lost', { - operationId: entry.operationId, - }); - try { - await manager.ops.send.reclaim(entry.operationId); - nfcConnectionLostPopup(); - return; - } catch (rollbackError) { - paymentLog.error('payment.screen_action.nfc.rollback_failed', { - error: - rollbackError instanceof Error ? rollbackError.message : String(rollbackError), - }); - nfcSendFailedPopup({ - rollbackFailed: true, - text: - rollbackError instanceof Error ? rollbackError.message : String(rollbackError), - }); - return; - } - } - - nfcSendFailedPopup({ text: message }); - } - }, - - /** - * Route text vs emoji copy on the sendToken screen. Split-menu UI - * (`ActionMenuButton`) calls `actions.copy.execute({ variantId })` with - * `'text'` or `'emoji'`. An omitted `variantId` uses the text path. - */ - copy: async (rawCtx) => { - const { entry } = sendCtx(rawCtx); - if (!entry.token) return; - // `ScreenActionContext` carries action params via the `[key: string]: unknown` - // index signature, so `rawCtx.variantId` is already typed as `unknown` — - // narrow it directly without a cast. - const variantId = typeof rawCtx.variantId === 'string' ? rawCtx.variantId : 'text'; - if (variantId === 'emoji') { - emojiPickerPopup({ token: getEncodedToken(entry.token) }); - return; - } - // Default — text clipboard copy. - try { - await Clipboard.setStringAsync(getEncodedToken(entry.token)); - copyPopup('token'); - paymentLog.info('payment.send_token.copy.text.success', { entryId: entry.id }); - } catch (e) { - paymentLog.error('payment.send_token.copy.failed', { - error: e instanceof Error ? e.message : String(e), - }); - } - }, - }, - - // ── mintQuote (Lightning receive) ──────────────────────────────── - // - // We override copy/share so we can record the *outbound distribution* - // method for the resulting transaction. The other wallet's payment - // method is unknowable, but we can capture which channel WE used to - // share the lightning invoice. The 'displayed' fallback is written by - // a global subscription in Colada.tsx when the quote transitions - // to PAID/ISSUED without any explicit copy/share action. - // - // The distribution store is keyed by `quoteId` (NOT historyEntry.id) - // for mint entries. quoteId is the deterministic identifier carried - // by the lightning quote itself — it's identical whether resolved from - // the screen entry, from a coco event payload, or from the persisted - // history row. Using historyEntry.id would risk a key mismatch if the - // screen entry's id (e.g. mintOp.id from a fallback) differs from - // coco's persisted row id, which is exactly the bug that caused the - // first-write-wins guard to silently fail in earlier versions. - // - // Both overrides reproduce the built-in handler's user-facing behavior - // (clipboard write / share sheet + popup) so the UX is unchanged. - mintQuote: { - copy: async (rawCtx) => { - const { entry } = mintQuoteCtx(rawCtx); - const paymentValue = getMintQuotePaymentValue(entry); - const quoteId = entry.quoteId; - if (!paymentValue || !quoteId) { - paymentLog.warn('payment.mint_quote.copy.no_payment_request', { - hasPaymentRequest: !!paymentValue, - hasQuoteId: !!quoteId, - }); - return; - } - try { - await Clipboard.setStringAsync(paymentValue); - useTransactionDistributionStore.getState().setDistribution(quoteId, 'copy'); - setDistributionAnnotation(`quote:${quoteId}`, 'copy'); - paymentLog.info('payment.mint_quote.copy.success', { - quoteId, - entryId: entry.id, - }); - copyPopup(getOnchainMintAddress(entry) ? 'address' : 'lightningInvoice'); - } catch (e) { - paymentLog.error('payment.mint_quote.copy.failed', { - error: e instanceof Error ? e.message : String(e), - }); - } - }, - - share: async (rawCtx) => { - const { entry } = mintQuoteCtx(rawCtx); - const paymentValue = getMintQuotePaymentValue(entry); - const quoteId = entry.quoteId; - if (!paymentValue || !quoteId) { - paymentLog.warn('payment.mint_quote.share.no_payment_request', { - hasPaymentRequest: !!paymentValue, - hasQuoteId: !!quoteId, - }); - return; - } - try { - // Read the share result so we can detect AirDrop on iOS. The - // provider share adapter intentionally returns void, so this - // action override owns the result inspection. - const result = await Share.share({ message: paymentValue }); - if (result.action !== Share.sharedAction) { - paymentLog.debug('payment.mint_quote.share.dismissed', { quoteId }); - return; - } - // iOS sets activityType to a UTI string identifying the chosen - // activity (e.g. 'com.apple.UIKit.activity.AirDrop'). Android - // always returns undefined, in which case we fall through to - // the generic 'share' source. - const isAirDrop = result.activityType === 'com.apple.UIKit.activity.AirDrop'; - const source = isAirDrop ? 'airdrop' : 'share'; - useTransactionDistributionStore.getState().setDistribution(quoteId, source); - setDistributionAnnotation(`quote:${quoteId}`, source); - paymentLog.info('payment.mint_quote.share.success', { - quoteId, - entryId: entry.id, - activityType: result.activityType ?? null, - source, - }); - } catch (e) { - paymentLog.error('payment.mint_quote.share.failed', { - error: e instanceof Error ? e.message : String(e), - }); - } - }, - }, - }; -} diff --git a/features/send/lib/sovranPaymentOperations.ts b/features/send/lib/sovranPaymentOperations.ts new file mode 100644 index 000000000..82dadc5cd --- /dev/null +++ b/features/send/lib/sovranPaymentOperations.ts @@ -0,0 +1,498 @@ +/** + * @fileoverview Sovran-side colada operation overrides — receive + mint quote. + * + * The two funds-execution operations where Sovran replaces colada's defaults to + * fix history-id races and offline-receive recovery: + * + * - createSovranExecuteReceive: drives Coco receive ops directly so a + * recoverable offline receive returns a `pending` result (not a terminal + * failure), and an immediate finalize is keyed to Coco's real persisted id + * rather than a synthesized one. + * - createSovranExecuteMintQuote: snapshots mint ids, then resolves Coco's + * persisted row by quoteId (deterministic) with a set-difference fallback, so + * the downstream location stamp / scan-history link key to the id + * `usePaginatedHistory` later returns. + * + * Both are funds-critical; the dual snapshot→poll→fallback shape is the + * race-window guard and must be preserved. + */ +import { getTokenMetadata } from '@cashu/cashu-ts'; +import type { + HistoryEntry, + Manager, + MintHistoryEntry, + ReceiveHistoryEntry, +} from '@cashu/coco-core'; +import { + classifyMeshRedeemError, + withTimeout, + type MachineOperations, +} from '@sovranbitcoin/colada'; + +import { amountToNumber, type AmountValue } from '@/shared/lib/cashu/amount'; +import { prepareBolt11MintQuote } from '@/shared/lib/cashu/cocoOperations'; +import { mintLocalId } from '@/shared/lib/id'; +import { paymentLog } from '@/shared/lib/logger'; +import { RECEIVE_PENDING_TOAST_COPY } from '@/shared/lib/popup/paymentStatusCopy'; + +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; + +type ReceiveOperationLike = { + id: string; + mintUrl: string; + unit?: string; + amount?: AmountValue; + state: string; + createdAt?: number; + updatedAt?: number; +}; + +function isRecoverableReceiveError(err: unknown): boolean { + const kind = classifyMeshRedeemError(err); + return kind === 'network' || kind === 'retryable'; +} + +function receiveHistoryId(operationId: string): string { + return `receive:${operationId}`; +} + +async function findReceiveHistoryEntryForOperation( + manager: Manager, + operationId: string, + mintUrl: string, + beforeIds: Set +): Promise { + const direct = await manager.history.getHistoryEntryById(receiveHistoryId(operationId)); + if (direct?.type === 'receive') return direct as ReceiveHistoryEntry; + + const after = await manager.history.getPaginatedHistory(0, 100); + return ( + after.find((h): h is ReceiveHistoryEntry => { + if (h.type !== 'receive' || h.mintUrl !== mintUrl) return false; + if (h.operationId === operationId) return true; + return !beforeIds.has(h.id); + }) ?? null + ); +} + +async function waitForReceiveHistoryEntry( + manager: Manager, + operationId: string, + mintUrl: string, + beforeIds: Set +): Promise { + const MAX_ATTEMPTS = 50; // ~10s of polling at 200ms intervals + const DELAY_MS = 200; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + try { + const entry = await findReceiveHistoryEntryForOperation( + manager, + operationId, + mintUrl, + beforeIds + ); + if (entry) { + paymentLog.info('payment.execute_receive.found', { + ...mintUrlLogFields(mintUrl), + realId: entry.id, + operationId, + attempts: attempt + 1, + }); + return entry; + } + } catch (e) { + paymentLog.warn('payment.execute_receive.poll_failed', { + attempt, + operationId, + error: e instanceof Error ? e.message : String(e), + }); + } + await new Promise((r) => setTimeout(r, DELAY_MS)); + } + return null; +} + +function buildPendingReceiveEntry( + operation: ReceiveOperationLike, + tokenString: string, + fallbackMintUrl: string, + fallbackAmount: number +) { + const now = Date.now(); + const mintUrl = operation.mintUrl || fallbackMintUrl; + const amount = amountToNumber(operation.amount ?? fallbackAmount); + const unit = operation.unit ?? 'sat'; + + return { + id: `receive-${operation.id}`, + type: 'receive' as const, + createdAt: operation.createdAt ?? now, + updatedAt: operation.updatedAt ?? now, + mintUrl, + unit, + amount, + state: 'executing', + operationId: operation.id, + metadata: { + rawToken: tokenString, + operationId: operation.id, + pendingReason: 'network', + }, + }; +} + +function buildFallbackFinalizedReceiveEntry( + tokenString: string, + mintUrl: string, + amount: number, + operationId?: string +) { + let tokenAmount = amount; + let tokenUnit = 'sat'; + try { + const metadata = getTokenMetadata(tokenString); + tokenAmount = amountToNumber(metadata.amount); + tokenUnit = metadata.unit ?? 'sat'; + } catch (error) { + paymentLog.warn('payment.execute_receive.fallback_decode_failed', { + ...mintUrlLogFields(mintUrl), + operationId: operationId ?? null, + tokenLength: tokenString.length, + error: error instanceof Error ? error.message : String(error), + }); + } + const now = Date.now(); + return { + id: mintLocalId('redeemed'), + type: 'receive' as const, + source: 'legacy' as const, + legacyHistoryId: mintLocalId('redeemed'), + createdAt: now, + updatedAt: now, + mintUrl, + unit: tokenUnit, + amount: tokenAmount, + state: 'finalized', + ...(operationId ? { operationId } : {}), + metadata: { rawToken: tokenString, ...(operationId ? { operationId } : {}) }, + }; +} + +/** + * Sovran-side override for colada's default `executeReceive`. + * + * colada's default `executeReceive` calls `mgr.wallet.receive(token)`, which + * hides Coco's operation lifecycle behind a single promise. That makes a + * recoverable offline receive look like a terminal receive failure to the UI. + * + * We still need the older real-id safeguard too: if the receive finalizes + * immediately, never let a synthesized id flow through setEntry, + * linkTransaction, onReceiveConfirmed, or onTransactionCreated. + * + * The fix is to use Coco receive ops directly. If the operation reaches + * `executing` and the mint/network is unreachable, we return Colada's pending + * result so the screen can wait for recovery. If it finalizes immediately, we + * still poll Coco history and use its real persisted id. + */ +export function createSovranExecuteReceive( + getManager: () => Manager | null +): NonNullable { + return async (tokenString, mintUrl, _amount) => { + const manager = getManager(); + if (!manager) { + paymentLog.error('payment.execute_receive.no_manager'); + throw new Error('Wallet manager is not available'); + } + + // Snapshot existing receive ids for this mint so we can identify the + // newly-persisted entry by set difference after the receive completes. + let beforeIds: Set; + try { + const beforeHistory = await manager.history.getPaginatedHistory(0, 100); + beforeIds = new Set( + (beforeHistory as readonly Record[]) + .filter((h) => h.type === 'receive' && h.mintUrl === mintUrl) + .map((h) => (typeof h.id === 'string' ? h.id : '')) + .filter((id) => id.length > 0) + ); + } catch (e) { + paymentLog.warn('payment.execute_receive.snapshot_failed', { + ...mintUrlLogFields(mintUrl), + error: e instanceof Error ? e.message : String(e), + }); + beforeIds = new Set(); + } + + // P2PK detection — preserved from coco's default so the downstream + // onP2PKReceiveCompleted notification still fires for users with + // `regenerateP2PKOnReceive` enabled. + let hadP2PKProofs = false; + try { + const metadata = getTokenMetadata(tokenString); + hadP2PKProofs = metadata.incompleteProofs.some((p) => { + try { + const parsed = JSON.parse(p.secret); + return Array.isArray(parsed) && parsed[0] === 'P2PK'; + } catch { + return false; + } + }); + } catch (error) { + paymentLog.warn('payment.execute_receive.p2pk_detection_decode_failed', { + ...mintUrlLogFields(mintUrl), + tokenLength: tokenString.length, + error: error instanceof Error ? error.message : String(error), + }); + // proof decode failure — fall back to false (no regression) + } + + paymentLog.info('payment.execute_receive.start', { + ...mintUrlLogFields(mintUrl), + beforeCount: beforeIds.size, + tokenLength: tokenString.length, + hadP2PKProofs, + expectedNext: 'prepare_receive_operation', + }); + + const prepared = await manager.ops.receive.prepare({ token: tokenString }); + paymentLog.info('payment.execute_receive.prepared', { + ...mintUrlLogFields(mintUrl), + operationId: prepared.id, + expectedNext: 'execute_or_mark_pending', + }); + + try { + const finalized = await manager.ops.receive.execute(prepared); + paymentLog.info('payment.execute_receive.executed', { + ...mintUrlLogFields(finalized.mintUrl), + operationId: finalized.id, + state: finalized.state, + expectedNext: 'history_entry_link', + }); + const entry = await waitForReceiveHistoryEntry( + manager, + finalized.id, + finalized.mintUrl, + beforeIds + ); + if (entry) { + paymentLog.info('payment.execute_receive.finalized', { + ...mintUrlLogFields(entry.mintUrl), + operationId: finalized.id, + receiveEntryId: entry.id, + hadP2PKProofs, + expectedNext: 'colada_receive_success', + }); + return { + status: 'finalized', + historyEntry: JSON.stringify(entry), + hadP2PKProofs, + }; + } + + paymentLog.error('payment.execute_receive.timeout_fallback', { + ...mintUrlLogFields(mintUrl), + operationId: finalized.id, + polledMs: 10_000, + }); + const fallbackEntry = buildFallbackFinalizedReceiveEntry( + tokenString, + finalized.mintUrl, + amountToNumber(finalized.amount), + finalized.id + ); + return { + status: 'finalized', + historyEntry: JSON.stringify(fallbackEntry), + hadP2PKProofs, + }; + } catch (err) { + let latest: ReceiveOperationLike | null = null; + try { + latest = (await manager.ops.receive.get(prepared.id)) as ReceiveOperationLike | null; + } catch (getErr) { + paymentLog.warn('payment.execute_receive.get_after_error_failed', { + operationId: prepared.id, + error: getErr instanceof Error ? getErr.message : String(getErr), + }); + } + + if (latest?.state === 'finalized') { + const entry = await waitForReceiveHistoryEntry( + manager, + latest.id, + latest.mintUrl, + beforeIds + ); + if (entry) { + paymentLog.info('payment.execute_receive.finalized_after_error', { + ...mintUrlLogFields(entry.mintUrl), + operationId: latest.id, + receiveEntryId: entry.id, + hadP2PKProofs, + expectedNext: 'colada_receive_success', + }); + return { + status: 'finalized', + historyEntry: JSON.stringify(entry), + hadP2PKProofs, + }; + } + } + + if (latest?.state === 'executing' && isRecoverableReceiveError(err)) { + paymentLog.info('payment.execute_receive.pending_recovery', { + ...mintUrlLogFields(latest.mintUrl), + operationId: latest.id, + hadP2PKProofs, + pendingReason: 'network', + expectedNext: 'coco_recovery_then_success_toast', + error: err instanceof Error ? err.message : String(err), + }); + const pendingEntry = buildPendingReceiveEntry(latest, tokenString, mintUrl, _amount); + return { + status: 'pending', + operationId: latest.id, + historyEntry: JSON.stringify(pendingEntry), + pendingReason: 'network', + message: RECEIVE_PENDING_TOAST_COPY.subtitle, + hadP2PKProofs, + }; + } + + paymentLog.error('payment.execute_receive.failed_terminal', { + ...mintUrlLogFields(mintUrl), + operationId: prepared.id, + latestState: latest?.state ?? null, + hadP2PKProofs, + error: err instanceof Error ? err.message : String(err), + expectedNext: 'colada_receive_failed', + }); + throw err; + } + }; +} + +const MINT_QUOTE_PREPARE_TIMEOUT_MS = 10_000; + +/** + * Sovran-side override for colada's default `executeMintQuote`. + * + * colada's default (defaultOperations.ts:275) calls `mgr.ops.mint.prepare(...)` + * and constructs the history entry directly from the returned operation, using + * `mintOp.id` as the entry id. The unspoken risk is that coco's persisted row + * may end up with a different id (or shape) than `mintOp.id`; when that happens + * the location stamp captured at onTransactionCreated (under `mintOp.id`) and + * the scan-history link end up keyed to an id that doesn't match what + * `usePaginatedHistory` returns later — same class of bug as the receive case. + * + * Fix: snapshot mint ids for this mintUrl before prepare, then poll coco + * history for the persisted row by `quoteId` (deterministic — no race) and fall + * back to set-difference. Use coco's persisted row as authoritative for the id, + * while preserving `paymentRequest` from the operation result so the + * LightningReceiveScreen still has a lightning invoice to display. + */ +export function createSovranExecuteMintQuote( + getManager: () => Manager | null +): NonNullable { + return async (mintUrl, amount, _unit, method = 'bolt11') => { + const manager = getManager(); + if (!manager) { + paymentLog.error('payment.execute_mint_quote.no_manager'); + throw new Error('Wallet manager is not available'); + } + + if (method === 'onchain') { + throw new Error('Onchain mint quotes are not supported by @cashu/coco-core 1.0.1'); + } + + // Snapshot existing mint ids for this mint URL so we can detect the + // newly-persisted row by set difference if quoteId matching fails. + let beforeIds: Set; + try { + const beforeHistory: HistoryEntry[] = await manager.history.getPaginatedHistory(0, 100); + beforeIds = new Set( + beforeHistory.filter((h) => h.type === 'mint' && h.mintUrl === mintUrl).map((h) => h.id) + ); + } catch (e) { + paymentLog.warn('payment.execute_mint_quote.snapshot_failed', { + error: e instanceof Error ? e.message : String(e), + }); + beforeIds = new Set(); + } + + paymentLog.info('payment.execute_mint_quote.start', { + ...mintUrlLogFields(mintUrl), + amount, + }); + const mintOp = await withTimeout( + prepareBolt11MintQuote(manager, mintUrl, amount, _unit), + MINT_QUOTE_PREPARE_TIMEOUT_MS, + 'executeMintQuote.prepare' + ); + paymentLog.info('payment.execute_mint_quote.prepared', { + operationId: mintOp.id, + quoteId: mintOp.quoteId, + }); + + // Constructed entry — used as a fallback (same shape as coco's default + // executeMintQuote) when polling can't find coco's persisted row in time. + const constructedEntry: MintHistoryEntry = { + id: mintOp.id, + type: 'mint', + operationId: mintOp.id, + createdAt: mintOp.createdAt, + mintUrl: mintOp.mintUrl, + unit: mintOp.unit, + quoteId: mintOp.quoteId, + state: mintOp.lastObservedRemoteState ?? 'UNPAID', + amount: mintOp.amount, + paymentRequest: mintOp.request, + metadata: { operationId: mintOp.id }, + }; + + // Poll for coco's persisted row. Prefer quoteId match (deterministic); + // fall back to id-diff against the pre-prepare snapshot. + const MAX_ATTEMPTS = 50; // ~10s of polling at 200ms intervals + const DELAY_MS = 200; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + try { + const after: HistoryEntry[] = await manager.history.getPaginatedHistory(0, 100); + const persisted = after.find((h): h is MintHistoryEntry => { + if (h.type !== 'mint' || h.mintUrl !== mintUrl) return false; + // Preferred: deterministic quoteId match + if (mintOp.quoteId && h.quoteId === mintOp.quoteId) return true; + // Fallback: set difference on ids + return !beforeIds.has(h.id); + }); + if (persisted) { + paymentLog.info('payment.execute_mint_quote.found', { + ...mintUrlLogFields(mintUrl), + persistedId: persisted.id, + constructedId: mintOp.id, + matchedById: persisted.id === mintOp.id, + attempts: attempt + 1, + }); + // Coco's persisted row is authoritative — its `id` is what flows + // downstream to onTransactionCreated and the scan-history link. + return { historyEntry: JSON.stringify(persisted) }; + } + } catch (e) { + paymentLog.warn('payment.execute_mint_quote.poll_failed', { + attempt, + error: e instanceof Error ? e.message : String(e), + }); + } + await new Promise((r) => setTimeout(r, DELAY_MS)); + } + + // Last resort: same fallback as coco's default. Logged loudly so we know + // the polling didn't catch the persisted row. + paymentLog.error('payment.execute_mint_quote.timeout_fallback', { + ...mintUrlLogFields(mintUrl), + operationId: mintOp.id, + polledMs: MAX_ATTEMPTS * DELAY_MS, + }); + return { historyEntry: JSON.stringify(constructedEntry) }; + }; +} diff --git a/features/send/lib/sovranScanSources.ts b/features/send/lib/sovranScanSources.ts new file mode 100644 index 000000000..aec2f8418 --- /dev/null +++ b/features/send/lib/sovranScanSources.ts @@ -0,0 +1,135 @@ +/** + * @fileoverview Sovran scan-input sources for the colada payment machine. + * + * The clipboard / photo-gallery / NFC sources the scanner pulls a payment + * payload from. Each returns a discriminated result ({ data } | { empty } | + * { canceled } | { error }); NFC distinguishes ambient listening cycles (quiet + * re-arm) from explicit taps (user-facing popups). + */ +import { scanFromURLAsync } from 'expo-camera'; +import * as Clipboard from 'expo-clipboard'; +import * as ImagePicker from 'expo-image-picker'; + +import type { NfcIOAdapter, ScanSources } from '@sovranbitcoin/colada'; + +import { paymentLog } from '@/shared/lib/logger'; +import { isAmbientNfcCycle, isUserCancelError, NfcError } from '@/shared/lib/nfc'; +import { paramPopup } from '@/shared/lib/popup'; +import { decode, isEncoded } from '@/shared/lib/third-party/emoji'; +import { useNfcTapStore } from '@/shared/stores/runtime/nfcTapStore'; +import { usePopupStore } from '@/shared/stores/runtime/popupStore'; + +export function createSovranScanSources(nfcAdapter?: NfcIOAdapter): ScanSources { + paymentLog.debug('payment.scan_sources.created', { hasNfc: !!nfcAdapter }); + return { + clipboard: async () => { + paymentLog.debug('payment.scan.clipboard.start'); + const rawText = (await Clipboard.getStringAsync()).trim(); + const decodedText = isEncoded(rawText) ? decode(rawText) : rawText; + if (!decodedText) { + paymentLog.debug('payment.scan.clipboard.empty'); + return { empty: true }; + } + paymentLog.info('payment.scan.clipboard.found', { chars: decodedText.length }); + return { data: decodedText }; + }, + gallery: async () => { + paymentLog.debug('payment.scan.gallery.start'); + try { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: false, + quality: 1, + }); + + if (result.canceled || !result.assets?.[0]?.uri) { + paymentLog.debug('payment.scan.gallery.canceled'); + return { canceled: true }; + } + + const scannedCodes = await scanFromURLAsync(result.assets[0].uri, ['qr']); + + if (scannedCodes.length === 0) { + paymentLog.debug('payment.scan.gallery.no_qr'); + return { empty: true }; + } + + paymentLog.info('payment.scan.gallery.found', { dataLen: scannedCodes[0].data.length }); + return { data: scannedCodes[0].data }; + } catch (err) { + paymentLog.error('payment.scan.gallery.failed', { + error: err instanceof Error ? err : new Error(String(err)), + }); + return { error: err instanceof Error ? err : new Error(String(err)) }; + } + }, + nfc: nfcAdapter + ? async () => { + // Ambient cycles (wallet-screen listening loop) re-arm every ~30s, + // so their per-cycle failures must stay quiet; explicit presses + // keep the popups. + const ambient = isAmbientNfcCycle(); + const closeTapSheet = () => { + const popup = usePopupStore.getState(); + if ( + popup.current && + 'sheetId' in popup.current && + popup.current.sheetId === 'nfc-tap' + ) { + popup.close(); + } + }; + try { + const data = await nfcAdapter.readPaymentRequest(); + // The flow navigates to the payment screen now — don't leave the + // tap sheet floating over it. + closeTapSheet(); + return { data }; + } catch (err) { + // User dismissed the system NFC sheet — treat as a no-op, + // not an error (suppresses the `general-error` popup). + if (isUserCancelError(err)) return { empty: true }; + // Preflight failures from acquireSession get their own popups — + // before this, an Android tap with NFC off hung forever silently. + if (err instanceof NfcError && err.code === 'NOT_ENABLED') { + if (!ambient) { + paramPopup('nfc-error', { + title: 'NFC is turned off', + message: 'Turn on NFC in system settings to scan.', + }); + } + return { empty: true }; + } + if (err instanceof NfcError && err.code === 'NOT_SUPPORTED') { + if (!ambient) { + paramPopup('nfc-error', { + title: 'NFC not supported', + message: 'This device has no NFC hardware.', + }); + } + return { empty: true }; + } + if (err instanceof NfcError && err.code === 'TIMEOUT') { + // Nothing was tapped within the window — quiet no-op. + return { empty: true }; + } + if (ambient) { + // A garbled ambient read (non-payment tag, partial APDU) must + // not surface the general-error popup; the loop just re-arms. + paymentLog.debug('nfc.ambient.read_failed', { + error: err instanceof Error ? err.message : String(err), + }); + return { empty: true }; + } + return { error: err instanceof Error ? err : new Error(String(err)) }; + } finally { + // Timeouts deliberately leave the sheet up: the ambient loop + // re-arms immediately and the sheet should read as continuous + // listening, not blink every 30s. Error popups replace the sheet + // through the popup store; the success path closed it above. + useNfcTapStore.getState().setPhase('armed'); + } + } + : undefined, + }; +} diff --git a/features/send/lib/sovranScreenActions.ts b/features/send/lib/sovranScreenActions.ts new file mode 100644 index 000000000..a73ec029f --- /dev/null +++ b/features/send/lib/sovranScreenActions.ts @@ -0,0 +1,216 @@ +/** + * @fileoverview Sovran post-terminal screen-action handlers for colada. + * + * Actions offered after a payment reaches a terminal state — NFC share, emoji + * token picker, copy, memo, recipient-pubkey injection. Each receives a colada + * ScreenActionContext narrowed to a manager-bearing Ctx. + */ +import { Share } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { paymentLog } from '@/shared/lib/logger'; + +import { getEncodedToken } from '@cashu/cashu-ts'; +import type { Manager, SendHistoryEntry, MintHistoryEntry } from '@cashu/coco-core'; +import { type ScreenActionContext, type ScreenActionHandlerMap } from '@sovranbitcoin/colada'; + +import { getMintQuotePaymentValue, getOnchainMintAddress } from '@/shared/lib/cashu/onchainMint'; +import { writeTokenToNFC, NfcError, isUserCancelError } from '@/shared/lib/nfc'; +import { + copyPopup, + emojiPickerPopup, + nfcConnectionLostPopup, + nfcEcashSharedPopup, + nfcSendFailedPopup, +} from '@/shared/lib/popup'; +import { setDistributionAnnotation } from '@/shared/stores/profile/transactionAnnotationStore'; +import { useTransactionDistributionStore } from '@/shared/stores/profile/transactionDistributionStore'; + +type Ctx = ScreenActionContext & { manager: Manager }; + +function sendCtx(ctx: ScreenActionContext): Ctx { + return ctx as Ctx; +} + +function mintQuoteCtx(ctx: ScreenActionContext): Ctx { + return ctx as Ctx; +} + +/** + * App-specific screen action overrides. Only actions that require platform + * primitives not available in colada (NFC writer, emoji picker). + * All other actions are handled by the built-in default handlers. + */ +export function createSovranScreenActionHandlers(): ScreenActionHandlerMap { + return { + sendToken: { + nfc: async (rawCtx) => { + paymentLog.info('payment.screen_action.nfc.start'); + const { entry, manager } = sendCtx(rawCtx); + if (!entry.token) { + paymentLog.warn('payment.screen_action.nfc.no_token'); + return; + } + + try { + await writeTokenToNFC(getEncodedToken(entry.token)); + paymentLog.info('payment.screen_action.nfc.success'); + nfcEcashSharedPopup(); + return; + } catch (rawError) { + // User dismissed the system NFC sheet — no popup, no rollback; + // the send op was never committed to the wire. + if (isUserCancelError(rawError)) { + paymentLog.info('payment.screen_action.nfc.user_cancel'); + return; + } + const code = rawError instanceof NfcError ? rawError.code : 'WRITE_FAILED'; + const message = + rawError instanceof Error ? rawError.message : 'Unable to write token via NFC.'; + const lostConnection = code === 'TAG_LOST' || code === 'TRANSCEIVE_FAILED'; + + if (lostConnection && entry.operationId) { + paymentLog.warn('payment.screen_action.nfc.connection_lost', { + operationId: entry.operationId, + }); + try { + await manager.ops.send.reclaim(entry.operationId); + nfcConnectionLostPopup(); + return; + } catch (rollbackError) { + paymentLog.error('payment.screen_action.nfc.rollback_failed', { + error: + rollbackError instanceof Error ? rollbackError.message : String(rollbackError), + }); + nfcSendFailedPopup({ + rollbackFailed: true, + text: + rollbackError instanceof Error ? rollbackError.message : String(rollbackError), + }); + return; + } + } + + nfcSendFailedPopup({ text: message }); + } + }, + + /** + * Route text vs emoji copy on the sendToken screen. Split-menu UI + * (`ActionMenuButton`) calls `actions.copy.execute({ variantId })` with + * `'text'` or `'emoji'`. An omitted `variantId` uses the text path. + */ + copy: async (rawCtx) => { + const { entry } = sendCtx(rawCtx); + if (!entry.token) return; + // `ScreenActionContext` carries action params via the `[key: string]: unknown` + // index signature, so `rawCtx.variantId` is already typed as `unknown` — + // narrow it directly without a cast. + const variantId = typeof rawCtx.variantId === 'string' ? rawCtx.variantId : 'text'; + if (variantId === 'emoji') { + emojiPickerPopup({ token: getEncodedToken(entry.token) }); + return; + } + // Default — text clipboard copy. + try { + await Clipboard.setStringAsync(getEncodedToken(entry.token)); + copyPopup('token'); + paymentLog.info('payment.send_token.copy.text.success', { entryId: entry.id }); + } catch (e) { + paymentLog.error('payment.send_token.copy.failed', { + error: e instanceof Error ? e.message : String(e), + }); + } + }, + }, + + // ── mintQuote (Lightning receive) ──────────────────────────────── + // + // We override copy/share so we can record the *outbound distribution* + // method for the resulting transaction. The other wallet's payment + // method is unknowable, but we can capture which channel WE used to + // share the lightning invoice. The 'displayed' fallback is written by + // a global subscription in Colada.tsx when the quote transitions + // to PAID/ISSUED without any explicit copy/share action. + // + // The distribution store is keyed by `quoteId` (NOT historyEntry.id) + // for mint entries. quoteId is the deterministic identifier carried + // by the lightning quote itself — it's identical whether resolved from + // the screen entry, from a coco event payload, or from the persisted + // history row. Using historyEntry.id would risk a key mismatch if the + // screen entry's id (e.g. mintOp.id from a fallback) differs from + // coco's persisted row id, which is exactly the bug that caused the + // first-write-wins guard to silently fail in earlier versions. + // + // Both overrides reproduce the built-in handler's user-facing behavior + // (clipboard write / share sheet + popup) so the UX is unchanged. + mintQuote: { + copy: async (rawCtx) => { + const { entry } = mintQuoteCtx(rawCtx); + const paymentValue = getMintQuotePaymentValue(entry); + const quoteId = entry.quoteId; + if (!paymentValue || !quoteId) { + paymentLog.warn('payment.mint_quote.copy.no_payment_request', { + hasPaymentRequest: !!paymentValue, + hasQuoteId: !!quoteId, + }); + return; + } + try { + await Clipboard.setStringAsync(paymentValue); + useTransactionDistributionStore.getState().setDistribution(quoteId, 'copy'); + setDistributionAnnotation(`quote:${quoteId}`, 'copy'); + paymentLog.info('payment.mint_quote.copy.success', { + quoteId, + entryId: entry.id, + }); + copyPopup(getOnchainMintAddress(entry) ? 'address' : 'lightningInvoice'); + } catch (e) { + paymentLog.error('payment.mint_quote.copy.failed', { + error: e instanceof Error ? e.message : String(e), + }); + } + }, + + share: async (rawCtx) => { + const { entry } = mintQuoteCtx(rawCtx); + const paymentValue = getMintQuotePaymentValue(entry); + const quoteId = entry.quoteId; + if (!paymentValue || !quoteId) { + paymentLog.warn('payment.mint_quote.share.no_payment_request', { + hasPaymentRequest: !!paymentValue, + hasQuoteId: !!quoteId, + }); + return; + } + try { + // Read the share result so we can detect AirDrop on iOS. The + // provider share adapter intentionally returns void, so this + // action override owns the result inspection. + const result = await Share.share({ message: paymentValue }); + if (result.action !== Share.sharedAction) { + paymentLog.debug('payment.mint_quote.share.dismissed', { quoteId }); + return; + } + // iOS sets activityType to a UTI string identifying the chosen + // activity (e.g. 'com.apple.UIKit.activity.AirDrop'). Android + // always returns undefined, in which case we fall through to + // the generic 'share' source. + const isAirDrop = result.activityType === 'com.apple.UIKit.activity.AirDrop'; + const source = isAirDrop ? 'airdrop' : 'share'; + useTransactionDistributionStore.getState().setDistribution(quoteId, source); + setDistributionAnnotation(`quote:${quoteId}`, source); + paymentLog.info('payment.mint_quote.share.success', { + quoteId, + entryId: entry.id, + activityType: result.activityType ?? null, + source, + }); + } catch (e) { + paymentLog.error('payment.mint_quote.share.failed', { + error: e instanceof Error ? e.message : String(e), + }); + } + }, + }, + }; +} diff --git a/features/send/providers/Colada.tsx b/features/send/providers/Colada.tsx index 140662a04..40cc5bbc3 100644 --- a/features/send/providers/Colada.tsx +++ b/features/send/providers/Colada.tsx @@ -38,14 +38,15 @@ import { resolveIdentityName } from '@/shared/lib/identity'; import { paymentLog } from '@/shared/lib/logger'; import { sendDirectMessageToRelays } from '@/shared/lib/nostr/sendDirectMessage'; import { useNostrMetadataCache } from '@/shared/stores/global/nostrMetadataCache'; +import { createSovranHandlers } from '@/features/send/lib/sovranHandlers'; +import { createSovranScreenActionHandlers } from '@/features/send/lib/sovranScreenActions'; +import { createSovranNotifications } from '@/features/send/lib/sovranNotifications'; +import { createSovranScanSources } from '@/features/send/lib/sovranScanSources'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; import { createSovranExecuteMintQuote, createSovranExecuteReceive, - createSovranHandlers, - createSovranNotifications, - createSovranScanSources, - createSovranScreenActionHandlers, -} from '@/features/send/lib/sovranPaymentConfig'; +} from '@/features/send/lib/sovranPaymentOperations'; import { deriveBitchatBLEIdentityMaterial } from '@/features/bitchat/lib/bleIdentity'; import { createSovranScreenActionsBridge, @@ -73,13 +74,6 @@ const FIAT_SYMBOLS: Record = { usd: '$', eur: '€', gbp: '£' } // 10s `updateMint` timeout so one dead mint can't visibly gate the list. const FIRST_OPEN_DEADLINE_MS = 3000; -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - export function SovranColadaProvider({ children }: { children: React.ReactNode }) { const manager = useManager(); const { keys } = useNostrKeysContext(); @@ -108,8 +102,8 @@ export function SovranColadaProvider({ children }: { children: React.ReactNode } const privateKeyRef = useLatestRef(keys?.privateKey); const [nfcAdapter] = useState(() => createNfcAdapter()); - const chainAdapter = useMemo(() => createMempoolSpaceChainAdapter(), []); - const scanSources = useMemo(() => createSovranScanSources(nfcAdapter), [nfcAdapter]); + const chainAdapter = createMempoolSpaceChainAdapter(); + const scanSources = createSovranScanSources(nfcAdapter); const clipboardAdapter = useMemo>( () => ({ writeText: (text) => Clipboard.setStringAsync(text).then(() => {}), @@ -288,86 +282,88 @@ export function SovranColadaProvider({ children }: { children: React.ReactNode } // so reopening the transaction from the list resolves them correctly. // See sovranPaymentConfig.createSovranExecuteReceive / createSovranExecuteMintQuote // for the full rationale. Spreading instance.operations preserves all other defaults. - const operationsOverride = useMemo( - () => - ({ - ...instance.operations, - executeReceive: createSovranExecuteReceive(() => manager), - executeMintQuote: createSovranExecuteMintQuote(() => manager), - // Stage 2 of recipient resolution: hex pubkey → Nostr kind-0 profile. - // Stage 1 (NIP-05 → pubkey) is shipped by colada's default - // operation set; this one has no default because NDK / cache wiring - // is app-specific. Returning null on any failure is the contract: - // the machine's resolver treats it as best-effort cosmetic data and - // does not block the flow. - resolveRecipientProfile: async (pubkey, signal): Promise => { - paymentLog.debug('colada.adapter.resolve_recipient_profile.start', { - pubkeyLength: pubkey.length, + const operationsOverride = useMemo(() => { + const ops = { + ...instance.operations, + executeReceive: createSovranExecuteReceive(() => manager), + executeMintQuote: createSovranExecuteMintQuote(() => manager), + // Stage 2 of recipient resolution: hex pubkey → Nostr kind-0 profile. + // Stage 1 (NIP-05 → pubkey) is shipped by colada's default + // operation set; this one has no default because NDK / cache wiring + // is app-specific. Returning null on any failure is the contract: + // the machine's resolver treats it as best-effort cosmetic data and + // does not block the flow. + resolveRecipientProfile: async ( + pubkey: string, + signal?: AbortSignal + ): Promise => { + paymentLog.debug('colada.adapter.resolve_recipient_profile.start', { + pubkeyLength: pubkey.length, + }); + const currentNdk = ndkRef.current; + if (!currentNdk) { + paymentLog.debug('colada.adapter.resolve_recipient_profile.skipped', { + reason: 'no_ndk', + }); + return null; + } + if (signal?.aborted) { + paymentLog.debug('colada.adapter.resolve_recipient_profile.skipped', { + reason: 'aborted_before_fetch', }); - const currentNdk = ndkRef.current; - if (!currentNdk) { + return null; + } + try { + const event = await currentNdk.fetchEvent({ + kinds: [Metadata as number], + authors: [pubkey], + limit: 1, + }); + if (!event) { paymentLog.debug('colada.adapter.resolve_recipient_profile.skipped', { - reason: 'no_ndk', + reason: 'not_found', }); return null; } - if (signal?.aborted) { + const parsed = parseRawMetadata(event.content); + if (!parsed) { paymentLog.debug('colada.adapter.resolve_recipient_profile.skipped', { - reason: 'aborted_before_fetch', + reason: 'invalid_metadata', }); return null; } - try { - const event = await currentNdk.fetchEvent({ - kinds: [Metadata as number], - authors: [pubkey], - limit: 1, - }); - if (!event) { - paymentLog.debug('colada.adapter.resolve_recipient_profile.skipped', { - reason: 'not_found', - }); - return null; - } - const parsed = parseRawMetadata(event.content); - if (!parsed) { - paymentLog.debug('colada.adapter.resolve_recipient_profile.skipped', { - reason: 'invalid_metadata', - }); - return null; - } - // Warm the shared SWR cache so other surfaces (ContactRow, - // DmChatHeader, profile screens, HistoryEntryHeader) hit warm - // cache for this pubkey on next render without re-fetching. - useNostrMetadataCache.getState().setProfile(pubkey, parsed); - const displayName = resolveIdentityName({ pubkey, nostrProfile: parsed }); - if (!displayName) { - paymentLog.debug('colada.adapter.resolve_recipient_profile.skipped', { - reason: 'no_display_name', - }); - return null; - } - paymentLog.debug('colada.adapter.resolve_recipient_profile.done', { - hasAvatar: !!parsed.picture, - hasNip05: !!parsed.nip05, - }); - return { - displayName, - avatarUrl: parsed.picture ?? null, - nip05: parsed.nip05 ?? null, - }; - } catch (err) { - paymentLog.warn('recipient.resolveProfile.threw', { - error: err instanceof Error ? err.message : String(err), + // Warm the shared SWR cache so other surfaces (ContactRow, + // DmChatHeader, profile screens, HistoryEntryHeader) hit warm + // cache for this pubkey on next render without re-fetching. + useNostrMetadataCache.getState().setProfile(pubkey, parsed); + const displayName = resolveIdentityName({ pubkey, nostrProfile: parsed }); + if (!displayName) { + paymentLog.debug('colada.adapter.resolve_recipient_profile.skipped', { + reason: 'no_display_name', }); return null; } - }, - }) as MachineOperations, - [instance, manager, ndkRef] - ); + paymentLog.debug('colada.adapter.resolve_recipient_profile.done', { + hasAvatar: !!parsed.picture, + hasNip05: !!parsed.nip05, + }); + return { + displayName, + avatarUrl: parsed.picture ?? null, + nip05: parsed.nip05 ?? null, + }; + } catch (err) { + paymentLog.warn('recipient.resolveProfile.threw', { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + }, + }; + return ops as MachineOperations; + }, [instance, manager, ndkRef]); - const actions = useMemo(() => createSovranScreenActionHandlers(), []); + const actions = createSovranScreenActionHandlers(); const navigation = useMemo( () => ({ @@ -413,15 +409,11 @@ export function SovranColadaProvider({ children }: { children: React.ReactNode } [deepLinkUrl, keys?.pubkey] ); - const screenActionsBridge = useMemo( - () => - createSovranScreenActionsBridge({ - manager, - requestCameraPermission, - p2pkKeyRefreshedSubscribers, - }), - [manager, requestCameraPermission] - ); + const screenActionsBridge = createSovranScreenActionsBridge({ + manager, + requestCameraPermission, + p2pkKeyRefreshedSubscribers, + }); const handlers = useCallback( (machine, refs) => diff --git a/features/send/screens/AmountFlowScreen.tsx b/features/send/screens/AmountFlowScreen.tsx index b5455125f..9c650c4de 100644 --- a/features/send/screens/AmountFlowScreen.tsx +++ b/features/send/screens/AmountFlowScreen.tsx @@ -225,8 +225,8 @@ export function AmountFlowContent({ amountEntry, headerMode = 'native' }: Amount return machine.subscribe(log); }, [machine]); - const emptyEntryStyle = useMemo(() => ({ flex: 1, backgroundColor: background }), [background]); - const amountBodyStyle = useMemo(() => ({ flex: 1 }), []); + const emptyEntryStyle = { flex: 1, backgroundColor: background }; + const amountBodyStyle = { flex: 1 }; const isSendOperation = entry?.destination !== 'mintQuote'; const offlineIconStyle = useMemo( () => ({ opacity: canSendOffline === null ? 0.3 : 1 }), @@ -272,19 +272,16 @@ export function AmountFlowContent({ amountEntry, headerMode = 'native' }: Amount ), [canSendOffline, offlineIconStyle] ); - const stackOptions = useMemo( - () => ({ - title: 'Select amount', - headerTitleAlign: 'center' as const, - headerTitle: renderHeaderTitle, - headerTintColor: foreground, - headerRight: isSendOperation && mintUrl ? renderHeaderRight : undefined, - }), - [foreground, isSendOperation, mintUrl, renderHeaderRight, renderHeaderTitle] - ); - const handleErrorGoBack = useCallback(() => { + const stackOptions = { + title: 'Select amount', + headerTitleAlign: 'center' as const, + headerTitle: renderHeaderTitle, + headerTintColor: foreground, + headerRight: isSendOperation && mintUrl ? renderHeaderRight : undefined, + }; + const handleErrorGoBack = () => { void actions.back.execute(); - }, [actions.back]); + }; if (error) { return ; diff --git a/features/send/screens/AmountSelector.tsx b/features/send/screens/AmountSelector.tsx index 3881255d4..1a50d2ffe 100644 --- a/features/send/screens/AmountSelector.tsx +++ b/features/send/screens/AmountSelector.tsx @@ -4,7 +4,7 @@ * typed contract and wires the bound actions through. */ -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import { PixelRatio, type StyleProp, @@ -102,35 +102,29 @@ export function AmountSelector({ useLifecycleLogger('AmountSelector', walletLog); const { rawInput, inputMode, numericValue, keyboardUnit, unit, secondaryDisplay, fiatSymbol } = - useMemo(() => readAmountEntryFields(entry), [entry]); + readAmountEntryFields(entry); - const handleKeyPress = useCallback( - (value: string) => { - walletLog.debug('amount.input.key', { value, inputMode }); - void actions.setInput.execute({ input: value }); - }, - [actions.setInput, inputMode] - ); + const handleKeyPress = (value: string) => { + walletLog.debug('amount.input.key', { value, inputMode }); + void actions.setInput.execute({ input: value }); + }; - const handleSuggestionTap = useCallback( - (suggestion: QuickSendSuggestion) => { - walletLog.info('amount.suggestion.tap', { - satoshis: suggestion.satoshis, - label: suggestion.label, - mode: suggestion.inputMode, - }); - void actions.setInput.execute({ - input: suggestion.inputValue, - mode: suggestion.inputMode, - }); - }, - [actions.setInput] - ); + const handleSuggestionTap = (suggestion: QuickSendSuggestion) => { + walletLog.info('amount.suggestion.tap', { + satoshis: suggestion.satoshis, + label: suggestion.label, + mode: suggestion.inputMode, + }); + void actions.setInput.execute({ + input: suggestion.inputValue, + mode: suggestion.inputMode, + }); + }; - const handleToggle = useCallback(() => { + const handleToggle = () => { walletLog.info('amount.input.toggle', { fromMode: inputMode }); void actions.toggle.execute(); - }, [actions.toggle, inputMode]); + }; // Pack recipient identity into the execute params on every `next` call. // Spread by the action manager into `ctx`, then read by colada's @@ -144,7 +138,7 @@ export function AmountSelector({ [recipientPubkey, recipientProfile] ); - const handleNext = useCallback(async () => { + const handleNext = async () => { walletLog.info('amount.next', { numericValue, inputMode, @@ -157,7 +151,7 @@ export function AmountSelector({ ?.displayName ?? null, }); await actions.next.execute(nextExecuteParams); - }, [actions.next, numericValue, inputMode, unit, transactionType, nextExecuteParams]); + }; // Map the colada availability variants (ecash/lightning/onchain on // send-money flows) into ActionMenuButton's variant shape. Each variant diff --git a/features/send/screens/PaymentRequestScreen.tsx b/features/send/screens/PaymentRequestScreen.tsx index 1e0b5ec43..5a2f0adf4 100644 --- a/features/send/screens/PaymentRequestScreen.tsx +++ b/features/send/screens/PaymentRequestScreen.tsx @@ -64,7 +64,7 @@ export function PaymentRequestScreen({ const tokenCreated = entry.metadata?.tokenCreated === 'true'; const nostrSent = entry.metadata?.nostrSent === 'true'; - const isPreview = isPaymentRequestPreview({ ...entry } as Record); + const isPreview = isPaymentRequestPreview(entry as unknown as Record); log.debug('send.payment_request.render', { isPreview, amount: entry.amount, diff --git a/features/send/screens/SendTokenScreen.tsx b/features/send/screens/SendTokenScreen.tsx index 04d3d793a..4a75c0f52 100644 --- a/features/send/screens/SendTokenScreen.tsx +++ b/features/send/screens/SendTokenScreen.tsx @@ -6,7 +6,7 @@ * only renders UI and wires buttons. */ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import { Platform, StyleSheet } from 'react-native'; import { Alert, Menu, type MenuTriggerRef } from 'heroui-native'; @@ -131,39 +131,32 @@ export function SendTokenScreen({ // Hooks — before this, `entry` flipping from undefined → defined would // add new hook calls mid-lifecycle and trigger "Rendered more hooks than // during the previous render". - const copyVariants: ActionVariant[] = useMemo( - () => - actions.copy.variants ?? [ - { - id: 'text', - label: 'as Text', - description: 'Copy the token as plain text', - icon: 'lets-icons:copy', - available: actions.copy.available, - }, - { - id: 'emoji', - label: 'as Emoji', - description: 'Copy the token as emoji', - icon: 'fluent:emoji-24-filled', - available: actions.copy.available, - }, - ], - [actions.copy.variants, actions.copy.available] - ); + const copyVariants: ActionVariant[] = actions.copy.variants ?? [ + { + id: 'text', + label: 'as Text', + description: 'Copy the token as plain text', + icon: 'lets-icons:copy', + available: actions.copy.available, + }, + { + id: 'emoji', + label: 'as Emoji', + description: 'Copy the token as emoji', + icon: 'fluent:emoji-24-filled', + available: actions.copy.available, + }, + ]; const copyMenuTriggerRef = useRef(null); - const handleCopyVariant = useCallback( - (variantId: string) => { - void actions.copy.execute({ variantId }); - }, - [actions.copy] - ); - const openCopyMenu = useCallback(() => { + const handleCopyVariant = (variantId: string) => { + void actions.copy.execute({ variantId }); + }; + const openCopyMenu = () => { // Defer to the next tick so the ButtonHandler's sheet-close / press-in // animation doesn't race with the menu's trigger-position measure call. setTimeout(() => copyMenuTriggerRef.current?.open(), 0); - }, []); + }; if (error) { log.warn('send.token.error', { error }); return ; @@ -366,21 +359,14 @@ export function SendTokenScreen({ } function SendTokenMemoText({ memo }: { memo: string }): React.ReactElement { - const references = useMemo(() => extractMemoNprofileReferences(memo), [memo]); - const pubkeys = useMemo( - () => [...new Set(references.map((reference) => reference.pubkey))], - [references] - ); + const references = extractMemoNprofileReferences(memo); + const pubkeys = [...new Set(references.map((reference) => reference.pubkey))]; const { metadata } = useNostrProfileMetadataMany(pubkeys); - const displayMemo = useMemo( - () => - formatMemoForDisplay(memo, (pubkey) => - resolveIdentityName({ - pubkey, - nostrProfile: metadata.get(pubkey), - }) - ), - [memo, metadata] + const displayMemo = formatMemoForDisplay(memo, (pubkey) => + resolveIdentityName({ + pubkey, + nostrProfile: metadata.get(pubkey), + }) ); return ( diff --git a/features/settings/index.ts b/features/settings/index.ts index 0c2bc15d6..b95ecf652 100644 --- a/features/settings/index.ts +++ b/features/settings/index.ts @@ -1,6 +1,6 @@ // settings feature barrel -export { SettingsScreen, name, version, buildNumber } from './screens/SettingsScreen'; +export { SettingsScreen } from './screens/SettingsScreen'; export { SettingsProfileScreen } from './screens/SettingsProfileScreen'; export { SettingsKeyringScreen } from './screens/SettingsKeyringScreen'; export { SettingsRecoveryScreen } from './screens/SettingsRecoveryScreen'; diff --git a/features/settings/screens/DeleteScreen.tsx b/features/settings/screens/DeleteScreen.tsx index 0aebec765..3234aecea 100644 --- a/features/settings/screens/DeleteScreen.tsx +++ b/features/settings/screens/DeleteScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { ScrollView } from 'react-native'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; @@ -14,17 +14,17 @@ import Icon from 'assets/icons'; import { Button, Card } from 'heroui-native'; import opacity from 'hex-color-opacity'; +const handleDelete = async () => { + log.warn('settings.delete.confirmed', { reason: 'user_initiated_slide_to_delete' }); + await deleteAllProfiles(); + log.info('settings.delete.complete'); +}; + export function DeleteScreen() { useLifecycleLogger('DeleteScreen'); const foreground = useThemeColor('foreground'); const [danger, red400] = useThemeColor(['danger', 'red-400'] as const); - const handleDelete = useCallback(async () => { - log.warn('settings.delete.confirmed', { reason: 'user_initiated_slide_to_delete' }); - await deleteAllProfiles(); - log.info('settings.delete.complete'); - }, []); - return ( diff --git a/features/settings/screens/SettingsAvatarScreen.tsx b/features/settings/screens/SettingsAvatarScreen.tsx index 3c10aa1d3..6d96518b0 100644 --- a/features/settings/screens/SettingsAvatarScreen.tsx +++ b/features/settings/screens/SettingsAvatarScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { ScrollView } from 'react-native'; import { Card, Radio, RadioGroup, Separator } from 'heroui-native'; @@ -24,14 +24,11 @@ export function SettingsAvatarScreen() { const avatarFallbackVariant = useSettingsStore((state) => state.avatarFallbackVariant); const setAvatarFallbackVariant = useSettingsStore((state) => state.setAvatarFallbackVariant); - const handleVariantChange = useCallback( - (value: string) => { - if (!isAvatarFallbackVariant(value)) return; - log.info('settings.avatar_fallback.change', { variant: value }); - setAvatarFallbackVariant(value); - }, - [setAvatarFallbackVariant] - ); + const handleVariantChange = (value: string) => { + if (!isAvatarFallbackVariant(value)) return; + log.info('settings.avatar_fallback.change', { variant: value }); + setAvatarFallbackVariant(value); + }; return ( diff --git a/features/settings/screens/SettingsDesignSystemEmptyStatesScreen.tsx b/features/settings/screens/SettingsDesignSystemEmptyStatesScreen.tsx index 55eb8540e..38618ccae 100644 --- a/features/settings/screens/SettingsDesignSystemEmptyStatesScreen.tsx +++ b/features/settings/screens/SettingsDesignSystemEmptyStatesScreen.tsx @@ -11,7 +11,7 @@ import { EmptyState } from '@/shared/ui/composed/EmptyState'; * single `EmptyState` component so they stay visually consistent. Adding a new * empty surface? Add its variant here and reuse `EmptyState` on the screen. */ -const EMPTY_STATES: Array<{ section: string; icon: string; title: string; subtitle?: string }> = [ +const EMPTY_STATES: { section: string; icon: string; title: string; subtitle?: string }[] = [ { section: 'Feed', icon: 'mdi:message-text', diff --git a/features/settings/screens/SettingsDesignSystemSkeletonCrossfadeScreen.tsx b/features/settings/screens/SettingsDesignSystemSkeletonCrossfadeScreen.tsx index df61c023a..51181b4dc 100644 --- a/features/settings/screens/SettingsDesignSystemSkeletonCrossfadeScreen.tsx +++ b/features/settings/screens/SettingsDesignSystemSkeletonCrossfadeScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { ScrollView } from 'react-native'; import { Button, Card } from 'heroui-native'; @@ -67,14 +67,14 @@ export function SettingsDesignSystemSkeletonCrossfadeScreen() { }; }, [auto]); - const toggle = useCallback(() => { + const toggle = () => { setAuto(false); setLoading((prev) => { const next = !prev; if (!next) setImageSeed((s) => s + 1); return next; }); - }, []); + }; const pictureUrl = loading ? undefined : `https://picsum.photos/seed/sovran-${imageSeed}/88`; diff --git a/features/settings/screens/SettingsDesignSystemTimelineScreen.tsx b/features/settings/screens/SettingsDesignSystemTimelineScreen.tsx index d4d09557d..dd0ac576e 100644 --- a/features/settings/screens/SettingsDesignSystemTimelineScreen.tsx +++ b/features/settings/screens/SettingsDesignSystemTimelineScreen.tsx @@ -18,10 +18,7 @@ const TIMELINE_COMPLETE_HOLD_STEPS = 2; export function SettingsDesignSystemTimelineScreen() { // Fixed createdAt so the simulated timestamps don't churn on re-render. const timelineCreatedAt = useMemo(() => Date.now() - 5 * 60 * 1000, []); - const timelineScenarios = useMemo( - () => buildTimelineScenarios(timelineCreatedAt), - [timelineCreatedAt] - ); + const timelineScenarios = buildTimelineScenarios(timelineCreatedAt); const [selectedScenarioId, setSelectedScenarioId] = useState(timelineScenarios[0].id); const [frameIndex, setFrameIndex] = useState(0); const [timelineAuto, setTimelineAuto] = useState(true); diff --git a/features/settings/screens/SettingsKeyringScreen.tsx b/features/settings/screens/SettingsKeyringScreen.tsx index 41fbb7174..287068a59 100644 --- a/features/settings/screens/SettingsKeyringScreen.tsx +++ b/features/settings/screens/SettingsKeyringScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { LoadingIndicator } from '@/shared/blocks/status'; import * as Clipboard from 'expo-clipboard'; import { Stack } from 'expo-router'; @@ -64,9 +64,9 @@ const CurrentKeyItem: React.FC<{ const activeData = isNpubTab ? npubValue! : keypair.publicKeyHex; const displayKey = isDerived ? keypair.publicKeyHex : activeData; - const handleTabPress = useCallback((tab: string) => { + const handleTabPress = (tab: string) => { setSelectedTab(tab); - }, []); + }; const handleShowQR = () => { router.navigate({ @@ -79,9 +79,9 @@ const CurrentKeyItem: React.FC<{ }); }; - const handleCopy = useCallback(() => { + const handleCopy = () => { onCopy(activeData); - }, [activeData, onCopy]); + }; return ( @@ -218,6 +218,11 @@ const KeyItem: React.FC<{ /** * KeyringSettings - P2PK key management page */ +const handleCopyKey = async (publicKey: string) => { + await Clipboard.setStringAsync(publicKey); + copyPopup('publicKey'); +}; + export const SettingsKeyringScreen: React.FC = () => { useLifecycleLogger('SettingsKeyringScreen'); const [foreground, defaultColor] = useThemeColor(['foreground', 'default'] as const); @@ -413,11 +418,6 @@ export const SettingsKeyringScreen: React.FC = () => { } }); - const handleCopyKey = async (publicKey: string) => { - await Clipboard.setStringAsync(publicKey); - copyPopup('publicKey'); - }; - const canImportCurrentNsec = !!manager && nostrKeysReady && !!nostrKeys?.privateKey; const isKeyringActionPending = isGenerating || isImportingCurrentNsec; const handleHeaderBack = useCallback(() => { @@ -461,14 +461,11 @@ export const SettingsKeyringScreen: React.FC = () => { ), [foreground, handleGenerateKey, handleImportNsec, isGenerating, isKeyringActionPending] ); - const stackOptions = useMemo( - () => ({ - title: 'P2PK Keys', - headerLeft: renderHeaderLeft, - headerRight: renderHeaderRight, - }), - [renderHeaderLeft, renderHeaderRight] - ); + const stackOptions = { + title: 'P2PK Keys', + headerLeft: renderHeaderLeft, + headerRight: renderHeaderRight, + }; return ( diff --git a/features/settings/screens/SettingsNetworkScreen.tsx b/features/settings/screens/SettingsNetworkScreen.tsx index 5cfd27f90..4678bbecf 100644 --- a/features/settings/screens/SettingsNetworkScreen.tsx +++ b/features/settings/screens/SettingsNetworkScreen.tsx @@ -8,7 +8,7 @@ * NIP-65 list (kind:10002): connection health, read/write markers, add/remove, * restore defaults, and publish. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { ScrollView, View } from 'react-native'; import { NDKEvent, useNDK } from '@nostr-dev-kit/ndk-mobile'; import { Button, Card, Input, ListGroup, Separator, Switch, TextField } from 'heroui-native'; @@ -17,7 +17,7 @@ import Icon from 'assets/icons'; import { backendConfig } from '@/shared/config/backend'; import { useSettingsStore } from '@/shared/stores/global/settingsStore'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; -import { useRelayHealth, type RelayHealth } from '@/shared/hooks/useRelayHealth'; +import { useRelayHealth } from '@/shared/hooks/useRelayHealth'; import { log } from '@/shared/lib/logger'; import { publishEvent } from '@/shared/lib/nostr/publish'; import { DEFAULT_RELAYS, safeNormalizeRelay } from '@/shared/lib/nostr/outbox/defaults'; @@ -56,19 +56,16 @@ export function SettingsNetworkScreen() { 'danger', ] as const); - const healthColor = useMemo>( - () => ({ - connected: successColor, - connecting: accentColor, - disconnected: mutedColor, - failed: dangerColor, - }), - [successColor, accentColor, mutedColor, dangerColor] - ); + const healthColor = { + connected: successColor, + connecting: accentColor, + disconnected: mutedColor, + failed: dangerColor, + }; const hasUnpublished = source === 'local'; - const handleAdd = useCallback(() => { + const handleAdd = () => { const normalized = safeNormalizeRelay(draftUrl.trim()); if (!normalized || !/^wss?:\/\//.test(normalized)) { setAddError('Enter a valid relay URL (wss://…).'); @@ -77,9 +74,9 @@ export function SettingsNetworkScreen() { setAddError(null); addRelay(normalized, { read: true, write: true }); setDraftUrl(''); - }, [draftUrl, addRelay]); + }; - const handlePublish = useCallback(async () => { + const handlePublish = async () => { if (!ndk) return; setPublishing(true); setPublishMsg(null); @@ -101,12 +98,9 @@ export function SettingsNetworkScreen() { } finally { setPublishing(false); } - }, [ndk, entries, markPublished]); + }; - const sortedEntries = useMemo( - () => [...entries].sort((a, b) => a.url.localeCompare(b.url)), - [entries] - ); + const sortedEntries = [...entries].sort((a, b) => a.url.localeCompare(b.url)); return ( diff --git a/features/settings/screens/SettingsNotificationPolicyScreen.tsx b/features/settings/screens/SettingsNotificationPolicyScreen.tsx index 48ddbed22..76d447aca 100644 --- a/features/settings/screens/SettingsNotificationPolicyScreen.tsx +++ b/features/settings/screens/SettingsNotificationPolicyScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { ScrollView } from 'react-native'; import { ListGroup, PressableFeedback, Separator } from 'heroui-native'; @@ -29,13 +29,10 @@ export function SettingsNotificationPolicyScreen() { const policy = useNotificationPolicyStore((state) => state.policy); const setPolicy = useNotificationPolicyStore((state) => state.setPolicy); - const handlePolicyChange = useCallback( - (value: FeedNotificationPolicy) => { - log.info('settings.notification_policy.change', { policy: value }); - setPolicy(value); - }, - [setPolicy] - ); + const handlePolicyChange = (value: FeedNotificationPolicy) => { + log.info('settings.notification_policy.change', { policy: value }); + setPolicy(value); + }; return ( diff --git a/features/settings/screens/SettingsProfileScreen.tsx b/features/settings/screens/SettingsProfileScreen.tsx index d7aeeff1d..e2a04c318 100644 --- a/features/settings/screens/SettingsProfileScreen.tsx +++ b/features/settings/screens/SettingsProfileScreen.tsx @@ -31,6 +31,14 @@ const DebugRow: React.FC<{ label: string; value: string }> = ({ label, value }) ); }; +const handleCopy = async (text: string, target: CopyTarget) => { + if (text) { + log.info('settings.profile.copy', { target }); + await Clipboard.setStringAsync(text); + copyPopup(target, { duration: 1000 }); + } +}; + export const SettingsProfileScreen = () => { useLifecycleLogger('SettingsProfileScreen'); const { value: mnemonic, loading: mnemonicLoading } = useMnemonic(); @@ -44,14 +52,6 @@ export const SettingsProfileScreen = () => { cashuMnemonic: false, }); - const handleCopy = async (text: string, target: CopyTarget) => { - if (text) { - log.info('settings.profile.copy', { target }); - await Clipboard.setStringAsync(text); - copyPopup(target, { duration: 1000 }); - } - }; - const toggleFieldVisibility = (field: keyof typeof visibleFields) => { log.debug('settings.profile.toggle_visibility', { field }); setVisibleFields((prev) => ({ diff --git a/features/settings/screens/SettingsRecoveryScreen.tsx b/features/settings/screens/SettingsRecoveryScreen.tsx index 3f598595a..7fc4ee704 100644 --- a/features/settings/screens/SettingsRecoveryScreen.tsx +++ b/features/settings/screens/SettingsRecoveryScreen.tsx @@ -24,6 +24,7 @@ import { LoadingIndicator } from '@/shared/blocks/status'; import { staticPopup, paramPopup } from '@/shared/lib/popup'; import { fetchJson } from '@/shared/lib/apiClient'; import { MintListResponse, parseWith } from '@sovranbitcoin/schemas'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; // ─── Deep probe: discover mints from audit API ───────────────────────────── @@ -32,13 +33,6 @@ const MAX_DISCOVERED_MINTS = 100; const parseMintList = parseWith(MintListResponse, 'cashu/mints'); -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - function normalizeMintUrl(url: string): string { return url.replace(/\/$/, '').toLowerCase(); } @@ -253,7 +247,7 @@ export const SettingsRecoveryScreen: React.FC = ({ // Check if funds were actually recovered regardless of whether restore threw const balances = await manager.wallet.balances .byMint() - .catch(() => ({}) as Awaited>); + .catch((): Awaited> => ({})); const mintBalance = amountToNumber(balances[mintUrl]?.total); const fundsFound = mintBalance > 0; const mintMs = Math.round((performance.now() - mintT0) * 100) / 100; diff --git a/features/settings/screens/SettingsRoutingScreen.tsx b/features/settings/screens/SettingsRoutingScreen.tsx index 053a86a79..33ff8f7cc 100644 --- a/features/settings/screens/SettingsRoutingScreen.tsx +++ b/features/settings/screens/SettingsRoutingScreen.tsx @@ -1,4 +1,5 @@ -import React, { useCallback } from 'react'; +import React from 'react'; +import { AMBER_ACCENT } from '@/shared/lib/brandColors'; import { ScrollView, View } from 'react-native'; import { useSettingsStore, @@ -24,6 +25,11 @@ import { log, useLifecycleLogger } from '@/shared/lib/logger'; // Main screen // --------------------------------------------------------------------------- +const asNumber = (value: number | number[]) => (Array.isArray(value) ? (value[0] ?? 0) : value); +// Snap to slider step so a stored rate that isn't a multiple of 5 doesn't +// display as e.g. "73%" while the thumb sits between stops. +const snapSuccessRate = (rate: number) => Math.round((rate * 100) / 5) * 5; + export function SettingsRoutingScreen() { useLifecycleLogger('SettingsRoutingScreen'); const middlemanRouting = useSettingsStore((state) => state.middlemanRouting); @@ -31,18 +37,10 @@ export function SettingsRoutingScreen() { const minTransferThreshold = useSettingsStore((state) => state.minTransferThreshold); const setMinTransferThreshold = useSettingsStore((state) => state.setMinTransferThreshold); - const update = useCallback( - (partial: Partial) => { - log.info('settings.routing.change', { ...partial }); - setMiddlemanRouting(partial); - }, - [setMiddlemanRouting] - ); - - const asNumber = (value: number | number[]) => (Array.isArray(value) ? (value[0] ?? 0) : value); - // Snap to slider step so a stored rate that isn't a multiple of 5 doesn't - // display as e.g. "73%" while the thumb sits between stops. - const snapSuccessRate = (rate: number) => Math.round((rate * 100) / 5) * 5; + const update = (partial: Partial) => { + log.info('settings.routing.change', { ...partial }); + setMiddlemanRouting(partial); + }; return ( @@ -197,10 +195,10 @@ export function SettingsRoutingScreen() { - + Untrusted mints will be temporarily trusted for the swap and untrusted afterward. Your ecash passes through mints you have not verified. Only use this with small amounts. diff --git a/features/settings/screens/SettingsScreen.tsx b/features/settings/screens/SettingsScreen.tsx index 8ed41e60a..ba164ef67 100644 --- a/features/settings/screens/SettingsScreen.tsx +++ b/features/settings/screens/SettingsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useCallback } from 'react'; +import React, { useRef } from 'react'; import { ScrollView, Alert } from 'react-native'; import { openExternalUrl } from '@/shared/lib/url'; import { Text } from '@/shared/ui/primitives/Text'; @@ -26,9 +26,9 @@ import { useNotificationPolicyStore } from '@/features/feed/stores/notificationP import { notificationPolicyLabel } from '@/features/feed/lib/notificationCopy'; import { useNip46RequestsStore } from '@/features/nostrSigner'; -export const name = Application.applicationName; -export const version = Application.nativeApplicationVersion; -export const buildNumber = Application.nativeBuildVersion; +const name = Application.applicationName; +const version = Application.nativeApplicationVersion; +const buildNumber = Application.nativeBuildVersion; const ProfileButton = () => { const { keys: nostrKeys } = useNostrKeysContext(); @@ -140,6 +140,19 @@ const BelowFold = ({ children }: { children: React.ReactNode }) => { return ready ? <>{children} : null; }; +const handleExportDatabase = async () => { + log.info('settings.export_database.start'); + try { + await CocoManager.exportDatabase(); + log.info('settings.export_database.success'); + } catch (error) { + log.error('settings.export_database.error', { + error: error instanceof Error ? error : new Error(String(error)), + }); + Alert.alert('Export Failed', error instanceof Error ? error.message : 'Unknown error'); + } +}; + export const SettingsScreen = () => { useLifecycleLogger('SettingsScreen'); const sendLocationEnabled = useSettingsStore((state) => state.sendLocationEnabled); @@ -167,7 +180,7 @@ export const SettingsScreen = () => { const tapCountRef = useRef(0); const lastTapRef = useRef(0); - const handleVersionPress = useCallback(() => { + const handleVersionPress = () => { const now = Date.now(); if (now - lastTapRef.current > TRIPLE_TAP_WINDOW_MS) { tapCountRef.current = 0; @@ -182,19 +195,6 @@ export const SettingsScreen = () => { setDevMode(newMode); paramPopup('dev-mode', newMode); } - }, [devMode, setDevMode]); - - const handleExportDatabase = async () => { - log.info('settings.export_database.start'); - try { - await CocoManager.exportDatabase(); - log.info('settings.export_database.success'); - } catch (error) { - log.error('settings.export_database.error', { - error: error instanceof Error ? error : new Error(String(error)), - }); - Alert.alert('Export Failed', error instanceof Error ? error.message : 'Unknown error'); - } }; return ( diff --git a/features/settings/screens/SettingsStorageScreen.tsx b/features/settings/screens/SettingsStorageScreen.tsx index 83c9c81a3..be6c1971a 100644 --- a/features/settings/screens/SettingsStorageScreen.tsx +++ b/features/settings/screens/SettingsStorageScreen.tsx @@ -190,15 +190,12 @@ export const SettingsStorageScreen = () => { const refreshLogFileInfo = useCallback(() => setLogFileInfo(getLogFileInfo()), []); - const handleToggleFileLogging = useCallback( - (next: boolean) => { - setFileLoggingEnabled(next); - refreshLogFileInfo(); - }, - [setFileLoggingEnabled, refreshLogFileInfo] - ); + const handleToggleFileLogging = (next: boolean) => { + setFileLoggingEnabled(next); + refreshLogFileInfo(); + }; - const handleExportLogFile = useCallback(async () => { + const handleExportLogFile = async () => { setIsExportingLogs(true); try { const shared = await exportLogFile(); @@ -211,12 +208,12 @@ export const SettingsStorageScreen = () => { setIsExportingLogs(false); refreshLogFileInfo(); } - }, [refreshLogFileInfo]); + }; - const handleClearLogFile = useCallback(() => { + const handleClearLogFile = () => { clearLogFile(); refreshLogFileInfo(); - }, [refreshLogFileInfo]); + }; const [zustandGroups, setZustandGroups] = useState(EMPTY_ZUSTAND_GROUPS); const [secureStoreKeys, setSecureStoreKeys] = useState([]); const [cocoDbFiles, setCocoDbFiles] = useState([]); @@ -260,7 +257,7 @@ export const SettingsStorageScreen = () => { void loadSnapshot(); }, [loadSnapshot]); - const handleShareDump = useCallback(async () => { + const handleShareDump = async () => { setIsSharing(true); try { const dump = await getFullAsyncStorageDump(); @@ -271,9 +268,9 @@ export const SettingsStorageScreen = () => { } finally { setIsSharing(false); } - }, []); + }; - const handleCopyDebugLogs = useCallback(async () => { + const handleCopyDebugLogs = async () => { setIsCopyingLogs(true); try { await Clipboard.setStringAsync(log.dumpForLLM()); @@ -283,7 +280,7 @@ export const SettingsStorageScreen = () => { } finally { setIsCopyingLogs(false); } - }, []); + }; const subtitle = useMemo(() => { if (isLoading) { @@ -292,40 +289,34 @@ export const SettingsStorageScreen = () => { return 'Shows storage keys/files that currently exist on this device.'; }, [isLoading]); - const secureStoreGrouped = useMemo( - () => ({ - static: secureStoreKeys.filter( - (key) => key === 'user_mnemonic' || key === 'migrations_complete' - ), - migrationFlags: secureStoreKeys.filter((key) => key.startsWith('migrations_complete_')), - derivedCaches: secureStoreKeys.filter( - (key) => key.startsWith('derived_keys_') || key.startsWith('cashu_mnemonic_') - ), - importedNsec: secureStoreKeys.filter((key) => key.startsWith('imported_nsec_')), - other: secureStoreKeys.filter( - (key) => - key !== 'user_mnemonic' && - key !== 'migrations_complete' && - !key.startsWith('migrations_complete_') && - !key.startsWith('derived_keys_') && - !key.startsWith('cashu_mnemonic_') && - !key.startsWith('imported_nsec_') - ), - }), - [secureStoreKeys] - ); - - const cocoGrouped = useMemo( - () => ({ - mainDbFiles: cocoDbFiles.filter( - (file) => !file.includes('-wal') && !file.includes('-shm') && !file.includes('-journal') - ), - sqliteSidecars: cocoDbFiles.filter( - (file) => file.includes('-wal') || file.includes('-shm') || file.includes('-journal') - ), - }), - [cocoDbFiles] - ); + const secureStoreGrouped = { + static: secureStoreKeys.filter( + (key) => key === 'user_mnemonic' || key === 'migrations_complete' + ), + migrationFlags: secureStoreKeys.filter((key) => key.startsWith('migrations_complete_')), + derivedCaches: secureStoreKeys.filter( + (key) => key.startsWith('derived_keys_') || key.startsWith('cashu_mnemonic_') + ), + importedNsec: secureStoreKeys.filter((key) => key.startsWith('imported_nsec_')), + other: secureStoreKeys.filter( + (key) => + key !== 'user_mnemonic' && + key !== 'migrations_complete' && + !key.startsWith('migrations_complete_') && + !key.startsWith('derived_keys_') && + !key.startsWith('cashu_mnemonic_') && + !key.startsWith('imported_nsec_') + ), + }; + + const cocoGrouped = { + mainDbFiles: cocoDbFiles.filter( + (file) => !file.includes('-wal') && !file.includes('-shm') && !file.includes('-journal') + ), + sqliteSidecars: cocoDbFiles.filter( + (file) => file.includes('-wal') || file.includes('-shm') || file.includes('-journal') + ), + }; return ( diff --git a/features/theme/components/AlbumPillTabs.tsx b/features/theme/components/AlbumPillTabs.tsx index efa23b9dc..deb86c8c2 100644 --- a/features/theme/components/AlbumPillTabs.tsx +++ b/features/theme/components/AlbumPillTabs.tsx @@ -4,7 +4,7 @@ * Used in the Background modal to filter the wallpaper grid by album. */ -import React, { useCallback } from 'react'; +import React from 'react'; import { ScrollView } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { View } from '@/shared/ui/primitives/View/View'; @@ -25,13 +25,10 @@ export function AlbumPillTabs({ tabs, selectedTab, onSelect }: AlbumPillTabsProp 'surface', ] as const); - const handlePress = useCallback( - (tab: string) => { - log.info('theme.background.album.tab', { album: tab }); - onSelect(tab); - }, - [onSelect] - ); + const handlePress = (tab: string) => { + log.info('theme.background.album.tab', { album: tab }); + onSelect(tab); + }; return ( diff --git a/features/theme/components/UnitPreviewCard.tsx b/features/theme/components/UnitPreviewCard.tsx index d66bd9380..714d6f93c 100644 --- a/features/theme/components/UnitPreviewCard.tsx +++ b/features/theme/components/UnitPreviewCard.tsx @@ -5,6 +5,7 @@ */ import React, { useEffect, useState } from 'react'; +import { INVARIANT_WHITE, WALLPAPER_PLACEHOLDER } from '@/shared/lib/brandColors'; import { StyleSheet } from 'react-native'; import { PressableFeedback } from 'heroui-native'; import { LinearGradient } from 'expo-linear-gradient'; @@ -17,6 +18,7 @@ import { THEMES } from '@/themes'; import { useWallpaperStore } from '@/shared/stores/global/wallpaperStore'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { log } from '@/shared/lib/logger'; +import { describeImageLoadError } from '@/shared/lib/imageLoadError'; /** A thumbUrl is only usable if it's a real http(s) URL — empty/whitespace/junk * must fall through to the gradient rather than render a blank ``. */ @@ -24,16 +26,6 @@ function isLikelyImageUrl(url: string | undefined | null): url is string { return typeof url === 'string' && /^https?:\/\/\S+/i.test(url.trim()); } -function describeImageLoadError(event: unknown): string { - if (event && typeof event === 'object') { - const directError = (event as { error?: unknown }).error; - if (typeof directError === 'string') return directError; - const nativeEvent = (event as { nativeEvent?: { error?: unknown } }).nativeEvent; - if (typeof nativeEvent?.error === 'string') return nativeEvent.error; - } - return String(event ?? 'unknown'); -} - interface UnitPreviewCardProps { themeName: string; label?: string; @@ -159,16 +151,18 @@ export const UnitPreviewCard = React.memo(function UnitPreviewCard({ ) : palette ? ( ) : ( - + )} {/* Phone-frame chrome mocks */} @@ -176,7 +170,7 @@ export const UnitPreviewCard = React.memo(function UnitPreviewCard({ {label ? ( - + {label} {sublabel ? ( @@ -192,7 +186,7 @@ export const UnitPreviewCard = React.memo(function UnitPreviewCard({ {badge ? ( - + {badge} diff --git a/features/theme/components/WallpaperThumbnail.tsx b/features/theme/components/WallpaperThumbnail.tsx index 8766087c7..601379528 100644 --- a/features/theme/components/WallpaperThumbnail.tsx +++ b/features/theme/components/WallpaperThumbnail.tsx @@ -7,6 +7,7 @@ */ import React, { useEffect, useState } from 'react'; +import { INVARIANT_WHITE, WALLPAPER_PLACEHOLDER } from '@/shared/lib/brandColors'; import { StyleSheet } from 'react-native'; import { PressableFeedback } from 'heroui-native'; import { LinearGradient } from 'expo-linear-gradient'; @@ -19,6 +20,7 @@ import { useWallpaperStore } from '@/shared/stores/global/wallpaperStore'; import type { WallpaperCatalogEntry } from '@/shared/stores/global/wallpaperStore'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { log } from '@/shared/lib/logger'; +import { describeImageLoadError } from '@/shared/lib/imageLoadError'; /** A thumbUrl is only usable if it's a real http(s) URL — empty strings, * whitespace, or junk like "null" must fall through to the gradient rather @@ -27,16 +29,6 @@ function isLikelyImageUrl(url: string | undefined | null): url is string { return typeof url === 'string' && /^https?:\/\/\S+/i.test(url.trim()); } -function describeImageLoadError(event: unknown): string { - if (event && typeof event === 'object') { - const directError = (event as { error?: unknown }).error; - if (typeof directError === 'string') return directError; - const nativeEvent = (event as { nativeEvent?: { error?: unknown } }).nativeEvent; - if (typeof nativeEvent?.error === 'string') return nativeEvent.error; - } - return String(event ?? 'unknown'); -} - interface WallpaperThumbnailProps { themeName: string; entry?: WallpaperCatalogEntry; @@ -147,21 +139,26 @@ export const WallpaperThumbnail = React.memo(function WallpaperThumbnail({ ) : paletteColors ? ( ) : ( - + )} {inProgress && ( - + {Math.round((activeDownloadProgress ?? 0) * 100)}% @@ -169,13 +166,13 @@ export const WallpaperThumbnail = React.memo(function WallpaperThumbnail({ {showPlayBadge && !inProgress && ( - + )} {!downloaded && entry && !inProgress && ( - + )} diff --git a/features/theme/screens/BackgroundScreen.tsx b/features/theme/screens/BackgroundScreen.tsx index 1a22d1ff2..27f08c5ff 100644 --- a/features/theme/screens/BackgroundScreen.tsx +++ b/features/theme/screens/BackgroundScreen.tsx @@ -6,7 +6,7 @@ * the selected pill; tapping a pill animates to that page. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { FlatList, useWindowDimensions } from 'react-native'; import { Stack } from 'expo-router'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; @@ -77,37 +77,31 @@ export function BackgroundScreen() { } }, [albums, activeIndex]); - const tabLabels = useMemo(() => albums.map((a) => a.displayName), [albums]); + const tabLabels = albums.map((a) => a.displayName); const selectedTabLabel = tabLabels[activeIndex] ?? ''; - const handleTabSelect = useCallback( - (label: string) => { - const idx = tabLabels.indexOf(label); - if (idx < 0) return; - setActiveIndex(idx); - pagerRef.current?.setPage(idx); - }, - [tabLabels] - ); + const handleTabSelect = (label: string) => { + const idx = tabLabels.indexOf(label); + if (idx < 0) return; + setActiveIndex(idx); + pagerRef.current?.setPage(idx); + }; - const onPageSelected = useCallback((event: { nativeEvent: { position: number } }) => { + const onPageSelected = (event: { nativeEvent: { position: number } }) => { const idx = event.nativeEvent.position; setActiveIndex(idx); - }, []); + }; const cardWidth = Math.floor( (screenWidth - GRID_HORIZONTAL_PADDING * 2 - GRID_GAP * (GRID_COLUMNS - 1)) / GRID_COLUMNS ); const cardHeight = Math.round(cardWidth * 1.55); - const handlePickWallpaper = useCallback( - (themeName: string) => { - log.info('theme.background.pick', { themeName, unitId }); - setUnitWallpaper(unitId, themeName); - router.back(); - }, - [setUnitWallpaper, unitId] - ); + const handlePickWallpaper = (themeName: string) => { + log.info('theme.background.pick', { themeName, unitId }); + setUnitWallpaper(unitId, themeName); + router.back(); + }; // Pager needs explicit height; carve out the space between tabs and the // bottom safe area so each page's grid can scroll vertically inside its @@ -179,26 +173,23 @@ const AlbumPage = React.memo(function AlbumPage({ cardHeight: number; onPick: (themeName: string) => void; }) { - const renderWallpaper = useCallback( - ({ item: themeName }: { item: string }) => { - const entry = catalog.find((w) => w.themeName === themeName); - const selected = draftUnitTheme === themeName; - return ( - - onPick(themeName)} - /> - - ); - }, - [catalog, draftUnitTheme, cardWidth, cardHeight, onPick] - ); + const renderWallpaper = ({ item: themeName }: { item: string }) => { + const entry = catalog.find((w) => w.themeName === themeName); + const selected = draftUnitTheme === themeName; + return ( + + onPick(themeName)} + /> + + ); + }; return ( diff --git a/features/theme/screens/GalleryScreen.tsx b/features/theme/screens/GalleryScreen.tsx index d49f89107..7145906f4 100644 --- a/features/theme/screens/GalleryScreen.tsx +++ b/features/theme/screens/GalleryScreen.tsx @@ -63,14 +63,11 @@ export function GalleryScreen() { const cardWidth = Math.round(screenWidth * 0.44); const cardHeight = cardWidth * CARD_RATIO; - const handlePickAlbum = useCallback( - (slug: string) => { - log.info('theme.gallery.pick_album', { slug }); - setAlbum(slug, PREVIEW_UNIT_IDS); - router.back(); - }, - [setAlbum] - ); + const handlePickAlbum = (slug: string) => { + log.info('theme.gallery.pick_album', { slug }); + setAlbum(slug, PREVIEW_UNIT_IDS); + router.back(); + }; return ( <> @@ -132,6 +129,7 @@ function SectionHeader({ topic, author }: { topic: string; author: AlbumAuthor | diff --git a/features/transactions/components/CounterpartyTransactions.tsx b/features/transactions/components/CounterpartyTransactions.tsx index 4d72e4e97..ae10c0781 100644 --- a/features/transactions/components/CounterpartyTransactions.tsx +++ b/features/transactions/components/CounterpartyTransactions.tsx @@ -6,7 +6,7 @@ * embedded mode, filtered to every other transaction with the same pubkey. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { StyleSheet } from 'react-native'; import opacity from 'hex-color-opacity'; import { LinearGradient } from 'expo-linear-gradient'; @@ -43,13 +43,12 @@ export function CounterpartyTransactions({ pubkey, excludeId }: CounterpartyTran const name = resolveIdentityName({ pubkey, nostrProfile: metadata }); const ruleStrong = opacity(foreground, alpha.soft); const ruleFaint = opacity(foreground, alpha.faint); - const leftRuleColors = useMemo(() => [ruleFaint, ruleStrong] as const, [ruleFaint, ruleStrong]); - const rightRuleColors = useMemo(() => [ruleStrong, ruleFaint] as const, [ruleFaint, ruleStrong]); - const headingStyle = useMemo(() => [styles.heading, { color: ruleStrong }], [ruleStrong]); + const leftRuleColors = [ruleFaint, ruleStrong] as const; + const rightRuleColors = [ruleStrong, ruleFaint] as const; + const headingStyle = [styles.heading, { color: ruleStrong }]; - const related = useMemo( - () => history.filter((e) => getCounterparty(e)?.pubkey === pubkey && e.id !== excludeId), - [history, pubkey, excludeId] + const related = history.filter( + (e) => getCounterparty(e)?.pubkey === pubkey && e.id !== excludeId ); if (related.length === 0) return null; diff --git a/features/transactions/components/MonthSelector.tsx b/features/transactions/components/MonthSelector.tsx index e60045211..7eae0d623 100644 --- a/features/transactions/components/MonthSelector.tsx +++ b/features/transactions/components/MonthSelector.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef, useEffect } from 'react'; +import React, { useMemo, useRef, useEffect } from 'react'; import { ScrollView, LayoutChangeEvent } from 'react-native'; import { Text } from '@/shared/ui/primitives/Text'; import { HStack } from '@/shared/ui/primitives/View/HStack'; @@ -9,7 +9,7 @@ import { HistoryEntry } from '@cashu/coco-core'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { log, Log } from '@/shared/lib/logger'; -export interface MonthItem { +interface MonthItem { key: string; label: string; fullLabel: string; @@ -45,10 +45,10 @@ function MonthTab({ item, isSelected, onPress, showYear }: MonthTabProps) { 'surface-secondary', ] as const); - const handlePress = useCallback(() => { + const handlePress = () => { log.info('transaction.month.select', { monthKey: item.key, label: item.label }); onPress(item.key); - }, [item.key, item.label, onPress]); + }; return ( @@ -131,12 +131,9 @@ export function MonthSelector({ return years.size > 1; }, [months, showYearProp]); - const handleItemLayout = useCallback( - (monthKey: string) => (event: LayoutChangeEvent) => { - itemPositions.current.set(monthKey, event.nativeEvent.layout.x); - }, - [] - ); + const handleItemLayout = (monthKey: string) => (event: LayoutChangeEvent) => { + itemPositions.current.set(monthKey, event.nativeEvent.layout.x); + }; useEffect(() => { if (selectedMonth && scrollViewRef.current) { diff --git a/features/transactions/components/MonthlyChart.tsx b/features/transactions/components/MonthlyChart.tsx index 1d49ba293..93b365b6b 100644 --- a/features/transactions/components/MonthlyChart.tsx +++ b/features/transactions/components/MonthlyChart.tsx @@ -142,11 +142,11 @@ const MonthlyChart = React.memo(function MonthlyChart({ const config = MODE_CONFIG[mode]; - const borderColor = useMemo(() => opacity(muted, 0.3), [muted]); + const borderColor = opacity(muted, 0.3); const actualLineColor = mode === 'spent' ? dangerColor : successColor; - const projectedLineColor = useMemo(() => opacity(foreground, 0.3), [foreground]); - const labelColor = useMemo(() => opacity(foreground, 0.66), [foreground]); + const projectedLineColor = opacity(foreground, 0.3); + const labelColor = opacity(foreground, 0.66); // Use a unique gradient ID per mode to avoid SVG collisions when both charts render const gradientId = `monthlyGradient-${mode}`; @@ -192,7 +192,7 @@ const MonthlyChart = React.memo(function MonthlyChart({ if (entry.unit !== unit) return false; if (entry.createdAt < monthStart || entry.createdAt > monthEnd) return false; if (entry.type === 'mint' || entry.type === 'melt') { - const quoteId = (entry as any).quoteId as string | undefined; + const quoteId = (entry as { quoteId?: string }).quoteId; if (quoteId && quoteIdToGroup[quoteId]) return false; } return config.filter(entry); @@ -253,15 +253,11 @@ const MonthlyChart = React.memo(function MonthlyChart({ // Build SVG paths // --------------------------------------------------------------------------- - const actualPath = useMemo(() => buildSmoothPath(actualPoints), [actualPoints]); - const projectedPath = useMemo(() => buildSmoothPath(projectedPoints), [projectedPoints]); - const projectedAreaPath = useMemo( - () => - buildAreaPath( - [...actualPoints, ...projectedPoints.slice(1)], - CHART_PADDING_TOP + drawableHeight - ), - [actualPoints, projectedPoints, drawableHeight] + const actualPath = buildSmoothPath(actualPoints); + const projectedPath = buildSmoothPath(projectedPoints); + const projectedAreaPath = buildAreaPath( + [...actualPoints, ...projectedPoints.slice(1)], + CHART_PADDING_TOP + drawableHeight ); const xTickPositions = useMemo(() => { diff --git a/features/transactions/components/SwapTransactionRow.tsx b/features/transactions/components/SwapTransactionRow.tsx index 62f649ff7..7721947e6 100644 --- a/features/transactions/components/SwapTransactionRow.tsx +++ b/features/transactions/components/SwapTransactionRow.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo } from 'react'; import opacity from 'hex-color-opacity'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; @@ -27,7 +27,7 @@ export const SwapTransactionRow = React.memo(({ group }: Props) => { return { text: 'Completed', color: success }; }, [group.legs, group.state, foreground, danger, success]); - const handlePress = useCallback(() => { + const handlePress = () => { log.info('transaction.swap.press', { groupId: group.id, state: group.state, @@ -37,7 +37,7 @@ export const SwapTransactionRow = React.memo(({ group }: Props) => { pathname: '/swap', params: { groupId: group.id }, }); - }, [group.id, group.state, group.legs.length]); + }; return ( diff --git a/features/transactions/components/SwipeableRow.tsx b/features/transactions/components/SwipeableRow.tsx index 9833073d5..650bdfa24 100644 --- a/features/transactions/components/SwipeableRow.tsx +++ b/features/transactions/components/SwipeableRow.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { StyleSheet, View as RNView } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; @@ -55,41 +55,37 @@ export function SwipeableRow({ const translateX = useSharedValue(0); const armed = useSharedValue(0); - const pan = useMemo( - () => - Gesture.Pan() - // Activate only on right-leaning horizontal motion past 12 px; - // cede vertical drags > 8 px to the parent ScrollView. - .activeOffsetX([-9999, 12]) - .failOffsetY([-8, 8]) - .enabled(enabled) - .onUpdate((event) => { - 'worklet'; - const next = Math.max(0, event.translationX); - translateX.set(next); - const past = next >= COMMIT_PX ? 1 : 0; - if (past !== armed.get()) { - armed.set(past); - runOnJS(tickHaptic)(); - } - }) - .onEnd((event) => { - 'worklet'; - armed.set(0); - const past = event.translationX >= COMMIT_PX; - if (past) { - runOnJS(commitHaptic)(); - // Spring the row back to rest — the spinner takes over visual - // feedback while reclaim runs. The list-item exit animation - // fires once reclaim completes and the row leaves the bucket. - translateX.set(withSpring(0, SPRING)); - runOnJS(onCommit)(); - } else { - translateX.set(withSpring(0, SPRING)); - } - }), - [enabled, onCommit, translateX, armed] - ); + const pan = Gesture.Pan() + // Activate only on right-leaning horizontal motion past 12 px; + // cede vertical drags > 8 px to the parent ScrollView. + .activeOffsetX([-9999, 12]) + .failOffsetY([-8, 8]) + .enabled(enabled) + .onUpdate((event) => { + 'worklet'; + const next = Math.max(0, event.translationX); + translateX.set(next); + const past = next >= COMMIT_PX ? 1 : 0; + if (past !== armed.get()) { + armed.set(past); + runOnJS(tickHaptic)(); + } + }) + .onEnd((event) => { + 'worklet'; + armed.set(0); + const past = event.translationX >= COMMIT_PX; + if (past) { + runOnJS(commitHaptic)(); + // Spring the row back to rest — the spinner takes over visual + // feedback while reclaim runs. The list-item exit animation + // fires once reclaim completes and the row leaves the bucket. + translateX.set(withSpring(0, SPRING)); + runOnJS(onCommit)(); + } else { + translateX.set(withSpring(0, SPRING)); + } + }); const rowStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.get() }], diff --git a/features/transactions/components/Transaction.tsx b/features/transactions/components/Transaction.tsx index 265a02129..4f9a7b7e4 100644 --- a/features/transactions/components/Transaction.tsx +++ b/features/transactions/components/Transaction.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import Animated, { Easing, LinearTransition } from 'react-native-reanimated'; import { HistoryEntry, SendHistoryEntry } from '@cashu/coco-core'; @@ -119,7 +119,7 @@ const useTransactionRow = (historyEntry: HistoryEntry) => { { displayAs: 'usd' } ); - const handlePress = useCallback((): void => { + const handlePress = (): void => { log.debug('transaction.press', { type: historyEntry.type, id: historyEntry.id }); const serializedHistoryEntry = JSON.stringify(historyEntry); const entryState = @@ -193,7 +193,7 @@ const useTransactionRow = (historyEntry: HistoryEntry) => { return; } } - }, [historyEntry]); + }; return { isSend, diff --git a/features/transactions/components/TransactionLocationSection.tsx b/features/transactions/components/TransactionLocationSection.tsx index a8e59624f..bc31f3eb8 100644 --- a/features/transactions/components/TransactionLocationSection.tsx +++ b/features/transactions/components/TransactionLocationSection.tsx @@ -14,6 +14,7 @@ */ import React from 'react'; +import { INVARIANT_WHITE } from '@/shared/lib/brandColors'; import { Platform, StyleSheet } from 'react-native'; import { View } from '@/shared/ui/primitives/View/View'; import { VStack } from '@/shared/ui/primitives/View/VStack'; @@ -197,7 +198,7 @@ function TransactionLocationMap({ { id: 'transaction-location', coordinates: { latitude, longitude }, - tintColor: grayscale ? '#FFFFFF' : shade300, + tintColor: grayscale ? INVARIANT_WHITE : shade300, title: 'Transaction location', }, ]; diff --git a/features/transactions/components/Transactions.tsx b/features/transactions/components/Transactions.tsx index 321410724..deb6d2dd8 100644 --- a/features/transactions/components/Transactions.tsx +++ b/features/transactions/components/Transactions.tsx @@ -162,7 +162,7 @@ export const Transactions = React.memo( // its state flips to `rolledBack`, killing the animation mid-frame. const collapsing = useRollbackStore((s) => s.collapsing); - const borderColor = useMemo(() => opacity(muted, 0.3), [muted]); + const borderColor = opacity(muted, 0.3); const swapGroupsById = useSwapTransactionsStore((state) => state.groups); const swapGroups = useMemo(() => { @@ -260,12 +260,9 @@ export const Transactions = React.memo( return date.getFullYear() === filterYear && date.getMonth() === filterMonthNum; }; - const swapItems: TimelineItem[] = swapGroups - .filter((group) => monthFilter(group.createdAt)) - .map((group) => ({ - kind: 'swap' as const, - data: group, - })); + const swapItems: TimelineItem[] = swapGroups.flatMap((group) => + monthFilter(group.createdAt) ? [{ kind: 'swap' as const, data: group }] : [] + ); return [...txItems, ...swapItems]; }, [filteredHistory, swapGroups, filter, type, selectedMonth, embedded]); @@ -275,23 +272,19 @@ export const Transactions = React.memo( [timelineItems] ); - const { pending, confirmed, expired } = useMemo( - () => - groupBy(sortedTimeline, (item: TimelineItem) => { - // Swap items are always "confirmed" - if (item.kind === 'swap') return 'confirmed'; - - const historyEntry = item.data; - const isCollapsingGhost = - historyEntry.type === 'send' && - collapsing.has((historyEntry as SendHistoryEntry).operationId); - - // Single colada classifier: handles expired mint quotes, pending - // sends, and unredeemed (executing) receives in one place. - return bucketTransaction(historyEntry, { isCollapsingGhost }); - }), - [sortedTimeline, collapsing] - ); + const { pending, confirmed, expired } = groupBy(sortedTimeline, (item: TimelineItem) => { + // Swap items are always "confirmed" + if (item.kind === 'swap') return 'confirmed'; + + const historyEntry = item.data; + const isCollapsingGhost = + historyEntry.type === 'send' && + collapsing.has((historyEntry as SendHistoryEntry).operationId); + + // Single colada classifier: handles expired mint quotes, pending + // sends, and unredeemed (executing) receives in one place. + return bucketTransaction(historyEntry, { isCollapsingGhost }); + }); const sections = useMemo(() => { const t0 = performance.now(); @@ -426,66 +419,52 @@ export const Transactions = React.memo( [onCancelPendingEcash, onTransactionPress] ); - const renderSection = useCallback( - ({ item: section }: { item: Section; index?: number }) => ( - - - {section.title} - - - - - {section.data.map((item, rowIndex) => renderTimelineItem(item, rowIndex))} - - - - - ), - [borderColor, foreground, muted, renderTimelineItem] - ); - - const resolvedHeader = useMemo( - () => {typeof header === 'function' ? header() : header}, - [header] + const renderSection = ({ item: section }: { item: Section; index?: number }) => ( + + + {section.title} + + + + + {section.data.map((item, rowIndex) => renderTimelineItem(item, rowIndex))} + + + + ); - const emptyComponent = useMemo( - () => - // While the first page is in flight the list is empty, so this renders - // in place of the rows. Match the feed's loading affordance (a centered - // spinner) instead of flashing the "No transactions found" card, which - // reads as "you have none" when we simply haven't loaded yet. - isFetching ? ( - - ) : ( - - - - - - - No transactions found - - - Try adjusting your filters or check back later - - - + const resolvedHeader = {typeof header === 'function' ? header() : header}; + + const emptyComponent = isFetching ? ( + + ) : ( + + + + + + + No transactions found + + + Try adjusting your filters or check back later + - - ), - [borderColor, foreground, isFetching, muted] + + + ); if (embedded) { diff --git a/features/transactions/components/TransactionsFilterContext.tsx b/features/transactions/components/TransactionsFilterContext.tsx index c9257232e..1673466c5 100644 --- a/features/transactions/components/TransactionsFilterContext.tsx +++ b/features/transactions/components/TransactionsFilterContext.tsx @@ -5,7 +5,7 @@ * and the TransactionsScreen component. */ -import React, { createContext, useContext, useState, useCallback, useMemo, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useMemo, ReactNode } from 'react'; import type { ScanMethod, TransactionDirection, @@ -78,7 +78,7 @@ export function TransactionsFilterProvider({ const [counterparty, setCounterparty] = useState('all'); const [selectedMonth, setSelectedMonth] = useState(null); - const openFilterSheet = useCallback(() => { + const openFilterSheet = () => { cashuLog.info('transactions.filters.open', { currency, paymentType, @@ -100,29 +100,16 @@ export function TransactionsFilterProvider({ counterparty, }, }); - }, [ - currency, - paymentType, - direction, - status, - mintUrl, - source, - lock, - counterparty, - selectedMonth, - ]); + }; - const hasActiveFilters = useMemo(() => { - return ( - paymentType !== 'all' || - direction !== 'all' || - status !== 'All' || - mintUrl !== 'all' || - source !== 'all' || - lock !== 'all' || - counterparty !== 'all' - ); - }, [paymentType, direction, status, mintUrl, source, lock, counterparty]); + const hasActiveFilters = + paymentType !== 'all' || + direction !== 'all' || + status !== 'All' || + mintUrl !== 'all' || + source !== 'all' || + lock !== 'all' || + counterparty !== 'all'; const activeFilterCount = useMemo(() => { let count = 0; @@ -136,45 +123,29 @@ export function TransactionsFilterProvider({ return count; }, [paymentType, direction, status, mintUrl, source, lock, counterparty]); - const value = useMemo( - () => ({ - currency, - paymentType, - direction, - status, - mintUrl, - source, - lock, - counterparty, - selectedMonth, - setCurrency, - setPaymentType, - setDirection, - setStatus, - setMintUrl, - setSource, - setLock, - setCounterparty, - setSelectedMonth, - openFilterSheet, - hasActiveFilters, - activeFilterCount, - }), - [ - currency, - paymentType, - direction, - status, - mintUrl, - source, - lock, - counterparty, - selectedMonth, - openFilterSheet, - hasActiveFilters, - activeFilterCount, - ] - ); + const value = { + currency, + paymentType, + direction, + status, + mintUrl, + source, + lock, + counterparty, + selectedMonth, + setCurrency, + setPaymentType, + setDirection, + setStatus, + setMintUrl, + setSource, + setLock, + setCounterparty, + setSelectedMonth, + openFilterSheet, + hasActiveFilters, + activeFilterCount, + }; return ( diff --git a/features/transactions/components/detail/HistoryEntryTimeline.tsx b/features/transactions/components/detail/HistoryEntryTimeline.tsx index fb94bd058..efa1667dd 100644 --- a/features/transactions/components/detail/HistoryEntryTimeline.tsx +++ b/features/transactions/components/detail/HistoryEntryTimeline.tsx @@ -157,6 +157,28 @@ function timelineStepTypeToCheckpointStatus(stepType: TimelineStepType): Checkpo return stepType === 'expired' ? 'failed' : stepType; } +const getLineType = (currentItem: TimelineItem, nextItem: TimelineItem): TimelineLineType => { + if (nextItem.stepType === 'expired') return 'expired-gradient'; + if (nextItem.stepType === 'already-spent') return 'rolled-back-gradient'; + if (nextItem.stepType === 'rolled-back') return 'rolled-back-gradient'; + if ( + currentItem.stepType === 'complete' || + currentItem.stepType === 'current' || + currentItem.stepType === 'waiting' || + currentItem.stepType === 'success' + ) { + if ( + nextItem.stepType === 'complete' || + nextItem.stepType === 'current' || + nextItem.stepType === 'waiting' || + nextItem.stepType === 'success' + ) { + return 'complete'; + } + } + return 'future'; +}; + export function HistoryEntryTimeline({ historyEntry, meltQuote, @@ -291,28 +313,6 @@ export function HistoryEntryTimeline({ const expiryBadge = getExpiryBadge(); - const getLineType = (currentItem: TimelineItem, nextItem: TimelineItem): TimelineLineType => { - if (nextItem.stepType === 'expired') return 'expired-gradient'; - if (nextItem.stepType === 'already-spent') return 'rolled-back-gradient'; - if (nextItem.stepType === 'rolled-back') return 'rolled-back-gradient'; - if ( - currentItem.stepType === 'complete' || - currentItem.stepType === 'current' || - currentItem.stepType === 'waiting' || - currentItem.stepType === 'success' - ) { - if ( - nextItem.stepType === 'complete' || - nextItem.stepType === 'current' || - nextItem.stepType === 'waiting' || - nextItem.stepType === 'success' - ) { - return 'complete'; - } - } - return 'future'; - }; - const getStatusHeaderColor = () => { switch (statusColorType) { case 'success': diff --git a/features/transactions/components/detail/TransactionSourceSection.tsx b/features/transactions/components/detail/TransactionSourceSection.tsx index 2815d7c3a..f476b7f9d 100644 --- a/features/transactions/components/detail/TransactionSourceSection.tsx +++ b/features/transactions/components/detail/TransactionSourceSection.tsx @@ -5,15 +5,6 @@ import { HStack } from '@/shared/ui/primitives/View/HStack'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { Log } from '@/shared/lib/logger'; import { useColadaTransactionAnnotation } from '@sovranbitcoin/colada/react'; -import type { ScanMethod } from '@sovranbitcoin/colada'; - -const SOURCE_LABELS: Record = { - qr: 'QR Code', - nfc: 'NFC', - paste: 'Clipboard', - deeplink: 'Deep Link', - ble: 'Bluetooth', -}; function isLightningKind(k: string) { return k === 'lightningInvoice' || k === 'lightningAddress' || k === 'lnurlp'; @@ -27,16 +18,6 @@ function isOnchainKind(k: string) { return k === 'onchainAddress'; } -/** - * Returns the human-readable source label for a transaction, or null if none is linked. - * Intended for use as a row in DetailsSection. - */ -export function useTransactionSource(transactionId: string | undefined): string | null { - const annotation = useColadaTransactionAnnotation(transactionId ? { id: transactionId } : null); - const method = annotation.scan?.method; - return method ? SOURCE_LABELS[method] : null; -} - /** * Returns BIP321 metadata for a transaction's DetailsSection. */ diff --git a/features/transactions/hooks/useHistoryWithMelts.ts b/features/transactions/hooks/useHistoryWithMelts.ts index bf845c7d2..adaa34f54 100644 --- a/features/transactions/hooks/useHistoryWithMelts.ts +++ b/features/transactions/hooks/useHistoryWithMelts.ts @@ -1,4 +1,3 @@ -import { useMemo } from 'react'; import { useColadaTransactions } from '@sovranbitcoin/colada/react'; import { useSettingsStore } from '@/shared/stores/global/settingsStore'; import { useMockDataStore } from '@/shared/stores/runtime/mockDataStore'; @@ -18,5 +17,8 @@ export function useHistoryWithMelts(pageSize = 100) { const history = mockMode ? mockHistory : result.history; - return useMemo(() => ({ ...result, history }), [result, history]); + return { + ...result, + history, + }; } diff --git a/features/transactions/index.ts b/features/transactions/index.ts index bf49870c2..90142d540 100644 --- a/features/transactions/index.ts +++ b/features/transactions/index.ts @@ -4,27 +4,15 @@ export { TransactionsScreen } from './screens/TransactionsScreen'; export { SwapTransactionScreen } from './screens/SwapTransactionScreen'; export { FiltersScreen } from './screens/FiltersScreen'; export { Transactions } from './components/Transactions'; -export { Transaction } from './components/Transaction'; -export { SwapTransactionRow } from './components/SwapTransactionRow'; -export { default as TransactionIcon } from './components/TransactionIcon'; -export { MonthSelector } from './components/MonthSelector'; export { ReceivedThisMonth, SpentThisMonth } from './components/MonthlyChart'; export { TransactionLocationSection } from './components/TransactionLocationSection'; export { HistoryEntryHeader } from './components/detail/HistoryEntryHeader'; export { HistoryEntryRefresh } from './components/detail/HistoryEntryRefresh'; export { HistoryEntryTimeline } from './components/detail/HistoryEntryTimeline'; export { TransactionDetailShell } from './components/detail/TransactionDetailShell'; -export { - useTransactionSource, - useBip321Info, - Bip321MethodIcons, -} from './components/detail/TransactionSourceSection'; +export { useBip321Info, Bip321MethodIcons } from './components/detail/TransactionSourceSection'; export { TransactionsFilterProvider, useTransactionsFilter, } from './components/TransactionsFilterContext'; -export { - getTransactionActionDirection, - getTransactionActionLabel, -} from './lib/transactionPresentation'; export { useHistoryWithMelts } from './hooks/useHistoryWithMelts'; diff --git a/features/transactions/lib/transactionPresentation.ts b/features/transactions/lib/transactionPresentation.ts index 7f9929d49..90bb1c107 100644 --- a/features/transactions/lib/transactionPresentation.ts +++ b/features/transactions/lib/transactionPresentation.ts @@ -1,7 +1,7 @@ import type { HistoryEntry } from '@cashu/coco-core'; -export type TransactionActionDirection = 'send' | 'receive'; -export type TransactionActionLabel = 'Send' | 'Receive'; +type TransactionActionDirection = 'send' | 'receive'; +type TransactionActionLabel = 'Send' | 'Receive'; const TRANSACTION_ACTION_DIRECTION_BY_TYPE = { melt: 'send', diff --git a/features/transactions/screens/FiltersScreen.tsx b/features/transactions/screens/FiltersScreen.tsx index 2a79a73ca..26a155b9e 100644 --- a/features/transactions/screens/FiltersScreen.tsx +++ b/features/transactions/screens/FiltersScreen.tsx @@ -3,7 +3,7 @@ * mint, and the annotation-driven filters (source, P2PK lock, counterparty). */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { ScrollView, StyleSheet, View } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; @@ -192,19 +192,16 @@ export function FiltersScreen() { params?.counterparty || 'all' ); - const mintOptions = useMemo( - () => [ - { mintUrl: 'all', name: 'All Mints', icon_url: undefined as string | undefined }, - ...trustedMints.map((mint) => ({ - mintUrl: mint.mintUrl, - name: mint.mintInfo?.name || extractDomain(mint.mintUrl) || 'Unknown', - icon_url: mint.mintInfo?.icon_url, - })), - ], - [trustedMints] - ); + const mintOptions = [ + { mintUrl: 'all', name: 'All Mints', icon_url: undefined as string | undefined }, + ...trustedMints.map((mint) => ({ + mintUrl: mint.mintUrl, + name: mint.mintInfo?.name || extractDomain(mint.mintUrl) || 'Unknown', + icon_url: mint.mintInfo?.icon_url, + })), + ]; - const handleApply = useCallback(() => { + const handleApply = () => { log.info('tx.filters.apply', { currency, paymentType, @@ -228,9 +225,9 @@ export function FiltersScreen() { filterCounterparty: counterparty, }, }); - }, [currency, paymentType, direction, status, mintUrl, source, lock, counterparty]); + }; - const handleReset = useCallback(() => { + const handleReset = () => { log.info('tx.filters.reset'); setCurrency('sat'); setPaymentType('all'); @@ -240,20 +237,17 @@ export function FiltersScreen() { setSource('all'); setLock('all'); setCounterparty('all'); - }, []); - - const hasActiveFilters = useMemo( - () => - currency.toLowerCase() !== 'sat' || - paymentType !== 'all' || - direction !== 'all' || - status !== 'All' || - mintUrl !== 'all' || - source !== 'all' || - lock !== 'all' || - counterparty !== 'all', - [currency, paymentType, direction, status, mintUrl, source, lock, counterparty] - ); + }; + + const hasActiveFilters = + currency.toLowerCase() !== 'sat' || + paymentType !== 'all' || + direction !== 'all' || + status !== 'All' || + mintUrl !== 'all' || + source !== 'all' || + lock !== 'all' || + counterparty !== 'all'; const resultCount = useMemo(() => { const normalizedCurrency = currency.toLowerCase(); diff --git a/features/transactions/screens/SwapTransactionScreen.tsx b/features/transactions/screens/SwapTransactionScreen.tsx index 75a4d92b8..2ff9a57b0 100644 --- a/features/transactions/screens/SwapTransactionScreen.tsx +++ b/features/transactions/screens/SwapTransactionScreen.tsx @@ -12,7 +12,8 @@ * Consecutive entries on the same mint omit the redundant arrow separator. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { INVARIANT_WHITE } from '@/shared/lib/brandColors'; import { StyleSheet } from 'react-native'; import { MeltQuoteState } from '@cashu/cashu-ts'; import Animated, { @@ -86,7 +87,7 @@ function groupLegs(legs: SwapLeg[]): LegGroup[] { if (leg.chainId) { // Try to append to the last group if it shares the same chainId const last = groups[groups.length - 1]; - if (last && last.chainId === leg.chainId) { + if (last?.chainId === leg.chainId) { last.legs.push(leg); continue; } @@ -211,7 +212,7 @@ const CollapsedLegGroup = React.memo(({ legGroup, mintInfoMap }: CollapsedLegGro - + @@ -249,7 +250,7 @@ export function SwapTransactionScreen({ groupId }: Props) { const [expanded, setExpanded] = useState(false); const chevronRotation = useSharedValue(0); - const toggleExpanded = useCallback(() => { + const toggleExpanded = () => { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); log.debug('tx.swap.toggle_expanded', { groupId }); setExpanded((prev) => { @@ -259,7 +260,7 @@ export function SwapTransactionScreen({ groupId }: Props) { }); return !prev; }); - }, [chevronRotation]); + }; const chevronAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${chevronRotation.value}deg` }], @@ -290,7 +291,9 @@ export function SwapTransactionScreen({ groupId }: Props) { for (const url of mintUrls) { try { const info = await getMintInfo(url); - map[url] = info ? { name: info.name, icon_url: (info as any).icon_url } : null; + map[url] = info + ? { name: info.name, icon_url: (info as { icon_url?: string }).icon_url } + : null; } catch { map[url] = null; } @@ -307,7 +310,7 @@ export function SwapTransactionScreen({ groupId }: Props) { const map = new Map(); for (const entry of history) { if (entry.type !== 'mint' && entry.type !== 'melt') continue; - const quoteId = (entry as any).quoteId as string | undefined; + const quoteId = (entry as { quoteId?: string }).quoteId; if (!quoteId) continue; map.set(quoteId, entry); } @@ -509,8 +512,7 @@ export function SwapTransactionScreen({ groupId }: Props) { // Skip the separator between chained legs when the previous // leg's destination is the same mint as this leg's source const prevLeg = legIdx > 0 ? legGroup.legs[legIdx - 1] : null; - const sameMintAsPrev = - prevLeg != null && prevLeg.toMintUrl === leg.fromMintUrl; + const sameMintAsPrev = prevLeg?.toMintUrl === leg.fromMintUrl; return ( diff --git a/features/transactions/screens/TransactionsScreen.tsx b/features/transactions/screens/TransactionsScreen.tsx index cef28d2d0..bad3774a5 100644 --- a/features/transactions/screens/TransactionsScreen.tsx +++ b/features/transactions/screens/TransactionsScreen.tsx @@ -44,18 +44,12 @@ import type { TransactionLockFilter, TransactionCounterpartyFilter, } from '../components/TransactionsFilterContext'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; type StatusTab = 'All' | 'Confirmed' | 'Pending' | 'Expired'; const MONTH_SELECTOR_HEIGHT = 48; -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - interface TransactionsScreenProps { initialTab?: StatusTab; /** Called when a transaction is tapped - used for flow-aware navigation */ @@ -124,21 +118,18 @@ export function TransactionsScreen({ [manager] ); - const handleCancelOne = useCallback( - async (entry: SendHistoryEntry) => { - if (useRollbackStore.getState().inFlight.has(entry.operationId)) return; - if (isOffline) { - staticPopup('cancel-transaction-offline'); - return; - } - log.info('transactions.pending.cancel.one', { - operationId: entry.operationId, - ...mintUrlLogFields(entry.mintUrl), - }); - await reclaimOne(entry.operationId); - }, - [isOffline, reclaimOne] - ); + const handleCancelOne = async (entry: SendHistoryEntry) => { + if (useRollbackStore.getState().inFlight.has(entry.operationId)) return; + if (isOffline) { + staticPopup('cancel-transaction-offline'); + return; + } + log.info('transactions.pending.cancel.one', { + operationId: entry.operationId, + ...mintUrlLogFields(entry.mintUrl), + }); + await reclaimOne(entry.operationId); + }; const handleSweepVisible = useCallback(async () => { if (isSweeping || visiblePendingEcash.length === 0) return; @@ -233,30 +224,17 @@ export function TransactionsScreen({ [handleMonthChange, months] ); - const handlePageSelected = useCallback( - (event: { nativeEvent: { position: number } }) => { - const idx = event.nativeEvent.position; - const next = months[idx]; - if (next) handleMonthChange(next.key); - }, - [months, handleMonthChange] - ); + const handlePageSelected = (event: { nativeEvent: { position: number } }) => { + const idx = event.nativeEvent.position; + const next = months[idx]; + if (next) handleMonthChange(next.key); + }; - const monthSelectorContent = useMemo( - () => ( - - ), - [months, selectedMonth, handlePillSelect] + const monthSelectorContent = ( + ); - const listHeader = useMemo( - () => , - [totalHeaderHeight] - ); + const listHeader = ; // Footer: cancel-all-visible button. Only on the Pending tab — surfacing // a sweep action while the user browses 'All' (mostly historical) mixes diff --git a/features/user/index.ts b/features/user/index.ts index 182d5741f..22d92c12e 100644 --- a/features/user/index.ts +++ b/features/user/index.ts @@ -1,5 +1,4 @@ // user feature barrel export { UserProfileScreen } from './screens/UserProfileScreen'; -export { UserMessagesScreen } from './screens/UserMessagesScreen'; export { ShareScreen, SHARE_CONFIGS, type ShareType } from './screens/ShareScreen'; diff --git a/features/user/screens/ShareRoute.tsx b/features/user/screens/ShareRoute.tsx index 02bf975f2..0d7f28e21 100644 --- a/features/user/screens/ShareRoute.tsx +++ b/features/user/screens/ShareRoute.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import { Stack } from 'expo-router'; import { z } from 'zod'; @@ -36,7 +36,7 @@ export default function ShareRoute() { const [headerTitle, setHeaderTitle] = useState( SHARE_CONFIGS[type]?.title ?? 'Share Profile' ); - const handleTitleChange = useCallback((title: string) => setHeaderTitle(title), []); + const handleTitleChange = (title: string) => setHeaderTitle(title); if (!parsed) return null; diff --git a/features/user/screens/ShareScreen.tsx b/features/user/screens/ShareScreen.tsx index c8c1e6e34..c3048da7f 100644 --- a/features/user/screens/ShareScreen.tsx +++ b/features/user/screens/ShareScreen.tsx @@ -5,7 +5,7 @@ * via QR code. Can be used standalone or within flow navigators. */ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import { PaymentInfo } from '@/shared/blocks/PaymentInfo'; import { Section } from '@/shared/ui/composed/Section'; import Icon, { CurrencyIcon } from 'assets/icons'; @@ -91,7 +91,7 @@ export function ShareScreen({ type, data, npub, lud16, onTitleChange }: ShareScr const [selectedTab, setSelectedTab] = useState(showP2pkTabs ? 'P2PK' : 'NPUB'); // Get the config based on current selection - const getActiveConfig = useCallback(() => { + const getActiveConfig = () => { if (showP2pkTabs && selectedTab === 'NPUB') { return SHARE_CONFIGS.npub; } @@ -99,10 +99,10 @@ export function ShareScreen({ type, data, npub, lud16, onTitleChange }: ShareScr return SHARE_CONFIGS.lud16; } return SHARE_CONFIGS[type]; - }, [showP2pkTabs, showNpubTabs, selectedTab, type]); + }; // Get the active data based on current selection - const getActiveData = useCallback(() => { + const getActiveData = () => { if (showP2pkTabs && selectedTab === 'NPUB') { return npub || ''; } @@ -110,38 +110,35 @@ export function ShareScreen({ type, data, npub, lud16, onTitleChange }: ShareScr return lud16 || ''; } return data; - }, [showP2pkTabs, showNpubTabs, selectedTab, npub, lud16, data]); + }; const config = getActiveConfig(); const activeData = getActiveData(); - const handleTabPress = useCallback( - (tab: string) => { - nostrLog.info('share.tab.change', { tab }); - setSelectedTab(tab); - // Notify parent of title change - if (onTitleChange) { - let newConfig; - if (tab === 'NPUB') { - newConfig = SHARE_CONFIGS.npub; - } else if (tab === 'LIGHTNING') { - newConfig = SHARE_CONFIGS.lud16; - } else if (tab === 'P2PK') { - newConfig = SHARE_CONFIGS.p2pk; - } else { - newConfig = SHARE_CONFIGS[type]; - } - onTitleChange(newConfig.title); + const handleTabPress = (tab: string) => { + nostrLog.info('share.tab.change', { tab }); + setSelectedTab(tab); + // Notify parent of title change + if (onTitleChange) { + let newConfig; + if (tab === 'NPUB') { + newConfig = SHARE_CONFIGS.npub; + } else if (tab === 'LIGHTNING') { + newConfig = SHARE_CONFIGS.lud16; + } else if (tab === 'P2PK') { + newConfig = SHARE_CONFIGS.p2pk; + } else { + newConfig = SHARE_CONFIGS[type]; } - }, - [onTitleChange, type] - ); + onTitleChange(newConfig.title); + } + }; - const handleCopy = useCallback(async () => { + const handleCopy = async () => { nostrLog.info('share.copy', { target: config.copyTarget }); await Clipboard.setStringAsync(activeData); copyPopup(config.copyTarget); - }, [activeData, config.copyTarget]); + }; return ( diff --git a/features/user/screens/UserMessagesScreen.tsx b/features/user/screens/UserMessagesScreen.tsx index fdd74a637..f081831ff 100644 --- a/features/user/screens/UserMessagesScreen.tsx +++ b/features/user/screens/UserMessagesScreen.tsx @@ -10,7 +10,7 @@ * boundary. Legacy NIP-04 conversations are no longer surfaced. */ -import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'; +import React, { useMemo, useState, useEffect, useRef } from 'react'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; import { useFocusEffect } from '@react-navigation/native'; import { staticPopup } from '@/shared/lib/popup'; @@ -128,16 +128,14 @@ export function UserMessagesScreen({ // next index, not via a live sub). Skip the first focus — the hook already // fetched on mount — and only re-fetch on RE-focus. const focusedOnceRef = useRef(false); - useFocusEffect( - useCallback(() => { - if (isMockThread) return; - if (!focusedOnceRef.current) { - focusedOnceRef.current = true; - return; - } - refresh(); - }, [isMockThread, refresh]) - ); + useFocusEffect(() => { + if (isMockThread) return; + if (!focusedOnceRef.current) { + focusedOnceRef.current = true; + return; + } + refresh(); + }); // Merge server history with optimistic echoes (deduped by id). Once nagg // returns the self-copy of an echoed message (same wrap id), the echo drops. @@ -180,137 +178,78 @@ export function UserMessagesScreen({ [messages, displayName] ); - const counterpartyAvatar = useMemo( - () => ( - - ), - [userPicture, pubkey, displayName] + const counterpartyAvatar = ( + ); - const handleBack = useCallback(() => { + const handleBack = () => { if (onBack) { onBack(); } else { router.back(); } - }, [onBack]); - - const handleNostrDMSend = useCallback( - async (text: string) => { - // Mock thread: append locally and stop. Publishing here would broadcast - // actual DMs to the real npubs seeded as demo contacts. - if (isMockThread) { - const timestamp = Math.floor(Date.now() / 1000); - setLocalMessages((prev) => [ - ...prev, - { - id: `demo-dm-local-${timestamp}`, - content: text, - isOwn: true, - created_at: timestamp, - pubkey: '', - }, - ]); - return; - } - const dmStart = performance.now(); - log.info('dm.send.start', { - messageLength: text.length, + }; + + const handleNostrDMSend = async (text: string) => { + // Mock thread: append locally and stop. Publishing here would broadcast + // actual DMs to the real npubs seeded as demo contacts. + if (isMockThread) { + const timestamp = Math.floor(Date.now() / 1000); + setLocalMessages((prev) => [ + ...prev, + { + id: `demo-dm-local-${timestamp}`, + content: text, + isOwn: true, + created_at: timestamp, + pubkey: '', + }, + ]); + return; + } + const dmStart = performance.now(); + log.info('dm.send.start', { + messageLength: text.length, + hasNdk: !!ndk, + hasPubkey: !!pubkey, + }); + const myPubkey = nostrKeys?.pubkey; + const myPrivateKey = nostrKeys?.privateKey; + if (!ndk || !myPrivateKey || !myPubkey || !pubkey) { + log.error('dm.send.missing_data', { hasNdk: !!ndk, + hasPrivateKey: !!myPrivateKey, hasPubkey: !!pubkey, }); - const myPubkey = nostrKeys?.pubkey; - const myPrivateKey = nostrKeys?.privateKey; - if (!ndk || !myPrivateKey || !myPubkey || !pubkey) { - log.error('dm.send.missing_data', { - hasNdk: !!ndk, - hasPrivateKey: !!myPrivateKey, - hasPubkey: !!pubkey, - }); - staticPopup('send-message-failed'); - return; - } + staticPopup('send-message-failed'); + return; + } - const timestamp = Math.floor(Date.now() / 1000); + const timestamp = Math.floor(Date.now() / 1000); - // NIP-04 (legacy): a single signed kind-4 event authored by us and - // addressed to the recipient via a `p` tag. No gift wrap / self-copy — - // nagg re-fetches our sent copy via the `authors` filter, so the echo - // dedups on its event id. - if (protocol === 'nip04') { - let nip04EchoId: string | undefined; - try { - const dm = buildNip04DM({ - content: text, - senderPrivateKey: myPrivateKey, - recipientPublicKey: pubkey, - }); - nip04EchoId = dm.id; - setLocalMessages((prev) => [ - ...prev, - { - id: dm.id, - content: text, - isOwn: true, - isSending: true, - created_at: timestamp, - pubkey: myPubkey, - }, - ]); - - const event = new NDKEvent(ndk); - event.kind = dm.kind; - event.content = dm.content; - event.tags = dm.tags; - event.created_at = dm.created_at; - event.pubkey = dm.pubkey; - event.id = dm.id; - event.sig = dm.sig; - await event.publish(); - - log.info('dm.send.complete', { - eventId: dm.id, - protocol: 'nip04', - total_ms: Math.round(performance.now() - dmStart), - }); - setLocalMessages((prev) => - prev.map((msg) => (msg.id === nip04EchoId ? { ...msg, isSending: false } : msg)) - ); - } catch (error) { - log.error('dm.send.failed', { - error, - protocol: 'nip04', - total_ms: Math.round(performance.now() - dmStart), - }); - setLocalMessages((prev) => prev.filter((msg) => msg.id !== nip04EchoId)); - staticPopup('send-message-failed'); - } - return; - } - - let echoId: string | undefined; + // NIP-04 (legacy): a single signed kind-4 event authored by us and + // addressed to the recipient via a `p` tag. No gift wrap / self-copy — + // nagg re-fetches our sent copy via the `authors` filter, so the echo + // dedups on its event id. + if (protocol === 'nip04') { + let nip04EchoId: string | undefined; try { - // Build NIP-17 gift-wrapped DM pair: one for the recipient, one self-copy. - const { recipientWrap, senderWrap } = buildGiftWrappedDMPair({ + const dm = buildNip04DM({ content: text, senderPrivateKey: myPrivateKey, recipientPublicKey: pubkey, }); - - // Optimistic echo keyed on the SELF-COPY wrap id — that's the id nagg - // returns for our own sent message (the self-copy lands in our inbox), - // so the server copy dedups this echo on the next fetch with no double. - echoId = senderWrap.id; + nip04EchoId = dm.id; setLocalMessages((prev) => [ ...prev, { - id: senderWrap.id, + id: dm.id, content: text, isOwn: true, isSending: true, @@ -319,54 +258,107 @@ export function UserMessagesScreen({ }, ]); - const wrapEvent = new NDKEvent(ndk); - wrapEvent.kind = recipientWrap.kind; - wrapEvent.content = recipientWrap.content; - wrapEvent.tags = recipientWrap.tags; - wrapEvent.created_at = recipientWrap.created_at; - wrapEvent.pubkey = recipientWrap.pubkey; - wrapEvent.id = recipientWrap.id; - wrapEvent.sig = recipientWrap.sig; - - await wrapEvent.publish(); - - log.info('dm.send.published', { - eventId: wrapEvent.id, - duration_ms: Math.round(performance.now() - dmStart), - }); - - // Publish the self-copy so we can retrieve our own sent messages later. - const selfWrapEvent = new NDKEvent(ndk); - selfWrapEvent.kind = senderWrap.kind; - selfWrapEvent.content = senderWrap.content; - selfWrapEvent.tags = senderWrap.tags; - selfWrapEvent.created_at = senderWrap.created_at; - selfWrapEvent.pubkey = senderWrap.pubkey; - selfWrapEvent.id = senderWrap.id; - selfWrapEvent.sig = senderWrap.sig; - - await selfWrapEvent.publish().catch((err: unknown) => { - log.warn('dm.send.self_copy_failed', { error: err }); - }); + const event = new NDKEvent(ndk); + event.kind = dm.kind; + event.content = dm.content; + event.tags = dm.tags; + event.created_at = dm.created_at; + event.pubkey = dm.pubkey; + event.id = dm.id; + event.sig = dm.sig; + await event.publish(); log.info('dm.send.complete', { - eventId: wrapEvent.id, + eventId: dm.id, + protocol: 'nip04', total_ms: Math.round(performance.now() - dmStart), }); - setLocalMessages((prev) => - prev.map((msg) => (msg.id === echoId ? { ...msg, isSending: false } : msg)) + prev.map((msg) => (msg.id === nip04EchoId ? { ...msg, isSending: false } : msg)) ); } catch (error) { - log.error('dm.send.failed', { error, total_ms: Math.round(performance.now() - dmStart) }); - setLocalMessages((prev) => prev.filter((msg) => msg.id !== echoId)); + log.error('dm.send.failed', { + error, + protocol: 'nip04', + total_ms: Math.round(performance.now() - dmStart), + }); + setLocalMessages((prev) => prev.filter((msg) => msg.id !== nip04EchoId)); staticPopup('send-message-failed'); } - }, - [ndk, nostrKeys?.privateKey, nostrKeys?.pubkey, pubkey, isMockThread, protocol] - ); + return; + } + + let echoId: string | undefined; + try { + // Build NIP-17 gift-wrapped DM pair: one for the recipient, one self-copy. + const { recipientWrap, senderWrap } = buildGiftWrappedDMPair({ + content: text, + senderPrivateKey: myPrivateKey, + recipientPublicKey: pubkey, + }); + + // Optimistic echo keyed on the SELF-COPY wrap id — that's the id nagg + // returns for our own sent message (the self-copy lands in our inbox), + // so the server copy dedups this echo on the next fetch with no double. + echoId = senderWrap.id; + setLocalMessages((prev) => [ + ...prev, + { + id: senderWrap.id, + content: text, + isOwn: true, + isSending: true, + created_at: timestamp, + pubkey: myPubkey, + }, + ]); + + const wrapEvent = new NDKEvent(ndk); + wrapEvent.kind = recipientWrap.kind; + wrapEvent.content = recipientWrap.content; + wrapEvent.tags = recipientWrap.tags; + wrapEvent.created_at = recipientWrap.created_at; + wrapEvent.pubkey = recipientWrap.pubkey; + wrapEvent.id = recipientWrap.id; + wrapEvent.sig = recipientWrap.sig; + + await wrapEvent.publish(); + + log.info('dm.send.published', { + eventId: wrapEvent.id, + duration_ms: Math.round(performance.now() - dmStart), + }); + + // Publish the self-copy so we can retrieve our own sent messages later. + const selfWrapEvent = new NDKEvent(ndk); + selfWrapEvent.kind = senderWrap.kind; + selfWrapEvent.content = senderWrap.content; + selfWrapEvent.tags = senderWrap.tags; + selfWrapEvent.created_at = senderWrap.created_at; + selfWrapEvent.pubkey = senderWrap.pubkey; + selfWrapEvent.id = senderWrap.id; + selfWrapEvent.sig = senderWrap.sig; + + await selfWrapEvent.publish().catch((err: unknown) => { + log.warn('dm.send.self_copy_failed', { error: err }); + }); + + log.info('dm.send.complete', { + eventId: wrapEvent.id, + total_ms: Math.round(performance.now() - dmStart), + }); + + setLocalMessages((prev) => + prev.map((msg) => (msg.id === echoId ? { ...msg, isSending: false } : msg)) + ); + } catch (error) { + log.error('dm.send.failed', { error, total_ms: Math.round(performance.now() - dmStart) }); + setLocalMessages((prev) => prev.filter((msg) => msg.id !== echoId)); + staticPopup('send-message-failed'); + } + }; - const handleSendMoney = useCallback(() => { + const handleSendMoney = () => { log.debug('user.messages.send_money', { lud16, userName: counterpartyMetadata?.name, @@ -380,7 +372,7 @@ export function UserMessagesScreen({ meltTarget: lud16, recipientPubkey: pubkey, }); - }, [counterpartyMetadata, lud16, machine, pubkey]); + }; return ( diff --git a/features/user/screens/UserProfileScreen.tsx b/features/user/screens/UserProfileScreen.tsx index ecba4b95d..d0a6d102c 100644 --- a/features/user/screens/UserProfileScreen.tsx +++ b/features/user/screens/UserProfileScreen.tsx @@ -80,6 +80,7 @@ import { useNostrProfileMetadata } from '@/shared/hooks/useNostrProfileMetadata' import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { useVisualStateLogger } from '@/shared/lib/contentShiftLog'; import { Log, nostrLog, paymentLog, useLifecycleLogger } from '@/shared/lib/logger'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; const BANNER_HEIGHT = 150; const AVATAR_SIZE = 90; @@ -96,13 +97,6 @@ const UserProfileParamsSchema = z path: ['pubkey'], }); -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - function buildUpdatedContactTags( existingTags: string[][], targetPubkey: string, @@ -480,10 +474,7 @@ function BannerWithAvatarComponent({ })); const [bannerStatus, setBannerStatus] = useState<'loading' | 'loaded' | 'failed'>('loading'); - const fallbackIndex = useMemo( - () => (pubkey ? parseInt(pubkey.slice(0, 8), 16) % 8 : 0), - [pubkey] - ); + const fallbackIndex = pubkey ? parseInt(pubkey.slice(0, 8), 16) % 8 : 0; const bannerError = bannerStatus === 'failed'; const hasBannerImage = Boolean(bannerUrl && !bannerError); // Mirror Avatar's state model for the banner: @@ -503,10 +494,7 @@ function BannerWithAvatarComponent({ fallbackIndex ); - const bannerGradientTheme = useMemo( - () => generateSeededGradient(`${pubkey || 'default'}`), - [pubkey] - ); + const bannerGradientTheme = generateSeededGradient(`${pubkey || 'default'}`); const gradientSource = useMemo(() => { if (pictureUrl && pfpColors.hasExtractedColors) return 'pfp'; @@ -786,10 +774,7 @@ export function UserProfileScreen() { if (npubParam) return npubToPubkey(npubParam); return ''; }, [npubParam, pubkeyParam]); - const profileHeaderVisualScope = useMemo( - () => `profile.${pubkey ? pubkey.slice(0, 12) : 'unknown'}.header`, - [pubkey] - ); + const profileHeaderVisualScope = `profile.${pubkey ? pubkey.slice(0, 12) : 'unknown'}.header`; const addRecentPerson = useRecentPeopleStore((state) => state.addRecentPerson); useEffect(() => { @@ -930,9 +915,7 @@ export function UserProfileScreen() { const followingCount = isOwnProfile ? (profileData?.follows ?? ownFollowingCount) : profileData?.follows; - const isFollowingProfile = useNostrSocialStore( - useMemo(() => selectIsFollowingPubkey(pubkey || ''), [pubkey]) - ); + const isFollowingProfile = useNostrSocialStore(selectIsFollowingPubkey(pubkey || '')); const followInFlight = !!followOptimisticEntry?.pending; // =========================== @@ -942,11 +925,11 @@ export function UserProfileScreen() { const [userVideoPosts, setUserVideoPosts] = useState([]); const hasStories = userVideoPosts.length > 0; - const handleVideoPostsReady = useCallback((videoPosts: VideoPostRecord[]) => { + const handleVideoPostsReady = (videoPosts: VideoPostRecord[]) => { setUserVideoPosts(videoPosts); - }, []); + }; - const handleAvatarStoryPress = useCallback(() => { + const handleAvatarStoryPress = () => { if (userVideoPosts.length === 0) return; nostrLog.info('user.profile.story.view', { pubkey, videoCount: userVideoPosts.length }); const storyUser: StoryUser = { @@ -961,7 +944,7 @@ export function UserProfileScreen() { storyUsersJson: JSON.stringify([storyUser]), }, }); - }, [userVideoPosts, pubkey, cachedProfile, displayName]); + }; // =========================== // HANDLERS @@ -1053,14 +1036,14 @@ export function UserProfileScreen() { // a second kind-3 publish with the first's `clearFollowOptimistic`. const handleToggleFollow = useSingleFlight(handleToggleFollowInner); - const handleMintInfoPress = useCallback(() => { + const handleMintInfoPress = () => { if (!profileMintUrl) return; paymentLog.info('user.profile.mint_info.open', { ...mintUrlLogFields(profileMintUrl), source: mintUrlParam ? 'route_param' : 'profile_api', }); router.navigate(buildMintInfoHref(profileMintUrl)); - }, [mintUrlParam, profileMintUrl]); + }; // =========================== // PROFILE INFO ITEMS (data-driven) diff --git a/features/wallet/components/BitcoinNearYou.tsx b/features/wallet/components/BitcoinNearYou.tsx index 2a68d7d76..cbc518f8e 100644 --- a/features/wallet/components/BitcoinNearYou.tsx +++ b/features/wallet/components/BitcoinNearYou.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; +import { INVARIANT_WHITE } from '@/shared/lib/brandColors'; import { Platform, StyleSheet, View as RNView } from 'react-native'; import { AppleMaps, GoogleMaps } from 'expo-maps'; import * as Location from 'expo-location'; @@ -77,10 +78,7 @@ function MapPreview({ const useChrome = Platform.OS === 'ios'; const isDark = scheme === 'dark'; - const cameraPosition = useMemo( - () => ({ coordinates: { latitude, longitude }, zoom: MAP_ZOOM }), - [latitude, longitude] - ); + const cameraPosition = { coordinates: { latitude, longitude }, zoom: MAP_ZOOM }; return ( @@ -272,7 +270,7 @@ export const BitcoinNearYou = React.memo(function BitcoinNearYou() { nearby.push({ id: String(place.id), coordinates: { latitude: place.lat, longitude: place.lon }, - tintColor: '#FFFFFF', + tintColor: INVARIANT_WHITE, title: `Place #${place.id}`, }); } diff --git a/features/wallet/components/FiatCurrencyPill/FiatCurrencyPill.androidMenu.tsx b/features/wallet/components/FiatCurrencyPill/FiatCurrencyPill.androidMenu.tsx index 1b4230ccb..931f3bf9e 100644 --- a/features/wallet/components/FiatCurrencyPill/FiatCurrencyPill.androidMenu.tsx +++ b/features/wallet/components/FiatCurrencyPill/FiatCurrencyPill.androidMenu.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef } from 'react'; +import React, { useRef } from 'react'; import { Menu, type MenuTriggerRef } from 'heroui-native'; import opacity from 'hex-color-opacity'; @@ -34,9 +34,9 @@ export function FiatCurrencyPillAndroidMenu(props: FiatCurrencyPillProps): React const [success] = useThemeColor(['success'] as const); const menuTriggerRef = useRef(null); - const openCurrencyMenu = useCallback(() => { + const openCurrencyMenu = () => { setTimeout(() => menuTriggerRef.current?.open(), 0); - }, []); + }; const primaryHandler = enableCurrencyMenu && !onPress ? openCurrencyMenu : onPress; const longPressHandler = enableCurrencyMenu && onPress ? openCurrencyMenu : undefined; diff --git a/features/wallet/components/FiatCurrencyPill/FiatCurrencyPill.flat.tsx b/features/wallet/components/FiatCurrencyPill/FiatCurrencyPill.flat.tsx index 89c8808d9..2d558185e 100644 --- a/features/wallet/components/FiatCurrencyPill/FiatCurrencyPill.flat.tsx +++ b/features/wallet/components/FiatCurrencyPill/FiatCurrencyPill.flat.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React from 'react'; import { ActionSheetIOS, StyleSheet } from 'react-native'; import opacity from 'hex-color-opacity'; @@ -21,19 +21,16 @@ export function FiatCurrencyPillFlat(props: FiatCurrencyPillProps): React.ReactE 'surface-secondary', 'muted', ] as const); - const pillStyle = useMemo( - () => [ - styles.pill, - { - backgroundColor: surfaceSecondary, - borderColor: opacity(muted, 0.3), - minHeight: iosHeight, - }, - ], - [iosHeight, muted, surfaceSecondary] - ); + const pillStyle = [ + styles.pill, + { + backgroundColor: surfaceSecondary, + borderColor: opacity(muted, 0.3), + minHeight: iosHeight, + }, + ]; - const openCurrencySheet = useCallback(() => { + const openCurrencySheet = () => { ActionSheetIOS.showActionSheetWithOptions( { options: CURRENCY_SHEET_OPTIONS, @@ -46,7 +43,7 @@ export function FiatCurrencyPillFlat(props: FiatCurrencyPillProps): React.ReactE if (buttonIndex === 2) handleSelectCurrency('gbp'); } ); - }, [handleSelectCurrency, colorScheme]); + }; const primaryHandler = enableCurrencyMenu && !onPress ? openCurrencySheet : onPress; const longPressHandler = enableCurrencyMenu && onPress ? openCurrencySheet : undefined; diff --git a/features/wallet/components/FiatCurrencyPill/useFiatCurrencyPill.ts b/features/wallet/components/FiatCurrencyPill/useFiatCurrencyPill.ts index 08a8192e4..bbce24f8c 100644 --- a/features/wallet/components/FiatCurrencyPill/useFiatCurrencyPill.ts +++ b/features/wallet/components/FiatCurrencyPill/useFiatCurrencyPill.ts @@ -1,4 +1,3 @@ -import { useCallback } from 'react'; import { DisplayCurrency, useSettingsStore } from '@/shared/stores/global/settingsStore'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; @@ -39,16 +38,13 @@ export function useFiatCurrencyPill({ const [success] = useThemeColor(['success'] as const); const setDisplayCurrency = useSettingsStore((state) => state.setDisplayCurrency); - const handleSelectCurrency = useCallback( - (currency: DisplayCurrency) => { - if (onSelectCurrency) { - onSelectCurrency(currency); - return; - } - setDisplayCurrency(currency); - }, - [onSelectCurrency, setDisplayCurrency] - ); + const handleSelectCurrency = (currency: DisplayCurrency) => { + if (onSelectCurrency) { + onSelectCurrency(currency); + return; + } + setDisplayCurrency(currency); + }; const text = showToggleGlyph ? `${displayText} ⇄` : displayText; const iosHeight = 34; diff --git a/features/wallet/components/MintSelector/index.ts b/features/wallet/components/MintSelector/index.ts index 8454ff72d..7fc76384a 100644 --- a/features/wallet/components/MintSelector/index.ts +++ b/features/wallet/components/MintSelector/index.ts @@ -1,2 +1 @@ export { default as MintSelector } from './MintSelector'; -export type { MintSelectorProps } from './useMintSelector'; diff --git a/features/wallet/components/MintSelector/useMintSelector.ts b/features/wallet/components/MintSelector/useMintSelector.ts index ef9edc781..5d81b07dd 100644 --- a/features/wallet/components/MintSelector/useMintSelector.ts +++ b/features/wallet/components/MintSelector/useMintSelector.ts @@ -15,6 +15,7 @@ import { getMintDisplayName } from '@/shared/lib/url'; import { amountToNumber } from '@/shared/lib/cashu/amount'; import { walletLog } from '@/shared/lib/logger'; import { useMintStore } from '@/shared/stores/profile/mintStore'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; export interface MintSelectorProps { /** Mint URL to display. When omitted, reads preferredMintUrl from store. */ @@ -50,13 +51,6 @@ interface MintSelectorShared { }; } -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - export function useMintSelector({ selectedMintUrl, onRequestMintList, @@ -69,10 +63,7 @@ export function useMintSelector({ const storedSelectedMint = useMintStore((state) => state.selectedMint); const mintUrl = selectedMintUrl ?? storedSelectedMint; const balance = mintUrl ? amountToNumber(liveBalances.byMint[mintUrl]?.total) : 0; - const mintData = useMemo( - () => (mintUrl ? mints.find((m) => m.mintUrl === mintUrl) : undefined), - [mints, mintUrl] - ); + const mintData = mintUrl ? mints.find((m) => m.mintUrl === mintUrl) : undefined; const mintInfo = useMemo(() => { if (!mintData) return null; diff --git a/features/wallet/components/PrimaryBalance.tsx b/features/wallet/components/PrimaryBalance.tsx index c1832a2d7..8986ca013 100644 --- a/features/wallet/components/PrimaryBalance.tsx +++ b/features/wallet/components/PrimaryBalance.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { StyleSheet } from 'react-native'; import type { GlassVariant } from 'liquid-glass-text'; import { VStack } from '@/shared/ui/primitives/View/VStack'; @@ -129,10 +129,10 @@ export function PrimaryBalance({ account }: PrimaryBalanceProps): React.ReactEle const breakdown = useColadaBalance(); const btcPrice = useBtcPrice(displayCurrency); - const toggleUnit = useCallback(async () => { + const toggleUnit = async () => { await EnhancedHaptics.successHaptic(); setDisplayBtc(((displayBtc + 1) % 4) as DisplayBtcMode); - }, [displayBtc, setDisplayBtc]); + }; const balance = mockMode ? mockBalance : breakdown.total; const reservedTotal = breakdown.reserved; @@ -158,7 +158,7 @@ export function PrimaryBalance({ account }: PrimaryBalanceProps): React.ReactEle }, [account.unit, mockMode, pendingTotal, reservedTotal, lockedTotal]); const displayText = `≈ ${currencyConfig.symbol}${fiatValue}`; - const handlePendingPress = useCallback(() => { + const handlePendingPress = () => { walletLog.info('wallet.pending.press', { pendingTotal, unit: pendingUnit, @@ -173,11 +173,11 @@ export function PrimaryBalance({ account }: PrimaryBalanceProps): React.ReactEle filterMintUrl: 'all', }, }); - }, [router, account.unit, pendingTotal, pendingUnit]); + }; // Wrap the menu in a promise so a rapid second tap on the Reserved pill is // dropped by `useSingleFlight` until the first interaction settles. - const handleReservedPressInner = useCallback(async () => { + const handleReservedPressInner = async () => { const recoverPending = async () => { walletLog.info('wallet.reserved.recovery_start', { reservedTotal }); try { @@ -233,14 +233,14 @@ export function PrimaryBalance({ account }: PrimaryBalanceProps): React.ReactEle ], }); }); - }, [reservedTotal]); + }; const handleReservedPress = useSingleFlight(handleReservedPressInner); // REDEEMING pill: retry redeeming received-but-unswapped ecash. Tapping runs // coco's receive recovery sweep, which swaps any `executing` receives once // the mint is reachable; on success they leave limbo and join the balance. - const handleRedeemingPressInner = useCallback(async () => { + const handleRedeemingPressInner = async () => { walletLog.info('wallet.redeeming.recovery_start', { lockedTotal }); try { const manager = CocoManager.getInstance(); @@ -260,7 +260,7 @@ export function PrimaryBalance({ account }: PrimaryBalanceProps): React.ReactEle text: error instanceof Error ? error.message : 'Unknown error', }); } - }, [lockedTotal]); + }; const handleRedeemingPress = useSingleFlight(handleRedeemingPressInner); diff --git a/features/wallet/hooks/useAmbientNfcArm.ts b/features/wallet/hooks/useAmbientNfcArm.ts index b95bdf12d..25fa1cf1c 100644 --- a/features/wallet/hooks/useAmbientNfcArm.ts +++ b/features/wallet/hooks/useAmbientNfcArm.ts @@ -17,7 +17,7 @@ * 30s (suppressed by the ambient flag in the nfc scan source) and re-arms. */ -import { useCallback, useRef } from 'react'; +import { useRef } from 'react'; import { Platform } from 'react-native'; import { useFocusEffect } from 'expo-router'; @@ -52,101 +52,97 @@ export function useAmbientNfcArm(machine: AmbientNfcMachine): void { const machineRef = useRef(machine); machineRef.current = machine; - useFocusEffect( - useCallback(() => { - if (Platform.OS !== 'android') return; - let active = true; - // Dismissal is sticky: once the user swipes the nfc-tap sheet away, - // the tag-connected listener must NOT re-surface it on the next tag - // arrival (an NFC card resting against the phone would re-open it - // ~1.2s after every dismissal, mid-exit-animation). Cleared when the - // sheet is shown again (explicit button press) or the loop restarts. - let userDismissedSheet = false; - const { setArmed, setPhase } = useNfcTapStore.getState(); + useFocusEffect(() => { + if (Platform.OS !== 'android') return; + let active = true; + // Dismissal is sticky: once the user swipes the nfc-tap sheet away, + // the tag-connected listener must NOT re-surface it on the next tag + // arrival (an NFC card resting against the phone would re-open it + // ~1.2s after every dismissal, mid-exit-animation). Cleared when the + // sheet is shown again (explicit button press) or the loop restarts. + let userDismissedSheet = false; + const { setArmed, setPhase } = useNfcTapStore.getState(); - const unsubscribePopup = usePopupStore.subscribe((state, prev) => { - const wasTapSheet = - prev.current != null && 'sheetId' in prev.current && prev.current.sheetId === 'nfc-tap'; - const isTapSheet = - state.current != null && - 'sheetId' in state.current && - state.current.sheetId === 'nfc-tap'; - if (wasTapSheet && state.current == null) userDismissedSheet = true; - if (isTapSheet) userDismissedSheet = false; - }); + const unsubscribePopup = usePopupStore.subscribe((state, prev) => { + const wasTapSheet = + prev.current != null && 'sheetId' in prev.current && prev.current.sheetId === 'nfc-tap'; + const isTapSheet = + state.current != null && 'sheetId' in state.current && state.current.sheetId === 'nfc-tap'; + if (wasTapSheet && state.current == null) userDismissedSheet = true; + if (isTapSheet) userDismissedSheet = false; + }); - // A tag entered the field: flip the sheet to 'Reading' and surface it - // if nothing else is showing (ambient tap with the sheet closed) and - // the user hasn't dismissed it this focus session. - setNfcTagConnectedListener(() => { - if (!active) return; - useNfcTapStore.getState().setPhase('reading'); - if (!userDismissedSheet && usePopupStore.getState().current == null) { - showActionSheet('nfc-tap', {}); - } - }); + // A tag entered the field: flip the sheet to 'Reading' and surface it + // if nothing else is showing (ambient tap with the sheet closed) and + // the user hasn't dismissed it this focus session. + setNfcTagConnectedListener(() => { + if (!active) return; + useNfcTapStore.getState().setPhase('reading'); + if (!userDismissedSheet && usePopupStore.getState().current == null) { + showActionSheet('nfc-tap', {}); + } + }); - const loop = async () => { - if (!(await isNfcSupported())) return; - walletLog.info('wallet.nfc.ambient_arm_start'); - while (active) { - if (!(await isNfcEnabled())) { - useNfcTapStore.getState().setArmed(false); - await delay(DISABLED_RECHECK_MS); - continue; - } - // Popup-lane payment surfaces (payment-options, proof-selector, - // send-memo) are NOT routes — the wallet stays focused beneath - // them, so without this guard the loop would keep cycling and its - // clearPaymentContext would wipe the amount draft mid-flow every - // ~31s. Pause (no clear, no scan) while any popup other than our - // own tap sheet is up. - const popupCurrent = usePopupStore.getState().current; - const popupBlocks = - popupCurrent != null && - !('sheetId' in popupCurrent && popupCurrent.sheetId === 'nfc-tap'); - if (popupBlocks) { - await delay(POPUP_RECHECK_MS); - continue; - } - setArmed(true); - setAmbientNfcCycle(true); - // Root-entry reset (sovran-payment-flow-guards): wallet focused + - // no payment popup up means no flow is active, so clearing stale - // amount/mint context before a read can enter the machine is safe. - clearPaymentContext('wallet.nfc_ambient'); - try { - await machineRef.current.scan?.(undefined, { source: 'nfc' }); - } catch (err) { - walletLog.debug('wallet.nfc.ambient_cycle_error', { - error: err instanceof Error ? err.message : String(err), - }); - } finally { - setAmbientNfcCycle(false); - } - if (!active) break; - await delay(REARM_DELAY_MS); + const loop = async () => { + if (!(await isNfcSupported())) return; + walletLog.info('wallet.nfc.ambient_arm_start'); + while (active) { + if (!(await isNfcEnabled())) { + useNfcTapStore.getState().setArmed(false); + await delay(DISABLED_RECHECK_MS); + continue; } - }; - void loop(); - - return () => { - active = false; - unsubscribePopup(); - setNfcTagConnectedListener(null); - useNfcTapStore.getState().setArmed(false); - setPhase('armed'); - // Don't leave a sheet claiming "listening" after the listener is - // gone (e.g. navigating away with the sheet up). - const popup = usePopupStore.getState(); - if (popup.current && 'sheetId' in popup.current && popup.current.sheetId === 'nfc-tap') { - popup.close(); + // Popup-lane payment surfaces (payment-options, proof-selector, + // send-memo) are NOT routes — the wallet stays focused beneath + // them, so without this guard the loop would keep cycling and its + // clearPaymentContext would wipe the amount draft mid-flow every + // ~31s. Pause (no clear, no scan) while any popup other than our + // own tap sheet is up. + const popupCurrent = usePopupStore.getState().current; + const popupBlocks = + popupCurrent != null && + !('sheetId' in popupCurrent && popupCurrent.sheetId === 'nfc-tap'); + if (popupBlocks) { + await delay(POPUP_RECHECK_MS); + continue; } - // Cancel the held requestTechnology so the pending cycle resolves - // (as a quiet user-cancel) instead of dangling for up to 30s. - void releaseSession(); - walletLog.info('wallet.nfc.ambient_arm_stop'); - }; - }, []) - ); + setArmed(true); + setAmbientNfcCycle(true); + // Root-entry reset (sovran-payment-flow-guards): wallet focused + + // no payment popup up means no flow is active, so clearing stale + // amount/mint context before a read can enter the machine is safe. + clearPaymentContext('wallet.nfc_ambient'); + try { + await machineRef.current.scan?.(undefined, { source: 'nfc' }); + } catch (err) { + walletLog.debug('wallet.nfc.ambient_cycle_error', { + error: err instanceof Error ? err.message : String(err), + }); + } finally { + setAmbientNfcCycle(false); + } + if (!active) break; + await delay(REARM_DELAY_MS); + } + }; + void loop(); + + return () => { + active = false; + unsubscribePopup(); + setNfcTagConnectedListener(null); + useNfcTapStore.getState().setArmed(false); + setPhase('armed'); + // Don't leave a sheet claiming "listening" after the listener is + // gone (e.g. navigating away with the sheet up). + const popup = usePopupStore.getState(); + if (popup.current && 'sheetId' in popup.current && popup.current.sheetId === 'nfc-tap') { + popup.close(); + } + // Cancel the held requestTechnology so the pending cycle resolves + // (as a quiet user-cancel) instead of dangling for up to 30s. + void releaseSession(); + walletLog.info('wallet.nfc.ambient_arm_stop'); + }; + }); } diff --git a/features/wallet/index.ts b/features/wallet/index.ts index cbe514810..20dc00997 100644 --- a/features/wallet/index.ts +++ b/features/wallet/index.ts @@ -2,9 +2,4 @@ export { WalletScreen } from './screens/WalletScreen'; export { MintSelector } from './components/MintSelector'; -export type { MintSelectorProps } from './components/MintSelector'; -export { PrimaryBalance } from './components/PrimaryBalance'; -export { Account } from './components/Account'; -export { BitcoinNearYou } from './components/BitcoinNearYou'; -export { FiatCurrencyPill } from './components/FiatCurrencyPill'; export { useAppBalance } from './hooks/useAppBalance'; diff --git a/features/wallet/screens/WalletScreen.tsx b/features/wallet/screens/WalletScreen.tsx index f10c8d783..412869136 100644 --- a/features/wallet/screens/WalletScreen.tsx +++ b/features/wallet/screens/WalletScreen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { Platform, StyleSheet, useWindowDimensions } from 'react-native'; import { Menu, type MenuTriggerRef } from 'heroui-native'; import { usePullToAiRefreshControl } from '@/shared/blocks/PullToAiRefreshControl'; @@ -57,6 +57,17 @@ const WALLET_HEADER_TO_BALANCE_GAP = Platform.select({ android: 12, default: 24 const RECEIVE_SYSTEM_ICON = Platform.OS === 'ios' ? 'arrow.down.left' : undefined; const SEND_SYSTEM_ICON = Platform.OS === 'ios' ? 'arrow.up.right' : undefined; +const handleTheme = () => { + walletLog.info('wallet.theme.tap'); + router.push('/(theme-flow)/preview'); +}; + +const handleNearPay = () => { + walletLog.info('wallet.near_pay.tap', { unit: ACCOUNT.unit }); + clearPaymentContext('wallet.near_pay'); + router.push('/(send-flow)/nearPay'); +}; + export function WalletScreen() { useLifecycleLogger('WalletScreen'); useBackgroundConfig({ blurMode: 'partial' }); @@ -72,14 +83,14 @@ export function WalletScreen() { const [contentHeight, setContentHeight] = useState(0); - const onContentSizeChange = useCallback((_width: number, height: number) => { + const onContentSizeChange = (_width: number, height: number) => { setContentHeight(height); - }, []); + }; const { history, refresh } = useHistoryWithMelts(); - const handlePullToAiRefresh = useCallback(() => { + const handlePullToAiRefresh = () => { void refresh(); - }, [refresh]); + }; const pullToAi = usePullToAiRefreshControl({ onRefresh: handlePullToAiRefresh }); useVersionCheck(); @@ -87,9 +98,9 @@ export function WalletScreen() { const walletContext = useWalletContext(); const machine = usePaymentFlowMachine({ walletContext, unit: ACCOUNT.unit }); const moreMenuTriggerRef = useRef(null); - const openMoreMenu = useCallback(() => { + const openMoreMenu = () => { setTimeout(() => moreMenuTriggerRef.current?.open(), 0); - }, []); + }; // While a multi-leg swap is running, every payment-initiating button on // this screen is gated. Coco's mint/melt services serialize through a @@ -98,13 +109,13 @@ export function WalletScreen() { // progress" errors. Greying out is the cheapest user-visible indicator. const isSwapping = useSwapStatusStore((s) => s.active?.state === 'running'); - const handleReceive = useCallback(() => { + const handleReceive = () => { walletLog.info('wallet.action.receive', { unit: ACCOUNT.unit }); clearPaymentContext('wallet.receive'); void machine.startReceive({ reset: true }); - }, [machine]); + }; - const handleScanQR = useCallback(async () => { + const handleScanQR = async () => { walletLog.info('wallet.action.scan_qr', { unit: ACCOUNT.unit }); const granted = await handlePermission(); if (!granted) { @@ -116,24 +127,13 @@ export function WalletScreen() { pathname: '/camera', params: { to: 'sendToken', unit: ACCOUNT.unit }, }); - }, [handlePermission]); + }; - const handleSend = useCallback(async () => { + const handleSend = async () => { walletLog.info('wallet.action.send', { unit: ACCOUNT.unit }); clearPaymentContext('wallet.send'); await machine.startSendEcash({ reset: true }); - }, [machine]); - - const handleTheme = useCallback(() => { - walletLog.info('wallet.theme.tap'); - router.push('/(theme-flow)/preview'); - }, []); - - const handleNearPay = useCallback(() => { - walletLog.info('wallet.near_pay.tap', { unit: ACCOUNT.unit }); - clearPaymentContext('wallet.near_pay'); - router.push('/(send-flow)/nearPay'); - }, []); + }; const nfcSupported = useNfcSupported(); // Android hybrid tap-to-pay: the ambient focus loop owns NFC scanning, so @@ -142,7 +142,7 @@ export function WalletScreen() { // iOS cannot listen ambiently. useAmbientNfcArm(machine); const nfcArmed = useNfcTapStore((s) => s.armed); - const handleNfc = useCallback(() => { + const handleNfc = () => { walletLog.info('wallet.action.nfc', { unit: ACCOUNT.unit, armed: nfcArmed }); if (Platform.OS === 'android' && nfcArmed) { showActionSheet('nfc-tap', {}); @@ -154,7 +154,7 @@ export function WalletScreen() { // be listening. clearPaymentContext('wallet.nfc'); void machine.scan?.(undefined, { source: 'nfc' }); - }, [machine, nfcArmed]); + }; // Keep BootEntrance and the wallet body mounted across the search toggle so // the splash→QR morph never replays and closing search restores this screen. diff --git a/features/whitenoise/client/network.ts b/features/whitenoise/client/network.ts index 73ae00fba..ecdb6497a 100644 --- a/features/whitenoise/client/network.ts +++ b/features/whitenoise/client/network.ts @@ -32,7 +32,7 @@ function ndkEventToNostr(event: NDKEvent): ApplesauceEvent { tags: event.tags, created_at: event.created_at ?? 0, sig: event.sig ?? '', - } as ApplesauceEvent; + } satisfies ApplesauceEvent; } function nostrEventToNdk(event: ApplesauceEvent, ndk: NDK): NDKEvent { diff --git a/features/whitenoise/components/WhitenoiseSetupBanner.tsx b/features/whitenoise/components/WhitenoiseSetupBanner.tsx index de6783489..f8ad9a95e 100644 --- a/features/whitenoise/components/WhitenoiseSetupBanner.tsx +++ b/features/whitenoise/components/WhitenoiseSetupBanner.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; +import { INVARIANT_BLACK } from '@/shared/lib/brandColors'; import { View, StyleSheet, Platform } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { usePathname } from 'expo-router'; @@ -73,12 +74,12 @@ export function WhitenoiseSetupBanner({ testID }: { testID?: string }) { return () => clearTimeout(id); }, [phase]); - const onPress = useCallback(async () => { + const onPress = async () => { if (phase !== 'idle') return; setPhase('running'); await bootstrap(); setPhase('success'); - }, [phase, bootstrap]); + }; // Render gates — idle state hides when there's nothing to set up. // Once the user starts, we keep rendering through the full sequence @@ -200,7 +201,7 @@ const styles = StyleSheet.create({ card: { borderRadius: 18, overflow: 'hidden', - shadowColor: '#000', + shadowColor: INVARIANT_BLACK, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.18, shadowRadius: 12, diff --git a/features/whitenoise/hooks/useWhitenoiseDM.ts b/features/whitenoise/hooks/useWhitenoiseDM.ts index 4e51fb544..e04b8f732 100644 --- a/features/whitenoise/hooks/useWhitenoiseDM.ts +++ b/features/whitenoise/hooks/useWhitenoiseDM.ts @@ -181,88 +181,85 @@ export function useWhitenoiseDM(counterpartyPubkey: string): UseWhitenoiseDMStat }; }, [client, group, relays, selfPubkey, upsertMessage]); - const sendInner = useCallback( - async (text: string) => { - if (!text.trim()) return; - if (!client) { - setError('White Noise client not ready'); - return; - } - if (!selfPubkey) { - setError('No active Nostr key'); - return; - } - setError(null); - - let activeGroup = groupRef.current; - - // Lazy-create the group on first send. - if (!activeGroup) { - setIsCreatingGroup(true); - try { - const fallbackRelays = relays.length > 0 ? [...relays] : []; - const lookupRelays = await resolveInboxRelays( - client.network, - counterpartyPubkey, - fallbackRelays - ); - const events = await client.network.request(lookupRelays, [ - { kinds: [KEY_PACKAGE_KIND], authors: [counterpartyPubkey], limit: 1 }, - ]); - if (events.length === 0) { - throw new Error("Recipient hasn't published a White Noise key package yet."); - } - const keyPackageEvent = events[0]; + const sendInner = async (text: string) => { + if (!text.trim()) return; + if (!client) { + setError('White Noise client not ready'); + return; + } + if (!selfPubkey) { + setError('No active Nostr key'); + return; + } + setError(null); - const created = (await client.createGroup(`dm:${counterpartyPubkey.slice(0, 16)}`, { - description: 'White Noise 1:1 DM', - relays: fallbackRelays, - })) as WnGroup; - await created.inviteByKeyPackageEvent(keyPackageEvent); + let activeGroup = groupRef.current; - await dmIndex.set(counterpartyPubkey, bytesToHex(created.id)); - activeGroup = created; - groupRef.current = created; - setGroup(created); - wnLog.info('whitenoise.dm.group_created', { - groupId: bytesToHex(created.id), - counterparty: counterpartyPubkey.slice(0, 16), - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setError(message); - wnLog.error('whitenoise.dm.group_create_failed', { error: message }); - setIsCreatingGroup(false); - return; + // Lazy-create the group on first send. + if (!activeGroup) { + setIsCreatingGroup(true); + try { + const fallbackRelays = relays.length > 0 ? [...relays] : []; + const lookupRelays = await resolveInboxRelays( + client.network, + counterpartyPubkey, + fallbackRelays + ); + const events = await client.network.request(lookupRelays, [ + { kinds: [KEY_PACKAGE_KIND], authors: [counterpartyPubkey], limit: 1 }, + ]); + if (events.length === 0) { + throw new Error("Recipient hasn't published a White Noise key package yet."); } - setIsCreatingGroup(false); - } + const keyPackageEvent = events[0]; - const optimisticId = mintLocalId('pending'); - const nowSec = Math.floor(Date.now() / 1000); - upsertMessage({ - id: optimisticId, - authorPubkey: selfPubkey, - content: text, - createdAt: nowSec, - isSelf: true, - isPending: true, - }); + const created = (await client.createGroup(`dm:${counterpartyPubkey.slice(0, 16)}`, { + description: 'White Noise 1:1 DM', + relays: fallbackRelays, + })) as WnGroup; + await created.inviteByKeyPackageEvent(keyPackageEvent); - try { - await activeGroup!.sendChatMessage(text); - setMessages((prev) => - prev.map((m) => (m.id === optimisticId ? { ...m, isPending: false } : m)) - ); + await dmIndex.set(counterpartyPubkey, bytesToHex(created.id)); + activeGroup = created; + groupRef.current = created; + setGroup(created); + wnLog.info('whitenoise.dm.group_created', { + groupId: bytesToHex(created.id), + counterparty: counterpartyPubkey.slice(0, 16), + }); } catch (err) { const message = err instanceof Error ? err.message : String(err); setError(message); - wnLog.error('whitenoise.dm.send_failed', { error: message }); - setMessages((prev) => prev.filter((m) => m.id !== optimisticId)); + wnLog.error('whitenoise.dm.group_create_failed', { error: message }); + setIsCreatingGroup(false); + return; } - }, - [client, counterpartyPubkey, relays, selfPubkey, upsertMessage, dmIndex] - ); + setIsCreatingGroup(false); + } + + const optimisticId = mintLocalId('pending'); + const nowSec = Math.floor(Date.now() / 1000); + upsertMessage({ + id: optimisticId, + authorPubkey: selfPubkey, + content: text, + createdAt: nowSec, + isSelf: true, + isPending: true, + }); + + try { + await activeGroup!.sendChatMessage(text); + setMessages((prev) => + prev.map((m) => (m.id === optimisticId ? { ...m, isPending: false } : m)) + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + wnLog.error('whitenoise.dm.send_failed', { error: message }); + setMessages((prev) => prev.filter((m) => m.id !== optimisticId)); + } + }; // The lazy group-creation path is the high-cost double-tap target: a // second concurrent call before `groupRef.current` is set re-enters the diff --git a/features/whitenoise/hooks/useWhitenoiseRequests.ts b/features/whitenoise/hooks/useWhitenoiseRequests.ts index 3513444d3..61fa85c24 100644 --- a/features/whitenoise/hooks/useWhitenoiseRequests.ts +++ b/features/whitenoise/hooks/useWhitenoiseRequests.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; import { bytesToHex } from '@noble/hashes/utils.js'; import type { UnreadInvite } from '@internet-privacy/marmot-ts'; @@ -70,66 +70,60 @@ export function useWhitenoiseRequests(): UseWhitenoiseRequestsState { }; }, [inviteReader]); - const acceptInner = useCallback( - async (request: WhitenoiseRequest) => { - if (!client || !inviteReader) { - setError('White Noise client not ready'); - return; - } - setBusyId(request.id); - setError(null); - try { - const { group } = await client.joinGroupFromWelcome({ - welcomeRumor: request.rumor as Parameters< - typeof client.joinGroupFromWelcome - >[0]['welcomeRumor'], - }); - const index = new WhitenoiseDmIndex(accountIndex); - await index.set(request.fromPubkey, bytesToHex(group.id)); - await inviteReader.markAsRead(request.id); - wnLog.info('whitenoise.requests.accepted', { - inviteId: request.id.slice(0, 8), - groupId: bytesToHex(group.id), - from: request.fromPubkey.slice(0, 16), - }); - // Open the new chat so the user lands directly in the conversation - // instead of staring at an empty Requests pill. - router.push({ - pathname: '/(user-flow)/whitenoiseDM' as never, - params: { pubkey: request.fromPubkey }, - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setError(message); - wnLog.error('whitenoise.requests.accept_failed', { error: message }); - } finally { - setBusyId(null); - } - }, - [accountIndex, client, inviteReader] - ); + const acceptInner = async (request: WhitenoiseRequest) => { + if (!client || !inviteReader) { + setError('White Noise client not ready'); + return; + } + setBusyId(request.id); + setError(null); + try { + const { group } = await client.joinGroupFromWelcome({ + welcomeRumor: request.rumor as Parameters< + typeof client.joinGroupFromWelcome + >[0]['welcomeRumor'], + }); + const index = new WhitenoiseDmIndex(accountIndex); + await index.set(request.fromPubkey, bytesToHex(group.id)); + await inviteReader.markAsRead(request.id); + wnLog.info('whitenoise.requests.accepted', { + inviteId: request.id.slice(0, 8), + groupId: bytesToHex(group.id), + from: request.fromPubkey.slice(0, 16), + }); + // Open the new chat so the user lands directly in the conversation + // instead of staring at an empty Requests pill. + router.push({ + pathname: '/(user-flow)/whitenoiseDM' as never, + params: { pubkey: request.fromPubkey }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + wnLog.error('whitenoise.requests.accept_failed', { error: message }); + } finally { + setBusyId(null); + } + }; - const declineInner = useCallback( - async (request: WhitenoiseRequest) => { - if (!inviteReader) return; - setBusyId(request.id); - setError(null); - try { - await inviteReader.markAsRead(request.id); - wnLog.info('whitenoise.requests.declined', { - inviteId: request.id.slice(0, 8), - from: request.fromPubkey.slice(0, 16), - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setError(message); - wnLog.error('whitenoise.requests.decline_failed', { error: message }); - } finally { - setBusyId(null); - } - }, - [inviteReader] - ); + const declineInner = async (request: WhitenoiseRequest) => { + if (!inviteReader) return; + setBusyId(request.id); + setError(null); + try { + await inviteReader.markAsRead(request.id); + wnLog.info('whitenoise.requests.declined', { + inviteId: request.id.slice(0, 8), + from: request.fromPubkey.slice(0, 16), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + wnLog.error('whitenoise.requests.decline_failed', { error: message }); + } finally { + setBusyId(null); + } + }; // `busyId` is React state and lands too late to block a rapid second tap. // The single-flight guard drops the duplicate before it reaches diff --git a/features/whitenoise/hooks/useWhitenoiseSetup.ts b/features/whitenoise/hooks/useWhitenoiseSetup.ts index 01d43ad3d..245786aea 100644 --- a/features/whitenoise/hooks/useWhitenoiseSetup.ts +++ b/features/whitenoise/hooks/useWhitenoiseSetup.ts @@ -60,7 +60,7 @@ export function useWhitenoiseSetup(): WhitenoiseSetupState { }; }, [client, refresh]); - const bootstrapInner = useCallback(async () => { + const bootstrapInner = async () => { if (!client) { setError('White Noise client not ready'); return; @@ -93,7 +93,7 @@ export function useWhitenoiseSetup(): WhitenoiseSetupState { } finally { setIsBootstrapping(false); } - }, [client, relays]); + }; // Key-package creation is finite-resource work — a duplicate concurrent // bootstrap would publish two key packages per slot and burn relay diff --git a/knip.json b/knip.json index cd12c4668..d317f79de 100644 --- a/knip.json +++ b/knip.json @@ -7,7 +7,9 @@ "app/**/*.{ts,tsx}", "plugins/withLowerPodDeploymentTarget.js", "plugins/withNeutralAndroidTextColors.js", - "scripts/*.{js,ts}" + "scripts/*.{js,ts}", + "__tests__/**/*.{ts,tsx}", + "**/*.test.{ts,tsx}" ], "project": ["**/*.{ts,tsx,js,jsx}"], "ignore": [".monicon/**", "modules/**", "targets/**"], @@ -23,34 +25,22 @@ "number-flow-react-native", "nutpatch", "patch-package", - "react-native-fast-squircle" + "react-native-fast-squircle", + "@sovranbitcoin/colada", + "@sovranbitcoin/nagg-ts", + "@sovranbitcoin/schemas" ], "ignoreBinaries": ["claude", "eas", "gh", "maestro", "tsx"], "ignoreIssues": { "**/*.android.{ts,tsx}": ["files"], + "**/*.ios.{ts,tsx}": ["files"], + "**/*.blur.{ts,tsx}": ["files"], "**/*.liquid.{ts,tsx}": ["files"], "codereview/**": ["exports", "types"], - "features/feed/**": ["exports", "types"], - "features/transactions/components/MonthSelector.tsx": ["types"], - "features/camera/**": ["files", "types"], - "features/mint/**": ["exports", "types"], - "features/settings/**": ["exports"], - "features/transactions/**": ["exports"], - "features/user/**": ["exports"], - "features/wallet/**": ["exports", "types"], "assets/icons/**": ["exports"], - "shared/blocks/PullToAiRefreshControl.tsx": ["exports"], - "shared/blocks/status/**": ["exports", "types", "duplicates"], - "shared/lib/date.ts": ["types"], - "shared/lib/nav/profileRoutes.ts": ["types"], - "shared/lib/popup/popups/index.ts": ["types"], - "shared/lib/wallpaperStorage.ts": ["exports"], - "shared/styles/tokens.ts": ["exports", "types"], - "shared/ui/capability/index.tsx": ["exports", "types"], - "shared/ui/composed/GlassSearchBar/**": ["files", "types"], - "shared/ui/composed/QRButton/**": ["files", "types"], - "shared/ui/primitives/SelectableCheck/index.tsx": ["types"], - "shared/lib/popup/**": ["exports"], + "features/feed/data/naggSchemas.ts": ["exports", "types"], + "features/feed/data/recentPeopleProfiles.ts": ["exports"], + "shared/lib/popup/popups/sendMemoSheet.tsx": ["exports"], "coco-cashu-plugin-p2pk-import/**": ["files", "exports", "types", "unlisted"] } } diff --git a/nativewind-env.d.ts b/nativewind-env.d.ts deleted file mode 100644 index 0fbe2e537..000000000 --- a/nativewind-env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -// @ts-ignore -/// diff --git a/navigation/nativeTabs.tsx b/navigation/nativeTabs.tsx index 1ece4ed2e..0ab0e7f4c 100644 --- a/navigation/nativeTabs.tsx +++ b/navigation/nativeTabs.tsx @@ -4,6 +4,7 @@ */ import React from 'react'; +import { INVARIANT_WHITE } from '@/shared/lib/brandColors'; import { Platform, StyleProp, ViewStyle } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { NativeTabs } from 'expo-router/unstable-native-tabs'; @@ -132,7 +133,7 @@ export function buildExpoRouterHeaderOptions({ const nextOptions: NativeStackNavigationOptions = { ...(options || {}), }; - const resolvedIconColor = iconColor ?? '#FFFFFF'; + const resolvedIconColor = iconColor ?? INVARIANT_WHITE; if (Platform.OS === 'android') { nextOptions.headerShadowVisible = nextOptions.headerShadowVisible ?? false; diff --git a/package.json b/package.json index 1841a55ea..2d98955f5 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "pretty": "prettier --write \"./**/*.{js,jsx,mjs,cjs,ts,tsx,json}\"", "pretty:check": "prettier --check \"./**/*.{js,jsx,mjs,cjs,ts,tsx,json}\"", "knip": "npx knip", + "check:react-compiler": "react-compiler-healthcheck --src './{app,features,shared,components}/**/*.{ts,tsx,js,jsx}' | node scripts/check-react-compiler.mjs", "vendor:marmot-ts": "bash scripts/vendor-marmot-ts.sh", "postinstall": "node scripts/apply-patches.js && node modules/bitchat-module/scripts/patch-bitchat-imports.js && node modules/bitchat-module/scripts/sync-bitchat-android.js && node scripts/link-local-siblings.mjs", "prebuild": "expo prebuild --clean", @@ -213,6 +214,7 @@ "@iconify-json/solar": "^1.2.5", "@iconify-json/stash": "^1.2.4", "@iconify-json/tabler": "^1.2.33", + "@okee-tech/eslint-plugin-neverthrow": "^1.0.14", "@types/jest": "^30.0.0", "@types/lodash": "^4.17.20", "@types/react": "~19.2.2", @@ -224,9 +226,12 @@ "eslint": "^9.25.1", "eslint-config-expo": "~55.0.0", "eslint-config-prettier": "^10.1.2", + "eslint-plugin-no-secrets": "^2.3.3", "eslint-plugin-prettier": "^5.5.0", "eslint-plugin-react-compiler": "19.1.0-rc.2", - "eslint-plugin-react-perf": "^3.3.3", + "eslint-plugin-security": "^4.0.1", + "eslint-plugin-sonarjs": "^4.1.0", + "eslint-plugin-unicorn": "^68.0.0", "eslint-plugin-unused-imports": "^4.1.4", "get-image-colors": "^4.0.1", "jest": "30.3.0", @@ -236,6 +241,7 @@ "metro-minify-terser": "0.83.3", "patch-package": "^8.0.1", "prettier": "^3.2.5", + "react-compiler-healthcheck": "1.0.0", "prettier-plugin-tailwindcss": "^0.5.11", "react-test-renderer": "19.2.0", "tailwindcss": "^4.1.17", diff --git a/redux/nostr/reducer.deprecated.ts b/redux/nostr/reducer.deprecated.ts index c8f6e4e9b..1cd230d7b 100644 --- a/redux/nostr/reducer.deprecated.ts +++ b/redux/nostr/reducer.deprecated.ts @@ -78,7 +78,6 @@ const initialState: NostrState = { ...(nostrState.contacts ? { contacts: nostrState.contacts as any[] } : {}), }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any export const nostrReducer: Reducer = ( state = initialState, _action diff --git a/scripts/check-react-compiler.mjs b/scripts/check-react-compiler.mjs new file mode 100644 index 000000000..a6baeaa95 --- /dev/null +++ b/scripts/check-react-compiler.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/** + * React Compiler coverage gate (stdin parser). + * + * The whole React-Compiler memoization strategy (app.json + * `experiments.reactCompiler`) rests on ONE premise: the compiler actually + * compiles every component. When a component "bails out" (a ref read in + * render, a rules-of-hooks violation, an unsupported pattern), the compiler + * SILENTLY skips it — it renders unmemoized, and any manual memo that was + * removed as "redundant" is now genuinely missing. There is no runtime error; + * the only symptom is jank. + * + * The `eslint-plugin-react-compiler` rule that would normally surface this is + * dead in this repo — it throws at config load under the repo-wide `zod@4` + * override (see eslint.config.js) and falls back to a no-op. So nothing in the + * lint run catches a new bailout. This gate restores that signal: the npm + * script pipes `react-compiler-healthcheck` output into this parser, which + * FAILS when compiled < total. See docs/adr/0007-react-compiler-coverage-gate.md. + */ + +let input = ''; +process.stdin.setEncoding('utf8'); +for await (const chunk of process.stdin) input += chunk; + +process.stdout.write(input); + +const m = input.match(/Successfully compiled\s+(\d+)\s+out of\s+(\d+)\s+components/); +if (!m) { + console.error('\n✗ Could not parse react-compiler-healthcheck output — refusing to pass blind.'); + process.exit(2); +} + +const compiled = Number(m[1]); +const total = Number(m[2]); +if (total === 0) { + console.error('\n✗ Healthcheck found 0 components — wrong --src glob?'); + process.exit(2); +} +if (compiled < total) { + console.error( + `\n✗ React Compiler bailout: ${total - compiled} of ${total} components did NOT compile.\n` + + ' Those components render unmemoized — a removed manual memo is now missing.\n' + + ' Find them with: bunx react-compiler-healthcheck --verbose, then either fix the\n' + + ' bailout (ref-in-render, rules-of-hooks) or restore the manual memo.' + ); + process.exit(1); +} + +console.error(`\n✓ React Compiler covers all ${total} components (zero bailouts).`); diff --git a/shared/blocks/DrawerProfileChrome.tsx b/shared/blocks/DrawerProfileChrome.tsx index 951718964..aefaa25b0 100644 --- a/shared/blocks/DrawerProfileChrome.tsx +++ b/shared/blocks/DrawerProfileChrome.tsx @@ -113,13 +113,13 @@ function useProfileSwitcher(closeDrawer: () => void) { [closeDrawer, activeAccountIndex] ); - const openSheet = useCallback(() => { + const openSheet = () => { profileSwitcherPopup({ onRequestAction: (action) => { void executeProfileAction(action); }, }); - }, [executeProfileAction]); + }; return { executeProfileAction, openSheet }; } @@ -221,14 +221,14 @@ export const DrawerProfileChrome = React.memo(function DrawerProfileChrome({ const mutedColor = opacity(foreground, alpha.disabled); - const handleAvatarPress = useCallback(() => { + const handleAvatarPress = () => { if (!nostrKeys?.pubkey) return; closeDrawer(); router.navigate({ pathname: '/(user-flow)/profile', params: { pubkey: nostrKeys.pubkey }, }); - }, [nostrKeys, closeDrawer]); + }; if (!nostrKeys?.pubkey) { return ; diff --git a/shared/blocks/PaymentInfo.tsx b/shared/blocks/PaymentInfo.tsx index 4262d1ba8..70a154c3f 100644 --- a/shared/blocks/PaymentInfo.tsx +++ b/shared/blocks/PaymentInfo.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Text } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import ViewShot from 'react-native-view-shot'; @@ -66,7 +66,7 @@ export function PaymentInfo({ const willAnimate = animated && selectedValue.length >= ANIMATE_THRESHOLD; - const cycleSpeed = useCallback(() => { + const cycleSpeed = () => { setSpeedIndex((prev) => { const next = (prev + 1) % SPEED_PRESETS.length; paymentLog.info('ui.qrcode.speed_changed', { @@ -76,9 +76,9 @@ export function PaymentInfo({ }); return next; }); - }, []); + }; - const cycleDensity = useCallback(() => { + const cycleDensity = () => { setDensityIndex((prev) => { const next = (prev + 1) % DENSITY_PRESETS.length; paymentLog.info('ui.qrcode.density_changed', { @@ -88,9 +88,9 @@ export function PaymentInfo({ }); return next; }); - }, []); + }; - const handleCopyPress = useCallback(async () => { + const handleCopyPress = async () => { paymentLog.info('ui.payment_info.copy', { copyTarget, hasLink: Boolean(link), @@ -99,7 +99,7 @@ export function PaymentInfo({ await EnhancedHaptics.copyHaptic(); await Clipboard.setStringAsync(link || selectedValue); copyPopup(copyTarget); - }, [link, selectedValue, copyTarget]); + }; const loading = !selectedValue; if (loading) { diff --git a/shared/blocks/PullToAiRefreshControl.tsx b/shared/blocks/PullToAiRefreshControl.tsx index e255ded64..59cf53942 100644 --- a/shared/blocks/PullToAiRefreshControl.tsx +++ b/shared/blocks/PullToAiRefreshControl.tsx @@ -14,11 +14,6 @@ type Props = Omit & { * `onRefresh`. The previous AI-tab navigation behaviour has been removed — * the gesture is now a plain refresh. */ -export function PullToAiRefreshControl({ onRefresh, refreshing = false, ...rest }: Props) { - const { refreshControl } = usePullToAiRefreshControl({ onRefresh, refreshing, ...rest }); - return refreshControl; -} - export function usePullToAiRefreshControl({ onRefresh, refreshing = false, ...rest }: Props = {}) { return { refreshControl: , diff --git a/shared/blocks/SovranTabBar.tsx b/shared/blocks/SovranTabBar.tsx index 4dffd94e1..fb237cd28 100644 --- a/shared/blocks/SovranTabBar.tsx +++ b/shared/blocks/SovranTabBar.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Animated, { @@ -44,20 +44,20 @@ function TabButton({ transform: [{ scale: scale.value }], })); - const onPressIn = useCallback(() => { + const onPressIn = () => { scale.value = withTiming(0.88, { duration: 70, easing: Easing.out(Easing.cubic), }); - }, [scale]); + }; - const onPressOut = useCallback(() => { + const onPressOut = () => { scale.value = withSpring(1, { damping: 12, stiffness: 380, mass: 0.6, }); - }, [scale]); + }; return ( { + const handleSearchChange = (text: string) => { setInputText(text); if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); searchDebounceRef.current = setTimeout(() => setSearchQuery(text), 150); - }, []); + }; - const handleSearchClear = useCallback(() => { + const handleSearchClear = () => { setInputText(''); setSearchQuery(''); if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); - }, []); + }; - const handleOpenChange = useCallback((open: boolean): void => { + const handleOpenChange = (open: boolean): void => { hostLog.info('actionMenuHost.openChange', { open, stayOpen: stayOpenRef.current, @@ -287,9 +287,9 @@ export function ActionMenuHost() { activeDismissRef.current = null; dismissActionMenuPopup(); if (!picked && onDismiss) onDismiss(); - }, []); + }; - const handleItemPress = useCallback((button: ActionMenuItem): void => { + const handleItemPress = (button: ActionMenuItem): void => { if (button.disabled || button.isFailed) return; selectedRef.current = true; if (button.keepOpen) { @@ -298,27 +298,24 @@ export function ActionMenuHost() { dismissActionMenuPopup(); } void button.onPress?.(() => dismissActionMenuPopup()); - }, []); + }; - const handlePrimaryPressInner = useCallback( - async (action: ActionMenuPrimaryAction): Promise => { - if (isSubmitting) return; - setError(null); - setIsSubmitting(true); - try { - await action.onPress(inputValues, { - setError, - close: () => { - selectedRef.current = true; - dismissActionMenuPopup(); - }, - }); - } finally { - setIsSubmitting(false); - } - }, - [inputValues, isSubmitting] - ); + const handlePrimaryPressInner = async (action: ActionMenuPrimaryAction): Promise => { + if (isSubmitting) return; + setError(null); + setIsSubmitting(true); + try { + await action.onPress(inputValues, { + setError, + close: () => { + selectedRef.current = true; + dismissActionMenuPopup(); + }, + }); + } finally { + setIsSubmitting(false); + } + }; // `isSubmitting` is React state — a rapid double-tap on the primary // action button (Import-Nsec, Claim Username, etc.) lands twice into @@ -359,10 +356,10 @@ export function ActionMenuHost() { // row clears the absolutely-positioned footer. Two Menu.Items + safe // area easily exceed any hardcoded value, so we measure dynamically. const [footerHeight, setFooterHeight] = useState(0); - const handleFooterLayout = useCallback((e: LayoutChangeEvent) => { + const handleFooterLayout = (e: LayoutChangeEvent) => { const h = Math.round(e.nativeEvent.layout.height); setFooterHeight((prev) => (Math.abs(prev - h) > 1 ? h : prev)); - }, []); + }; useEffect(() => { if (!hasFooter) setFooterHeight(0); }, [hasFooter]); @@ -622,6 +619,7 @@ export function ActionMenuHost() { // overrides the scrollable type to `VIEW` on mount and pan // gestures dismiss the sheet instead of scrolling the list. // Only needed when there *is* a nested scroll container. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- `useDirectView` is an undocumented gorhom internal not in the public contentContainerProps type; `as never` forces it through (see comment above). contentContainerProps={useScrollBody ? ({ useDirectView: true } as never) : undefined} // gorhom's `BottomSheetFooter` slot — pinned absolute at the // sheet's animated bottom edge AND auto-tracks the keyboard diff --git a/shared/blocks/popup/PopupHost.tsx b/shared/blocks/popup/PopupHost.tsx index ecdb8a749..1101cc662 100644 --- a/shared/blocks/popup/PopupHost.tsx +++ b/shared/blocks/popup/PopupHost.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Log } from '@/shared/lib/logger'; import { Keyboard, @@ -594,23 +594,23 @@ function SheetPopup() { ); const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); - const pushCustomPage = useCallback( - (sheetId: K, pagePayload: ActionSheetPayloads[K]) => { - setCustomNavDirection('forward'); - setCustomStack((prev) => [...prev, { sheetId, payload: pagePayload }]); - }, - [] - ); + const pushCustomPage = ( + sheetId: K, + pagePayload: ActionSheetPayloads[K] + ) => { + setCustomNavDirection('forward'); + setCustomStack((prev) => [...prev, { sheetId, payload: pagePayload }]); + }; - const popCustomPage = useCallback(() => { + const popCustomPage = () => { setCustomNavDirection('back'); setCustomStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev)); - }, []); + }; const activeCustomPage = useMemo(() => { if (!isCustom || !payload) return null; if (customStack.length > 0) return customStack[customStack.length - 1]; - return { sheetId: payload.sheetId, payload: payload.payload } as CustomSheetPage; + return { sheetId: payload.sheetId, payload: payload.payload } satisfies CustomSheetPage; }, [customStack, isCustom, payload]); const canPopCustomPage = customStack.length > 1; @@ -623,7 +623,9 @@ function SheetPopup() { return; } - setCustomStack([{ sheetId: current.sheetId, payload: current.payload } as CustomSheetPage]); + setCustomStack([ + { sheetId: current.sheetId, payload: current.payload } satisfies CustomSheetPage, + ]); setCustomNavDirection('forward'); setCustomFooterConfig(null); }, [isOpen, current]); @@ -652,8 +654,8 @@ function SheetPopup() { const confirmedProgress = useSharedValue(standardPayload?.status === 'confirmed' ? 1 : 0); // Opaque muted green: blend overlay (card bg) with success. success-soft is transparent; we need solid. - const overlayColor = useMemo(() => sanitizeColor(String(overlay)), [overlay]); - const successMutedColor = useMemo(() => blendColors(overlay, success, 0.15), [overlay, success]); + const overlayColor = sanitizeColor(String(overlay)); + const successMutedColor = blendColors(overlay, success, 0.15); useEffect(() => { if (standardPayload?.status === 'confirmed') { @@ -695,17 +697,12 @@ function SheetPopup() { }; }, []); - const customSnapPoints = useMemo( - () => (layoutConfig?.mode === 'snapPoints' ? [...layoutConfig.snapPoints] : undefined), - [layoutConfig] - ); - const customMaxDynamicContentSize = useMemo( - () => - isCustom && layoutConfig?.mode === 'contentHeight' - ? Math.max(0, windowHeight - insets.top) - : undefined, - [insets.top, isCustom, layoutConfig?.mode, windowHeight] - ); + const customSnapPoints = + layoutConfig?.mode === 'snapPoints' ? [...layoutConfig.snapPoints] : undefined; + const customMaxDynamicContentSize = + isCustom && layoutConfig?.mode === 'contentHeight' + ? Math.max(0, windowHeight - insets.top) + : undefined; // Pinned-footer clearance for scrollable sheets: the gorhom footer overlays // the scroll content, so the content needs bottom padding to scroll fully @@ -775,57 +772,57 @@ function SheetPopup() { // alongside heroui's path is safe. const handleNativeSheetClose = () => handleOpenChange(false); - const renderCustomFooter = useCallback( - (props: { animatedFooterPosition: any }) => { - if (!isCustom) return null; - if (!customFooterConfig || customFooterConfig.buttons.length === 0) return null; - return ( - + const renderCustomFooter = (props: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- gorhom BottomSheetFooter render-prop: animatedFooterPosition is a SharedValue the lib does not export a usable name for here + animatedFooterPosition: any; + }) => { + if (!isCustom) return null; + if (!customFooterConfig || customFooterConfig.buttons.length === 0) return null; + return ( + + setPinnedFooterHeight(event.nativeEvent.layout.height) + : undefined + }> setPinnedFooterHeight(event.nativeEvent.layout.height) - : undefined - }> - - {customFooterConfig.buttons.map((button, index) => { - const variant = button.variant ?? (index === 0 ? 'primary' : 'tertiary'); - const buttonClassName = getSheetButtonClassName(variant); - const className = - customFooterConfig.layout === 'row' - ? [buttonClassName, 'flex-1'].filter(Boolean).join(' ') - : buttonClassName; - - return ( - - ); - })} - + className={customFooterConfig.layout === 'row' ? 'flex-row' : undefined} + style={{ gap: 10 }}> + {customFooterConfig.buttons.map((button, index) => { + const variant = button.variant ?? (index === 0 ? 'primary' : 'tertiary'); + const buttonClassName = getSheetButtonClassName(variant); + const className = + customFooterConfig.layout === 'row' + ? [buttonClassName, 'flex-1'].filter(Boolean).join(' ') + : buttonClassName; + + return ( + + ); + })} - - ); - }, - [isCustom, customFooterConfig, isScrollableContentHeight, layoutConfig?.mode] - ); + + + ); + }; if (destroyed) return null; @@ -868,9 +865,9 @@ function SheetPopup() { // contentHeight modes show the default handle. undefined : hasLiveStatus - ? (props: any) => ( - - ) + ? ( + props: any // eslint-disable-line @typescript-eslint/no-explicit-any -- gorhom/bottom-sheet render-prop; injected props lack an exported type that unifies with our {...props} spread + ) => : undefined } className={isCustom ? undefined : 'mx-4'} @@ -883,9 +880,9 @@ function SheetPopup() { backgroundClassName={isCustom ? 'bg-overlay' : 'bg-surface rounded-[32px]'} backgroundComponent={ hasLiveStatus - ? (props: any) => ( - - ) + ? ( + props: any // eslint-disable-line @typescript-eslint/no-explicit-any -- gorhom/bottom-sheet render-prop; injected props lack an exported type that unifies with our {...props} spread + ) => : undefined } contentContainerClassName={ diff --git a/shared/blocks/status/LoadingIndicator.tsx b/shared/blocks/status/LoadingIndicator.tsx index 56a609559..de6b9e6ed 100644 --- a/shared/blocks/status/LoadingIndicator.tsx +++ b/shared/blocks/status/LoadingIndicator.tsx @@ -42,12 +42,12 @@ const AnimatedPath = Animated.createAnimatedComponent(Path); export type Phase = 'idle' | 'loading' | 'done'; export type Result = 'success' | 'error' | 'reverted' | 'warning'; -export interface ConfirmationProgress { +interface ConfirmationProgress { currentConfirmations: number | null; requiredConfirmations: number; } -export interface SegmentedProgress { +interface SegmentedProgress { completedSegments: number | null; segmentCount: number; } @@ -62,7 +62,7 @@ type LoadingIndicatorVisualProps = { visualDisabled?: boolean; }; -export interface LoadingIndicatorProps extends LoadingIndicatorVisualProps { +interface LoadingIndicatorProps extends LoadingIndicatorVisualProps { phase?: Phase; result?: Result; size?: number; @@ -173,7 +173,7 @@ const T_ICON = 550; let loadingIndicatorVisualInstance = 0; -export interface NormalizedSegmentedProgress { +interface NormalizedSegmentedProgress { segmentCount: number; completedSegments: number; } @@ -417,12 +417,9 @@ export function LoadingIndicator({ ...(typeof visualExtra === 'function' ? visualExtra() : (visualExtra ?? {})), }), }); - const handleVisualLayout = React.useCallback( - (event: LayoutChangeEvent) => { - visualLayout.onLayout(event); - }, - [visualLayout] - ); + const handleVisualLayout = (event: LayoutChangeEvent) => { + visualLayout.onLayout(event); + }; // Mount in terminal state when phase='done': skip the ring/fill/icon // choreography and render the resolved frame immediately. Matches @@ -711,5 +708,3 @@ export function LoadingIndicator({ ); } - -export default LoadingIndicator; diff --git a/shared/blocks/status/index.ts b/shared/blocks/status/index.ts index 7e5e75281..fb15e8558 100644 --- a/shared/blocks/status/index.ts +++ b/shared/blocks/status/index.ts @@ -1,11 +1,4 @@ -export { LoadingIndicator, default } from './LoadingIndicator'; -export type { - Phase, - Result, - LoadingIndicatorProps, - ConfirmationProgress, - SegmentedProgress, - NormalizedSegmentedProgress, -} from './LoadingIndicator'; +export { LoadingIndicator } from './LoadingIndicator'; +export type { Phase, Result } from './LoadingIndicator'; export { mapCheckpointStatusToIndicator } from './mapCheckpointStatus'; -export type { CheckpointStatus, IndicatorTuple } from './mapCheckpointStatus'; +export type { CheckpointStatus } from './mapCheckpointStatus'; diff --git a/shared/blocks/status/mapCheckpointStatus.ts b/shared/blocks/status/mapCheckpointStatus.ts index 5200d6268..f683f0c75 100644 --- a/shared/blocks/status/mapCheckpointStatus.ts +++ b/shared/blocks/status/mapCheckpointStatus.ts @@ -15,7 +15,7 @@ export type CheckpointStatus = | 'rolled-back' | 'already-spent'; -export interface IndicatorTuple { +interface IndicatorTuple { phase: Phase; result: Result; } diff --git a/shared/blocks/transfer/TransferCard.tsx b/shared/blocks/transfer/TransferCard.tsx index 6478f196e..02f019cfe 100644 --- a/shared/blocks/transfer/TransferCard.tsx +++ b/shared/blocks/transfer/TransferCard.tsx @@ -10,7 +10,7 @@ * Used by both SwapTransactionScreen and RebalanceStepRow. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { StyleSheet } from 'react-native'; import opacity from 'hex-color-opacity'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; @@ -31,10 +31,10 @@ export const TransferCard = React.memo( ({ accentColor: accentColorProp, children }: TransferCardProps) => { const muted = useThemeColor('muted'); - const accentColor = useMemo(() => accentColorProp ?? muted, [accentColorProp, muted]); + const accentColor = accentColorProp ?? muted; // Always show the tinted border — matches Transactions component exactly. - const borderColor = useMemo(() => opacity(accentColor, 0.3), [accentColor]); + const borderColor = opacity(accentColor, 0.3); return ( diff --git a/shared/blocks/transfer/TransferEntryRow.tsx b/shared/blocks/transfer/TransferEntryRow.tsx index 388e07e58..fc29ac51e 100644 --- a/shared/blocks/transfer/TransferEntryRow.tsx +++ b/shared/blocks/transfer/TransferEntryRow.tsx @@ -13,6 +13,7 @@ */ import React from 'react'; +import { INVARIANT_WHITE } from '@/shared/lib/brandColors'; import { StyleSheet } from 'react-native'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import opacity from 'hex-color-opacity'; @@ -85,7 +86,7 @@ export const TransferEntryRow = React.memo( diff --git a/shared/blocks/transfer/TransferSeparator.tsx b/shared/blocks/transfer/TransferSeparator.tsx index 50d3df378..68d011fd2 100644 --- a/shared/blocks/transfer/TransferSeparator.tsx +++ b/shared/blocks/transfer/TransferSeparator.tsx @@ -11,6 +11,7 @@ */ import React from 'react'; +import { INVARIANT_WHITE } from '@/shared/lib/brandColors'; import { StyleSheet } from 'react-native'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { View } from '@/shared/ui/primitives/View/View'; @@ -39,11 +40,11 @@ export const TransferSeparator = React.memo(({ failed, status }: TransferSeparat case 'running': return ; case 'done': - return ; + return ; case 'failed': - return ; + return ; default: - return ; + return ; } }; diff --git a/shared/blocks/transfer/TransferStepChain.tsx b/shared/blocks/transfer/TransferStepChain.tsx index 465d5c957..49b5e6fb8 100644 --- a/shared/blocks/transfer/TransferStepChain.tsx +++ b/shared/blocks/transfer/TransferStepChain.tsx @@ -241,10 +241,10 @@ export const TransferStepChain = React.memo( 'warning', ] as const); - const labelColor = useMemo(() => opacity(foreground, 0.5), [foreground]); - const dimLabelColor = useMemo(() => opacity(foreground, 0.25), [foreground]); + const labelColor = opacity(foreground, 0.5); + const dimLabelColor = opacity(foreground, 0.25); - const chain = useMemo(() => buildChain(status, middleLabel), [middleLabel, status]); + const chain = buildChain(status, middleLabel); const currentIdx = statusToCurrentIdx(status); // ── Stagger delay computation ── diff --git a/shared/hooks/useHeaderSearch.ts b/shared/hooks/useHeaderSearch.ts index e4c875d4e..f5ec8524e 100644 --- a/shared/hooks/useHeaderSearch.ts +++ b/shared/hooks/useHeaderSearch.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useRef } from 'react'; import { Keyboard } from 'react-native'; import { log } from '@/shared/lib/logger'; @@ -18,13 +18,13 @@ export function useHeaderSearch() { const [seedText, setSeedText] = useState(''); const openedAtRef = useRef(0); - const onOpenSearch = useCallback(() => { + const onOpenSearch = () => { openedAtRef.current = Date.now(); log.debug('header_search.open'); setIsSearching(true); - }, []); + }; - const onCloseSearch = useCallback(() => { + const onCloseSearch = () => { const duration = openedAtRef.current ? Date.now() - openedAtRef.current : 0; log.debug('header_search.close', { duration_ms: duration, @@ -36,19 +36,19 @@ export function useHeaderSearch() { setSeedText(''); setClearKey((prev) => prev + 1); Keyboard.dismiss(); - }, [searchQuery]); + }; - const onSearchChange = useCallback((query: string) => { + const onSearchChange = (query: string) => { setSearchQuery(query); - }, []); + }; // Programmatically run a query (e.g. tapping a recent-search chip): set the // results immediately and remount the input seeded with the text. - const setQuery = useCallback((query: string) => { + const setQuery = (query: string) => { setSearchQuery(query); setSeedText(query); setClearKey((prev) => prev + 1); - }, []); + }; return { isSearching, diff --git a/shared/hooks/useIdentityName.ts b/shared/hooks/useIdentityName.ts index 1ffd2564a..2d33dd285 100644 --- a/shared/hooks/useIdentityName.ts +++ b/shared/hooks/useIdentityName.ts @@ -1,4 +1,3 @@ -import { useMemo } from 'react'; import { useNostrProfileMetadata } from './useNostrProfileMetadata'; import { resolveIdentityName, type IdentityNameInputs } from '@/shared/lib/identity'; @@ -30,15 +29,11 @@ export function useIdentityName( ): { displayName: string; isLoading: boolean } { const { metadata, isLoading } = useNostrProfileMetadata(pubkey ?? undefined); - const displayName = useMemo( - () => - resolveIdentityName({ - pubkey: pubkey ?? undefined, - nostrProfile: metadata, - ...extra, - }), - [pubkey, metadata, extra] - ); + const displayName = resolveIdentityName({ + pubkey: pubkey ?? undefined, + nostrProfile: metadata, + ...extra, + }); return { displayName, isLoading }; } diff --git a/shared/hooks/useMintInfo.ts b/shared/hooks/useMintInfo.ts index 4659c5622..c57444580 100644 --- a/shared/hooks/useMintInfo.ts +++ b/shared/hooks/useMintInfo.ts @@ -4,13 +4,7 @@ import type { MintInfo } from '@cashu/cashu-ts'; import { useMintManagement } from '@/features/mint/hooks/useMintManagement'; import { cashuLog } from '@/shared/lib/logger'; - -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; /** * Loads mint info for a given mint URL. diff --git a/shared/hooks/useNostrProfile.ts b/shared/hooks/useNostrProfile.ts index db0e7080f..cb9c89f0c 100644 --- a/shared/hooks/useNostrProfile.ts +++ b/shared/hooks/useNostrProfile.ts @@ -112,9 +112,9 @@ export function useNostrProfile(pubkey: string | null): UseNostrProfileResult { return () => controller.abort(); }, [fetchProfile]); - const refetch = useCallback(() => { + const refetch = () => { void fetchProfile(); - }, [fetchProfile]); + }; return { data, isLoading, error, refetch }; } diff --git a/shared/hooks/useNostrProfileMetadata.ts b/shared/hooks/useNostrProfileMetadata.ts index c1cfb3967..be4d0d131 100644 --- a/shared/hooks/useNostrProfileMetadata.ts +++ b/shared/hooks/useNostrProfileMetadata.ts @@ -123,7 +123,7 @@ export function useNostrProfileMetadataMany( // Stable key from sorted pubkeys so a fresh array reference with // identical contents doesn't re-trigger memos / subscriptions. - const stableKey = useMemo(() => [...pubkeys].sort().join(','), [pubkeys]); + const stableKey = [...pubkeys].sort().join(','); const metadata = useMemo(() => { const map = new Map(); diff --git a/shared/hooks/usePaymentStatusListener.ts b/shared/hooks/usePaymentStatusListener.ts index 74547cbf7..d770dedf9 100644 --- a/shared/hooks/usePaymentStatusListener.ts +++ b/shared/hooks/usePaymentStatusListener.ts @@ -16,16 +16,10 @@ import { usePaymentStatusStore } from '@/shared/stores/runtime/paymentStatusStor import { isSwapStatusActive } from '@/shared/stores/runtime/swapStatusStore'; import { amountToNumber } from '@/shared/lib/cashu/amount'; import { paymentLog } from '@/shared/lib/logger'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; const NPC_RECEIVE_POPUP_MAX_AGE_MS = 5 * 60 * 1000; -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - function activeMintUrlLogFields(mintUrl: string | null | undefined): Record { return { activeHasMintUrl: !!mintUrl, @@ -263,8 +257,7 @@ export function usePaymentStatusListener(): void { const active = store.active; const amount = amountToNumber(entry.amount); if ( - !active || - active.variant !== 'receive-ecash' || + active?.variant !== 'receive-ecash' || active.mintUrl !== mintUrl || active.amount !== amount || active.receiveEntryId || diff --git a/shared/hooks/useRelayHealth.ts b/shared/hooks/useRelayHealth.ts index 5bbec97c5..9e43f09d0 100644 --- a/shared/hooks/useRelayHealth.ts +++ b/shared/hooks/useRelayHealth.ts @@ -10,7 +10,7 @@ import { useEffect, useState } from 'react'; import { NDKRelayStatus, useNDK } from '@nostr-dev-kit/ndk-mobile'; -export type RelayHealth = 'connected' | 'connecting' | 'disconnected' | 'failed'; +type RelayHealth = 'connected' | 'connecting' | 'disconnected' | 'failed'; const POLL_MS = 2_000; diff --git a/shared/hooks/useSingleFlight.ts b/shared/hooks/useSingleFlight.ts index cfb994428..3b7f40f12 100644 --- a/shared/hooks/useSingleFlight.ts +++ b/shared/hooks/useSingleFlight.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { useRef } from 'react'; /** * Wraps an async callback so that calls made while a previous call is still @@ -22,21 +22,18 @@ export function useSingleFlight( ): (...args: TArgs) => Promise { const inFlightRef = useRef | null>(null); - return useCallback( - async (...args: TArgs) => { - if (inFlightRef.current) return undefined; - const promise = fn(...args); - inFlightRef.current = promise; - try { - return await promise; - } finally { - if (inFlightRef.current === promise) { - inFlightRef.current = null; - } + return async (...args: TArgs) => { + if (inFlightRef.current) return undefined; + const promise = fn(...args); + inFlightRef.current = promise; + try { + return await promise; + } finally { + if (inFlightRef.current === promise) { + inFlightRef.current = null; } - }, - [fn] - ); + } + }; } /** @@ -55,20 +52,17 @@ export function useKeyedSingleFlight( ): (...args: TArgs) => Promise { const inFlightRef = useRef>>(new Map()); - return useCallback( - async (...args: TArgs) => { - const key = keyOf(...args); - if (inFlightRef.current.has(key)) return undefined; - const promise = fn(...args); - inFlightRef.current.set(key, promise); - try { - return await promise; - } finally { - if (inFlightRef.current.get(key) === promise) { - inFlightRef.current.delete(key); - } + return async (...args: TArgs) => { + const key = keyOf(...args); + if (inFlightRef.current.has(key)) return undefined; + const promise = fn(...args); + inFlightRef.current.set(key, promise); + try { + return await promise; + } finally { + if (inFlightRef.current.get(key) === promise) { + inFlightRef.current.delete(key); } - }, - [fn, keyOf] - ); + } + }; } diff --git a/shared/hooks/useTransactionLocationSection.ts b/shared/hooks/useTransactionLocationSection.ts index 0851a6e50..94a910f16 100644 --- a/shared/hooks/useTransactionLocationSection.ts +++ b/shared/hooks/useTransactionLocationSection.ts @@ -5,7 +5,7 @@ * into a single API surface for the TransactionLocationSection component. */ -import { useState, useCallback } from 'react'; +import { useState } from 'react'; import { log } from '@/shared/lib/logger'; import { useSettingsStore } from '@/shared/stores/global/settingsStore'; @@ -43,23 +43,20 @@ export function useTransactionLocationSection( (state) => state.setTransactionLocation ); - const reveal = useCallback(() => { + const reveal = () => { setIsRevealed(true); - }, []); + }; - const hide = useCallback(() => { + const hide = () => { setIsRevealed(false); - }, []); + }; - const setLocationEnabled = useCallback( - (enabled: boolean) => { - setSendLocationEnabled(enabled); - setJustEnabled(enabled); - }, - [setSendLocationEnabled] - ); + const setLocationEnabled = (enabled: boolean) => { + setSendLocationEnabled(enabled); + setJustEnabled(enabled); + }; - const attachCurrentLocation = useCallback(async (): Promise => { + const attachCurrentLocation = async (): Promise => { if (!transactionId) return false; setIsCapturing(true); @@ -83,7 +80,7 @@ export function useTransactionLocationSection( } finally { setIsCapturing(false); } - }, [transactionId, setTransactionLocation]); + }; return { location, diff --git a/shared/lib/brandColors.ts b/shared/lib/brandColors.ts index be55d7df5..582ce7c0b 100644 --- a/shared/lib/brandColors.ts +++ b/shared/lib/brandColors.ts @@ -65,3 +65,23 @@ export const TOAST_DANGER_DARK_BG = '#9A082E'; /** Theme-invariant dark warning tint for animated frosted payment toasts. */ export const TOAST_WARNING_DARK_BG = '#9A6A08'; + +/** Fixed pink "like" accent (#ff5a7a). Used wherever a liked/favourited cue is + * rendered cross-theme (feed MetricsFooter, image-overlay BottomPanel) so the + * cue reads identically on every theme — sibling to COMMENT_ACCENT. */ +export const LIKE_ACCENT = '#ff5a7a'; + +/** Amber/gold accent (#f59e0b, Tailwind amber-500). Fixed cross-theme warning + * and highlight colour: routing-warning text/icon (SettingsRoutingScreen) and + * the hero-transition gold highlight. */ +export const AMBER_ACCENT = '#f59e0b'; + +/** Theme-invariant dark gradient shown as a wallpaper preview placeholder + * before (or in place of) a resolved palette. Used by UnitPreviewCard and + * WallpaperThumbnail for the `palette[shade] || …` fallback and the solid + * loading background (`.d800`). */ +export const WALLPAPER_PLACEHOLDER = { + d800: '#1a1a1a', + d900: '#0d0d0d', + d950: INVARIANT_BLACK, +} as const; diff --git a/shared/lib/buildMintListItems.ts b/shared/lib/buildMintListItems.ts index 71aeab032..53460fb85 100644 --- a/shared/lib/buildMintListItems.ts +++ b/shared/lib/buildMintListItems.ts @@ -8,13 +8,7 @@ import { import { log } from '@/shared/lib/logger'; import { getMintDisplayName } from '@/shared/lib/url'; - -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; /** * Builds a fully-resolved `MintListItem[]` from trusted mints, their availability, diff --git a/shared/lib/cache/createPubkeyScopedCache.ts b/shared/lib/cache/createPubkeyScopedCache.ts index c61d44975..2ef1b58e6 100644 --- a/shared/lib/cache/createPubkeyScopedCache.ts +++ b/shared/lib/cache/createPubkeyScopedCache.ts @@ -180,7 +180,7 @@ export function createPubkeyScopedCache(opts: PubkeyScopedCacheOpts): Pubk } } - if (negRes && negRes.status === 'fulfilled' && negRes.value) { + if (negRes?.status === 'fulfilled' && negRes.value) { try { const parsed = JSON.parse(negRes.value) as Record; for (const [key, cachedAt] of Object.entries(parsed)) { diff --git a/shared/lib/cashu/cocoOperations.ts b/shared/lib/cashu/cocoOperations.ts index 6841dcdf9..682dd285d 100644 --- a/shared/lib/cashu/cocoOperations.ts +++ b/shared/lib/cashu/cocoOperations.ts @@ -1,13 +1,7 @@ import type { Manager } from '@cashu/coco-core'; import { cashuLog } from '@/shared/lib/logger'; - -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; type PreparedBolt11MintOperation = Awaited>; type PreparedBolt11MeltOperation = Awaited>; diff --git a/shared/lib/cashu/manager.ts b/shared/lib/cashu/manager.ts index 512a009fb..31cd6de30 100644 --- a/shared/lib/cashu/manager.ts +++ b/shared/lib/cashu/manager.ts @@ -35,6 +35,7 @@ import { type P2PKSecretKeyInput, } from '@sovranbitcoin/coco-cashu-plugin-p2pk-import'; import Constants from 'expo-constants'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; // Shared giveaway P2PK key, injected at build time via app.config.js `extra` // (from the non-EXPO_PUBLIC `GIVEAWAY_P2PK_SECRET`). This is intentionally @@ -49,13 +50,6 @@ const GIVEAWAY_P2PK_SECRET: string | null = ? Constants.expoConfig.extra.giveawayP2pkSecret : null; -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - interface Signer { signEvent: (e: EventTemplate) => Promise; } @@ -254,7 +248,7 @@ export class CocoManager { load: async ({ mnemonic }) => { const mnemonicHash = hashMnemonic(mnemonic); const cached = await retrieveCashuSeed(accountIndex); - if (cached && cached.mnemonicHash === mnemonicHash) { + if (cached?.mnemonicHash === mnemonicHash) { initLog('CocoManager', 'seed loaded from SecureStore cache (skipped PBKDF2)'); cashuLog.debug('cashu.manager.seed_cache.hit', { accountIndex }); return cached.seed; diff --git a/shared/lib/cashu/managerInternals.ts b/shared/lib/cashu/managerInternals.ts index 4157b0ace..792937096 100644 --- a/shared/lib/cashu/managerInternals.ts +++ b/shared/lib/cashu/managerInternals.ts @@ -30,6 +30,7 @@ import type { CoreProof, Manager } from '@cashu/coco-core'; import type { Wallet } from '@cashu/cashu-ts'; import { cashuLog } from '@/shared/lib/logger'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; interface ManagerInternals { proofRepository: { @@ -64,13 +65,6 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - /** Ready (UNSPENT, unreserved) proofs for one mint, via the private ProofService. */ export async function getReadyProofs(manager: Manager, mintUrl: string): Promise { cashuLog.debug('cashu.manager_internals.ready_proofs.start', { @@ -108,21 +102,6 @@ export async function getWallet(manager: Manager, mintUrl: string): Promise { - cashuLog.debug('cashu.manager_internals.reserved_proofs.start'); - try { - const proofs = await internals(manager).proofRepository.getReservedProofs(); - cashuLog.debug('cashu.manager_internals.reserved_proofs.done', { count: proofs.length }); - return proofs; - } catch (error) { - cashuLog.warn('cashu.manager_internals.reserved_proofs.failed', { - error: errorMessage(error), - }); - throw error; - } -} - /** * Inflight proofs (transient state during mint/melt), optionally filtered by mint. * Used by the per-mint rebalance recovery to clear leftovers after a melt failure. diff --git a/shared/lib/cashu/migration.ts b/shared/lib/cashu/migration.ts index 580e5709d..ddc6b71aa 100644 --- a/shared/lib/cashu/migration.ts +++ b/shared/lib/cashu/migration.ts @@ -5,13 +5,7 @@ import { store } from '@/redux/store/store.deprecated'; import { RootState } from '@/redux/store/reducer.deprecated'; import { CashuProfile } from '@/redux/cashu/types.deprecated'; import { cashuLog } from '../logger'; - -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; function profileLogFields(profile: CashuProfile): Record { return { diff --git a/shared/lib/cashu/onchainMelt.ts b/shared/lib/cashu/onchainMelt.ts index 37414c1fd..c583aa7e3 100644 --- a/shared/lib/cashu/onchainMelt.ts +++ b/shared/lib/cashu/onchainMelt.ts @@ -10,7 +10,7 @@ function getMetadata(entry: HistoryEntry | null | undefined): EntryRecord | unde } export function getOnchainMeltAddress(entry: HistoryEntry | null | undefined): string | null { - if (!entry || entry.type !== 'melt') { + if (entry?.type !== 'melt') { cashuLog.debug('onchain.melt.address.result', { reason: !entry ? 'missing-entry' : 'wrong-type', type: entry?.type ?? null, diff --git a/shared/lib/cashu/onchainMint.ts b/shared/lib/cashu/onchainMint.ts index 8e8e3d52b..beb93cefe 100644 --- a/shared/lib/cashu/onchainMint.ts +++ b/shared/lib/cashu/onchainMint.ts @@ -41,7 +41,7 @@ function getBip321OnchainAddress(value: string): string | null { } export function getOnchainMintAddress(entry: HistoryEntry | null | undefined): string | null { - if (!entry || entry.type !== 'mint') { + if (entry?.type !== 'mint') { cashuLog.debug('onchain.mint.address.result', { reason: !entry ? 'missing-entry' : 'wrong-type', type: entry?.type ?? null, diff --git a/shared/lib/cashu/utils.ts b/shared/lib/cashu/utils.ts index f85ea3a1d..67e9517cb 100644 --- a/shared/lib/cashu/utils.ts +++ b/shared/lib/cashu/utils.ts @@ -17,13 +17,7 @@ import { getTokenMetadata } from '@cashu/cashu-ts'; import { log } from '../logger'; import { mintLocalId } from '../id'; import { amountToNumber } from './amount'; - -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; /** * Validates if a string is a valid ecash token by attempting to decode it @@ -162,7 +156,7 @@ export function buildReceiveHistoryEntry( export async function attemptRollback(mgr: Manager, operationId: string): Promise { try { const operation = await mgr.ops.send.get(operationId); - if (operation && operation.state === 'prepared') { + if (operation?.state === 'prepared') { await mgr.ops.send.cancel(operationId); } else if (operation && (operation.state === 'pending' || operation.state === 'executing')) { await mgr.ops.send.reclaim(operationId); diff --git a/shared/lib/colorExtraction.ts b/shared/lib/colorExtraction.ts index 1d0bc8fda..b8390d32a 100644 --- a/shared/lib/colorExtraction.ts +++ b/shared/lib/colorExtraction.ts @@ -154,6 +154,7 @@ function clampColor(hex: string): string { // Platform-aware candidate extraction (shared by both hooks) // --------------------------------------------------------------------------- +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-native-image-colors platform-tagged union read behind a runtime Platform.OS check TS cannot narrow function extractCandidates(res: any): (string | undefined)[] { if (Platform.OS === 'android') { return [res.vibrant, res.dominant, res.lightVibrant, res.muted, res.average]; @@ -204,6 +205,7 @@ export function useExtractedColors( let mounted = true; getColors(imageUrl, { fallback, cache: true, key: imageUrl }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-native-image-colors returns a platform-tagged union read behind a runtime Platform.OS check TS cannot narrow .then((res: any) => { if (!mounted || !res) return; @@ -279,6 +281,7 @@ export function useDominantColor( let mounted = true; getColors(imageUrl, { fallback, cache: true, key: imageUrl }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-native-image-colors returns a platform-tagged union read behind a runtime Platform.OS check TS cannot narrow .then((res: any) => { if (!mounted || !res) return; diff --git a/shared/lib/contentShiftGeometry.ts b/shared/lib/contentShiftGeometry.ts new file mode 100644 index 000000000..f5a31628b --- /dev/null +++ b/shared/lib/contentShiftGeometry.ts @@ -0,0 +1,74 @@ +/** + * Pure layout-geometry helpers for content-shift instrumentation. + * + * Rectangle math only — overlap, edges, viewport classification. No registry, + * no logger, no React. Kept separate so the measurement/snapshot logic in + * `contentShiftLog` reads as orchestration over these primitives. + */ + +export type LayoutRect = { + x: number; + y: number; + width: number; + height: number; +}; + +export function validRect(rect: LayoutRect): boolean { + return ( + Number.isFinite(rect.x) && + Number.isFinite(rect.y) && + Number.isFinite(rect.width) && + Number.isFinite(rect.height) + ); +} + +export function rectBottom(rect: LayoutRect): number { + return rect.y + rect.height; +} + +export function rectRight(rect: LayoutRect): number { + return rect.x + rect.width; +} + +function verticalOverlap(a: LayoutRect, b: LayoutRect): number { + return Math.min(rectBottom(a), rectBottom(b)) - Math.max(a.y, b.y); +} + +function horizontalOverlap(a: LayoutRect, b: LayoutRect): number { + return Math.min(rectRight(a), rectRight(b)) - Math.max(a.x, b.x); +} + +export function rectOverlap(a: LayoutRect, b: LayoutRect): { x: number; y: number; area: number } { + const x = horizontalOverlap(a, b); + const y = verticalOverlap(a, b); + return { x, y, area: x > 0 && y > 0 ? x * y : 0 }; +} + +export function viewportFlags(rect: LayoutRect, viewport: { width: number; height: number }) { + const bottom = rectBottom(rect); + const right = rectRight(rect); + const visible = + rect.width > 0 && + rect.height > 0 && + bottom >= 0 && + rect.y <= viewport.height && + right >= 0 && + rect.x <= viewport.width; + const farOutsideY = rect.y < -viewport.height * 2 || rect.y > viewport.height * 3; + const farOutsideX = rect.x < -viewport.width * 2 || rect.x > viewport.width * 3; + const absurdHeight = rect.height > viewport.height * 3; + const absurdWidth = rect.width > viewport.width * 3; + const zeroArea = rect.width <= 0 || rect.height <= 0; + return { + visible, + offscreenTop: bottom < 0, + offscreenBottom: rect.y > viewport.height, + offscreenLeft: right < 0, + offscreenRight: rect.x > viewport.width, + farOutsideY, + farOutsideX, + absurdHeight, + absurdWidth, + zeroArea, + }; +} diff --git a/shared/lib/contentShiftLog.ts b/shared/lib/contentShiftLog.ts index cabb52491..7a8f88c24 100644 --- a/shared/lib/contentShiftLog.ts +++ b/shared/lib/contentShiftLog.ts @@ -29,6 +29,14 @@ import { } from 'react-native'; import { feedLog, monotonicNow } from '@/shared/lib/logger'; +import { + type LayoutRect, + rectBottom, + rectOverlap, + rectRight, + validRect, + viewportFlags, +} from './contentShiftGeometry'; /** Sub-pixel layout deltas are noise from rounding, not a visible shift. */ const SHIFT_EPSILON = 0.5; @@ -89,13 +97,6 @@ export function visualLayoutScopePart(value: string | undefined): string { return normalized || 'default'; } -type LayoutRect = { - x: number; - y: number; - width: number; - height: number; -}; - type MeasureableNode = { measureInWindow?: (cb: (x: number, y: number, width: number, height: number) => void) => void; }; @@ -233,7 +234,7 @@ type VisualScrollMetricsConfig = { extra?: VisualExtra; }; -type VisualScrollMetricsReporter = { +export type VisualScrollMetricsReporter = { onContentSizeChange: (width: number, height: number) => void; onLayout: (event: LayoutChangeEvent) => void; onScroll: (event: NativeSyntheticEvent) => void; @@ -358,37 +359,6 @@ function resolveExtra(extra: VisualExtra | undefined): Record { return extra; } -function validRect(rect: LayoutRect): boolean { - return ( - Number.isFinite(rect.x) && - Number.isFinite(rect.y) && - Number.isFinite(rect.width) && - Number.isFinite(rect.height) - ); -} - -function rectBottom(rect: LayoutRect): number { - return rect.y + rect.height; -} - -function rectRight(rect: LayoutRect): number { - return rect.x + rect.width; -} - -function verticalOverlap(a: LayoutRect, b: LayoutRect): number { - return Math.min(rectBottom(a), rectBottom(b)) - Math.max(a.y, b.y); -} - -function horizontalOverlap(a: LayoutRect, b: LayoutRect): number { - return Math.min(rectRight(a), rectRight(b)) - Math.max(a.x, b.x); -} - -function rectOverlap(a: LayoutRect, b: LayoutRect): { x: number; y: number; area: number } { - const x = horizontalOverlap(a, b); - const y = verticalOverlap(a, b); - return { x, y, area: x > 0 && y > 0 ? x * y : 0 }; -} - function findOverlaps( scope: string, itemKey: string, @@ -487,35 +457,6 @@ function findContainerViolations( return violations; } -function viewportFlags(rect: LayoutRect, viewport: { width: number; height: number }) { - const bottom = rectBottom(rect); - const right = rectRight(rect); - const visible = - rect.width > 0 && - rect.height > 0 && - bottom >= 0 && - rect.y <= viewport.height && - right >= 0 && - rect.x <= viewport.width; - const farOutsideY = rect.y < -viewport.height * 2 || rect.y > viewport.height * 3; - const farOutsideX = rect.x < -viewport.width * 2 || rect.x > viewport.width * 3; - const absurdHeight = rect.height > viewport.height * 3; - const absurdWidth = rect.width > viewport.width * 3; - const zeroArea = rect.width <= 0 || rect.height <= 0; - return { - visible, - offscreenTop: bottom < 0, - offscreenBottom: rect.y > viewport.height, - offscreenLeft: right < 0, - offscreenRight: rect.x > viewport.width, - farOutsideY, - farOutsideX, - absurdHeight, - absurdWidth, - zeroArea, - }; -} - function scopeRecords(scope: string): Map { let records = VISUAL_SCOPE_REGISTRY.get(scope); if (!records) { @@ -1096,44 +1037,38 @@ export function useVisualListLogger( }; }, []); - const onItemSizeChanged = useCallback( - (info: VisualListItemSizeInfo) => { - const current = configRef.current; - if (!visualLoggingEnabled(current.enabled)) return; - const itemKey = current.getItemKey?.(info.itemData, info.index, info.itemKey) ?? info.itemKey; - const state = current.getListState?.() ?? null; - const delta = info.size - info.previous; - const firstMeasure = info.previous <= 0; - const sizeJump = !firstMeasure && Math.abs(delta) >= VISUAL_LIST_SIZE_JUMP_WARN_PX; - const invalidSize = !Number.isFinite(info.size) || info.size <= 0; - const params = { - ...baseParams(), - key: safeLogKey(itemKey), - index: info.index, - itemSize: round(info.size), - previousItemSize: firstMeasure ? null : round(info.previous), - deltaItemSize: firstMeasure ? null : round(delta), - firstMeasure, - sizeJump, - invalidSize, - ...visualVirtualMetrics(state, itemKey, info.index), - ...current.getItemContext?.(info.itemData, info.index), - }; - feedLog[sizeJump || invalidSize ? 'warn' : 'info']('visual.layout.item_size_changed', params); - }, - [baseParams] - ); + const onItemSizeChanged = (info: VisualListItemSizeInfo) => { + const current = configRef.current; + if (!visualLoggingEnabled(current.enabled)) return; + const itemKey = current.getItemKey?.(info.itemData, info.index, info.itemKey) ?? info.itemKey; + const state = current.getListState?.() ?? null; + const delta = info.size - info.previous; + const firstMeasure = info.previous <= 0; + const sizeJump = !firstMeasure && Math.abs(delta) >= VISUAL_LIST_SIZE_JUMP_WARN_PX; + const invalidSize = !Number.isFinite(info.size) || info.size <= 0; + const params = { + ...baseParams(), + key: safeLogKey(itemKey), + index: info.index, + itemSize: round(info.size), + previousItemSize: firstMeasure ? null : round(info.previous), + deltaItemSize: firstMeasure ? null : round(delta), + firstMeasure, + sizeJump, + invalidSize, + ...visualVirtualMetrics(state, itemKey, info.index), + ...current.getItemContext?.(info.itemData, info.index), + }; + feedLog[sizeJump || invalidSize ? 'warn' : 'info']('visual.layout.item_size_changed', params); + }; - const onLoad = useCallback( - (info: VisualListLoadInfo) => { - if (!visualLoggingEnabled(configRef.current.enabled)) return; - feedLog.info('visual.layout.list_load', { - ...baseParams(), - elapsedMs: round(info.elapsedTimeInMs), - }); - }, - [baseParams] - ); + const onLoad = (info: VisualListLoadInfo) => { + if (!visualLoggingEnabled(configRef.current.enabled)) return; + feedLog.info('visual.layout.list_load', { + ...baseParams(), + elapsedMs: round(info.elapsedTimeInMs), + }); + }; const onMetricsChange = useCallback( (metrics: VisualListMetrics) => { @@ -1185,80 +1120,74 @@ export function useVisualListLogger( [baseParams] ); - const onStickyHeaderChange = useCallback( - (info: VisualStickyHeaderInfo) => { - const current = configRef.current; - if (!visualLoggingEnabled(current.enabled)) return; - const fallbackKey = `sticky:${info.index}`; - const itemContext = current.getItemContext?.(info.item, info.index) ?? {}; - const itemKey = - current.getItemKey?.(info.item, info.index, fallbackKey) ?? - contextRowKey(itemContext) ?? - fallbackKey; - const state = current.getListState?.() ?? null; - const params = { - ...baseParams(), - index: info.index, - key: safeLogKey(itemKey), - ...visualListStateSnapshot(state), - ...visualVirtualMetrics(state, itemKey, info.index), - ...safeContextKeys(itemContext), - }; - feedLog.info('visual.layout.sticky_header', params); - requestAnimationFrame(() => { - remeasureVisualLayoutScope(current.scope, 'sticky-header', { - minIntervalMs: 200, - maxItems: 40, - extra: { - stickyIndex: info.index, - stickyKey: itemKey, - stickyItemType: itemContext.itemType ?? null, - }, - }); + const onStickyHeaderChange = (info: VisualStickyHeaderInfo) => { + const current = configRef.current; + if (!visualLoggingEnabled(current.enabled)) return; + const fallbackKey = `sticky:${info.index}`; + const itemContext = current.getItemContext?.(info.item, info.index) ?? {}; + const itemKey = + current.getItemKey?.(info.item, info.index, fallbackKey) ?? + contextRowKey(itemContext) ?? + fallbackKey; + const state = current.getListState?.() ?? null; + const params = { + ...baseParams(), + index: info.index, + key: safeLogKey(itemKey), + ...visualListStateSnapshot(state), + ...visualVirtualMetrics(state, itemKey, info.index), + ...safeContextKeys(itemContext), + }; + feedLog.info('visual.layout.sticky_header', params); + requestAnimationFrame(() => { + remeasureVisualLayoutScope(current.scope, 'sticky-header', { + minIntervalMs: 200, + maxItems: 40, + extra: { + stickyIndex: info.index, + stickyKey: itemKey, + stickyItemType: itemContext.itemType ?? null, + }, }); - }, - [baseParams] - ); + }); + }; - const onViewableItemsChanged = useCallback( - (info: VisualViewabilityInfo) => { - const nowMs = monotonicNow(); - if (nowMs - lastViewabilityLogAtRef.current < VISUAL_LIST_VIEWABILITY_LOG_INTERVAL_MS) return; - lastViewabilityLogAtRef.current = nowMs; - const current = configRef.current; - if (!visualLoggingEnabled(current.enabled)) return; - const state = current.getListState?.() ?? null; - const buffered = visualBufferedRangeSummary(state, current, current.scope); - const virtualPositionChunks = visualVirtualPositionChunks(state, current, current.scope); - feedLog.info('visual.layout.viewability', { + const onViewableItemsChanged = (info: VisualViewabilityInfo) => { + const nowMs = monotonicNow(); + if (nowMs - lastViewabilityLogAtRef.current < VISUAL_LIST_VIEWABILITY_LOG_INTERVAL_MS) return; + lastViewabilityLogAtRef.current = nowMs; + const current = configRef.current; + if (!visualLoggingEnabled(current.enabled)) return; + const state = current.getListState?.() ?? null; + const buffered = visualBufferedRangeSummary(state, current, current.scope); + const virtualPositionChunks = visualVirtualPositionChunks(state, current, current.scope); + feedLog.info('visual.layout.viewability', { + ...baseParams(), + ...visualListStateSnapshot(state), + start: info.start, + end: info.end, + startBuffered: info.startBuffered, + endBuffered: info.endBuffered, + viewableCount: info.viewableItems.length, + changedCount: info.changed.length, + bufferedCount: buffered.length, + viewable: viewTokenSummary(info.viewableItems, current, state), + changed: viewTokenSummary(info.changed, current, state), + buffered, + }); + for (const chunk of virtualPositionChunks) { + feedLog[chunk.summary.virtualAnomaly ? 'warn' : 'info']('visual.layout.virtual_positions', { ...baseParams(), ...visualListStateSnapshot(state), - start: info.start, - end: info.end, - startBuffered: info.startBuffered, - endBuffered: info.endBuffered, - viewableCount: info.viewableItems.length, - changedCount: info.changed.length, - bufferedCount: buffered.length, - viewable: viewTokenSummary(info.viewableItems, current, state), - changed: viewTokenSummary(info.changed, current, state), - buffered, + totalRows: chunk.totalRows, + chunkIndex: chunk.chunkIndex, + chunkCount: chunk.chunkCount, + rowCount: chunk.rows.length, + ...chunk.summary, + rows: chunk.rows, }); - for (const chunk of virtualPositionChunks) { - feedLog[chunk.summary.virtualAnomaly ? 'warn' : 'info']('visual.layout.virtual_positions', { - ...baseParams(), - ...visualListStateSnapshot(state), - totalRows: chunk.totalRows, - chunkIndex: chunk.chunkIndex, - chunkCount: chunk.chunkCount, - rowCount: chunk.rows.length, - ...chunk.summary, - rows: chunk.rows, - }); - } - }, - [baseParams] - ); + } + }; return { onItemSizeChanged, @@ -1323,39 +1252,30 @@ export function useVisualScrollMetricsLogger( [onMetricsChange] ); - const onContentSizeChange = useCallback( - (width: number, height: number) => { - metricsRef.current.contentWidth = width; - metricsRef.current.contentHeight = height; - reportNow('content-size'); - }, - [reportNow] - ); + const onContentSizeChange = (width: number, height: number) => { + metricsRef.current.contentWidth = width; + metricsRef.current.contentHeight = height; + reportNow('content-size'); + }; - const onLayout = useCallback( - (event: LayoutChangeEvent) => { - metricsRef.current.viewportWidth = event.nativeEvent.layout.width; - metricsRef.current.viewportHeight = event.nativeEvent.layout.height; - reportNow('layout'); - }, - [reportNow] - ); + const onLayout = (event: LayoutChangeEvent) => { + metricsRef.current.viewportWidth = event.nativeEvent.layout.width; + metricsRef.current.viewportHeight = event.nativeEvent.layout.height; + reportNow('layout'); + }; - const onScroll = useCallback( - (event: NativeSyntheticEvent) => { - const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; - metricsRef.current = { - contentHeight: contentSize.height, - contentWidth: contentSize.width, - offsetX: contentOffset.x, - offsetY: contentOffset.y, - viewportHeight: layoutMeasurement.height, - viewportWidth: layoutMeasurement.width, - }; - reportNow('scroll'); - }, - [reportNow] - ); + const onScroll = (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + metricsRef.current = { + contentHeight: contentSize.height, + contentWidth: contentSize.width, + offsetX: contentOffset.x, + offsetY: contentOffset.y, + viewportHeight: layoutMeasurement.height, + viewportWidth: layoutMeasurement.width, + }; + reportNow('scroll'); + }; return { onContentSizeChange, onLayout, onScroll, reportNow }; } @@ -1375,9 +1295,9 @@ export function useVisualLayoutLogger(config: VisualLayoutConfig): VisualLayoutR configRef.current = config; viewportRef.current = viewport; - const setRef = useCallback((node: MeasureableNode | null) => { + const setRef = (node: MeasureableNode | null) => { nodeRef.current = node; - }, []); + }; const measureNow = useCallback((reason: string, extraOverride?: Record) => { const node = nodeRef.current; @@ -1521,15 +1441,12 @@ export function useVisualLayoutLogger(config: VisualLayoutConfig): VisualLayoutR }); }, []); - const onLayout = useCallback( - (event: LayoutChangeEvent) => { - if (!visualLoggingEnabled(configRef.current.enabled)) return; - const { x, y, width, height } = event.nativeEvent.layout; - localRectRef.current = { x, y, width, height }; - measureNow('layout'); - }, - [measureNow] - ); + const onLayout = (event: LayoutChangeEvent) => { + if (!visualLoggingEnabled(configRef.current.enabled)) return; + const { x, y, width, height } = event.nativeEvent.layout; + localRectRef.current = { x, y, width, height }; + measureNow('layout'); + }; useEffect(() => { const current = configRef.current; diff --git a/shared/lib/date.ts b/shared/lib/date.ts index 34aa1e9a9..b649355e8 100644 --- a/shared/lib/date.ts +++ b/shared/lib/date.ts @@ -30,7 +30,7 @@ import { useSettingsStore } from '@/shared/stores/global/settingsStore'; type DateInput = Date | number | string; -export type AbsoluteDateStyle = +type AbsoluteDateStyle = /** Time of day. en-US: `3:42 PM`. en-GB / de-DE: `15:42`. */ | 'time' /** Compact short date. en-US: `Mar 16, 2026`. en-GB: `16 Mar 2026`. */ @@ -45,7 +45,7 @@ export type AbsoluteDateStyle = * round-trip identically across devices. */ | 'iso'; -export type RelativeDateStyle = +type RelativeDateStyle = /** `Intl.RelativeTimeFormat` verbose form: `5 minutes ago`, `yesterday`, * `in 3 days`. Use for presence indicators and one-shot timestamps * where the label has room to breathe. */ diff --git a/shared/lib/downloadedThemeRegistry.ts b/shared/lib/downloadedThemeRegistry.ts index 10adb5339..942a0f2f1 100644 --- a/shared/lib/downloadedThemeRegistry.ts +++ b/shared/lib/downloadedThemeRegistry.ts @@ -56,7 +56,7 @@ export function registerDownloadedTheme(data: DownloadedThemeData): boolean { } // 4. Set image source (file:// URI for downloaded images) - (backgroundImageThemes as Record)[themeName] = { uri: localUri }; + (backgroundImageThemes as Record)[themeName] = { uri: localUri }; // 5. Set display name (backgroundThemeDisplayNames as Record)[themeName] = displayName; @@ -90,7 +90,7 @@ export function unregisterDownloadedTheme(themeName: string): void { const idx = BACKGROUND_THEME_NAMES.indexOf(themeName); if (idx !== -1) BACKGROUND_THEME_NAMES.splice(idx, 1); - delete (backgroundImageThemes as Record)[themeName]; + delete (backgroundImageThemes as Record)[themeName]; delete (backgroundThemeDisplayNames as Record)[themeName]; delete (backgroundThemeDominantColors as Record)[themeName]; delete (backgroundThemeGradientColors as Record)[themeName]; diff --git a/shared/lib/getMintCatalog.ts b/shared/lib/getMintCatalog.ts index a56547d68..94130d3a8 100644 --- a/shared/lib/getMintCatalog.ts +++ b/shared/lib/getMintCatalog.ts @@ -40,6 +40,7 @@ import { import { useAuditMintStore } from '@/shared/stores/global/auditMintStore'; import { useKYMMintStore } from '@/shared/stores/global/kymMintStore'; import { useMintProfileStore } from '@/shared/stores/global/mintProfileStore'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; type MintCatalogNetworkMode = 'cache-only' | 'cache-first' | 'network-first'; @@ -61,13 +62,6 @@ function hasCatalogFields(entry: MintCatalogEntry): boolean { return Object.values(entry).some((value) => value !== undefined); } -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - function readCachedEntry(mintUrl: string): { entry: MintCatalogEntry; info: unknown } { const audit = useAuditMintStore.getState().getCached(mintUrl); const kym = useKYMMintStore.getState().getCached(mintUrl); @@ -129,7 +123,7 @@ async function resolveNostrProfile( }); return null; }); - if (profile && profile.isOk()) { + if (profile?.isOk()) { const { followers, score } = profile.value; useMintProfileStore.getState().setCached(mintUrl, followers, score); log.info('mint.catalog.profile.fetch_success', { @@ -182,7 +176,7 @@ async function fetchEntry( let info: unknown = isMintInfoObject(cached.info) ? cached.info : null; // Audit data + info from the audit endpoint when available … - if (auditRes && auditRes.isOk()) { + if (auditRes?.isOk()) { const audit = auditRes.value; const { score } = transformAuditData(audit); entry.auditScore = score; @@ -231,7 +225,7 @@ async function fetchEntry( }); } - if (reviewRes && reviewRes.isOk()) { + if (reviewRes?.isOk()) { const review = reviewRes.value; if (review.score !== null) { entry.kymScore = review.score; diff --git a/shared/lib/imageLoadError.ts b/shared/lib/imageLoadError.ts new file mode 100644 index 000000000..6c9f1935d --- /dev/null +++ b/shared/lib/imageLoadError.ts @@ -0,0 +1,16 @@ +/** + * Normalize a React Native onError event into a readable string. + * + * The error shape varies (`event.error`, `event.nativeEvent.error`, or the raw + * value), so this single owner unwraps it for logging — used by every surface + * that reports an image load failure. + */ +export function describeImageLoadError(event: unknown): string { + if (event && typeof event === 'object') { + const directError = (event as { error?: unknown }).error; + if (typeof directError === 'string') return directError; + const nativeEvent = (event as { nativeEvent?: { error?: unknown } }).nativeEvent; + if (typeof nativeEvent?.error === 'string') return nativeEvent.error; + } + return String(event ?? 'unknown'); +} diff --git a/shared/lib/loggerCore.ts b/shared/lib/loggerCore.ts index 674c239d4..2fa6ab551 100644 --- a/shared/lib/loggerCore.ts +++ b/shared/lib/loggerCore.ts @@ -23,6 +23,9 @@ import { useEffect } from 'react'; import { Platform } from 'react-native'; +import { compactValue, redactKnownSecretSubstrings } from './loggerValueCompact'; +import { getCallerLocation } from './loggerSourceLocation'; + // ─── Types ─────────────────────────────────────────────────────────────────── type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; @@ -261,315 +264,6 @@ function getEntryDeviceInfo(): Record { return _cachedEntryDeviceInfo; } -// ─── Value Summarization ───────────────────────────────────────────────────── -// -// Detect known verbose patterns and replace with a compact summary: -// { _kind: "jwt", len: 512, preview: "eyJhbGciOi…" } - -// Secret patterns: a previewed prefix is itself sensitive — emit `_kind, len` -// only. Order: secret patterns run before LONG_STRING_PATTERNS so a string -// matching both is classified as the secret it actually is. -const SECRET_STRING_PATTERNS: { name: string; test: (s: string) => boolean }[] = [ - { name: 'pem_key', test: (s) => s.includes('-----BEGIN') }, - { name: 'jwt', test: (s) => /^eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(s) }, - { name: 'data_uri', test: (s) => /^data:[^;]+;base64,/.test(s) }, - { name: 'connection_str', test: (s) => /^(postgres|mysql|mongodb|redis|wss?):\/\//.test(s) }, - { name: 'nsec', test: (s) => /^nsec1[023456789acdefghjklmnpqrstuvwxyz]{58}$/.test(s) }, - { name: 'cashu_token', test: (s) => s.startsWith('cashuA') || s.startsWith('cashuB') }, - { name: 'lightning_invoice', test: (s) => /^ln(bc|tb|tbs)[0-9a-z]{50,}/i.test(s) }, - // A bare 32-byte hex string is the secp256k1 private-key length. A private - // key and a public key / event id are indistinguishable by value (both are - // 64 hex chars), so we cannot safely preview *any* of them: a 32-char preview - // would leak half a private key. Classify all 64-char hex as a secret and - // emit only `{ _kind, len }` (no preview). Deliberately log npubs (which keep - // their preview) when you need a readable identity in logs. Runs before the - // base64/hex long-string patterns, which would otherwise preview it. - { name: 'hex32', test: (s) => /^(0x)?[0-9a-fA-F]{64}$/.test(s) }, - // BIP32 extended PRIVATE keys (xprv/yprv/zprv + testnet tprv/uprv/vprv). - // base58, ~111 chars — without this they fall through to the base64 long - // pattern and (being ≤120) get logged in full. - { - name: 'xprv', - test: (s) => /^(xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{100,}$/.test(s), - }, - // WIF private keys: base58, mainnet 5/K/L, testnet 9/c, 51-52 chars. - // Otherwise classified as a generic long_string and logged in full. - { name: 'wif', test: (s) => /^[59cKL][1-9A-HJ-NP-Za-km-z]{50,51}$/.test(s) }, - // base64 that decodes to a 32- or 64-byte payload — the size of a private key - // or seed. Ambiguous (could be a 32-byte hash) but we hide rather than risk - // leaking key material; the length is still reported via `len`. - { - name: 'base64_key', - test: (s) => /^[A-Za-z0-9+/]{43}={0,1}$|^[A-Za-z0-9+/]{86,88}={0,2}$/.test(s), - }, -]; - -const EMBEDDED_SECRET_PATTERNS: { replacement: string; pattern: RegExp }[] = [ - { - replacement: '', - pattern: /\bnsec1[023456789acdefghjklmnpqrstuvwxyz]{58}\b/g, - }, - { - replacement: '', - pattern: /\b(xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{100,}/g, - }, - { - replacement: '', - pattern: /\bcashu[AB][A-Za-z0-9_-]{20,}/g, - }, - { - replacement: '', - pattern: /\bln(bc|tb|tbs)[0-9a-z]{50,}/gi, - }, - { - replacement: '', - pattern: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, - }, -]; - -// `noPreview` marks opaque encodings whose first 32 chars could themselves be -// key material — emit `{ _kind, len }` only, never the bytes (even when short -// enough to otherwise print in full). -const LONG_STRING_PATTERNS: { name: string; noPreview?: boolean; test: (s: string) => boolean }[] = - [ - { name: 'npub', test: (s) => /^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/.test(s) }, - { name: 'base64', noPreview: true, test: (s) => /^[A-Za-z0-9+/]{60,}={0,2}$/.test(s) }, - { name: 'hex', noPreview: true, test: (s) => /^(0x)?[0-9a-fA-F]{40,}$/.test(s) }, - { - name: 'uuid', - test: (s) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s), - }, - { name: 'url', test: (s) => /^https?:\/\/.{80,}/.test(s) }, - { name: 'json_blob', test: (s) => s.length > 200 && (s[0] === '{' || s[0] === '[') }, - { name: 'xml_blob', test: (s) => s.length > 200 && s.trimStart().startsWith('<') }, - // Opaque base58 (>=32 chars): could be a Solana pubkey, a base58-encoded key, - // or other key material. Indistinguishable by value, so never show the bytes. - { - name: 'base58', - noPreview: true, - test: (s) => /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(s) && s.length >= 32, - }, - ]; - -type StringClass = - | { kind: 'secret'; name: string } - | { kind: 'long'; name: string; noPreview?: boolean }; - -function classifyString(s: string): StringClass { - for (const p of SECRET_STRING_PATTERNS) if (p.test(s)) return { kind: 'secret', name: p.name }; - for (const p of LONG_STRING_PATTERNS) - if (p.test(s)) return { kind: 'long', name: p.name, noPreview: p.noPreview }; - return { kind: 'long', name: 'long_string' }; -} - -type Compact = string | { _kind: string; len: number; preview?: string }; - -function redactKnownSecretSubstrings(s: string): string { - let out = s; - for (const { pattern, replacement } of EMBEDDED_SECRET_PATTERNS) { - out = out.replace(pattern, replacement); - } - return out; -} - -function summarizeString(s: string, maxLen: number): Compact { - const c = classifyString(s); - if (c.kind === 'secret') return { _kind: c.name, len: s.length }; - const redacted = redactKnownSecretSubstrings(s); - if (redacted !== s) { - if (redacted.length <= maxLen) return redacted; - return { _kind: 'redacted_string', len: s.length, preview: redacted.slice(0, 32) + '…' }; - } - // Opaque encodings (hex/base64/base58) could be key material — never show - // their bytes, even when short enough to otherwise print in full. - if (c.kind === 'long' && c.noPreview) return { _kind: c.name, len: s.length }; - if (s.length <= maxLen) return s; - return { _kind: c.name, len: s.length, preview: s.slice(0, 32) + '…' }; -} - -function normalizeFieldName(name: string): string { - return name.replace(/[^a-z0-9]/gi, '').toLowerCase(); -} - -function sensitiveFieldKind(fieldName: string): string | null { - const normalized = normalizeFieldName(fieldName); - if ( - normalized === 'secret' || - normalized === 'nsec' || - normalized.endsWith('nsec') || - normalized === 'privkey' || - normalized.endsWith('privatekey') || - normalized.endsWith('privatekeyhex') || - normalized.endsWith('secretkey') || - normalized.endsWith('signerkey') - ) { - return 'private_key'; - } - if ( - normalized === 'mnemonic' || - normalized.endsWith('mnemonic') || - normalized === 'seed' || - normalized.endsWith('seed') || - normalized.endsWith('seedhex') || - normalized.endsWith('xpriv') || - normalized.endsWith('passphrase') || - normalized.endsWith('password') - ) { - return 'secret'; - } - if ( - normalized === 'token' || - normalized.endsWith('token') || - normalized === 'authorization' || - normalized.endsWith('authorization') - ) { - return 'secret'; - } - return null; -} - -function compactSensitiveField(value: unknown, fieldName: string | undefined): unknown | undefined { - if (!fieldName) return undefined; - const kind = sensitiveFieldKind(fieldName); - if (!kind) return undefined; - if (typeof value === 'string') { - const c = classifyString(value); - // Prefer a specifically branded secret type (nsec/cashu_token/jwt/pem/…) - // over the field-derived kind. The generic 32-byte-hex secret (`hex32`) is - // *less* specific than a named field like `privateKeyHex`, so keep the - // field's kind in that case. - const useValueName = c.kind === 'secret' && c.name !== 'hex32'; - return { _kind: useValueName ? c.name : kind, len: value.length }; - } - if (value instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))) { - return { _kind: kind, bytes: (value as Uint8Array).byteLength }; - } - if (value === null || value === undefined) return value; - return { _kind: kind }; -} - -function compactValue( - value: unknown, - opts: { maxStringLength: number; maxArrayItems: number; maxDepth: number; maxObjectKeys: number }, - depth: number = 0, - fieldName?: string -): unknown { - const sensitive = compactSensitiveField(value, fieldName); - if (sensitive !== undefined) return sensitive; - if ( - value === null || - value === undefined || - typeof value === 'boolean' || - typeof value === 'number' - ) - return value; - if (typeof value === 'string') return summarizeString(value, opts.maxStringLength); - if (value instanceof Error) { - return { - _kind: 'error', - name: value.name, - message: value.message, - stack: (value.stack ?? '') - .split('\n') - .map((l) => l.trim()) - .filter(Boolean) - .slice(0, 10), - }; - } - if (value instanceof Date) return { _kind: 'date', iso: value.toISOString() }; - if (value instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))) - return { _kind: 'buffer', bytes: (value as Uint8Array).byteLength }; - if (value instanceof RegExp) return { _kind: 'regexp', source: value.toString() }; - if (value instanceof Map) { - const obj: Record = {}; - let count = 0; - for (const [k, v] of value) { - if (count >= opts.maxObjectKeys) { - obj[`…${value.size - count}_more`] = true; - break; - } - const entryKey = String(k); - obj[entryKey] = compactValue(v, opts, depth + 1, entryKey); - count++; - } - return { _kind: 'map', size: value.size, entries: obj }; - } - if (value instanceof Set) { - return { - _kind: 'set', - size: value.size, - sample: [...value].slice(0, opts.maxArrayItems).map((v) => compactValue(v, opts, depth + 1)), - }; - } - if (Array.isArray(value)) { - if (depth >= opts.maxDepth) return { _kind: 'array', length: value.length }; - const items = value.slice(0, opts.maxArrayItems).map((v) => compactValue(v, opts, depth + 1)); - if (value.length > opts.maxArrayItems) items.push(`…${value.length - opts.maxArrayItems} more`); - return items; - } - if (typeof value === 'function') return { _kind: 'function', name: value.name || 'anon' }; - if (typeof value === 'object') { - if (depth >= opts.maxDepth) { - const keys = Object.keys(value as object); - return { _kind: 'object', keys: keys.length, sample: keys.slice(0, 8) }; - } - return compactPlainObject(value as Record, opts, depth); - } - return String(value); -} - -function compactPlainObject( - obj: Record, - opts: { maxStringLength: number; maxArrayItems: number; maxDepth: number; maxObjectKeys: number }, - depth: number -): Record { - const keys = Object.keys(obj); - const result: Record = {}; - const limit = Math.min(keys.length, opts.maxObjectKeys); - for (let i = 0; i < limit; i++) { - result[keys[i]] = compactValue(obj[keys[i]], opts, depth + 1, keys[i]); - } - if (keys.length > opts.maxObjectKeys) result[`…${keys.length - opts.maxObjectKeys}_more`] = true; - return result; -} - -// ─── Source Location ───────────────────────────────────────────────────────── - -interface SourceLocation { - file: string; - func: string; - line: number; -} - -function getCallerLocation(stackOffset: number = 3): SourceLocation { - const fallback: SourceLocation = { file: 'unknown', func: 'unknown', line: 0 }; - try { - const stack = new Error().stack; - if (!stack) return fallback; - const lines = stack.split('\n'); - const target = lines[stackOffset]; - if (!target) return fallback; - let match = target.match(/at\s+(.+?)\s+\((.+):(\d+):\d+\)/); - if (match) - return { func: match[1], file: simplifyPath(match[2]), line: parseInt(match[3], 10) }; - match = target.match(/at\s+(.+):(\d+):\d+/); - if (match) - return { func: '', file: simplifyPath(match[1]), line: parseInt(match[2], 10) }; - return fallback; - } catch { - return fallback; - } -} - -function simplifyPath(fullPath: string): string { - const cleaned = fullPath - .replace(/^file:\/\//, '') - .replace(/\?.*$/, '') - .replace(/\/\/&.*$/, ''); - const parts = cleaned.split(/[\\/]/); - return parts.slice(-3).join('/'); -} - // ─── Async Emission ────────────────────────────────────────────────────────── type IdleCallback = (deadline: { didTimeout: boolean; timeRemaining: () => number }) => void; diff --git a/shared/lib/loggerSourceLocation.ts b/shared/lib/loggerSourceLocation.ts new file mode 100644 index 000000000..e1087e6cc --- /dev/null +++ b/shared/lib/loggerSourceLocation.ts @@ -0,0 +1,41 @@ +/** + * Caller source-location resolution for the structured logger. + * + * Pure stack-trace parsing: turn a `new Error().stack` frame into a compact + * `{ file, func, line }` for the log entry's `src`. No logger state, no I/O. + */ + +interface SourceLocation { + file: string; + func: string; + line: number; +} + +function simplifyPath(fullPath: string): string { + const cleaned = fullPath + .replace(/^file:\/\//, '') + .replace(/\?.*$/, '') + .replace(/\/\/&.*$/, ''); + const parts = cleaned.split(/[\\/]/); + return parts.slice(-3).join('/'); +} + +export function getCallerLocation(stackOffset: number = 3): SourceLocation { + const fallback: SourceLocation = { file: 'unknown', func: 'unknown', line: 0 }; + try { + const stack = new Error('source-location probe').stack; + if (!stack) return fallback; + const lines = stack.split('\n'); + const target = lines[stackOffset]; + if (!target) return fallback; + let match = target.match(/at\s+(.+?)\s+\((.+):(\d+):\d+\)/); + if (match) + return { func: match[1], file: simplifyPath(match[2]), line: parseInt(match[3], 10) }; + match = target.match(/at\s+(.+):(\d+):\d+/); + if (match) + return { func: '', file: simplifyPath(match[1]), line: parseInt(match[2], 10) }; + return fallback; + } catch { + return fallback; + } +} diff --git a/shared/lib/loggerValueCompact.ts b/shared/lib/loggerValueCompact.ts new file mode 100644 index 000000000..02052c60a --- /dev/null +++ b/shared/lib/loggerValueCompact.ts @@ -0,0 +1,295 @@ +/** + * Value summarization + secret redaction for the structured logger. + * + * Pure functions, no I/O and no logger state: take an arbitrary param value and + * return a compact, secret-safe representation for the log entry. Long/opaque + * strings (JWTs, pubkeys, base64, keys) become `{ _kind, len, preview? }` + * summaries; values whose first bytes could themselves be key material emit + * `{ _kind, len }` with no preview. + * + * SECURITY: this module is the redaction boundary. `summarizeString` and + * `redactKnownSecretSubstrings` are what keep nsec/xprv/cashu-token/seed + * material out of the ring buffer and file transport. Changes here are + * security-sensitive — keep `loggerRedaction.test.ts` / `loggerValueCompact` + * tests green. + */ + +/** Tunables shared by the compacting walk (mirrors the logger's options). */ +interface CompactOpts { + maxStringLength: number; + maxArrayItems: number; + maxDepth: number; + maxObjectKeys: number; +} + +// ─── Value Summarization ───────────────────────────────────────────────────── +// +// Detect known verbose patterns and replace with a compact summary: +// { _kind: "jwt", len: 512, preview: "eyJhbGciOi…" } + +// Secret patterns: a previewed prefix is itself sensitive — emit `_kind, len` +// only. Order: secret patterns run before LONG_STRING_PATTERNS so a string +// matching both is classified as the secret it actually is. +const SECRET_STRING_PATTERNS: { name: string; test: (s: string) => boolean }[] = [ + { name: 'pem_key', test: (s) => s.includes('-----BEGIN') }, + { name: 'jwt', test: (s) => /^eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(s) }, + { name: 'data_uri', test: (s) => /^data:[^;]+;base64,/.test(s) }, + { name: 'connection_str', test: (s) => /^(postgres|mysql|mongodb|redis|wss?):\/\//.test(s) }, + { name: 'nsec', test: (s) => /^nsec1[023456789acdefghjklmnpqrstuvwxyz]{58}$/.test(s) }, + { name: 'cashu_token', test: (s) => s.startsWith('cashuA') || s.startsWith('cashuB') }, + { name: 'lightning_invoice', test: (s) => /^ln(bc|tb|tbs)[0-9a-z]{50,}/i.test(s) }, + // A bare 32-byte hex string is the secp256k1 private-key length. A private + // key and a public key / event id are indistinguishable by value (both are + // 64 hex chars), so we cannot safely preview *any* of them: a 32-char preview + // would leak half a private key. Classify all 64-char hex as a secret and + // emit only `{ _kind, len }` (no preview). Deliberately log npubs (which keep + // their preview) when you need a readable identity in logs. Runs before the + // base64/hex long-string patterns, which would otherwise preview it. + { name: 'hex32', test: (s) => /^(0x)?[0-9a-fA-F]{64}$/.test(s) }, + // BIP32 extended PRIVATE keys (xprv/yprv/zprv + testnet tprv/uprv/vprv). + // base58, ~111 chars — without this they fall through to the base64 long + // pattern and (being ≤120) get logged in full. + { + name: 'xprv', + test: (s) => /^(xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{100,}$/.test(s), + }, + // WIF private keys: base58, mainnet 5/K/L, testnet 9/c, 51-52 chars. + // Otherwise classified as a generic long_string and logged in full. + { name: 'wif', test: (s) => /^[59cKL][1-9A-HJ-NP-Za-km-z]{50,51}$/.test(s) }, + // base64 that decodes to a 32- or 64-byte payload — the size of a private key + // or seed. Ambiguous (could be a 32-byte hash) but we hide rather than risk + // leaking key material; the length is still reported via `len`. + { + name: 'base64_key', + test: (s) => /^[A-Za-z0-9+/]{43}={0,1}$|^[A-Za-z0-9+/]{86,88}={0,2}$/.test(s), + }, +]; + +const EMBEDDED_SECRET_PATTERNS: { replacement: string; pattern: RegExp }[] = [ + { + replacement: '', + pattern: /\bnsec1[023456789acdefghjklmnpqrstuvwxyz]{58}\b/g, + }, + { + replacement: '', + pattern: /\b(xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{100,}/g, + }, + { + replacement: '', + pattern: /\bcashu[AB][A-Za-z0-9_-]{20,}/g, + }, + { + replacement: '', + pattern: /\bln(bc|tb|tbs)[0-9a-z]{50,}/gi, + }, + { + replacement: '', + pattern: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, + }, +]; + +// `noPreview` marks opaque encodings whose first 32 chars could themselves be +// key material — emit `{ _kind, len }` only, never the bytes (even when short +// enough to otherwise print in full). +const LONG_STRING_PATTERNS: { name: string; noPreview?: boolean; test: (s: string) => boolean }[] = + [ + { name: 'npub', test: (s) => /^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/.test(s) }, + { name: 'base64', noPreview: true, test: (s) => /^[A-Za-z0-9+/]{60,}={0,2}$/.test(s) }, + { name: 'hex', noPreview: true, test: (s) => /^(0x)?[0-9a-fA-F]{40,}$/.test(s) }, + { + name: 'uuid', + test: (s) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s), + }, + { name: 'url', test: (s) => /^https?:\/\/.{80,}/.test(s) }, + { name: 'json_blob', test: (s) => s.length > 200 && (s[0] === '{' || s[0] === '[') }, + { name: 'xml_blob', test: (s) => s.length > 200 && s.trimStart().startsWith('<') }, + // Opaque base58 (>=32 chars): could be a Solana pubkey, a base58-encoded key, + // or other key material. Indistinguishable by value, so never show the bytes. + { + name: 'base58', + noPreview: true, + test: (s) => /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(s) && s.length >= 32, + }, + ]; + +type StringClass = + | { kind: 'secret'; name: string } + | { kind: 'long'; name: string; noPreview?: boolean }; + +function classifyString(s: string): StringClass { + for (const p of SECRET_STRING_PATTERNS) if (p.test(s)) return { kind: 'secret', name: p.name }; + for (const p of LONG_STRING_PATTERNS) + if (p.test(s)) return { kind: 'long', name: p.name, noPreview: p.noPreview }; + return { kind: 'long', name: 'long_string' }; +} + +type Compact = string | { _kind: string; len: number; preview?: string }; + +export function redactKnownSecretSubstrings(s: string): string { + let out = s; + for (const { pattern, replacement } of EMBEDDED_SECRET_PATTERNS) { + out = out.replace(pattern, replacement); + } + return out; +} + +function summarizeString(s: string, maxLen: number): Compact { + const c = classifyString(s); + if (c.kind === 'secret') return { _kind: c.name, len: s.length }; + const redacted = redactKnownSecretSubstrings(s); + if (redacted !== s) { + if (redacted.length <= maxLen) return redacted; + return { _kind: 'redacted_string', len: s.length, preview: redacted.slice(0, 32) + '…' }; + } + // Opaque encodings (hex/base64/base58) could be key material — never show + // their bytes, even when short enough to otherwise print in full. + if (c.kind === 'long' && c.noPreview) return { _kind: c.name, len: s.length }; + if (s.length <= maxLen) return s; + return { _kind: c.name, len: s.length, preview: s.slice(0, 32) + '…' }; +} + +function normalizeFieldName(name: string): string { + return name.replace(/[^a-z0-9]/gi, '').toLowerCase(); +} + +function sensitiveFieldKind(fieldName: string): string | null { + const normalized = normalizeFieldName(fieldName); + if ( + normalized === 'secret' || + normalized === 'nsec' || + normalized.endsWith('nsec') || + normalized === 'privkey' || + normalized.endsWith('privatekey') || + normalized.endsWith('privatekeyhex') || + normalized.endsWith('secretkey') || + normalized.endsWith('signerkey') + ) { + return 'private_key'; + } + if ( + normalized === 'mnemonic' || + normalized.endsWith('mnemonic') || + normalized === 'seed' || + normalized.endsWith('seed') || + normalized.endsWith('seedhex') || + normalized.endsWith('xpriv') || + normalized.endsWith('passphrase') || + normalized.endsWith('password') + ) { + return 'secret'; + } + if ( + normalized === 'token' || + normalized.endsWith('token') || + normalized === 'authorization' || + normalized.endsWith('authorization') + ) { + return 'secret'; + } + return null; +} + +function compactSensitiveField(value: unknown, fieldName: string | undefined): unknown | undefined { + if (!fieldName) return undefined; + const kind = sensitiveFieldKind(fieldName); + if (!kind) return undefined; + if (typeof value === 'string') { + const c = classifyString(value); + // Prefer a specifically branded secret type (nsec/cashu_token/jwt/pem/…) + // over the field-derived kind. The generic 32-byte-hex secret (`hex32`) is + // *less* specific than a named field like `privateKeyHex`, so keep the + // field's kind in that case. + const useValueName = c.kind === 'secret' && c.name !== 'hex32'; + return { _kind: useValueName ? c.name : kind, len: value.length }; + } + if (value instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))) { + return { _kind: kind, bytes: (value as Uint8Array).byteLength }; + } + if (value === null || value === undefined) return value; + return { _kind: kind }; +} + +export function compactValue( + value: unknown, + opts: CompactOpts, + depth: number = 0, + fieldName?: string +): unknown { + const sensitive = compactSensitiveField(value, fieldName); + if (sensitive !== undefined) return sensitive; + if ( + value === null || + value === undefined || + typeof value === 'boolean' || + typeof value === 'number' + ) + return value; + if (typeof value === 'string') return summarizeString(value, opts.maxStringLength); + if (value instanceof Error) { + return { + _kind: 'error', + name: value.name, + message: value.message, + stack: (value.stack ?? '') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .slice(0, 10), + }; + } + if (value instanceof Date) return { _kind: 'date', iso: value.toISOString() }; + if (value instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))) + return { _kind: 'buffer', bytes: (value as Uint8Array).byteLength }; + if (value instanceof RegExp) return { _kind: 'regexp', source: value.toString() }; + if (value instanceof Map) { + const obj: Record = {}; + let count = 0; + for (const [k, v] of value) { + if (count >= opts.maxObjectKeys) { + obj[`…${value.size - count}_more`] = true; + break; + } + const entryKey = String(k); + obj[entryKey] = compactValue(v, opts, depth + 1, entryKey); + count++; + } + return { _kind: 'map', size: value.size, entries: obj }; + } + if (value instanceof Set) { + return { + _kind: 'set', + size: value.size, + sample: [...value].slice(0, opts.maxArrayItems).map((v) => compactValue(v, opts, depth + 1)), + }; + } + if (Array.isArray(value)) { + if (depth >= opts.maxDepth) return { _kind: 'array', length: value.length }; + const items = value.slice(0, opts.maxArrayItems).map((v) => compactValue(v, opts, depth + 1)); + if (value.length > opts.maxArrayItems) items.push(`…${value.length - opts.maxArrayItems} more`); + return items; + } + if (typeof value === 'function') return { _kind: 'function', name: value.name || 'anon' }; + if (typeof value === 'object') { + if (depth >= opts.maxDepth) { + const keys = Object.keys(value as object); + return { _kind: 'object', keys: keys.length, sample: keys.slice(0, 8) }; + } + return compactPlainObject(value as Record, opts, depth); + } + return String(value); +} + +function compactPlainObject( + obj: Record, + opts: CompactOpts, + depth: number +): Record { + const keys = Object.keys(obj); + const result: Record = {}; + const limit = Math.min(keys.length, opts.maxObjectKeys); + for (let i = 0; i < limit; i++) { + result[keys[i]] = compactValue(obj[keys[i]], opts, depth + 1, keys[i]); + } + if (keys.length > opts.maxObjectKeys) result[`…${keys.length - opts.maxObjectKeys}_more`] = true; + return result; +} diff --git a/shared/lib/map/btcMapClusterCache.ts b/shared/lib/map/btcMapClusterCache.ts index b2452c930..0f73300af 100644 --- a/shared/lib/map/btcMapClusterCache.ts +++ b/shared/lib/map/btcMapClusterCache.ts @@ -35,10 +35,11 @@ function evictIfNeeded() { export function getOrBuildBTCMapClusterManager( cacheKey: string, points: GeoPoint[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Supercluster point/cluster prop generics; this cache is prop-agnostic options?: Supercluster.Options ): ClusterManager { const existing = CACHE.get(cacheKey); - if (existing && existing.pointsCount === points.length && existing.manager.isLoaded()) { + if (existing?.pointsCount === points.length && existing.manager.isLoaded()) { // Touch on hit so the LRU eviction in `evictIfNeeded` actually drops // the least-recently-used entry, not the oldest-built one. existing.createdAt = Date.now(); diff --git a/shared/lib/map/mapClustering.ts b/shared/lib/map/mapClustering.ts index 1d3174dec..51a19a901 100644 --- a/shared/lib/map/mapClustering.ts +++ b/shared/lib/map/mapClustering.ts @@ -57,6 +57,7 @@ export class ClusterManager { private cluster: Supercluster; private loaded: boolean = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Supercluster point/cluster prop generics; this manager is prop-agnostic constructor(options?: Supercluster.Options) { this.cluster = new Supercluster({ radius: 60, // Cluster radius in pixels @@ -114,7 +115,7 @@ export class ClusterManager { return clusters.map((feature): MapMarker => { const [lon, lat] = feature.geometry.coordinates; - const props = feature.properties as any; + const props = feature.properties; if (props.cluster) { // Cluster marker diff --git a/shared/lib/mintUrlLog.ts b/shared/lib/mintUrlLog.ts new file mode 100644 index 000000000..e649e2698 --- /dev/null +++ b/shared/lib/mintUrlLog.ts @@ -0,0 +1,14 @@ +/** + * Canonical log-field helper for mint URLs. + * + * A mint URL can be sensitive context, so payment/mint logs record only its + * presence and length, never the value. This is the single owner — every + * surface that logs mint context imports it rather than redefining the shape, + * so the redaction contract stays identical everywhere. + */ +export function mintUrlLogFields(mintUrl: string | null | undefined): Record { + return { + hasMintUrl: !!mintUrl, + mintUrlLength: mintUrl?.length ?? 0, + }; +} diff --git a/shared/lib/nav/profileRoutes.ts b/shared/lib/nav/profileRoutes.ts index 836b63811..cca3c5c23 100644 --- a/shared/lib/nav/profileRoutes.ts +++ b/shared/lib/nav/profileRoutes.ts @@ -3,9 +3,9 @@ import { useSegments } from 'expo-router'; export const USER_PROFILE_FLOW = '(user-flow)'; export const MODAL_PROFILE_FLOW = '(profile-flow)'; -export type ProfileFlowGroup = typeof USER_PROFILE_FLOW | typeof MODAL_PROFILE_FLOW; +type ProfileFlowGroup = typeof USER_PROFILE_FLOW | typeof MODAL_PROFILE_FLOW; -export type ProfileRouteName = +type ProfileRouteName = | 'profile' | 'share' | 'userMessages' @@ -18,7 +18,7 @@ export type ProfileRouteName = type ProfileRouteParamValue = string | number | (string | number)[] | null | undefined; type ProfileRouteParams = Record; -export type ProfileHref = { +type ProfileHref = { pathname: `/${ProfileFlowGroup}/${Route}`; params?: ProfileRouteParams; }; diff --git a/shared/lib/nav/useRouteParams.ts b/shared/lib/nav/useRouteParams.ts index 80344478d..f76ee655a 100644 --- a/shared/lib/nav/useRouteParams.ts +++ b/shared/lib/nav/useRouteParams.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect } from 'react'; import { router, useLocalSearchParams } from 'expo-router'; import type { ZodType, infer as zInfer } from 'zod'; import { loggableIssues } from '@sovranbitcoin/schemas'; @@ -38,7 +38,7 @@ export function useRouteParams( options: UseRouteParamsOptions ): zInfer | null { const raw = useLocalSearchParams(); - const parsed = useMemo(() => schema.safeParse(raw), [schema, raw]); + const parsed = schema.safeParse(raw); useEffect(() => { if (parsed.success) return; diff --git a/shared/lib/nostr/buildNostrDataLayer.ts b/shared/lib/nostr/buildNostrDataLayer.ts index 9412d5d8c..168e9c588 100644 --- a/shared/lib/nostr/buildNostrDataLayer.ts +++ b/shared/lib/nostr/buildNostrDataLayer.ts @@ -114,7 +114,7 @@ export function buildNostrDataLayer(): facade.NostrDataLayer | null { const configKey = JSON.stringify(config); const pubkey = activeViewerPubkey(); - if (memo && memo.configKey === configKey && memo.pubkey === pubkey) return memo.layer; + if (memo?.configKey === configKey && memo.pubkey === pubkey) return memo.layer; // Identity switch: drop the prior profile's cache before serving the new one. if (memo && memo.pubkey !== pubkey) { diff --git a/shared/lib/nostr/client.ts b/shared/lib/nostr/client.ts index 2d3f1a733..9208ac2fd 100644 --- a/shared/lib/nostr/client.ts +++ b/shared/lib/nostr/client.ts @@ -13,7 +13,9 @@ export function npubToPubkey(npub: string): string { try { const data = nip19.decode(npub); if (data.type === 'npub') { - nostrLog.debug('nostr.client.npub_to_pubkey', { inputLen: npub.length, type: data.type }); + // No success log here: this pure decode is called per-row across + // follower/contact lists, and a per-call debug log was ~30% of all log + // volume. Only the decode FAILURE (below) is worth recording. return data.data; } } catch (err) { @@ -26,62 +28,3 @@ export function npubToPubkey(npub: string): string { } return npub; } - -/** - * Nostr event type for recommendation events - */ -export interface NostrEvent { - id: string; - pubkey: string; - created_at: number; - content: string; - tags: string[][]; - kind?: number; -} - -/** - * Parse recommendation score/comment from event content - * Format: "[score/5] comment" - * @param raw - Raw event content string - * @returns Parsed recommendation with score and comment, or null if invalid - */ -export function parseRecommendation(raw: string): { score: number; comment: string } | null { - const match = raw.match(/^\s*\[(\d+)\/(\d+)\]\s*(.*)$/); - if (!match) { - nostrLog.debug('nostr.client.parse_recommendation.no_match', { rawLen: raw.length }); - return null; - } - const score = parseInt(match[1], 10); - const outOf = parseInt(match[2], 10); - const comment = match[3] ?? ''; - if (!Number.isFinite(score) || score < 0 || score > 5) return null; - if (outOf !== 5) return null; - nostrLog.debug('nostr.client.parse_recommendation', { score, commentLen: comment.length }); - return { score, comment }; -} - -/** - * Validate event is a Cashu recommendation event - * Checks for k tag with value "38172" indicating it's recommending a mint - * @param e - Nostr event to validate - * @returns True if event is a Cashu recommendation - */ -export function isCashuRecommendationEvent(e: NostrEvent): boolean { - const kindTag = e.tags.find((t) => t[0] === 'k'); - return Boolean(kindTag && kindTag[1] === '38172'); -} - -/** - * Extract mint URL from u tag in event - * @param e - Nostr event containing mint URL - * @returns Mint URL string or null if not found - */ -export function extractMintUrlFromEvent(e: NostrEvent): string | null { - const urlTag = e.tags.find((t) => t[0] === 'u'); - const url = urlTag?.[1] || null; - nostrLog.debug('nostr.client.extract_mint_url', { - found: url !== null, - eventId: e.id?.slice(0, 8), - }); - return url; -} diff --git a/shared/lib/nostr/media/blossomAuth.ts b/shared/lib/nostr/media/blossomAuth.ts index d1de00c4e..ff9099425 100644 --- a/shared/lib/nostr/media/blossomAuth.ts +++ b/shared/lib/nostr/media/blossomAuth.ts @@ -13,9 +13,9 @@ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'; /** Blossom authorization event kind (BUD-01). */ export const BLOSSOM_AUTH_KIND = 24242; -export type BlossomAction = 'upload' | 'delete' | 'get' | 'list'; +type BlossomAction = 'upload' | 'delete' | 'get' | 'list'; -export interface UnsignedBlossomAuth { +interface UnsignedBlossomAuth { kind: number; content: string; created_at: number; @@ -61,7 +61,7 @@ export function encodeAuthHeader(signedEventJson: string): string { const bytes = utf8ToBytes(signedEventJson); let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); - // eslint-disable-next-line no-restricted-globals -- base64 of an in-memory string, not a network call + const base64 = typeof btoa === 'function' ? btoa(binary) : Buffer.from(bytes).toString('base64'); return `Nostr ${base64}`; } diff --git a/shared/lib/nostr/media/blossomClient.ts b/shared/lib/nostr/media/blossomClient.ts index dc3d292a9..228a54378 100644 --- a/shared/lib/nostr/media/blossomClient.ts +++ b/shared/lib/nostr/media/blossomClient.ts @@ -49,7 +49,7 @@ function parseDescriptor(body: string): BlobDescriptor | null { } } -export interface UploadOptions { +interface UploadOptions { ndk: NDK; server: string; fileUri: string; diff --git a/shared/lib/nostr/media/mediaServerStore.ts b/shared/lib/nostr/media/mediaServerStore.ts index 7c8bbb663..2068d53bc 100644 --- a/shared/lib/nostr/media/mediaServerStore.ts +++ b/shared/lib/nostr/media/mediaServerStore.ts @@ -14,7 +14,7 @@ import { createProfileScopedStorage } from '@/shared/lib/cashu/profileScopedStor import { persistConfig } from '@/shared/lib/persist/persistConfig'; /** Default Blossom server (BUD-02). */ -export const DEFAULT_BLOSSOM_SERVER = 'https://blossom.primal.net'; +const DEFAULT_BLOSSOM_SERVER = 'https://blossom.primal.net'; interface MediaServerState { server: string; @@ -31,7 +31,7 @@ function normalizeServer(server: string): string { return /^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`; } -export const useMediaServerStore = create()( +const useMediaServerStore = create()( persist( (set) => ({ server: DEFAULT_BLOSSOM_SERVER, diff --git a/shared/lib/nostr/media/mediaUpload.ts b/shared/lib/nostr/media/mediaUpload.ts index cca33364f..983340421 100644 --- a/shared/lib/nostr/media/mediaUpload.ts +++ b/shared/lib/nostr/media/mediaUpload.ts @@ -12,14 +12,14 @@ import { uploadToBlossom, type BlossomError } from '@/shared/lib/nostr/media/blo import { getMediaServer } from '@/shared/lib/nostr/media/mediaServerStore'; import type { MediaDescriptor } from '@/shared/lib/nostr/media/types'; -export interface PickedAsset { +interface PickedAsset { uri: string; mimeType: string; width?: number; height?: number; } -export interface UploadMediaOptions { +interface UploadMediaOptions { ndk: NDK; asset: PickedAsset; alt?: string; diff --git a/shared/lib/nostr/media/types.ts b/shared/lib/nostr/media/types.ts index 505f3ca04..2bb0f9804 100644 --- a/shared/lib/nostr/media/types.ts +++ b/shared/lib/nostr/media/types.ts @@ -30,8 +30,3 @@ export interface MediaDescriptor { sensitive?: boolean; blurhash?: string; } - -export interface UploadProgress { - /** 0..1 fraction uploaded. */ - fraction: number; -} diff --git a/shared/lib/nostr/nip11.ts b/shared/lib/nostr/nip11.ts index 7716da13e..fc63b2372 100644 --- a/shared/lib/nostr/nip11.ts +++ b/shared/lib/nostr/nip11.ts @@ -11,7 +11,7 @@ import { ResultAsync } from 'neverthrow'; import { nostrLog } from '@/shared/lib/logger'; -export interface RelayLimitation { +interface RelayLimitation { max_content_length?: number; max_message_length?: number; max_subscriptions?: number; @@ -19,7 +19,7 @@ export interface RelayLimitation { payment_required?: boolean; } -export interface RelayInformation { +interface RelayInformation { name?: string; description?: string; software?: string; @@ -29,7 +29,7 @@ export interface RelayInformation { limitation?: RelayLimitation; } -export type RelayInfoError = { type: 'fetch-failed' } | { type: 'invalid' }; +type RelayInfoError = { type: 'fetch-failed' } | { type: 'invalid' }; const TTL_MS = 60 * 60 * 1000; // 1h const FETCH_TIMEOUT_MS = 6_000; @@ -74,9 +74,7 @@ function parseInfo(raw: unknown): RelayInformation | null { } /** Fetches (and caches) a relay's NIP-11 document. */ -export function fetchRelayInformation( - relayUrl: string -): ResultAsync { +function fetchRelayInformation(relayUrl: string): ResultAsync { const cached = cache.get(relayUrl); if (cached && Date.now() - cached.at < TTL_MS) { return ResultAsync.fromSafePromise(Promise.resolve(cached.info)); diff --git a/shared/lib/nostr/nip17.ts b/shared/lib/nostr/nip17.ts index b176b5529..f3f9803c9 100644 --- a/shared/lib/nostr/nip17.ts +++ b/shared/lib/nostr/nip17.ts @@ -206,7 +206,7 @@ export function buildRecipientGiftWrap(params: { * (setTimeout, InteractionManager, microtask) so the Schnorr + NIP-44 work * doesn't delay the caller's primary delivery. */ -export function buildSenderSelfCopyWrap(params: { +function buildSenderSelfCopyWrap(params: { rumor: Rumor; senderPrivateKey: Uint8Array; }): VerifiedEvent { diff --git a/shared/lib/nostr/njump.ts b/shared/lib/nostr/njump.ts index a0139ba47..77e09148c 100644 --- a/shared/lib/nostr/njump.ts +++ b/shared/lib/nostr/njump.ts @@ -7,7 +7,7 @@ */ import { tryNeventEncode } from '@/features/feed/components/nostr/feedParse'; -export interface ShareLinks { +interface ShareLinks { nevent: string; /** NIP-21 `nostr:nevent…`. */ nostrUri: string; diff --git a/shared/lib/nostr/nostrTierConfig.ts b/shared/lib/nostr/nostrTierConfig.ts index d664d80e3..47a6d0f26 100644 --- a/shared/lib/nostr/nostrTierConfig.ts +++ b/shared/lib/nostr/nostrTierConfig.ts @@ -15,7 +15,7 @@ import { useSettingsStore } from '@/shared/stores/global/settingsStore'; * so it ships safely while the app still resolves nagg-ts to the published * registry version that predates the facade. */ -export type NostrTierConfig = { +type NostrTierConfig = { nagg: { enabled: boolean; appViewBaseUrl: string }; primal: { enabled: boolean; url: string }; relay: { enabled: boolean; relays: readonly string[] }; @@ -40,15 +40,3 @@ export function getNostrTierConfig(): NostrTierConfig { }); return config; } - -/** Reactive hook form for components that want to display tier state. */ -export function useNostrTierConfig(): NostrTierConfig { - const naggTierEnabled = useSettingsStore((st) => st.naggTierEnabled); - const primalTierEnabled = useSettingsStore((st) => st.primalTierEnabled); - const relayTierEnabled = useSettingsStore((st) => st.relayTierEnabled); - return { - nagg: { enabled: naggTierEnabled, appViewBaseUrl: backendConfig.nostrAppViewBaseUrl }, - primal: { enabled: primalTierEnabled, url: backendConfig.primalCacheUrl }, - relay: { enabled: relayTierEnabled, relays: DEFAULT_RELAYS }, - }; -} diff --git a/shared/lib/nostr/outbox/recipientRelays.ts b/shared/lib/nostr/outbox/recipientRelays.ts index ec44aa531..059d31afa 100644 --- a/shared/lib/nostr/outbox/recipientRelays.ts +++ b/shared/lib/nostr/outbox/recipientRelays.ts @@ -35,7 +35,7 @@ async function getRecipientReadRelays(ndk: NDK, pubkey: string): Promise {}; @@ -28,11 +24,9 @@ function useCachedRecord( store: facade.NormalizingStore | undefined, key: string | undefined ): T | undefined { - const subscribe = useCallback( - (onChange: () => void) => (store && key ? store.subscribeKey(key, onChange) : NOOP_UNSUB), - [store, key] - ); - const getSnapshot = useCallback(() => (store && key ? store.get(key) : undefined), [store, key]); + const subscribe = (onChange: () => void) => + store && key ? store.subscribeKey(key, onChange) : NOOP_UNSUB; + const getSnapshot = () => (store && key ? store.get(key) : undefined); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } @@ -40,14 +34,9 @@ function usePendingProfile( pending: facade.PendingSet | undefined, key: string | undefined ): boolean { - const subscribe = useCallback( - (onChange: () => void) => (pending && key ? pending.subscribeKey(key, onChange) : NOOP_UNSUB), - [pending, key] - ); - const getSnapshot = useCallback( - () => (pending && key ? pending.has(key) : false), - [pending, key] - ); + const subscribe = (onChange: () => void) => + pending && key ? pending.subscribeKey(key, onChange) : NOOP_UNSUB; + const getSnapshot = () => (pending && key ? pending.has(key) : false); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } @@ -71,27 +60,3 @@ export function useProfile(pubkey: string | undefined): { return { profile, status }; }, [record, pending]); } - -/** A cached note body by id (structurally a FeedEvent). */ -export function useNote(id: string | undefined): FeedEvent | undefined { - const cache = buildNostrDataLayer()?.cache; - return useCachedRecord(cache?.notes, id); -} - -/** Cached engagement metrics for a note id, mapped to the app's NoteMetrics shape. */ -export function useNoteStats(id: string | undefined): NoteMetrics | undefined { - const cache = buildNostrDataLayer()?.cache; - const record = useCachedRecord(cache?.noteStats, id); - return useMemo( - () => - record - ? { - likeCount: record.likes, - repostCount: record.reposts, - replyCount: record.replies, - satsZapped: record.satsZapped, - } - : undefined, - [record] - ); -} diff --git a/shared/lib/nutCreq.ts b/shared/lib/nutCreq.ts index b5be16762..86d443f28 100644 --- a/shared/lib/nutCreq.ts +++ b/shared/lib/nutCreq.ts @@ -133,7 +133,7 @@ export function lockableMintsFromCreq( return null; } const expected = `02${nostrPubkeyHex}`.toLowerCase(); - if (!parsed.lockPubkey33 || parsed.lockPubkey33.toLowerCase() !== expected) { + if (parsed.lockPubkey33?.toLowerCase() !== expected) { cashuLog.debug('cashu.creq.lockable.rejected', { reason: 'lock-mismatch', creqLength: creq.length, diff --git a/shared/lib/persist/createMergeWithSchema.ts b/shared/lib/persist/createMergeWithSchema.ts index 7f7ed7a25..cbc9becc8 100644 --- a/shared/lib/persist/createMergeWithSchema.ts +++ b/shared/lib/persist/createMergeWithSchema.ts @@ -31,6 +31,10 @@ export function createMergeWithSchema(name: string, schema: ZodType void; const toastDebugFieldsRef = React.useRef(toastDebugFields); const paymentCopy = usePaymentCopyResolver(); - const cases = React.useMemo(() => createPaymentStatusToastCases(paymentCopy), [paymentCopy]); + const cases = createPaymentStatusToastCases(paymentCopy); const config = cases[variant]; const active = usePaymentStatusStore((s) => s.active); const activeForPayment = active?.id === paymentId ? active : null; @@ -325,16 +325,13 @@ export function PaymentStatusToast({ const onNutDropRadar = variant === 'receive-ecash' && radarVisible; const title = effectiveActive?.titleOverride ?? (onNutDropRadar ? 'Received payment' : config.message); - const statusToastDebugFields = React.useMemo( - () => ({ - toastId: toastId ?? null, - debugLabel: debugLabel ?? null, - variant, - paymentId, - status, - }), - [debugLabel, paymentId, status, toastId, variant] - ); + const statusToastDebugFields = { + toastId: toastId ?? null, + debugLabel: debugLabel ?? null, + variant, + paymentId, + status, + }; return ( { + const onPressView = () => { if (!groupId) { hide(); return; } guardedRouter.push({ pathname: '/swap', params: { groupId } }); hide(); - }, [groupId, hide]); + }; if (!view.present) return null; diff --git a/shared/lib/popup/ToastSlab.tsx b/shared/lib/popup/ToastSlab.tsx index e5ff96a16..666682720 100644 --- a/shared/lib/popup/ToastSlab.tsx +++ b/shared/lib/popup/ToastSlab.tsx @@ -77,6 +77,7 @@ export function ToastSlab({ toastProps, variant, tint, children }: ToastSlabProp isAnimatedStyleActive={false} // toastProps carries manager-injected props (index, total, heights, // show, hide) that aren't part of the public Toast type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- toastProps carries manager-injected props (index, total, heights, show, hide) absent from the public Toast type; see comment above. {...(toastProps as any)}> {frosted && ( diff --git a/shared/lib/popup/index.ts b/shared/lib/popup/index.ts index 7f56e6ef7..6d754d5d9 100644 --- a/shared/lib/popup/index.ts +++ b/shared/lib/popup/index.ts @@ -9,10 +9,9 @@ */ export { popup } from './popups/engine'; -export { registerToast, setPopupDuration, showActionSheet, showCustomToast } from './popups/bridge'; +export { registerToast, showActionSheet } from './popups/bridge'; export type { ActionSheetPayloads } from './actionSheetTypes'; -export { fmt, isAmountSegment } from './format'; -export { parsePaymentError } from './parsePaymentError'; +export { isAmountSegment } from './format'; export type { PopupTextSegment } from './format'; export { resolvePopupIcon } from './icons'; export type { PopupIcon } from './icons'; diff --git a/shared/lib/popup/popups/bridge.ts b/shared/lib/popup/popups/bridge.ts index 3f7ce4101..9e6d57df6 100644 --- a/shared/lib/popup/popups/bridge.ts +++ b/shared/lib/popup/popups/bridge.ts @@ -3,7 +3,7 @@ import type { ReactNode } from 'react'; import { popupLog } from '../../logger'; import type { PopupIcon } from '../icons'; import type { PopupTextSegment } from '../format'; -import { isCustomSheetPayload, usePopupStore } from '@/shared/stores/runtime/popupStore'; +import { usePopupStore } from '@/shared/stores/runtime/popupStore'; import type { SheetCloseEvent } from '@/shared/stores/runtime/popupStore'; import type { ActionSheetPayloads } from '../actionSheetTypes'; import { CompactToast } from '../CompactToast'; @@ -11,7 +11,7 @@ import type { LiveSheetConfig } from '../liveSheetTypes'; /** Best-effort first stack frame outside the popup module — gives "where did this come from" without a full trace. */ function getCallerFrame(): string | undefined { - const stack = new Error().stack; + const stack = new Error('caller-frame probe').stack; if (!stack) return undefined; const lines = stack.split('\n'); for (let i = 1; i < lines.length; i++) { @@ -215,19 +215,6 @@ export function showSheet(config: SheetConfig) { usePopupStore.getState().open(config); } -/** Set duration (ms) on the current sheet. Starts auto-close timer. Call anytime while sheet is open. */ -export function setPopupDuration(ms: number): void { - const { current, update } = usePopupStore.getState(); - if (!current || isCustomSheetPayload(current)) { - popupLog.warn('popup.set_duration_ignored', { - ms, - reason: current ? 'custom_sheet' : 'no_current', - }); - return; - } - update({ duration: ms }); -} - export function showActionSheet( sheetId: K, payload: ActionSheetPayloads[K] diff --git a/shared/lib/popup/popups/emojiPicker.tsx b/shared/lib/popup/popups/emojiPicker.tsx index 3358cea09..c6d152716 100644 --- a/shared/lib/popup/popups/emojiPicker.tsx +++ b/shared/lib/popup/popups/emojiPicker.tsx @@ -68,7 +68,7 @@ const EmojiCell = React.memo(function EmojiCell({ entry: EmojiEntry; onSelect: (emoji: string) => void; }) { - const handlePress = useCallback(() => onSelect(entry.emoji), [entry.emoji, onSelect]); + const handlePress = () => onSelect(entry.emoji); return ( { + const handleSearchChange = (text: string) => { setInputText(text); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { emojiLog.debug('emojiPicker.search.debounced', { queryLen: text.length }); setSearchQuery(text); }, 150); - }, []); + }; - const handleSearchClear = useCallback(() => { + const handleSearchClear = () => { emojiLog.debug('emojiPicker.search.clear', {}); setInputText(''); setSearchQuery(''); if (debounceRef.current) clearTimeout(debounceRef.current); - }, []); + }; // Each category becomes a virtualized section. `data` is the flat // emoji array — `SectionAnchorList` chunks it into rows of `COLS` @@ -335,11 +335,10 @@ export function EmojiPickerContent({ // Pre-chunk search results so the override `List` virtualizes // per row (not per cell) — matches the rowChunkSize=6 layout of the // sectioned mode, so cells stay on the same x-grid as the search bar. - const searchRows = useMemo(() => chunkEmojis(searchResults), [searchResults]); + const searchRows = chunkEmojis(searchResults); - const renderEmojiRow = useCallback( - (items: EmojiEntry[]) => , - [handleEmojiSelect] + const renderEmojiRow = (items: EmojiEntry[]) => ( + ); const renderEmojiSearchRow = useCallback( ({ item }: { item: EmojiEntry[] }) => , diff --git a/shared/lib/popup/popups/index.ts b/shared/lib/popup/popups/index.ts index 1d0ec25e2..b19d7b94a 100644 --- a/shared/lib/popup/popups/index.ts +++ b/shared/lib/popup/popups/index.ts @@ -13,7 +13,7 @@ export { sendMemoPopup } from './sendMemoSheet'; export { emojiPickerPopup } from './emojiPicker'; export { modelPickerPopup } from './modelPicker'; export type { ProfileSwitcherAction } from '../actionSheetTypes'; -export { actionMenuPopup, dismissActionMenuPopup } from './actionMenu'; +export { actionMenuPopup } from './actionMenu'; export { paymentStatusPopup, swapStatusPopup, @@ -412,9 +412,9 @@ const PARAM_POPUPS = { }), } as const satisfies Record PopupSpec>; -export type StaticPopupKey = keyof typeof STATIC_POPUPS; -export type ParamPopupKey = keyof typeof PARAM_POPUPS; -export type PopupParams = Parameters<(typeof PARAM_POPUPS)[K]>[0]; +type StaticPopupKey = keyof typeof STATIC_POPUPS; +type ParamPopupKey = keyof typeof PARAM_POPUPS; +type PopupParams = Parameters<(typeof PARAM_POPUPS)[K]>[0]; export function staticPopup(key: StaticPopupKey, overrides?: PopupOverrides): void { popup({ ...STATIC_POPUPS[key], ...overrides }); diff --git a/shared/lib/popup/popups/modelPicker.tsx b/shared/lib/popup/popups/modelPicker.tsx index 29f67170d..79b5215f9 100644 --- a/shared/lib/popup/popups/modelPicker.tsx +++ b/shared/lib/popup/popups/modelPicker.tsx @@ -23,7 +23,7 @@ * modelPickerPopup({}); */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { ScrollView, StyleSheet, View } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { BottomSheet, Menu } from 'heroui-native'; @@ -194,23 +194,17 @@ export function ModelPickerContent({ close }: ModelPickerContentProps) { const balanceSats = balanceMsats != null ? Math.floor(balanceMsats / 1000) : 0; const models = cachedModels ?? []; - const activeProvider = useMemo( - () => AI_PROVIDERS.find((p) => p.id === activeProviderTab) ?? AI_PROVIDERS[0], - [activeProviderTab] - ); + const activeProvider = AI_PROVIDERS.find((p) => p.id === activeProviderTab) ?? AI_PROVIDERS[0]; - const handleSelect = useCallback( - (tier: AiTier) => { - pickerLog.info('modelPicker.select', { - provider: activeProvider.id, - tier: tier.id, - }); - setSelectedSlot({ provider: activeProvider.id, tier: tier.id }); - paramPopup('model-switched', { modelName: `${activeProvider.label} ${tier.label}` }); - close(); - }, - [activeProvider.id, activeProvider.label, setSelectedSlot, close] - ); + const handleSelect = (tier: AiTier) => { + pickerLog.info('modelPicker.select', { + provider: activeProvider.id, + tier: tier.id, + }); + setSelectedSlot({ provider: activeProvider.id, tier: tier.id }); + paramPopup('model-switched', { modelName: `${activeProvider.label} ${tier.label}` }); + close(); + }; return ( diff --git a/shared/lib/popup/popups/payment.ts b/shared/lib/popup/popups/payment.ts index f812136fb..6e3ab96c8 100644 --- a/shared/lib/popup/popups/payment.ts +++ b/shared/lib/popup/popups/payment.ts @@ -106,7 +106,7 @@ export function swapStatusPopup(): void { }); // Mid-flight swipe leaves the gate engaged; clear only after the // toast unmounts in a terminal state (or the store is already empty). - if (!cur || cur.state !== 'running') { + if (cur?.state !== 'running') { useSwapStatusStore.getState().clear(); } }, diff --git a/shared/lib/popup/popups/sendMemoSheet.tsx b/shared/lib/popup/popups/sendMemoSheet.tsx index 05e680af3..c6467411b 100644 --- a/shared/lib/popup/popups/sendMemoSheet.tsx +++ b/shared/lib/popup/popups/sendMemoSheet.tsx @@ -69,6 +69,10 @@ function isMentionSearchResult(result: DisplayResult): result is MentionSearchRe return !!result.profile && !!result.pubkey && !result.pubkey.startsWith('placeholder-'); } +function mentionKeyExtractor(item: MentionSearchResult): string { + return item.pubkey; +} + function renderMentionScrollComponent(props: ScrollViewProps) { // This list is nested inside a fixed-height panel. Avoid BottomSheetScrollView // here because its content-size setter can shrink the whole dynamic sheet @@ -130,43 +134,39 @@ export function SendMemoContent({ payload, close }: SendMemoContentProps): React ] as const); const [isFocused, setIsFocused] = useState(false); const canSubmit = memo.trim().length > 0; - const mentionToken = useMemo( - () => (selection.start === selection.end ? findActiveMemoMention(memo, selection.start) : null), - [memo, selection.end, selection.start] - ); + const mentionToken = + selection.start === selection.end ? findActiveMemoMention(memo, selection.start) : null; - const submit = useCallback(() => { + const submit = () => { if (!canSubmit) return; submitSendMemo(payload.machine, serializeMemoWithMentions(memo, mentionEntities)); close(); - }, [canSubmit, close, memo, mentionEntities, payload.machine]); + }; - const skip = useCallback(() => { + const skip = () => { submitSendMemo(payload.machine, undefined); close(); - }, [close, payload.machine]); + }; - const handleMemoChange = useCallback( - (nextMemo: string) => { - setMentionEntities((current) => reconcileMemoMentionEntities(memo, nextMemo, current)); - setMemo(nextMemo); - }, - [memo] - ); + const handleMemoChange = (nextMemo: string) => { + setMentionEntities((current) => reconcileMemoMentionEntities(memo, nextMemo, current)); + setMemo(nextMemo); + }; - const handleSelectionChange = useCallback( - (event: NativeSyntheticEvent) => { - setSelection(event.nativeEvent.selection); - setSelectionOverride(undefined); - }, - [] - ); + const handleSelectionChange = ( + event: NativeSyntheticEvent + ) => { + setSelection(event.nativeEvent.selection); + setSelectionOverride(undefined); + }; - const handleChromeLayout = useCallback((event: LayoutChangeEvent) => { + const handleChromeLayout = (event: LayoutChangeEvent) => { const nextHeight = event.nativeEvent.layout.height; setChromeHeight((current) => (Math.abs(current - nextHeight) <= 1 ? current : nextHeight)); - }, []); + }; + // Memoized: these are useEffect deps; fresh identities would re-subscribe the + // keyboard listeners on every render (React Compiler does not stabilize them). const handleKeyboardShow = useCallback( (event: KeyboardEvent) => { const screenY = event.endCoordinates?.screenY ?? windowHeight; @@ -193,62 +193,51 @@ export function SendMemoContent({ payload, close }: SendMemoContentProps): React }; }, [handleKeyboardHide, handleKeyboardShow]); - const mentionPanelHeight = useMemo(() => { - return getSendMemoMentionPanelHeight({ - windowHeight, - insetTop: insets.top, - keyboardHeight, - chromeHeight, + const mentionPanelHeight = getSendMemoMentionPanelHeight({ + windowHeight, + insetTop: insets.top, + keyboardHeight, + chromeHeight, + }); + + const insertMention = (result: MentionSearchResult) => { + if (!mentionToken) return; + + const nprofile = createMemoMentionNprofile(result.pubkey, defaultRelays); + const displayName = resolveIdentityName({ + pubkey: result.pubkey, + nostrProfile: result.profile, }); - }, [chromeHeight, insets.top, keyboardHeight, windowHeight]); - - const insertMention = useCallback( - (result: MentionSearchResult) => { - if (!mentionToken) return; - - const nprofile = createMemoMentionNprofile(result.pubkey, defaultRelays); - const displayName = resolveIdentityName({ - pubkey: result.pubkey, - nostrProfile: result.profile, - }); - const next = insertMemoMentionProfile( - memo, - mentionToken, - { displayName, nprofile }, - mentionEntities - ); - const nextSelection = { start: next.cursor, end: next.cursor }; - setMemo(next.value); - setMentionEntities(next.entities); - setSelection(nextSelection); - setSelectionOverride(nextSelection); - requestAnimationFrame(() => inputRef.current?.focus()); + const next = insertMemoMentionProfile( + memo, + mentionToken, + { displayName, nprofile }, + mentionEntities + ); + const nextSelection = { start: next.cursor, end: next.cursor }; + setMemo(next.value); + setMentionEntities(next.entities); + setSelection(nextSelection); + setSelectionOverride(nextSelection); + requestAnimationFrame(() => inputRef.current?.focus()); + }; + + const inputStyle = [ + styles.input, + { + borderColor: isFocused ? accentBorder : defaultBorder, + backgroundColor: defaultBg, + color: foreground, }, - [memo, mentionEntities, mentionToken] - ); - - const inputStyle = useMemo( - () => [ - styles.input, - { - borderColor: isFocused ? accentBorder : defaultBorder, - backgroundColor: defaultBg, - color: foreground, - }, - ], - [accentBorder, defaultBg, defaultBorder, foreground, isFocused] - ); + ]; - const sendButtonStyle = useMemo( - () => [ - styles.sendButton, - { - backgroundColor: foreground, - opacity: canSubmit ? 1 : 0.5, - }, - ], - [canSubmit, foreground] - ); + const sendButtonStyle = [ + styles.sendButton, + { + backgroundColor: foreground, + opacity: canSubmit ? 1 : 0.5, + }, + ]; const sendIconColor = background; @@ -325,8 +314,8 @@ function MentionSearchResults({ const [foreground] = useThemeColor(['foreground'] as const); const { displayResults, searchLoading, hasSearched } = useContactSearch(query); const trimmedQuery = query.trim(); - const rawResults = useMemo(() => displayResults.filter(isMentionSearchResult), [displayResults]); - const resultPubkeys = useMemo(() => rawResults.map((result) => result.pubkey), [rawResults]); + const rawResults = displayResults.filter(isMentionSearchResult); + const resultPubkeys = rawResults.map((result) => result.pubkey); const { metadata: cachedMetadata } = useNostrProfileMetadataMany(resultPubkeys); const results = useMemo( () => @@ -355,20 +344,15 @@ function MentionSearchResults({ trimmedQuery.length >= CONTACT_SEARCH_MIN_LENGTH && results.length === 0 && (searchLoading || !hasSearched); - const renderResult = useCallback( - ({ item }: { item: MentionSearchResult }) => ( - onSelectResult(item)} - testID={`send-memo-mention:${item.pubkey}`} - /> - ), - [onSelectResult] + const renderResult = ({ item }: { item: MentionSearchResult }) => ( + onSelectResult(item)} + testID={`send-memo-mention:${item.pubkey}`} + /> ); - const keyExtractor = useCallback((item: MentionSearchResult) => item.pubkey, []); - let content: React.ReactNode; if (trimmedQuery.length === 0) { content = ( @@ -412,7 +396,7 @@ function MentionSearchResults({ content = ( data={results} - keyExtractor={keyExtractor} + keyExtractor={mentionKeyExtractor} renderItem={renderResult} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="always" diff --git a/shared/lib/routstr/api.ts b/shared/lib/routstr/api.ts index a51d31cc1..cb685c0a2 100644 --- a/shared/lib/routstr/api.ts +++ b/shared/lib/routstr/api.ts @@ -118,6 +118,12 @@ interface ParsedErrorData { // ── Error Handling ─────────────────────────────────────────────────────── +/** Build a typed RoutstrError. Factory exists so the throw sites don't need an + * object-literal `as RoutstrError` cast (banned by consistent-type-assertions). */ +function makeRoutstrError(status: number, error: RoutstrError['error']): RoutstrError { + return { status, error }; +} + async function parseErrorResponse(response: Response): Promise { const contentType = response.headers.get('content-type') || ''; if (contentType.includes('text/html')) { @@ -220,28 +226,22 @@ async function throwResponseError(response: Response): Promise { useRoutstrStore.getState().clearBalance(); } - throw { - status, - error: { - message: getUserFriendlyErrorMessage(status, errorData), - type: errorData.type || 'unknown_error', - details: errorData.details, - }, - } as RoutstrError; + throw makeRoutstrError(status, { + message: getUserFriendlyErrorMessage(status, errorData), + type: errorData.type || 'unknown_error', + details: errorData.details, + }); } /** Wrap a caught unknown into a RoutstrError (re-throws if already one). */ function toRoutstrError(error: unknown): never { if (error && typeof error === 'object' && 'status' in error) throw error; if (isAbortError(error)) { - throw { - status: 0, - error: { message: 'Request cancelled', type: 'aborted' }, - } as RoutstrError; + throw makeRoutstrError(0, { message: 'Request cancelled', type: 'aborted' }); } const message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Network error'; - throw { status: 0, error: { message, type: 'network_error' } } as RoutstrError; + throw makeRoutstrError(0, { message, type: 'network_error' }); } export interface RoutstrModel { @@ -279,7 +279,7 @@ export interface RoutstrModel { max_completion_cost: number; max_cost: number; }; - per_request_limits: any; + per_request_limits: unknown; top_provider: { context_length: number; max_completion_tokens: number | null; @@ -451,7 +451,7 @@ async function* parseSSEStream(response: Response): AsyncGenerator = T extends Primitive : [K]; }[Extract]; -type PathArrayValue = P extends [infer K, ...infer Rest] +type PathArrayValue = P extends [infer K, ...infer Rest] ? K extends keyof T ? Rest extends [number, ...infer Sub] ? T[K] extends (infer U)[] - ? Sub extends readonly any[] + ? Sub extends readonly unknown[] ? PathArrayValue : U : never - : Rest extends readonly any[] + : Rest extends readonly unknown[] ? PathArrayValue : T[K] : never @@ -83,6 +83,7 @@ export function typedUpdate>( ): T; /** Implementation */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- implementation signature behind the two strongly-typed overloads above; it handles both string and tuple paths generically, so the loose impl types are intentional (callers get full safety from the overloads). export function typedUpdate(path: any, updater: any, obj: any): any { return update(path, updater, obj); } diff --git a/shared/lib/utils.tsx b/shared/lib/utils.tsx index 6769a7b6f..d6430f0c1 100644 --- a/shared/lib/utils.tsx +++ b/shared/lib/utils.tsx @@ -196,47 +196,55 @@ export function cn(...inputs: ClassValue[]) { export const compose = ( providers: ( | React.FC<{ children: React.ReactNode }> + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic provider composition: provider components/props are heterogeneous | React.ComponentType + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic provider composition: provider components/props are heterogeneous | [React.ComponentType, Record] )[] ): React.FC<{ children: React.ReactNode }> => { - const ComposedProvider = providers.reduce((Prev, Curr) => { - const ProviderComponent = ({ children }: { children: React.ReactNode }) => { - let CurrentProvider: React.FC<{ children: React.ReactNode }>; - - // Handle tuple syntax [Component, props] - if (Array.isArray(Curr) && Curr.length === 2) { - const [Component, props] = Curr; - const ConfiguredProvider = ({ children }: { children: React.ReactNode }) => { - const wrappedChildren = React.Children.count(children) > 1 ? <>{children} : children; - return {wrappedChildren}; - }; - ConfiguredProvider.displayName = `ConfiguredProvider(${Component.displayName || Component.name || 'Unknown'})`; - CurrentProvider = ConfiguredProvider; - } - // Handle direct component reference - else if (typeof Curr === 'function' && Curr.length === 1) { - CurrentProvider = Curr as React.FC<{ children: React.ReactNode }>; - } - // Handle configured component (arrow function) - else { - CurrentProvider = Curr as React.FC<{ children: React.ReactNode }>; - } - - if (!Prev) return {children}; - return ( - - {children} - - ); - }; - const componentName = Array.isArray(Curr) - ? Curr[0].displayName || Curr[0].name - : Curr.displayName || Curr.name; - ProviderComponent.displayName = `ProviderWrapper(${componentName || 'Unknown'})`; - return ProviderComponent; - }, undefined as any); - + const ComposedProvider = providers.reduce( + (Prev, Curr) => { + const ProviderComponent = ({ children }: { children: React.ReactNode }) => { + let CurrentProvider: React.FC<{ children: React.ReactNode }>; + + // Handle tuple syntax [Component, props] + if (Array.isArray(Curr) && Curr.length === 2) { + const [Component, props] = Curr; + const ConfiguredProvider = ({ children }: { children: React.ReactNode }) => { + const wrappedChildren = React.Children.count(children) > 1 ? <>{children} : children; + return {wrappedChildren}; + }; + ConfiguredProvider.displayName = `ConfiguredProvider(${Component.displayName || Component.name || 'Unknown'})`; + CurrentProvider = ConfiguredProvider; + } + // Handle direct component reference + else if (typeof Curr === 'function' && Curr.length === 1) { + CurrentProvider = Curr as React.FC<{ children: React.ReactNode }>; + } + // Handle configured component (arrow function) + else { + CurrentProvider = Curr as React.FC<{ children: React.ReactNode }>; + } + + if (!Prev) return {children}; + return ( + + {children} + + ); + }; + const componentName = Array.isArray(Curr) + ? Curr[0].displayName || Curr[0].name + : Curr.displayName || Curr.name; + ProviderComponent.displayName = `ProviderWrapper(${componentName || 'Unknown'})`; + return ProviderComponent; + }, + undefined as React.FC<{ children: React.ReactNode }> | undefined + ); + + if (!ComposedProvider) { + throw new Error('compose() requires at least one provider'); + } ComposedProvider.displayName = 'ComposedProvider'; return ComposedProvider; }; diff --git a/shared/lib/version.ts b/shared/lib/version.ts index c206f1b71..225271ce7 100644 --- a/shared/lib/version.ts +++ b/shared/lib/version.ts @@ -88,16 +88,3 @@ export const supportsLiquidGlass = (): boolean => { device.platform('macos').gte(26) ); }; - -/** - * Conditionally include SwiftUI glass modifiers when liquid glass is - * enabled. Returns `[]` if either the build-time flag or the runtime - * `mockNoGlass` toggle is set, so callers spreading the result get - * an empty modifier list and the SwiftUI view falls back to its - * default appearance. - */ -export function liquidGlassModifiers(...modifiers: T[]): T[] { - if (!LIQUID_GLASS_ENABLED) return []; - if (useSettingsStore.getState().mockNoGlass) return []; - return modifiers; -} diff --git a/shared/providers/BackgroundProvider.tsx b/shared/providers/BackgroundProvider.tsx index 7d3dfae02..cd58cfc4c 100644 --- a/shared/providers/BackgroundProvider.tsx +++ b/shared/providers/BackgroundProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useCallback, ReactNode } from 'react'; +import React, { createContext, useContext, ReactNode } from 'react'; import { useSharedValue, withTiming, SharedValue, Easing } from 'react-native-reanimated'; import { useFocusEffect } from 'expo-router'; import { log, initLog, useInitMount } from '@/shared/lib/logger'; @@ -109,73 +109,61 @@ export function BackgroundProvider({ children }: BackgroundProviderProps) { const backgroundOpacity = useSharedValue(1); const backgroundColor = useSharedValue(''); // Empty string means use theme default (primary-900) - const setConfig = useCallback( - (config: BackgroundConfig) => { - // Map blur mode to number - const modeMap: Record = { - none: 0, - partial: 1, - full: 2, - gradient: 3, - }; - const targetMode = modeMap[config.blurMode]; - const bgOpacity = config.backgroundOpacity ?? 1; - - // Skip if mode and opacity haven't changed — avoids redundant animations on tab refocus - if (blurMode.value === targetMode && backgroundOpacity.value === bgOpacity) { - return; - } - - log.info('bg.blur.transition', { - blurMode: config.blurMode, - backgroundOpacity: bgOpacity, - animationMs: ANIMATION_CONFIG.duration, - }); - const defaults = DEFAULT_CONFIGS[config.blurMode]; - const intensity = config.blurIntensity ?? defaults.blurIntensity; - const gradientStart = config.blurGradientStart ?? defaults.blurGradientStart; - const gradientEnd = config.blurGradientEnd ?? defaults.blurGradientEnd; - const bgColor = config.backgroundColor ?? ''; // Empty string = use theme default - - // Animate to new values - blurMode.value = targetMode; - blurIntensity.value = withTiming(intensity, ANIMATION_CONFIG); - blurGradientStart.value = withTiming(gradientStart, ANIMATION_CONFIG); - blurGradientEnd.value = withTiming(gradientEnd, ANIMATION_CONFIG); - backgroundOpacity.value = withTiming(bgOpacity, ANIMATION_CONFIG); - backgroundColor.value = bgColor; // Color changes instantly (no animation) - - // Animate opacity based on mode - switch (config.blurMode) { - case 'none': - partialBlurOpacity.value = withTiming(0, ANIMATION_CONFIG); - fullBlurOpacity.value = withTiming(0, ANIMATION_CONFIG); - break; - case 'partial': - partialBlurOpacity.value = withTiming(1, ANIMATION_CONFIG); - fullBlurOpacity.value = withTiming(0, ANIMATION_CONFIG); - break; - case 'full': - partialBlurOpacity.value = withTiming(0, ANIMATION_CONFIG); - fullBlurOpacity.value = withTiming(1, ANIMATION_CONFIG); - break; - case 'gradient': - partialBlurOpacity.value = withTiming(0, ANIMATION_CONFIG); - fullBlurOpacity.value = withTiming(0, ANIMATION_CONFIG); - break; - } - }, - [ - blurMode, - blurIntensity, - blurGradientStart, - blurGradientEnd, - partialBlurOpacity, - fullBlurOpacity, - backgroundOpacity, - backgroundColor, - ] - ); + const setConfig = (config: BackgroundConfig) => { + // Map blur mode to number + const modeMap: Record = { + none: 0, + partial: 1, + full: 2, + gradient: 3, + }; + const targetMode = modeMap[config.blurMode]; + const bgOpacity = config.backgroundOpacity ?? 1; + + // Skip if mode and opacity haven't changed — avoids redundant animations on tab refocus + if (blurMode.value === targetMode && backgroundOpacity.value === bgOpacity) { + return; + } + + log.info('bg.blur.transition', { + blurMode: config.blurMode, + backgroundOpacity: bgOpacity, + animationMs: ANIMATION_CONFIG.duration, + }); + const defaults = DEFAULT_CONFIGS[config.blurMode]; + const intensity = config.blurIntensity ?? defaults.blurIntensity; + const gradientStart = config.blurGradientStart ?? defaults.blurGradientStart; + const gradientEnd = config.blurGradientEnd ?? defaults.blurGradientEnd; + const bgColor = config.backgroundColor ?? ''; // Empty string = use theme default + + // Animate to new values + blurMode.value = targetMode; + blurIntensity.value = withTiming(intensity, ANIMATION_CONFIG); + blurGradientStart.value = withTiming(gradientStart, ANIMATION_CONFIG); + blurGradientEnd.value = withTiming(gradientEnd, ANIMATION_CONFIG); + backgroundOpacity.value = withTiming(bgOpacity, ANIMATION_CONFIG); + backgroundColor.value = bgColor; // Color changes instantly (no animation) + + // Animate opacity based on mode + switch (config.blurMode) { + case 'none': + partialBlurOpacity.value = withTiming(0, ANIMATION_CONFIG); + fullBlurOpacity.value = withTiming(0, ANIMATION_CONFIG); + break; + case 'partial': + partialBlurOpacity.value = withTiming(1, ANIMATION_CONFIG); + fullBlurOpacity.value = withTiming(0, ANIMATION_CONFIG); + break; + case 'full': + partialBlurOpacity.value = withTiming(0, ANIMATION_CONFIG); + fullBlurOpacity.value = withTiming(1, ANIMATION_CONFIG); + break; + case 'gradient': + partialBlurOpacity.value = withTiming(0, ANIMATION_CONFIG); + fullBlurOpacity.value = withTiming(0, ANIMATION_CONFIG); + break; + } + }; const value: BackgroundContextValue = { blurMode, @@ -210,19 +198,9 @@ export function useBackgroundContext() { export function useBackgroundConfig(config: BackgroundConfig) { const context = useContext(BackgroundContext); - useFocusEffect( - useCallback(() => { - if (context) { - context.setConfig(config); - } - }, [ - context, - config.blurMode, - config.blurIntensity, - config.blurGradientStart, - config.blurGradientEnd, - config.backgroundOpacity, - config.backgroundColor, - ]) - ); + useFocusEffect(() => { + if (context) { + context.setConfig(config); + } + }); } diff --git a/shared/providers/CocoProvider.tsx b/shared/providers/CocoProvider.tsx index 939ff0163..0b531327e 100644 --- a/shared/providers/CocoProvider.tsx +++ b/shared/providers/CocoProvider.tsx @@ -10,6 +10,7 @@ import { log, initLog, initPhase, useInitMount, deferWork } from '@/shared/lib/l import { getBootMorphCompleted, subscribeBootMorphCompleted } from '@/shared/lib/qrButtonAnchor'; import { awaitRestoreReady } from '@/shared/providers/awaitRestoreReady'; import { useWalletLifecycleStore } from '@/shared/stores/global/walletLifecycleStore'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; initLog('Module', 'CocoProvider loaded'); @@ -31,13 +32,6 @@ interface CocoProviderProps { children: ReactNode; } -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - function defaultSelectedMintLogFields(mintUrl: string | null | undefined): Record { return { hasDefaultSelectedMint: !!mintUrl, diff --git a/shared/providers/InitializationProvider.tsx b/shared/providers/InitializationProvider.tsx index 7bf452b5a..d663b5353 100644 --- a/shared/providers/InitializationProvider.tsx +++ b/shared/providers/InitializationProvider.tsx @@ -193,7 +193,7 @@ export function InitializationProvider({ children }: InitializationProviderProps const result = stage.dependsOn.every((depId) => { const depStage = stages.get(depId); - return depStage && depStage.status === 'complete'; + return depStage?.status === 'complete'; }); if (result && stage.status === 'pending') { @@ -300,23 +300,17 @@ export function useInitializationStage(stageId: string, config: StageConfig = {} // eslint-disable-next-line react-hooks/exhaustive-deps }, [stageId]); - const log = useCallback( - (message: string) => { - updateStage(stageId, { message, status: 'loading' }); - }, - [stageId, updateStage] - ); + const log = (message: string) => { + updateStage(stageId, { message, status: 'loading' }); + }; - const complete = useCallback(() => { + const complete = () => { updateStage(stageId, { status: 'complete' }); - }, [stageId, updateStage]); + }; - const error = useCallback( - (errorMessage: string) => { - updateStage(stageId, { status: 'error', error: errorMessage }); - }, - [stageId, updateStage] - ); + const error = (errorMessage: string) => { + updateStage(stageId, { status: 'error', error: errorMessage }); + }; const canStart = canStageStart(stageId); diff --git a/shared/providers/NostrNDKProvider.tsx b/shared/providers/NostrNDKProvider.tsx index 16308a3e7..10139e8d6 100644 --- a/shared/providers/NostrNDKProvider.tsx +++ b/shared/providers/NostrNDKProvider.tsx @@ -1,12 +1,4 @@ -import React, { - createContext, - useContext, - useEffect, - useMemo, - useRef, - useState, - ReactNode, -} from 'react'; +import React, { createContext, useContext, useEffect, useRef, useState, ReactNode } from 'react'; import { NDKCacheAdapterSqlite, NDKPrivateKeySigner, useNDK } from '@nostr-dev-kit/ndk-mobile'; import { relays } from '@/shared/ndk'; import { giftWrapCache } from '@/shared/lib/nostr/giftWrapCache'; @@ -50,10 +42,8 @@ export function NostrNDKProvider({ const activeAccountIndex = accountIndexProp ?? 0; const hasInitialized = useRef(false); const [isInitialized, setIsInitialized] = useState(false); - const cacheAdapter = useMemo( - () => - new NDKCacheAdapterSqlite(activeAccountIndex === 0 ? 'nostr' : `nostr-${activeAccountIndex}`), - [activeAccountIndex] + const cacheAdapter = new NDKCacheAdapterSqlite( + activeAccountIndex === 0 ? 'nostr' : `nostr-${activeAccountIndex}` ); // Non-blocking: starts after all blocking stages complete so it doesn't @@ -147,7 +137,7 @@ export function NostrNDKProvider({ // Memoized so context consumers (e.g. the NIP-46 signer service) only // re-render when readiness actually flips, not on every provider render. - const contextValue = useMemo(() => ({ isInitialized }), [isInitialized]); + const contextValue = { isInitialized }; return {children}; } diff --git a/shared/providers/OfflineProvider.tsx b/shared/providers/OfflineProvider.tsx index 5c9bb5a64..890371d39 100644 --- a/shared/providers/OfflineProvider.tsx +++ b/shared/providers/OfflineProvider.tsx @@ -241,7 +241,9 @@ export function OfflineStatusProvider({ children }: { children: React.ReactNode }; }, []); - const contextValue = useMemo(() => ({ isOffline }), [isOffline]); + const contextValue = { + isOffline, + }; return {children}; } @@ -257,33 +259,22 @@ export function OfflineShell({ children }: { children: React.ReactNode }) { const frame = useSafeAreaFrame(); const offlineAccentColor = info; const offlineTextColor = foreground; - const screenCornerRadius = useMemo( - () => getIosCornerRadius(frame.width, frame.height), - [frame.height, frame.width] - ); - const shellCornerStyle = useMemo( - () => ({ - borderRadius: screenCornerRadius, - ...(Platform.OS === 'ios' - ? ({ - borderCurve: 'continuous', - } as const) - : null), - }), - [screenCornerRadius] - ); - const outerShellStyle = useMemo( - () => ({ - backgroundColor: isOffline ? offlineAccentColor : 'transparent', - }), - [isOffline, offlineAccentColor] - ); - const topSectionStyle = useMemo( - () => ({ - height: isOffline ? BANNER_HEIGHT + insets.top : 0, - }), - [insets.top, isOffline] - ); + const screenCornerRadius = getIosCornerRadius(frame.width, frame.height); + const shellCornerStyle = { + borderRadius: screenCornerRadius, + + ...(Platform.OS === 'ios' + ? ({ + borderCurve: 'continuous', + } as const) + : null), + }; + const outerShellStyle = { + backgroundColor: isOffline ? offlineAccentColor : 'transparent', + }; + const topSectionStyle = { + height: isOffline ? BANNER_HEIGHT + insets.top : 0, + }; const contentShellStyle = useMemo(() => { const inset = isOffline ? BORDER_WIDTH : 0; const contentRadius = Math.max(0, screenCornerRadius - inset); diff --git a/shared/providers/PricelistProvider.tsx b/shared/providers/PricelistProvider.tsx index c55bc690d..bb60dc4f7 100644 --- a/shared/providers/PricelistProvider.tsx +++ b/shared/providers/PricelistProvider.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, createContext, useMemo } from 'react'; +import React, { useEffect, createContext } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { usePricelistStore, BitcoinPrices } from '@/shared/stores/global/pricelistStore'; import { log, initLog, useInitMount } from '@/shared/lib/logger'; @@ -128,15 +128,14 @@ export const PricelistProvider = ({ children }: { children: React.ReactNode }) = }; }, [setBtcPrices, setLoading, setError]); - const contextValue = useMemo( - () => ({ - btcPrice: pricelist?.usd?.btc, - isLoading, - error, - isStale: isDataStale(5), // Consider data stale after 5 minutes - }), - [pricelist, isLoading, error, isDataStale] - ); + const contextValue = { + btcPrice: pricelist?.usd?.btc, + isLoading, + error, + + // Consider data stale after 5 minutes + isStale: isDataStale(5), + }; return {children}; }; diff --git a/shared/providers/WalletContextProvider.tsx b/shared/providers/WalletContextProvider.tsx index 9af284e89..78f3157ac 100644 --- a/shared/providers/WalletContextProvider.tsx +++ b/shared/providers/WalletContextProvider.tsx @@ -30,18 +30,12 @@ import { amountToNumber } from '@/shared/lib/cashu/amount'; import { useMintStore } from '@/shared/stores/profile/mintStore'; import { useShallowMemo } from '@/shared/hooks/useShallowMemo'; import { walletLog, initLog, useInitMount } from '@/shared/lib/logger'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; initLog('Module', 'WalletContextProvider loaded'); const WalletContextCtx = createContext(null); -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - function preferredMintLogFields(mintUrl: string | null | undefined): Record { return { hasPreferredMintUrl: !!mintUrl, @@ -65,23 +59,16 @@ export function useWalletContext(): WalletContext { */ export function useWalletContextWithOverride(preferredMintUrl?: string): WalletContext { const ctx = useWalletContext(); - return useMemo( - () => (preferredMintUrl != null ? { ...ctx, preferredMintUrl } : ctx), - [ctx, preferredMintUrl] - ); + return preferredMintUrl != null ? { ...ctx, preferredMintUrl } : ctx; } export function WalletContextProvider({ children }: { children: React.ReactNode }) { useInitMount('WalletContextProvider'); const { trustedMints: rawTrustedMints } = useMints(); const { balances: rawBalanceCtx } = useBalanceContext(); - const rawMintBalances = useMemo( - () => - Object.fromEntries( - Object.entries(rawBalanceCtx.byMint).map(([url, snap]) => [url, amountToNumber(snap.total)]) - ) as Record, - [rawBalanceCtx] - ); + const rawMintBalances = Object.fromEntries( + Object.entries(rawBalanceCtx.byMint).map(([url, snap]) => [url, amountToNumber(snap.total)]) + ) as Record; const manager = useManager(); const preferredMintUrl = useMintStore((state) => state.selectedMint); @@ -91,7 +78,7 @@ export function WalletContextProvider({ children }: { children: React.ReactNode const mintBalances = useShallowMemo(rawMintBalances); // Stabilise trustedMintUrls by comparing the serialised URL list - const trustedMintUrls = useMemo(() => rawTrustedMints.map((m) => m.mintUrl), [rawTrustedMints]); + const trustedMintUrls = rawTrustedMints.map((m) => m.mintUrl); const mintMethodCapabilities = useMemo( () => deriveMintMethodCapabilityMapFromTrustedMints( diff --git a/shared/providers/hero-transition/HeroTransitionProvider.tsx b/shared/providers/hero-transition/HeroTransitionProvider.tsx index 10e76449b..deef44e00 100644 --- a/shared/providers/hero-transition/HeroTransitionProvider.tsx +++ b/shared/providers/hero-transition/HeroTransitionProvider.tsx @@ -1,4 +1,5 @@ -import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; +import React, { createContext, useContext, useRef, useState } from 'react'; +import { AMBER_ACCENT } from '@/shared/lib/brandColors'; import { Platform } from 'react-native'; import { FullWindowOverlay } from 'react-native-screens'; import Animated, { @@ -13,7 +14,7 @@ import opacity from 'hex-color-opacity'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { router } from 'expo-router'; import { ClaimUsernameCardFrame } from '@/shared/blocks/claim/ClaimUsernameCardFrame'; -import { measureInWindowAsync, rafAsync } from './measure'; +import { measureInWindowAsync, rafAsync, type Measurable } from './measure'; import type { HeroId, Rect, HeroRole } from './types'; import { zIndex } from '@/shared/styles/tokens'; @@ -25,7 +26,7 @@ type HeroTransitionPhase = | { state: 'back_animating'; id: HeroId; params?: Record }; type Ctx = { - registerRef: (id: HeroId, role: HeroRole, ref: any) => void; + registerRef: (id: HeroId, role: HeroRole, ref: Measurable | null) => void; startClaimUsername: () => void; closeClaimUsername: () => void; isHidden: (id: HeroId, role: HeroRole) => boolean; @@ -44,10 +45,10 @@ export function HeroTransitionProvider({ children }: { children: React.ReactNode ] as const); const primary950 = background; const primary50 = surfaceForeground; - const gold = '#f59e0b'; + const gold = AMBER_ACCENT; const overlayBorderColor = opacity(gold, 0.3); - const refs = useRef>>>({ + const refs = useRef>>>({ claimUsername: {}, }); @@ -64,40 +65,37 @@ export function HeroTransitionProvider({ children }: { children: React.ReactNode const toW = useSharedValue(0); const toH = useSharedValue(0); - const registerRef = useCallback((id: HeroId, role: HeroRole, ref: any) => { + const registerRef = (id: HeroId, role: HeroRole, ref: Measurable | null) => { refs.current[id] = refs.current[id] || {}; refs.current[id][role] = ref; - }, []); - - const animateOverlay = useCallback( - (fromRect: Rect, toRect: Rect, onDone: () => void) => { - // Set geometry synchronously so the overlay never mounts "empty/invisible". - fromX.set(fromRect.x); - fromY.set(fromRect.y); - fromW.set(fromRect.width); - fromH.set(fromRect.height); - toX.set(toRect.x); - toY.set(toRect.y); - toW.set(toRect.width); - toH.set(toRect.height); - - // Drive animation directly from JS thread — reanimated handles the UI-thread - // transition internally. On completion, bounce back to JS via runOnJS. - // (Previously used scheduleOnUI/scheduleOnRN from react-native-worklets which - // caused SIGABRT crashes when combined with navigation transitions.) - cancelAnimation(progress); - progress.set(0); - progress.set( - withTiming(1, { duration: DURATION_MS, easing: Easing.out(Easing.cubic) }, (finished) => { - 'worklet'; - if (finished) { - runOnJS(onDone)(); - } - }) - ); - }, - [fromH, fromW, fromX, fromY, progress, toH, toW, toX, toY] - ); + }; + + const animateOverlay = (fromRect: Rect, toRect: Rect, onDone: () => void) => { + // Set geometry synchronously so the overlay never mounts "empty/invisible". + fromX.set(fromRect.x); + fromY.set(fromRect.y); + fromW.set(fromRect.width); + fromH.set(fromRect.height); + toX.set(toRect.x); + toY.set(toRect.y); + toW.set(toRect.width); + toH.set(toRect.height); + + // Drive animation directly from JS thread — reanimated handles the UI-thread + // transition internally. On completion, bounce back to JS via runOnJS. + // (Previously used scheduleOnUI/scheduleOnRN from react-native-worklets which + // caused SIGABRT crashes when combined with navigation transitions.) + cancelAnimation(progress); + progress.set(0); + progress.set( + withTiming(1, { duration: DURATION_MS, easing: Easing.out(Easing.cubic) }, (finished) => { + 'worklet'; + if (finished) { + runOnJS(onDone)(); + } + }) + ); + }; const overlayStyle = useAnimatedStyle(() => { const p = progress.get(); @@ -138,31 +136,23 @@ export function HeroTransitionProvider({ children }: { children: React.ReactNode }; }); - const isAnimating = useCallback( - (id: HeroId) => - (phase.state === 'forward_animating' || phase.state === 'back_animating') && phase.id === id, - [phase] - ); + const isAnimating = (id: HeroId) => + (phase.state === 'forward_animating' || phase.state === 'back_animating') && phase.id === id; - const isTransitioning = useCallback( - (id: HeroId) => overlayVisible && phase.state !== 'idle' && 'id' in phase && phase.id === id, - [overlayVisible, phase] - ); + const isTransitioning = (id: HeroId) => + overlayVisible && phase.state !== 'idle' && 'id' in phase && phase.id === id; - const isHidden = useCallback( - (id: HeroId, role: HeroRole) => { - // While overlay is visible, hide both source and destination nodes to avoid double-render flicker. - // (The overlay is the "one true" element during the morph.) - if (!overlayVisible) return false; - if (phase.state === 'idle') return false; - if (!('id' in phase)) return false; - if (phase.id !== id) return false; - return role === 'source' || role === 'destination'; - }, - [overlayVisible, phase] - ); + const isHidden = (id: HeroId, role: HeroRole) => { + // While overlay is visible, hide both source and destination nodes to avoid double-render flicker. + // (The overlay is the "one true" element during the morph.) + if (!overlayVisible) return false; + if (phase.state === 'idle') return false; + if (!('id' in phase)) return false; + if (phase.id !== id) return false; + return role === 'source' || role === 'destination'; + }; - const startClaimUsername = useCallback(async () => { + const startClaimUsername = async () => { if (phase.state !== 'idle') return; const sourceRef = refs.current.claimUsername?.source; @@ -206,9 +196,9 @@ export function HeroTransitionProvider({ children }: { children: React.ReactNode setOverlayVisible(false); setPhase({ state: 'idle' }); - }, [animateOverlay, fromH, fromW, fromX, fromY, phase.state, progress, toH, toW, toX, toY]); + }; - const closeClaimUsername = useCallback(async () => { + const closeClaimUsername = async () => { if (phase.state !== 'idle') return; const destRef = refs.current.claimUsername?.destination; @@ -250,23 +240,23 @@ export function HeroTransitionProvider({ children }: { children: React.ReactNode setOverlayVisible(false); setPhase({ state: 'idle' }); - }, [animateOverlay, fromH, fromW, fromX, fromY, phase.state, progress, toH, toW, toX, toY]); - - const value = useMemo( - () => ({ - registerRef, - startClaimUsername: () => { - void startClaimUsername(); - }, - closeClaimUsername: () => { - void closeClaimUsername(); - }, - isHidden, - isAnimating, - isTransitioning, - }), - [registerRef, startClaimUsername, closeClaimUsername, isHidden, isAnimating, isTransitioning] - ); + }; + + const value = { + registerRef, + + startClaimUsername: () => { + void startClaimUsername(); + }, + + closeClaimUsername: () => { + void closeClaimUsername(); + }, + + isHidden, + isAnimating, + isTransitioning, + }; return ( diff --git a/shared/providers/hero-transition/measure.ts b/shared/providers/hero-transition/measure.ts index 321ba14c6..236431694 100644 --- a/shared/providers/hero-transition/measure.ts +++ b/shared/providers/hero-transition/measure.ts @@ -1,6 +1,14 @@ import type { Rect } from './types'; -export async function measureInWindowAsync(ref: any): Promise { +export type Measurable = { + measureInWindow?: ( + callback: (x: number, y: number, width: number, height: number) => void + ) => void; +}; + +export async function measureInWindowAsync( + ref: Measurable | null | undefined +): Promise { return await new Promise((resolve) => { try { if (!ref?.measureInWindow) return resolve(null); diff --git a/shared/stores/global/migrateSettings.ts b/shared/stores/global/migrateSettings.ts index 76ef29611..15e988521 100644 --- a/shared/stores/global/migrateSettings.ts +++ b/shared/stores/global/migrateSettings.ts @@ -6,6 +6,7 @@ import { useSettingsStore } from './settingsStore'; * Migration script to move settings from Redux to Zustand * This should be run once during app startup */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- reads the legacy persisted Redux blob whose historical shape predates the current stores (cf. redux/*.deprecated.ts); typing it would be churn on migration scaffolding slated for removal. export const migrateSettingsFromRedux = async (reduxState?: any) => { try { storeLog.info('settings.migration.start'); diff --git a/shared/stores/profile/ownContentStore.ts b/shared/stores/profile/ownContentStore.ts index b404fe788..67d16652c 100644 --- a/shared/stores/profile/ownContentStore.ts +++ b/shared/stores/profile/ownContentStore.ts @@ -137,7 +137,7 @@ export const useOwnContentStore = create()( confirmOwn: (id) => set((state) => { const cur = state.byId[id]; - if (!cur || cur.status !== 'pending') return state; + if (cur?.status !== 'pending') return state; return { byId: { ...state.byId, [id]: { ...cur, status: 'local', updatedAt: Date.now() } }, }; @@ -154,7 +154,7 @@ export const useOwnContentStore = create()( ingestSeen: (event) => set((state) => { const cur = state.byId[event.id]; - if (cur && cur.status === 'confirmed') return state; // already settled + if (cur?.status === 'confirmed') return state; // already settled const entry: OwnContentEntry = { event: feedEventFrom(event), authorPubkey: event.pubkey, diff --git a/shared/stores/profile/restoreActiveSessionView.ts b/shared/stores/profile/restoreActiveSessionView.ts index 7b1532eac..59ce4082d 100644 --- a/shared/stores/profile/restoreActiveSessionView.ts +++ b/shared/stores/profile/restoreActiveSessionView.ts @@ -26,5 +26,6 @@ export function restoreActiveSessionView s.id === state.currentSessionId); if (!session) return; state.conversationHistory = session.messages; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- empty default for the generic TActiveChildren (extends Record): {} is the no-children case but isn't structurally assignable to an arbitrary TActiveChildren. state.activeChildren = session.activeChildren ?? ({} as TActiveChildren); } diff --git a/shared/stores/profile/transactionAnnotationStore.ts b/shared/stores/profile/transactionAnnotationStore.ts index a1ea662e8..be23d5311 100644 --- a/shared/stores/profile/transactionAnnotationStore.ts +++ b/shared/stores/profile/transactionAnnotationStore.ts @@ -43,7 +43,7 @@ const PersistedTransactionAnnotationStore = z.object({ _migratedLegacy: z.boolean().default(false), }); -export const useTransactionAnnotationStore = create()( +const useTransactionAnnotationStore = create()( subscribeWithSelector( persist( (): TransactionAnnotationState => ({ diff --git a/shared/stores/profile/transactionDistributionStore.ts b/shared/stores/profile/transactionDistributionStore.ts index dfc66887c..c22e80c03 100644 --- a/shared/stores/profile/transactionDistributionStore.ts +++ b/shared/stores/profile/transactionDistributionStore.ts @@ -49,7 +49,7 @@ import { persistConfig } from '@/shared/lib/persist/persistConfig'; * intentionally distinct from the inbound `ScanSource` ('qr' | 'nfc' | * 'paste' | 'deeplink') so the row can render a different icon for each. */ -export type DistributionSource = 'copy' | 'share' | 'airdrop' | 'displayed'; +type DistributionSource = 'copy' | 'share' | 'airdrop' | 'displayed'; interface DistributionEntry { source: DistributionSource; diff --git a/shared/stores/runtime/amountDraftStore.ts b/shared/stores/runtime/amountDraftStore.ts index 70fb725c4..02e11bedf 100644 --- a/shared/stores/runtime/amountDraftStore.ts +++ b/shared/stores/runtime/amountDraftStore.ts @@ -46,7 +46,7 @@ export const useAmountDraftStore = create((set, get) => ({ }, take: (scope) => { const p = get().pending; - if (!p || p.scope !== scope) { + if (p?.scope !== scope) { paymentLog.info('amount_draft.take_miss', { scope, hasPending: !!p, diff --git a/shared/stores/runtime/mockDataStore.ts b/shared/stores/runtime/mockDataStore.ts index 0ae217629..676e39337 100644 --- a/shared/stores/runtime/mockDataStore.ts +++ b/shared/stores/runtime/mockDataStore.ts @@ -29,9 +29,9 @@ import { import { withSkippedPersistWrites } from '@/shared/lib/cashu/profileScopedStorage'; import { amountToNumber, toCocoAmount } from '@/shared/lib/cashu/amount'; import type { HistoryEntry } from '@cashu/coco-core'; -// Type-only import — `useRecentContacts` does not import this file at runtime -// (it reads mock state via getMockState() below), so there's no cycle. -import type { RecentContact } from '@/features/payments/hooks/useNip17RecentContacts'; +// The row shape lives in its own type module so this store and the +// `useNip17RecentContacts` hook can share it without importing each other. +import type { RecentContact } from '@/features/payments/hooks/recentContactTypes'; // --------------------------------------------------------------------------- // Demo row definition — single source of truth for all mock data. diff --git a/shared/styles/tokens.ts b/shared/styles/tokens.ts index a23981dae..423d00374 100644 --- a/shared/styles/tokens.ts +++ b/shared/styles/tokens.ts @@ -49,8 +49,6 @@ export const spacing = { '4xl': 48, } as const; -export type Spacing = (typeof spacing)[keyof typeof spacing]; - // ─── Border radius ──────────────────────────────────────────────────────── // Outliers (6, 10, 14, 18) snap to nearest scale step. Use `pill` for fully // rounded buttons / circles instead of `borderRadius: 999` magic number. @@ -70,8 +68,6 @@ export const radius = { pill: 999, } as const; -export type Radius = (typeof radius)[keyof typeof radius]; - // ─── Alpha (opacity values) ─────────────────────────────────────────────── // The big consolidation. Audit found 32 unique stops in `opacity(color, x)` // calls; this scale collapses them to seven intent-named tokens. Named @@ -96,8 +92,6 @@ export const alpha = { prominent: 0.85, } as const; -export type Alpha = (typeof alpha)[keyof typeof alpha]; - // ─── Animation timing ───────────────────────────────────────────────────── // In milliseconds. Pass to Reanimated's `withTiming(target, { duration })`. // Outliers (150, 180, 220, 350, 600, 1000) all map to one of these. @@ -119,8 +113,6 @@ export const duration = { loop: 1500, } as const; -export type Duration = (typeof duration)[keyof typeof duration]; - // ─── Z-index hierarchy ─────────────────────────────────────────────────── // Replaces the freeform 1 / 10 / 50 / 99 / 1000 / 9999 sprawl with a // hierarchy that documents INTENT. If a new layer is needed, pick the next @@ -143,8 +135,6 @@ export const zIndex = { overlay: 9999, } as const; -export type ZIndex = (typeof zIndex)[keyof typeof zIndex]; - // ─── Icon size ──────────────────────────────────────────────────────────── // For ``. The audit found 20+ unique sizes in use; this // scale covers every meaningful tier without tempting another off-grid pick. @@ -166,8 +156,6 @@ export const iconSize = { '3xl': 48, } as const; -export type IconSize = (typeof iconSize)[keyof typeof iconSize]; - // ─── Hit slop / minimum touch target ───────────────────────────────────── // Apple HIG and Material both recommend ≥ 44pt. Anything smaller needs // explicit hit slop to remain accessible. @@ -182,7 +170,7 @@ export const hitSlop = { /** Minimum interactive element height. iOS HIG: 44pt. Material: 48pt. * Use 44 by default; bump to 48 only when the parent has dense vertical * packing that hides the difference. */ -export const minTouchTarget = 44; +const minTouchTarget = 44; // ─── Shadows ────────────────────────────────────────────────────────────── // Cross-platform pairs: iOS shadow* props + Android elevation. Apply with diff --git a/shared/ui/capability/index.tsx b/shared/ui/capability/index.tsx index 33dfc0917..2549c7ad4 100644 --- a/shared/ui/capability/index.tsx +++ b/shared/ui/capability/index.tsx @@ -13,15 +13,13 @@ * the sync helpers in `shared/lib/version.ts`. */ -import React, { createContext, useContext, useMemo } from 'react'; +import React, { createContext, useContext } from 'react'; -import { liquidGlassModifiers as syncLiquidGlassModifiers } from '@/shared/lib/version'; import { useSettingsStore } from '@/shared/stores/global/settingsStore'; import { detectCapabilities } from './detect'; import type { Capabilities } from './types'; -export type { Capabilities, IconSource } from './types'; export { defineVariants } from './defineVariants'; const CapabilityContext = createContext(null); @@ -37,10 +35,7 @@ export function CapabilityProvider({ children, }: CapabilityProviderProps): React.ReactElement { const mockNoGlass = useSettingsStore((s) => s.mockNoGlass); - const detected = useMemo( - () => value ?? detectCapabilities({ mockNoGlass }), - [value, mockNoGlass] - ); + const detected = value ?? detectCapabilities({ mockNoGlass }); return {children}; } @@ -57,18 +52,3 @@ export function useCapabilities(): Capabilities { } return v; } - -/** - * Hook variant of `liquidGlassModifiers()`. Use this from inside React render - * so the consumer re-renders when `mockNoGlass` flips. Module-scope callers - * (worklets, native tabs) should keep using the sync helper from `version.ts`. - */ -export function useLiquidGlassModifiers(...modifiers: T[]): T[] { - const { liquidGlass } = useCapabilities(); - return liquidGlass ? modifiers : []; -} - -// Re-export the sync helper here too so component code only needs one import -// path. The sync version reads `useSettingsStore.getState()` directly and is -// safe to call from worklets / module scope. -export { syncLiquidGlassModifiers as liquidGlassModifiers }; diff --git a/shared/ui/composed/ActionMenuButton.tsx b/shared/ui/composed/ActionMenuButton.tsx index 7e8d79b8e..d99254b55 100644 --- a/shared/ui/composed/ActionMenuButton.tsx +++ b/shared/ui/composed/ActionMenuButton.tsx @@ -23,7 +23,7 @@ * colada. */ -import React, { useCallback, useRef } from 'react'; +import React, { useRef } from 'react'; import { Platform, StyleProp, ViewStyle } from 'react-native'; import { Menu, type MenuTriggerRef } from 'heroui-native'; @@ -134,13 +134,13 @@ export function ActionMenuButton({ // imperatively via ref from the Button's onPress. The same pattern is used // by SendTokenScreen's Copy menu. const menuTriggerRef = useRef(null); - const openMenu = useCallback(() => { + const openMenu = () => { // Defer to the next tick so the Button's press animation doesn't race // with the Trigger's `measure()` call inside heroui's `.open()`. setTimeout(() => menuTriggerRef.current?.open(), 0); - }, []); + }; - const handlePrimaryPress = useCallback(async () => { + const handlePrimaryPress = async () => { if (!defaultVariant || primaryDisabled) return; try { await defaultVariant.onPress(); @@ -151,7 +151,7 @@ export function ActionMenuButton({ error: error instanceof Error ? error.message : String(error), }); } - }, [defaultVariant, primaryDisabled, testID]); + }; const primaryIconNode = icon ? : undefined; diff --git a/shared/ui/composed/AmountEntryView.tsx b/shared/ui/composed/AmountEntryView.tsx index bbca0cb59..49d392ecf 100644 --- a/shared/ui/composed/AmountEntryView.tsx +++ b/shared/ui/composed/AmountEntryView.tsx @@ -7,7 +7,7 @@ * create cycles, so any local-state caller can reuse it without the machine. */ -import { useCallback, useEffect, useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { ScrollView, StyleSheet, Text as RNText, useWindowDimensions } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -317,50 +317,33 @@ export function AmountEntryView({ ); }, [transactionType, suggestions, onSuggestionTap, foreground, background]); - const logNextPress = useCallback( - (source: 'plain' | 'leading-row') => { - cashuLog.info('amount_entry.next.press', { - source, - transactionType, - inputMode, - rawInputLength: rawInput.length, - numericValue, - unit, - nextDisabled, - nextLoading, - hasNextVariants: !!nextVariants?.length, - extraButtonCount: extraButtons?.length ?? 0, - hasLeadingBottomButton: !!leadingBottomButton, - }); - }, - [ - extraButtons?.length, + const logNextPress = (source: 'plain' | 'leading-row') => { + cashuLog.info('amount_entry.next.press', { + source, + transactionType, inputMode, - leadingBottomButton, + rawInputLength: rawInput.length, + numericValue, + unit, nextDisabled, nextLoading, - nextVariants?.length, - numericValue, - rawInput.length, + hasNextVariants: !!nextVariants?.length, + extraButtonCount: extraButtons?.length ?? 0, + hasLeadingBottomButton: !!leadingBottomButton, + }); + }; + const handleKeyPress = (value: string) => { + cashuLog.debug('amount_entry.keyboard.input', { transactionType, + inputMode, + previousLength: rawInput.length, + nextLength: value.length, + numericValue, unit, - ] - ); - const handleKeyPress = useCallback( - (value: string) => { - cashuLog.debug('amount_entry.keyboard.input', { - transactionType, - inputMode, - previousLength: rawInput.length, - nextLength: value.length, - numericValue, - unit, - }); - onKeyPress(value); - }, - [inputMode, numericValue, onKeyPress, rawInput.length, transactionType, unit] - ); - const handleToggleMode = useCallback(() => { + }); + onKeyPress(value); + }; + const handleToggleMode = () => { cashuLog.info('amount_entry.mode.toggle', { transactionType, inputMode, @@ -369,7 +352,7 @@ export function AmountEntryView({ hasToggleHandler: !!onToggleMode, }); onToggleMode?.(); - }, [inputMode, numericValue, onToggleMode, rawInput.length, transactionType]); + }; useEffect(() => { cashuLog.debug('amount_entry.render_state', { @@ -451,7 +434,6 @@ export function AmountEntryView({ - {suggestionsRow} getScrollableOverlayMeshPoints(overlayLocations[0], overlayLocations[1]), - [overlayLocations] - ); - const androidBackgroundMeshColors = useMemo( - () => - getScrollableOverlayMeshColors({ - top: opacity(screenBackgroundColor, 0), - mid: screenBackgroundColor, - bottom: screenBackgroundColor, - }), - [screenBackgroundColor] + const androidMeshPoints = getScrollableOverlayMeshPoints( + overlayLocations[0], + overlayLocations[1] ); + const androidBackgroundMeshColors = getScrollableOverlayMeshColors({ + top: opacity(screenBackgroundColor, 0), + mid: screenBackgroundColor, + bottom: screenBackgroundColor, + }); return ( getMeshGradientColors(gradientColors, gradientColor || surface), - [gradientColors, gradientColor, surface] - ); + const meshGradientColors = getMeshGradientColors(gradientColors, gradientColor || surface); log.debug('bg.view.render', { theme: currentTheme, @@ -349,4 +342,4 @@ function AnimatedBackgroundViewComponent({ ); } -export const AnimatedBackgroundView = memo(AnimatedBackgroundViewComponent); +export const AnimatedBackgroundView = React.memo(AnimatedBackgroundViewComponent); diff --git a/shared/ui/composed/BootEntrance.tsx b/shared/ui/composed/BootEntrance.tsx index 3130a8e7b..26e4bce92 100644 --- a/shared/ui/composed/BootEntrance.tsx +++ b/shared/ui/composed/BootEntrance.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import { StyleSheet, type ViewStyle } from 'react-native'; import Animated, { Easing, @@ -85,10 +85,7 @@ export function BootEntrance({ children, style }: BootEntranceProps): React.Reac opacity: opacity.get(), transform: [{ scale: scale.get() }], })); - const containerStyle = useMemo( - () => [styles.container, style, animatedStyle], - [animatedStyle, style] - ); + const containerStyle = [styles.container, style, animatedStyle]; return ( diff --git a/shared/ui/composed/BottomButtons.tsx b/shared/ui/composed/BottomButtons.tsx index 230258120..9dde92cfc 100644 --- a/shared/ui/composed/BottomButtons.tsx +++ b/shared/ui/composed/BottomButtons.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useCallback, useMemo } from 'react'; +import React, { ReactNode } from 'react'; import { LayoutChangeEvent, Platform, StyleSheet, ViewStyle, StyleProp } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BlurView, BlurTint } from 'expo-blur'; @@ -95,24 +95,17 @@ export function BottomButtons({ gradientColor === null ? null : (gradientColor ?? screenBackground ?? themeBackground); const { setFooterHeight } = useScreenFooter(); const shouldRenderBlur = blur && Platform.OS !== 'android'; - const handleLayout = useCallback( - (event: LayoutChangeEvent) => { - setFooterHeight(event.nativeEvent.layout.height); - onLayout?.(event); - }, - [setFooterHeight, onLayout] - ); - const colorGradientColors = useMemo( - () => - resolvedGradientColor - ? ([ - opacity(resolvedGradientColor, 0), - opacity(resolvedGradientColor, 0.75), - opacity(resolvedGradientColor, 1), - ] as const) - : null, - [resolvedGradientColor] - ); + const handleLayout = (event: LayoutChangeEvent) => { + setFooterHeight(event.nativeEvent.layout.height); + onLayout?.(event); + }; + const colorGradientColors = resolvedGradientColor + ? ([ + opacity(resolvedGradientColor, 0), + opacity(resolvedGradientColor, 0.75), + opacity(resolvedGradientColor, 1), + ] as const) + : null; return ( */ +// The Menu closes itself on select (shouldCloseOnSelect default); keep +// async action failures contained so overflow actions do not surface as +// unhandled promise rejections on Android. +const handleMenuItemPress = async (button: ButtonHandlerActionButton): Promise => { + if (button.disabled) return; + try { + await button.onPress?.(); + } catch (error) { + log.error('ui.button_handler.menu_action_failed', { + testID: button.testID, + error: error instanceof Error ? error.message : String(error), + }); + } +}; + export function ButtonHandler({ context: _context, buttons, @@ -182,23 +197,8 @@ export function ButtonHandler({ // invisible ref-backed Trigger + `.open()` from the visible button's // onPress. const moreMenuTriggerRef = useRef(null); - const openMoreMenu = useCallback(() => { + const openMoreMenu = () => { setTimeout(() => moreMenuTriggerRef.current?.open(), 0); - }, []); - - // The Menu closes itself on select (shouldCloseOnSelect default); keep - // async action failures contained so overflow actions do not surface as - // unhandled promise rejections on Android. - const handleMenuItemPress = async (button: ButtonHandlerActionButton): Promise => { - if (button.disabled) return; - try { - await button.onPress?.(); - } catch (error) { - log.error('ui.button_handler.menu_action_failed', { - testID: button.testID, - error: error instanceof Error ? error.message : String(error), - }); - } }; // The inner shared `Button` already routes its onPress through diff --git a/shared/ui/composed/ContactRow.tsx b/shared/ui/composed/ContactRow.tsx index 2d44b252b..72e8468c8 100644 --- a/shared/ui/composed/ContactRow.tsx +++ b/shared/ui/composed/ContactRow.tsx @@ -270,21 +270,6 @@ export function geohashIdentity( }; } -export function selfIdentity( - pubkey: string, - nickname: string, - opts?: { avatarUrl?: string; isActive?: boolean; subtitle?: string } -): SelfIdentity { - return { - kind: 'self', - pubkey, - nickname, - avatarUrl: opts?.avatarUrl, - isActive: !!opts?.isActive, - subtitle: opts?.subtitle, - }; -} - // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- @@ -581,7 +566,7 @@ function buildStats( } break; case 'connection': - if (ble && ble.isConnected !== undefined) { + if (ble?.isConnected !== undefined) { // Three-state badge: direct link (green), mesh-only (warning), offline. // The mesh-only state is the one users find confusing — peer shows // up but DMs are flaky. Calling it out by icon + word avoids that. @@ -871,7 +856,7 @@ export function ContactRow({ // - mesh → warning lan-disconnect icon ("DM may stall") // - offline → faded clock ("last seen…") const bleConnectionNode = - ble && ble.isConnected !== undefined ? ( + ble?.isConnected !== undefined ? ( !ble.isConnected ? ( ) : ble.hasDirectLink === false ? ( diff --git a/shared/ui/composed/CustomKeyboard.tsx b/shared/ui/composed/CustomKeyboard.tsx index 530efbd3e..29502185a 100644 --- a/shared/ui/composed/CustomKeyboard.tsx +++ b/shared/ui/composed/CustomKeyboard.tsx @@ -71,28 +71,25 @@ const CustomKeyboard: React.FC = ({ [onKeyPress, unit] ); - const renderButton = useCallback( - (value: KeyboardValue) => ( - handlePress(value)}> - {value === '<' ? ( - - ) : ( - - {value} - - )} - - ), - [compact, foreground, handlePress, loading] + const renderButton = (value: KeyboardValue) => ( + handlePress(value)}> + {value === '<' ? ( + + ) : ( + + {value} + + )} + ); const buttons: KeyboardValue[][] = [ diff --git a/shared/ui/composed/GlassSearchBar/GlassSearchBar.android.tsx b/shared/ui/composed/GlassSearchBar/GlassSearchBar.android.tsx index 1e2b0a830..fc34286ed 100644 --- a/shared/ui/composed/GlassSearchBar/GlassSearchBar.android.tsx +++ b/shared/ui/composed/GlassSearchBar/GlassSearchBar.android.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import { TextInput } from 'react-native'; import opacity from 'hex-color-opacity'; @@ -8,7 +8,7 @@ import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { radius } from '@/shared/styles/tokens'; import type { GlassSearchBarProps } from './types'; -export const GlassSearchBar = memo(function GlassSearchBar({ +export const GlassSearchBar = React.memo(function GlassSearchBar({ clearKey, onChangeText, placeholder, @@ -44,20 +44,17 @@ export const GlassSearchBar = memo(function GlassSearchBar({ }; }, []); - const handleTextChange = useCallback( - (text: string) => { - if (!debounceMs) { - onChangeTextRef.current(text); - return; - } - latestTextRef.current = text; - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - onChangeTextRef.current(latestTextRef.current); - }, debounceMs); - }, - [debounceMs] - ); + const handleTextChange = (text: string) => { + if (!debounceMs) { + onChangeTextRef.current(text); + return; + } + latestTextRef.current = text; + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + onChangeTextRef.current(latestTextRef.current); + }, debounceMs); + }; return ( diff --git a/shared/ui/composed/GlassSearchBar/GlassSearchBar.ios.tsx b/shared/ui/composed/GlassSearchBar/GlassSearchBar.ios.tsx index c3d541d62..ba34e676d 100644 --- a/shared/ui/composed/GlassSearchBar/GlassSearchBar.ios.tsx +++ b/shared/ui/composed/GlassSearchBar/GlassSearchBar.ios.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import { TextInput, StyleSheet } from 'react-native'; import { GlassView } from 'expo-glass-effect'; @@ -10,7 +10,7 @@ import { supportsLiquidGlass } from '@/shared/lib/version'; import opacity from 'hex-color-opacity'; import type { GlassSearchBarProps } from './types'; -export const GlassSearchBar = memo(function GlassSearchBar({ +export const GlassSearchBar = function GlassSearchBar({ width, height = 44, clearKey, @@ -45,20 +45,17 @@ export const GlassSearchBar = memo(function GlassSearchBar({ }; }, []); - const handleTextChange = useCallback( - (text: string) => { - if (!debounceMs) { - onChangeTextRef.current(text); - return; - } - latestTextRef.current = text; - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - onChangeTextRef.current(latestTextRef.current); - }, debounceMs); - }, - [debounceMs] - ); + const handleTextChange = (text: string) => { + if (!debounceMs) { + onChangeTextRef.current(text); + return; + } + latestTextRef.current = text; + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + onChangeTextRef.current(latestTextRef.current); + }, debounceMs); + }; // Liquid devices get a real glass capsule (the component's namesake); // everywhere else keeps the flat surface-secondary field. @@ -100,7 +97,7 @@ export const GlassSearchBar = memo(function GlassSearchBar({ ); -}); +}; const styles = StyleSheet.create({ // Capsule (radius = height/2 applied inline) matching the liquid design diff --git a/shared/ui/composed/GlassSearchBar/index.ts b/shared/ui/composed/GlassSearchBar/index.ts index decc1bfce..c8ca9022c 100644 --- a/shared/ui/composed/GlassSearchBar/index.ts +++ b/shared/ui/composed/GlassSearchBar/index.ts @@ -1,2 +1 @@ export { GlassSearchBar } from './GlassSearchBar'; -export type { GlassSearchBarProps } from './types'; diff --git a/shared/ui/composed/HeaderGlassCircle.tsx b/shared/ui/composed/HeaderGlassCircle.tsx index 204a4c0dc..3605f610a 100644 --- a/shared/ui/composed/HeaderGlassCircle.tsx +++ b/shared/ui/composed/HeaderGlassCircle.tsx @@ -36,22 +36,16 @@ export function HeaderGlassCircle({ const colorScheme = useColorScheme(); const size = headerButtonSize; const interactive = !!onPress && !disabled; - const containerStyle = React.useMemo( - () => [styles.box, { opacity: disabled ? 0.4 : 1 }], - [disabled] - ); + const containerStyle = [styles.box, { opacity: disabled ? 0.4 : 1 }]; - const buttonModifiers = React.useMemo( - () => [ - environment('colorScheme', colorScheme), - frame({ height: size, width: size, alignment: 'center' as const }), - glassEffect({ - shape: 'circle' as const, - glass: { variant: 'regular' as const, interactive }, - }), - ], - [colorScheme, interactive, size] - ); + const buttonModifiers = [ + environment('colorScheme', colorScheme), + frame({ height: size, width: size, alignment: 'center' as const }), + glassEffect({ + shape: 'circle' as const, + glass: { variant: 'regular' as const, interactive }, + }), + ]; return ( { + const handleScroll = (event: { nativeEvent: NativeScrollEvent }) => { const { contentInset } = event.nativeEvent; if (contentInset) { setAdjustedInsets({ @@ -126,7 +126,7 @@ export function LayoutDebugWrapper({ right: contentInset.right, }); } - }, []); + }; const actualBottomInset = adjustedInsets.bottom; const estimatedBottomArea = TAB_BAR_HEIGHT + insets.bottom; diff --git a/shared/ui/composed/MintIcon.tsx b/shared/ui/composed/MintIcon.tsx index c9122b8a0..01761c63d 100644 --- a/shared/ui/composed/MintIcon.tsx +++ b/shared/ui/composed/MintIcon.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Image as ExpoImage } from 'expo-image'; import opacity from 'hex-color-opacity'; import { StyleSheet, type StyleProp, View, type ViewStyle } from 'react-native'; @@ -53,34 +53,28 @@ export function MintIcon({ setFailedUrl(null); }, [normalizedIconUrl]); - const loadingColor = useMemo(() => opacity(foreground, 0.07), [foreground]); + const loadingColor = opacity(foreground, 0.07); const fallbackBackground = muted; const borderRadius = size / 2; const imageAlt = alt ?? `${name || 'Mint'} icon`; const iconSize = Math.round(size * 0.72); - const containerStyle = useMemo( - () => [ - styles.container, - { - width: size, - height: size, - borderRadius, - backgroundColor: isLoading ? loadingColor : fallbackBackground, - }, - style, - ], - [borderRadius, fallbackBackground, isLoading, loadingColor, size, style] - ); - const imageSource = useMemo( - () => (normalizedIconUrl ? { uri: normalizedIconUrl } : undefined), - [normalizedIconUrl] - ); - const handleImageError = useCallback(() => { + const containerStyle = [ + styles.container, + { + width: size, + height: size, + borderRadius, + backgroundColor: isLoading ? loadingColor : fallbackBackground, + }, + style, + ]; + const imageSource = normalizedIconUrl ? { uri: normalizedIconUrl } : undefined; + const handleImageError = () => { if (normalizedIconUrl) { setFailedUrl(normalizedIconUrl); } - }, [normalizedIconUrl]); + }; if (isLoading) { return ; diff --git a/shared/ui/composed/PatternBackground.tsx b/shared/ui/composed/PatternBackground.tsx index 3119d1e19..4431570ca 100644 --- a/shared/ui/composed/PatternBackground.tsx +++ b/shared/ui/composed/PatternBackground.tsx @@ -1,4 +1,4 @@ -import React, { memo, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import { Asset } from 'expo-asset'; import * as FileSystem from 'expo-file-system/legacy'; @@ -125,4 +125,4 @@ function PatternBackgroundComponent({ ); } -export const PatternBackground = memo(PatternBackgroundComponent); +export const PatternBackground = React.memo(PatternBackgroundComponent); diff --git a/shared/ui/composed/QRButton/QRButton.ios.tsx b/shared/ui/composed/QRButton/QRButton.ios.tsx index 399326255..62b6cf359 100644 --- a/shared/ui/composed/QRButton/QRButton.ios.tsx +++ b/shared/ui/composed/QRButton/QRButton.ios.tsx @@ -23,7 +23,7 @@ import { import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { useQRButtonPressFeedback } from './useQRButtonPressFeedback'; -export interface QRButtonProps { +interface QRButtonProps { onPress: () => void; accentColor?: string; color?: string; @@ -71,7 +71,7 @@ export function QRButton(props: QRButtonProps): React.ReactElement { runOnUI(() => { 'worklet'; const m = measure(animatedRef); - if (m === null || !m.width || !m.height) return; + if (!m?.width || !m.height) return; runOnJS(setQRButtonAnchor)({ x: m.pageX, y: m.pageY, diff --git a/shared/ui/composed/QRButton/index.ts b/shared/ui/composed/QRButton/index.ts index 0b6e6c5fa..65be9622b 100644 --- a/shared/ui/composed/QRButton/index.ts +++ b/shared/ui/composed/QRButton/index.ts @@ -1,2 +1 @@ export { QRButton } from './QRButton'; -export type { QRButtonProps } from './QRButton'; diff --git a/shared/ui/composed/QRButton/useQRButtonPressFeedback.ts b/shared/ui/composed/QRButton/useQRButtonPressFeedback.ts index 7cce9c938..203cb9df6 100644 --- a/shared/ui/composed/QRButton/useQRButtonPressFeedback.ts +++ b/shared/ui/composed/QRButton/useQRButtonPressFeedback.ts @@ -1,4 +1,3 @@ -import { useCallback } from 'react'; import type { GestureResponderEvent } from 'react-native'; import { Easing, @@ -26,25 +25,19 @@ export function useQRButtonPressFeedback() { transform: [{ scale: scale.get() }], })); - const onPressIn = useCallback( - (_event: GestureResponderEvent) => { - void EnhancedHaptics.buttonHaptic(); - scale.set( - withTiming(PRESSED_SCALE, { - duration: duration.instant, - easing: Easing.out(Easing.cubic), - }) - ); - }, - [scale] - ); + const onPressIn = (_event: GestureResponderEvent) => { + void EnhancedHaptics.buttonHaptic(); + scale.set( + withTiming(PRESSED_SCALE, { + duration: duration.instant, + easing: Easing.out(Easing.cubic), + }) + ); + }; - const onPressOut = useCallback( - (_event: GestureResponderEvent) => { - scale.set(withSpring(REST_SCALE, RETURN_SPRING)); - }, - [scale] - ); + const onPressOut = (_event: GestureResponderEvent) => { + scale.set(withSpring(REST_SCALE, RETURN_SPRING)); + }; return { animatedStyle, onPressIn, onPressOut }; } diff --git a/shared/ui/composed/Screen.tsx b/shared/ui/composed/Screen.tsx index deb2ad081..8b6be7cfb 100644 --- a/shared/ui/composed/Screen.tsx +++ b/shared/ui/composed/Screen.tsx @@ -18,7 +18,6 @@ import React, { ReactNode, useCallback, useContext, - useMemo, useRef, useState, useLayoutEffect, @@ -135,10 +134,7 @@ export function Screen({ setMeasuredFooterHeight(rounded); }, []); - const footerContextValue = useMemo( - () => ({ setFooterHeight: updateFooterHeight }), - [updateFooterHeight] - ); + const footerContextValue = { setFooterHeight: updateFooterHeight }; // Defer the content subtree so the modal present starts on a cheap frame. // The Log boundary + ModalLayoutWrapper background stay mounted immediately, diff --git a/shared/ui/composed/ScreenHeaderAction.tsx b/shared/ui/composed/ScreenHeaderAction.tsx index 5276d428b..c6378b143 100644 --- a/shared/ui/composed/ScreenHeaderAction.tsx +++ b/shared/ui/composed/ScreenHeaderAction.tsx @@ -63,14 +63,11 @@ export function ScreenHeaderAction({ ) : null); - const circleStyle = React.useMemo( - () => [ - styles.circle, - { backgroundColor: surfaceSecondary, borderColor: opacity(muted, 0.3) }, - { opacity: disabled ? 0.4 : 1 }, - ], - [disabled, muted, surfaceSecondary] - ); + const circleStyle = [ + styles.circle, + { backgroundColor: surfaceSecondary, borderColor: opacity(muted, 0.3) }, + { opacity: disabled ? 0.4 : 1 }, + ]; if (supportsLiquidGlass()) { return ( diff --git a/shared/ui/composed/SearchLayout.tsx b/shared/ui/composed/SearchLayout.tsx index 8fff60762..65bd9edd0 100644 --- a/shared/ui/composed/SearchLayout.tsx +++ b/shared/ui/composed/SearchLayout.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useCallback, useMemo, type ReactNode } from 'react'; +import { createContext, useContext, type ReactNode } from 'react'; import { useWindowDimensions, View as RNView } from 'react-native'; import { Stack } from 'expo-router'; import { DrawerActions, useNavigation } from '@react-navigation/native'; @@ -107,6 +107,8 @@ type SearchLayoutProps = { transparent?: boolean; }; +const headerRight = () => ; + export function SearchLayout({ title, placeholder, @@ -117,85 +119,50 @@ export function SearchLayout({ const navigation = useNavigation(); const search = useHeaderSearch(); - const openDrawer = useCallback( - () => navigation.dispatch(DrawerActions.openDrawer()), - [navigation] - ); + const openDrawer = () => navigation.dispatch(DrawerActions.openDrawer()); // Only render custom headerTitle when searching (shows GlassSearchBar). // When not searching, let React Navigation render the native title // so it picks up the correct tintColor / Liquid Glass styling. - const searchBarTitle = useCallback( - () => , - [placeholder] - ); - const headerRight = useCallback(() => , []); - const headerLeft = useCallback(() => , [openDrawer]); - - const screenOptions = useMemo( - () => - buildExpoRouterHeaderOptions({ - iconColor, - headerLeft, - headerRight, - options: { - title, - // Without this, the native bar inherits the locked dark - // `userInterfaceStyle` and renders the title white — invisible on - // the light theme's `surface` background. - headerTitleStyle: { color: iconColor }, - headerTintColor: iconColor, - ...(transparent - ? { headerTransparent: true, headerStyle: { backgroundColor: 'transparent' } } - : { headerStyle: { backgroundColor: surface } }), - // GlassSearchBar wins while searching; otherwise an optional custom - // idle title (Wallet's MintSelector), else the native `title`. - ...(search.isSearching - ? { headerTitle: searchBarTitle } - : renderIdleTitle - ? { - headerTitle: () => ( - {renderIdleTitle()} - ), - } - : {}), - }, - }), - [ - iconColor, - headerLeft, - headerRight, - surface, + const searchBarTitle = () => ; + const headerLeft = () => ; + + const screenOptions = buildExpoRouterHeaderOptions({ + iconColor, + headerLeft, + headerRight, + options: { title, - search.isSearching, - searchBarTitle, - renderIdleTitle, - transparent, - ] - ); - - const contextValue: SearchContextValue = useMemo( - () => ({ - isSearching: search.isSearching, - searchQuery: search.searchQuery, - clearKey: search.clearKey, - seedText: search.seedText, - onSearchChange: search.onSearchChange, - onOpenSearch: search.onOpenSearch, - onCloseSearch: search.onCloseSearch, - setQuery: search.setQuery, - }), - [ - search.isSearching, - search.searchQuery, - search.clearKey, - search.seedText, - search.onSearchChange, - search.onOpenSearch, - search.onCloseSearch, - search.setQuery, - ] - ); + // Without this, the native bar inherits the locked dark + // `userInterfaceStyle` and renders the title white — invisible on + // the light theme's `surface` background. + headerTitleStyle: { color: iconColor }, + headerTintColor: iconColor, + ...(transparent + ? { headerTransparent: true, headerStyle: { backgroundColor: 'transparent' } } + : { headerStyle: { backgroundColor: surface } }), + // GlassSearchBar wins while searching; otherwise an optional custom + // idle title (Wallet's MintSelector), else the native `title`. + ...(search.isSearching + ? { headerTitle: searchBarTitle } + : renderIdleTitle + ? { + headerTitle: () => {renderIdleTitle()}, + } + : {}), + }, + }); + + const contextValue: SearchContextValue = { + isSearching: search.isSearching, + searchQuery: search.searchQuery, + clearKey: search.clearKey, + seedText: search.seedText, + onSearchChange: search.onSearchChange, + onOpenSearch: search.onOpenSearch, + onCloseSearch: search.onCloseSearch, + setQuery: search.setQuery, + }; return ( diff --git a/shared/ui/composed/SkeletonExitShimmer.tsx b/shared/ui/composed/SkeletonExitShimmer.tsx index f37ada49f..65d409528 100644 --- a/shared/ui/composed/SkeletonExitShimmer.tsx +++ b/shared/ui/composed/SkeletonExitShimmer.tsx @@ -1,11 +1,4 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type PropsWithChildren, -} from 'react'; +import React, { useEffect, useMemo, useRef, useState, type PropsWithChildren } from 'react'; import { StyleSheet, View, type LayoutChangeEvent, useWindowDimensions } from 'react-native'; import Animated, { cancelAnimation, @@ -149,14 +142,11 @@ export function SkeletonExitReveal({ return () => cancelAnimation(progress); }, [active, progress]); - const handleLayout = useCallback( - (event: LayoutChangeEvent) => { - const w = Math.round(event.nativeEvent.layout.width); - setContainerWidth((prev) => (prev === w ? prev : w)); - visualLayout.onLayout(event); - }, - [visualLayout] - ); + const handleLayout = (event: LayoutChangeEvent) => { + const w = Math.round(event.nativeEvent.layout.width); + setContainerWidth((prev) => (prev === w ? prev : w)); + visualLayout.onLayout(event); + }; const sweepWidth = containerWidth > 0 ? containerWidth : screenWidth; @@ -177,10 +167,7 @@ export function SkeletonExitReveal({ return shimmerColors(highlightColor, opacity(foreground, 0.55)); }, [foreground, highlightColor]); - const shimmerBarStyle = useMemo( - () => [styles.shimmerBar, EXIT_SHIMMER_BAR_BASE, shimmerStyle], - [shimmerStyle] - ); + const shimmerBarStyle = [styles.shimmerBar, EXIT_SHIMMER_BAR_BASE, shimmerStyle]; return ( cancelAnimation(progress); }, [active, progress]); - const handleLayout = useCallback( - (event: LayoutChangeEvent) => { - const w = Math.round(event.nativeEvent.layout.width); - setContainerWidth((prev) => (prev === w ? prev : w)); - visualLayout.onLayout(event); - }, - [visualLayout] - ); + const handleLayout = (event: LayoutChangeEvent) => { + const w = Math.round(event.nativeEvent.layout.width); + setContainerWidth((prev) => (prev === w ? prev : w)); + visualLayout.onLayout(event); + }; const sweepWidth = containerWidth > 0 ? containerWidth : screenWidth; const highlightWidth = Math.max(40, Math.round(sweepWidth * LOADING_HIGHLIGHT_WIDTH_RATIO)); @@ -290,10 +274,7 @@ export function SkeletonLoadingShimmer({ }, [background, highlightColor]); const widthStyle = useMemo(() => ({ width: highlightWidth }), [highlightWidth]); - const shimmerBarStyle = useMemo( - () => [styles.shimmerBar, widthStyle, shimmerStyle], - [shimmerStyle, widthStyle] - ); + const shimmerBarStyle = [styles.shimmerBar, widthStyle, shimmerStyle]; if (!active) return ( diff --git a/shared/ui/composed/SlideToConfirm.tsx b/shared/ui/composed/SlideToConfirm.tsx index cd0a16fe2..c6dd24ef8 100644 --- a/shared/ui/composed/SlideToConfirm.tsx +++ b/shared/ui/composed/SlideToConfirm.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { useWindowDimensions } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { @@ -45,9 +45,9 @@ export const SlideToConfirm: React.FC = ({ const translateX = useSharedValue(0); const isComplete = useSharedValue(false); - const handleComplete = useCallback(() => { + const handleComplete = () => { onConfirm(); - }, [onConfirm]); + }; const panGesture = Gesture.Pan() .onUpdate((event) => { diff --git a/shared/ui/composed/SpriteView.tsx b/shared/ui/composed/SpriteView.tsx index 617a4244e..6a218b4e7 100644 --- a/shared/ui/composed/SpriteView.tsx +++ b/shared/ui/composed/SpriteView.tsx @@ -6,6 +6,7 @@ import { Image } from '@/shared/ui/primitives/Image'; import { backgroundImageThemes } from 'config/backgroundImageThemes'; import { useTheme } from '@/shared/providers/ThemeProvider'; import { Log, log } from '@/shared/lib/logger'; +import { describeImageLoadError } from '@/shared/lib/imageLoadError'; interface AnimatedSpriteBackgroundProps { backgroundColor: string; @@ -39,16 +40,6 @@ function describeImageSource(source: unknown): Record { return { sourceKind: typeof source }; } -function describeImageLoadError(event: unknown): string { - if (event && typeof event === 'object') { - const directError = (event as { error?: unknown }).error; - if (typeof directError === 'string') return directError; - const nativeEvent = (event as { nativeEvent?: { error?: unknown } }).nativeEvent; - if (typeof nativeEvent?.error === 'string') return nativeEvent.error; - } - return String(event ?? 'unknown'); -} - const AnimatedSpriteBackground = React.memo(function AnimatedSpriteBackground({ backgroundColor, themeName, diff --git a/shared/ui/composed/VisualLayoutProbe.tsx b/shared/ui/composed/VisualLayoutProbe.tsx index 92aa40b5d..b20ea0328 100644 --- a/shared/ui/composed/VisualLayoutProbe.tsx +++ b/shared/ui/composed/VisualLayoutProbe.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, type ReactNode } from 'react'; +import React, { useMemo, type ReactNode } from 'react'; import { StyleSheet, View, @@ -63,13 +63,10 @@ export function VisualLayoutProbe({ [extra, pointerEvents, style] ); const layout = useVisualLayoutLogger({ ...config, extra: visualExtra }); - const handleLayout = useCallback( - (event: LayoutChangeEvent) => { - onLayout?.(event); - layout.onLayout(event); - }, - [layout, onLayout] - ); + const handleLayout = (event: LayoutChangeEvent) => { + onLayout?.(event); + layout.onLayout(event); + }; return ( { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - function tokenLogFields(token: string): Record { return { tokenLength: token.length, diff --git a/shared/ui/composed/chat/ChatMessageBubble.tsx b/shared/ui/composed/chat/ChatMessageBubble.tsx index c5d2b3ffc..55c28fb8e 100644 --- a/shared/ui/composed/chat/ChatMessageBubble.tsx +++ b/shared/ui/composed/chat/ChatMessageBubble.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; +import { INVARIANT_WHITE } from '@/shared/lib/brandColors'; import { View } from 'react-native'; import { Text } from '@/shared/ui/primitives/Text'; import { VStack } from '@/shared/ui/primitives/View/VStack'; @@ -175,7 +176,7 @@ export function ChatMessageBubble({ {displayContent} diff --git a/shared/ui/composed/chat/ChatScreen.tsx b/shared/ui/composed/chat/ChatScreen.tsx index 058d070ac..020b415db 100644 --- a/shared/ui/composed/chat/ChatScreen.tsx +++ b/shared/ui/composed/chat/ChatScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { Keyboard, ScrollView, @@ -127,6 +127,8 @@ const MESSAGE_ROW_STYLE = { paddingHorizontal: 16 } as const; * Each consumer is responsible for mapping its native event into * `ChatBubbleMessage[]`; everything below that is shared. */ +const keyExtractor = (m: ChatBubbleMessage) => m.id; + export function ChatScreen({ surface, log, @@ -176,10 +178,10 @@ export function ChatScreen({ // slide *under* the composer's translucent glass on scroll-up (the // iMessage / Telegram bleed-under-input look). const [composerHeight, setComposerHeight] = useState(0); - const handleComposerLayout = useCallback((e: LayoutChangeEvent) => { + const handleComposerLayout = (e: LayoutChangeEvent) => { const next = e.nativeEvent.layout.height; setComposerHeight((prev) => (Math.abs(prev - next) > 0.5 ? next : prev)); - }, []); + }; // Keyboard avoidance split across two mechanisms: // @@ -258,7 +260,7 @@ export function ChatScreen({ } }); - const handleSubmit = useCallback(() => { + const handleSubmit = () => { const text = draft.trim(); if (!text) return; setDraft(''); @@ -266,49 +268,40 @@ export function ChatScreen({ // Errors already logged by dispatchSend; consumer's onSend is // expected to surface user-visible feedback (popups/banners). }); - }, [draft, dispatchSend]); - - const renderItem = useCallback( - ({ item }: { item: ChatBubbleMessage; index: number }) => { - const group = groupingMap.get(item.id); - const isFirstInGroup = group?.isFirst ?? true; - const isLastInGroup = group?.isLast ?? true; - return ( - - {renderBubble ? ( - renderBubble({ message: item, isFirstInGroup, isLastInGroup }) - ) : ( - - )} - - ); - }, - [counterpartyAvatar, groupingMap, renderBubble] - ); + }; - const keyExtractor = useCallback((m: ChatBubbleMessage) => m.id, []); + const renderItem = ({ item }: { item: ChatBubbleMessage; index: number }) => { + const group = groupingMap.get(item.id); + const isFirstInGroup = group?.isFirst ?? true; + const isLastInGroup = group?.isLast ?? true; + return ( + + {renderBubble ? ( + renderBubble({ message: item, isFirstInGroup, isLastInGroup }) + ) : ( + + )} + + ); + }; // Tap-to-dismiss-keyboard wrapper around the consumer-provided empty // placeholder. Mounted in place of the list when there are no messages; // the composer stays mounted on top, ready to accept the first send. - const wrappedEmptyContent = useMemo( - () => - emptyContent ? ( - - {emptyContent} - - ) : null, - [emptyContent] - ); + const wrappedEmptyContent = emptyContent ? ( + + {emptyContent} + + ) : null; // Pad bottom of the list so the newest bubble rests just above the // composer's top edge. No `paddingTop` here: adding one breaks @@ -319,12 +312,9 @@ export function ChatScreen({ // `resolvedTopInset` clearance against a transparent floating header is // already accounted for at the screen level by consumers that need it // (Screen primitive's `safeArea` / header inset handling). - const listContentContainerStyle = useMemo( - () => ({ - paddingBottom: composerHeight + resolvedBottomInset + 16, - }), - [composerHeight, resolvedBottomInset] - ); + const listContentContainerStyle = { + paddingBottom: composerHeight + resolvedBottomInset + 16, + }; return ( diff --git a/shared/ui/composed/chat/DmChatHeader.tsx b/shared/ui/composed/chat/DmChatHeader.tsx index c04306065..96ed44322 100644 --- a/shared/ui/composed/chat/DmChatHeader.tsx +++ b/shared/ui/composed/chat/DmChatHeader.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { useWindowDimensions } from 'react-native'; import { Stack } from 'expo-router'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; @@ -77,16 +77,12 @@ export function DmChatHeader({ const { metadata, isLoading } = useNostrProfileMetadata(pubkey); - const displayName = useMemo( - () => - resolveIdentityName({ - pubkey, - nostrProfile: metadata, - bleNickname: nickname, - overrideName: displayNameOverride, - }), - [displayNameOverride, metadata, nickname, pubkey] - ); + const displayName = resolveIdentityName({ + pubkey, + nostrProfile: metadata, + bleNickname: nickname, + overrideName: displayNameOverride, + }); const userPicture = metadata?.picture; const shouldShowAvatarLoading = !!pubkey && isLoading && !metadata; @@ -100,13 +96,13 @@ export function DmChatHeader({ } }, [pubkey]); - const handleShareQr = useCallback(() => { + const handleShareQr = () => { if (!npub) return; router.navigate({ pathname: '/share', params: { type: 'profile', data: npub }, }); - }, [npub]); + }; const trailingNode = trailing ?? diff --git a/shared/ui/composed/chat/LiquidChatComposer.tsx b/shared/ui/composed/chat/LiquidChatComposer.tsx index ee9ad13fe..139803eaf 100644 --- a/shared/ui/composed/chat/LiquidChatComposer.tsx +++ b/shared/ui/composed/chat/LiquidChatComposer.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useEffect, useId, useRef, useState } from 'react'; +import React, { useEffect, useId, useRef, useState } from 'react'; +import { INVARIANT_WHITE } from '@/shared/lib/brandColors'; import { Platform, TextInput, @@ -170,70 +171,63 @@ export function LiquidChatComposer({ setResetKey((k) => k + 1); } }, [value]); - const handleSwiftValueChange = useCallback( - (text: string) => { - lastSwiftValueRef.current = text; - onChangeText(text); - }, - [onChangeText] - ); + const handleSwiftValueChange = (text: string) => { + lastSwiftValueRef.current = text; + onChangeText(text); + }; // Imperative ref so taps on the capsule's padding edges (outside the // TextField's intrinsic content rect) can focus the field — see the // `onTapGesture(focusTextField)` on the bubble below. const textFieldRef = useRef(null); - const focusTextField = useCallback(() => { + const focusTextField = () => { void textFieldRef.current?.focus(); - }, []); + }; // Fallback-only state: the RN multiline `TextInput` reports its intrinsic // height via `onContentSizeChange`. We clamp to `MIN_ROW_HEIGHT` so the // bubble matches the buttons when empty / single-line, and cap at // `MAX_ROW_HEIGHT` for very long input. The SwiftUI path doesn't use this. const [contentHeight, setContentHeight] = useState(0); - const handleContentSizeChange = useCallback( - (e: NativeSyntheticEvent) => { - setContentHeight(Math.round(e.nativeEvent.contentSize.height)); - }, - [] - ); + const handleContentSizeChange = ( + e: NativeSyntheticEvent + ) => { + setContentHeight(Math.round(e.nativeEvent.contentSize.height)); + }; const fallbackRowHeight = Math.min( Math.max(contentHeight + INPUT_VPAD, MIN_ROW_HEIGHT), MAX_ROW_HEIGHT ); const lastLayoutRef = useRef<{ height: number; width: number } | null>(null); - const handleLayout = useCallback( - (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout; - const last = lastLayoutRef.current; - const changed = - !last || Math.abs(last.height - height) >= 0.5 || Math.abs(last.width - width) >= 0.5; - if (!changed) return; - lastLayoutRef.current = { height, width }; - chatLog.debug('chat.composer.layout', { - surface: surface ?? 'unknown', - width: Math.round(width), - height: Math.round(height), - hasText: trimmedHasText, - }); - }, - [surface, trimmedHasText] - ); + const handleLayout = (e: LayoutChangeEvent) => { + const { width, height } = e.nativeEvent.layout; + const last = lastLayoutRef.current; + const changed = + !last || Math.abs(last.height - height) >= 0.5 || Math.abs(last.width - width) >= 0.5; + if (!changed) return; + lastLayoutRef.current = { height, width }; + chatLog.debug('chat.composer.layout', { + surface: surface ?? 'unknown', + width: Math.round(width), + height: Math.round(height), + hasText: trimmedHasText, + }); + }; - const handleSendPress = useCallback(() => { + const handleSendPress = () => { chatLog.info('chat.composer.send_tap', { surface: surface ?? 'unknown', textLen: value.length, disabled, }); onSend(); - }, [surface, value.length, disabled, onSend]); + }; - const handlePlusPress = useCallback(() => { + const handlePlusPress = () => { chatLog.info('chat.composer.plus_tap', { surface: surface ?? 'unknown' }); onPlusPress?.(); - }, [surface, onPlusPress]); + }; if (useNativeGlass) { return ( @@ -286,7 +280,11 @@ export function LiquidChatComposer({ modifiers={[ frame({ maxWidth: Infinity, maxHeight: Infinity, alignment: 'center' }), ]}> - + @@ -391,7 +389,7 @@ export function LiquidChatComposer({ @@ -448,7 +446,7 @@ export function LiquidChatComposer({ overflow: 'hidden', backgroundColor: useBlur ? undefined : surfaceSecondary, }}> - + @@ -526,7 +524,7 @@ export function LiquidChatComposer({ diff --git a/shared/ui/composed/chat/useChatSurfacePerfLogger.ts b/shared/ui/composed/chat/useChatSurfacePerfLogger.ts index 8a29b3869..162bdf13f 100644 --- a/shared/ui/composed/chat/useChatSurfacePerfLogger.ts +++ b/shared/ui/composed/chat/useChatSurfacePerfLogger.ts @@ -73,61 +73,52 @@ export function useChatSurfacePerfLogger( }, [kbState.isVisible, kbState.height, headerHeight, log, surface]); const listLayoutRef = useRef<{ height: number; width: number } | null>(null); - const handleListLayout = useCallback( - (e: LayoutChangeEvent | unknown) => { - const { width, height } = (e as LayoutChangeEvent).nativeEvent.layout; - const last = listLayoutRef.current; - if (last && Math.abs(last.width - width) < 0.5 && Math.abs(last.height - height) < 0.5) { - return; - } - listLayoutRef.current = { width, height }; - log.info('chat.list.layout', { - surface, - width: Math.round(width), - height: Math.round(height), - }); - }, - [log, surface] - ); + const handleListLayout = (e: LayoutChangeEvent | unknown) => { + const { width, height } = (e as LayoutChangeEvent).nativeEvent.layout; + const last = listLayoutRef.current; + if (last && Math.abs(last.width - width) < 0.5 && Math.abs(last.height - height) < 0.5) { + return; + } + listLayoutRef.current = { width, height }; + log.info('chat.list.layout', { + surface, + width: Math.round(width), + height: Math.round(height), + }); + }; const listContentSizeRef = useRef<{ w: number; h: number } | null>(null); - const handleListContentSize = useCallback( - (w: number, h: number) => { - const last = listContentSizeRef.current; - if (last && Math.abs(last.w - w) < 0.5 && Math.abs(last.h - h) < 0.5) return; - const viewportH = listLayoutRef.current?.height ?? 0; - listContentSizeRef.current = { w, h }; - log.debug('chat.list.content_size', { - surface, - contentW: Math.round(w), - contentH: Math.round(h), - viewportH: Math.round(viewportH), - overflow: Math.round(h - viewportH), - msgsCount: messages.length, - }); - }, - [log, surface, messages.length] - ); + const handleListContentSize = (w: number, h: number) => { + const last = listContentSizeRef.current; + if (last && Math.abs(last.w - w) < 0.5 && Math.abs(last.h - h) < 0.5) return; + const viewportH = listLayoutRef.current?.height ?? 0; + listContentSizeRef.current = { w, h }; + log.debug('chat.list.content_size', { + surface, + contentW: Math.round(w), + contentH: Math.round(h), + viewportH: Math.round(viewportH), + overflow: Math.round(h - viewportH), + msgsCount: messages.length, + }); + }; const lastScrollLogRef = useRef(0); - const handleListScroll = useCallback( - (e: NativeSyntheticEvent | unknown) => { - const now = Date.now(); - if (now - lastScrollLogRef.current < 120) return; - lastScrollLogRef.current = now; - const { contentOffset, contentSize, layoutMeasurement } = ( - e as NativeSyntheticEvent - ).nativeEvent; - log.debug('chat.list.scroll', { - surface, - offsetY: Math.round(contentOffset.y), - contentH: Math.round(contentSize.height), - viewportH: Math.round(layoutMeasurement.height), - distFromEnd: Math.round(contentSize.height - (contentOffset.y + layoutMeasurement.height)), - }); - }, - [log, surface] - ); + const handleListScroll = (e: NativeSyntheticEvent | unknown) => { + const now = Date.now(); + if (now - lastScrollLogRef.current < 120) return; + lastScrollLogRef.current = now; + const { contentOffset, contentSize, layoutMeasurement } = ( + e as NativeSyntheticEvent + ).nativeEvent; + log.debug('chat.list.scroll', { + surface, + offsetY: Math.round(contentOffset.y), + contentH: Math.round(contentSize.height), + viewportH: Math.round(layoutMeasurement.height), + distFromEnd: Math.round(contentSize.height - (contentOffset.y + layoutMeasurement.height)), + }); + }; const prevMsgRef = useRef({ count: 0, lastId: '' }); useEffect(() => { @@ -234,7 +225,7 @@ export function useChatKeyboardAnimationLogger({ (curr, prev) => { 'worklet'; if (cycleActive.value === 0) return; - if (prev && curr.p === prev.p && curr.h === prev.h) return; + if (curr.p === prev?.p && curr.h === prev.h) return; progressTickCount.value += 1; if (curr.p < progressMin.value) progressMin.value = curr.p; if (curr.p > progressMax.value) progressMax.value = curr.p; diff --git a/shared/ui/composed/search/SearchOverlay.tsx b/shared/ui/composed/search/SearchOverlay.tsx index 5f63a22ce..f051e38db 100644 --- a/shared/ui/composed/search/SearchOverlay.tsx +++ b/shared/ui/composed/search/SearchOverlay.tsx @@ -5,7 +5,7 @@ * Wallet, Feed, or Contacts surface mounted while search is active, so closing * search returns to the exact same tab/list state. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; @@ -33,10 +33,7 @@ export function SearchOverlay({ }: SearchOverlayProps) { const { isSearching } = useSearchContext(); const surface = useThemeColor('surface'); - const overlayStyle = useMemo>( - () => [styles.overlay, { backgroundColor: surface, paddingTop: topInset }, style], - [surface, style, topInset] - ); + const overlayStyle = [styles.overlay, { backgroundColor: surface, paddingTop: topInset }, style]; if (!isSearching) return null; diff --git a/shared/ui/composed/search/SearchResultRows.tsx b/shared/ui/composed/search/SearchResultRows.tsx index 6267d8bd6..784dc417b 100644 --- a/shared/ui/composed/search/SearchResultRows.tsx +++ b/shared/ui/composed/search/SearchResultRows.tsx @@ -7,7 +7,7 @@ * the unified search surface can feed it different buckets (All, People, Groups, * Mints) from one `useSearchAggregates` call without duplicating queries. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { View, StyleSheet } from 'react-native'; import { List } from '@/shared/ui/composed/List'; @@ -28,6 +28,7 @@ import { paymentLog, cashuLog } from '@/shared/lib/logger'; import { NoResultsFound } from '@/features/payments/components/NoResultsFound'; import { CONTACT_SEARCH_MIN_LENGTH } from '@/features/payments/hooks/useContactSearch'; import type { TierEntry } from '@/features/bitchat/hooks/useLocationTiers'; +import { mintUrlLogFields } from '@/shared/lib/mintUrlLog'; type SearchResultRowsProps = { results: AllSearchResult[]; @@ -43,13 +44,6 @@ function searchResultItemType(item: AllSearchResult): string { return item.type === 'contact' && item.isLoadingProfile ? 'contact-loading' : item.type; } -function mintUrlLogFields(mintUrl: string | null | undefined): Record { - return { - hasMintUrl: !!mintUrl, - mintUrlLength: mintUrl?.length ?? 0, - }; -} - function GeohashJumpRow({ geohash }: { geohash: string }) { return ( ['m ); } +const renderItem = ({ item }: { item: AllSearchResult }) => renderSearchResult(item); + +const renderSearchResult = (item: AllSearchResult) => { + switch (item.type) { + case 'geohash': + return ; + case 'tier': + return ; + case 'mint': + return ; + case 'contact': + return ( + } + onPress={() => navigateToProfile(item.pubkey)} + testID={`contact-row:nostr:${item.pubkey}`} + /> + ); + } +}; + export function SearchResultRows({ results, loading, @@ -134,57 +152,26 @@ export function SearchResultRows({ return results.length === 0; }, [results.length, loading, searchQuery]); - const renderSearchResult = useCallback((item: AllSearchResult) => { - switch (item.type) { - case 'geohash': - return ; - case 'tier': - return ; - case 'mint': - return ; - case 'contact': - return ( - } - onPress={() => navigateToProfile(item.pubkey)} - testID={`contact-row:nostr:${item.pubkey}`} - /> - ); - } - }, []); - - const renderEmpty = useCallback(() => { + const renderEmpty = () => { if (showNoResults) return ; return null; - }, [showNoResults, ListEmptyComponent]); + }; // While the search is in flight and we have nothing yet, render skeleton // placeholder rows so the feed doesn't look empty. `ContactRow` treats // `isLoadingProfile: true` as the skeleton trigger, so we reuse the regular // render path instead of a parallel loader component. const showPlaceholders = loading && results.length === 0 && searchQuery.trim().length >= 2; - const placeholderData = useMemo( - () => - Array.from({ length: 4 }, (_, i) => ({ - type: 'contact' as const, - id: `placeholder-${i}`, - pubkey: `placeholder-${i}`, - profile: undefined, - isLoadingProfile: true, - score: 0, - })), - [] - ); + const placeholderData = Array.from({ length: 4 }, (_, i) => ({ + type: 'contact' as const, + id: `placeholder-${i}`, + pubkey: `placeholder-${i}`, + profile: undefined, + isLoadingProfile: true, + score: 0, + })); const listData = showPlaceholders ? placeholderData : showNoResults ? [] : results; - const renderItem = useCallback( - ({ item }: { item: AllSearchResult }) => renderSearchResult(item), - [renderSearchResult] - ); - return ( computeVisibleScopes(trimmed, counts), [trimmed, counts]); + const visibleScopes = computeVisibleScopes(trimmed, counts); // Single-selection invariant: if the active scope's tab disappears, fall back // to All. This is the only reset path — there is no second selection axis. diff --git a/shared/ui/composed/search/useRecentSearches.ts b/shared/ui/composed/search/useRecentSearches.ts index a2af96356..ae3b74c50 100644 --- a/shared/ui/composed/search/useRecentSearches.ts +++ b/shared/ui/composed/search/useRecentSearches.ts @@ -23,10 +23,7 @@ export function useRecentSearches(surface: RecentSearchSurface) { const clearSearchHistory = useSearchHistoryStore((s) => s.clearSearchHistory); const addQuery = useCallback((q: string) => addSearch(q, surface), [addSearch, surface]); - const clearQueries = useCallback( - () => clearSearchHistory(surface), - [clearSearchHistory, surface] - ); + const clearQueries = () => clearSearchHistory(surface); return { queries, addQuery, clearQueries }; } diff --git a/shared/ui/composed/search/useSearchAggregates.ts b/shared/ui/composed/search/useSearchAggregates.ts index dca6dd22f..71daa264b 100644 --- a/shared/ui/composed/search/useSearchAggregates.ts +++ b/shared/ui/composed/search/useSearchAggregates.ts @@ -57,16 +57,12 @@ export function useSearchAggregates(query: string): SearchAggregates { enabled: mintEnabled, }); - const mints = useMemo( - () => - mintResults.map((mint, i) => ({ - type: 'mint' as const, - id: `mint:${mint.url}`, - mint, - score: SCORE_MINT_BASE - i, - })), - [mintResults] - ); + const mints = mintResults.map((mint, i) => ({ + type: 'mint' as const, + id: `mint:${mint.url}`, + mint, + score: SCORE_MINT_BASE - i, + })); const all = useMemo(() => { const combined = [...results, ...mints]; @@ -74,27 +70,26 @@ export function useSearchAggregates(query: string): SearchAggregates { return combined; }, [results, mints]); - return useMemo( - () => ({ - query: trimmed, - all, - people, - groups, - mints, - postsAuthors, - counts: { - // People/Posts gate on *real* matched authors (not the placeholder - // skeleton rows present mid-load), so their tabs don't flicker in and - // back out while a search is in flight. - people: postsAuthors.length, - groups: groups.length, - mints: mints.length, - posts: postsAuthors.length, - }, - loading: loading || (mintEnabled && mintLoading), - peopleLoading: loading, - mintsLoading: mintEnabled && mintLoading, - }), - [trimmed, all, people, groups, mints, postsAuthors, loading, mintEnabled, mintLoading] - ); + return { + query: trimmed, + all, + people, + groups, + mints, + postsAuthors, + + counts: { + // People/Posts gate on *real* matched authors (not the placeholder + // skeleton rows present mid-load), so their tabs don't flicker in and + // back out while a search is in flight. + people: postsAuthors.length, + groups: groups.length, + mints: mints.length, + posts: postsAuthors.length, + }, + + loading: loading || (mintEnabled && mintLoading), + peopleLoading: loading, + mintsLoading: mintEnabled && mintLoading, + }; } diff --git a/shared/ui/primitives/Avatar.tsx b/shared/ui/primitives/Avatar.tsx index 9b94468dc..cd528f14f 100644 --- a/shared/ui/primitives/Avatar.tsx +++ b/shared/ui/primitives/Avatar.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { StyleSheet, View, type LayoutChangeEvent } from 'react-native'; import BoringAvatar from '@mealection/react-native-boring-avatars'; import { Image as ExpoImage } from 'expo-image'; @@ -61,7 +61,7 @@ function GradientFallbackContent({ fallbackSeed: string; borderRadius: number; }) { - const gradientTheme = useMemo(() => generateSeededGradient(fallbackSeed), [fallbackSeed]); + const gradientTheme = generateSeededGradient(fallbackSeed); return ( opacity(foreground, 0.07), [foreground]); + const loadingColor = opacity(foreground, 0.07); useEffect(() => { void prefetchImage(picture); @@ -211,11 +211,11 @@ export const Avatar = ({ setImageStatus('loading'); }, [picture]); - const handleImageLoad = useCallback(() => { + const handleImageLoad = () => { if (picture) setLoadedPicture(picture); setImageStatus('loaded'); - }, [picture]); - const handleImageError = useCallback(() => setImageStatus('failed'), []); + }; + const handleImageError = () => setImageStatus('failed'); const borderRadius = size / 2; const statusIconSize = size * 0.33; @@ -223,19 +223,13 @@ export const Avatar = ({ () => ({ width: size, height: size, borderRadius, overflow: 'hidden' as const }), [borderRadius, size] ); - const containerStyle = useMemo( - () => ({ - ...avatarStyle, - justifyContent: 'center' as const, - alignItems: 'center' as const, - }), - [avatarStyle] - ); + const containerStyle = { + ...avatarStyle, + justifyContent: 'center' as const, + alignItems: 'center' as const, + }; - const fallbackSeed = useMemo( - () => sanitizeAvatarFallbackSeed(seed ?? name ?? alt ?? 'avatar'), - [alt, name, seed] - ); + const fallbackSeed = sanitizeAvatarFallbackSeed(seed ?? name ?? alt ?? 'avatar'); const statusBadge = useMemo(() => { if (!status) return null; @@ -270,15 +264,9 @@ export const Avatar = ({ const defaultAlt = 'Avatar'; const imageAlt = alt || defaultAlt; const previousPicture = loadedPicture && loadedPicture !== picture ? loadedPicture : null; - const pictureSource = useMemo(() => ({ uri: picture }), [picture]); - const previousPictureSource = useMemo( - () => (previousPicture ? { uri: previousPicture } : null), - [previousPicture] - ); - const overlayImageStyle = useMemo( - () => [StyleSheet.absoluteFillObject, avatarStyle], - [avatarStyle] - ); + const pictureSource = { uri: picture }; + const previousPictureSource = previousPicture ? { uri: previousPicture } : null; + const overlayImageStyle = [StyleSheet.absoluteFillObject, avatarStyle]; const showsLoadingPlaceholder = state === 'loading' || (state === 'image' && !!picture && imageStatus !== 'loaded' && !previousPicture); @@ -304,12 +292,9 @@ export const Avatar = ({ ...(typeof visualExtra === 'function' ? visualExtra() : (visualExtra ?? {})), }), }); - const handleVisualLayout = useCallback( - (event: LayoutChangeEvent) => { - visualLayout.onLayout(event); - }, - [visualLayout] - ); + const handleVisualLayout = (event: LayoutChangeEvent) => { + visualLayout.onLayout(event); + }; // 1. Loading state — 50% foreground fill, no image, no gradient. if (state === 'loading') { diff --git a/shared/ui/primitives/Button.tsx b/shared/ui/primitives/Button.tsx index 8d5c8424d..073b4cef6 100644 --- a/shared/ui/primitives/Button.tsx +++ b/shared/ui/primitives/Button.tsx @@ -57,7 +57,7 @@ * @see {@link ./Text} */ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { StyleProp, ViewStyle, @@ -153,60 +153,51 @@ const useRipple = ({ enabled, config }: UseRippleOptions) => { centered = true, } = config; - const handleLayout = useCallback( - (e: LayoutChangeEvent) => { - if (!enabled) return; - const { width, height } = e.nativeEvent.layout; - setButtonSize({ width, height }); - setRippleSize(Math.max(width, height) * 2); - }, - [enabled] - ); + const handleLayout = (e: LayoutChangeEvent) => { + if (!enabled) return; + const { width, height } = e.nativeEvent.layout; + setButtonSize({ width, height }); + setRippleSize(Math.max(width, height) * 2); + }; - const handlePressIn = useCallback( - (event: GestureResponderEvent) => { - if (!enabled) return; - - // Calculate ripple position - if (!centered && event.nativeEvent) { - const { locationX, locationY } = event.nativeEvent; - setRipplePosition({ x: locationX, y: locationY }); - } else { - setRipplePosition({ x: buttonSize.width / 2, y: buttonSize.height / 2 }); - } - - rippleScale.setValue(0); - rippleOpacity.setValue(opacity); - Animated.parallel([ - Animated.timing(rippleScale, { - toValue: 1, - duration, - useNativeDriver: true, - }), - Animated.timing(rippleOpacity, { - toValue: 0, - duration, - useNativeDriver: true, - }), - ]).start(); - }, - [enabled, centered, buttonSize, rippleScale, rippleOpacity, opacity, duration] - ); + const handlePressIn = (event: GestureResponderEvent) => { + if (!enabled) return; - const getRippleStyle = useCallback( - () => ({ - position: 'absolute' as const, - top: ripplePosition.y - rippleSize / 2, - left: ripplePosition.x - rippleSize / 2, - width: rippleSize, - height: rippleSize, - borderRadius: rippleSize / 2, - backgroundColor: color, - transform: [{ scale: rippleScale }], - opacity: rippleOpacity, - }), - [ripplePosition, rippleSize, color, rippleScale, rippleOpacity] - ); + // Calculate ripple position + if (!centered && event.nativeEvent) { + const { locationX, locationY } = event.nativeEvent; + setRipplePosition({ x: locationX, y: locationY }); + } else { + setRipplePosition({ x: buttonSize.width / 2, y: buttonSize.height / 2 }); + } + + rippleScale.setValue(0); + rippleOpacity.setValue(opacity); + Animated.parallel([ + Animated.timing(rippleScale, { + toValue: 1, + duration, + useNativeDriver: true, + }), + Animated.timing(rippleOpacity, { + toValue: 0, + duration, + useNativeDriver: true, + }), + ]).start(); + }; + + const getRippleStyle = () => ({ + position: 'absolute' as const, + top: ripplePosition.y - rippleSize / 2, + left: ripplePosition.x - rippleSize / 2, + width: rippleSize, + height: rippleSize, + borderRadius: rippleSize / 2, + backgroundColor: color, + transform: [{ scale: rippleScale }], + opacity: rippleOpacity, + }); return { handleLayout, diff --git a/shared/ui/primitives/Pressable.tsx b/shared/ui/primitives/Pressable.tsx index 4376171bd..2548687ed 100644 --- a/shared/ui/primitives/Pressable.tsx +++ b/shared/ui/primitives/Pressable.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useCallback } from 'react'; +import React, { forwardRef } from 'react'; import { Pressable as RNPressable, type PressableProps as RNPressableProps, @@ -79,26 +79,16 @@ export const Pressable = forwardRef(function Pressab }; const shouldFireHaptics = haptics !== false; - const triggerHaptic = useCallback( - async (trigger: 'start' | 'end') => { - if (!shouldFireHaptics) return; - if (trigger === 'start' && !hapticConfig.onPressStart) return; - if (trigger === 'end' && !hapticConfig.onPressEnd) return; - try { - await fireHaptic(hapticConfig); - } catch (error) { - log.warn('ui.haptics.not_supported', { type: 'pressable', error }); - } - }, - [ - shouldFireHaptics, - hapticConfig.onPressStart, - hapticConfig.onPressEnd, - hapticConfig.type, - hapticConfig.impactStyle, - hapticConfig.notificationType, - ] - ); + const triggerHaptic = async (trigger: 'start' | 'end') => { + if (!shouldFireHaptics) return; + if (trigger === 'start' && !hapticConfig.onPressStart) return; + if (trigger === 'end' && !hapticConfig.onPressEnd) return; + try { + await fireHaptic(hapticConfig); + } catch (error) { + log.warn('ui.haptics.not_supported', { type: 'pressable', error }); + } + }; const guardedOnPress = useSingleFlight(async (e: GestureResponderEvent) => { if (!onPress) return; @@ -110,13 +100,10 @@ export const Pressable = forwardRef(function Pressab if (result instanceof Promise) await result; }); - const handlePressIn = useCallback( - async (e: GestureResponderEvent) => { - await triggerHaptic('start'); - onPressIn?.(e); - }, - [triggerHaptic, onPressIn] - ); + const handlePressIn = async (e: GestureResponderEvent) => { + await triggerHaptic('start'); + onPressIn?.(e); + }; // Compose the user's style with default opacity-on-press feedback so // callers don't have to thread `({pressed})` themselves. `activeOpacity` diff --git a/shared/ui/primitives/SelectableCheck/SelectableCheck.circle.tsx b/shared/ui/primitives/SelectableCheck/SelectableCheck.circle.tsx index b719e4ff8..e9eaa2491 100644 --- a/shared/ui/primitives/SelectableCheck/SelectableCheck.circle.tsx +++ b/shared/ui/primitives/SelectableCheck/SelectableCheck.circle.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { INVARIANT_WHITE } from '@/shared/lib/brandColors'; import opacity from 'hex-color-opacity'; @@ -41,7 +42,9 @@ export function SelectableCheckCircle({ justifyContent: 'center', opacity: disabled ? 0.5 : 1, }}> - {selected ? : null} + {selected ? ( + + ) : null} ); diff --git a/shared/ui/primitives/SelectableCheck/index.tsx b/shared/ui/primitives/SelectableCheck/index.tsx index ddaa2e978..8ed0b2c22 100644 --- a/shared/ui/primitives/SelectableCheck/index.tsx +++ b/shared/ui/primitives/SelectableCheck/index.tsx @@ -4,8 +4,6 @@ import { SelectableCheckCircle } from './SelectableCheck.circle'; import { SelectableCheckSquare } from './SelectableCheck.square'; import type { SelectableCheckProps } from './types'; -export type { SelectableCheckProps, SelectableCheckStyle, SelectableCheckVariant } from './types'; - /** * Selection mark for "is this option selected?" UI. Two styles: * diff --git a/shared/ui/primitives/Skeleton.tsx b/shared/ui/primitives/Skeleton.tsx index 77a3c50db..c51501ea8 100644 --- a/shared/ui/primitives/Skeleton.tsx +++ b/shared/ui/primitives/Skeleton.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef } from 'react'; +import React, { useRef } from 'react'; import { View, type LayoutChangeEvent } from 'react-native'; import { @@ -53,24 +53,18 @@ const Skeleton = React.forwardRef(function Skeleton( extra: visualExtra, }); - const setRef = useCallback( - (node: View | null) => { - layout.ref(node); - if (typeof forwardedRef === 'function') { - forwardedRef(node); - } else if (forwardedRef) { - forwardedRef.current = node; - } - }, - [forwardedRef, layout] - ); - const handleLayout = useCallback( - (event: LayoutChangeEvent) => { - onLayout?.(event); - layout.onLayout(event); - }, - [layout, onLayout] - ); + const setRef = (node: View | null) => { + layout.ref(node); + if (typeof forwardedRef === 'function') { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }; + const handleLayout = (event: LayoutChangeEvent) => { + onLayout?.(event); + layout.onLayout(event); + }; return ( { - layout.onLayout(event); - }, - [layout] - ); + const handleLayout = (event: LayoutChangeEvent) => { + layout.onLayout(event); + }; return ( { - layout.onLayout(event); - }, - [layout] - ); + const handleLayout = (event: LayoutChangeEvent) => { + layout.onLayout(event); + }; const loadingBarStyle = React.useMemo>( () => [loadingInsetStyle, { borderRadius: 4, backgroundColor: loadingColor }], [loadingColor] ); - const hiddenTextCompositeStyle = React.useMemo( - () => [textProps.style, hiddenTextStyle], - [textProps.style] - ); + const hiddenTextCompositeStyle = [textProps.style, hiddenTextStyle]; return ( ((props, ref) => { +const HStack = React.forwardRef, HStackProps>((props, ref) => { const { spacing, gap, diff --git a/shared/ui/primitives/View/VStack.tsx b/shared/ui/primitives/View/VStack.tsx index 96328f07a..ad9442460 100644 --- a/shared/ui/primitives/View/VStack.tsx +++ b/shared/ui/primitives/View/VStack.tsx @@ -16,7 +16,7 @@ type VStackProps = ViewProps & { flexBasis?: DimensionValue; }; -const VStack = React.forwardRef((props, ref) => { +const VStack = React.forwardRef, VStackProps>((props, ref) => { const { spacing, gap, diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index bc26b9141..000000000 --- a/tailwind.config.js +++ /dev/null @@ -1,28 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ['./App.{js,ts,tsx}', './components/**/*.{js,ts,tsx}', './app/**/*.{js,ts,tsx}'], - - theme: { - extend: { - colors: { - dominant: { - 100: 'var(--color-dominant-100)', - 200: 'var(--color-dominant-200)', - 300: 'var(--color-dominant-300)', - 400: 'var(--color-dominant-400)', - 500: 'var(--color-dominant-500)', - }, - gradient: { - 100: 'var(--color-gradient-100)', - 200: 'var(--color-gradient-200)', - 300: 'var(--color-gradient-300)', - }, - }, - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)', - }, - }, - }, -}; diff --git a/tsconfig.json b/tsconfig.json index 7c7df21fa..0b13842f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,7 +34,7 @@ "neverthrow/*": ["./node_modules/neverthrow/*"] } }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"], + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"], "exclude": [ "sovran.money/**", "coco/**",