@ethora/chat-component-rn 26.10.1 → 26.10.3
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/lib/commonjs/components/MainComponents/ChatHeader.js +11 -10
- package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
- package/lib/commonjs/components/MainComponents/MessageContainer.js +10 -2
- package/lib/commonjs/components/MainComponents/MessageContainer.js.map +1 -1
- package/lib/commonjs/components/MainComponents/RoomList.js +8 -18
- package/lib/commonjs/components/MainComponents/RoomList.js.map +1 -1
- package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +17 -14
- package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
- package/lib/commonjs/components/Modals/UserProfileModal/UserProfileModal.js +131 -17
- package/lib/commonjs/components/Modals/UserProfileModal/UserProfileModal.js.map +1 -1
- package/lib/commonjs/components/RoomComponents/ChatRoomItem.js +6 -1
- package/lib/commonjs/components/RoomComponents/ChatRoomItem.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/CallButtons.js +3 -1
- package/lib/commonjs/components/VideoCalls/CallButtons.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +123 -111
- package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/VideoCallSession.js +207 -346
- package/lib/commonjs/components/VideoCalls/VideoCallSession.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/callActions.js +61 -0
- package/lib/commonjs/components/VideoCalls/callActions.js.map +1 -0
- package/lib/commonjs/components/VideoCalls/callSessionController.js +431 -0
- package/lib/commonjs/components/VideoCalls/callSessionController.js.map +1 -0
- package/lib/commonjs/components/VideoCalls/useDraggable.js +59 -0
- package/lib/commonjs/components/VideoCalls/useDraggable.js.map +1 -0
- package/lib/commonjs/helpers/createRoomFromApi.js +4 -0
- package/lib/commonjs/helpers/createRoomFromApi.js.map +1 -1
- package/lib/commonjs/helpers/incomingCallFromPush.js +1 -1
- package/lib/commonjs/helpers/incomingCallFromPush.js.map +1 -1
- package/lib/commonjs/helpers/roomUserCount.js +7 -1
- package/lib/commonjs/helpers/roomUserCount.js.map +1 -1
- package/lib/commonjs/helpers/uniqueMembers.js +36 -0
- package/lib/commonjs/helpers/uniqueMembers.js.map +1 -0
- package/lib/commonjs/hooks/useLeaveRoom.js +52 -0
- package/lib/commonjs/hooks/useLeaveRoom.js.map +1 -0
- package/lib/commonjs/hooks/useLogout.js +15 -10
- package/lib/commonjs/hooks/useLogout.js.map +1 -1
- package/lib/commonjs/i18n/strings.js +18 -0
- package/lib/commonjs/i18n/strings.js.map +1 -1
- package/lib/commonjs/networking/api-requests/rooms.api.js +36 -1
- package/lib/commonjs/networking/api-requests/rooms.api.js.map +1 -1
- package/lib/commonjs/networking/callTokenStanza.js +47 -3
- package/lib/commonjs/networking/callTokenStanza.js.map +1 -1
- package/lib/commonjs/networking/xmppClient.js +9 -2
- package/lib/commonjs/networking/xmppClient.js.map +1 -1
- package/lib/commonjs/version.js +1 -1
- package/lib/module/components/MainComponents/ChatHeader.js +13 -12
- package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
- package/lib/module/components/MainComponents/MessageContainer.js +10 -2
- package/lib/module/components/MainComponents/MessageContainer.js.map +1 -1
- package/lib/module/components/MainComponents/RoomList.js +8 -18
- package/lib/module/components/MainComponents/RoomList.js.map +1 -1
- package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +18 -15
- package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
- package/lib/module/components/Modals/UserProfileModal/UserProfileModal.js +132 -18
- package/lib/module/components/Modals/UserProfileModal/UserProfileModal.js.map +1 -1
- package/lib/module/components/RoomComponents/ChatRoomItem.js +6 -1
- package/lib/module/components/RoomComponents/ChatRoomItem.js.map +1 -1
- package/lib/module/components/VideoCalls/CallButtons.js +3 -1
- package/lib/module/components/VideoCalls/CallButtons.js.map +1 -1
- package/lib/module/components/VideoCalls/VideoCallOverlay.js +125 -113
- package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
- package/lib/module/components/VideoCalls/VideoCallSession.js +208 -348
- package/lib/module/components/VideoCalls/VideoCallSession.js.map +1 -1
- package/lib/module/components/VideoCalls/callActions.js +55 -0
- package/lib/module/components/VideoCalls/callActions.js.map +1 -0
- package/lib/module/components/VideoCalls/callSessionController.js +420 -0
- package/lib/module/components/VideoCalls/callSessionController.js.map +1 -0
- package/lib/module/components/VideoCalls/useDraggable.js +55 -0
- package/lib/module/components/VideoCalls/useDraggable.js.map +1 -0
- package/lib/module/helpers/createRoomFromApi.js +4 -0
- package/lib/module/helpers/createRoomFromApi.js.map +1 -1
- package/lib/module/helpers/incomingCallFromPush.js +1 -1
- package/lib/module/helpers/incomingCallFromPush.js.map +1 -1
- package/lib/module/helpers/roomUserCount.js +7 -1
- package/lib/module/helpers/roomUserCount.js.map +1 -1
- package/lib/module/helpers/uniqueMembers.js +31 -0
- package/lib/module/helpers/uniqueMembers.js.map +1 -0
- package/lib/module/hooks/useLeaveRoom.js +48 -0
- package/lib/module/hooks/useLeaveRoom.js.map +1 -0
- package/lib/module/hooks/useLogout.js +15 -10
- package/lib/module/hooks/useLogout.js.map +1 -1
- package/lib/module/i18n/strings.js +18 -0
- package/lib/module/i18n/strings.js.map +1 -1
- package/lib/module/networking/api-requests/rooms.api.js +35 -1
- package/lib/module/networking/api-requests/rooms.api.js.map +1 -1
- package/lib/module/networking/callTokenStanza.js +47 -3
- package/lib/module/networking/callTokenStanza.js.map +1 -1
- package/lib/module/networking/xmppClient.js +8 -1
- package/lib/module/networking/xmppClient.js.map +1 -1
- package/lib/module/version.js +1 -1
- package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/MessageContainer.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/RoomList.d.ts.map +1 -1
- package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
- package/lib/typescript/components/Modals/UserProfileModal/UserProfileModal.d.ts.map +1 -1
- package/lib/typescript/components/RoomComponents/ChatRoomItem.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/CallButtons.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts +0 -6
- package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts +9 -20
- package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/callActions.d.ts +15 -0
- package/lib/typescript/components/VideoCalls/callActions.d.ts.map +1 -0
- package/lib/typescript/components/VideoCalls/callSessionController.d.ts +61 -0
- package/lib/typescript/components/VideoCalls/callSessionController.d.ts.map +1 -0
- package/lib/typescript/components/VideoCalls/useDraggable.d.ts +19 -0
- package/lib/typescript/components/VideoCalls/useDraggable.d.ts.map +1 -0
- package/lib/typescript/helpers/createRoomFromApi.d.ts.map +1 -1
- package/lib/typescript/helpers/roomUserCount.d.ts +1 -0
- package/lib/typescript/helpers/roomUserCount.d.ts.map +1 -1
- package/lib/typescript/helpers/uniqueMembers.d.ts +11 -0
- package/lib/typescript/helpers/uniqueMembers.d.ts.map +1 -0
- package/lib/typescript/hooks/useLeaveRoom.d.ts +9 -0
- package/lib/typescript/hooks/useLeaveRoom.d.ts.map +1 -0
- package/lib/typescript/hooks/useLogout.d.ts.map +1 -1
- package/lib/typescript/i18n/strings.d.ts.map +1 -1
- package/lib/typescript/networking/api-requests/rooms.api.d.ts +8 -0
- package/lib/typescript/networking/api-requests/rooms.api.d.ts.map +1 -1
- package/lib/typescript/networking/callTokenStanza.d.ts.map +1 -1
- package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
- package/lib/typescript/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/MainComponents/ChatHeader.tsx +10 -8
- package/src/components/MainComponents/MessageContainer.tsx +14 -1
- package/src/components/MainComponents/RoomList.tsx +8 -14
- package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +16 -13
- package/src/components/Modals/UserProfileModal/UserProfileModal.tsx +167 -36
- package/src/components/RoomComponents/ChatRoomItem.tsx +8 -1
- package/src/components/VideoCalls/CallButtons.tsx +5 -1
- package/src/components/VideoCalls/VideoCallOverlay.tsx +156 -163
- package/src/components/VideoCalls/VideoCallSession.tsx +207 -378
- package/src/components/VideoCalls/callActions.ts +63 -0
- package/src/components/VideoCalls/callSessionController.ts +421 -0
- package/src/components/VideoCalls/useDraggable.ts +46 -0
- package/src/helpers/createRoomFromApi.tsx +2 -0
- package/src/helpers/incomingCallFromPush.ts +2 -2
- package/src/helpers/roomUserCount.ts +6 -2
- package/src/helpers/uniqueMembers.ts +26 -0
- package/src/hooks/useLeaveRoom.ts +42 -0
- package/src/hooks/useLogout.tsx +15 -10
- package/src/i18n/strings.ts +18 -0
- package/src/networking/api-requests/rooms.api.ts +28 -1
- package/src/networking/callTokenStanza.ts +46 -6
- package/src/networking/xmppClient.ts +10 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { store } from '../../roomStore';
|
|
2
|
+
import { declineIncomingCall, endCall } from '../../roomStore/callSlice';
|
|
3
|
+
import { addRoomMessage } from '../../roomStore/roomsSlice';
|
|
4
|
+
import { buildLocalCallLogMessage } from '../../helpers/callLogMessage';
|
|
5
|
+
import {
|
|
6
|
+
CallSignalState,
|
|
7
|
+
sendCallStateSignal,
|
|
8
|
+
} from '../../networking/callTokenStanza';
|
|
9
|
+
import { translateKey } from '../../i18n/strings';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Call teardown that doesn't need a mounted component.
|
|
13
|
+
*
|
|
14
|
+
* It used to live inside VideoCallOverlay, which meant only a rendered
|
|
15
|
+
* overlay could end a call. The LiveKit session now outlives the UI
|
|
16
|
+
* (callSessionController.ts), so the session's own "peer left" and
|
|
17
|
+
* "disconnected" events need the same teardown, with the same local log
|
|
18
|
+
* entry and the same signal to the peer.
|
|
19
|
+
*/
|
|
20
|
+
export const terminateActiveCall = (state: CallSignalState): void => {
|
|
21
|
+
// Write a local "call ended" log entry BEFORE endCall() wipes the call
|
|
22
|
+
// slice. The authoritative entry is a server call-state broadcast, but
|
|
23
|
+
// that isn't guaranteed to arrive, so we synthesize our own. It's
|
|
24
|
+
// deduped against any server copy by callId in addRoomMessage.
|
|
25
|
+
const snap = store.getState().call;
|
|
26
|
+
if (snap.phase === 'idle') {return;}
|
|
27
|
+
if (snap.roomJid && (snap.callId || snap.connectedAt)) {
|
|
28
|
+
const durationMs = snap.connectedAt
|
|
29
|
+
? Math.max(0, Date.now() - snap.connectedAt)
|
|
30
|
+
: 0;
|
|
31
|
+
store.dispatch(
|
|
32
|
+
addRoomMessage({
|
|
33
|
+
roomJID: snap.roomJid,
|
|
34
|
+
message: buildLocalCallLogMessage({
|
|
35
|
+
callId: snap.callId || '',
|
|
36
|
+
direction: snap.direction === 'incoming' ? 'incoming' : 'outgoing',
|
|
37
|
+
durationMs,
|
|
38
|
+
kind: snap.kind === 'audio' ? 'audio' : 'video',
|
|
39
|
+
selfXmppUsername:
|
|
40
|
+
store.getState().chatSettingStore.user?.xmppUsername || '',
|
|
41
|
+
}),
|
|
42
|
+
})
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
sendCallStateSignal(state);
|
|
46
|
+
store.dispatch(endCall());
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const declineActiveCall = (): void => {
|
|
50
|
+
sendCallStateSignal('declined');
|
|
51
|
+
store.dispatch(declineIncomingCall());
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** `t` for code outside React, resolved by the same rule as useT. */
|
|
55
|
+
export const translateCallString = (
|
|
56
|
+
key: string,
|
|
57
|
+
vars?: Record<string, string | number>
|
|
58
|
+
): string => {
|
|
59
|
+
const settings = store.getState().chatSettingStore as any;
|
|
60
|
+
const locale =
|
|
61
|
+
settings?.uiLocale || settings?.config?.i18n?.locale || settings?.langSource;
|
|
62
|
+
return translateKey(key, locale, settings?.config?.i18n?.strings, vars);
|
|
63
|
+
};
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import { Platform } from 'react-native';
|
|
3
|
+
import { store } from '../../roomStore';
|
|
4
|
+
import { setCallError, setCallPhase } from '../../roomStore/callSlice';
|
|
5
|
+
import { ensureLiveKitGlobals, loadLiveKit } from './livekitRuntime';
|
|
6
|
+
import { terminateActiveCall, translateCallString } from './callActions';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The LiveKit connection of the current call, owned outside React.
|
|
10
|
+
*
|
|
11
|
+
* It used to live in VideoCallSession's effect, so the connection was as
|
|
12
|
+
* long-lived as that component instance. Minimizing swapped the Modal for a
|
|
13
|
+
* floating View, React remounted the session, the effect cleanup
|
|
14
|
+
* disconnected, and the peer saw us leave (and hung up). The web SDK avoids
|
|
15
|
+
* that by keeping one LiveKitRoom element mounted across both layouts; the
|
|
16
|
+
* RN equivalent is to not tie the room to a component at all.
|
|
17
|
+
*
|
|
18
|
+
* Lifetime is driven by the call slice: a session exists while the call is
|
|
19
|
+
* 'connecting' or 'in-call' with a token, and is torn down by a store
|
|
20
|
+
* subscription the moment that stops being true (hangup, remote end,
|
|
21
|
+
* logout's resetCall), whether or not any call UI is mounted. Components
|
|
22
|
+
* only read the snapshot and call the controls.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface CallSessionSnapshot {
|
|
26
|
+
/** A LiveKit room exists for the current call. */
|
|
27
|
+
active: boolean;
|
|
28
|
+
connected: boolean;
|
|
29
|
+
/** Translated, dismissible camera/mic problem. Never fails the call. */
|
|
30
|
+
deviceError: string | null;
|
|
31
|
+
micOn: boolean;
|
|
32
|
+
cameraOn: boolean;
|
|
33
|
+
speakerOn: boolean;
|
|
34
|
+
/** `{ participant, publication, source }` for `<VideoTrack />`, live and unmuted only. */
|
|
35
|
+
remoteVideo: any;
|
|
36
|
+
localVideo: any;
|
|
37
|
+
remoteName: string | null;
|
|
38
|
+
remoteJoined: boolean;
|
|
39
|
+
/** Peer joined but has no live microphone. */
|
|
40
|
+
remoteMicMuted: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* When the call truly connected: the later of the two LiveKit `joinedAt`
|
|
43
|
+
* stamps. The server sets them, so both sides count from the same moment
|
|
44
|
+
* (web's useSharedCallElapsed). Null until the peer is in.
|
|
45
|
+
*/
|
|
46
|
+
anchorAt: number | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const IDLE: CallSessionSnapshot = {
|
|
50
|
+
active: false,
|
|
51
|
+
connected: false,
|
|
52
|
+
deviceError: null,
|
|
53
|
+
micOn: true,
|
|
54
|
+
cameraOn: false,
|
|
55
|
+
speakerOn: false,
|
|
56
|
+
remoteVideo: null,
|
|
57
|
+
localVideo: null,
|
|
58
|
+
remoteName: null,
|
|
59
|
+
remoteJoined: false,
|
|
60
|
+
remoteMicMuted: false,
|
|
61
|
+
anchorAt: null,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
interface Session {
|
|
65
|
+
key: string;
|
|
66
|
+
room: any;
|
|
67
|
+
kind: 'audio' | 'video';
|
|
68
|
+
disposed: boolean;
|
|
69
|
+
everJoined: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let session: Session | null = null;
|
|
73
|
+
let snapshot: CallSessionSnapshot = IDLE;
|
|
74
|
+
const listeners = new Set<() => void>();
|
|
75
|
+
let storeUnsubscribe: (() => void) | null = null;
|
|
76
|
+
|
|
77
|
+
const emit = (patch: Partial<CallSessionSnapshot>) => {
|
|
78
|
+
snapshot = { ...snapshot, ...patch };
|
|
79
|
+
listeners.forEach((listener) => listener());
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const subscribe = (listener: () => void) => {
|
|
83
|
+
listeners.add(listener);
|
|
84
|
+
return () => {
|
|
85
|
+
listeners.delete(listener);
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const getSnapshot = () => snapshot;
|
|
90
|
+
|
|
91
|
+
export const useCallSession = (): CallSessionSnapshot =>
|
|
92
|
+
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
93
|
+
|
|
94
|
+
const sessionKey = (livekitUrl: string, token: string) => `${livekitUrl}|${token}`;
|
|
95
|
+
|
|
96
|
+
// A device (camera/mic) permission/availability problem during enable,
|
|
97
|
+
// distinct from a connect failure. Mirrors the web SDK's humanizeDeviceError.
|
|
98
|
+
const humanizeDeviceError = (error: unknown): string => {
|
|
99
|
+
const name = (error as { name?: string })?.name || '';
|
|
100
|
+
const text = String((error as Error)?.message || '').toLowerCase();
|
|
101
|
+
if (name === 'NotAllowedError' || text.includes('permission') || text.includes('denied')) {
|
|
102
|
+
return translateCallString('call.error.deviceBlocked');
|
|
103
|
+
}
|
|
104
|
+
if (name === 'NotFoundError' || text.includes('not found')) {
|
|
105
|
+
return translateCallString('call.error.deviceNotFound');
|
|
106
|
+
}
|
|
107
|
+
if (name === 'NotReadableError' || text.includes('in use')) {
|
|
108
|
+
return translateCallString('call.error.deviceInUse');
|
|
109
|
+
}
|
|
110
|
+
return translateCallString('call.error.deviceGeneric');
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Only a live, unmuted track gets a video surface, so the stage never
|
|
114
|
+
// flips between an avatar and a black frame (web's hasLiveVideo).
|
|
115
|
+
const liveVideoRef = (participant: any, Track: any) => {
|
|
116
|
+
if (!participant) {return null;}
|
|
117
|
+
const publication = participant.getTrackPublication?.(Track.Source.Camera);
|
|
118
|
+
if (!publication?.track || publication.isMuted) {return null;}
|
|
119
|
+
return { participant, publication, source: Track.Source.Camera };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const joinedAtMs = (participant: any): number => {
|
|
123
|
+
const value = participant?.joinedAt;
|
|
124
|
+
const ms = value ? new Date(value).getTime() : 0;
|
|
125
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const resync = () => {
|
|
129
|
+
const current = session;
|
|
130
|
+
const runtime = loadLiveKit();
|
|
131
|
+
if (!current || current.disposed || !runtime) {return;}
|
|
132
|
+
const { Track } = runtime;
|
|
133
|
+
const room = current.room;
|
|
134
|
+
|
|
135
|
+
const remote = Array.from(room.remoteParticipants?.values?.() || [])[0] as any;
|
|
136
|
+
if (remote) {current.everJoined = true;}
|
|
137
|
+
|
|
138
|
+
const remoteMic = remote?.getTrackPublication?.(Track.Source.Microphone);
|
|
139
|
+
const localJoined = joinedAtMs(room.localParticipant);
|
|
140
|
+
const remoteJoinedAt = joinedAtMs(remote);
|
|
141
|
+
|
|
142
|
+
emit({
|
|
143
|
+
remoteJoined: !!remote,
|
|
144
|
+
remoteName: remote?.name || remote?.identity || null,
|
|
145
|
+
remoteVideo: liveVideoRef(remote, Track),
|
|
146
|
+
localVideo: liveVideoRef(room.localParticipant, Track),
|
|
147
|
+
remoteMicMuted: !!remote && (!remoteMic || remoteMic.isMuted !== false),
|
|
148
|
+
anchorAt:
|
|
149
|
+
remote && localJoined && remoteJoinedAt
|
|
150
|
+
? Math.max(localJoined, remoteJoinedAt)
|
|
151
|
+
: remote
|
|
152
|
+
? snapshot.anchorAt ?? Date.now()
|
|
153
|
+
: null,
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const stopCallSession = (): void => {
|
|
158
|
+
const current = session;
|
|
159
|
+
if (!current) {return;}
|
|
160
|
+
current.disposed = true;
|
|
161
|
+
session = null;
|
|
162
|
+
try {
|
|
163
|
+
current.room.removeAllListeners?.();
|
|
164
|
+
void current.room.disconnect();
|
|
165
|
+
} catch {
|
|
166
|
+
// Already gone.
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
void loadLiveKit()?.AudioSession?.stopAudioSession();
|
|
170
|
+
} catch {
|
|
171
|
+
// Never let teardown throw.
|
|
172
|
+
}
|
|
173
|
+
snapshot = IDLE;
|
|
174
|
+
listeners.forEach((listener) => listener());
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Tear the session down as soon as the call slice says there is no call to
|
|
178
|
+
// carry it any more, independent of what UI happens to be mounted.
|
|
179
|
+
const ensureStoreWatcher = () => {
|
|
180
|
+
if (storeUnsubscribe) {return;}
|
|
181
|
+
storeUnsubscribe = store.subscribe(() => {
|
|
182
|
+
if (!session) {return;}
|
|
183
|
+
const call = store.getState().call;
|
|
184
|
+
const live =
|
|
185
|
+
!!call.token && (call.phase === 'connecting' || call.phase === 'in-call');
|
|
186
|
+
if (!live || !session.key.endsWith(`|${call.token}`)) {
|
|
187
|
+
stopCallSession();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export interface StartCallSessionOptions {
|
|
193
|
+
token: string;
|
|
194
|
+
livekitUrl: string;
|
|
195
|
+
kind: 'audio' | 'video';
|
|
196
|
+
startWithMicOn?: boolean;
|
|
197
|
+
startWithCameraOn?: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Join the LiveKit room for the current call. Idempotent per (url, token):
|
|
202
|
+
* calling it again from a remounted overlay re-attaches to the same room.
|
|
203
|
+
*/
|
|
204
|
+
export const startCallSession = ({
|
|
205
|
+
token,
|
|
206
|
+
livekitUrl,
|
|
207
|
+
kind,
|
|
208
|
+
startWithMicOn = true,
|
|
209
|
+
startWithCameraOn = true,
|
|
210
|
+
}: StartCallSessionOptions): void => {
|
|
211
|
+
const key = sessionKey(livekitUrl, token);
|
|
212
|
+
if (session?.key === key) {return;}
|
|
213
|
+
stopCallSession();
|
|
214
|
+
ensureStoreWatcher();
|
|
215
|
+
|
|
216
|
+
const runtime = loadLiveKit();
|
|
217
|
+
if (!runtime) {
|
|
218
|
+
store.dispatch(setCallError(translateCallString('call.error.sdkMissing')));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (!token || !livekitUrl) {
|
|
222
|
+
store.dispatch(setCallError(translateCallString('call.error.missingConfig')));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
ensureLiveKitGlobals(runtime);
|
|
227
|
+
const { Room, RoomEvent, AudioSession } = runtime;
|
|
228
|
+
|
|
229
|
+
// adaptiveStream + dynacast keep mobile data and battery sane: the SDK
|
|
230
|
+
// drops resolution for a small tile and stops publishing layers nobody
|
|
231
|
+
// subscribes to. Both are safe defaults for 1:1.
|
|
232
|
+
const room = new Room({ adaptiveStream: true, dynacast: true });
|
|
233
|
+
const current: Session = { key, room, kind, disposed: false, everJoined: false };
|
|
234
|
+
session = current;
|
|
235
|
+
snapshot = {
|
|
236
|
+
...IDLE,
|
|
237
|
+
active: true,
|
|
238
|
+
micOn: startWithMicOn,
|
|
239
|
+
cameraOn: kind === 'video' && startWithCameraOn,
|
|
240
|
+
// Video calls belong on the loudspeaker, audio calls on the earpiece.
|
|
241
|
+
speakerOn: kind === 'video',
|
|
242
|
+
};
|
|
243
|
+
listeners.forEach((listener) => listener());
|
|
244
|
+
|
|
245
|
+
const onChange = () => {
|
|
246
|
+
if (!current.disposed) {resync();}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
room
|
|
250
|
+
.on(RoomEvent.TrackSubscribed, onChange)
|
|
251
|
+
.on(RoomEvent.TrackUnsubscribed, onChange)
|
|
252
|
+
.on(RoomEvent.TrackPublished, onChange)
|
|
253
|
+
.on(RoomEvent.TrackUnpublished, onChange)
|
|
254
|
+
.on(RoomEvent.LocalTrackPublished, onChange)
|
|
255
|
+
.on(RoomEvent.LocalTrackUnpublished, onChange)
|
|
256
|
+
.on(RoomEvent.TrackMuted, onChange)
|
|
257
|
+
.on(RoomEvent.TrackUnmuted, onChange)
|
|
258
|
+
.on(RoomEvent.ParticipantConnected, onChange)
|
|
259
|
+
.on(RoomEvent.MediaDevicesError, (error: unknown) => {
|
|
260
|
+
if (current.disposed) {return;}
|
|
261
|
+
emit({ deviceError: humanizeDeviceError(error) });
|
|
262
|
+
})
|
|
263
|
+
.on(RoomEvent.ParticipantDisconnected, () => {
|
|
264
|
+
if (current.disposed) {return;}
|
|
265
|
+
resync();
|
|
266
|
+
// 1:1 semantics, as web's PeerLeaveWatcher: once the peer had been
|
|
267
|
+
// in the room, their leaving IS the end of the call. Before they
|
|
268
|
+
// join (outgoing "Ringing...") an empty room is expected.
|
|
269
|
+
if (current.everJoined && (room.remoteParticipants?.size ?? 0) === 0) {
|
|
270
|
+
terminateActiveCall('ended');
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
.on(RoomEvent.Disconnected, () => {
|
|
274
|
+
if (current.disposed) {return;}
|
|
275
|
+
emit({ connected: false });
|
|
276
|
+
terminateActiveCall('ended');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
(async () => {
|
|
280
|
+
// Joining the LiveKit room is the only step whose failure should fail
|
|
281
|
+
// the call (bad token, network, server down). Device setup below is
|
|
282
|
+
// best-effort.
|
|
283
|
+
try {
|
|
284
|
+
// Claims audio focus and sets the right native audio mode. Must
|
|
285
|
+
// start BEFORE connecting or the first seconds route to the earpiece
|
|
286
|
+
// on Android even for a video call.
|
|
287
|
+
await AudioSession.startAudioSession();
|
|
288
|
+
await room.connect(livekitUrl, token);
|
|
289
|
+
} catch (error: any) {
|
|
290
|
+
if (current.disposed) {return;}
|
|
291
|
+
stopCallSession();
|
|
292
|
+
store.dispatch(
|
|
293
|
+
setCallError(
|
|
294
|
+
String(error?.message || '') ||
|
|
295
|
+
translateCallString('call.error.connectFailed')
|
|
296
|
+
)
|
|
297
|
+
);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (current.disposed) {return;}
|
|
301
|
+
|
|
302
|
+
// Devices are best-effort: a blocked permission or the iOS Simulator
|
|
303
|
+
// (no camera at all) must not kill a call that connected fine.
|
|
304
|
+
try {
|
|
305
|
+
await room.localParticipant.setMicrophoneEnabled(startWithMicOn);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (!current.disposed) {emit({ deviceError: humanizeDeviceError(error) });}
|
|
308
|
+
}
|
|
309
|
+
if (kind === 'video') {
|
|
310
|
+
try {
|
|
311
|
+
await room.localParticipant.setCameraEnabled(startWithCameraOn);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (!current.disposed) {
|
|
314
|
+
emit({ cameraOn: false, deviceError: humanizeDeviceError(error) });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (current.disposed) {return;}
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
await AudioSession.setAppleAudioConfiguration?.({
|
|
322
|
+
audioCategory: 'playAndRecord',
|
|
323
|
+
audioCategoryOptions: kind === 'video' ? ['defaultToSpeaker'] : [],
|
|
324
|
+
audioMode: kind === 'video' ? 'videoChat' : 'voiceChat',
|
|
325
|
+
});
|
|
326
|
+
} catch {
|
|
327
|
+
// iOS-only helper.
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
emit({ connected: true });
|
|
331
|
+
resync();
|
|
332
|
+
store.dispatch(setCallPhase('in-call'));
|
|
333
|
+
})();
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// ---- controls -----------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
export const toggleCallMic = async (): Promise<void> => {
|
|
339
|
+
const current = session;
|
|
340
|
+
if (!current) {return;}
|
|
341
|
+
const next = !snapshot.micOn;
|
|
342
|
+
emit({ micOn: next });
|
|
343
|
+
try {
|
|
344
|
+
await current.room.localParticipant.setMicrophoneEnabled(next);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (!current.disposed) {
|
|
347
|
+
emit({ micOn: !next, deviceError: humanizeDeviceError(error) });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
export const toggleCallCamera = async (): Promise<void> => {
|
|
353
|
+
const current = session;
|
|
354
|
+
if (!current) {return;}
|
|
355
|
+
const next = !snapshot.cameraOn;
|
|
356
|
+
emit({ cameraOn: next });
|
|
357
|
+
try {
|
|
358
|
+
await current.room.localParticipant.setCameraEnabled(next);
|
|
359
|
+
resync();
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (!current.disposed) {
|
|
362
|
+
emit({ cameraOn: !next, deviceError: humanizeDeviceError(error) });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
export const switchCallCamera = async (): Promise<void> => {
|
|
368
|
+
const current = session;
|
|
369
|
+
const runtime = loadLiveKit();
|
|
370
|
+
if (!current || !runtime) {return;}
|
|
371
|
+
try {
|
|
372
|
+
const publication = current.room.localParticipant.getTrackPublication?.(
|
|
373
|
+
runtime.Track.Source.Camera
|
|
374
|
+
);
|
|
375
|
+
const track = publication?.track;
|
|
376
|
+
// `restartTrack` with the flipped facingMode is the portable way to
|
|
377
|
+
// switch cameras; the RN SDK's `_switchCamera` helper is internal.
|
|
378
|
+
await track?.restartTrack?.({
|
|
379
|
+
facingMode:
|
|
380
|
+
track?.mediaStreamTrack?.getSettings?.().facingMode === 'environment'
|
|
381
|
+
? 'user'
|
|
382
|
+
: 'environment',
|
|
383
|
+
});
|
|
384
|
+
resync();
|
|
385
|
+
} catch {
|
|
386
|
+
// Single camera, or the track is mid-restart.
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
export const toggleCallSpeaker = async (): Promise<void> => {
|
|
391
|
+
const runtime = loadLiveKit();
|
|
392
|
+
if (!session || !runtime) {return;}
|
|
393
|
+
const next = !snapshot.speakerOn;
|
|
394
|
+
emit({ speakerOn: next });
|
|
395
|
+
try {
|
|
396
|
+
if (Platform.OS === 'android') {
|
|
397
|
+
// On Android `selectAudioOutput` device ids are the literal strings
|
|
398
|
+
// 'speaker' / 'earpiece'.
|
|
399
|
+
await runtime.AudioSession.selectAudioOutput(next ? 'speaker' : 'earpiece');
|
|
400
|
+
} else {
|
|
401
|
+
await runtime.AudioSession.setAppleAudioConfiguration?.({
|
|
402
|
+
audioCategory: 'playAndRecord',
|
|
403
|
+
audioCategoryOptions: next ? ['defaultToSpeaker'] : [],
|
|
404
|
+
audioMode: next ? 'videoChat' : 'voiceChat',
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
emit({ speakerOn: !next });
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
export const dismissCallDeviceError = (): void => {
|
|
413
|
+
emit({ deviceError: null });
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
/** Reset module state. Test seam only. */
|
|
417
|
+
export const __resetCallSessionForTests = (): void => {
|
|
418
|
+
stopCallSession();
|
|
419
|
+
storeUnsubscribe?.();
|
|
420
|
+
storeUnsubscribe = null;
|
|
421
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useMemo, useRef } from 'react';
|
|
2
|
+
import { Animated, PanResponder } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Drag a floating element around (web's useDraggable, for the self-view
|
|
6
|
+
* tile and the minimized call panel). The offset is kept between drags.
|
|
7
|
+
*
|
|
8
|
+
* The responder only claims the gesture once the finger actually moves a
|
|
9
|
+
* few points, so taps still reach the buttons inside the dragged element.
|
|
10
|
+
*/
|
|
11
|
+
export const useDraggable = () => {
|
|
12
|
+
const position = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current;
|
|
13
|
+
const offset = useRef({ x: 0, y: 0 });
|
|
14
|
+
|
|
15
|
+
const panHandlers = useMemo(
|
|
16
|
+
() =>
|
|
17
|
+
PanResponder.create({
|
|
18
|
+
onMoveShouldSetPanResponder: (_event, gesture) =>
|
|
19
|
+
Math.abs(gesture.dx) > 4 || Math.abs(gesture.dy) > 4,
|
|
20
|
+
onPanResponderGrant: () => {
|
|
21
|
+
position.setOffset(offset.current);
|
|
22
|
+
position.setValue({ x: 0, y: 0 });
|
|
23
|
+
},
|
|
24
|
+
onPanResponderMove: Animated.event(
|
|
25
|
+
[null, { dx: position.x, dy: position.y }],
|
|
26
|
+
{ useNativeDriver: false }
|
|
27
|
+
),
|
|
28
|
+
onPanResponderRelease: (_event, gesture) => {
|
|
29
|
+
offset.current = {
|
|
30
|
+
x: offset.current.x + gesture.dx,
|
|
31
|
+
y: offset.current.y + gesture.dy,
|
|
32
|
+
};
|
|
33
|
+
position.flattenOffset();
|
|
34
|
+
},
|
|
35
|
+
onPanResponderTerminate: () => {
|
|
36
|
+
position.flattenOffset();
|
|
37
|
+
},
|
|
38
|
+
}).panHandlers,
|
|
39
|
+
[position]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
panHandlers,
|
|
44
|
+
style: { transform: position.getTranslateTransform() },
|
|
45
|
+
};
|
|
46
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { uniqueMembers } from './uniqueMembers';
|
|
1
2
|
import { ApiRoom, IRoom } from '../types/types';
|
|
2
3
|
import type { LastMessage } from '../types/models/message.model';
|
|
3
4
|
|
|
@@ -81,6 +82,7 @@ export const createRoomFromApi = (
|
|
|
81
82
|
const lastMessage = mapApiLastMessage(room?.lastMessage, jid);
|
|
82
83
|
const roomData: IRoom = {
|
|
83
84
|
...room,
|
|
85
|
+
...(Array.isArray(room?.members) ? { members: uniqueMembers(room.members) } : {}),
|
|
84
86
|
id: (room as any)?._id || '',
|
|
85
87
|
jid,
|
|
86
88
|
name: room?.title || '',
|
|
@@ -58,7 +58,7 @@ export const handleCallPush = (
|
|
|
58
58
|
(room: any) =>
|
|
59
59
|
(room?.jid || '').split('@')[0] === parsed.roomBareName ||
|
|
60
60
|
room?.jid === parsed.roomRef
|
|
61
|
-
) as { jid?: string; name?: string } | undefined;
|
|
61
|
+
) as { jid?: string; name?: string; title?: string } | undefined;
|
|
62
62
|
|
|
63
63
|
const roomJid =
|
|
64
64
|
resolved?.jid ||
|
|
@@ -70,7 +70,7 @@ export const handleCallPush = (
|
|
|
70
70
|
store.dispatch(
|
|
71
71
|
setIncomingCallToken({
|
|
72
72
|
roomJid,
|
|
73
|
-
roomName: parsed.callerName || resolved?.name || null,
|
|
73
|
+
roomName: parsed.callerName || resolved?.title || resolved?.name || null,
|
|
74
74
|
roomBareName: parsed.roomBareName,
|
|
75
75
|
token: parsed.token,
|
|
76
76
|
kind: parsed.kind,
|
|
@@ -2,7 +2,7 @@ import { IRoom } from '../types/types';
|
|
|
2
2
|
|
|
3
3
|
type CountSource =
|
|
4
4
|
| Pick<IRoom, 'members' | 'usersCnt'>
|
|
5
|
-
| { members?: unknown; usersCnt?: unknown }
|
|
5
|
+
| { members?: unknown; usersCnt?: unknown; type?: unknown }
|
|
6
6
|
| null
|
|
7
7
|
| undefined;
|
|
8
8
|
|
|
@@ -17,11 +17,15 @@ export const getRoomUserCount = (room: CountSource): number => {
|
|
|
17
17
|
typeof room?.usersCnt === 'number' && Number.isFinite(room.usersCnt)
|
|
18
18
|
? room.usersCnt
|
|
19
19
|
: 0;
|
|
20
|
-
|
|
20
|
+
const total = Math.max(cnt, members);
|
|
21
|
+
// A private chat is two people by definition; the server's usersCnt has
|
|
22
|
+
// been seen to drift above that (3 for a 1:1 room).
|
|
23
|
+
return (room as { type?: unknown })?.type === 'private' ? Math.min(total, 2) : total;
|
|
21
24
|
};
|
|
22
25
|
|
|
23
26
|
/** True when the room is known to have more users than members loaded. */
|
|
24
27
|
export const isRoomMembersTruncated = (room: CountSource): boolean => {
|
|
28
|
+
if ((room as { type?: unknown })?.type === 'private') {return false;}
|
|
25
29
|
const members = Array.isArray(room?.members) ? room.members.length : 0;
|
|
26
30
|
const cnt =
|
|
27
31
|
typeof room?.usersCnt === 'number' && Number.isFinite(room.usersCnt)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop repeated entries from a room's member list, keeping the first.
|
|
3
|
+
*
|
|
4
|
+
* The same person could appear twice in a 1:1 chat ("Members: 3", four
|
|
5
|
+
* rows): the list came back from the server with repeats after the private
|
|
6
|
+
* chat was requested again for an existing room. Members are keyed by
|
|
7
|
+
* whatever identity the entry carries: xmppUsername, then _id, then the
|
|
8
|
+
* JID's localpart.
|
|
9
|
+
*/
|
|
10
|
+
export const uniqueMembers = <T,>(members: T[] | undefined | null): T[] => {
|
|
11
|
+
if (!Array.isArray(members)) {return [];}
|
|
12
|
+
const seen = new Set<string>();
|
|
13
|
+
const out: T[] = [];
|
|
14
|
+
for (const member of members) {
|
|
15
|
+
const m = member as any;
|
|
16
|
+
const key = String(
|
|
17
|
+
m?.xmppUsername || m?._id || String(m?.jid || '').split('@')[0] || ''
|
|
18
|
+
).trim();
|
|
19
|
+
if (key) {
|
|
20
|
+
if (seen.has(key)) {continue;}
|
|
21
|
+
seen.add(key);
|
|
22
|
+
}
|
|
23
|
+
out.push(member);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
import { useDispatch } from 'react-redux';
|
|
3
|
+
import { deleteRoom } from '../roomStore/roomsSlice';
|
|
4
|
+
import { leaveRoom } from '../networking/api-requests/rooms.api';
|
|
5
|
+
import { useXmppClient } from '../context/xmppProvider';
|
|
6
|
+
|
|
7
|
+
/** Rooms with a leave request in flight: a second tap waits for the first. */
|
|
8
|
+
const inFlight = new Set<string>();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Leave a chat, shared by the room-list swipe, the chat header and the chat
|
|
12
|
+
* profile. The server goes first: if it refuses, the chat stays where it is
|
|
13
|
+
* (dropping it locally would only have it come back on the next /chats/my).
|
|
14
|
+
* Then the unavailable presence to the MUC and the room out of the store.
|
|
15
|
+
* Resolves false on failure, so the caller can say so in its own way.
|
|
16
|
+
*/
|
|
17
|
+
export const useLeaveRoom = () => {
|
|
18
|
+
const dispatch = useDispatch();
|
|
19
|
+
const { client } = useXmppClient();
|
|
20
|
+
return useCallback(
|
|
21
|
+
async (jid: string): Promise<boolean> => {
|
|
22
|
+
if (!jid || inFlight.has(jid)) {return true;}
|
|
23
|
+
inFlight.add(jid);
|
|
24
|
+
try {
|
|
25
|
+
await leaveRoom(jid.split('@')[0]);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.warn('[ethora-rn] leave failed', error);
|
|
28
|
+
return false;
|
|
29
|
+
} finally {
|
|
30
|
+
inFlight.delete(jid);
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
client?.leaveTheRoomStanza?.(jid);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error('Failed to leave the room:', error);
|
|
36
|
+
}
|
|
37
|
+
dispatch(deleteRoom({ jid }));
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
[client, dispatch]
|
|
41
|
+
);
|
|
42
|
+
};
|