diff --git a/packages/react-native-sdk/__tests__/dogfood/meetingUiLifecycle.test.tsx b/packages/react-native-sdk/__tests__/dogfood/meetingUiLifecycle.test.tsx index 8643ff239a..d537ee466a 100644 --- a/packages/react-native-sdk/__tests__/dogfood/meetingUiLifecycle.test.tsx +++ b/packages/react-native-sdk/__tests__/dogfood/meetingUiLifecycle.test.tsx @@ -103,6 +103,9 @@ void DOGFOOD; const { MeetingUI } = jest.requireActual( '../../../../sample-apps/react-native/dogfood/src/components/MeetingUI', ) as { MeetingUI: React.ComponentType }; +const { LobbyE2EEContext } = jest.requireActual( + '../../../../sample-apps/react-native/dogfood/src/contexts/LobbyE2EEContext', +) as { LobbyE2EEContext: React.Context }; const { EncryptionManager } = jest.requireMock( '@stream-io/video-react-native-sdk', ) as { EncryptionManager: any }; @@ -129,19 +132,27 @@ const navigation = () => ({ goBack: jest.fn(), }); +/** The meeting's lobby key, as the meeting screen provides it. */ +const e2eeControls = { + encryptionKey: 'a-passphrase', + updateEncryptionKey: jest.fn(), +}; + +const meeting = (nav: any) => ( + + + +); + const renderMeeting = (nav = navigation()) => { - const view = render( - , - ); + const view = render(meeting(nav)); return { view, nav }; }; /** Swaps in a replacement Call, the way a changed callId or client does. */ const replaceCall = (view: any, nav: any, next: any) => { mockCall = next; - view.rerender( - , - ); + view.rerender(meeting(nav)); }; /** diff --git a/sample-apps/react-native/dogfood/App.tsx b/sample-apps/react-native/dogfood/App.tsx index 170ec7d683..265838ca65 100755 --- a/sample-apps/react-native/dogfood/App.tsx +++ b/sample-apps/react-native/dogfood/App.tsx @@ -4,7 +4,7 @@ import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { RootStackParamList } from './types'; import { - deeplinkCallId$, + deeplinkCall$, useDeepLinkEffect, } from './src/hooks/useDeepLinkEffect'; import { @@ -136,8 +136,8 @@ const StackNavigator = () => { } useEffect(() => { - const subscription = deeplinkCallId$.subscribe((prontoCallId) => { - if (prontoCallId) { + const subscription = deeplinkCall$.subscribe((deeplinkCall) => { + if (deeplinkCall) { setState({ appMode: 'Meeting' }); } }); diff --git a/sample-apps/react-native/dogfood/__tests__/e2ee.test.tsx b/sample-apps/react-native/dogfood/__tests__/e2ee.test.tsx index f0bf23bd36..c43411a8f8 100644 --- a/sample-apps/react-native/dogfood/__tests__/e2ee.test.tsx +++ b/sample-apps/react-native/dogfood/__tests__/e2ee.test.tsx @@ -4,7 +4,8 @@ import { type Call, EncryptionManager, } from '@stream-io/video-react-native-sdk'; -import { attachE2EEIfConfigured } from '../src/utils/e2ee'; +import { attachE2EE, attachE2EEIfConfigured } from '../src/utils/e2ee'; +import { LobbyE2EEContext } from '../src/contexts/LobbyE2EEContext'; import { mmkvStorage } from '../src/contexts/createStoreContext'; import { MeetingUI } from '../src/components/MeetingUI'; @@ -60,7 +61,10 @@ jest.mock('../src/components/CallErrorComponent', () => ({ const createCall = () => { const call = { currentUserId: undefined as string | undefined, - state: { callingState: 'idle' }, + state: { + callingState: 'idle', + settings: undefined as { encryption: { mode: string } } | undefined, + }, setE2EEManager: jest.fn(), join: jest.fn().mockResolvedValue(undefined), leave: jest.fn(async () => { @@ -105,15 +109,46 @@ it('remains a no-op without a configured key', async () => { expect(EncryptionManager.create).not.toHaveBeenCalled(); }); -it('shows the setup error and ends the meeting flow before joining', async () => { +it('does not read the ringing-call passphrase for a meeting', async () => { + mockCall.currentUserId = 'user'; + await expect( + attachE2EE(mockCall as unknown as Call, undefined), + ).resolves.toBeUndefined(); + await expect( + attachE2EE(mockCall as unknown as Call, ' '), + ).resolves.toBeUndefined(); + expect(mmkvStorage.getString).not.toHaveBeenCalled(); + expect(EncryptionManager.create).not.toHaveBeenCalled(); +}); + +const renderMeeting = (encryptionKey: string) => { type Props = React.ComponentProps; - await renderAsync( - , + return renderAsync( + + + , ); +}; + +it('joins an existing unencrypted call in the clear despite a brought key', async () => { + mockCall.currentUserId = 'user'; + mockCall.state.settings = { encryption: { mode: 'disabled' } }; + await renderMeeting('test-key'); + await act(async () => mockJoin()); + + expect(EncryptionManager.create).not.toHaveBeenCalled(); + expect(mockCall.setE2EEManager).not.toHaveBeenCalled(); + expect(mockCall.join).toHaveBeenCalledWith({ create: true }); +}); + +it('shows the setup error and ends the meeting flow before joining', async () => { + await renderMeeting('test-key'); await act(async () => mockJoin()); expect(mockCall.join).not.toHaveBeenCalled(); diff --git a/sample-apps/react-native/dogfood/__tests__/encryptedDeepLink.test.tsx b/sample-apps/react-native/dogfood/__tests__/encryptedDeepLink.test.tsx index f8bdb2c874..df77a747ca 100644 --- a/sample-apps/react-native/dogfood/__tests__/encryptedDeepLink.test.tsx +++ b/sample-apps/react-native/dogfood/__tests__/encryptedDeepLink.test.tsx @@ -7,7 +7,7 @@ import { import { useDeepLinkEffect, useEncryptedDeepLinkEffect, - deeplinkCallId$, + deeplinkCall$, } from '../src/hooks/useDeepLinkEffect'; const mockClient = { @@ -15,7 +15,6 @@ const mockClient = { }; let mockEnvironment = 'pronto'; let mockRoute = 'JoinMeetingScreen'; -const mockSetState = jest.fn(); // This app-level test runs before SDK builds in CI. Mock only the SDK API it uses. jest.mock( @@ -34,7 +33,6 @@ jest.mock( { virtual: true }, ); jest.mock('../src/contexts/AppContext', () => ({ - useAppGlobalStoreSetState: () => mockSetState, useAppGlobalStoreValue: (selector: any) => selector({ appEnvironment: mockEnvironment }), })); @@ -66,9 +64,8 @@ beforeEach(() => { mockEnvironment = 'pronto'; mockRoute = 'JoinMeetingScreen'; mockClient.state.calls = []; - mockSetState.mockReset(); removeListener.mockClear(); - deeplinkCallId$.next(undefined); + deeplinkCall$.next(undefined); jest.spyOn(Alert, 'alert').mockImplementation(() => {}); jest.spyOn(Linking, 'getInitialURL').mockResolvedValue(null); jest.spyOn(Linking, 'addEventListener').mockImplementation((_, handler) => { @@ -88,24 +85,22 @@ const listen = async () => { }; it.each(encryptedLinks)( - 'retains a $environment cold-start key through login and saves it before navigation', + 'retains a $environment cold-start key through login and hands it to that meeting only', async ({ environment, url }) => { mockEnvironment = environment; jest.mocked(Linking.getInitialURL).mockResolvedValue(url); await listen(); - expect(mockSetState).not.toHaveBeenCalled(); - expect(deeplinkCallId$.value).toBeUndefined(); - const subscription = deeplinkCallId$.subscribe((callId) => { - if (callId) { - expect(mockSetState).toHaveBeenCalledWith({ e2eeKeyInput: 'new-key' }); - } - }); + expect(deeplinkCall$.value).toBeUndefined(); const view = renderHook(useClientLinks); - expect(deeplinkCallId$.value).toBe('call-123'); + expect(deeplinkCall$.value).toEqual({ + callId: 'call-123', + encryptionKey: 'new-key', + }); + // consumed by the join screen, and not replayed on the next login + deeplinkCall$.next(undefined); view.unmount(); renderHook(useClientLinks); - expect(mockSetState).toHaveBeenCalledTimes(1); - subscription.unsubscribe(); + expect(deeplinkCall$.value).toBeUndefined(); }, ); @@ -117,13 +112,15 @@ it.each(['https', 'streamvideo'])( openURL( `${scheme}://pronto.getstream.io/join/call-123?encryption_key=%20new%2Bkey%20`, ); - expect(mockSetState).toHaveBeenCalledWith({ e2eeKeyInput: 'new+key' }); - expect(deeplinkCallId$.value).toBe('call-123'); + expect(deeplinkCall$.value).toEqual({ + callId: 'call-123', + encryptionKey: 'new+key', + }); }, ); it.each(encryptedLinks)( - 'alerts outside $environment without saving or navigating, and does not retry on switching', + 'alerts outside $environment without navigating, and does not retry on switching', async ({ environment, url }) => { await listen(); mockEnvironment = environment === 'pronto' ? 'demo' : 'pronto'; @@ -134,8 +131,7 @@ it.each(encryptedLinks)( ); mockEnvironment = environment; view.rerender({}); - expect(mockSetState).not.toHaveBeenCalled(); - expect(deeplinkCallId$.value).toBeUndefined(); + expect(deeplinkCall$.value).toBeUndefined(); }, ); @@ -154,8 +150,7 @@ it.each([ expect(Alert.alert).toHaveBeenCalledWith( 'Leave the current call before opening this link', ); - expect(mockSetState).not.toHaveBeenCalled(); - expect(deeplinkCallId$.value).toBeUndefined(); + expect(deeplinkCall$.value).toBeUndefined(); }, ); @@ -169,8 +164,7 @@ it.each(['MeetingScreen', 'GuestMeetingScreen'])( expect(Alert.alert).toHaveBeenCalledWith( 'Leave the current call before opening this link', ); - expect(mockSetState).not.toHaveBeenCalled(); - expect(deeplinkCallId$.value).toBeUndefined(); + expect(deeplinkCall$.value).toBeUndefined(); }, ); @@ -179,13 +173,12 @@ it.each([ 'https://getstream.io/video/demos/join/call-123', 'https://pronto-staging.getstream.io/join/call-123', 'https://example.com/join/call-123', -])('preserves the saved key for an ordinary link: %s', async (url) => { +])('opens an ordinary link without a key: %s', async (url) => { await listen(); renderHook(useClientLinks); openURL(url); - expect(mockSetState).not.toHaveBeenCalled(); expect(Alert.alert).not.toHaveBeenCalled(); - expect(deeplinkCallId$.value).toBe('call-123'); + expect(deeplinkCall$.value).toEqual({ callId: 'call-123' }); }); it.each([ @@ -198,14 +191,12 @@ it.each([ async (url) => { jest.mocked(Linking.getInitialURL).mockResolvedValue(url); await listen(); - expect(deeplinkCallId$.value).toBeUndefined(); + expect(deeplinkCall$.value).toBeUndefined(); renderHook(useClientLinks); - expect(mockSetState).not.toHaveBeenCalled(); - expect(deeplinkCallId$.value).toBeUndefined(); + expect(deeplinkCall$.value).toBeUndefined(); openURL(url); - expect(mockSetState).not.toHaveBeenCalled(); - expect(deeplinkCallId$.value).toBeUndefined(); + expect(deeplinkCall$.value).toBeUndefined(); }, ); @@ -214,8 +205,7 @@ it('ignores malformed links and removes its listener on unmount', async () => { renderHook(useClientLinks); openURL('not a URL'); openURL('https://pronto.getstream.io/join/invalid!id?encryption_key=unused'); - expect(mockSetState).not.toHaveBeenCalled(); - expect(deeplinkCallId$.value).toBeUndefined(); + expect(deeplinkCall$.value).toBeUndefined(); await cleanupAsync(); expect(removeListener).toHaveBeenCalledTimes(1); }); diff --git a/sample-apps/react-native/dogfood/__tests__/inviteLink.test.ts b/sample-apps/react-native/dogfood/__tests__/inviteLink.test.ts new file mode 100644 index 0000000000..0657212543 --- /dev/null +++ b/sample-apps/react-native/dogfood/__tests__/inviteLink.test.ts @@ -0,0 +1,15 @@ +import { getInviteUrl } from '../src/utils/inviteLink'; + +it.each([ + ['pronto', 'https://pronto.getstream.io/join/call-123'], + ['pronto-staging', 'https://pronto-staging.getstream.io/join/call-123'], + ['demo', 'https://getstream.io/video/demos/join/call-123'], +] as const)('links a %s call without a key', (environment, url) => { + expect(getInviteUrl(environment, 'call-123')).toBe(url); +}); + +it('carries the shared key, encoded, so the invitee can decrypt', () => { + expect(getInviteUrl('pronto', 'call-123', 'amber+otter canyon')).toBe( + 'https://pronto.getstream.io/join/call-123?encryption_key=amber%2Botter%20canyon', + ); +}); diff --git a/sample-apps/react-native/dogfood/src/components/E2EEKeyInput.tsx b/sample-apps/react-native/dogfood/src/components/E2EEKeyInput.tsx index 741289b85b..b9466c160a 100644 --- a/sample-apps/react-native/dogfood/src/components/E2EEKeyInput.tsx +++ b/sample-apps/react-native/dogfood/src/components/E2EEKeyInput.tsx @@ -8,9 +8,14 @@ import { import { appTheme } from '../theme'; import { TextInput } from './TextInput'; import { isE2EESupported } from '../utils/e2ee'; +import { useAppI18n } from '../hooks/useAppI18n'; /** - * Debug entry for the end-to-end encryption key. + * Entry for the ringing-call end-to-end encryption passphrase. + * + * Ringing calls are joined by the SDK (often from CallKit/Telecom, possibly + * with the app killed), so unlike meetings there is no lobby in which to enter a + * per-call key: caller and callee configure the same passphrase here up front. * * Keys are the app's business, not the SDK's, so the passphrase never leaves * this app: it is stretched locally and installed as a shared key. Interop with @@ -22,6 +27,7 @@ export const E2EEKeyInput = () => { const stored = useAppGlobalStoreValue((store) => store.e2eeKeyInput) ?? ''; const [draft, setDraft] = useState(stored); const styles = useStyles(); + const { t } = useAppI18n(); useEffect(() => { setDraft(stored); @@ -49,13 +55,16 @@ export const E2EEKeyInput = () => { return ( - End-to-end encryption + + {t('joinCall.e2eeKey.label', 'End-to-end encryption for ringing calls')} + {status} @@ -70,6 +79,11 @@ const useStyles = () => { container: { marginTop: appTheme.spacing.lg, }, + // The shared TextInput is `flex: 1` for row layouts; in this column it + // would collapse to zero height. + input: { + flex: 0, + }, label: { color: theme.colors.textPrimary, fontSize: 14, diff --git a/sample-apps/react-native/dogfood/src/components/E2EEKeyNotification.tsx b/sample-apps/react-native/dogfood/src/components/E2EEKeyNotification.tsx index 12390c87e4..2099d6564b 100644 --- a/sample-apps/react-native/dogfood/src/components/E2EEKeyNotification.tsx +++ b/sample-apps/react-native/dogfood/src/components/E2EEKeyNotification.tsx @@ -7,6 +7,7 @@ import { useAppGlobalStoreSetState, useAppGlobalStoreValue, } from '../contexts/AppContext'; +import { useLobbyE2EE } from '../contexts/LobbyE2EEContext'; import { updateE2EESharedKeys } from '../utils/e2ee'; import { appTheme } from '../theme'; import { TextInput } from './TextInput'; @@ -28,7 +29,10 @@ export const E2EEKeyNotification = () => { const call = useCall(); const { t } = useAppI18n(); const setState = useAppGlobalStoreSetState(); + const e2ee = useLobbyE2EE(); const storedKey = useAppGlobalStoreValue((store) => store.e2eeKeyInput) ?? ''; + // A meeting carries its own key; a ringing call uses the persisted one. + const currentKey = e2ee ? (e2ee.encryptionKey ?? '') : storedKey; const [dismissed, setDismissed] = useState(false); const [draftKey, setDraftKey] = useState(''); const styles = useStyles(); @@ -46,10 +50,14 @@ export const E2EEKeyNotification = () => { const applyKey = () => { const key = draftKey.trim(); if (!key || !call) return; - // Persist as well as apply: the stored value is what the next call is - // created and encrypted with. - setState({ e2eeKeyInput: key }); - updateE2EESharedKeys(call, key); + if (e2ee) { + e2ee.updateEncryptionKey(key); + } else { + // Persist as well as apply: the stored value is what the next ringing + // call is created and encrypted with. + setState({ e2eeKeyInput: key }); + updateE2EESharedKeys(call, key); + } setDraftKey(''); }; @@ -82,7 +90,7 @@ export const E2EEKeyNotification = () => { { + const { t } = useAppI18n(); + const e2ee = useLobbyE2EE(); + const call = useCall(); + const environment = useAppGlobalStoreValue((store) => store.appEnvironment); + const { useCallSettings, useCallCreatedBy } = useCallStateHooks(); + const settings = useCallSettings(); + const createdBy = useCallCreatedBy(); + const connectedUser = useConnectedUser(); + const { theme } = useTheme(); + const styles = useStyles(); + + const [copied, setCopied] = useState(false); + const copyResetRef = useRef>(undefined); + useEffect(() => () => clearTimeout(copyResetRef.current), []); + // A joiner who arrived with the key should not be able to edit (and break) a + // working key; one who did not has to type it in. + const [arrivedWithKey] = useState(!!e2ee?.encryptionKey?.trim()); + + const encryptionKey = e2ee?.encryptionKey ?? ''; + + const onRefresh = useCallback(() => { + e2ee?.updateEncryptionKey(getRandomWords(3)); + }, [e2ee]); + + const onCopyLink = useCallback(() => { + if (!call) return; + Clipboard.setString( + getInviteUrl(environment, call.id, encryptionKey.trim() || undefined), + ); + setCopied(true); + clearTimeout(copyResetRef.current); + copyResetRef.current = setTimeout(() => setCopied(false), 2000); + }, [call, environment, encryptionKey]); + + // Creator vs joiner and encrypted vs plain are only known once the call + // response arrives, so until then nothing renders. + if (!e2ee || !settings || !createdBy) return null; + + const isEncryptedCall = isCallEncrypted(settings); + if (!isEncryptedCall) { + // A key was brought along for a call that already exists unencrypted: + // encryption cannot be added now, so say so rather than joining in the + // clear without a word. + if (!e2ee.encryptionKey?.trim()) return null; + return ( + + + + + + {t( + 'encryption.lobby.notEncrypted.title', + 'This call is not end-to-end encrypted', + )} + + + {t( + 'encryption.lobby.notEncrypted.description', + 'It was created without encryption, so your key will not be used. Start a new call to encrypt it.', + )} + + + + + ); + } + + const isCreator = + !!connectedUser && !!createdBy && createdBy.id === connectedUser.id; + const needsKey = !isCreator && !arrivedWithKey; + const keyReadOnly = !isCreator && arrivedWithKey; + const creatorName = createdBy.name || createdBy.id; + + return ( + + + + + + {t( + 'encryption.lobby.endToEndEncryption.title', + 'End-to-end encryption', + )} + + + {needsKey + ? t( + 'encryption.lobby.enterSharedKey.description', + 'Enter the shared key to join', + ) + : isCreator + ? t( + 'encryption.lobby.onlyPeopleWithKey.description', + 'Only people with the key can join', + ) + : `${t('encryption.lobby.enabledBy.text', 'Enabled by')} ${creatorName}`} + + + + + + + {t('encryption.lobby.sharedKey.label', 'Shared key')} + + + + {isCreator && ( + + ↻ + + )} + + + {copied + ? t('common.copied.label', 'Copied') + : t('common.copyLink.label', 'Copy link')} + + + + + {needsKey + ? t( + 'encryption.lobby.askCreatorForKey.description', + 'Ask the call creator for the shared key, then enter it here.', + ) + : t( + 'encryption.lobby.shareKeyWarning.description', + 'Anyone with this key (or the invite link that contains it) can join the call. Share it only with people you trust.', + )} + + + + ); +}; + +const useStyles = () => { + const { theme } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignSelf: 'stretch', + backgroundColor: theme.colors.sheetSecondary, + borderColor: theme.colors.buttonDisabled, + borderRadius: 12, + borderWidth: 1, + marginTop: appTheme.spacing.md, + padding: appTheme.spacing.md, + }, + containerOn: { + borderColor: theme.colors.buttonPrimary, + }, + switchRow: { + alignItems: 'center', + flexDirection: 'row', + gap: appTheme.spacing.md, + }, + text: { + flex: 1, + }, + title: { + color: theme.colors.textPrimary, + fontSize: 15, + fontWeight: '600', + }, + subtitle: { + color: appTheme.colors.light_gray, + fontSize: 13, + marginTop: 2, + }, + details: { + marginTop: appTheme.spacing.md, + }, + keyLabel: { + color: appTheme.colors.light_gray, + fontSize: 12, + fontWeight: '500', + }, + keyRow: { + alignItems: 'center', + flexDirection: 'row', + gap: appTheme.spacing.sm, + }, + iconButton: { + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: appTheme.spacing.sm, + }, + iconButtonText: { + color: theme.colors.textPrimary, + fontSize: 22, + }, + copyButton: { + backgroundColor: theme.colors.buttonPrimary, + borderRadius: 8, + paddingHorizontal: appTheme.spacing.md, + paddingVertical: appTheme.spacing.sm, + }, + copyButtonText: { + color: theme.colors.textPrimary, + fontSize: 14, + fontWeight: '600', + }, + hint: { + color: appTheme.colors.light_gray, + fontSize: 12, + marginTop: appTheme.spacing.xs, + }, + }), + [theme], + ); +}; diff --git a/sample-apps/react-native/dogfood/src/components/LobbyViewComponent.tsx b/sample-apps/react-native/dogfood/src/components/LobbyViewComponent.tsx index 269d4f0142..dfb88e0857 100644 --- a/sample-apps/react-native/dogfood/src/components/LobbyViewComponent.tsx +++ b/sample-apps/react-native/dogfood/src/components/LobbyViewComponent.tsx @@ -1,18 +1,28 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { useNavigation, useRoute } from '@react-navigation/native'; import { JoinCallButton, + type JoinCallButtonProps, Lobby, useCallStateHooks, - useTheme, } from '@stream-io/video-react-native-sdk'; -import React, { useCallback } from 'react'; -import { Pressable, StyleSheet, View, Text } from 'react-native'; +import React from 'react'; +import { + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, +} from 'react-native'; import { MeetingStackParamList } from '../../types'; import { appTheme } from '../theme'; import { useOrientation } from '../hooks/useOrientation'; import { useAppI18n } from '../hooks/useAppI18n'; -import { isCallEncrypted, isE2EEConfigured } from '../utils/e2ee'; -import { LockIcon } from './LockIcon'; +import { isCallEncrypted } from '../utils/e2ee'; +import { useLobbyE2EE } from '../contexts/LobbyE2EEContext'; +import { LobbyEncryption } from './LobbyEncryption'; +import { Button } from './Button'; type LobbyViewComponentType = NativeStackScreenProps< MeetingStackParamList, @@ -22,72 +32,86 @@ type LobbyViewComponentType = NativeStackScreenProps< onJoinCallHandler: () => void; }; -export const LobbyViewComponent = ({ - callId, - navigation, - route, - onJoinCallHandler, -}: LobbyViewComponentType) => { +type LobbyNavigation = LobbyViewComponentType['navigation']; + +/** + * The lobby's Join area: the (key-gated) Join button, the encryption control and + * the guest escape hatch. + * + * A module-level component on purpose. `Lobby` renders whatever it is handed as + * `JoinCallButton` as a component type, so one rebuilt on every state change + * would remount the encryption control, wiping its state and dismissing the + * keyboard mid-typing. + */ +const LobbyJoinSection = ({ onJoinCallHandler }: JoinCallButtonProps) => { const { t } = useAppI18n(); - const orientation = useOrientation(); - const { theme } = useTheme(); + const navigation = useNavigation(); + const route = useRoute(); const { useCallSettings } = useCallStateHooks(); const settings = useCallSettings(); + const e2ee = useLobbyE2EE(); // An `auto-on` call requires E2EE of every participant, so the backend rejects - // a join without it. Say so here rather than letting the join fail: the key is - // entered on the previous screen, and `useE2eeEnabled()` is still false at this - // point because the SFU has not been asked yet. - const needsEncryptionKey = isCallEncrypted(settings) && !isE2EEConfigured(); + // a non-e2ee join: gate the Join button until a key is provided. + const needsEncryptionKey = + isCallEncrypted(settings) && !e2ee?.encryptionKey?.trim(); - const JoinCallButtonComponent = useCallback(() => { - return ( - <> - {needsEncryptionKey && ( - - - - {t( - 'lobbyView.encryptionKeyRequired.text', - 'This call is end-to-end encrypted. Set a meeting key before joining.', - )} - - - )} + return ( + <> + {needsEncryptionKey ? ( +