-
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathApp.tsx
More file actions
2045 lines (1978 loc) · 87.5 KB
/
Copy pathApp.tsx
File metadata and controls
2045 lines (1978 loc) · 87.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Polyfill must be the first import. Required before any @noble/* usage.
import "react-native-get-random-values";
import { FiraCode_400Regular } from "@expo-google-fonts/fira-code";
import {
JetBrainsMono_400Regular,
useFonts,
} from "@expo-google-fonts/jetbrains-mono";
import { Feather } from "@expo/vector-icons";
import { NavigationBar } from "expo-navigation-bar";
import { StatusBar } from "expo-status-bar";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
AppState,
BackHandler,
DeviceEventEmitter,
Linking,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from "react-native-gesture-handler";
import { runOnJS } from "react-native-reanimated";
import {
initialWindowMetrics,
SafeAreaProvider,
SafeAreaView,
} from "react-native-safe-area-context";
import AirhopBLE from "./src/bridge/NativeAirhopBLE";
import type { Identity } from "./src/core/crypto/identity";
import { loadIdentity } from "./src/core/crypto/identity";
import {
primeTorRoutingOnStartup,
revalidateTorRouting,
} from "./src/core/nostr/tor-routing";
import ChannelList from "./src/features/chat/channel-list";
import ChatSearchResults from "./src/features/chat/chat-search-results";
import DmList from "./src/features/chat/dm-list";
import MessageThread from "./src/features/chat/message-thread";
import NotificationCenter from "./src/features/chat/notification-center";
import { StartNewSheet } from "./src/features/chat/start-new-sheet";
import PeerList from "./src/features/discovery/peer-list";
import IdentityScreen from "./src/features/onboarding/identity-screen";
import PermissionPrimer from "./src/features/onboarding/permission-primer";
import UsernameScreen from "./src/features/onboarding/username-screen";
import WelcomeScreen from "./src/features/onboarding/welcome-screen";
import ProfileScreen from "./src/features/settings/profile-screen";
import WalletScreen, {
type WalletAction,
} from "./src/features/wallet/wallet-screen";
import { initI18n, t, useT, useTPlural, type TranslationKey } from "./src/i18n";
import { arrowBack, isRTLLayout } from "./src/i18n/layout";
import { setAudioForPlayback } from "./src/services/audio-session";
import { sweepExpiredAttachments } from "./src/services/file-transfer-service";
import { applyAirhopLink } from "./src/services/link-router";
import {
hasLocationPermission,
requestLocationPermission,
} from "./src/services/location-service";
import { getMeshService, initMeshService } from "./src/services/mesh-service";
import {
configureNotifications,
dismissNearbyNotification,
dismissNotificationsFor,
handleInboundMessage,
handleNearbyPeers,
requestNotificationPermission,
setAppBadgeCount,
setMeshNavigator,
setNotificationNavigator,
setNotificationsActiveChannel,
setNotificationsAppActive,
} from "./src/services/notification-service";
import { setNutzapWatcher } from "./src/services/nutzap-watcher-handle";
import { applyPresence } from "./src/services/presence";
import {
initWalletService,
publishOwnNutzapInfo,
reconcile,
reconcileIfDue,
startNutzapWatcher,
} from "./src/services/wallet-service";
import { useActivityStore } from "./src/store/activity-store";
import { showAlert } from "./src/store/alert-store";
import {
flushChatPersistence,
subscribeInboundMessages,
useChatStore,
} from "./src/store/chat-store";
import {
useMeshBanners,
useMeshStateStore,
type BannerAction,
} from "./src/store/mesh-state-store";
import { countReachablePeers, usePeerStore } from "./src/store/peer-store";
import {
acknowledgePermissionPrimer,
showPermissionPrimer,
usePrimerStore,
} from "./src/store/primer-store";
import { useSettingsStore } from "./src/store/settings-store";
import { useTransferStore } from "./src/store/transfer-store";
import { useWalletStore } from "./src/store/wallet-store";
import Avatar from "./src/ui/components/avatar";
import CustomAlert from "./src/ui/components/custom-alert";
import MeshStatusBar from "./src/ui/components/mesh-status-bar";
import PrivacyCover from "./src/ui/components/privacy-cover";
import TransferBadge from "./src/ui/components/transfer-badge";
import {
DISABLED_OPACITY,
FontSize,
FontWeight,
hitSlopFor,
MaxFontScale,
Radius,
Shadow,
Spacing,
useResolvedTheme,
useThemeColors,
} from "./src/ui/theme";
import { getBatteryOptimizationSettingsURI } from "./src/utils/battery-optimization";
import {
ensureBlePermissions,
hasBlePermissions,
} from "./src/utils/ble-permissions";
import { parseAirhopLink } from "./src/utils/deep-link";
import { formatNumber } from "./src/utils/format";
import { mentionsNickname } from "./src/utils/mentions";
import { messagePreviewText } from "./src/utils/message-preview";
import { showBlockedAlert } from "./src/utils/permissions";
import { sumUnread } from "./src/utils/unread";
import { peerIDToUsername } from "./src/utils/username";
// Layout direction is a native flag that React Native reads once, at startup,
// before anything mounts. Setting it here, at module scope, is the only place
// early enough for the very first frame to be correct.
initI18n();
// ---------------------------------------------------------------------------
// Navigation types
// ---------------------------------------------------------------------------
type OnboardingStep = "welcome" | "generating" | "reveal";
type MainTab = "chats" | "mesh" | "wallet" | "profile";
type ChatSubTab = "channels" | "dms";
type ChatView =
{ kind: "list" } | { kind: "thread"; channel: string } | { kind: "search" };
// Which message a thread should scroll to and flash on open, set from a
// search-result tap. `trigger` increments on every selection so re-tapping
// the same result re-fires the effect (an id-only dependency wouldn't).
interface MessageTarget {
channel: string;
messageId: string;
trigger: number;
}
// Placeholder peer ID shown before identity is loaded from secure storage.
const FALLBACK_PEER_ID = "0000000000000000";
// Request the BLE runtime permissions the OS requires, THEN start the mesh.
// Without the grant, native startScanning/startAdvertising throw and are
// swallowed: a silent, total discovery failure. On denial we surface a
// dialog instead of failing quietly, and still start the service so Nostr
// (internet) transport keeps working even when BLE is unavailable.
async function startMeshWithPermissions(
identity: Identity,
nickname: string,
): Promise<void> {
// Explain the ask before the OS makes it, once per install.
//
// Gated on the permission not already being held, so it never appears for a
// returning user whose grant is settled - and gated on the flag so a user who
// declined does not get the lecture again on every launch. It resolves however
// the sheet is dismissed, so nothing here can hang: a primer that failed to
// resolve would hold BLE startup behind it forever, which is a far worse bug
// than the one it exists to prevent.
//
// Android-only in practice, and correctly so: hasBlePermissions() resolves
// true on iOS, where CoreBluetooth prompts on first use and carries our own
// NSBluetoothAlwaysUsageDescription string. That prompt IS the primer there,
// and iOS has no location coupling to explain away. The sheet still renders
// correctly on iOS if it is ever shown; it simply is not needed.
const settings = useSettingsStore.getState();
if (!settings.permissionPrimerSeen && !(await hasBlePermissions())) {
settings.markPermissionPrimerSeen();
await showPermissionPrimer();
}
const perm = await ensureBlePermissions();
// Record WHY the mesh cannot run, not merely that it cannot.
//
// A single "granted" boolean collapsed three situations that need three
// different responses: denied but re-askable, denied for good, and the
// Android 12+ case where the user picked "Approximate" so the app holds
// BLUETOOTH_SCAN and still gets no scan results. All three used to render as
// "Bluetooth permission needed", and only the first is fixed by granting
// Bluetooth. The controller re-reads the device on its first pass and will
// correct this either way; setting it here means the banner is right during
// the very first frames rather than after the first reconcile.
const blocker = useMeshStateStore.getState().setBleBlocker;
// Recorded separately, and BEFORE the mesh starts, so the controller's first
// reconcile refines the platform's coarse "denied" into the permanent form
// rather than offering a prompt that will never appear.
useMeshStateStore.getState().setBlePermissionBlocked(perm.blockedForever);
if (perm.granted) {
blocker("starting");
} else if (perm.blockedForever) {
blocker("permission-blocked");
} else if (perm.needsPreciseLocation) {
blocker("precise-location");
} else {
blocker("permission-denied");
}
if (!perm.granted && perm.blockedForever) {
// The OS will not prompt again, so the only way out is Settings. Same
// deep-linked dialog the camera and photo flows use, rather than a
// dead-end box reciting a path to tap through.
showBlockedAlert({
label: t("permission.bluetooth.label"),
purpose: t("permission.bluetooth.purpose"),
});
}
// A denial that can still be re-asked gets no dialog. The Mesh banner already
// says what is wrong and carries the button that fixes it, and stacking a
// modal on top of that is two things to dismiss for one problem.
// Apply the persisted Tor preference BEFORE the mesh starts, so the very first
// relay pool is built on the Tor socket (never leaking the clear net for a Tor
// user). No-op when Tor is off or unavailable.
primeTorRoutingOnStartup();
initMeshService(identity, nickname);
// Open the encrypted ecash store and settle anything left in flight. Proofs
// live in an AES-256 MMKV file whose key is in the Keychain/Keystore, so this
// is async and must happen before the Wallet tab can spend. Failure leaves
// the wallet locked rather than silently falling back to plaintext storage.
//
// `reconcile` then finishes the work a previous session could not: Lightning
// deposits whose invoice was paid after the app was closed, and reserved
// sends whose recipient has since redeemed them.
void (async () => {
const unlocked = await initWalletService();
if (!unlocked) return;
// Settling leftovers is a background chore, not a prerequisite. It walks
// every pending deposit and reserved send, one mint round trip at a time,
// so on a bad network it can take minutes. Awaiting it here would hold the
// nutzap watcher behind it and quietly drop incoming payments for that
// whole window. Nothing below depends on its result.
void reconcile().catch(() => {
// Offline, or the mint is down. Retried on the next launch.
});
const client = getMeshService()?.getNostrClient();
const privKey = getMeshService()?.getNostrPrivKey();
const pubKey = getMeshService()?.getNostrPubKeyHex();
if (!client || !privKey || !pubKey) return;
// Tell the network how to pay us (NIP-61 kind 10019), then watch for
// incoming nutzaps. Both are no-ops without a mint configured.
await publishOwnNutzapInfo({
client,
privKey,
relays: client.activeRelays,
});
// Installed through the shared handle rather than a local, so a panic wipe
// can stop it too - see nutzap-watcher-handle.ts.
setNutzapWatcher(
startNutzapWatcher({
myPubkey: pubKey,
client,
onRedeemed: (amount, unit, from) => {
showAlert(
t("wallet.nutzap.received_title", {
amount: formatNumber(amount),
unit,
}),
t("wallet.nutzap.received_body", { from: from.slice(0, 12) }),
);
},
}),
);
})();
// The mesh always starts Online (advertising + scanning), so keep the chosen
// presence in step, in case a prior session left it Away/Invisible.
useMeshStateStore.getState().setPresenceStatus("online");
// Remaining permission prompts, sequenced one after another so the OS never
// shows two at once (concurrent prompts raced on a fresh install: the
// notification prompt got swallowed and sometimes crashed). Runs after the
// mesh has started so BLE is never held up waiting on any of them.
// 1. Location powers the geohash public channels (#block…#region): without a
// position the app cannot resolve its cell, so they stay BLE-only and
// never show internet participants or bitchat traffic. On Android the BLE
// grant above already covers it; on iOS this is the only place it is asked.
// 2. Notifications, last, so it lands cleanly after the others.
void (async () => {
const granted =
(await hasLocationPermission()) || (await requestLocationPermission());
// Reflect the grant on the Mesh banner: without it the location channels
// are unavailable, and saying so beats a silently empty channel list.
useMeshStateStore.getState().setLocationGranted(granted);
if (granted) getMeshService()?.refreshGeoChannels();
await requestNotificationPermission();
})();
}
// Carry out the fix a Mesh banner offers.
//
// The banner names an intent; this turns it into the platform call. Keeping the
// two apart means the store stays free of native imports and Linking, and the
// copy for a blocker lives next to the logic that decides the blocker applies.
//
// Every branch ends by re-reading the device rather than assuming the fix
// worked: the user may cancel the Bluetooth dialog, or wander out of Settings
// without changing anything, and a banner that clears itself optimistically is
// how you end up with a green UI over a dead radio.
async function handleBannerAction(
kind: BannerAction,
nickname: string,
): Promise<void> {
switch (kind) {
case "resume":
applyPresence("online", nickname);
return;
case "enable-bluetooth": {
// Android can show the system enable dialog in place. iOS cannot - Apple
// provides no API to turn the radio on from inside an app - so it
// resolves false and we fall back to Settings rather than offering a
// button that does nothing.
const enabled = await AirhopBLE.requestEnableBluetooth().catch(
() => false,
);
if (!enabled) await Linking.openSettings().catch(() => undefined);
break;
}
case "open-location-settings": {
const opened = await AirhopBLE.openLocationSettings().catch(() => false);
if (!opened) await Linking.openSettings().catch(() => undefined);
break;
}
case "open-app-settings":
await Linking.openSettings().catch(() => undefined);
break;
case "open-background-limits": {
// Deep-link straight into the OEM's own background/autostart screen -
// Xiaomi's autostart list, Samsung's sleeping-apps list, and so on. There
// is no common Android surface for this, which is exactly why describing
// where to tap does not work: the path differs on every skin.
//
// Acknowledged either way. The whitelist itself is not readable back, so
// "did it work" is unanswerable; taking the user to the right screen is
// the whole of what the app can do, and repeating the advice afterwards
// would be nagging someone who has already acted on it.
useSettingsStore.getState().markBackgroundLimitsAcknowledged();
const uri = getBatteryOptimizationSettingsURI();
if (uri !== null) {
const opened = await Linking.openURL(uri).then(
() => true,
() => false,
);
// OEM deep links are undocumented and disappear between skin versions.
// The app's own settings page always exists, and battery controls are
// reachable from it, so it is a landing place rather than a dead end.
if (!opened) await Linking.openSettings().catch(() => undefined);
} else {
await Linking.openSettings().catch(() => undefined);
}
// Nothing about the radios changed, so there is nothing to re-read.
return;
}
}
getMeshService()?.retryRadios();
}
// ---------------------------------------------------------------------------
// Root component
// ---------------------------------------------------------------------------
export default function App(): React.JSX.Element {
const Colors = useThemeColors();
const styles = useMemo(() => createStyles(Colors), [Colors]);
const resolvedTheme = useResolvedTheme();
// App is the root, so subscribing here is what makes a language change
// re-render every screen below it, the same way useThemeColors does for a
// theme change. It is also why the formatters in utils/format.ts can read
// the language at call time instead of each being a hook.
const T = useT();
const TP = useTPlural();
// appReady guards against a flash of the welcome screen on every launch.
// The identity check is async, so we render nothing until it resolves.
const [appReady, setAppReady] = useState(false);
// Load JetBrains Mono in the background so it is ready the instant a user
// picks it under Appearance. Startup is NOT gated on it: the app defaults to
// the system monospace, so there is nothing to wait for and a missing/unlinked
// font can never delay or hang launch. The mono bits switch over live once it
// is loaded (see useThemeColors).
useFonts({ JetBrainsMono_400Regular, FiraCode_400Regular });
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep | null>(
null,
);
const [generatedPeerID, setGeneratedPeerID] =
useState<string>(FALLBACK_PEER_ID);
const [tab, setTab] = useState<MainTab>("mesh");
// Bumped whenever the Profile tab is tapped, so tapping "You" while inside a
// sub-screen (About, Version, ...) pops ProfileScreen back to its root, the
// same way tapping Chats returns to the conversation list.
const [profileResetSignal, setProfileResetSignal] = useState(0);
// Profile owns its own settings stack (sections are early returns, not
// routes), so the shell learns its depth from the screen and pops it by
// bumping a counter. See ProfileScreen onCanGoBackChange / popSignal.
const [profileCanGoBack, setProfileCanGoBack] = useState(false);
const [profilePopSignal, setProfilePopSignal] = useState(0);
const [chatSubTab, setChatSubTab] = useState<ChatSubTab>("channels");
const [chatView, setChatView] = useState<ChatView>({ kind: "list" });
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<TextInput>(null);
// Which message a search result should scroll a thread to on open.
const [messageTarget, setMessageTarget] = useState<MessageTarget | null>(
null,
);
// Counter-based trigger: incrementing opens the "start something new" chooser.
const [startNewTrigger, setStartNewTrigger] = useState(0);
const [meshViewMode, setMeshViewMode] = useState<"list" | "radar">("radar");
// Counter-based trigger: incrementing tells PeerList to open the add-contact scanner.
const [meshAddCounter, setMeshAddCounter] = useState(0);
// Counter-based trigger: incrementing (with an action) tells WalletScreen
// to open the matching modal, same pattern as startNewTrigger/meshAddCounter.
const [walletAction, setWalletAction] = useState<WalletAction | null>(null);
// Whether there is any ecash at all to spend. Selected as a boolean so the
// header only re-renders when the answer flips, not on every proof change.
const hasSpendableEcash = useWalletStore((s) =>
Object.values(s.proofs).some((list) => list.length > 0),
);
const [walletActionTrigger, setWalletActionTrigger] = useState(0);
// Notification center (bell) visibility, and the count of unseen activity
// that badges the bell. Subscribing to entries keeps the badge live.
const [showActivity, setShowActivity] = useState(false);
const activityUnseen = useActivityStore((s) =>
s.entries.reduce((n, e) => (e.seen ? n : n + 1), 0),
);
const {
setActiveChannel,
unreadCounts,
mutedChannels,
markChannelRead,
setLastThread,
} = useChatStore();
const meshBanners = useMeshBanners();
const primerVisible = usePrimerStore((s) => s.visible);
// On mount: check for an existing persisted identity. If found, skip
// onboarding and start the BLE mesh service immediately.
useEffect(() => {
loadIdentity()
.then((existing) => {
if (existing) {
setGeneratedPeerID(existing.peerID);
setOnboardingStep(null);
// Android can destroy the Activity while the foreground service keeps
// the process (and the JS runtime, and the mesh) alive. Reopening then
// remounts this component with everything already set up, and tearing
// that down just to rebuild it is what made a reopen feel like a hang:
// a full stop() says goodbye to every peer, drops the relay pool, and
// bounces the foreground service, all to arrive back where we started.
//
// So a cold start is exactly: no mesh at all, or one belonging to a
// different identity (a wipe re-onboarded as someone else). An
// existing mesh is left alone whatever state it is in - including
// stopped, because the only things that stop it are the user choosing
// Away and the notification's "Stop mesh". Restarting it here would
// undo a decision they just made, from an event they didn't trigger.
const existingMesh = getMeshService();
if (existingMesh?.peerID !== existing.peerID) {
void startMeshWithPermissions(
existing,
peerIDToUsername(existing.peerID),
);
}
// Restore the last open thread after an OS-kill-and-reopen. The
// channel name is persisted by setLastThread and cleared by closeThread.
const { lastThread } = useChatStore.getState();
if (lastThread) {
if (lastThread.startsWith("dm:")) setChatSubTab("dms");
setChatView({ kind: "thread", channel: lastThread });
}
} else {
// First launch: show the welcome/onboarding flow.
setOnboardingStep("welcome");
}
setAppReady(true);
})
.catch(() => {
// EncryptedStorage unavailable (e.g. simulator without secure enclave).
// Fall through to onboarding so identity can be generated and stored later.
setOnboardingStep("welcome");
setAppReady(true);
});
}, []);
// Aggregate unread for the badges, muted conversations excluded (their
// per-row count still shows; they just do not shout at the app level). Split
// by the "dm:" prefix (see chat-store) so the Channels/Direct segments show
// which side the activity is on, from one source of truth.
const chatsUnread = sumUnread(unreadCounts, mutedChannels);
const channelsUnread = sumUnread(
unreadCounts,
mutedChannels,
(channel) => !channel.startsWith("dm:"),
);
const dmsUnread = sumUnread(unreadCounts, mutedChannels, (channel) =>
channel.startsWith("dm:"),
);
// Derived state computed before any early return so hook call order is stable.
const isInThread =
onboardingStep === null && tab === "chats" && chatView.kind === "thread";
// The thread on screen, which is not always the one that was asked for: a DM
// keyed by a Nostr pubkey is folded into its peer-ID thread when that peer's
// announce arrives, which can happen while it is open. Derived rather than
// synced into state, so no frame renders a channel that is already gone.
const channelRedirects = useChatStore((s) => s.channelRedirects);
const openThread =
chatView.kind === "thread"
? (channelRedirects[chatView.channel] ?? chatView.channel)
: "";
const isSearching =
onboardingStep === null && tab === "chats" && chatView.kind === "search";
const username = peerIDToUsername(generatedPeerID);
// Android hardware/gesture back button: exit a message thread, or cancel
// an in-progress search. Otherwise back would fall through to minimizing
// the app while either is open.
// Reached through a ref, so the subscription depends only on whether it should
// exist rather than on a function identity that changes every render. Listing
// `handleCancelSearch` itself would tear down and re-register the OS back
// handler on each render; capturing render-zero's copy would eventually cancel
// a search using stale state.
const cancelSearchRef = useRef(handleCancelSearch);
const closeThreadRef = useRef(closeThread);
useEffect(() => {
cancelSearchRef.current = handleCancelSearch;
closeThreadRef.current = closeThread;
});
useEffect(() => {
if (!isInThread && !isSearching) return;
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
if (isInThread) {
setChatView({ kind: "list" });
} else {
cancelSearchRef.current();
}
return true; // prevent default (close app)
});
return () => sub.remove();
}, [isInThread, isSearching]);
// Retire attachments past their retention window, once per launch.
//
// Runs unconditionally, before any identity check: expired media belongs to
// nobody, and a launch that ends at the onboarding screen is exactly the
// launch after a wipe, where leftover files matter most. Deliberately not on
// a timer, since nothing accumulates while the app is closed.
//
// Wrapped because the cache directory may be unreadable on a device with no
// storage left, and a failed sweep must not stop the app from opening.
//
// Read once at launch rather than subscribed to: shortening the window takes
// effect on the next start, which is when the sweep runs anyway. Lengthening
// it cannot bring anything back, since the files are already gone.
useEffect(() => {
try {
const days = useSettingsStore.getState().mediaRetentionDays;
sweepExpiredAttachments(Date.now(), days * 24 * 60 * 60 * 1000);
} catch {
// Unreadable cache directory. Retried next launch.
}
}, []);
// Claim an audible audio session once. Otherwise it is the OS default, which
// on iOS is a category the ring/silent switch mutes, so a voice note played
// before anything has been recorded is silent. Recording borrows the session
// and hands it back. See services/audio-session.
useEffect(() => {
void setAudioForPlayback().catch(() => {
// No audio hardware, or a call holds the session. Nothing to say here.
});
}, []);
// Transfer watchdog: promote quiet transfers to "stalled", then "failed", on a
// wall clock. This is what turns a bar frozen mid-progress (peer out of range)
// into an honest "waiting for peer" and eventually a themed failure, rather
// than a silent lie. Only ticks while transfers exist, so it costs nothing at
// rest. See transfer-store reconcile().
const hasTransfers = useTransferStore(
(s) => Object.keys(s.transfers).length > 0,
);
useEffect(() => {
if (!hasTransfers) return;
const handle = setInterval(
() => useTransferStore.getState().reconcile(),
3000,
);
return () => clearInterval(handle);
}, [hasTransfers]);
// Local message notifications. There is no push server: the running process
// raises these itself the instant a message lands over any transport (BLE,
// WiFi, courier, Nostr), so a backgrounded app still alerts. On Android the
// mesh foreground service keeps the process alive to make that possible; on
// iOS it fires whenever the OS has the app awake. See notification-service.
// The ref lets a notification tap open the right thread without re-registering
// the handler on every render.
const openChannelRef = useRef<(channel: string) => void>(() => undefined);
// Same trick for the tab navigator, which a tapped nearby-peers notice uses
// to land on Mesh.
const navigateToTabRef = useRef<(tab: MainTab) => void>(() => undefined);
// Whether the app is on screen. Read by the "what is being read" effect
// below, because a thread the user has walked away from is not being read.
const [appActive, setAppActive] = useState(
AppState.currentState === "active",
);
// Foreground/background tracking, so a banner is only raised when the user is
// not already looking at the app.
useEffect(() => {
// No setAppActive here: the useState initialiser above already read
// AppState, and the listener below carries every change after it.
setNotificationsAppActive(AppState.currentState === "active");
// Re-read the permissions whenever we come to the foreground: the user may
// have changed either in system Settings while we were backgrounded, and
// coming back is the only signal we get. Bluetooth adapter changes already
// arrive as native events, so they need no polling here.
//
// Both are checks, never requests: prompting someone who just walked back
// into the app would be ambushing them.
const syncPermissions = (): void => {
void hasLocationPermission().then((granted) =>
useMeshStateStore.getState().setLocationGranted(granted),
);
// Re-check the radios unconditionally.
//
// This used to compare the BLE permission against the last known value
// and only act when it had changed, which meant every blocker that is not
// a permission - Bluetooth switched off, location services switched off,
// a grant that had not yet reached the Bluetooth stack - came back to a
// mesh that had decided nothing needed doing. The controller is a
// reconciler: it reads the device itself and issues only the calls that
// change something, so calling it on every resume is both correct and
// free.
getMeshService()?.retryRadios();
};
syncPermissions();
// Tell the mesh which side of the screen we are on, for both states: the
// radio controller turns the scan rate down when backgrounded, and needs
// the leaving edge as much as the returning one.
getMeshService()?.setAppForeground(AppState.currentState === "active");
const sub = AppState.addEventListener("change", (next) => {
setAppActive(next === "active");
setNotificationsAppActive(next === "active");
getMeshService()?.setAppForeground(next === "active");
if (next !== "active") {
// Chat persistence is throttled, so leaving the foreground is the last
// safe moment to force whatever is still inside that window to disk.
// The OS can stop giving us cycles at any point after this.
flushChatPersistence();
}
if (next === "active") {
syncPermissions();
// The Mesh tab is a tap away now, so a "someone nearby" from earlier is
// stale the moment the app is open.
void dismissNearbyNotification();
// A trip away from Airhop is how Orbot gets stopped, so returning is
// when a "Tor on" claim is most likely to have gone stale. Cheap and
// Android-only; iOS owns Arti and hears about it directly.
void revalidateTorRouting();
// Leaving the app is also how a Lightning invoice gets paid: the user
// switches to their Lightning wallet, pays, and comes back. Until this,
// the deposit only landed if the deposit sheet happened to still be open
// (it polls) or the user thought to pull to refresh, so coming back to a
// balance that had not moved was the normal experience of paying.
//
// Throttled and deduplicated inside the service, and returns
// immediately: a pass is minutes of mint round trips and must never be
// awaited on the foreground path. Also settles a melt whose response was
// lost and a reserved send the recipient has since redeemed.
reconcileIfDue();
}
});
return () => sub.remove();
}, []);
// "Stop mesh" on the Android background notification. The native service
// hands it here rather than tearing things down itself, so stopping from the
// notification and stopping from the Status picker are the same action: the
// radios come down, the gateway switches off, and presence lands on Away - so
// reopening the app shows "Mesh paused · You're away" with a way back, not a
// dead mesh wearing a green dot.
useEffect(() => {
const sub = DeviceEventEmitter.addListener(
"AirhopBLE.meshStopRequested",
() => applyPresence("away", username),
);
return () => sub.remove();
}, [username]);
// One-time setup, deferred until past onboarding so the OS permission prompt
// lands on the mesh screen in context (alongside the Bluetooth/Location
// prompt) rather than on the welcome screen. Wires the inbound observer
// (raise a notification) and the tap handler (open the conversation).
//
// The appReady guard matters: onboardingStep starts as null (unknown) before
// loadIdentity resolves, so without it a brand-new install would run this
// during that initial window and fire the notification prompt at launch,
// before onboarding even appears.
useEffect(() => {
if (!appReady || onboardingStep !== null) return;
setNotificationNavigator((channel) => openChannelRef.current(channel));
const unsubscribe = subscribeInboundMessages((msg) => {
const chat = useChatStore.getState();
const isMuted = chat.mutedChannels.includes(msg.channel);
// Being @-mentioned overrides mute, the way every major chat app treats a
// mention: even a muted channel pings and logs a bell entry when it is you
// being addressed by name.
const mentionsMe = !msg.isSystem && mentionsNickname(msg.text, username);
// A muted conversation otherwise stays silent: no system notification, no
// haptic, and no bell entry. Its unread still shows on its own row.
if (!isMuted || mentionsMe) {
void handleInboundMessage(
msg,
sumUnread(chat.unreadCounts, chat.mutedChannels),
);
// Bell history logs real notifications only: skip the conversation you
// are actively reading (that is not a notification), same activeChannel
// rule the unread count uses.
if (!msg.isSystem && msg.channel !== chat.activeChannel) {
useActivityStore.getState().record({
id: msg.id,
channel: msg.channel,
isDM: msg.channel.startsWith("dm:"),
senderID: msg.senderID,
senderNickname: msg.senderNickname,
preview: messagePreviewText(msg),
timestampMs: msg.timestampMs,
});
}
}
});
// Nearby peers, while nobody is looking. The mesh keeps scanning with the
// app in the background (Android's foreground service), so this is a real
// event the user would otherwise never learn about. Counted by the store's
// own reachability rule rather than by map size, because a peer who left
// without a LEAVE lingers in the map: measure both sides of the change with
// one clock so the comparison is honest. Everything about when it is worth
// a notification is in shouldNotifyNearby.
setMeshNavigator(() => navigateToTabRef.current("mesh"));
const unsubscribePeers = usePeerStore.subscribe((state, prev) => {
const nowMs = Date.now();
void handleNearbyPeers(
countReachablePeers(state.peers, nowMs),
countReachablePeers(prev.peers, nowMs),
);
});
void configureNotifications();
return () => {
unsubscribe();
unsubscribePeers();
};
}, [appReady, onboardingStep, username]);
// The single answer to "which conversation is the user reading right now".
//
// Every consumer of that fact reads it from here: the chat store decides
// whether an arriving message counts as unread, and the notifier decides
// whether to buzz and which delivered notification to clear.
//
// Backgrounded counts as NOT reading, even with a thread still on screen.
// Without that the app contradicts itself: the OS notification fires (it is
// gated on foreground alone, correctly), while the chat store sees the thread
// as active and files the message as already read, so the user taps the
// notification and finds no unread badge and no app icon count. Every
// mainstream messenger treats leaving the app as leaving the conversation.
//
// Coming back marks it read again, which is the same rule as opening it.
useEffect(() => {
const reading = appActive ? openThread : "";
setActiveChannel(reading);
setNotificationsActiveChannel(reading);
if (reading) {
markChannelRead(reading);
void dismissNotificationsFor(reading);
}
}, [openThread, appActive, setActiveChannel, markChannelRead]);
// Keep the app icon badge in step with total unread across channels and DMs.
useEffect(() => {
void setAppBadgeCount(chatsUnread);
}, [chatsUnread]);
// Airhop deep links: airhop://channel/<name> and airhop://peer/<id>. Tapping a
// shared invite opens the app here. Joining is user-initiated (you tapped the
// link), so adding the channel / opening the DM is legitimate consent, not the
// stranger-injection the mesh guards against. Deferred until past onboarding,
// so a cold-start link waits for the identity to load.
useEffect(() => {
if (!appReady || onboardingStep !== null) return;
const handle = (url: string | null): void => {
if (url === null) return;
const link = parseAirhopLink(url);
if (link === null) return;
// What the link does lives in services/link-router, shared with the Join
// sheet's paste field, so a tapped link and a pasted one behave the same.
const channel = applyAirhopLink(link);
if (channel !== null) openChannelRef.current(channel);
};
void Linking.getInitialURL().then(handle);
const sub = Linking.addEventListener("url", ({ url }) => handle(url));
return () => sub.remove();
}, [appReady, onboardingStep]);
function triggerWalletAction(action: WalletAction): void {
setWalletAction(action);
setWalletActionTrigger((c) => c + 1);
}
function openChannel(requested: string): void {
// A notification, a bell row or a restored last-thread can name a DM by
// the key it had before its owner was identified, so resolve it first.
const channel = useChatStore.getState().resolveChannel(requested);
setLastThread(channel);
// So returning to list view lands on whichever sub-tab this channel
// actually belongs to. That matters when opened from search, which spans
// both; a no-op when opened from the list itself (already the right tab).
setChatSubTab(channel.startsWith("dm:") ? "dms" : "channels");
setChatView({ kind: "thread", channel });
}
// Keep the notification tap handler pointed at the latest openChannel. In an
// effect, not during render: a render can be discarded or replayed, and the
// handler outlives both.
useEffect(() => {
openChannelRef.current = openChannel;
});
// Open the bell screen and mark its backlog seen, so the badge clears the way
// it does when you open any notifications list.
function openActivityCenter(): void {
useActivityStore.getState().markAllSeen();
setShowActivity(true);
}
// Tapping a notification-center row: close the sheet and jump to that thread.
function openChannelFromActivity(channel: string): void {
setShowActivity(false);
openChannel(channel);
}
// Same as openChannel, but also tells the thread which message to scroll
// to and flash, used when opening from a "Messages" search result.
function openChannelAtMessage(channel: string, messageId: string): void {
setMessageTarget((prev) => ({
channel,
messageId,
trigger: (prev?.trigger ?? 0) + 1,
}));
openChannel(channel);
}
function closeSearch(): void {
searchInputRef.current?.blur();
setSearchQuery("");
}
function handleSelectChatResult(channel: string): void {
closeSearch();
openChannel(channel);
}
function handleSelectMessageResult(channel: string, messageId: string): void {
closeSearch();
openChannelAtMessage(channel, messageId);
}
function handleCancelSearch(): void {
closeSearch();
setChatView({ kind: "list" });
}
function closeThread(): void {
setLastThread("");
setChatView({ kind: "list" });
}
// Single entry point for every tab change (tab bar tap, swipe gesture,
// deep-link-style jumps like opening a DM from Mesh).
const navigateToTab = useCallback(
(nextTab: MainTab, resetChatView = true): void => {
setTab(nextTab);
if (nextTab === "chats" && resetChatView) {
setChatView({ kind: "list" });
setSearchQuery("");
}
// Tapping the Profile tab always returns to its root sub-screen.
if (nextTab === "profile") {
setProfileResetSignal((n) => n + 1);
}
},
[],
);
// Keep the notification tap handler pointed at the latest navigateToTab, in an
// effect for the same reason as openChannelRef above.
useEffect(() => {
navigateToTabRef.current = navigateToTab;
});
function openDMFromMesh(channel: string): void {
setLastThread(channel);
setChatSubTab("dms");
navigateToTab("chats", false);
setChatView({ kind: "thread", channel });
}
// Jump to the conversation a transfer belongs to (from the global badge).
function openTransferChannel(channel: string): void {
setLastThread(channel);
setChatSubTab(channel.startsWith("dm:") ? "dms" : "channels");
navigateToTab("chats", false);
setChatView({ kind: "thread", channel });
}
// Somewhere to go back to within the current tab, and how to get there.
// Chats holds threads and search, Profile holds its settings sections; from
// inside one of those, stepping to the next tab loses the reader's place.
const canGoBackInTab =
isInThread || isSearching || (tab === "profile" && profileCanGoBack);
const goBackInTab = useCallback((): void => {
if (isInThread) {
closeThreadRef.current();
return;
}
if (isSearching) {
cancelSearchRef.current();
return;
}
setProfilePopSignal((n) => n + 1);
}, [isInThread, isSearching]);
// Swipe across the content area. Two behaviours, decided by where you are:
//
// at a tab's root step through tabs, in the order the tab bar shows them
// inside a section go back to its parent, and never change tab
//
// The back swipe is confined to a leading-edge strip, the width iOS gives its
// interactive pop. `hitSlop` keeps the gesture from ever seeing a mid-screen
// drag: a thread carries its own horizontal scrollers (the mention picker),
// and a full-width pan would take those touches and do nothing with them.
//
// activeOffsetX/failOffsetY keep this from hijacking vertical list scrolling:
// it only activates once the gesture is clearly more horizontal than vertical,
// and per-row Swipeable actions (channel/DM list) still win since they
// activate on a much smaller offset than the 60px threshold below.
const swipeGesture = useMemo(() => {
const pan = Gesture.Pan()
.activeOffsetX([-20, 20])
.failOffsetY([-15, 15])
.onEnd((event) => {
const passedThreshold =
Math.abs(event.translationX) > 60 || Math.abs(event.velocityX) > 600;
if (!passedThreshold) return;
// Forward in reading order, so the gesture matches RTL layouts.
const forward = isRTLLayout
? event.translationX > 0
: event.translationX < 0;
if (canGoBackInTab) {
if (!forward) runOnJS(goBackInTab)();
return;
}
const currentIndex = TABS.findIndex((t) => t.id === tab);
const target = forward
? TABS[currentIndex + 1]
: TABS[currentIndex - 1];
if (target) runOnJS(navigateToTab)(target.id, true);
});