Skip to content
Open
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
66 changes: 54 additions & 12 deletions packages/happy-app/sources/components/ActiveSessionsGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Text } from '@/components/StyledText';
import { useRouter } from 'expo-router';
import { Session, Machine } from '@/sync/storageTypes';
import { Ionicons } from '@expo/vector-icons';
import { getSessionName, useSessionStatus, getSessionAvatarId, formatPathRelativeToHome } from '@/utils/sessionUtils';
import { getSessionName, getSessionDefaultName, getSessionCustomName, useSessionStatus, getSessionAvatarId, formatPathRelativeToHome } from '@/utils/sessionUtils';
import { Avatar } from './Avatar';
import { Typography } from '@/constants/Typography';
import { StatusDot } from './StatusDot';
Expand Down Expand Up @@ -178,12 +178,19 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({
...Typography.default(),
},
swipeAction: {
width: 112,
width: 80,
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.colors.status.error,
},
swipeActionRename: {
width: 80,
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.colors.textSecondary,
},
swipeActionText: {
marginTop: 4,
fontSize: 12,
Expand Down Expand Up @@ -367,6 +374,27 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi
);
}, [performArchive]);

const defaultName = getSessionDefaultName(session);
const customName = getSessionCustomName(session);

const handleRename = React.useCallback(async () => {
const newName = await Modal.prompt(
t('sessionInfo.renameSession'),
t('sessionInfo.renameSessionPrompt'),
{
placeholder: defaultName,
defaultValue: customName || '',
confirmText: t('common.rename'),
cancelText: t('common.cancel'),
maxLength: 100
}
);

if (newName !== null) {
storage.getState().updateSessionCustomName(session.id, newName || null);
}
}, [session.id, customName, defaultName]);

const avatarId = React.useMemo(() => {
return getSessionAvatarId(session);
}, [session]);
Expand Down Expand Up @@ -470,16 +498,30 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi
}

const renderRightActions = () => (
<Pressable
style={styles.swipeAction}
onPress={handleArchive}
disabled={archivingSession}
>
<Ionicons name="archive-outline" size={20} color="#FFFFFF" />
<Text style={styles.swipeActionText} numberOfLines={2}>
{t('sessionInfo.archiveSession')}
</Text>
</Pressable>
<View style={{ flexDirection: 'row' }}>
<Pressable
style={styles.swipeActionRename}
onPress={() => {
swipeableRef.current?.close();
handleRename();
}}
>
<Ionicons name="pencil-outline" size={20} color="#FFFFFF" />
<Text style={styles.swipeActionText} numberOfLines={2}>
{t('sessionInfo.renameSession')}
</Text>
</Pressable>
<Pressable
style={styles.swipeAction}
onPress={handleArchive}
disabled={archivingSession}
>
<Ionicons name="archive-outline" size={20} color="#FFFFFF" />
<Text style={styles.swipeActionText} numberOfLines={2}>
{t('sessionInfo.archiveSession')}
</Text>
</Pressable>
</View>
);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Text } from '@/components/StyledText';
import { router, useRouter } from 'expo-router';
import { Session, Machine } from '@/sync/storageTypes';
import { Ionicons } from '@expo/vector-icons';
import { getSessionName, useSessionStatus, getSessionAvatarId, formatPathRelativeToHome } from '@/utils/sessionUtils';
import { getSessionName, getSessionDefaultName, getSessionCustomName, useSessionStatus, getSessionAvatarId, formatPathRelativeToHome } from '@/utils/sessionUtils';
import { Avatar } from './Avatar';
import { Typography } from '@/constants/Typography';
import { StatusDot } from './StatusDot';
Expand Down Expand Up @@ -134,12 +134,19 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({
...Typography.default('regular'),
},
swipeAction: {
width: 112,
width: 80,
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.colors.status.error,
},
swipeActionRename: {
width: 80,
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.colors.textSecondary,
},
swipeActionText: {
marginTop: 4,
fontSize: 12,
Expand Down Expand Up @@ -322,6 +329,27 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi
);
}, [performArchive]);

const defaultName = getSessionDefaultName(session);
const customName = getSessionCustomName(session);

const handleRename = React.useCallback(async () => {
const newName = await Modal.prompt(
t('sessionInfo.renameSession'),
t('sessionInfo.renameSessionPrompt'),
{
placeholder: defaultName,
defaultValue: customName || '',
confirmText: t('common.rename'),
cancelText: t('common.cancel'),
maxLength: 100
}
);

if (newName !== null) {
storage.getState().updateSessionCustomName(session.id, newName || null);
}
}, [session.id, customName, defaultName]);

const itemContent = (
<Pressable
style={[
Expand Down Expand Up @@ -403,16 +431,30 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi
}

const renderRightActions = () => (
<Pressable
style={styles.swipeAction}
onPress={handleArchive}
disabled={archivingSession}
>
<Ionicons name="archive-outline" size={20} color="#FFFFFF" />
<Text style={styles.swipeActionText} numberOfLines={2}>
{t('sessionInfo.archiveSession')}
</Text>
</Pressable>
<View style={{ flexDirection: 'row' }}>
<Pressable
style={styles.swipeActionRename}
onPress={() => {
swipeableRef.current?.close();
handleRename();
}}
>
<Ionicons name="pencil-outline" size={20} color="#FFFFFF" />
<Text style={styles.swipeActionText} numberOfLines={2}>
{t('sessionInfo.renameSession')}
</Text>
</Pressable>
<Pressable
style={styles.swipeAction}
onPress={handleArchive}
disabled={archivingSession}
>
<Ionicons name="archive-outline" size={20} color="#FFFFFF" />
<Text style={styles.swipeActionText} numberOfLines={2}>
{t('sessionInfo.archiveSession')}
</Text>
</Pressable>
</View>
);

return (
Expand Down
1 change: 0 additions & 1 deletion packages/happy-app/sources/components/SessionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import { useHappyAction } from '@/hooks/useHappyAction';
import { sessionDelete } from '@/sync/ops';
import { HappyError } from '@/utils/errors';
import { Modal } from '@/modal';

const stylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
Expand Down
8 changes: 5 additions & 3 deletions packages/happy-app/sources/modal/ModalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,11 @@ class ModalManagerClass implements IModal {
cancelText?: string;
confirmText?: string;
inputType?: 'default' | 'secure-text' | 'email-address' | 'numeric';
maxLength?: number;
}
): Promise<string | null> {
if (Platform.OS === 'ios' && !options?.inputType) {
// Use native Alert.prompt on iOS (only supports basic text input)
if (Platform.OS === 'ios' && !options?.inputType && !options?.maxLength) {
// Use native Alert.prompt on iOS (only supports basic text input, no maxLength)
return new Promise<string | null>((resolve) => {
// @ts-ignore - Alert.prompt is iOS only
Alert.prompt(
Expand Down Expand Up @@ -190,7 +191,8 @@ class ModalManagerClass implements IModal {
defaultValue: options?.defaultValue,
cancelText: options?.cancelText,
confirmText: options?.confirmText,
inputType: options?.inputType
inputType: options?.inputType,
maxLength: options?.maxLength
} as Omit<ModalConfig, 'id'>);

return new Promise<string | null>((resolve) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export function WebPromptModal({ config, onClose, onConfirm }: WebPromptModalPro
autoFocus={Platform.OS === 'web'}
onSubmitEditing={handleConfirm}
returnKeyType="done"
maxLength={config.maxLength}
/>
</View>

Expand Down
2 changes: 2 additions & 0 deletions packages/happy-app/sources/modal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface PromptModalConfig extends BaseModalConfig {
cancelText?: string;
confirmText?: string;
inputType?: 'default' | 'secure-text' | 'email-address' | 'numeric';
maxLength?: number;
}

export interface CustomModalConfig extends BaseModalConfig {
Expand Down Expand Up @@ -72,6 +73,7 @@ export interface IModal {
cancelText?: string;
confirmText?: string;
inputType?: 'default' | 'secure-text' | 'email-address' | 'numeric';
maxLength?: number;
}): Promise<string | null>;
show(config: Omit<CustomModalConfig, 'id' | 'type'>): string;
hide(id: string): void;
Expand Down
142 changes: 142 additions & 0 deletions packages/happy-app/sources/sync/persistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

const mockStorage = new Map<string, string>();

vi.mock('react-native-mmkv', () => ({
MMKV: vi.fn().mockImplementation(() => ({
getString: (key: string) => mockStorage.get(key) ?? undefined,
set: (key: string, value: string) => mockStorage.set(key, value),
delete: (key: string) => mockStorage.delete(key),
clearAll: () => mockStorage.clear(),
}))
}));

import { loadSessionCustomNames, saveSessionCustomNames } from './persistence';

describe('session custom names persistence', () => {
beforeEach(() => {
mockStorage.clear();
});

describe('loadSessionCustomNames', () => {
describe('empty state', () => {
it('should return empty object when no data is stored', () => {
expect(loadSessionCustomNames()).toEqual({});
});

it('should return empty object when key does not exist in storage', () => {
mockStorage.set('some-other-key', 'value');
expect(loadSessionCustomNames()).toEqual({});
});
});

describe('valid data', () => {
it('should return parsed object when valid JSON is stored', () => {
const names = { 'session-1': 'My Session', 'session-2': 'Another Session' };
mockStorage.set('session-custom-names', JSON.stringify(names));
expect(loadSessionCustomNames()).toEqual(names);
});

it('should handle a single session name', () => {
const names = { 'abc-123': 'Frontend' };
mockStorage.set('session-custom-names', JSON.stringify(names));
expect(loadSessionCustomNames()).toEqual(names);
});

it('should handle many session names', () => {
const names: Record<string, string> = {};
for (let i = 0; i < 50; i++) {
names[`session-${i}`] = `Name ${i}`;
}
mockStorage.set('session-custom-names', JSON.stringify(names));
expect(loadSessionCustomNames()).toEqual(names);
});

it('should handle session names with special characters', () => {
const names = {
'session-1': 'My "Quoted" Name',
'session-2': 'Name with émojis 🚀',
'session-3': 'Name/with/slashes'
};
mockStorage.set('session-custom-names', JSON.stringify(names));
expect(loadSessionCustomNames()).toEqual(names);
});
});

describe('error handling', () => {
it('should return empty object when invalid JSON is stored', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockStorage.set('session-custom-names', 'not valid json{{{');
expect(loadSessionCustomNames()).toEqual({});
spy.mockRestore();
});

it('should return empty object when stored value is empty string', () => {
mockStorage.set('session-custom-names', '');
expect(loadSessionCustomNames()).toEqual({});
});
});
});

describe('saveSessionCustomNames', () => {
describe('basic functionality', () => {
it('should save serialized JSON to MMKV', () => {
const names = { 'session-1': 'Custom Name' };
saveSessionCustomNames(names);
expect(mockStorage.get('session-custom-names')).toBe(JSON.stringify(names));
});

it('should save empty object', () => {
saveSessionCustomNames({});
expect(mockStorage.get('session-custom-names')).toBe('{}');
});

it('should handle names with unicode characters', () => {
const names = { 'session-1': '日本語セッション' };
saveSessionCustomNames(names);
expect(JSON.parse(mockStorage.get('session-custom-names')!)).toEqual(names);
});
});

describe('overwrite behavior', () => {
it('should overwrite previous data completely', () => {
saveSessionCustomNames({ 'session-1': 'First' });
saveSessionCustomNames({ 'session-2': 'Second' });
expect(JSON.parse(mockStorage.get('session-custom-names')!)).toEqual({
'session-2': 'Second'
});
});

it('should not merge with previous data', () => {
saveSessionCustomNames({ 'session-1': 'Name A', 'session-2': 'Name B' });
saveSessionCustomNames({ 'session-1': 'Updated A' });
const result = JSON.parse(mockStorage.get('session-custom-names')!);
expect(result).toEqual({ 'session-1': 'Updated A' });
expect(result['session-2']).toBeUndefined();
});
});

describe('round-trip', () => {
it('should save and load data consistently', () => {
const names = {
'session-abc': 'Frontend Server',
'session-def': 'Backend API',
'session-ghi': 'Database Migration'
};
saveSessionCustomNames(names);
expect(loadSessionCustomNames()).toEqual(names);
});

it('should handle save-load-save cycle', () => {
saveSessionCustomNames({ 'session-1': 'First' });
const loaded = loadSessionCustomNames();
loaded['session-2'] = 'Second';
saveSessionCustomNames(loaded);
expect(loadSessionCustomNames()).toEqual({
'session-1': 'First',
'session-2': 'Second'
});
});
});
});
});
Loading