@oxyhq/services 5.16.25 → 5.16.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,8 @@ import { translate } from '../../i18n';
34
34
  import { updateAvatarVisibility, updateProfileWithAvatar } from '../utils/avatarUtils';
35
35
  import { useAccountStore } from '../stores/accountStore';
36
36
  import { logger as loggerUtil } from '../../utils/loggerUtils';
37
+ import { useTransferStore, useTransferCodesForPersistence } from '../stores/transferStore';
38
+ import { useCheckPendingTransfers } from '../hooks/useTransferQueries';
37
39
 
38
40
  export interface OxyContextState {
39
41
  user: User | null;
@@ -59,9 +61,12 @@ export interface OxyContextState {
59
61
  isIdentitySynced: () => Promise<boolean>;
60
62
  syncIdentity: () => Promise<User>;
61
63
  deleteIdentityAndClearAccount: (skipBackup?: boolean, force?: boolean, userConfirmed?: boolean) => Promise<void>;
62
- storeTransferCode: (transferId: string, code: string, sourceDeviceId: string | null, publicKey: string) => void;
63
- getTransferCode: (transferId: string) => { code: string; sourceDeviceId: string | null; publicKey: string; timestamp: number } | null;
64
- clearTransferCode: (transferId: string) => void;
64
+ storeTransferCode: (transferId: string, code: string, sourceDeviceId: string | null, publicKey: string) => Promise<void>;
65
+ getTransferCode: (transferId: string) => { code: string; sourceDeviceId: string | null; publicKey: string; timestamp: number; state: 'pending' | 'completed' | 'failed' } | null;
66
+ clearTransferCode: (transferId: string) => Promise<void>;
67
+ getAllPendingTransfers: () => Array<{ transferId: string; data: { code: string; sourceDeviceId: string | null; publicKey: string; timestamp: number; state: 'pending' | 'completed' | 'failed' } }>;
68
+ getActiveTransferId: () => string | null;
69
+ updateTransferState: (transferId: string, state: 'pending' | 'completed' | 'failed') => Promise<void>;
65
70
 
66
71
  // Identity sync state (reactive, from Zustand store)
67
72
  identitySyncState: {
@@ -384,6 +389,23 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
384
389
  oxyServices.clearCache();
385
390
  }, [queryClient, storage, clearSessionState, logger, oxyServices]);
386
391
 
392
+ // Extract Zustand store functions early (before they're used in callbacks)
393
+ const getAllPendingTransfersStore = useTransferStore((state) => state.getAllPendingTransfers);
394
+ const getActiveTransferIdStore = useTransferStore((state) => state.getActiveTransferId);
395
+ const storeTransferCodeStore = useTransferStore((state) => state.storeTransferCode);
396
+ const getTransferCodeStore = useTransferStore((state) => state.getTransferCode);
397
+ const updateTransferStateStore = useTransferStore((state) => state.updateTransferState);
398
+ const clearTransferCodeStore = useTransferStore((state) => state.clearTransferCode);
399
+
400
+ // Transfer code management functions (must be defined before deleteIdentityAndClearAccount)
401
+ const getAllPendingTransfers = useCallback(() => {
402
+ return getAllPendingTransfersStore();
403
+ }, [getAllPendingTransfersStore]);
404
+
405
+ const getActiveTransferId = useCallback(() => {
406
+ return getActiveTransferIdStore();
407
+ }, [getActiveTransferIdStore]);
408
+
387
409
  // Delete identity and clear all account data
388
410
  // In accounts app, deleting identity means losing the account completely
389
411
  const deleteIdentityAndClearAccount = useCallback(async (
@@ -391,12 +413,30 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
391
413
  force: boolean = false,
392
414
  userConfirmed: boolean = false
393
415
  ): Promise<void> => {
416
+ // CRITICAL: Check for active transfers before deletion (unless force is true)
417
+ // This prevents accidental identity loss during transfer
418
+ if (!force) {
419
+ const pendingTransfers = getAllPendingTransfers();
420
+ if (pendingTransfers.length > 0) {
421
+ const activeTransferId = getActiveTransferId();
422
+ const hasActiveTransfer = activeTransferId && pendingTransfers.some((t: { transferId: string; data: any }) => t.transferId === activeTransferId);
423
+
424
+ if (hasActiveTransfer) {
425
+ throw new Error(
426
+ 'Cannot delete identity: An active identity transfer is in progress. ' +
427
+ 'Please wait for the transfer to complete or cancel it first. ' +
428
+ 'If you proceed, you may lose access to your identity permanently.'
429
+ );
430
+ }
431
+ }
432
+ }
433
+
394
434
  // First, clear all account data
395
435
  await clearAllAccountData();
396
436
 
397
437
  // Then delete the identity keys
398
438
  await KeyManager.deleteIdentity(skipBackup, force, userConfirmed);
399
- }, [clearAllAccountData]);
439
+ }, [clearAllAccountData, getAllPendingTransfers, getActiveTransferId]);
400
440
 
401
441
  // Network reconnect sync - TanStack Query automatically retries mutations on reconnect
402
442
  // We only need to sync identity if it's not synced
@@ -474,6 +514,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
474
514
  loggerUtil.debug('Identity sync timeout (expected when offline)', { component: 'OxyContext', method: 'checkNetworkAndSync' }, syncError as unknown);
475
515
  }
476
516
  }
517
+
518
+ // Check for pending transfers that may have completed while offline
519
+ // This is handled by useCheckPendingTransfers hook which runs automatically
520
+ // when authenticated and online
477
521
  }
478
522
 
479
523
  // TanStack Query will automatically retry pending mutations
@@ -515,7 +559,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
515
559
  clearTimeout(checkTimeout);
516
560
  }
517
561
  };
518
- }, [oxyServices, storage, syncIdentity, logger]);
562
+ }, [oxyServices, storage, syncIdentity, logger, hasIdentity, isAuthenticated]);
519
563
 
520
564
  const { getDeviceSessions, logoutAllDeviceSessions, updateDeviceName } = useDeviceManagement({
521
565
  oxyServices,
@@ -634,51 +678,110 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
634
678
  // The JWT token's userId field contains the MongoDB ObjectId
635
679
  const userId = oxyServices.getCurrentUserId() || user?.id;
636
680
 
637
- // Store transfer codes in memory for verification
638
- // Map: transferId -> { code, sourceDeviceId, publicKey, timestamp }
639
- const transferCodesRef = useRef<Map<string, { code: string; sourceDeviceId: string | null; publicKey: string; timestamp: number }>>(new Map());
681
+ // Use Zustand store for transfer state management
682
+ const TRANSFER_CODES_STORAGE_KEY = `${storageKeyPrefix}_transfer_codes`;
683
+ const ACTIVE_TRANSFER_STORAGE_KEY = `${storageKeyPrefix}_active_transfer_id`;
684
+ const isRestored = useTransferStore((state) => state.isRestored);
685
+ const restoreFromStorage = useTransferStore((state) => state.restoreFromStorage);
686
+ const markRestored = useTransferStore((state) => state.markRestored);
687
+ const cleanupExpired = useTransferStore((state) => state.cleanupExpired);
640
688
 
641
- // Cleanup old transfer codes (older than 15 minutes)
689
+ // Load transfer codes from storage on startup (only once)
642
690
  useEffect(() => {
643
- const cleanup = setInterval(() => {
644
- const now = Date.now();
645
- const fifteenMinutes = 15 * 60 * 1000;
646
- transferCodesRef.current.forEach((value, key) => {
647
- if (now - value.timestamp > fifteenMinutes) {
648
- transferCodesRef.current.delete(key);
649
- if (__DEV__) {
650
- logger('Cleaned up expired transfer code', { transferId: key });
651
- }
691
+ if (!storage || !isStorageReady || isRestored) return;
692
+
693
+ const loadTransferCodes = async () => {
694
+ try {
695
+ // Load transfer codes
696
+ const storedCodes = await storage.getItem(TRANSFER_CODES_STORAGE_KEY);
697
+ const storedActiveTransferId = await storage.getItem(ACTIVE_TRANSFER_STORAGE_KEY);
698
+
699
+ const parsedCodes = storedCodes ? JSON.parse(storedCodes) : {};
700
+ const activeTransferId = storedActiveTransferId || null;
701
+
702
+ // Restore to Zustand store (store handles validation and expiration)
703
+ restoreFromStorage(parsedCodes, activeTransferId);
704
+ markRestored();
705
+
706
+ if (__DEV__ && Object.keys(parsedCodes).length > 0) {
707
+ logger('Restored transfer codes from storage', {
708
+ count: Object.keys(parsedCodes).length,
709
+ hasActiveTransfer: !!activeTransferId,
710
+ });
652
711
  }
653
- });
712
+ } catch (error) {
713
+ if (__DEV__) {
714
+ logger('Failed to load transfer codes from storage', error);
715
+ }
716
+ // Mark as restored even on error to prevent retries
717
+ markRestored();
718
+ }
719
+ };
720
+
721
+ loadTransferCodes();
722
+ }, [storage, isStorageReady, isRestored, restoreFromStorage, markRestored, logger, storageKeyPrefix]);
723
+
724
+ // Persist transfer codes to storage whenever store changes
725
+ const { transferCodes, activeTransferId } = useTransferCodesForPersistence();
726
+ useEffect(() => {
727
+ if (!storage || !isStorageReady || !isRestored) return;
728
+
729
+ const persistTransferCodes = async () => {
730
+ try {
731
+ await storage.setItem(TRANSFER_CODES_STORAGE_KEY, JSON.stringify(transferCodes));
732
+
733
+ if (activeTransferId) {
734
+ await storage.setItem(ACTIVE_TRANSFER_STORAGE_KEY, activeTransferId);
735
+ } else {
736
+ await storage.removeItem(ACTIVE_TRANSFER_STORAGE_KEY);
737
+ }
738
+ } catch (error) {
739
+ if (__DEV__) {
740
+ logger('Failed to persist transfer codes', error);
741
+ }
742
+ }
743
+ };
744
+
745
+ persistTransferCodes();
746
+ }, [transferCodes, activeTransferId, storage, isStorageReady, isRestored, logger]);
747
+
748
+ // Cleanup expired transfer codes (every minute)
749
+ useEffect(() => {
750
+ const cleanup = setInterval(() => {
751
+ cleanupExpired();
654
752
  }, 60000); // Check every minute
655
753
 
656
754
  return () => clearInterval(cleanup);
657
- }, [logger]);
658
-
659
- // Transfer code management functions
660
- const storeTransferCode = useCallback((transferId: string, code: string, sourceDeviceId: string | null, publicKey: string) => {
661
- transferCodesRef.current.set(transferId, {
662
- code,
663
- sourceDeviceId,
664
- publicKey,
665
- timestamp: Date.now(),
666
- });
755
+ }, [cleanupExpired]);
756
+
757
+ // Transfer code management functions using Zustand store
758
+ const storeTransferCode = useCallback(async (transferId: string, code: string, sourceDeviceId: string | null, publicKey: string) => {
759
+ storeTransferCodeStore(transferId, code, sourceDeviceId, publicKey);
760
+
667
761
  if (__DEV__) {
668
762
  logger('Stored transfer code', { transferId, sourceDeviceId, publicKey: publicKey.substring(0, 16) + '...' });
669
763
  }
670
- }, [logger]);
764
+ }, [logger, storeTransferCodeStore]);
671
765
 
672
766
  const getTransferCode = useCallback((transferId: string) => {
673
- return transferCodesRef.current.get(transferId) || null;
674
- }, []);
767
+ return getTransferCodeStore(transferId);
768
+ }, [getTransferCodeStore]);
769
+
770
+ const updateTransferState = useCallback(async (transferId: string, state: 'pending' | 'completed' | 'failed') => {
771
+ updateTransferStateStore(transferId, state);
772
+
773
+ if (__DEV__) {
774
+ logger('Updated transfer state', { transferId, state });
775
+ }
776
+ }, [logger, updateTransferStateStore]);
675
777
 
676
- const clearTransferCode = useCallback((transferId: string) => {
677
- transferCodesRef.current.delete(transferId);
778
+ const clearTransferCode = useCallback(async (transferId: string) => {
779
+ clearTransferCodeStore(transferId);
780
+
678
781
  if (__DEV__) {
679
782
  logger('Cleared transfer code', { transferId });
680
783
  }
681
- }, [logger]);
784
+ }, [logger, clearTransferCodeStore]);
682
785
 
683
786
  const refreshSessionsWithUser = useCallback(
684
787
  () => refreshSessions(userId),
@@ -697,6 +800,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
697
800
  logout().catch((remoteError) => logger('Failed to process remote sign out', remoteError));
698
801
  }, [logger, logout]);
699
802
 
803
+ // Check pending transfers when authenticated and online using TanStack Query
804
+ const { data: pendingTransferResults } = useCheckPendingTransfers();
805
+
700
806
  const handleIdentityTransferComplete = useCallback(
701
807
  async (data: { transferId: string; sourceDeviceId: string; publicKey: string; transferCode?: string; completedAt: string }) => {
702
808
  try {
@@ -789,6 +895,11 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
789
895
  return;
790
896
  }
791
897
 
898
+ // NOTE: Target device verification already happened server-side when notifyTransferComplete was called
899
+ // The server verified that the target device is authenticated and has the matching public key
900
+ // Additional client-side verification is not necessary and would require source device authentication
901
+ // which may not be available. The existing checks (public key match, transfer code, device ID) are sufficient.
902
+
792
903
  logger('All transfer verifications passed, deleting identity from source device', {
793
904
  transferId: data.transferId,
794
905
  sourceDeviceId: data.sourceDeviceId,
@@ -796,8 +907,31 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
796
907
  });
797
908
 
798
909
  try {
910
+ // Verify identity still exists before deletion (safety check)
911
+ const identityStillExists = await KeyManager.hasIdentity();
912
+ if (!identityStillExists) {
913
+ logger('Identity already deleted - skipping deletion', {
914
+ transferId: data.transferId,
915
+ });
916
+ await updateTransferState(data.transferId, 'completed');
917
+ await clearTransferCode(data.transferId);
918
+ return;
919
+ }
920
+
799
921
  await deleteIdentityAndClearAccount(false, false, true);
800
- clearTransferCode(data.transferId);
922
+
923
+ // Verify identity was actually deleted
924
+ const identityDeleted = !(await KeyManager.hasIdentity());
925
+ if (!identityDeleted) {
926
+ logger('Identity deletion failed - identity still exists', {
927
+ transferId: data.transferId,
928
+ });
929
+ await updateTransferState(data.transferId, 'failed');
930
+ throw new Error('Identity deletion failed - identity still exists');
931
+ }
932
+
933
+ await updateTransferState(data.transferId, 'completed');
934
+ await clearTransferCode(data.transferId);
801
935
 
802
936
  logger('Identity successfully deleted and transfer code cleared', {
803
937
  transferId: data.transferId,
@@ -806,6 +940,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
806
940
  toast.success('Identity successfully transferred and removed from this device');
807
941
  } catch (deleteError: any) {
808
942
  logger('Error during identity deletion', deleteError);
943
+ await updateTransferState(data.transferId, 'failed');
809
944
  throw deleteError;
810
945
  }
811
946
  } catch (error: any) {
@@ -813,7 +948,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
813
948
  toast.error(error?.message || 'Failed to remove identity from this device. Please try again manually from Security Settings.');
814
949
  }
815
950
  },
816
- [deleteIdentityAndClearAccount, logger, getTransferCode, clearTransferCode, currentDeviceId, activeSessionId],
951
+ [deleteIdentityAndClearAccount, logger, getTransferCode, clearTransferCode, updateTransferState, currentDeviceId, activeSessionId, oxyServices],
817
952
  );
818
953
 
819
954
  useSessionSocket({
@@ -907,6 +1042,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
907
1042
  storeTransferCode,
908
1043
  getTransferCode,
909
1044
  clearTransferCode,
1045
+ getAllPendingTransfers,
1046
+ getActiveTransferId,
1047
+ updateTransferState,
910
1048
  identitySyncState: {
911
1049
  isSynced: isIdentitySyncedStore ?? true,
912
1050
  isSyncing: isSyncing ?? false,
@@ -940,6 +1078,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
940
1078
  storeTransferCode,
941
1079
  getTransferCode,
942
1080
  clearTransferCode,
1081
+ getAllPendingTransfers,
1082
+ getActiveTransferId,
1083
+ updateTransferState,
943
1084
  isIdentitySyncedStore,
944
1085
  isSyncing,
945
1086
  currentLanguage,
@@ -0,0 +1,154 @@
1
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2
+ import { useOxy } from '../context/OxyContext';
3
+ import { useTransferStore } from '../stores/transferStore';
4
+
5
+ /**
6
+ * Query keys for transfer-related queries
7
+ */
8
+ export const transferQueryKeys = {
9
+ all: ['transfers'] as const,
10
+ completion: (transferId: string) => ['transfers', 'completion', transferId] as const,
11
+ pending: () => ['transfers', 'pending'] as const,
12
+ };
13
+
14
+ /**
15
+ * Hook to check if a transfer was completed
16
+ * Only runs when authenticated and transferId is provided
17
+ */
18
+ export const useCheckTransferCompletion = (transferId: string | null, enabled: boolean = true) => {
19
+ const { oxyServices, isAuthenticated } = useOxy();
20
+
21
+ return useQuery({
22
+ queryKey: transferId ? transferQueryKeys.completion(transferId) : ['transfers', 'completion', 'null'],
23
+ queryFn: async () => {
24
+ if (!transferId || !oxyServices) {
25
+ return null;
26
+ }
27
+
28
+ try {
29
+ const response = await oxyServices.makeRequest<{
30
+ completed: boolean;
31
+ transferId?: string;
32
+ sourceDeviceId?: string;
33
+ publicKey?: string;
34
+ transferCode?: string;
35
+ completedAt?: string;
36
+ }>(
37
+ 'GET',
38
+ `/api/identity/check-transfer/${transferId}`,
39
+ undefined,
40
+ { cache: false }
41
+ );
42
+
43
+ return response;
44
+ } catch (error: any) {
45
+ // Handle 401 errors gracefully - don't throw, just return null
46
+ if (error?.status === 401 || error?.message?.includes('401') || error?.message?.includes('authentication')) {
47
+ if (__DEV__) {
48
+ console.warn('[useCheckTransferCompletion] Authentication required, skipping check');
49
+ }
50
+ return null;
51
+ }
52
+ throw error;
53
+ }
54
+ },
55
+ enabled: enabled && !!transferId && isAuthenticated && !!oxyServices,
56
+ staleTime: 30 * 1000, // 30 seconds - completion status doesn't change frequently
57
+ gcTime: 5 * 60 * 1000, // 5 minutes
58
+ retry: (failureCount, error: any) => {
59
+ // Don't retry on 401 errors
60
+ if (error?.status === 401 || error?.message?.includes('401')) {
61
+ return false;
62
+ }
63
+ return failureCount < 2;
64
+ },
65
+ retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
66
+ });
67
+ };
68
+
69
+ /**
70
+ * Hook to check all pending transfers for completion
71
+ * Used when app comes back online
72
+ */
73
+ export const useCheckPendingTransfers = () => {
74
+ const { oxyServices, isAuthenticated } = useOxy();
75
+ const getAllPendingTransfers = useTransferStore((state) => state.getAllPendingTransfers);
76
+ const pendingTransfers = getAllPendingTransfers();
77
+
78
+ return useQuery({
79
+ queryKey: [...transferQueryKeys.pending(), pendingTransfers.map(t => t.transferId).join(',')],
80
+ queryFn: async () => {
81
+ if (!oxyServices || pendingTransfers.length === 0) {
82
+ return [];
83
+ }
84
+
85
+ const results: Array<{
86
+ transferId: string;
87
+ completed: boolean;
88
+ data?: {
89
+ transferId?: string;
90
+ sourceDeviceId?: string;
91
+ publicKey?: string;
92
+ transferCode?: string;
93
+ completedAt?: string;
94
+ };
95
+ }> = [];
96
+
97
+ // Check each pending transfer
98
+ for (const { transferId, data } of pendingTransfers) {
99
+ try {
100
+ const response = await oxyServices.makeRequest<{
101
+ completed: boolean;
102
+ transferId?: string;
103
+ sourceDeviceId?: string;
104
+ publicKey?: string;
105
+ transferCode?: string;
106
+ completedAt?: string;
107
+ }>(
108
+ 'GET',
109
+ `/api/identity/check-transfer/${transferId}`,
110
+ undefined,
111
+ { cache: false }
112
+ );
113
+
114
+ if (response.completed && response.publicKey === data.publicKey) {
115
+ results.push({
116
+ transferId,
117
+ completed: true,
118
+ data: response,
119
+ });
120
+ } else {
121
+ results.push({
122
+ transferId,
123
+ completed: false,
124
+ });
125
+ }
126
+ } catch (error: any) {
127
+ // Handle 401 errors gracefully - skip this transfer
128
+ if (error?.status === 401 || error?.message?.includes('401') || error?.message?.includes('authentication')) {
129
+ if (__DEV__) {
130
+ console.warn(`[useCheckPendingTransfers] Authentication required for transfer ${transferId}, skipping`);
131
+ }
132
+ results.push({
133
+ transferId,
134
+ completed: false,
135
+ });
136
+ continue;
137
+ }
138
+ // For other errors, mark as not completed
139
+ results.push({
140
+ transferId,
141
+ completed: false,
142
+ });
143
+ }
144
+ }
145
+
146
+ return results;
147
+ },
148
+ enabled: isAuthenticated && !!oxyServices && pendingTransfers.length > 0,
149
+ staleTime: 30 * 1000, // 30 seconds
150
+ gcTime: 5 * 60 * 1000, // 5 minutes
151
+ retry: false, // Don't retry - we'll check again on next reconnect
152
+ });
153
+ };
154
+
@@ -0,0 +1,201 @@
1
+ import { create } from 'zustand';
2
+ import { useShallow } from 'zustand/react/shallow';
3
+
4
+ export interface TransferCodeData {
5
+ code: string;
6
+ sourceDeviceId: string | null;
7
+ publicKey: string;
8
+ timestamp: number;
9
+ state: 'pending' | 'completed' | 'failed';
10
+ }
11
+
12
+ export interface TransferState {
13
+ // Transfer codes map: transferId -> TransferCodeData
14
+ transferCodes: Record<string, TransferCodeData>;
15
+
16
+ // Active transfer ID (only one active transfer at a time)
17
+ activeTransferId: string | null;
18
+
19
+ // Restoration flag to prevent duplicate restorations
20
+ isRestored: boolean;
21
+
22
+ // Actions
23
+ storeTransferCode: (transferId: string, code: string, sourceDeviceId: string | null, publicKey: string) => void;
24
+ getTransferCode: (transferId: string) => TransferCodeData | null;
25
+ clearTransferCode: (transferId: string) => void;
26
+ updateTransferState: (transferId: string, state: 'pending' | 'completed' | 'failed') => void;
27
+ getAllPendingTransfers: () => Array<{ transferId: string; data: TransferCodeData }>;
28
+ getActiveTransferId: () => string | null;
29
+ setActiveTransferId: (transferId: string | null) => void;
30
+ restoreFromStorage: (codes: Record<string, TransferCodeData>, activeTransferId: string | null) => void;
31
+ markRestored: () => void;
32
+ cleanupExpired: () => void;
33
+ reset: () => void;
34
+ }
35
+
36
+ const FIFTEEN_MINUTES = 15 * 60 * 1000;
37
+
38
+ const initialState = {
39
+ transferCodes: {} as Record<string, TransferCodeData>,
40
+ activeTransferId: null as string | null,
41
+ isRestored: false,
42
+ };
43
+
44
+ export const useTransferStore = create<TransferState>((set, get) => ({
45
+ ...initialState,
46
+
47
+ storeTransferCode: (transferId: string, code: string, sourceDeviceId: string | null, publicKey: string) => {
48
+ set((state) => ({
49
+ transferCodes: {
50
+ ...state.transferCodes,
51
+ [transferId]: {
52
+ code,
53
+ sourceDeviceId,
54
+ publicKey,
55
+ timestamp: Date.now(),
56
+ state: 'pending',
57
+ },
58
+ },
59
+ activeTransferId: transferId,
60
+ }));
61
+ },
62
+
63
+ getTransferCode: (transferId: string) => {
64
+ const state = get();
65
+ return state.transferCodes[transferId] || null;
66
+ },
67
+
68
+ clearTransferCode: (transferId: string) => {
69
+ set((state) => {
70
+ const { [transferId]: removed, ...rest } = state.transferCodes;
71
+ const newActiveTransferId = state.activeTransferId === transferId ? null : state.activeTransferId;
72
+ return {
73
+ transferCodes: rest,
74
+ activeTransferId: newActiveTransferId,
75
+ };
76
+ });
77
+ },
78
+
79
+ updateTransferState: (transferId: string, newState: 'pending' | 'completed' | 'failed') => {
80
+ set((state) => {
81
+ const existing = state.transferCodes[transferId];
82
+ if (!existing) {
83
+ return state;
84
+ }
85
+
86
+ const updated = {
87
+ ...existing,
88
+ state: newState,
89
+ };
90
+
91
+ // Clear active transfer if completed or failed
92
+ const newActiveTransferId =
93
+ (newState === 'completed' || newState === 'failed') && state.activeTransferId === transferId
94
+ ? null
95
+ : state.activeTransferId;
96
+
97
+ return {
98
+ transferCodes: {
99
+ ...state.transferCodes,
100
+ [transferId]: updated,
101
+ },
102
+ activeTransferId: newActiveTransferId,
103
+ };
104
+ });
105
+ },
106
+
107
+ getAllPendingTransfers: () => {
108
+ const state = get();
109
+ const pending: Array<{ transferId: string; data: TransferCodeData }> = [];
110
+
111
+ Object.entries(state.transferCodes).forEach(([transferId, data]) => {
112
+ if (data.state === 'pending') {
113
+ pending.push({ transferId, data });
114
+ }
115
+ });
116
+
117
+ return pending;
118
+ },
119
+
120
+ getActiveTransferId: () => {
121
+ return get().activeTransferId;
122
+ },
123
+
124
+ setActiveTransferId: (transferId: string | null) => {
125
+ set({ activeTransferId: transferId });
126
+ },
127
+
128
+ restoreFromStorage: (codes: Record<string, TransferCodeData>, activeTransferId: string | null) => {
129
+ const now = Date.now();
130
+ const validCodes: Record<string, TransferCodeData> = {};
131
+
132
+ // Only restore non-expired pending transfers
133
+ Object.entries(codes).forEach(([transferId, data]) => {
134
+ if (data.state === 'pending' && (now - data.timestamp) < FIFTEEN_MINUTES) {
135
+ validCodes[transferId] = data;
136
+ }
137
+ });
138
+
139
+ // Verify active transfer is still valid
140
+ let validActiveTransferId = activeTransferId;
141
+ if (activeTransferId && (!validCodes[activeTransferId] || validCodes[activeTransferId].state !== 'pending')) {
142
+ validActiveTransferId = null;
143
+ }
144
+
145
+ set({
146
+ transferCodes: validCodes,
147
+ activeTransferId: validActiveTransferId,
148
+ isRestored: true,
149
+ });
150
+ },
151
+
152
+ markRestored: () => {
153
+ set({ isRestored: true });
154
+ },
155
+
156
+ cleanupExpired: () => {
157
+ const now = Date.now();
158
+ set((state) => {
159
+ const validCodes: Record<string, TransferCodeData> = {};
160
+ let newActiveTransferId = state.activeTransferId;
161
+
162
+ Object.entries(state.transferCodes).forEach(([transferId, data]) => {
163
+ const age = now - data.timestamp;
164
+ if (age < FIFTEEN_MINUTES) {
165
+ validCodes[transferId] = data;
166
+ } else if (transferId === state.activeTransferId) {
167
+ newActiveTransferId = null;
168
+ }
169
+ });
170
+
171
+ return {
172
+ transferCodes: validCodes,
173
+ activeTransferId: newActiveTransferId,
174
+ };
175
+ });
176
+ },
177
+
178
+ reset: () => {
179
+ set(initialState);
180
+ },
181
+ }));
182
+
183
+ /**
184
+ * Hook to get transfer codes for persistence
185
+ */
186
+ export const useTransferCodesForPersistence = () => {
187
+ return useTransferStore(
188
+ useShallow((state) => ({
189
+ transferCodes: state.transferCodes,
190
+ activeTransferId: state.activeTransferId,
191
+ }))
192
+ );
193
+ };
194
+
195
+ /**
196
+ * Hook to check if store has been restored
197
+ */
198
+ export const useTransferStoreRestored = () => {
199
+ return useTransferStore((state) => state.isRestored);
200
+ };
201
+