Skip to content

Commit f019d9b

Browse files
authored
feat: presence sync ui (#7392)
1 parent 6bbf88a commit f019d9b

72 files changed

Lines changed: 4226 additions & 134 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/containers/RoomHeader/RoomHeader.stories.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,61 @@ export const Themes = () => (
100100
<ThemeStory theme='black' />
101101
</>
102102
);
103+
104+
export const DM_Status = () => {
105+
const futureExpiry = '2030-06-15T19:00:00.000Z';
106+
const now = new Date().getTime();
107+
const hourLaterExpiry = new Date(now + 3600000).toISOString();
108+
const expiredExpiry = new Date(now - 3600000).toISOString();
109+
return (
110+
<>
111+
<HeaderExample
112+
title={() => <RoomHeader title='John Doe' type='d' roomUserId='user1' subtitle='Online' status='online' />}
113+
/>
114+
<HeaderExample title={() => <RoomHeader title='John Doe' type='d' roomUserId='user2' subtitle='Away' status='away' />} />
115+
<HeaderExample title={() => <RoomHeader title='John Doe' type='d' roomUserId='user3' subtitle='Busy' status='busy' />} />
116+
<HeaderExample
117+
title={() => <RoomHeader title='John Doe' type='d' roomUserId='user4' subtitle='Offline' status='offline' />}
118+
/>
119+
<HeaderExample
120+
title={() => <RoomHeader title='John Doe' type='d' roomUserId='user5' subtitle='In a meeting' status='online' />}
121+
/>
122+
<HeaderExample
123+
title={() => (
124+
<RoomHeader
125+
title='John Doe'
126+
type='d'
127+
roomUserId='user6'
128+
subtitle='In a meeting'
129+
status='online'
130+
statusExpiresAt={futureExpiry}
131+
/>
132+
)}
133+
/>
134+
<HeaderExample
135+
title={() => (
136+
<RoomHeader
137+
title='John Doe'
138+
type='d'
139+
roomUserId='user9'
140+
subtitle='having lunch (clock icon should be visible)'
141+
status='away'
142+
statusExpiresAt={hourLaterExpiry}
143+
/>
144+
)}
145+
/>
146+
<HeaderExample
147+
title={() => (
148+
<RoomHeader
149+
title='John Doe'
150+
type='d'
151+
roomUserId='user8'
152+
subtitle='In a meeting (past expiry — clock icon should be hidden)'
153+
status='online'
154+
statusExpiresAt={expiredExpiry}
155+
/>
156+
)}
157+
/>
158+
</>
159+
);
160+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots';
22
import * as stories from './RoomHeader.stories';
33

4+
jest.useFakeTimers();
5+
jest.setSystemTime(new Date('2026-06-15T12:00:00.000Z'));
6+
47
generateSnapshots(stories);

app/containers/RoomHeader/RoomHeader.tsx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import I18n from '../../i18n';
99
import sharedStyles from '../../views/Styles';
1010
import { MarkdownPreview } from '../markdown';
1111
import RoomTypeIcon from '../RoomTypeIcon';
12+
import { CustomIcon } from '../CustomIcon';
1213
import { type TUserStatus, type IOmnichannelSource, type ISubscription } from '../../definitions';
14+
import { formatStatusExpiry } from '../../lib/methods/helpers/formatStatusExpiry';
1315
import { useTheme } from '../../theme';
1416
import { useAppSelector } from '../../lib/hooks/useAppSelector';
1517
import useStatusAccessibilityLabel from '../../lib/hooks/useStatusAccessibilityLabel';
@@ -45,12 +47,16 @@ const styles = StyleSheet.create({
4547
},
4648
typingUsers: {
4749
...sharedStyles.textSemibold
50+
},
51+
clockIcon: {
52+
marginRight: 4
4853
}
4954
});
5055

5156
type TRoomHeaderSubTitle = {
5257
usersTyping: IUsersTyping;
5358
subtitle?: string;
59+
formattedStatusExpiry?: string;
5460
renderFunc?: () => ReactElement;
5561
scale: number;
5662
};
@@ -66,6 +72,7 @@ type TRoomHeaderHeaderTitle = {
6672
interface IRoomHeader {
6773
title?: string;
6874
subtitle?: string;
75+
statusExpiresAt?: string;
6976
type: string;
7077
width: number;
7178
height: number;
@@ -87,7 +94,7 @@ interface IRoomHeader {
8794

8895
type IRoomHeaderProps = IRoomHeader;
8996

90-
const SubTitle = memo(({ usersTyping, subtitle, renderFunc, scale }: TRoomHeaderSubTitle) => {
97+
const SubTitle = memo(({ usersTyping, subtitle, formattedStatusExpiry, renderFunc, scale }: TRoomHeaderSubTitle) => {
9198
const { colors } = useTheme();
9299
const fontSize = getSubTitleSize(scale);
93100
// typing
@@ -113,6 +120,14 @@ const SubTitle = memo(({ usersTyping, subtitle, renderFunc, scale }: TRoomHeader
113120

114121
// subtitle
115122
if (subtitle) {
123+
if (formattedStatusExpiry) {
124+
return (
125+
<View style={styles.titleContainer}>
126+
<CustomIcon name='clock' size={fontSize} color={colors.fontSecondaryInfo} style={styles.clockIcon} />
127+
<MarkdownPreview msg={subtitle} style={[styles.subtitle, { fontSize, color: colors.fontSecondaryInfo }]} />
128+
</View>
129+
);
130+
}
116131
return <MarkdownPreview msg={subtitle} style={[styles.subtitle, { fontSize, color: colors.fontSecondaryInfo }]} />;
117132
}
118133

@@ -138,6 +153,7 @@ const HeaderTitle = memo(({ title, tmid, prid, scale, testID }: TRoomHeaderHeade
138153
const Header = ({
139154
title,
140155
subtitle,
156+
statusExpiresAt,
141157
parentTitle,
142158
type,
143159
status,
@@ -173,8 +189,15 @@ const Header = ({
173189
// Only move focus to the header for accessibility navigation (screen reader or physical
174190
// keyboard); regular touch users shouldn't have focus yanked onto the header on room open.
175191
const autoFocusHeader = useIsAccessibilityNavigationEnabled();
192+
const formattedStatusExpiry = formatStatusExpiry(statusExpiresAt);
176193
const subtitleAccessibilityLabel = tmid ? parentTitle : subtitle;
177-
const accessibilityLabel = `${statusAccessibilityLabel} ${title} ${subtitleAccessibilityLabel || ''}.`;
194+
const fullSubtitleAccessibilityLabel = formattedStatusExpiry
195+
? `${subtitleAccessibilityLabel || ''}, ${formattedStatusExpiry}`
196+
: subtitleAccessibilityLabel || '';
197+
const accessibilityLabel =
198+
type === 'd' && subtitleAccessibilityLabel === statusAccessibilityLabel
199+
? `${title}. ${fullSubtitleAccessibilityLabel}.`
200+
: `${statusAccessibilityLabel} ${title} ${fullSubtitleAccessibilityLabel}.`;
178201

179202
if (!portrait && !tmid && !isMasterDetail) {
180203
if (usersTyping.length > 0 || subtitle) {
@@ -231,7 +254,13 @@ const Header = ({
231254
)}
232255
<HeaderTitle title={title} tmid={tmid} prid={prid} scale={scale} testID={testID} />
233256
</View>
234-
<SubTitle usersTyping={tmid ? [] : usersTyping} subtitle={subtitle} renderFunc={renderFunc} scale={scale} />
257+
<SubTitle
258+
usersTyping={tmid ? [] : usersTyping}
259+
subtitle={subtitle}
260+
formattedStatusExpiry={formattedStatusExpiry}
261+
renderFunc={renderFunc}
262+
scale={scale}
263+
/>
235264
</TouchableOpacity>
236265
</KeyboardFocusView>
237266
);
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { type ReactNode } from 'react';
2+
import { render } from '@testing-library/react-native';
3+
import { Provider } from 'react-redux';
4+
5+
import { mockedStore } from '../../reducers/mockedStore';
6+
import { setActiveUsers } from '../../actions/activeUsers';
7+
import { connectSuccess, connectRequest, disconnect } from '../../actions/connect';
8+
import { initStore } from '../../lib/store/auxStore';
9+
import { selectServerSuccess } from '../../actions/server';
10+
import RoomHeaderContainer from './index';
11+
12+
const mockChild = jest.fn<any, [Record<string, unknown>]>(() => null);
13+
jest.mock('./RoomHeader', () => ({
14+
__esModule: true,
15+
default: (props: Record<string, unknown>) => {
16+
mockChild(props);
17+
return null;
18+
}
19+
}));
20+
21+
const Wrapper = ({ children }: { children: ReactNode }) => <Provider store={mockedStore}>{children}</Provider>;
22+
23+
const defaultProps = {
24+
title: 'John Doe',
25+
type: 'd',
26+
roomUserId: 'user-123',
27+
onPress: jest.fn()
28+
};
29+
30+
const renderContainer = (props: Record<string, unknown> = {}) =>
31+
render(<RoomHeaderContainer {...defaultProps} {...props} />, { wrapper: Wrapper });
32+
33+
describe('RoomHeaderContainer', () => {
34+
beforeAll(() => {
35+
initStore(mockedStore);
36+
});
37+
38+
beforeEach(() => {
39+
jest.clearAllMocks();
40+
mockedStore.dispatch(disconnect());
41+
});
42+
43+
const connect = () => {
44+
mockedStore.dispatch(selectServerSuccess({ server: 'https://example.com', version: '6.0.0', name: 'Test' }));
45+
mockedStore.dispatch(connectSuccess());
46+
};
47+
48+
it('should pass subtitle as Connecting when meteor is connecting for non-DM room', () => {
49+
mockedStore.dispatch(connectRequest());
50+
mockedStore.dispatch(selectServerSuccess({ server: 'https://example.com', version: '6.0.0', name: 'Test' }));
51+
52+
renderContainer({ type: 'c', roomUserId: undefined });
53+
54+
const props = mockChild.mock.calls[0][0];
55+
expect(props.subtitle).toBe('Connecting...');
56+
});
57+
58+
it('should pass subtitle as Waiting for network... when not connected for non-DM room', () => {
59+
mockedStore.dispatch(selectServerSuccess({ server: 'https://example.com', version: '6.0.0', name: 'Test' }));
60+
61+
renderContainer({ type: 'c', roomUserId: undefined });
62+
63+
const props = mockChild.mock.calls[0][0];
64+
expect(props.subtitle).toBe('Waiting for network...');
65+
});
66+
67+
it('should pass presence status for DM room when connected and activeUser exists', () => {
68+
connect();
69+
mockedStore.dispatch(
70+
setActiveUsers({ 'user-123': { status: 'online', statusText: 'Working from home', statusExpiresAt: undefined } })
71+
);
72+
73+
renderContainer();
74+
75+
const props = mockChild.mock.calls[0][0];
76+
expect(props.subtitle).toBe('Working from home');
77+
expect(props.type).toBe('d');
78+
});
79+
80+
it('should pass statusExpiresAt for DM room when activeUser has it', () => {
81+
connect();
82+
mockedStore.dispatch(
83+
setActiveUsers({
84+
'user-123': { status: 'away', statusText: 'In a meeting', statusExpiresAt: '2026-06-20T13:00:00.000Z' }
85+
})
86+
);
87+
88+
renderContainer();
89+
90+
const props = mockChild.mock.calls[0][0];
91+
expect(props.subtitle).toBe('In a meeting');
92+
expect(props.statusExpiresAt).toBe('2026-06-20T13:00:00.000Z');
93+
});
94+
95+
it('should use presence label when statusText is empty', () => {
96+
connect();
97+
mockedStore.dispatch(setActiveUsers({ 'user-123': { status: 'busy', statusText: '', statusExpiresAt: undefined } }));
98+
99+
renderContainer();
100+
101+
const props = mockChild.mock.calls[0][0];
102+
expect(props.subtitle).toBe('Busy');
103+
});
104+
105+
it('should not pass statusExpiresAt for non-DM room', () => {
106+
connect();
107+
mockedStore.dispatch(
108+
setActiveUsers({ 'user-123': { status: 'away', statusText: 'Away', statusExpiresAt: '2026-06-20T13:00:00.000Z' } })
109+
);
110+
111+
renderContainer({ type: 'c', roomUserId: undefined });
112+
113+
const props = mockChild.mock.calls[0][0];
114+
expect(props.statusExpiresAt).toBeUndefined();
115+
});
116+
117+
it('should pass subtitleProp when connected and no activeUser', () => {
118+
connect();
119+
120+
renderContainer({ subtitle: 'Custom subtitle', roomUserId: undefined, type: 'c' });
121+
122+
const props = mockChild.mock.calls[0][0];
123+
expect(props.subtitle).toBe('Custom subtitle');
124+
});
125+
126+
it('should pass roomUserId to RoomHeader', () => {
127+
connect();
128+
129+
renderContainer();
130+
131+
const props = mockChild.mock.calls[0][0];
132+
expect(props.roomUserId).toBe('user-123');
133+
});
134+
135+
it('should handle activeUser with offline status', () => {
136+
connect();
137+
mockedStore.dispatch(setActiveUsers({ 'user-123': { status: 'offline', statusText: '', statusExpiresAt: undefined } }));
138+
139+
renderContainer();
140+
141+
const props = mockChild.mock.calls[0][0];
142+
expect(props.subtitle).toBe('Offline');
143+
});
144+
145+
it('should use statusText even when status is undefined', () => {
146+
connect();
147+
mockedStore.dispatch(
148+
setActiveUsers({
149+
'user-123': { status: undefined, statusText: 'Custom text', statusExpiresAt: undefined } as any
150+
})
151+
);
152+
153+
renderContainer();
154+
155+
const props = mockChild.mock.calls[0][0];
156+
expect(props.subtitle).toBe('Custom text');
157+
});
158+
159+
it('should pass onPress to RoomHeader', () => {
160+
connect();
161+
162+
renderContainer();
163+
164+
const props = mockChild.mock.calls[0][0];
165+
expect(props.onPress).toBe(defaultProps.onPress);
166+
});
167+
});

0 commit comments

Comments
 (0)