@ethora/chat-component-rn 26.10.6 → 26.10.8

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.
Files changed (134) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/README.md +6 -3
  3. package/lib/commonjs/components/MessageBubble/Message.js +16 -6
  4. package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
  5. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +16 -2
  6. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  7. package/lib/commonjs/components/VideoCalls/callActions.js +32 -2
  8. package/lib/commonjs/components/VideoCalls/callActions.js.map +1 -1
  9. package/lib/commonjs/components/VideoCalls/systemCall.js +83 -0
  10. package/lib/commonjs/components/VideoCalls/systemCall.js.map +1 -0
  11. package/lib/commonjs/components/VideoCalls/useCallKeep.js +83 -66
  12. package/lib/commonjs/components/VideoCalls/useCallKeep.js.map +1 -1
  13. package/lib/commonjs/helpers/incomingCallFromPush.js +95 -21
  14. package/lib/commonjs/helpers/incomingCallFromPush.js.map +1 -1
  15. package/lib/commonjs/helpers/secureStoreRuntime.js +10 -1
  16. package/lib/commonjs/helpers/secureStoreRuntime.js.map +1 -1
  17. package/lib/commonjs/main.js +114 -0
  18. package/lib/commonjs/main.js.map +1 -1
  19. package/lib/commonjs/networking/api-requests/push.api.js +22 -7
  20. package/lib/commonjs/networking/api-requests/push.api.js.map +1 -1
  21. package/lib/commonjs/networking/callTokenStanza.js +6 -0
  22. package/lib/commonjs/networking/callTokenStanza.js.map +1 -1
  23. package/lib/commonjs/networking/xmpp/sendMessageReaction.xmpp.js +22 -6
  24. package/lib/commonjs/networking/xmpp/sendMessageReaction.xmpp.js.map +1 -1
  25. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  26. package/lib/commonjs/push/pushKeyStore.js +169 -0
  27. package/lib/commonjs/push/pushKeyStore.js.map +1 -0
  28. package/lib/commonjs/push/sealedBox.js +106 -0
  29. package/lib/commonjs/push/sealedBox.js.map +1 -0
  30. package/lib/commonjs/push/sealedPush.js +135 -0
  31. package/lib/commonjs/push/sealedPush.js.map +1 -0
  32. package/lib/commonjs/push/sealedPushTask.js +221 -0
  33. package/lib/commonjs/push/sealedPushTask.js.map +1 -0
  34. package/lib/commonjs/services/pushRegistration.js +12 -7
  35. package/lib/commonjs/services/pushRegistration.js.map +1 -1
  36. package/lib/commonjs/version.js +1 -1
  37. package/lib/module/components/MessageBubble/Message.js +16 -6
  38. package/lib/module/components/MessageBubble/Message.js.map +1 -1
  39. package/lib/module/components/VideoCalls/VideoCallOverlay.js +17 -3
  40. package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  41. package/lib/module/components/VideoCalls/callActions.js +29 -0
  42. package/lib/module/components/VideoCalls/callActions.js.map +1 -1
  43. package/lib/module/components/VideoCalls/systemCall.js +73 -0
  44. package/lib/module/components/VideoCalls/systemCall.js.map +1 -0
  45. package/lib/module/components/VideoCalls/useCallKeep.js +77 -64
  46. package/lib/module/components/VideoCalls/useCallKeep.js.map +1 -1
  47. package/lib/module/helpers/incomingCallFromPush.js +92 -22
  48. package/lib/module/helpers/incomingCallFromPush.js.map +1 -1
  49. package/lib/module/helpers/secureStoreRuntime.js +10 -1
  50. package/lib/module/helpers/secureStoreRuntime.js.map +1 -1
  51. package/lib/module/main.js +9 -0
  52. package/lib/module/main.js.map +1 -1
  53. package/lib/module/networking/api-requests/push.api.js +21 -6
  54. package/lib/module/networking/api-requests/push.api.js.map +1 -1
  55. package/lib/module/networking/callTokenStanza.js +6 -0
  56. package/lib/module/networking/callTokenStanza.js.map +1 -1
  57. package/lib/module/networking/xmpp/sendMessageReaction.xmpp.js +21 -6
  58. package/lib/module/networking/xmpp/sendMessageReaction.xmpp.js.map +1 -1
  59. package/lib/module/networking/xmppClient.js.map +1 -1
  60. package/lib/module/push/pushKeyStore.js +159 -0
  61. package/lib/module/push/pushKeyStore.js.map +1 -0
  62. package/lib/module/push/sealedBox.js +97 -0
  63. package/lib/module/push/sealedBox.js.map +1 -0
  64. package/lib/module/push/sealedPush.js +127 -0
  65. package/lib/module/push/sealedPush.js.map +1 -0
  66. package/lib/module/push/sealedPushTask.js +212 -0
  67. package/lib/module/push/sealedPushTask.js.map +1 -0
  68. package/lib/module/services/pushRegistration.js +12 -7
  69. package/lib/module/services/pushRegistration.js.map +1 -1
  70. package/lib/module/version.js +1 -1
  71. package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
  72. package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
  73. package/lib/typescript/components/VideoCalls/callActions.d.ts +6 -0
  74. package/lib/typescript/components/VideoCalls/callActions.d.ts.map +1 -1
  75. package/lib/typescript/components/VideoCalls/systemCall.d.ts +25 -0
  76. package/lib/typescript/components/VideoCalls/systemCall.d.ts.map +1 -0
  77. package/lib/typescript/components/VideoCalls/useCallKeep.d.ts +8 -23
  78. package/lib/typescript/components/VideoCalls/useCallKeep.d.ts.map +1 -1
  79. package/lib/typescript/helpers/incomingCallFromPush.d.ts +5 -13
  80. package/lib/typescript/helpers/incomingCallFromPush.d.ts.map +1 -1
  81. package/lib/typescript/helpers/secureStoreRuntime.d.ts +17 -3
  82. package/lib/typescript/helpers/secureStoreRuntime.d.ts.map +1 -1
  83. package/lib/typescript/main.d.ts +11 -0
  84. package/lib/typescript/main.d.ts.map +1 -1
  85. package/lib/typescript/networking/api-requests/push.api.d.ts +14 -2
  86. package/lib/typescript/networking/api-requests/push.api.d.ts.map +1 -1
  87. package/lib/typescript/networking/callTokenStanza.d.ts.map +1 -1
  88. package/lib/typescript/networking/xmpp/sendMessageReaction.xmpp.d.ts +13 -1
  89. package/lib/typescript/networking/xmpp/sendMessageReaction.xmpp.d.ts.map +1 -1
  90. package/lib/typescript/networking/xmppClient.d.ts +2 -4
  91. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  92. package/lib/typescript/push/pushKeyStore.d.ts +35 -0
  93. package/lib/typescript/push/pushKeyStore.d.ts.map +1 -0
  94. package/lib/typescript/push/sealedBox.d.ts +24 -0
  95. package/lib/typescript/push/sealedBox.d.ts.map +1 -0
  96. package/lib/typescript/push/sealedPush.d.ts +67 -0
  97. package/lib/typescript/push/sealedPush.d.ts.map +1 -0
  98. package/lib/typescript/push/sealedPushTask.d.ts +44 -0
  99. package/lib/typescript/push/sealedPushTask.d.ts.map +1 -0
  100. package/lib/typescript/services/pushRegistration.d.ts +8 -0
  101. package/lib/typescript/services/pushRegistration.d.ts.map +1 -1
  102. package/lib/typescript/types/models/xmpp.model.d.ts +2 -4
  103. package/lib/typescript/types/models/xmpp.model.d.ts.map +1 -1
  104. package/lib/typescript/types/types.d.ts +6 -0
  105. package/lib/typescript/types/types.d.ts.map +1 -1
  106. package/lib/typescript/version.d.ts +1 -1
  107. package/package.json +11 -1
  108. package/plugins/notification-service/NotificationService.swift +113 -0
  109. package/plugins/notification-service/SealedBox.swift +355 -0
  110. package/plugins/notification-service/check/gen-vectors.mjs +24 -0
  111. package/plugins/notification-service/check/main.swift +37 -0
  112. package/plugins/notification-service/check/run.sh +12 -0
  113. package/plugins/notification-service/check/vectors.json +1 -0
  114. package/plugins/withNotificationServiceExtension.js +70 -18
  115. package/src/components/MessageBubble/Message.tsx +16 -5
  116. package/src/components/VideoCalls/VideoCallOverlay.tsx +18 -2
  117. package/src/components/VideoCalls/callActions.ts +28 -0
  118. package/src/components/VideoCalls/systemCall.ts +109 -0
  119. package/src/components/VideoCalls/useCallKeep.ts +89 -97
  120. package/src/helpers/incomingCallFromPush.ts +97 -26
  121. package/src/helpers/secureStoreRuntime.ts +21 -3
  122. package/src/main.ts +38 -0
  123. package/src/networking/api-requests/push.api.ts +29 -4
  124. package/src/networking/callTokenStanza.ts +10 -0
  125. package/src/networking/xmpp/sendMessageReaction.xmpp.ts +38 -6
  126. package/src/networking/xmppClient.ts +2 -2
  127. package/src/push/pushKeyStore.ts +163 -0
  128. package/src/push/sealedBox.ts +104 -0
  129. package/src/push/sealedPush.ts +138 -0
  130. package/src/push/sealedPushTask.ts +221 -0
  131. package/src/services/pushRegistration.ts +25 -5
  132. package/src/types/models/xmpp.model.ts +2 -1
  133. package/src/types/types.ts +6 -0
  134. package/src/version.ts +1 -1
@@ -1,15 +1,21 @@
1
1
  /**
2
- * Expo config plugin — adds an iOS Notification Service Extension that shows
3
- * the attachment thumbnail (photo / video preview) in chat pushes.
4
- *
5
- * The push service sends `previewUrl` with `mutable-content: 1`. Android
6
- * renders the picture by itself; iOS only does it through an extension
7
- * target inside the app, which downloads the file and attaches it
8
- * (plugins/notification-service/NotificationService.swift).
2
+ * Expo config plugin — adds an iOS Notification Service Extension for Ethora
3
+ * chat pushes. The extension (plugins/notification-service/NotificationService.swift)
4
+ * - decrypts encrypted (sealed) pushes with the device key the app keeps in
5
+ * the keychain and shows the real title and text in place of the
6
+ * "New message" placeholder;
7
+ * - shows the attachment thumbnail (photo / video preview) the push service
8
+ * sends as `previewUrl`.
9
9
  *
10
10
  * An extension is a separate target signed with the app, so it cannot ship
11
11
  * inside the npm package as code: this plugin writes it into ios/ on every
12
12
  * `expo prebuild` (the generated ios/ folder is gitignored in Expo apps).
13
+ * For the key to be readable from the extension, the app and the extension
14
+ * share a keychain access group (`$(AppIdentifierPrefix)<bundle id>`, the
15
+ * app's own default group, listed first so items the app writes without a
16
+ * group — expo-secure-store's — land in it). The sealed box is opened in
17
+ * plain Swift (SealedBox.swift: CryptoKit for X25519, the rest ported from
18
+ * tweetnacl), so the extension needs no pod.
13
19
  *
14
20
  * Host app.json:
15
21
  * "plugins": ["@ethora/chat-component-rn"]
@@ -23,25 +29,39 @@
23
29
 
24
30
  const fs = require('fs');
25
31
  const path = require('path');
26
- const { withDangerousMod, withXcodeProject } = require('expo/config-plugins');
32
+ const { withDangerousMod, withEntitlementsPlist, withXcodeProject } = require('expo/config-plugins');
27
33
 
28
34
  const TARGET_NAME = 'EthoraNotificationService';
29
35
  const SOURCE_DIR = path.join(__dirname, 'notification-service');
30
- const FILES = ['NotificationService.swift', 'Info.plist'];
36
+ const SOURCES = ['NotificationService.swift', 'SealedBox.swift'];
37
+ const FILES = [...SOURCES, 'Info.plist'];
38
+ const ENTITLEMENTS_FILE = `${TARGET_NAME}.entitlements`;
39
+ const KEYCHAIN_GROUPS_KEY = 'keychain-access-groups';
31
40
 
32
- const extensionBundleId = (config) => {
33
- const appBundleId = config.ios && config.ios.bundleIdentifier;
34
- if (!appBundleId) {
41
+ const appBundleId = (config) => {
42
+ const bundleId = config.ios && config.ios.bundleIdentifier;
43
+ if (!bundleId) {
35
44
  throw new Error(
36
45
  '[@ethora/chat-component-rn] ios.bundleIdentifier is required for the notification service extension'
37
46
  );
38
47
  }
39
- return `${appBundleId}.${TARGET_NAME}`;
48
+ return bundleId;
49
+ };
50
+
51
+ const extensionBundleId = (config) => `${appBundleId(config)}.${TARGET_NAME}`;
52
+
53
+ /** The keychain access group shared by the app and the extension. */
54
+ const keychainGroup = (config) => `$(AppIdentifierPrefix)${appBundleId(config)}`;
55
+
56
+ const withSharedGroupFirst = (groups, group) => {
57
+ const list = Array.isArray(groups) ? groups.filter((g) => g !== group) : [];
58
+ return [group, ...list];
40
59
  };
41
60
 
42
61
  // EAS Build creates signing credentials only for targets it knows about.
43
62
  const withEasAppExtension = (config) => {
44
63
  const bundleIdentifier = extensionBundleId(config);
64
+ const group = keychainGroup(config);
45
65
  config.extra = config.extra || {};
46
66
  config.extra.eas = config.extra.eas || {};
47
67
  config.extra.eas.build = config.extra.eas.build || {};
@@ -49,12 +69,42 @@ const withEasAppExtension = (config) => {
49
69
  const ios = (config.extra.eas.build.experimental.ios =
50
70
  config.extra.eas.build.experimental.ios || {});
51
71
  const extensions = (ios.appExtensions = ios.appExtensions || []);
52
- if (!extensions.some((ext) => ext.targetName === TARGET_NAME)) {
53
- extensions.push({ targetName: TARGET_NAME, bundleIdentifier, entitlements: {} });
72
+ let entry = extensions.find((ext) => ext.targetName === TARGET_NAME);
73
+ if (!entry) {
74
+ entry = { targetName: TARGET_NAME, bundleIdentifier, entitlements: {} };
75
+ extensions.push(entry);
54
76
  }
77
+ entry.entitlements = entry.entitlements || {};
78
+ entry.entitlements[KEYCHAIN_GROUPS_KEY] = withSharedGroupFirst(
79
+ entry.entitlements[KEYCHAIN_GROUPS_KEY],
80
+ group
81
+ );
55
82
  return config;
56
83
  };
57
84
 
85
+ // The app's side of the shared keychain group (Keychain Sharing capability).
86
+ const withAppKeychainGroup = (config) =>
87
+ withEntitlementsPlist(config, (cfg) => {
88
+ const group = keychainGroup(cfg);
89
+ cfg.modResults[KEYCHAIN_GROUPS_KEY] = withSharedGroupFirst(
90
+ cfg.modResults[KEYCHAIN_GROUPS_KEY],
91
+ group
92
+ );
93
+ return cfg;
94
+ });
95
+
96
+ const entitlementsPlist = (group) => `<?xml version="1.0" encoding="UTF-8"?>
97
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
98
+ <plist version="1.0">
99
+ <dict>
100
+ <key>${KEYCHAIN_GROUPS_KEY}</key>
101
+ <array>
102
+ <string>${group}</string>
103
+ </array>
104
+ </dict>
105
+ </plist>
106
+ `;
107
+
58
108
  // Copied on every prebuild so an SDK update reaches the app.
59
109
  const withExtensionFiles = (config) =>
60
110
  withDangerousMod(config, [
@@ -65,6 +115,7 @@ const withExtensionFiles = (config) =>
65
115
  for (const file of FILES) {
66
116
  fs.copyFileSync(path.join(SOURCE_DIR, file), path.join(dest, file));
67
117
  }
118
+ fs.writeFileSync(path.join(dest, ENTITLEMENTS_FILE), entitlementsPlist(keychainGroup(cfg)));
68
119
  return cfg;
69
120
  },
70
121
  ]);
@@ -97,14 +148,14 @@ const withExtensionTarget = (config, { appleTeamId } = {}) =>
97
148
  objects.PBXTargetDependency = objects.PBXTargetDependency || {};
98
149
  objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
99
150
 
100
- const group = project.addPbxGroup(FILES, TARGET_NAME, TARGET_NAME);
151
+ const group = project.addPbxGroup([...FILES, ENTITLEMENTS_FILE], TARGET_NAME, TARGET_NAME);
101
152
  const mainGroup = project.getFirstProject().firstProject.mainGroup;
102
153
  project.addToPbxGroup(group.uuid, mainGroup);
103
154
 
104
155
  // 'app_extension' also embeds the .appex into the app (Copy Files to
105
156
  // PlugIns) and makes the app depend on it.
106
157
  const target = project.addTarget(TARGET_NAME, 'app_extension', TARGET_NAME, bundleIdentifier);
107
- project.addBuildPhase(['NotificationService.swift'], 'PBXSourcesBuildPhase', 'Sources', target.uuid);
158
+ project.addBuildPhase(SOURCES, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
108
159
  project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
109
160
  project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
110
161
  targetUuid = target.uuid;
@@ -116,6 +167,7 @@ const withExtensionTarget = (config, { appleTeamId } = {}) =>
116
167
  PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`,
117
168
  INFOPLIST_FILE: `"${TARGET_NAME}/Info.plist"`,
118
169
  GENERATE_INFOPLIST_FILE: 'NO',
170
+ CODE_SIGN_ENTITLEMENTS: `"${TARGET_NAME}/${ENTITLEMENTS_FILE}"`,
119
171
  SWIFT_VERSION: appSettings.SWIFT_VERSION || '5.0',
120
172
  IPHONEOS_DEPLOYMENT_TARGET: appSettings.IPHONEOS_DEPLOYMENT_TARGET || '15.1',
121
173
  TARGETED_DEVICE_FAMILY: appSettings.TARGETED_DEVICE_FAMILY || '"1,2"',
@@ -132,6 +184,6 @@ const withExtensionTarget = (config, { appleTeamId } = {}) =>
132
184
  });
133
185
 
134
186
  const withNotificationServiceExtension = (config, props = {}) =>
135
- withExtensionTarget(withExtensionFiles(withEasAppExtension(config)), props);
187
+ withExtensionTarget(withExtensionFiles(withAppKeychainGroup(withEasAppExtension(config))), props);
136
188
 
137
189
  module.exports = withNotificationServiceExtension;
@@ -407,12 +407,23 @@ const Message: React.FC<MessageProps> = ({ message, isUser, isReply }) => {
407
407
 
408
408
  const handleReactionMessage = (emoji: string) => {
409
409
  if (!reactionsEnabled(config) || config?.disableInteractions) {return;}
410
- const sender = { firstName: user.firstName, lastName: user.lastName };
411
410
  const own = message.reaction?.[user.xmppUsername || '']?.emoji || [];
412
- const next = own.includes(emoji)
413
- ? own.filter((reaction) => reaction !== emoji)
414
- : [...own, emoji];
415
- client?.sendMessageReactionStanza(message.id, message.roomJid, next, sender);
411
+ const adding = !own.includes(emoji);
412
+ const next = adding ? [...own, emoji] : own.filter((reaction) => reaction !== emoji);
413
+ // The author gets a push about an added emoji (see sendMessageReaction);
414
+ // nothing for a removed one or for one's own message.
415
+ const author = message.user?.id || '';
416
+ client?.sendMessageReactionStanza(message.id, message.roomJid, next, {
417
+ firstName: user.firstName,
418
+ lastName: user.lastName,
419
+ photoURL: user.profileImage,
420
+ push: adding && !!author && author !== user.xmppUsername,
421
+ messageAuthor: author,
422
+ messageText: message.body,
423
+ isMediafile: message.isMediafile,
424
+ mimetype: message.mimetype,
425
+ fileName: message.originalName || message.fileName,
426
+ });
416
427
  };
417
428
 
418
429
  // Long-press → capture the bubble's on-screen bounding box. The actual
@@ -26,6 +26,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
26
26
  import { RootState } from '../../roomStore';
27
27
  import {
28
28
  acceptIncomingCall,
29
+ declineIncomingCall,
29
30
  resetCall,
30
31
  setCallError,
31
32
  } from '../../roomStore/callSlice';
@@ -64,6 +65,12 @@ const ACCEPT = '#10B981';
64
65
  // PSTN ring window.
65
66
  const OUTGOING_CALL_TIMEOUT_MS = 30000;
66
67
 
68
+ // Stop ringing an incoming call nobody cancelled. The caller gives up after
69
+ // OUTGOING_CALL_TIMEOUT_MS and says so, but that signal is lost when the
70
+ // call came in by push and the socket never came up (the system call
71
+ // screen of a VoIP push would otherwise ring on).
72
+ const INCOMING_CALL_TIMEOUT_MS = 45000;
73
+
67
74
  // Ring pattern for an incoming call: wait, buzz, pause, buzz... Android
68
75
  // loops this from index 0, iOS ignores the pattern and just vibrates.
69
76
  const RING_VIBRATION_PATTERN = [0, 700, 900];
@@ -507,8 +514,6 @@ const VideoCallOverlayInner: FC = () => {
507
514
  }, [enabled, canRenderSession, call.token, livekitUrl, call.kind]);
508
515
 
509
516
  // Give up on an unanswered outgoing dial. Unlike web (which only shows
510
- // the error), also tell the callee, who would otherwise keep ringing:
511
- // there is no incoming ring timeout on either platform.
512
517
  useEffect(() => {
513
518
  if (call.phase !== 'requesting' || call.direction !== 'outgoing') {
514
519
  return;
@@ -520,6 +525,17 @@ const VideoCallOverlayInner: FC = () => {
520
525
  return () => clearTimeout(timer);
521
526
  }, [call.phase, call.direction, call.startedAt, dispatch, t]);
522
527
 
528
+ useEffect(() => {
529
+ if (call.phase !== 'ringing-incoming' || call.direction !== 'incoming') {
530
+ return;
531
+ }
532
+ const elapsed = Date.now() - (call.startedAt || Date.now());
533
+ const timer = setTimeout(() => {
534
+ dispatch(declineIncomingCall());
535
+ }, Math.max(0, INCOMING_CALL_TIMEOUT_MS - elapsed));
536
+ return () => clearTimeout(timer);
537
+ }, [call.phase, call.direction, call.startedAt, dispatch]);
538
+
523
539
  // Buzz while an incoming call is ringing. The ring screen is already on
524
540
  // top, so this is the only alert needed when the app is in the
525
541
  // foreground; a backgrounded/killed app is handled by the call push.
@@ -7,6 +7,7 @@ import {
7
7
  sendCallStateSignal,
8
8
  } from '../../networking/callTokenStanza';
9
9
  import { translateKey } from '../../i18n/strings';
10
+ import { getGlobalXmppClient } from '../../utils/clientRegistry';
10
11
 
11
12
  /**
12
13
  * Call teardown that doesn't need a mounted component.
@@ -51,6 +52,33 @@ export const declineActiveCall = (): void => {
51
52
  store.dispatch(declineIncomingCall());
52
53
  };
53
54
 
55
+ const DECLINE_SIGNAL_WAIT_MS = 30_000;
56
+ const DECLINE_SIGNAL_POLL_MS = 500;
57
+
58
+ /**
59
+ * Decline the ringing call, telling the caller as soon as the socket is
60
+ * up. A call declined on the lock screen right after a push woke the app
61
+ * gets here while XMPP is still connecting, and the signal would be lost.
62
+ */
63
+ export const declineActiveCallWhenOnline = (): void => {
64
+ const call = store.getState().call;
65
+ const signal = {
66
+ peerXmppUsername: call.peerXmppUsername,
67
+ callId: call.callId,
68
+ roomBareName: call.roomBareName,
69
+ };
70
+ store.dispatch(declineIncomingCall());
71
+ const deadline = Date.now() + DECLINE_SIGNAL_WAIT_MS;
72
+ const attempt = () => {
73
+ if (getGlobalXmppClient()?.status === 'online') {
74
+ sendCallStateSignal('declined', signal);
75
+ return;
76
+ }
77
+ if (Date.now() < deadline) {setTimeout(attempt, DECLINE_SIGNAL_POLL_MS);}
78
+ };
79
+ attempt();
80
+ };
81
+
54
82
  /** `t` for code outside React, resolved by the same rule as useT. */
55
83
  export const translateCallString = (
56
84
  key: string,
@@ -0,0 +1,109 @@
1
+ import { Platform } from 'react-native';
2
+
3
+
4
+ export interface CallKeepModule {
5
+ setup: (options: any) => Promise<void>;
6
+ displayIncomingCall: (
7
+ uuid: string,
8
+ handle: string,
9
+ localizedCallerName?: string,
10
+ handleType?: string,
11
+ hasVideo?: boolean
12
+ ) => void;
13
+ endCall: (uuid: string) => void;
14
+ endAllCalls: () => void;
15
+ startCall?: (
16
+ uuid: string,
17
+ handle: string,
18
+ contactIdentifier?: string,
19
+ handleType?: string,
20
+ hasVideo?: boolean
21
+ ) => void;
22
+ answerIncomingCall?: (uuid: string) => void;
23
+ reportConnectingOutgoingCallWithUUID?: (uuid: string) => void;
24
+ reportConnectedOutgoingCallWithUUID?: (uuid: string) => void;
25
+ reportEndCallWithUUID?: (uuid: string, reason: number) => void;
26
+ setMutedCall?: (uuid: string, muted: boolean) => void;
27
+ addEventListener: (event: string, handler: (payload: any) => void) => void;
28
+ removeEventListener: (event: string) => void;
29
+ setAvailable?: (available: boolean) => void;
30
+ backToForeground?: () => void;
31
+ clearInitialEvents?: () => void;
32
+ }
33
+
34
+ let cachedCallKeep: CallKeepModule | null | undefined;
35
+
36
+ export const loadCallKeep = (): CallKeepModule | null => {
37
+ if (cachedCallKeep !== undefined) {
38
+ return cachedCallKeep;
39
+ }
40
+ try {
41
+ const { NativeModules } = require('react-native');
42
+ if (!NativeModules?.RNCallKeep) {
43
+ cachedCallKeep = null;
44
+ return cachedCallKeep;
45
+ }
46
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
47
+ const mod = require('react-native-callkeep');
48
+ cachedCallKeep = (mod?.default || mod) as CallKeepModule;
49
+ } catch {
50
+ cachedCallKeep = null;
51
+ }
52
+ return cachedCallKeep;
53
+ };
54
+
55
+ export const callKitUuid = (seed: string): string => {
56
+ let hash = 0x811c9dc5;
57
+ const bytes: number[] = [];
58
+ for (let i = 0; i < 32; i++) {
59
+ const ch = seed.charCodeAt(i % Math.max(1, seed.length)) + i;
60
+ hash ^= ch;
61
+ hash = Math.imul(hash, 0x01000193) >>> 0;
62
+ bytes.push(hash & 0xff);
63
+ }
64
+ const hex = bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
65
+ return [
66
+ hex.slice(0, 8),
67
+ hex.slice(8, 12),
68
+ '4' + hex.slice(13, 16),
69
+ ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16) + hex.slice(17, 20),
70
+ hex.slice(20, 32),
71
+ ].join('-');
72
+ };
73
+
74
+ export type SystemCallAction = 'answer' | 'end';
75
+
76
+ const SYSTEM_ACTION_TTL_MS = 2 * 60 * 1000;
77
+ const pendingActions = new Map<string, { action: SystemCallAction; at: number }>();
78
+
79
+ const key = (uuid: string) => uuid.trim().toLowerCase();
80
+
81
+ export const rememberSystemCallAction = (
82
+ uuid: string,
83
+ action: SystemCallAction
84
+ ): void => {
85
+ if (!uuid) {return;}
86
+ pendingActions.set(key(uuid), { action, at: Date.now() });
87
+ };
88
+
89
+ export const takeSystemCallAction = (uuid: string): SystemCallAction | null => {
90
+ const entry = pendingActions.get(key(uuid));
91
+ pendingActions.delete(key(uuid));
92
+ if (!entry || Date.now() - entry.at > SYSTEM_ACTION_TTL_MS) {return null;}
93
+ return entry.action;
94
+ };
95
+
96
+ export const endSystemIncomingCall = (callId: string | null | undefined): void => {
97
+ if (Platform.OS !== 'ios' || !callId) {return;}
98
+ const callKeep = loadCallKeep();
99
+ if (!callKeep?.reportEndCallWithUUID) {return;}
100
+ try {
101
+ callKeep.reportEndCallWithUUID(callKitUuid(callId), 3);
102
+ } catch {
103
+ }
104
+ };
105
+
106
+ export const __resetSystemCallForTests = (): void => {
107
+ pendingActions.clear();
108
+ cachedCallKeep = undefined;
109
+ };
@@ -4,10 +4,16 @@ import { useDispatch, useSelector } from 'react-redux';
4
4
  import { RootState, store } from '../../roomStore';
5
5
  import { acceptIncomingCall, declineIncomingCall } from '../../roomStore/callSlice';
6
6
  import { sendCallStateSignal } from '../../networking/callTokenStanza';
7
- import { terminateActiveCall } from './callActions';
7
+ import { declineActiveCallWhenOnline, terminateActiveCall } from './callActions';
8
8
  import { setCallMicEnabled, useCallSession } from './callSessionController';
9
9
  import { loadLiveKit } from './livekitRuntime';
10
10
  import { setCallSystemRinging } from './callUiStore';
11
+ import {
12
+ callKitUuid,
13
+ loadCallKeep,
14
+ rememberSystemCallAction,
15
+ takeSystemCallAction,
16
+ } from './systemCall';
11
17
 
12
18
  /**
13
19
  * Native incoming-call UI (Android ConnectionService / iOS CallKit) via
@@ -27,95 +33,15 @@ import { setCallSystemRinging } from './callUiStore';
27
33
  * Platform reality, stated plainly:
28
34
  * - Android: full value. A ConnectionService call shows the system
29
35
  * incoming-call screen over the lock screen.
30
- * - iOS: this only fires while the app is running. A CallKit screen from
31
- * a killed app requires a VoIP push over PushKit, which needs a
32
- * separate Apple VoIP certificate and a separate send path on the
33
- * backend (not FCM). Until that exists, iOS falls back to the regular
34
- * data push and the in-app ring screen.
36
+ * - iOS: this hook only runs while `<Chat>` is mounted. A CallKit screen
37
+ * from a killed app comes from the host: its PushKit handler reports
38
+ * the VoIP push to CallKit natively under `callKitUuid(callId)` and
39
+ * hands the payload to `handlePushPayload`. This hook then adopts that
40
+ * call (same UUID, CallKit answers "CallUUIDAlreadyExists" to our own
41
+ * report) and replays what the user did on it before JS was up.
35
42
  */
36
43
 
37
- interface CallKeepModule {
38
- setup: (options: any) => Promise<void>;
39
- displayIncomingCall: (
40
- uuid: string,
41
- handle: string,
42
- localizedCallerName?: string,
43
- handleType?: string,
44
- hasVideo?: boolean
45
- ) => void;
46
- endCall: (uuid: string) => void;
47
- endAllCalls: () => void;
48
- startCall?: (
49
- uuid: string,
50
- handle: string,
51
- contactIdentifier?: string,
52
- handleType?: string,
53
- hasVideo?: boolean
54
- ) => void;
55
- answerIncomingCall?: (uuid: string) => void;
56
- reportConnectingOutgoingCallWithUUID?: (uuid: string) => void;
57
- reportConnectedOutgoingCallWithUUID?: (uuid: string) => void;
58
- reportEndCallWithUUID?: (uuid: string, reason: number) => void;
59
- setMutedCall?: (uuid: string, muted: boolean) => void;
60
- addEventListener: (event: string, handler: (payload: any) => void) => void;
61
- removeEventListener: (event: string) => void;
62
- setAvailable?: (available: boolean) => void;
63
- backToForeground?: () => void;
64
- }
65
-
66
- let cachedCallKeep: CallKeepModule | null | undefined;
67
-
68
- export const loadCallKeep = (): CallKeepModule | null => {
69
- if (cachedCallKeep !== undefined) {
70
- return cachedCallKeep;
71
- }
72
- try {
73
- // The JS package being installed does NOT mean the native side is
74
- // linked: a host can `npm i react-native-callkeep` and skip
75
- // `pod install`, or (very common in this repo) node_modules can carry
76
- // it over from another branch whose build did link it. In that state
77
- // requiring the package builds a `new NativeEventEmitter(null)` at
78
- // module scope, which throws an invariant that RN surfaces as an
79
- // uncaught redbox on every render of the call overlay — the whole app
80
- // becomes unusable over a feature the host never switched on.
81
- // Probing NativeModules first keeps the optional dependency genuinely
82
- // optional: no native module => behave exactly as if it isn't installed.
83
- // eslint-disable-next-line @typescript-eslint/no-require-imports
84
- const { NativeModules } = require('react-native');
85
- if (!NativeModules?.RNCallKeep) {
86
- cachedCallKeep = null;
87
- return cachedCallKeep;
88
- }
89
- // eslint-disable-next-line @typescript-eslint/no-require-imports
90
- const mod = require('react-native-callkeep');
91
- cachedCallKeep = (mod?.default || mod) as CallKeepModule;
92
- } catch {
93
- cachedCallKeep = null;
94
- }
95
- return cachedCallKeep;
96
- };
97
-
98
- // CallKeep identifies calls by UUID. Our callIds aren't UUIDs, so derive a
99
- // stable v4-shaped string from the callId: same call always maps to the
100
- // same UUID, which is what endCall() needs to dismiss the right screen.
101
- const toUuid = (seed: string): string => {
102
- let hash = 0x811c9dc5;
103
- const bytes: number[] = [];
104
- for (let i = 0; i < 32; i++) {
105
- const ch = seed.charCodeAt(i % Math.max(1, seed.length)) + i;
106
- hash ^= ch;
107
- hash = Math.imul(hash, 0x01000193) >>> 0;
108
- bytes.push(hash & 0xff);
109
- }
110
- const hex = bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
111
- return [
112
- hex.slice(0, 8),
113
- hex.slice(8, 12),
114
- '4' + hex.slice(13, 16),
115
- ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16) + hex.slice(17, 20),
116
- hex.slice(20, 32),
117
- ].join('-');
118
- };
44
+ export { loadCallKeep };
119
45
 
120
46
  const trace = (...args: unknown[]) => {
121
47
  if (__DEV__) {console.log('[calls] CallKit:', ...args);}
@@ -192,7 +118,25 @@ export const useCallKeep = (): void => {
192
118
  useEffect(() => {
193
119
  if (!callKeep) {return;}
194
120
 
195
- const onAnswer = () => {
121
+ const isOtherCall = (payload: any): boolean => {
122
+ const uuid = String(payload?.callUUID || '').toLowerCase();
123
+ if (!uuid) {return false;}
124
+ const active = activeUuidRef.current?.toLowerCase() || null;
125
+ if (active === uuid) {return false;}
126
+ const current = store.getState().call;
127
+ const ringingUuid =
128
+ current.direction === 'incoming' && current.callId
129
+ ? callKitUuid(current.callId)
130
+ : null;
131
+ return ringingUuid !== uuid;
132
+ };
133
+
134
+ const onAnswer = (payload?: any) => {
135
+ if (isOtherCall(payload)) {
136
+ trace('answer before the call arrived', payload?.callUUID);
137
+ rememberSystemCallAction(String(payload.callUUID), 'answer');
138
+ return;
139
+ }
196
140
  answeredRef.current = true;
197
141
  // The user answered from the system screen, which on Android may
198
142
  // mean the app was not even in the foreground.
@@ -206,7 +150,12 @@ export const useCallKeep = (): void => {
206
150
  }
207
151
  };
208
152
 
209
- const onEnd = () => {
153
+ const onEnd = (payload?: any) => {
154
+ if (isOtherCall(payload)) {
155
+ trace('declined before the call arrived', payload?.callUUID);
156
+ rememberSystemCallAction(String(payload.callUUID), 'end');
157
+ return;
158
+ }
210
159
  const snapshot = store.getState().call;
211
160
  if (snapshot.phase === 'idle') {return;}
212
161
  if (
@@ -262,16 +211,46 @@ export const useCallKeep = (): void => {
262
211
  callKeep.addEventListener('didResetProvider', onReset);
263
212
  // The system's verdict on displayIncomingCall ("Unentitled" means the
264
213
  // app lacks the voip background mode).
265
- const onDisplayed = ({ error, errorCode }: { error?: string; errorCode?: string }) => {
266
- trace('incoming call shown', error ? `failed: ${errorCode || ''} ${error}` : 'ok');
214
+ const onDisplayed = ({
215
+ error,
216
+ errorCode,
217
+ fromPushKit,
218
+ }: {
219
+ error?: string;
220
+ errorCode?: string;
221
+ fromPushKit?: string;
222
+ }) => {
223
+ trace(
224
+ `incoming call shown${fromPushKit === '1' ? ' (PushKit)' : ''}`,
225
+ error ? `failed: ${errorCode || ''} ${error}` : 'ok'
226
+ );
227
+ if (errorCode === 'CallUUIDAlreadyExists') {return;}
267
228
  // CallKit refused it (Do Not Disturb, unentitled...): bring the
268
- // in-app ring card back so the call can still be answered.
269
- if (error) {setCallSystemRinging(false);}
229
+ if (error && fromPushKit !== '1') {setCallSystemRinging(false);}
270
230
  };
271
231
  callKeep.addEventListener('didDisplayIncomingCall', onDisplayed);
272
232
  callKeep.addEventListener('didDeactivateAudioSession', onAudioDeactivated);
273
233
 
234
+ const replay: Record<string, (data: any) => void> = {
235
+ RNCallKeepPerformAnswerCallAction: onAnswer,
236
+ RNCallKeepPerformEndCallAction: onEnd,
237
+ RNCallKeepDidDisplayIncomingCall: onDisplayed,
238
+ RNCallKeepDidActivateAudioSession: onAudioActivated,
239
+ RNCallKeepDidDeactivateAudioSession: onAudioDeactivated,
240
+ RNCallKeepDidPerformSetMutedCallAction: onMuted,
241
+ };
242
+ const onLoadWithEvents = (events: { name: string; data: any }[]) => {
243
+ if (!Array.isArray(events) || events.length === 0) {return;}
244
+ trace('replaying', events.map((e) => e.name).join(', '));
245
+ callKeep.clearInitialEvents?.();
246
+ for (const event of events) {
247
+ replay[event.name]?.(event.data);
248
+ }
249
+ };
250
+ callKeep.addEventListener('didLoadWithEvents', onLoadWithEvents);
251
+
274
252
  return () => {
253
+ callKeep.removeEventListener('didLoadWithEvents');
275
254
  callKeep.removeEventListener('answerCall');
276
255
  callKeep.removeEventListener('endCall');
277
256
  callKeep.removeEventListener('didPerformSetMutedCallAction');
@@ -313,11 +292,23 @@ export const useCallKeep = (): void => {
313
292
 
314
293
  if (isRinging) {
315
294
  if (activeUuidRef.current) {return;}
316
- const uuid = toUuid(call.callId || call.roomJid || String(Date.now()));
295
+ const uuid = callKitUuid(call.callId || call.roomJid || String(Date.now()));
317
296
  activeUuidRef.current = uuid;
318
297
  answeredRef.current = false;
298
+ const earlier = takeSystemCallAction(uuid);
299
+ if (earlier === 'answer') {
300
+ trace('answered earlier on the system screen', uuid);
301
+ answeredRef.current = true;
302
+ dispatch(acceptIncomingCall());
303
+ return;
304
+ }
305
+ if (earlier === 'end') {
306
+ trace('declined earlier on the system screen', uuid);
307
+ activeUuidRef.current = null;
308
+ declineActiveCallWhenOnline();
309
+ return;
310
+ }
319
311
  // iOS: the system banner is the ring UI (Telegram-style); the in-app
320
- // card would only cover the screen under it.
321
312
  if (isIOS) {setCallSystemRinging(true);}
322
313
  try {
323
314
  callKeep.displayIncomingCall(
@@ -339,7 +330,7 @@ export const useCallKeep = (): void => {
339
330
  if (isIOS && isOutgoing && !activeUuidRef.current && callKeep.startCall) {
340
331
  // Unique per call: CallKit refuses a UUID it has seen before, and
341
332
  // the callId only arrives with the token.
342
- const uuid = toUuid(`${call.roomJid || ''}|${call.startedAt || Date.now()}`);
333
+ const uuid = callKitUuid(`${call.roomJid || ''}|${call.startedAt || Date.now()}`);
343
334
  activeUuidRef.current = uuid;
344
335
  outgoingReportedRef.current = 'none';
345
336
  trace('startCall', uuid);
@@ -382,6 +373,7 @@ export const useCallKeep = (): void => {
382
373
  }, [
383
374
  callKeep,
384
375
  ready,
376
+ dispatch,
385
377
  isIOS,
386
378
  isRinging,
387
379
  isLive,