@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.
- package/CHANGELOG.md +7 -1
- package/README.md +6 -3
- package/lib/commonjs/components/MessageBubble/Message.js +16 -6
- package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +16 -2
- package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/callActions.js +32 -2
- package/lib/commonjs/components/VideoCalls/callActions.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/systemCall.js +83 -0
- package/lib/commonjs/components/VideoCalls/systemCall.js.map +1 -0
- package/lib/commonjs/components/VideoCalls/useCallKeep.js +83 -66
- package/lib/commonjs/components/VideoCalls/useCallKeep.js.map +1 -1
- package/lib/commonjs/helpers/incomingCallFromPush.js +95 -21
- package/lib/commonjs/helpers/incomingCallFromPush.js.map +1 -1
- package/lib/commonjs/helpers/secureStoreRuntime.js +10 -1
- package/lib/commonjs/helpers/secureStoreRuntime.js.map +1 -1
- package/lib/commonjs/main.js +114 -0
- package/lib/commonjs/main.js.map +1 -1
- package/lib/commonjs/networking/api-requests/push.api.js +22 -7
- package/lib/commonjs/networking/api-requests/push.api.js.map +1 -1
- package/lib/commonjs/networking/callTokenStanza.js +6 -0
- package/lib/commonjs/networking/callTokenStanza.js.map +1 -1
- package/lib/commonjs/networking/xmpp/sendMessageReaction.xmpp.js +22 -6
- package/lib/commonjs/networking/xmpp/sendMessageReaction.xmpp.js.map +1 -1
- package/lib/commonjs/networking/xmppClient.js.map +1 -1
- package/lib/commonjs/push/pushKeyStore.js +169 -0
- package/lib/commonjs/push/pushKeyStore.js.map +1 -0
- package/lib/commonjs/push/sealedBox.js +106 -0
- package/lib/commonjs/push/sealedBox.js.map +1 -0
- package/lib/commonjs/push/sealedPush.js +135 -0
- package/lib/commonjs/push/sealedPush.js.map +1 -0
- package/lib/commonjs/push/sealedPushTask.js +221 -0
- package/lib/commonjs/push/sealedPushTask.js.map +1 -0
- package/lib/commonjs/services/pushRegistration.js +12 -7
- package/lib/commonjs/services/pushRegistration.js.map +1 -1
- package/lib/commonjs/version.js +1 -1
- package/lib/module/components/MessageBubble/Message.js +16 -6
- package/lib/module/components/MessageBubble/Message.js.map +1 -1
- package/lib/module/components/VideoCalls/VideoCallOverlay.js +17 -3
- package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
- package/lib/module/components/VideoCalls/callActions.js +29 -0
- package/lib/module/components/VideoCalls/callActions.js.map +1 -1
- package/lib/module/components/VideoCalls/systemCall.js +73 -0
- package/lib/module/components/VideoCalls/systemCall.js.map +1 -0
- package/lib/module/components/VideoCalls/useCallKeep.js +77 -64
- package/lib/module/components/VideoCalls/useCallKeep.js.map +1 -1
- package/lib/module/helpers/incomingCallFromPush.js +92 -22
- package/lib/module/helpers/incomingCallFromPush.js.map +1 -1
- package/lib/module/helpers/secureStoreRuntime.js +10 -1
- package/lib/module/helpers/secureStoreRuntime.js.map +1 -1
- package/lib/module/main.js +9 -0
- package/lib/module/main.js.map +1 -1
- package/lib/module/networking/api-requests/push.api.js +21 -6
- package/lib/module/networking/api-requests/push.api.js.map +1 -1
- package/lib/module/networking/callTokenStanza.js +6 -0
- package/lib/module/networking/callTokenStanza.js.map +1 -1
- package/lib/module/networking/xmpp/sendMessageReaction.xmpp.js +21 -6
- package/lib/module/networking/xmpp/sendMessageReaction.xmpp.js.map +1 -1
- package/lib/module/networking/xmppClient.js.map +1 -1
- package/lib/module/push/pushKeyStore.js +159 -0
- package/lib/module/push/pushKeyStore.js.map +1 -0
- package/lib/module/push/sealedBox.js +97 -0
- package/lib/module/push/sealedBox.js.map +1 -0
- package/lib/module/push/sealedPush.js +127 -0
- package/lib/module/push/sealedPush.js.map +1 -0
- package/lib/module/push/sealedPushTask.js +212 -0
- package/lib/module/push/sealedPushTask.js.map +1 -0
- package/lib/module/services/pushRegistration.js +12 -7
- package/lib/module/services/pushRegistration.js.map +1 -1
- package/lib/module/version.js +1 -1
- package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/callActions.d.ts +6 -0
- package/lib/typescript/components/VideoCalls/callActions.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/systemCall.d.ts +25 -0
- package/lib/typescript/components/VideoCalls/systemCall.d.ts.map +1 -0
- package/lib/typescript/components/VideoCalls/useCallKeep.d.ts +8 -23
- package/lib/typescript/components/VideoCalls/useCallKeep.d.ts.map +1 -1
- package/lib/typescript/helpers/incomingCallFromPush.d.ts +5 -13
- package/lib/typescript/helpers/incomingCallFromPush.d.ts.map +1 -1
- package/lib/typescript/helpers/secureStoreRuntime.d.ts +17 -3
- package/lib/typescript/helpers/secureStoreRuntime.d.ts.map +1 -1
- package/lib/typescript/main.d.ts +11 -0
- package/lib/typescript/main.d.ts.map +1 -1
- package/lib/typescript/networking/api-requests/push.api.d.ts +14 -2
- package/lib/typescript/networking/api-requests/push.api.d.ts.map +1 -1
- package/lib/typescript/networking/callTokenStanza.d.ts.map +1 -1
- package/lib/typescript/networking/xmpp/sendMessageReaction.xmpp.d.ts +13 -1
- package/lib/typescript/networking/xmpp/sendMessageReaction.xmpp.d.ts.map +1 -1
- package/lib/typescript/networking/xmppClient.d.ts +2 -4
- package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
- package/lib/typescript/push/pushKeyStore.d.ts +35 -0
- package/lib/typescript/push/pushKeyStore.d.ts.map +1 -0
- package/lib/typescript/push/sealedBox.d.ts +24 -0
- package/lib/typescript/push/sealedBox.d.ts.map +1 -0
- package/lib/typescript/push/sealedPush.d.ts +67 -0
- package/lib/typescript/push/sealedPush.d.ts.map +1 -0
- package/lib/typescript/push/sealedPushTask.d.ts +44 -0
- package/lib/typescript/push/sealedPushTask.d.ts.map +1 -0
- package/lib/typescript/services/pushRegistration.d.ts +8 -0
- package/lib/typescript/services/pushRegistration.d.ts.map +1 -1
- package/lib/typescript/types/models/xmpp.model.d.ts +2 -4
- package/lib/typescript/types/models/xmpp.model.d.ts.map +1 -1
- package/lib/typescript/types/types.d.ts +6 -0
- package/lib/typescript/types/types.d.ts.map +1 -1
- package/lib/typescript/version.d.ts +1 -1
- package/package.json +11 -1
- package/plugins/notification-service/NotificationService.swift +113 -0
- package/plugins/notification-service/SealedBox.swift +355 -0
- package/plugins/notification-service/check/gen-vectors.mjs +24 -0
- package/plugins/notification-service/check/main.swift +37 -0
- package/plugins/notification-service/check/run.sh +12 -0
- package/plugins/notification-service/check/vectors.json +1 -0
- package/plugins/withNotificationServiceExtension.js +70 -18
- package/src/components/MessageBubble/Message.tsx +16 -5
- package/src/components/VideoCalls/VideoCallOverlay.tsx +18 -2
- package/src/components/VideoCalls/callActions.ts +28 -0
- package/src/components/VideoCalls/systemCall.ts +109 -0
- package/src/components/VideoCalls/useCallKeep.ts +89 -97
- package/src/helpers/incomingCallFromPush.ts +97 -26
- package/src/helpers/secureStoreRuntime.ts +21 -3
- package/src/main.ts +38 -0
- package/src/networking/api-requests/push.api.ts +29 -4
- package/src/networking/callTokenStanza.ts +10 -0
- package/src/networking/xmpp/sendMessageReaction.xmpp.ts +38 -6
- package/src/networking/xmppClient.ts +2 -2
- package/src/push/pushKeyStore.ts +163 -0
- package/src/push/sealedBox.ts +104 -0
- package/src/push/sealedPush.ts +138 -0
- package/src/push/sealedPushTask.ts +221 -0
- package/src/services/pushRegistration.ts +25 -5
- package/src/types/models/xmpp.model.ts +2 -1
- package/src/types/types.ts +6 -0
- package/src/version.ts +1 -1
|
@@ -1,33 +1,61 @@
|
|
|
1
1
|
import { store } from '../roomStore';
|
|
2
|
-
import { setIncomingCallToken } from '../roomStore/callSlice';
|
|
2
|
+
import { acceptIncomingCall, setIncomingCallToken } from '../roomStore/callSlice';
|
|
3
|
+
import { endSystemIncomingCall } from '../components/VideoCalls/systemCall';
|
|
4
|
+
import { declineActiveCallWhenOnline } from '../components/VideoCalls/callActions';
|
|
3
5
|
import { isCallPush, parseCallPush } from './callPush';
|
|
6
|
+
import { normalizeRoomJid } from './normalizeRoomJid';
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
8
|
+
const PENDING_CALL_PUSH_TTL_MS = 60_000;
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export type CallPushIntent = 'ring' | 'answer' | 'decline';
|
|
12
|
+
|
|
13
|
+
let pendingPush: { data: Record<string, any>; intent: CallPushIntent; at: number } | null = null;
|
|
14
|
+
let unwatch: (() => void) | null = null;
|
|
15
|
+
|
|
16
|
+
const trace = (...args: unknown[]) => {
|
|
17
|
+
if (__DEV__) {console.log('[calls] push:', ...args);}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const callsConfigured = (): boolean =>
|
|
21
|
+
store.getState().chatSettingStore.config?.videoCalls !== undefined;
|
|
22
|
+
|
|
23
|
+
const holdUntilConfigured = (data: Record<string, any>, intent: CallPushIntent): void => {
|
|
24
|
+
pendingPush = { data, intent, at: Date.now() };
|
|
25
|
+
if (unwatch) {return;}
|
|
26
|
+
unwatch = store.subscribe(() => {
|
|
27
|
+
if (!callsConfigured()) {return;}
|
|
28
|
+
unwatch?.();
|
|
29
|
+
unwatch = null;
|
|
30
|
+
const held = pendingPush;
|
|
31
|
+
pendingPush = null;
|
|
32
|
+
if (!held) {return;}
|
|
33
|
+
if (Date.now() - held.at > PENDING_CALL_PUSH_TTL_MS) {
|
|
34
|
+
trace('held call push expired');
|
|
35
|
+
endSystemIncomingCall(parseCallPush(held.data).callId);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
ringFromPush(held.data, held.intent);
|
|
39
|
+
});
|
|
40
|
+
};
|
|
23
41
|
|
|
42
|
+
const applyIntent = (intent: CallPushIntent): void => {
|
|
43
|
+
const call = store.getState().call;
|
|
44
|
+
if (call.direction !== 'incoming' || call.phase !== 'ringing-incoming') {return;}
|
|
45
|
+
if (intent === 'answer') {store.dispatch(acceptIncomingCall());}
|
|
46
|
+
if (intent === 'decline') {declineActiveCallWhenOnline();}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const ringFromPush = (data: Record<string, any>, intent: CallPushIntent): boolean => {
|
|
24
50
|
const parsed = parseCallPush(data);
|
|
25
51
|
const state = store.getState();
|
|
26
52
|
|
|
27
53
|
// Calls off in this deployment: swallow the push rather than ringing a
|
|
28
54
|
// screen the host never opted into.
|
|
29
55
|
if (state.chatSettingStore.config?.videoCalls?.enabled !== true) {
|
|
30
|
-
|
|
56
|
+
trace('calls are off, dropping', parsed.callId);
|
|
57
|
+
endSystemIncomingCall(parsed.callId);
|
|
58
|
+
return false;
|
|
31
59
|
}
|
|
32
60
|
|
|
33
61
|
// Already ringing or in this exact call (the push raced the stanza, or
|
|
@@ -38,9 +66,15 @@ export const handleCallPush = (
|
|
|
38
66
|
const sameCall =
|
|
39
67
|
(!!parsed.callId && parsed.callId === current.callId) ||
|
|
40
68
|
(!!parsed.roomBareName && parsed.roomBareName === current.roomBareName);
|
|
41
|
-
if (sameCall
|
|
69
|
+
if (sameCall) {
|
|
70
|
+
applyIntent(intent);
|
|
42
71
|
return true;
|
|
43
72
|
}
|
|
73
|
+
if (current.phase === 'in-call') {
|
|
74
|
+
// Busy with another call: no second ring.
|
|
75
|
+
endSystemIncomingCall(parsed.callId);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
44
78
|
}
|
|
45
79
|
|
|
46
80
|
// Without a LiveKit token there is nothing to accept: the push is only
|
|
@@ -48,7 +82,8 @@ export const handleCallPush = (
|
|
|
48
82
|
// cannot work, so treat it as handled and let the stanza (once the
|
|
49
83
|
// socket is back up) open the real ring screen.
|
|
50
84
|
if (!parsed.token) {
|
|
51
|
-
|
|
85
|
+
endSystemIncomingCall(parsed.callId);
|
|
86
|
+
return false;
|
|
52
87
|
}
|
|
53
88
|
|
|
54
89
|
// Resolve the full room JID from the local room map. The push carries
|
|
@@ -60,11 +95,16 @@ export const handleCallPush = (
|
|
|
60
95
|
room?.jid === parsed.roomRef
|
|
61
96
|
) as { jid?: string; name?: string; title?: string } | undefined;
|
|
62
97
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
98
|
+
const conference =
|
|
99
|
+
state.chatSettingStore.config?.xmppSettings?.conference || '';
|
|
100
|
+
const fromRef = parsed.roomRef
|
|
101
|
+
? normalizeRoomJid(parsed.roomRef, conference)
|
|
102
|
+
: '';
|
|
103
|
+
const roomJid = resolved?.jid || (fromRef.includes('@') ? fromRef : '');
|
|
66
104
|
if (!roomJid) {
|
|
67
|
-
|
|
105
|
+
trace('no room for call push', parsed.roomRef);
|
|
106
|
+
endSystemIncomingCall(parsed.callId);
|
|
107
|
+
return false;
|
|
68
108
|
}
|
|
69
109
|
|
|
70
110
|
store.dispatch(
|
|
@@ -78,6 +118,37 @@ export const handleCallPush = (
|
|
|
78
118
|
peerXmppUsername: parsed.callerXmppUsername,
|
|
79
119
|
})
|
|
80
120
|
);
|
|
121
|
+
trace('ringing', parsed.callId, intent);
|
|
122
|
+
applyIntent(intent);
|
|
123
|
+
return true;
|
|
124
|
+
};
|
|
81
125
|
|
|
126
|
+
export const handleCallPush = (
|
|
127
|
+
data: Record<string, any> | null | undefined,
|
|
128
|
+
intent: CallPushIntent = 'ring'
|
|
129
|
+
): boolean => {
|
|
130
|
+
if (!isCallPush(data)) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
if (!callsConfigured()) {
|
|
134
|
+
trace('holding call push until the chat is configured');
|
|
135
|
+
holdUntilConfigured(data as Record<string, any>, intent);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
ringFromPush(data as Record<string, any>, intent);
|
|
82
139
|
return true;
|
|
83
140
|
};
|
|
141
|
+
|
|
142
|
+
export const answerCallFromPush = (
|
|
143
|
+
data: Record<string, any> | null | undefined
|
|
144
|
+
): boolean => handleCallPush(data, 'answer');
|
|
145
|
+
|
|
146
|
+
export const declineCallFromPush = (
|
|
147
|
+
data: Record<string, any> | null | undefined
|
|
148
|
+
): boolean => handleCallPush(data, 'decline');
|
|
149
|
+
|
|
150
|
+
export const __resetCallPushForTests = (): void => {
|
|
151
|
+
unwatch?.();
|
|
152
|
+
unwatch = null;
|
|
153
|
+
pendingPush = null;
|
|
154
|
+
};
|
|
@@ -12,10 +12,25 @@
|
|
|
12
12
|
* AsyncStorage rather than crash (see `secureKeyValue.ts`).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The subset of expo-secure-store's options the SDK uses. `keychainService`
|
|
17
|
+
* must be repeated on every read of an item written with it; the
|
|
18
|
+
* accessibility constants come from `SecureStoreRuntime.accessible`.
|
|
19
|
+
*/
|
|
20
|
+
export interface SecureStoreOptions {
|
|
21
|
+
keychainService?: string;
|
|
22
|
+
/** iOS only: one of `accessible`; ignored on Android. */
|
|
23
|
+
keychainAccessible?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
export interface SecureStoreRuntime {
|
|
16
|
-
getItemAsync: (key: string) => Promise<string | null>;
|
|
17
|
-
setItemAsync: (key: string, value: string) => Promise<void>;
|
|
18
|
-
deleteItemAsync: (key: string) => Promise<void>;
|
|
27
|
+
getItemAsync: (key: string, options?: SecureStoreOptions) => Promise<string | null>;
|
|
28
|
+
setItemAsync: (key: string, value: string, options?: SecureStoreOptions) => Promise<void>;
|
|
29
|
+
deleteItemAsync: (key: string, options?: SecureStoreOptions) => Promise<void>;
|
|
30
|
+
/** expo-secure-store's iOS keychain accessibility constants (undefined on older versions). */
|
|
31
|
+
accessible: {
|
|
32
|
+
afterFirstUnlockThisDeviceOnly?: unknown;
|
|
33
|
+
};
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
let cached: SecureStoreRuntime | null | undefined;
|
|
@@ -45,6 +60,9 @@ export const loadSecureStore = (): SecureStoreRuntime | null => {
|
|
|
45
60
|
getItemAsync: mod.getItemAsync,
|
|
46
61
|
setItemAsync: mod.setItemAsync,
|
|
47
62
|
deleteItemAsync: mod.deleteItemAsync,
|
|
63
|
+
accessible: {
|
|
64
|
+
afterFirstUnlockThisDeviceOnly: mod.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
|
|
65
|
+
},
|
|
48
66
|
};
|
|
49
67
|
}
|
|
50
68
|
} catch {
|
package/src/main.ts
CHANGED
|
@@ -33,6 +33,14 @@ export { XmppProvider } from './context/xmppProvider';
|
|
|
33
33
|
export { useUnread } from './hooks/useUnreadMessagesCounter';
|
|
34
34
|
|
|
35
35
|
export { handlePushPayload, openRoomFromPush } from './helpers/pushPayload';
|
|
36
|
+
export { callKitUuid } from './components/VideoCalls/systemCall';
|
|
37
|
+
export {
|
|
38
|
+
answerCallFromPush,
|
|
39
|
+
declineCallFromPush,
|
|
40
|
+
} from './helpers/incomingCallFromPush';
|
|
41
|
+
export type { CallPushIntent } from './helpers/incomingCallFromPush';
|
|
42
|
+
export { isCallPush, parseCallPush } from './helpers/callPush';
|
|
43
|
+
export type { ParsedCallPush } from './helpers/callPush';
|
|
36
44
|
export type { PushPayloadOutcome } from './helpers/pushPayload';
|
|
37
45
|
export {
|
|
38
46
|
registerPushToken,
|
|
@@ -56,6 +64,36 @@ export type {
|
|
|
56
64
|
PushSubscriptionPayload,
|
|
57
65
|
} from './networking/api-requests/push.api';
|
|
58
66
|
export type { PushTokenType, PushTokenRegistration } from './types/types';
|
|
67
|
+
// Encrypted (sealed) pushes: the device key and the Android renderer.
|
|
68
|
+
// iOS renders them in the notification service extension the config
|
|
69
|
+
// plugin adds. See README, "Encrypted pushes".
|
|
70
|
+
export {
|
|
71
|
+
ensurePushKeyPair,
|
|
72
|
+
getPushKeyPair,
|
|
73
|
+
resetPushKeyPair,
|
|
74
|
+
clearPushKeyPair,
|
|
75
|
+
PUSH_KEY_KEYCHAIN_SERVICE,
|
|
76
|
+
PUSH_SECRET_KEY_ITEM,
|
|
77
|
+
} from './push/pushKeyStore';
|
|
78
|
+
export type { PushKeyPair } from './push/pushKeyStore';
|
|
79
|
+
export {
|
|
80
|
+
registerSealedPushTask,
|
|
81
|
+
presentSealedPush,
|
|
82
|
+
SEALED_PUSH_TASK,
|
|
83
|
+
} from './push/sealedPushTask';
|
|
84
|
+
export type { SealedPushTaskOptions } from './push/sealedPushTask';
|
|
85
|
+
export {
|
|
86
|
+
openSealedPush,
|
|
87
|
+
isSealedPush,
|
|
88
|
+
sealedPushEnvelopeOf,
|
|
89
|
+
SEALED_PUSH_ALG,
|
|
90
|
+
} from './push/sealedPush';
|
|
91
|
+
export type {
|
|
92
|
+
SealedPushEnvelope,
|
|
93
|
+
SealedPushContent,
|
|
94
|
+
SealedPushFailure,
|
|
95
|
+
OpenSealedPushResult,
|
|
96
|
+
} from './push/sealedPush';
|
|
59
97
|
export { useChatRoomFocus } from './hooks/useChatRoomFocus';
|
|
60
98
|
export { logoutService, useLogout } from './hooks/useLogout';
|
|
61
99
|
export { useQRCodeChat, handleQRChatId } from './hooks/useQRCodeChatHandler';
|
|
@@ -67,10 +67,20 @@ const isUnknownFieldError = (error: any): boolean =>
|
|
|
67
67
|
error?.response?.status === 422 &&
|
|
68
68
|
/not allowed/i.test(String(error?.response?.data?.error ?? ''));
|
|
69
69
|
|
|
70
|
+
/** The only sealing scheme the push service knows (README, "Encrypted pushes"). */
|
|
71
|
+
export const PUSH_KEY_ALG = 'sealed-box-x25519';
|
|
72
|
+
|
|
70
73
|
export interface PushSubscriptionPayload {
|
|
71
74
|
registrationToken: string;
|
|
72
75
|
deviceType: PushDeviceType;
|
|
73
76
|
tokenType?: PushTokenType;
|
|
77
|
+
/**
|
|
78
|
+
* Encrypted pushes: base64 of the device's 32-byte X25519 public key.
|
|
79
|
+
* Absent keeps whatever key the service holds for this token.
|
|
80
|
+
*/
|
|
81
|
+
pushKey?: string;
|
|
82
|
+
pushKeyAlg?: typeof PUSH_KEY_ALG;
|
|
83
|
+
pushKeyId?: string;
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
export interface RegisterPushSubscriptionArgs {
|
|
@@ -78,15 +88,23 @@ export interface RegisterPushSubscriptionArgs {
|
|
|
78
88
|
token: string;
|
|
79
89
|
tokenType: PushTokenType;
|
|
80
90
|
authToken: string;
|
|
91
|
+
/** Base64 public key for encrypted pushes (`ensurePushKeyPair().publicKey`). */
|
|
92
|
+
pushKey?: string;
|
|
93
|
+
pushKeyId?: string;
|
|
81
94
|
}
|
|
82
95
|
|
|
83
96
|
export const buildPushSubscriptionPayload = (
|
|
84
97
|
token: string,
|
|
85
|
-
tokenType: PushTokenType
|
|
98
|
+
tokenType: PushTokenType,
|
|
99
|
+
pushKey?: string,
|
|
100
|
+
pushKeyId?: string
|
|
86
101
|
): PushSubscriptionPayload => ({
|
|
87
102
|
registrationToken: token,
|
|
88
103
|
deviceType: resolvePushDeviceType(),
|
|
89
104
|
tokenType,
|
|
105
|
+
...(pushKey
|
|
106
|
+
? { pushKey, pushKeyAlg: PUSH_KEY_ALG, ...(pushKeyId ? { pushKeyId } : {}) }
|
|
107
|
+
: {}),
|
|
90
108
|
});
|
|
91
109
|
|
|
92
110
|
export async function registerPushSubscription({
|
|
@@ -94,22 +112,29 @@ export async function registerPushSubscription({
|
|
|
94
112
|
token,
|
|
95
113
|
tokenType,
|
|
96
114
|
authToken,
|
|
115
|
+
pushKey,
|
|
116
|
+
pushKeyId,
|
|
97
117
|
}: RegisterPushSubscriptionArgs): Promise<void> {
|
|
98
118
|
const path = `/v1/push/subscription/${encodeURIComponent(appId)}`;
|
|
99
119
|
const headers = { Authorization: authToken };
|
|
100
|
-
const payload = buildPushSubscriptionPayload(token, tokenType);
|
|
120
|
+
const payload = buildPushSubscriptionPayload(token, tokenType, pushKey, pushKeyId);
|
|
101
121
|
|
|
102
122
|
try {
|
|
103
123
|
await http.post(path, payload, { headers });
|
|
104
124
|
return;
|
|
105
125
|
} catch (error: any) {
|
|
106
|
-
|
|
126
|
+
// A backend that predates `tokenType` still delivers to apns / fcm
|
|
127
|
+
// tokens. One that predates `pushKey` would deliver in the clear: the
|
|
128
|
+
// host asked for encryption, so that is an error, never a quiet fallback.
|
|
129
|
+
const canDowngrade = (tokenType === 'apns' || tokenType === 'fcm') && !pushKey;
|
|
107
130
|
if (!(canDowngrade && isUnknownFieldError(error))) {
|
|
108
131
|
throw toRegistrationError(
|
|
109
132
|
error,
|
|
110
133
|
tokenType === 'expo'
|
|
111
134
|
? 'Push registration (the backend does not accept Expo push tokens yet)'
|
|
112
|
-
:
|
|
135
|
+
: pushKey && isUnknownFieldError(error)
|
|
136
|
+
? 'Push registration (the backend does not support encrypted pushes)'
|
|
137
|
+
: 'Push registration'
|
|
113
138
|
);
|
|
114
139
|
}
|
|
115
140
|
}
|
|
@@ -528,6 +528,16 @@ export const onCallTokenMessage = (stanza: Element): boolean => {
|
|
|
528
528
|
return true;
|
|
529
529
|
}
|
|
530
530
|
|
|
531
|
+
if (
|
|
532
|
+
callState.direction === 'incoming' &&
|
|
533
|
+
callState.phase !== 'idle' &&
|
|
534
|
+
!!callId &&
|
|
535
|
+
callState.callId === callId
|
|
536
|
+
) {
|
|
537
|
+
callTrace('drop call-token: call already known from push', { callId });
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
|
|
531
541
|
store.dispatch(
|
|
532
542
|
setIncomingCallToken({
|
|
533
543
|
roomJid,
|
|
@@ -1,18 +1,51 @@
|
|
|
1
1
|
import { Client, xml } from '@xmpp/client';
|
|
2
|
+
import { shouldEncrypt } from '../../e2ee/send';
|
|
3
|
+
|
|
4
|
+
export interface ReactionStanzaData {
|
|
5
|
+
firstName?: string;
|
|
6
|
+
lastName?: string;
|
|
7
|
+
photoURL?: string;
|
|
8
|
+
push?: boolean;
|
|
9
|
+
messageAuthor?: string;
|
|
10
|
+
messageText?: string;
|
|
11
|
+
isMediafile?: string;
|
|
12
|
+
mimetype?: string;
|
|
13
|
+
fileName?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const REACTION_MESSAGE_TEXT_MAX = 120;
|
|
2
17
|
|
|
3
18
|
export function sendMessageReaction(
|
|
4
19
|
client: Client,
|
|
5
20
|
messageId: string,
|
|
6
21
|
roomJid: string,
|
|
7
22
|
reactionsList: string[],
|
|
8
|
-
data:
|
|
9
|
-
reactionSymbol?: any,
|
|
23
|
+
data: ReactionStanzaData = {}
|
|
10
24
|
) {
|
|
11
25
|
const id = `message-reaction:${Date.now().toString()}`;
|
|
12
26
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
27
|
+
const push = Boolean(data.push) && reactionsList.length > 0;
|
|
28
|
+
const messageText =
|
|
29
|
+
push && !shouldEncrypt(roomJid)
|
|
30
|
+
? String(data.messageText ?? '').slice(0, REACTION_MESSAGE_TEXT_MAX)
|
|
31
|
+
: '';
|
|
32
|
+
|
|
33
|
+
const dataReaction: Record<string, string> = {
|
|
34
|
+
senderFirstName: data.firstName ?? '',
|
|
35
|
+
senderLastName: data.lastName ?? '',
|
|
36
|
+
senderJID: client.jid?.toString() ?? '',
|
|
37
|
+
roomJid,
|
|
38
|
+
...(data.photoURL ? { photoURL: data.photoURL } : {}),
|
|
39
|
+
...(push
|
|
40
|
+
? {
|
|
41
|
+
push: 'true',
|
|
42
|
+
messageAuthor: data.messageAuthor ?? '',
|
|
43
|
+
messageText,
|
|
44
|
+
isMediafile: data.isMediafile ?? '',
|
|
45
|
+
mimetype: data.mimetype ?? '',
|
|
46
|
+
fileName: data.fileName ?? '',
|
|
47
|
+
}
|
|
48
|
+
: {}),
|
|
16
49
|
};
|
|
17
50
|
|
|
18
51
|
const message = xml(
|
|
@@ -31,7 +64,6 @@ export function sendMessageReaction(
|
|
|
31
64
|
xmlns: 'urn:xmpp:reactions:0',
|
|
32
65
|
},
|
|
33
66
|
...reactionsList.map((reaction) => xml('reaction', {}, reaction ?? ''))
|
|
34
|
-
// xml('reaction', {}, reactionSymbol ?? '👋'),
|
|
35
67
|
),
|
|
36
68
|
xml('data', dataReaction),
|
|
37
69
|
xml('store', { xmlns: 'urn:xmpp:hints' })
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
HistoryPageCursor,
|
|
17
17
|
} from './xmpp/getHistory.xmpp';
|
|
18
18
|
import { sendTextMessage } from './xmpp/sendTextMessage.xmpp';
|
|
19
|
-
import { sendMessageReaction } from './xmpp/sendMessageReaction.xmpp';
|
|
19
|
+
import { sendMessageReaction, type ReactionStanzaData } from './xmpp/sendMessageReaction.xmpp';
|
|
20
20
|
import { sendReceivedReceipt } from './xmpp/sendReceivedReceipt.xmpp';
|
|
21
21
|
import { sendDisplayedMarker } from './xmpp/sendDisplayedMarker.xmpp';
|
|
22
22
|
import { sendTextMessageWithTranslateTag } from './xmpp/sendTextMessageWithTranslateTag.xmpp';
|
|
@@ -1646,7 +1646,7 @@ export class XmppClient {
|
|
|
1646
1646
|
messageId: string,
|
|
1647
1647
|
roomJid: string,
|
|
1648
1648
|
reactionsList: string[],
|
|
1649
|
-
data?:
|
|
1649
|
+
data?: ReactionStanzaData
|
|
1650
1650
|
) {
|
|
1651
1651
|
sendMessageReaction(
|
|
1652
1652
|
this.client,
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { fromBase64, toBase64 } from '../e2ee/crypto';
|
|
2
|
+
import { loadSecureStore, type SecureStoreOptions } from '../helpers/secureStoreRuntime';
|
|
3
|
+
import { pushLog } from '../utils/devLogger';
|
|
4
|
+
import {
|
|
5
|
+
SEALED_BOX_PUBLIC_KEY_BYTES,
|
|
6
|
+
SEALED_BOX_SECRET_KEY_BYTES,
|
|
7
|
+
generateSealedBoxKeyPair,
|
|
8
|
+
sealedBoxKeyId,
|
|
9
|
+
sealedBoxPublicKeyOf,
|
|
10
|
+
} from './sealedBox';
|
|
11
|
+
import type { SealedPushKey } from './sealedPush';
|
|
12
|
+
|
|
13
|
+
// The device key for encrypted pushes: one X25519 pair per install, in the
|
|
14
|
+
// platform keychain / keystore through expo-secure-store. The public half
|
|
15
|
+
// goes to the push service with the token (`pushKey`); the secret half
|
|
16
|
+
// never leaves the device and is read by whoever renders a sealed push —
|
|
17
|
+
// the Android background task (`sealedPushTask.ts`, same process, same
|
|
18
|
+
// store) and the iOS notification service extension, which reads the
|
|
19
|
+
// keychain item directly (plugins/notification-service), so the item's
|
|
20
|
+
// attributes below are a contract with the Swift side:
|
|
21
|
+
// service `ethora.push` (expo-secure-store stores it as `ethora.push:no-auth`)
|
|
22
|
+
// account `ethora_push_secret_key`, UTF-8
|
|
23
|
+
// value base64 of the 32-byte secret key, UTF-8
|
|
24
|
+
// group the app's first keychain access group, which the config
|
|
25
|
+
// plugin shares with the extension
|
|
26
|
+
// Readable after the first unlock so a push that arrives while the phone
|
|
27
|
+
// is locked still decrypts; this device only, so an iCloud keychain copy
|
|
28
|
+
// on another device is never a key for pushes that go to this one.
|
|
29
|
+
|
|
30
|
+
export const PUSH_KEY_KEYCHAIN_SERVICE = 'ethora.push';
|
|
31
|
+
export const PUSH_SECRET_KEY_ITEM = 'ethora_push_secret_key';
|
|
32
|
+
export const PUSH_PUBLIC_KEY_ITEM = 'ethora_push_public_key';
|
|
33
|
+
|
|
34
|
+
export interface PushKeyPair {
|
|
35
|
+
/** Base64 of the raw 32-byte X25519 public key — the `pushKey` of the registration. */
|
|
36
|
+
publicKey: string;
|
|
37
|
+
/** The `pushKeyId` the service derives for it, for logs and the kid check. */
|
|
38
|
+
keyId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let warnedMissingStore = false;
|
|
42
|
+
const warnMissingStoreOnce = () => {
|
|
43
|
+
if (warnedMissingStore) {return;}
|
|
44
|
+
warnedMissingStore = true;
|
|
45
|
+
pushLog(
|
|
46
|
+
'warn',
|
|
47
|
+
'push: expo-secure-store is not installed, so no device key for encrypted ' +
|
|
48
|
+
'pushes can be kept; pushes stay in the clear.'
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const storeOptions = (): SecureStoreOptions | null => {
|
|
53
|
+
const store = loadSecureStore();
|
|
54
|
+
if (!store) {return null;}
|
|
55
|
+
const accessible = store.accessible.afterFirstUnlockThisDeviceOnly;
|
|
56
|
+
return {
|
|
57
|
+
keychainService: PUSH_KEY_KEYCHAIN_SERVICE,
|
|
58
|
+
...(accessible !== undefined ? { keychainAccessible: accessible } : {}),
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const readItem = async (item: string): Promise<string | null> => {
|
|
63
|
+
const store = loadSecureStore();
|
|
64
|
+
const options = storeOptions();
|
|
65
|
+
if (!store || !options) {return null;}
|
|
66
|
+
try {
|
|
67
|
+
return await store.getItemAsync(item, options);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
pushLog('warn', `push: reading ${item} failed`, e);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const decodeKey = (raw: string | null, length: number): Uint8Array | null => {
|
|
75
|
+
if (!raw) {return null;}
|
|
76
|
+
try {
|
|
77
|
+
const bytes = fromBase64(raw);
|
|
78
|
+
return bytes.length === length ? bytes : null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The stored pair, or null when there is none (or it is unreadable).
|
|
86
|
+
* The public key is re-derived from the secret, so the pair is consistent
|
|
87
|
+
* even if only the secret item survived.
|
|
88
|
+
*/
|
|
89
|
+
export async function loadPushSecretKey(): Promise<SealedPushKey | null> {
|
|
90
|
+
const secretKey = decodeKey(await readItem(PUSH_SECRET_KEY_ITEM), SEALED_BOX_SECRET_KEY_BYTES);
|
|
91
|
+
if (!secretKey) {return null;}
|
|
92
|
+
const stored = decodeKey(await readItem(PUSH_PUBLIC_KEY_ITEM), SEALED_BOX_PUBLIC_KEY_BYTES);
|
|
93
|
+
return { publicKey: stored ?? sealedBoxPublicKeyOf(secretKey), secretKey };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const describe = (publicKey: Uint8Array): PushKeyPair => ({
|
|
97
|
+
publicKey: toBase64(publicKey),
|
|
98
|
+
keyId: sealedBoxKeyId(publicKey),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** The public half of the stored pair, null when the device has no key. */
|
|
102
|
+
export async function getPushKeyPair(): Promise<PushKeyPair | null> {
|
|
103
|
+
const key = await loadPushSecretKey();
|
|
104
|
+
return key ? describe(key.publicKey) : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Generate and store a new pair, replacing any existing one. Pushes sealed
|
|
109
|
+
* for the old key stop opening at once, so re-register the token with the
|
|
110
|
+
* new `publicKey` right after. Null when expo-secure-store is missing or
|
|
111
|
+
* the keychain refused the write.
|
|
112
|
+
*/
|
|
113
|
+
export async function resetPushKeyPair(): Promise<PushKeyPair | null> {
|
|
114
|
+
const store = loadSecureStore();
|
|
115
|
+
const options = storeOptions();
|
|
116
|
+
if (!store || !options) {
|
|
117
|
+
warnMissingStoreOnce();
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const pair = generateSealedBoxKeyPair();
|
|
121
|
+
try {
|
|
122
|
+
await store.setItemAsync(PUSH_SECRET_KEY_ITEM, toBase64(pair.secretKey), options);
|
|
123
|
+
await store.setItemAsync(PUSH_PUBLIC_KEY_ITEM, toBase64(pair.publicKey), options);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
pushLog('warn', 'push: storing the device key failed', e);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const check = await loadPushSecretKey();
|
|
129
|
+
if (!check || toBase64(check.publicKey) !== toBase64(pair.publicKey)) {
|
|
130
|
+
pushLog('warn', 'push: the device key was not kept');
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
pushLog('rn', `push: new device key ${sealedBoxKeyId(pair.publicKey)}`);
|
|
134
|
+
return describe(pair.publicKey);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The device key for encrypted pushes, created on first call. Hand
|
|
139
|
+
* `publicKey` to `registerPushToken` (or send it as `pushKey` yourself).
|
|
140
|
+
*/
|
|
141
|
+
export async function ensurePushKeyPair(): Promise<PushKeyPair | null> {
|
|
142
|
+
const existing = await getPushKeyPair();
|
|
143
|
+
return existing ?? resetPushKeyPair();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Remove the pair; the next `ensurePushKeyPair` creates a new one. */
|
|
147
|
+
export async function clearPushKeyPair(): Promise<void> {
|
|
148
|
+
const store = loadSecureStore();
|
|
149
|
+
const options = storeOptions();
|
|
150
|
+
if (!store || !options) {return;}
|
|
151
|
+
for (const item of [PUSH_SECRET_KEY_ITEM, PUSH_PUBLIC_KEY_ITEM]) {
|
|
152
|
+
try {
|
|
153
|
+
await store.deleteItemAsync(item, options);
|
|
154
|
+
} catch {
|
|
155
|
+
// Not there — nothing to remove.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Test seam only. */
|
|
161
|
+
export const __resetPushKeyStoreForTests = (): void => {
|
|
162
|
+
warnedMissingStore = false;
|
|
163
|
+
};
|