Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions __tests__/editPubkyAction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { submitEditPubky } from '../src/utils/actions/editPubkyAction';
import { EBackupPreference, Pubky } from '../src/types/pubky';
import { getPubkySecretKey, signInToHomeserver, signUpToHomeserver } from '../src/utils/pubky';
import { setPubkyData } from '../src/store/slices/pubkysSlice';

jest.mock('../src/utils/pubky', () => ({
__esModule: true,
getPubkySecretKey: jest.fn(),
signInToHomeserver: jest.fn(),
signUpToHomeserver: jest.fn(),
}));

jest.mock('../src/store/slices/pubkysSlice', () => ({
__esModule: true,
setPubkyData: jest.fn(payload => ({ type: 'pubky/setPubkyData', payload })),
}));

const getPubkySecretKeyMock = getPubkySecretKey as jest.MockedFunction<typeof getPubkySecretKey>;
const signUpToHomeserverMock = signUpToHomeserver as jest.MockedFunction<typeof signUpToHomeserver>;
const signInToHomeserverMock = signInToHomeserver as jest.MockedFunction<typeof signInToHomeserver>;
const setPubkyDataMock = setPubkyData as jest.MockedFunction<typeof setPubkyData>;

const okResult = <T,>(value: T) => ({
isErr: () => false,
value,
});

const errResult = (message: string) => ({
isErr: () => true,
error: { message },
});

const pubkyData = (overrides: Partial<Pubky> = {}): Pubky => ({
name: 'Old name',
homeserver: 'pk:server',
signupToken: 'OLD-TOKEN',
signedUp: true,
image: '',
sessions: [],
backupPreference: EBackupPreference.encryptedFile,
isBackedUp: false,
...overrides,
});

describe('submitEditPubky', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('saves only metadata when already signed up to the selected homeserver', async () => {
const dispatch = jest.fn();

const result = await submitEditPubky({
pubky: 'pubky',
storedPubkyData: pubkyData(),
name: 'New name',
homeserver: 'pk:server',
signupToken: 'ABCD-1234-WXYZ',
dispatch,
});

expect(result).toEqual({ success: true });
expect(getPubkySecretKeyMock).not.toHaveBeenCalled();
expect(signUpToHomeserverMock).not.toHaveBeenCalled();
expect(signInToHomeserverMock).not.toHaveBeenCalled();
expect(setPubkyDataMock).toHaveBeenCalledWith({
pubky: 'pubky',
data: {
name: 'New name',
homeserver: 'pk:server',
},
});
});

it('returns token signup errors without falling back to signin', async () => {
const dispatch = jest.fn();
getPubkySecretKeyMock.mockResolvedValue(okResult({ secretKey: 'secret-key' }) as never);
signUpToHomeserverMock.mockResolvedValue(errResult('Token required') as never);

const result = await submitEditPubky({
pubky: 'pubky',
storedPubkyData: pubkyData({ homeserver: 'pk:old-server' }),
name: 'New name',
homeserver: 'pk:new-server',
signupToken: 'ABCD-1234-WXYZ',
dispatch,
});

expect(result).toEqual({ success: false, type: 'signup', message: 'Token required' });
expect(signInToHomeserverMock).not.toHaveBeenCalled();
expect(setPubkyDataMock).toHaveBeenCalledWith({
pubky: 'pubky',
data: { name: 'New name' },
});
});

it('clears homeserver signup state when removing the homeserver from the pubky', async () => {
const dispatch = jest.fn();

const result = await submitEditPubky({
pubky: 'pubky',
storedPubkyData: pubkyData(),
name: 'New name',
homeserver: '',
signupToken: '',
dispatch,
});

expect(result).toEqual({ success: true });
expect(getPubkySecretKeyMock).not.toHaveBeenCalled();
expect(setPubkyDataMock).toHaveBeenCalledWith({
pubky: 'pubky',
data: {
name: 'New name',
homeserver: '',
signedUp: false,
signupToken: '',
},
});
});
});
59 changes: 59 additions & 0 deletions src/components/EditPubky/EditPubkyFlowContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React, { createContext, ReactElement, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
interface EditPubkyFlowContextValue {
selectedHomeserver: string;
setSelectedHomeserver: (homeserver: string) => void;
signupTokensByHomeserver: Record<string, string>;
setSignupTokenForHomeserver: (homeserver: string, signupToken: string) => void;
resetVersion: number;
resetFlow: () => void;
}

const EditPubkyFlowContext = createContext<EditPubkyFlowContextValue | undefined>(undefined);

export const EditPubkyFlowProvider = ({ children }: { children: ReactNode }): ReactElement => {
const [selectedHomeserver, setSelectedHomeserver] = useState('');
const [signupTokensByHomeserver, setSignupTokensByHomeserver] = useState<Record<string, string>>({});
const [resetVersion, setResetVersion] = useState(0);

const setSignupTokenForHomeserver = useCallback((homeserver: string, signupToken: string): void => {
setSignupTokensByHomeserver(currentTokens => {
const nextTokens = { ...currentTokens };
if (signupToken) {
nextTokens[homeserver] = signupToken;
} else {
delete nextTokens[homeserver];
}
return nextTokens;
});
}, []);

const resetFlow = useCallback((): void => {
setSelectedHomeserver('');
setSignupTokensByHomeserver({});
setResetVersion(version => version + 1);
}, []);

const value = useMemo(
() => ({
selectedHomeserver,
setSelectedHomeserver,
signupTokensByHomeserver,
setSignupTokenForHomeserver,
resetVersion,
resetFlow,
}),
[resetFlow, resetVersion, selectedHomeserver, setSignupTokenForHomeserver, signupTokensByHomeserver],
);

return <EditPubkyFlowContext.Provider value={value}>{children}</EditPubkyFlowContext.Provider>;
};

export const useEditPubkyFlow = (): EditPubkyFlowContextValue => {
const context = useContext(EditPubkyFlowContext);

if (!context) {
throw new Error('useEditPubkyFlow must be used within EditPubkyFlowProvider');
}

return context;
};
216 changes: 216 additions & 0 deletions src/components/EditPubky/ServerForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import React, { ReactElement, useCallback, useMemo, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
import Button from '../Button.tsx';
import { SheetScreen } from '../Sheet.tsx';
import TextField from '../TextField.tsx';
import { TextXsM } from '../../theme/typography.ts';
import { Homeserver } from '../../types/pubky.ts';
import { getHomeservers } from '../../store/selectors/pubkySelectors.ts';
import { defaultHomeserver } from '../../store/shapes/pubky.ts';
import { addHomeserver, updateHomeserver } from '../../store/slices/pubkysSlice.ts';
import { CheckCircle } from '../../icons/index.ts';
import { formatSignupToken, isValidSignupTokenFormat } from '../../utils/helpers.ts';
import { useEditPubkyFlow } from './EditPubkyFlowContext.tsx';

interface ServerFormProps {
title: string;
onBackPress: () => void;
initialServer?: Homeserver;
testIDPrefix: 'AddServer' | 'EditServer';
}

const ServerForm = ({ title, onBackPress, initialServer, testIDPrefix }: ServerFormProps): ReactElement => {
const { t } = useTranslation();
const dispatch = useDispatch();
const knownHomeservers = useSelector(getHomeservers);
const { selectedHomeserver, setSelectedHomeserver, signupTokensByHomeserver, setSignupTokenForHomeserver } =
useEditPubkyFlow();
const [label, setLabel] = useState(initialServer?.name ?? '');
const [homeserver, setHomeserver] = useState(initialServer?.publicKey ?? '');
const [signupToken, setSignupToken] = useState(
initialServer?.publicKey ? (signupTokensByHomeserver[initialServer.publicKey] ?? '') : '',
);
const [homeserverError, setHomeserverError] = useState('');

const isDefaultServer = initialServer?.publicKey === defaultHomeserver.publicKey;

const isSignupTokenValid = useMemo(() => {
return !signupToken || isValidSignupTokenFormat(signupToken);
}, [signupToken]);

const hasValidSignupToken = Boolean(signupToken && isSignupTokenValid);

const canSave = useMemo(
() => Boolean(label.trim() && homeserver.trim() && isSignupTokenValid),
[label, homeserver, isSignupTokenValid],
);

const handleHomeserverChange = useCallback((text: string) => {
setHomeserver(text);
setHomeserverError('');
}, []);

const handleSignupTokenChange = useCallback((text: string) => {
setSignupToken(formatSignupToken(text));
}, []);

const inviteCheckmarkStyle = useAnimatedStyle(() => ({
opacity: withTiming(hasValidSignupToken ? 1 : 0, { duration: 300 }),
transform: [{ scale: withTiming(hasValidSignupToken ? 1 : 0.8, { duration: 300 }) }],
}));

const handleSave = useCallback(() => {
const publicKey = homeserver.trim();
const existingAddress = initialServer?.publicKey;

if (!label.trim() || !publicKey) {
return;
}

if (isDefaultServer) {
setSignupTokenForHomeserver(publicKey, signupToken.trim());
onBackPress();
return;
}

if (
knownHomeservers.some(server => server.publicKey === publicKey && server.publicKey !== existingAddress)
) {
setHomeserverError(t('addServer.duplicateError'));
return;
}

if (initialServer) {
dispatch(
updateHomeserver({
originalPublicKey: initialServer.publicKey,
homeserver: { name: label.trim(), publicKey },
}),
);
if (initialServer.publicKey !== publicKey) {
setSignupTokenForHomeserver(initialServer.publicKey, '');
}
if (selectedHomeserver === initialServer.publicKey) {
setSelectedHomeserver(publicKey);
}
} else {
dispatch(addHomeserver({ name: label.trim(), publicKey }));
}

setSignupTokenForHomeserver(publicKey, signupToken.trim());
onBackPress();
}, [
dispatch,
homeserver,
initialServer,
isDefaultServer,
knownHomeservers,
label,
onBackPress,
selectedHomeserver,
setSelectedHomeserver,
setSignupTokenForHomeserver,
signupToken,
t,
]);

return (
<SheetScreen id="edit-pubky" title={title} onBackPress={onBackPress}>
<View style={styles.section}>
<TextXsM>{t('addServer.label')}</TextXsM>
<TextField
value={label}
placeholder={t('addServer.labelPlaceholder')}
autoFocus={true}
testID={`${testIDPrefix}LabelInput`}
editable={!isDefaultServer}
containerStyle={[styles.input, isDefaultServer && styles.disabled]}
onChangeText={setLabel}
/>
</View>

<View style={styles.section}>
<TextXsM>{t('addServer.homeserverLabel')}</TextXsM>
<TextField
value={homeserver}
placeholder={t('addServer.homeserverPlaceholder')}
error={homeserverError}
errorStyle={styles.errorText}
testID={`${testIDPrefix}HomeserverInput`}
editable={!isDefaultServer}
containerStyle={[styles.input, isDefaultServer && styles.disabled]}
onChangeText={handleHomeserverChange}
/>
</View>

<View style={styles.section}>
<TextXsM>{t('addServer.inviteCodeLabel')}</TextXsM>
<TextField
containerStyle={styles.input}
value={signupToken}
placeholder={t('addServer.inviteCodePlaceholder')}
success={hasValidSignupToken}
autoCapitalize="characters"
autoCorrect={false}
maxLength={14}
testID={`${testIDPrefix}InviteCodeInput`}
onChangeText={handleSignupTokenChange}
rightElement={
<View style={styles.rightElement}>
<Animated.View style={inviteCheckmarkStyle}>
<CheckCircle colorName="pubkyApp" size={24} />
</Animated.View>
</View>
}
/>
</View>

<View style={styles.buttonContainer}>
<Button
text={t('common.cancel')}
size="large"
testID={`${testIDPrefix}CancelButton`}
onPress={onBackPress}
/>
<Button
text={t('common.save')}
size="large"
variant="secondary"
disabled={!canSave}
testID={`${testIDPrefix}SaveButton`}
onPress={handleSave}
/>
</View>
</SheetScreen>
);
};

const styles = StyleSheet.create({
section: {
marginBottom: 16,
},
input: {
marginTop: 8,
},
disabled: {
opacity: 0.32,
},
rightElement: {
paddingRight: 24,
},
errorText: {
textAlign: 'center',
marginTop: 4,
},
buttonContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
marginTop: 'auto',
},
});

export default ServerForm;
Loading
Loading