diff --git a/native/profile/linked-devices-header-right-button.react.js b/native/profile/linked-devices-header-right-button.react.js
index e01f12af4..4fe507612 100644
--- a/native/profile/linked-devices-header-right-button.react.js
+++ b/native/profile/linked-devices-header-right-button.react.js
@@ -1,32 +1,21 @@
// @flow
import { useNavigation } from '@react-navigation/native';
import * as React from 'react';
import HeaderRightTextButton from '../navigation/header-right-text-button.react.js';
import { SecondaryDeviceQRCodeScannerRouteName } from '../navigation/route-names.js';
-import Alert from '../utils/alert.js';
-import { deviceIsEmulator } from '../utils/url-utils.js';
function LinkedDevicesHeaderRightButton(): React.Node {
const { navigate } = useNavigation();
const navigateToQRCodeScanner = React.useCallback(() => {
- if (deviceIsEmulator) {
- Alert.alert(
- 'Unsupported device',
- "You can't access the QR code scanner on a simulator.",
- [{ text: 'OK' }],
- { cancelable: false },
- );
- return;
- }
navigate(SecondaryDeviceQRCodeScannerRouteName);
}, [navigate]);
return (
);
}
export default LinkedDevicesHeaderRightButton;
diff --git a/native/profile/profile.react.js b/native/profile/profile.react.js
index 598bdd62e..d605f4bd4 100644
--- a/native/profile/profile.react.js
+++ b/native/profile/profile.react.js
@@ -1,252 +1,253 @@
// @flow
import type {
StackNavigationProp,
StackNavigationHelpers,
StackHeaderProps,
StackHeaderLeftButtonProps,
} from '@react-navigation/core';
import { createStackNavigator } from '@react-navigation/stack';
import * as React from 'react';
import { View, useWindowDimensions } from 'react-native';
import AddKeyserver from './add-keyserver.react.js';
import AppearancePreferences from './appearance-preferences.react.js';
import BackupMenu from './backup-menu.react.js';
import BuildInfo from './build-info.react.js';
import DefaultNotificationsPreferences from './default-notifications-preferences.react.js';
import DeleteAccount from './delete-account.react.js';
import DevTools from './dev-tools.react.js';
import EditPassword from './edit-password.react.js';
import EmojiUserAvatarCreation from './emoji-user-avatar-creation.react.js';
import FarcasterAccountSettings from './farcaster-account-settings.react.js';
import KeyserverSelectionListHeaderRightButton from './keyserver-selection-list-header-right-button.react.js';
import KeyserverSelectionList from './keyserver-selection-list.react.js';
import LinkedDevicesHeaderRightButton from './linked-devices-header-right-button.react.js';
import LinkedDevices from './linked-devices.react.js';
import PrivacyPreferences from './privacy-preferences.react.js';
import ProfileHeader from './profile-header.react.js';
import ProfileScreen from './profile-screen.react.js';
import RelationshipList from './relationship-list.react.js';
import SecondaryDeviceQRCodeScanner from './secondary-device-qr-code-scanner.react.js';
import TunnelbrokerMenu from './tunnelbroker-menu.react.js';
import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react.js';
import CommunityDrawerButton from '../navigation/community-drawer-button.react.js';
import HeaderBackButton from '../navigation/header-back-button.react.js';
import {
ProfileScreenRouteName,
EditPasswordRouteName,
DeleteAccountRouteName,
EmojiUserAvatarCreationRouteName,
BuildInfoRouteName,
DevToolsRouteName,
AppearancePreferencesRouteName,
PrivacyPreferencesRouteName,
FriendListRouteName,
DefaultNotificationsPreferencesRouteName,
BlockListRouteName,
LinkedDevicesRouteName,
SecondaryDeviceQRCodeScannerRouteName,
BackupMenuRouteName,
KeyserverSelectionListRouteName,
AddKeyserverRouteName,
FarcasterAccountSettingsRouteName,
type ScreenParamList,
type ProfileParamList,
TunnelbrokerMenuRouteName,
} from '../navigation/route-names.js';
import type { TabNavigationProp } from '../navigation/tab-navigator.react.js';
import { useStyles, useColors } from '../themes/colors.js';
+import { deviceIsEmulator } from '../utils/url-utils.js';
const header = (props: StackHeaderProps) => ;
const profileScreenOptions = { headerTitle: 'Profile' };
const emojiAvatarCreationOptions = {
headerTitle: 'Emoji avatar selection',
headerBackTitleVisible: false,
};
const editPasswordOptions = { headerTitle: 'Change password' };
const deleteAccountOptions = { headerTitle: 'Delete account' };
const linkedDevicesOptions = {
headerTitle: 'Linked devices',
headerRight: () => ,
};
const keyserverSelectionListOptions = {
headerTitle: 'Keyservers',
headerRight: () => ,
};
const addKeyserverOptions = { headerTitle: 'Add keyserver' };
const backupMenuOptions = { headerTitle: 'Backup menu' };
const tunnelbrokerMenuOptions = { headerTitle: 'Tunnelbroker menu' };
const secondaryDeviceQRCodeScannerOptions = {
- headerTitle: '',
+ headerTitle: deviceIsEmulator ? 'Link device' : '',
headerBackTitleVisible: false,
};
const buildInfoOptions = { headerTitle: 'Build info' };
const devToolsOptions = { headerTitle: 'Developer tools' };
const appearanceOptions = { headerTitle: 'Appearance' };
const privacyOptions = { headerTitle: 'Privacy' };
const friendListOptions = { headerTitle: 'Friend list' };
const blockListOptions = { headerTitle: 'Block list' };
const defaultNotificationsOptions = { headerTitle: 'Default Notifications' };
const farcasterSettingsOptions = { headerTitle: 'Farcaster account' };
export type ProfileNavigationProp<
RouteName: $Keys = $Keys,
> = StackNavigationProp;
const Profile = createStackNavigator<
ScreenParamList,
ProfileParamList,
StackNavigationHelpers,
>();
type Props = {
+navigation: TabNavigationProp<'Profile'>,
...
};
function ProfileComponent(props: Props): React.Node {
const styles = useStyles(unboundStyles);
const colors = useColors();
const headerLeftButton = React.useCallback(
(headerProps: StackHeaderLeftButtonProps) =>
headerProps.canGoBack ? (
) : (
),
[props.navigation],
);
const { width: screenWidth } = useWindowDimensions();
const screenOptions = React.useMemo(
() => ({
header,
headerLeft: headerLeftButton,
headerStyle: {
backgroundColor: colors.tabBarBackground,
shadowOpacity: 0,
},
gestureEnabled: true,
gestureResponseDistance: screenWidth,
}),
[colors.tabBarBackground, headerLeftButton, screenWidth],
);
return (
);
}
const unboundStyles = {
keyboardAvoidingView: {
flex: 1,
},
view: {
flex: 1,
backgroundColor: 'panelBackground',
},
};
export default ProfileComponent;
diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js
index 46c147417..cb41e0f93 100644
--- a/native/profile/secondary-device-qr-code-scanner.react.js
+++ b/native/profile/secondary-device-qr-code-scanner.react.js
@@ -1,314 +1,428 @@
// @flow
import { useNavigation } from '@react-navigation/native';
import { BarCodeScanner, type BarCodeEvent } from 'expo-barcode-scanner';
import invariant from 'invariant';
import * as React from 'react';
-import { View } from 'react-native';
+import { View, Text } from 'react-native';
import { parseDataFromDeepLink } from 'lib/facts/links.js';
import {
useBroadcastDeviceListUpdates,
useGetAndUpdateDeviceListsForUsers,
} from 'lib/hooks/peer-list-hooks.js';
import { getForeignPeerDevices } from 'lib/selectors/user-selectors.js';
import { addDeviceToDeviceList } from 'lib/shared/device-list-utils.js';
import { IdentityClientContext } from 'lib/shared/identity-client-context.js';
import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js';
import {
backupKeysValidator,
type BackupKeys,
} from 'lib/types/backup-types.js';
import {
tunnelbrokerMessageTypes,
type TunnelbrokerMessage,
} from 'lib/types/tunnelbroker/messages.js';
import {
peerToPeerMessageTypes,
peerToPeerMessageValidator,
type PeerToPeerMessage,
} from 'lib/types/tunnelbroker/peer-to-peer-message-types.js';
import { qrCodeAuthMessageTypes } from 'lib/types/tunnelbroker/qr-code-auth-message-types.js';
import { rawDeviceListFromSignedList } from 'lib/utils/device-list-utils.js';
import { assertWithValidator } from 'lib/utils/validation-utils.js';
import type { ProfileNavigationProp } from './profile.react.js';
import { getBackupSecret } from '../backup/use-client-backup.js';
+import TextInput from '../components/text-input.react.js';
import { commCoreModule } from '../native-modules.js';
+import HeaderRightTextButton from '../navigation/header-right-text-button.react.js';
import type { NavigationRoute } from '../navigation/route-names.js';
import {
composeTunnelbrokerQRAuthMessage,
parseTunnelbrokerQRAuthMessage,
} from '../qr-code/qr-code-utils.js';
import { useSelector } from '../redux/redux-utils.js';
-import { useStyles } from '../themes/colors.js';
+import { useStyles, useColors } from '../themes/colors.js';
import Alert from '../utils/alert.js';
+import { deviceIsEmulator } from '../utils/url-utils.js';
const barCodeTypes = [BarCodeScanner.Constants.BarCodeType.qr];
type Props = {
+navigation: ProfileNavigationProp<'SecondaryDeviceQRCodeScanner'>,
+route: NavigationRoute<'SecondaryDeviceQRCodeScanner'>,
};
// eslint-disable-next-line no-unused-vars
function SecondaryDeviceQRCodeScanner(props: Props): React.Node {
const [hasPermission, setHasPermission] = React.useState(null);
const [scanned, setScanned] = React.useState(false);
+ const [urlInput, setURLInput] = React.useState('');
const styles = useStyles(unboundStyles);
- const navigation = useNavigation();
+ const { goBack, setOptions } = useNavigation();
const tunnelbrokerContext = useTunnelbroker();
const identityContext = React.useContext(IdentityClientContext);
invariant(identityContext, 'identity context not set');
const aes256Key = React.useRef(null);
const secondaryDeviceID = React.useRef(null);
const broadcastDeviceListUpdates = useBroadcastDeviceListUpdates();
const getAndUpdateDeviceListsForUsers = useGetAndUpdateDeviceListsForUsers();
const foreignPeerDevices = useSelector(getForeignPeerDevices);
+ const { panelForegroundTertiaryLabel } = useColors();
+
const tunnelbrokerMessageListener = React.useCallback(
async (message: TunnelbrokerMessage) => {
const encryptionKey = aes256Key.current;
const targetDeviceID = secondaryDeviceID.current;
if (!encryptionKey || !targetDeviceID) {
return;
}
if (message.type !== tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE) {
return;
}
let innerMessage: PeerToPeerMessage;
try {
innerMessage = JSON.parse(message.payload);
} catch {
return;
}
if (
!peerToPeerMessageValidator.is(innerMessage) ||
innerMessage.type !== peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE
) {
return;
}
const payload = await parseTunnelbrokerQRAuthMessage(
encryptionKey,
innerMessage,
);
if (
!payload ||
payload.type !==
qrCodeAuthMessageTypes.SECONDARY_DEVICE_REGISTRATION_SUCCESS
) {
return;
}
invariant(identityContext, 'identity context not set');
const { getAuthMetadata, identityClient } = identityContext;
const { userID, deviceID } = await getAuthMetadata();
if (!userID || !deviceID) {
throw new Error('missing auth metadata');
}
const deviceLists =
await identityClient.getDeviceListHistoryForUser(userID);
invariant(deviceLists.length > 0, 'received empty device list history');
const lastSignedDeviceList = deviceLists[deviceLists.length - 1];
const deviceList = rawDeviceListFromSignedList(lastSignedDeviceList);
const ownOtherDevices = deviceList.devices.filter(it => it !== deviceID);
await Promise.all([
broadcastDeviceListUpdates(
[...ownOtherDevices, ...foreignPeerDevices],
lastSignedDeviceList,
),
getAndUpdateDeviceListsForUsers([userID]),
]);
if (!payload.requestBackupKeys) {
Alert.alert('Device added', 'Device registered successfully', [
- { text: 'OK' },
+ { text: 'OK', onPress: goBack },
]);
return;
}
const backupSecret = await getBackupSecret();
const backupKeysResponse =
await commCoreModule.retrieveBackupKeys(backupSecret);
const backupKeys = assertWithValidator(
JSON.parse(backupKeysResponse),
backupKeysValidator,
);
const backupKeyMessage = await composeTunnelbrokerQRAuthMessage(
encryptionKey,
{
type: qrCodeAuthMessageTypes.BACKUP_DATA_KEY_MESSAGE,
...backupKeys,
},
);
await tunnelbrokerContext.sendMessage({
deviceID: targetDeviceID,
payload: JSON.stringify(backupKeyMessage),
});
Alert.alert('Device added', 'Device registered successfully', [
- { text: 'OK' },
+ { text: 'OK', onPress: goBack },
]);
},
[
identityContext,
broadcastDeviceListUpdates,
foreignPeerDevices,
getAndUpdateDeviceListsForUsers,
tunnelbrokerContext,
+ goBack,
],
);
React.useEffect(() => {
tunnelbrokerContext.addListener(tunnelbrokerMessageListener);
return () => {
tunnelbrokerContext.removeListener(tunnelbrokerMessageListener);
};
}, [tunnelbrokerMessageListener, tunnelbrokerContext]);
React.useEffect(() => {
void (async () => {
const { status } = await BarCodeScanner.requestPermissionsAsync();
setHasPermission(status === 'granted');
if (status !== 'granted') {
Alert.alert(
'No access to camera',
'Please allow Comm to access your camera in order to scan the QR code.',
[{ text: 'OK' }],
);
- navigation.goBack();
+ goBack();
}
})();
- }, [navigation]);
+ }, [goBack]);
+
+ const processDeviceListUpdate = React.useCallback(async () => {
+ try {
+ const { deviceID: primaryDeviceID, userID } =
+ await identityContext.getAuthMetadata();
+ if (!primaryDeviceID || !userID) {
+ throw new Error('missing auth metadata');
+ }
+ const encryptionKey = aes256Key.current;
+ const targetDeviceID = secondaryDeviceID.current;
+ if (!encryptionKey || !targetDeviceID) {
+ throw new Error('missing tunnelbroker message data');
+ }
+ await addDeviceToDeviceList(
+ identityContext.identityClient,
+ userID,
+ targetDeviceID,
+ );
+ const message = await composeTunnelbrokerQRAuthMessage(encryptionKey, {
+ type: qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS,
+ userID,
+ primaryDeviceID,
+ });
+ await tunnelbrokerContext.sendMessage({
+ deviceID: targetDeviceID,
+ payload: JSON.stringify(message),
+ });
+ } catch (err) {
+ console.log('Primary device error:', err);
+ Alert.alert('Adding device failed', 'Failed to update the device list', [
+ { text: 'OK' },
+ ]);
+ goBack();
+ }
+ }, [goBack, identityContext, tunnelbrokerContext]);
+
+ const onPressSave = React.useCallback(async () => {
+ if (!urlInput) {
+ return;
+ }
+
+ const parsedData = parseDataFromDeepLink(urlInput);
+ const keysMatch = parsedData?.data?.keys;
+
+ if (!parsedData || !keysMatch) {
+ Alert.alert(
+ 'Scan failed',
+ 'QR code does not contain a valid pair of keys.',
+ [{ text: 'OK' }],
+ );
+ return;
+ }
+
+ try {
+ const keys = JSON.parse(decodeURIComponent(keysMatch));
+ const { aes256, ed25519 } = keys;
+ aes256Key.current = aes256;
+ secondaryDeviceID.current = ed25519;
+ } catch (err) {
+ console.log('Failed to decode URI component:', err);
+ }
+ await processDeviceListUpdate();
+ }, [processDeviceListUpdate, urlInput]);
+
+ const buttonDisabled = !urlInput;
+ React.useEffect(() => {
+ if (!deviceIsEmulator) {
+ return;
+ }
+ setOptions({
+ headerRight: () => (
+
+ ),
+ });
+ }, [buttonDisabled, onPressSave, setOptions]);
+
+ const onChangeText = React.useCallback(
+ (text: string) => setURLInput(text),
+ [],
+ );
const onConnect = React.useCallback(
async (barCodeEvent: BarCodeEvent) => {
const { data } = barCodeEvent;
const parsedData = parseDataFromDeepLink(data);
const keysMatch = parsedData?.data?.keys;
if (!parsedData || !keysMatch) {
Alert.alert(
'Scan failed',
'QR code does not contain a valid pair of keys.',
[{ text: 'OK' }],
);
return;
}
- const keys = JSON.parse(decodeURIComponent(keysMatch));
- const { aes256, ed25519 } = keys;
- aes256Key.current = aes256;
- secondaryDeviceID.current = ed25519;
-
try {
- const { deviceID: primaryDeviceID, userID } =
- await identityContext.getAuthMetadata();
- if (!primaryDeviceID || !userID) {
- throw new Error('missing auth metadata');
- }
- await addDeviceToDeviceList(
- identityContext.identityClient,
- userID,
- ed25519,
- );
- const message = await composeTunnelbrokerQRAuthMessage(aes256, {
- type: qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS,
- userID,
- primaryDeviceID,
- });
- await tunnelbrokerContext.sendMessage({
- deviceID: ed25519,
- payload: JSON.stringify(message),
- });
+ const keys = JSON.parse(decodeURIComponent(keysMatch));
+ const { aes256, ed25519 } = keys;
+ aes256Key.current = aes256;
+ secondaryDeviceID.current = ed25519;
} catch (err) {
- console.log('Primary device error:', err);
- Alert.alert(
- 'Adding device failed',
- 'Failed to update the device list',
- [{ text: 'OK' }],
- );
- navigation.goBack();
+ console.log('Failed to decode URI component:', err);
}
+
+ await processDeviceListUpdate();
},
- [tunnelbrokerContext, identityContext, navigation],
+ [processDeviceListUpdate],
);
const onCancelScan = React.useCallback(() => setScanned(false), []);
const handleBarCodeScanned = React.useCallback(
(barCodeEvent: BarCodeEvent) => {
setScanned(true);
Alert.alert(
'Connect with this device?',
'Are you sure you want to allow this device to log in to your account?',
[
{
text: 'Cancel',
style: 'cancel',
onPress: onCancelScan,
},
{
text: 'Connect',
onPress: () => onConnect(barCodeEvent),
},
],
{ cancelable: false },
);
},
[onCancelScan, onConnect],
);
if (hasPermission === null) {
return ;
}
+ if (deviceIsEmulator) {
+ return (
+
+ QR Code URL
+
+
+
+
+ );
+ }
// Note: According to the BarCodeScanner Expo docs, we should adhere to two
// guidances when using the BarCodeScanner:
// 1. We should specify the potential barCodeTypes we want to scan for to
// minimize battery usage.
// 2. We should set the onBarCodeScanned callback to undefined if it scanned
// in order to 'pause' the scanner from continuing to scan while we
// process the data from the scan.
// See: https://docs.expo.io/versions/latest/sdk/bar-code-scanner
return (
-
+
);
}
const unboundStyles = {
- container: {
+ scannerContainer: {
flex: 1,
flexDirection: 'column',
justifyContent: 'center',
},
scanner: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
+ textInputContainer: {
+ paddingTop: 8,
+ },
+ header: {
+ color: 'panelBackgroundLabel',
+ fontSize: 12,
+ fontWeight: '400',
+ paddingBottom: 3,
+ paddingHorizontal: 24,
+ },
+ inputContainer: {
+ backgroundColor: 'panelForeground',
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ paddingHorizontal: 24,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ borderColor: 'panelForegroundBorder',
+ borderTopWidth: 1,
+ },
+ input: {
+ color: 'panelForegroundLabel',
+ flex: 1,
+ fontFamily: 'Arial',
+ fontSize: 16,
+ paddingVertical: 0,
+ borderBottomColor: 'transparent',
+ },
};
export default SecondaryDeviceQRCodeScanner;