Skip to content

Commit f844efc

Browse files
committed
feat: prevent duplicate address book entries
1 parent d06f949 commit f844efc

3 files changed

Lines changed: 213 additions & 28 deletions

File tree

app/contacts.tsx

Lines changed: 113 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import React, { useState } from 'react';
2-
import { View, Text, StyleSheet, FlatList, Alert } from 'react-native';
2+
import { View, Text, StyleSheet, FlatList, Alert, TouchableOpacity } from 'react-native';
33
import { Button } from '../src/components/Button';
44
import { Input } from '../src/components/Input';
55
import { COLORS, SIZES, RADIUS } from '../src/constants/theme';
66
import { useAppStore, Contact } from '../src/store/appStore';
77
import { validateAddress } from '../src/utils/validation';
8-
import { Trash2, User } from 'lucide-react-native';
8+
import { findDuplicate, duplicateMessage, normalizeAddress } from '../src/utils/address';
9+
import { Trash2, User, AlertTriangle, Pencil } from 'lucide-react-native';
910

1011
export default function ContactsScreen() {
11-
const { contacts, addContact, removeContact, findContactByPublicKey } = useAppStore();
12+
const { contacts, addContact, removeContact, updateContact } = useAppStore();
1213
const [name, setName] = useState('');
1314
const [publicKey, setPublicKey] = useState('');
1415
const [nameError, setNameError] = useState<string | undefined>();
1516
const [keyError, setKeyError] = useState<string | undefined>();
1617
const [duplicateError, setDuplicateError] = useState<string | undefined>();
18+
const [foundDuplicate, setFoundDuplicate] = useState<Contact | null>(null);
1719
const [isAdding, setIsAdding] = useState(false);
1820

1921
const handleNameChange = (value: string) => {
@@ -25,6 +27,7 @@ export default function ContactsScreen() {
2527
setPublicKey(value);
2628
setKeyError(value.trim() ? validateAddress(value) ?? undefined : undefined);
2729
if (duplicateError) setDuplicateError(undefined);
30+
if (foundDuplicate) setFoundDuplicate(null);
2831
};
2932

3033
const handleAdd = async () => {
@@ -33,32 +36,60 @@ export default function ContactsScreen() {
3336
setNameError(currentNameError);
3437
setKeyError(currentKeyError);
3538
setDuplicateError(undefined);
39+
setFoundDuplicate(null);
3640

3741
if (currentNameError || currentKeyError) {
3842
return;
3943
}
4044

41-
const existing = findContactByPublicKey(publicKey);
45+
// Duplicate detection via utility (consistent normalization)
46+
const existing = findDuplicate(publicKey, contacts);
4247
if (existing) {
43-
setDuplicateError(
44-
`This address is already saved as "${existing.name}". You cannot add duplicate addresses.`,
45-
);
48+
setFoundDuplicate(existing);
49+
setDuplicateError(duplicateMessage(existing.name));
4650
return;
4751
}
4852

4953
const newContact: Contact = {
5054
id: Date.now().toString(),
5155
name: name.trim(),
52-
publicKey: publicKey.trim(),
56+
publicKey: normalizeAddress(publicKey),
5357
};
5458

55-
await addContact(newContact);
59+
// Store-level duplicate check (defense in depth)
60+
const result = await addContact(newContact);
61+
if (!result.success) {
62+
setDuplicateError(duplicateMessage(result.duplicateName ?? ''));
63+
return;
64+
}
65+
66+
resetForm();
67+
setIsAdding(false);
68+
};
69+
70+
const handleUpdateExisting = async () => {
71+
if (!foundDuplicate) return;
72+
const newName = name.trim() || foundDuplicate.name;
73+
try {
74+
await updateContact(foundDuplicate.id, newName);
75+
Alert.alert(
76+
'Updated',
77+
`Contact "${foundDuplicate.name}" has been updated to "${newName}".`,
78+
);
79+
resetForm();
80+
setIsAdding(false);
81+
} catch (e: any) {
82+
Alert.alert('Error', e.message || 'Failed to update contact.');
83+
}
84+
};
85+
86+
const resetForm = () => {
5687
setName('');
5788
setPublicKey('');
5889
setNameError(undefined);
5990
setKeyError(undefined);
6091
setDuplicateError(undefined);
61-
setIsAdding(false);
92+
setFoundDuplicate(null);
6293
};
6394

6495
const handleRemove = (id: string) => {
@@ -82,12 +113,33 @@ export default function ContactsScreen() {
82113
error={keyError}
83114
autoCapitalize="none"
84115
/>
85-
{duplicateError && (
116+
{foundDuplicate && (
117+
<View style={styles.duplicateBanner}>
118+
<View style={styles.duplicateBannerHeader}>
119+
<AlertTriangle color={COLORS.warning} size={18} />
120+
<Text style={styles.duplicateBannerTitle}>Duplicate Address</Text>
121+
</View>
122+
<Text style={styles.duplicateBannerText}>
123+
This address is already saved as "{foundDuplicate.name}".
124+
</Text>
125+
<Text style={styles.duplicateBannerHint}>
126+
You can update the existing entry's name below, or cancel to keep it unchanged.
127+
</Text>
128+
<TouchableOpacity style={styles.updateButton} onPress={handleUpdateExisting}>
129+
<Pencil color={COLORS.primary} size={16} />
130+
<Text style={styles.updateButtonText}>
131+
Update "{foundDuplicate.name}" to "{name.trim() || foundDuplicate.name}"
132+
</Text>
133+
</TouchableOpacity>
134+
</View>
135+
)}
136+
137+
{duplicateError && !foundDuplicate && (
86138
<Text style={styles.duplicateWarning}>{duplicateError}</Text>
87139
)}
88140
<View style={styles.actions}>
89141
<Button title="Save Contact" onPress={handleAdd} style={styles.actionBtn} />
90-
<Button title="Cancel" variant="outline" onPress={() => setIsAdding(false)} style={styles.actionBtn} />
142+
<Button title="Cancel" variant="outline" onPress={() => { resetForm(); setIsAdding(false); }} style={styles.actionBtn} />
91143
</View>
92144
</View>
93145
) : (
@@ -193,5 +245,53 @@ const styles = StyleSheet.create({
193245
marginTop: SIZES.xs,
194246
marginLeft: SIZES.xs,
195247
marginBottom: SIZES.sm,
196-
}
248+
},
249+
duplicateBanner: {
250+
backgroundColor: 'rgba(255, 196, 0, 0.08)',
251+
borderRadius: RADIUS.md,
252+
padding: SIZES.md,
253+
marginTop: SIZES.sm,
254+
marginBottom: SIZES.sm,
255+
borderWidth: 1,
256+
borderColor: 'rgba(255, 196, 0, 0.25)',
257+
},
258+
duplicateBannerHeader: {
259+
flexDirection: 'row',
260+
alignItems: 'center',
261+
gap: SIZES.sm,
262+
marginBottom: SIZES.xs,
263+
},
264+
duplicateBannerTitle: {
265+
color: COLORS.warning,
266+
fontSize: 14,
267+
fontWeight: '700',
268+
},
269+
duplicateBannerText: {
270+
color: COLORS.warning,
271+
fontSize: 13,
272+
marginBottom: SIZES.xs,
273+
lineHeight: 18,
274+
},
275+
duplicateBannerHint: {
276+
color: COLORS.textMuted,
277+
fontSize: 12,
278+
marginBottom: SIZES.md,
279+
lineHeight: 17,
280+
},
281+
updateButton: {
282+
flexDirection: 'row',
283+
alignItems: 'center',
284+
backgroundColor: 'rgba(0, 229, 255, 0.1)',
285+
borderRadius: RADIUS.sm,
286+
padding: SIZES.sm + 2,
287+
gap: SIZES.sm,
288+
borderWidth: 1,
289+
borderColor: 'rgba(0, 229, 255, 0.2)',
290+
},
291+
updateButtonText: {
292+
color: COLORS.primary,
293+
fontSize: 13,
294+
fontWeight: '600',
295+
flex: 1,
296+
},
197297
});

src/store/appStore.ts

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { create } from 'zustand';
22
import AsyncStorage from '@react-native-async-storage/async-storage';
3+
import { normalizeAddress, isDuplicate, findDuplicate, duplicateMessage } from '../utils/address';
34

45
export interface Contact {
56
id: string;
@@ -14,7 +15,8 @@ interface AppState {
1415

1516
// Actions
1617
initializeApp: () => Promise<void>;
17-
addContact: (contact: Contact) => Promise<void>;
18+
addContact: (contact: Contact) => Promise<{ success: boolean; duplicateName?: string }>;
19+
updateContact: (id: string, name: string) => Promise<void>;
1820
removeContact: (id: string) => Promise<void>;
1921
findContactByPublicKey: (publicKey: string) => Contact | undefined;
2022
toggleDarkMode: () => Promise<void>;
@@ -25,10 +27,22 @@ const STORAGE_KEYS = {
2527
DARK_MODE: '@pocketpay_theme',
2628
};
2729

30+
/**
31+
* @deprecated Use normalizeAddress from src/utils/address instead.
32+
* Kept for backward compatibility with existing callers.
33+
*/
2834
export function normalizePublicKey(publicKey: string): string {
29-
return publicKey.trim().toUpperCase();
35+
return normalizeAddress(publicKey);
3036
}
3137

38+
const persistContacts = async (contacts: Contact[]) => {
39+
try {
40+
await AsyncStorage.setItem(STORAGE_KEYS.CONTACTS, JSON.stringify(contacts));
41+
} catch (e) {
42+
console.error('Failed to save contacts:', e);
43+
}
44+
};
45+
3246
export const useAppStore = create<AppState>((set, get) => ({
3347
contacts: [],
3448
isDarkMode: true, // Default to a premium dark mode as suggested in plan
@@ -53,29 +67,48 @@ export const useAppStore = create<AppState>((set, get) => ({
5367
},
5468

5569
addContact: async (contact: Contact) => {
56-
const newContacts = [...get().contacts, contact];
57-
set({ contacts: newContacts });
58-
try {
59-
await AsyncStorage.setItem(STORAGE_KEYS.CONTACTS, JSON.stringify(newContacts));
60-
} catch (e) {
61-
console.error('Failed to save contact:', e);
70+
const { contacts } = get();
71+
const normalized = normalizeAddress(contact.publicKey);
72+
73+
// Defense-in-depth: check for duplicates in the store as well.
74+
const existing = contacts.find(
75+
(c) => normalizeAddress(c.publicKey) === normalized,
76+
);
77+
if (existing) {
78+
return { success: false, duplicateName: existing.name };
6279
}
80+
81+
// Also normalize the stored key so every entry in the list is consistent.
82+
const sanitized: Contact = {
83+
...contact,
84+
publicKey: normalized,
85+
name: contact.name.trim(),
86+
};
87+
88+
const newContacts = [...contacts, sanitized];
89+
set({ contacts: newContacts });
90+
await persistContacts(newContacts);
91+
return { success: true };
92+
},
93+
94+
updateContact: async (id: string, name: string) => {
95+
const newContacts = get().contacts.map((c) =>
96+
c.id === id ? { ...c, name: name.trim() } : c,
97+
);
98+
set({ contacts: newContacts });
99+
await persistContacts(newContacts);
63100
},
64101

65102
removeContact: async (id: string) => {
66103
const newContacts = get().contacts.filter(c => c.id !== id);
67104
set({ contacts: newContacts });
68-
try {
69-
await AsyncStorage.setItem(STORAGE_KEYS.CONTACTS, JSON.stringify(newContacts));
70-
} catch (e) {
71-
console.error('Failed to remove contact:', e);
72-
}
105+
await persistContacts(newContacts);
73106
},
74107

75108
findContactByPublicKey: (publicKey: string) => {
76-
const normalized = normalizePublicKey(publicKey);
109+
const normalized = normalizeAddress(publicKey);
77110
return get().contacts.find(
78-
(c) => normalizePublicKey(c.publicKey) === normalized,
111+
(c) => normalizeAddress(c.publicKey) === normalized,
79112
);
80113
},
81114

src/utils/address.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Stellar address normalization and duplicate-detection utilities.
3+
*
4+
* All comparisons are case-insensitive and whitespace-insensitive so that
5+
* "gabc...", "GABC...", and " GABC... " are treated as the same address.
6+
*/
7+
8+
/**
9+
* Normalize a Stellar public key for consistent comparison.
10+
* Trims whitespace and converts to uppercase.
11+
*/
12+
export const normalizeAddress = (publicKey: string): string =>
13+
publicKey.trim().toUpperCase();
14+
15+
/**
16+
* Check whether two addresses represent the same Stellar public key.
17+
*/
18+
export const isSameAddress = (a: string, b: string): boolean =>
19+
normalizeAddress(a) === normalizeAddress(b);
20+
21+
export interface AddressBookEntry {
22+
id: string;
23+
name: string;
24+
publicKey: string;
25+
}
26+
27+
/**
28+
* Find a contact in the address book whose public key matches `candidate`
29+
* (normalized comparison). Returns the first match, or `undefined` if no match.
30+
*/
31+
export const findDuplicate = (
32+
candidate: string,
33+
entries: ReadonlyArray<AddressBookEntry>,
34+
): AddressBookEntry | undefined => {
35+
const normalized = normalizeAddress(candidate);
36+
return entries.find((entry) => normalizeAddress(entry.publicKey) === normalized);
37+
};
38+
39+
/**
40+
* Check whether adding `candidate` to `entries` would create a duplicate.
41+
*/
42+
export const isDuplicate = (
43+
candidate: string,
44+
entries: ReadonlyArray<AddressBookEntry>,
45+
): boolean => findDuplicate(candidate, entries) !== undefined;
46+
47+
/**
48+
* Build a human-readable duplicate error message referencing the existing entry.
49+
*/
50+
export const duplicateMessage = (existingName: string): string =>
51+
`This address is already saved as "${existingName || 'Unnamed'}". ` +
52+
'You can update the existing entry instead of creating a duplicate.';

0 commit comments

Comments
 (0)