Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
12 changes: 12 additions & 0 deletions app/assets/icons/arrow-upward.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Path } from 'react-native-svg';

const ArrowUpwardOutlined = ({ color }: { color: string }) => (
<>
<Path
d="M450-180v-485.08L222.15-437.23 180-480l300-300 300 300-42.15 42.77L510-665.08V-180h-60Z"
fill={color}
/>
</>
);

export default ArrowUpwardOutlined;
12 changes: 12 additions & 0 deletions app/assets/icons/attach-file.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Path } from 'react-native-svg';

const AttachFileOutlined = ({ color }: { color: string }) => (
<>
<Path
d="M706.92-334.23q0 97.46-67.81 165.84Q571.31-100 474.23-100q-97.46 0-165.46-68.39-68-68.38-68-165.84v-359.62q0-69.23 48.08-117.69Q336.92-860 406.15-860q69.23 0 117.31 48.46t48.08 117.69v340.39q0 40.61-28.35 69.34-28.34 28.73-68.96 28.73-40.61 0-69.34-28.53-28.73-28.54-28.73-69.54v-351.15h59.99v351.15q0 16.08 10.81 27.08t26.89 11q16.07 0 26.88-11 10.81-11 10.81-27.08v-340.77q-.62-44.31-30.85-75.04Q450.46-800 406.15-800q-44.3 0-74.84 30.92-30.54 30.93-30.54 75.23v359.62q-.62 72.54 50.15 123.38Q401.69-160 474.23-160q71.54 0 121.5-50.85 49.96-50.84 51.19-123.38v-370.38h60v370.38Z"
fill={color}
/>
</>
);

export default AttachFileOutlined;
4 changes: 4 additions & 0 deletions app/assets/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import AddBusinessOutlined from './add-business';
import AlternateEmailOutlined from './alternate-email';
import AltRouteOutlined from './alt-route';
import ApiOutlined from './api';
import ArrowUpwardOutlined from './arrow-upward';
import AssignmentTurnedInOutlined from './assignment-turned-in';
import AttachFileOutlined from './attach-file';
import BadgeOutlined from './badge';
import BalanceOutlined from './balance';
import CategoryOutlined from './category';
Expand Down Expand Up @@ -56,7 +58,9 @@ export const OutlinedIcons = {
'alternate-email': AlternateEmailOutlined,
'alt-route': AltRouteOutlined,
api: ApiOutlined,
'arrow-upward': ArrowUpwardOutlined,
'assignment-turned-in': AssignmentTurnedInOutlined,
'attach-file': AttachFileOutlined,
badge: BadgeOutlined,
balance: BalanceOutlined,
category: CategoryOutlined,
Expand Down
98 changes: 90 additions & 8 deletions app/src/__tests__/utils/formatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
formatUtcDate,
calculateRelativeDate,
getDatePartsForLocale,
formatDateForChat,
} from '@/utils/formatting';

const EMPTY_STRING = '';
Expand Down Expand Up @@ -358,28 +359,24 @@ describe('getDatePartsForLocale', () => {
expect(getDatePartsForLocale('invalid-date', enLocale)).toBeNull();
});

it('returns correct date and time parts for English locale', () => {
it('returns correct date parts for English locale', () => {
const result = getDatePartsForLocale(isoDate, enLocale);

expect(result).not.toBeNull();
if (!result) return;

expect(result.hour).toBe('15');
expect(result.minute).toBe('45');
expect(result.weekday).toBe('Tue');
expect(result.day).toBe('10');
expect(result.month).toBe('Mar');
expect(result.year).toBe('2026');
});

it('returns correct date and time parts for French locale', () => {
it('returns correct date parts for French locale', () => {
const result = getDatePartsForLocale(isoDate, frLocale);

expect(result).not.toBeNull();
if (!result) return;

expect(result.hour).toBe('15');
expect(result.minute).toBe('45');
expect(result.weekday).toBe('mar.');
expect(result.day).toBe('10');
expect(result.month).toBe('mars');
Expand All @@ -393,11 +390,96 @@ describe('getDatePartsForLocale', () => {
expect(result).not.toBeNull();
if (!result) return;

expect(result.hour).toBe('08');
expect(result.minute).toBe('05');
expect(result.weekday).toBe('Fri');
expect(result.day).toBe('25');
expect(result.month).toBe('Dec');
expect(result.year).toBe('2026');
});
});

describe('formatDateForChat', () => {
const locale = 'en-GB';

const NOW = new Date('2026-03-10T12:00:00Z');

beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(NOW);
});

afterAll(() => {
jest.useRealTimers();
});

describe('invalid input', () => {
it('returns EMPTY_STRING when isoDate is undefined', () => {
expect(formatDateForChat(undefined, locale)).toBe(EMPTY_STRING);
});

it('returns EMPTY_STRING when isoDate is empty string', () => {
expect(formatDateForChat('', locale)).toBe(EMPTY_STRING);
});

it('returns EMPTY_STRING when isoDate is invalid', () => {
expect(formatDateForChat('invalid-date', locale)).toBe(EMPTY_STRING);
});
});

describe('within last 24 hours', () => {
it('returns time (HH:mm)', () => {
const iso = '2026-03-10T09:30:00Z';

expect(formatDateForChat(iso, locale)).toBe('09:30');
});
});

describe('within last 7 days', () => {
it('returns weekday', () => {
const iso = '2026-03-08T10:00:00Z';

const result = formatDateForChat(iso, locale);

expect(result).toBe('Sun');
});
});

describe('within current year but older than 7 days', () => {
it('returns DD Mon', () => {
const iso = '2026-02-01T10:00:00Z';

expect(formatDateForChat(iso, locale)).toBe('01 Feb');
});

it('pads day with leading zero', () => {
const iso = '2026-01-05T10:00:00Z';

expect(formatDateForChat(iso, locale)).toBe('05 Jan');
});
});

describe('older than current year', () => {
it('returns DD Mon YYYY', () => {
const iso = '2025-12-25T10:00:00Z';

expect(formatDateForChat(iso, locale)).toBe('25 Dec 2025');
});
});

describe('different locales', () => {
it('formats weekday using locale rules', () => {
const iso = '2026-03-08T10:00:00Z';

const result = formatDateForChat(iso, 'fr-FR');

expect(result).toBe('dim.');
});

it('formats month using locale rules', () => {
const iso = '2026-02-01T10:00:00Z';

const result = formatDateForChat(iso, 'fr-FR');

expect(result).toBe('01 févr.');
});
});
});
2 changes: 1 addition & 1 deletion app/src/components/avatar/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const Avatar: React.FC<AvatarProps> = ({
<Image
source={{ uri: imageSource.uri, headers: imageSource.headers }}
style={styles.imageStyle}
contentFit="contain"
contentFit="cover"
onError={handleImageLoadError}
cachePolicy="memory-disk"
/>
Expand Down
62 changes: 62 additions & 0 deletions app/src/components/chat/ChatConversationFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { OutlinedIcons } from '@assets/icons';
import { View, TextInput, StyleSheet } from 'react-native';

import OutlinedIcon from '@/components/common/OutlinedIcon';
import { buttonStyle, Color, inputStyle, chatStyle } from '@/styles';

type Props = {
value: string;
onChangeText: (text: string) => void;
onSend: () => void;
};

const ChatConversationFooter = ({ value, onChangeText, onSend }: Props) => {
return (
<View style={styles.inputContainer}>
<View style={styles.iconContainer}>
<OutlinedIcon
name={'attach-file' as keyof typeof OutlinedIcons}
color={Color.brand.primary}
size={24}
/>
</View>

<View style={styles.textInputWrapper}>
<TextInput
placeholder="Type a message"
style={styles.input}
value={value}
onChangeText={onChangeText}
onSubmitEditing={onSend}
returnKeyType="send"
multiline
scrollEnabled
/>
</View>

<View style={styles.buttonPrimaryIconOnly}>
<OutlinedIcon
name={'arrow-upward' as keyof typeof OutlinedIcons}
color={Color.brand.white}
size={24}
/>
</View>
</View>
);
};

const styles = StyleSheet.create({
textInputWrapper: chatStyle.textInputWrapper,
iconContainer: chatStyle.iconContainer,
inputContainer: chatStyle.inputContainer,
input: {
...inputStyle.container,
...inputStyle.inputChat,
},
buttonPrimaryIconOnly: {
...buttonStyle.primary,
...buttonStyle.primaryIconOnly,
},
});

export default ChatConversationFooter;
66 changes: 66 additions & 0 deletions app/src/components/chat/ChatMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useMemo } from 'react';
import { View, Text, StyleSheet } from 'react-native';

import OutlinedIcon from '../common/OutlinedIcon';

import Avatar from '@/components/avatar/Avatar';
import { chatMessageStyle } from '@/styles/components';
import { Color } from '@/styles/tokens';
import type { Message, MessageType } from '@/types/chat';
import { formatDateForChat, getTime } from '@/utils/formatting';

type Props = {
message: Message;
currentUserId: string;
locale: string;
};

const ChatMessage = ({ message, currentUserId, locale }: Props) => {
const isOwn = message.identity.id === currentUserId;
const type: MessageType = isOwn ? 'own' : 'other';
const messageDate = formatDateForChat(message.audit.created?.at, locale);
const messageTime = getTime(message.audit.created?.at || '');

const styles = useMemo(
() =>
StyleSheet.create({
/* eslint-disable react-native/no-unused-styles */
container: chatMessageStyle[type].container,
messageWrapper: chatMessageStyle.messageWrapper,
textContainer: chatMessageStyle[type].textContainer,
text: chatMessageStyle[type].text,
info: chatMessageStyle[type].info,
infoText: chatMessageStyle.infoText,
avatarWrapper: chatMessageStyle.avatarWrapper,
}),
[type],
);

const avatarId = message.identity.id;
const avatarPath = message.identity?.icon;

return (
<View style={styles.container}>
{!isOwn && (
<View style={styles.avatarWrapper}>
<Avatar id={avatarId} imagePath={avatarPath} variant="small" />
</View>
)}
<View style={styles.messageWrapper}>
<View style={styles.info}>
{!isOwn && <Text style={styles.infoText}>{message.sender.identity.name}</Text>}
{messageDate !== messageTime && <Text style={styles.infoText}>{messageDate}</Text>}
<Text style={styles.infoText}>{messageTime}</Text>
<Text>
{isOwn && <OutlinedIcon name="more-horiz" size={16} color={Color.brand.type} />}
</Text>
</View>
<View style={styles.textContainer}>
<Text style={styles.text}>{message.content}</Text>
</View>
</View>
</View>
);
};

export default ChatMessage;
27 changes: 17 additions & 10 deletions app/src/components/details/DetailsHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { View, Text, StyleSheet } from 'react-native';

Expand All @@ -14,6 +15,7 @@ const DetailsHeader = ({
title,
subtitle,
statusText,
variant = 'default',
headerTitleTestId,
headerStatusTestId,
}: ListItemWithStatusProps) => {
Expand All @@ -23,6 +25,20 @@ const DetailsHeader = ({
const hasImage = Boolean(imagePath);
const status = getStatus(statusText, statusList);

const styles = useMemo(
() =>
StyleSheet.create({
/* eslint-disable react-native/no-unused-styles */
screenHeader: listItemStyle.detailsHeaderContainer[variant],
topRow: listItemStyle.detailsHeaderTopRow[variant],
avatarWrapper: listItemStyle.detailsHeaderAvatarWrapper[variant],
textWrapper: listItemStyle.textContainer,
title: listItemStyle.detailsHeaderTitle,
subtitle: listItemStyle.detailsHeaderSubtitle,
}),
[variant],
);

return (
<View style={styles.screenHeader}>
<View style={styles.topRow}>
Expand All @@ -42,20 +58,11 @@ const DetailsHeader = ({
</Text>
</View>
</View>
{status && (
{status && statusText && (
<Chip status={status} text={t(`status.${statusText}`)} testId={headerStatusTestId} />
)}
</View>
);
};

const styles = StyleSheet.create({
screenHeader: listItemStyle.detailsHeaderContainer,
topRow: listItemStyle.detailsHeaderTopRow,
textWrapper: listItemStyle.textContainer,
title: listItemStyle.detailsHeaderTitle,
subtitle: listItemStyle.detailsHeaderSubtitle,
avatarWrapper: listItemStyle.textAndImage.avatarWrapper,
});

export default DetailsHeader;
1 change: 0 additions & 1 deletion app/src/screens/account/UserSettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ const styles = StyleSheet.create({
},
sectionHeader: screenStyle.sectionHeader,
buttonPrimary: {
...buttonStyle.common,
...buttonStyle.primaryLight,
...buttonStyle.fullWidth,
},
Expand Down
Loading
Loading