Skip to content

Commit 029c4a1

Browse files
committed
feat(clients): favorite / pinned locations
Pin the countries you use most with a star; favorites sort to the top of the picker. Matters once 12+ countries are deployed. - mobile: a star per picker row (persisted favorites), favorites-first ordering. - desktop: same, with a star in each row (stopPropagation so it doesn't select). - Persisted in storage / localStorage; surfaced through useVpn / useConnection. tsc / eslint / prettier / tests green on both clients.
1 parent 0565bc2 commit 029c4a1

8 files changed

Lines changed: 118 additions & 14 deletions

File tree

clients/desktop/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ export function App(): JSX.Element {
152152
onPick={picker === 'exit' ? conn.selectExit : conn.select}
153153
onClose={() => setPicker(null)}
154154
onRefresh={conn.refresh}
155+
favorites={conn.favorites}
156+
onToggleFavorite={conn.toggleFavorite}
155157
/>
156158
)}
157159

clients/desktop/src/components/CountryPicker.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ interface Props {
1111
readonly onClose: () => void;
1212
/** Re-discover the fleet (re-test node load/quality). */
1313
readonly onRefresh: () => Promise<void>;
14+
/** Favorited country codes (surfaced first). */
15+
readonly favorites: readonly string[];
16+
/** Pin/unpin a country. */
17+
readonly onToggleFavorite: (code: string) => void;
1418
}
1519

1620
/** Quality-tone → CSS colour var (green best … red busiest). */
@@ -28,6 +32,8 @@ export function CountryPicker({
2832
onPick,
2933
onClose,
3034
onRefresh,
35+
favorites,
36+
onToggleFavorite,
3137
}: Props): JSX.Element {
3238
const [q, setQ] = useState('');
3339
const [refreshing, setRefreshing] = useState(false);
@@ -41,13 +47,16 @@ export function CountryPicker({
4147
};
4248
const filtered = useMemo(() => {
4349
const needle = q.trim().toLowerCase();
44-
if (!needle) {
45-
return countries;
46-
}
47-
return countries.filter(
48-
(c) => c.name.toLowerCase().includes(needle) || c.code.toLowerCase().includes(needle),
49-
);
50-
}, [countries, q]);
50+
const matched = needle
51+
? countries.filter(
52+
(c) => c.name.toLowerCase().includes(needle) || c.code.toLowerCase().includes(needle),
53+
)
54+
: countries;
55+
// Favorites first, then the rest in their existing order.
56+
const fav = matched.filter((c) => favorites.includes(c.code));
57+
const rest = matched.filter((c) => !favorites.includes(c.code));
58+
return [...fav, ...rest];
59+
}, [countries, q, favorites]);
5160

5261
return (
5362
<div className="sheet">
@@ -76,6 +85,17 @@ export function CountryPicker({
7685
onClose();
7786
}}
7887
>
88+
<span
89+
className={`star ${favorites.includes(c.code) ? 'on' : ''}`}
90+
role="button"
91+
aria-label={favorites.includes(c.code) ? 'Unpin' : 'Pin'}
92+
onClick={(e) => {
93+
e.stopPropagation();
94+
onToggleFavorite(c.code);
95+
}}
96+
>
97+
{favorites.includes(c.code) ? '★' : '☆'}
98+
</span>
7999
<span className="flag">{c.flag}</span>
80100
<span className="cn">{c.name}</span>
81101
<span className="qlabel" style={{ color: TONE_VAR[q.tone] }}>

clients/desktop/src/hooks/useConnection.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ export interface ConnectionModel {
7272
readonly connectedSince: number | null;
7373
/** Re-discover the fleet (re-test node load/quality); keeps the selection. */
7474
readonly refresh: () => Promise<void>;
75+
/** Favorited (pinned) country codes, surfaced first in the picker. */
76+
readonly favorites: readonly string[];
77+
/** Pin/unpin a country. */
78+
readonly toggleFavorite: (code: string) => void;
7579
}
7680

7781
const DOWN: TunnelStatus = {
@@ -121,6 +125,9 @@ export function useConnection(): ConnectionModel {
121125
// whether we ever reached 'connected' this session (for auto-reconnect).
122126
const [connectedSince, setConnectedSince] = useState<number | null>(null);
123127
const wasConnectedRef = useRef(false);
128+
const [favorites, setFavorites] = useState<readonly string[]>(() =>
129+
(localStorage.getItem('cvpn.favorites') ?? '').split(',').filter(Boolean),
130+
);
124131

125132
// Bootstrap: discover the fleet and restore the last-selected country.
126133
useEffect(() => {
@@ -196,6 +203,14 @@ export function useConnection(): ConnectionModel {
196203
localStorage.setItem('cvpn.autoConnect', on ? '1' : '0');
197204
}, []);
198205

206+
const toggleFavorite = useCallback((code: string) => {
207+
setFavorites((prev) => {
208+
const next = prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code];
209+
localStorage.setItem('cvpn.favorites', next.join(','));
210+
return next;
211+
});
212+
}, []);
213+
199214
/** Poll chain entitlement from the metering gateway; never drops the tunnel. */
200215
const refreshEntitlement = useCallback(
201216
async (gatewayIp: string, signPubKey: string) => {
@@ -340,5 +355,7 @@ export function useConnection(): ConnectionModel {
340355
setAutoConnect,
341356
connectedSince,
342357
refresh,
358+
favorites,
359+
toggleFavorite,
343360
};
344361
}

clients/desktop/src/styles.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,15 @@ button {
718718
font-size: 11px;
719719
color: var(--ink-3);
720720
}
721+
.crow .star {
722+
font-size: 15px;
723+
color: var(--ink-3);
724+
cursor: pointer;
725+
flex-shrink: 0;
726+
}
727+
.crow .star.on {
728+
color: var(--amber);
729+
}
721730
.crow .qlabel {
722731
font-size: 12px;
723732
font-weight: 600;

clients/mobile/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ function App(): React.JSX.Element {
5454
onSelect={(code) => void vpn.selectCountry(code)}
5555
onClose={() => setRoute('connect')}
5656
onRefresh={() => vpn.refresh()}
57+
favorites={vpn.favorites}
58+
onToggleFavorite={(code) => void vpn.toggleFavorite(code)}
5759
/>
5860
) : route === 'entry' ? (
5961
<CountryPickerScreen
@@ -62,6 +64,8 @@ function App(): React.JSX.Element {
6264
onSelect={(code) => void vpn.selectEntryCountry(code)}
6365
onClose={() => setRoute('connect')}
6466
onRefresh={() => vpn.refresh()}
67+
favorites={vpn.favorites}
68+
onToggleFavorite={(code) => void vpn.toggleFavorite(code)}
6569
/>
6670
) : route === 'exit' ? (
6771
<CountryPickerScreen
@@ -70,6 +74,8 @@ function App(): React.JSX.Element {
7074
onSelect={(code) => void vpn.selectExitCountry(code)}
7175
onClose={() => setRoute('connect')}
7276
onRefresh={() => vpn.refresh()}
77+
favorites={vpn.favorites}
78+
onToggleFavorite={(code) => void vpn.toggleFavorite(code)}
7379
/>
7480
) : route === 'upgrade' ? (
7581
<UpgradeScreen

clients/mobile/src/screens/CountryPickerScreen.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ interface Props {
3333
readonly onClose: () => void;
3434
/** Re-run discovery + an active latency re-test of the fleet. */
3535
readonly onRefresh: () => Promise<void>;
36+
/** Favorited country codes (surfaced first). */
37+
readonly favorites: readonly string[];
38+
/** Pin/unpin a country. */
39+
readonly onToggleFavorite: (code: string) => void;
3640
}
3741

3842
export function CountryPickerScreen({
@@ -41,6 +45,8 @@ export function CountryPickerScreen({
4145
onSelect,
4246
onClose,
4347
onRefresh,
48+
favorites,
49+
onToggleFavorite,
4450
}: Props): React.JSX.Element {
4551
const [query, setQuery] = useState('');
4652
const [refreshing, setRefreshing] = useState(false);
@@ -56,13 +62,16 @@ export function CountryPickerScreen({
5662

5763
const filtered = useMemo(() => {
5864
const q = query.trim().toLowerCase();
59-
if (!q) {
60-
return countries;
61-
}
62-
return countries.filter(
63-
(c) => c.name.toLowerCase().includes(q) || c.city.toLowerCase().includes(q),
64-
);
65-
}, [countries, query]);
65+
const matched = q
66+
? countries.filter(
67+
(c) => c.name.toLowerCase().includes(q) || c.city.toLowerCase().includes(q),
68+
)
69+
: countries;
70+
// Favorites first, otherwise keep the incoming (latency-sorted) order.
71+
const fav = matched.filter((c) => favorites.includes(c.code));
72+
const rest = matched.filter((c) => !favorites.includes(c.code));
73+
return [...fav, ...rest];
74+
}, [countries, query, favorites]);
6675

6776
return (
6877
<View style={styles.root}>
@@ -105,6 +114,7 @@ export function CountryPickerScreen({
105114
}
106115
renderItem={({ item }) => {
107116
const q = gatewayQuality(item.latencyMs, item.best.load);
117+
const pinned = favorites.includes(item.code);
108118
return (
109119
<Pressable
110120
style={[styles.row, item.code === selectedCode && styles.rowSelected]}
@@ -114,6 +124,14 @@ export function CountryPickerScreen({
114124
}}
115125
accessibilityRole="button"
116126
>
127+
<Pressable
128+
onPress={() => onToggleFavorite(item.code)}
129+
accessibilityRole="button"
130+
accessibilityLabel={pinned ? 'Unpin' : 'Pin'}
131+
hitSlop={8}
132+
>
133+
<Text style={[styles.star, pinned && styles.starOn]}>{pinned ? '★' : '☆'}</Text>
134+
</Pressable>
117135
<Text style={styles.flag}>{item.flag}</Text>
118136
<View style={styles.meta}>
119137
<Text style={styles.name}>{item.name}</Text>
@@ -175,6 +193,8 @@ const styles = StyleSheet.create({
175193
borderColor: 'rgba(52,228,218,0.4)',
176194
borderWidth: 1,
177195
},
196+
star: { fontSize: 17, color: color.inkFaint },
197+
starOn: { color: color.amber },
178198
flag: { fontSize: 24 },
179199
meta: { flex: 1 },
180200
name: { color: color.ink, fontSize: 15, fontWeight: '600' },

clients/mobile/src/state/storage.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ export async function saveAutoConnect(enabled: boolean): Promise<void> {
7777
mem.set('autoConnect', enabled ? '1' : '0');
7878
}
7979

80+
/** Load the set of favorited (pinned) country codes. */
81+
export async function loadFavorites(): Promise<string[]> {
82+
const raw = mem.get('favorites');
83+
return raw ? raw.split(',').filter(Boolean) : [];
84+
}
85+
86+
/** Persist the favorited country codes. */
87+
export async function saveFavorites(codes: readonly string[]): Promise<void> {
88+
mem.set('favorites', codes.join(','));
89+
}
90+
8091
/** Load the multi-hop entry country code, or null for auto-pick. */
8192
export async function loadEntryCountry(): Promise<string | null> {
8293
return mem.get('entryCountry') ?? null;

clients/mobile/src/state/useVpn.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ import {
3333
loadAutoConnect,
3434
loadEntryCountry,
3535
loadExitCountry,
36+
loadFavorites,
3637
loadKeypair,
3738
loadKillSwitch,
3839
loadRouteStyle,
3940
loadSelectedCountry,
4041
saveAutoConnect,
4142
saveEntryCountry,
4243
saveExitCountry,
44+
saveFavorites,
4345
saveKeypair,
4446
saveKillSwitch,
4547
saveRouteStyle,
@@ -81,6 +83,8 @@ export interface VpnModel {
8183
readonly autoConnect: boolean;
8284
/** Unix-ms when the active session connected, or null when not connected. */
8385
readonly connectedSince: number | null;
86+
/** Favorited (pinned) country codes, surfaced first in the picker. */
87+
readonly favorites: readonly string[];
8488
}
8589

8690
/** Chain-payment identity derived from the device key + last enrollment. */
@@ -111,6 +115,8 @@ export interface VpnActions {
111115
setKillSwitch(enabled: boolean): Promise<void>;
112116
/** Toggle auto-connect on launch (persisted). */
113117
setAutoConnect(enabled: boolean): Promise<void>;
118+
/** Pin/unpin a country as a favorite (persisted). */
119+
toggleFavorite(code: string): Promise<void>;
114120
/** Open the OS VPN settings (Android lockdown hand-off; no-op on iOS). */
115121
openVpnSettings(): Promise<void>;
116122
}
@@ -132,6 +138,7 @@ export function useVpn(): VpnModel & VpnActions {
132138
const [exitCode, setExitCode] = useState<string | null>(null);
133139
const [killSwitch, setKillSwitchState] = useState(false);
134140
const [autoConnect, setAutoConnectState] = useState(false);
141+
const [favorites, setFavorites] = useState<readonly string[]>([]);
135142
const autoConnectedRef = useRef(false);
136143
// Unix-ms when the current session connected, for the session timer.
137144
const [connectedSince, setConnectedSince] = useState<number | null>(null);
@@ -210,6 +217,7 @@ export function useVpn(): VpnModel & VpnActions {
210217
setExitCode(await loadExitCountry());
211218
setKillSwitchState(await loadKillSwitch());
212219
setAutoConnectState(await loadAutoConnect());
220+
setFavorites(await loadFavorites());
213221
await refresh();
214222
} catch (e) {
215223
if (alive) {
@@ -423,6 +431,15 @@ export function useVpn(): VpnModel & VpnActions {
423431
await saveAutoConnect(enabled);
424432
}, []);
425433

434+
const toggleFavorite = useCallback(async (code: string): Promise<void> => {
435+
let next: string[] = [];
436+
setFavorites((prev) => {
437+
next = prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code];
438+
return next;
439+
});
440+
await saveFavorites(next);
441+
}, []);
442+
426443
const openVpnSettings = useCallback(async (): Promise<void> => {
427444
await CumulusTunnel.openVpnSettings();
428445
}, []);
@@ -444,6 +461,7 @@ export function useVpn(): VpnModel & VpnActions {
444461
killSwitch,
445462
autoConnect,
446463
connectedSince,
464+
favorites,
447465
connect,
448466
disconnect,
449467
selectCountry,
@@ -453,6 +471,7 @@ export function useVpn(): VpnModel & VpnActions {
453471
selectExitCountry,
454472
setKillSwitch,
455473
setAutoConnect,
474+
toggleFavorite,
456475
openVpnSettings,
457476
};
458477
}

0 commit comments

Comments
 (0)