Skip to content

Commit e3a7a78

Browse files
committed
feat: Voice message blocks (#7057)
1 parent 106cbd7 commit e3a7a78

70 files changed

Lines changed: 7685 additions & 922 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
1.01 KB
Binary file not shown.

app/containers/CustomIcon/mappedIcons.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,14 @@ export const mappedIcons = {
157157
'pause': 59803,
158158
'pause-filled': 59802,
159159
'pause-shape-filled': 59843,
160-
'pause-shape-unfilled': 59879,
160+
'pause-shape-unfilled': 59880,
161161
'percentage': 59777,
162162
'phone': 59806,
163163
'phone-disabled': 59804,
164-
'phone-end': 59805,
165164
'phone-in': 59809,
166-
'phone-issue': 59835,
165+
'phone-issue': 59879,
166+
'phone-off': 59805,
167+
'phone-question-mark': 59835,
167168
'pin': 59808,
168169
'pin-map': 59807,
169170
'play': 59811,

app/containers/CustomIcon/selection.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

app/containers/MediaCallHeader/components/EndCall.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const EndCall = () => {
1414
testID='media-call-header-end'
1515
accessibilityLabel={I18n.t('End')}
1616
onPress={endCall}
17-
iconName='phone-end'
17+
iconName='phone-off'
1818
color={colors.fontDanger}
1919
/>
2020
</HeaderButton.Container>

app/containers/ServerItem/__snapshots__/ServerItem.test.tsx.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@ exports[`Story Snapshots: SwipeActions should match snapshot 1`] = `
645645
"top": 0,
646646
},
647647
{
648-
"width": undefined,
648+
"width": 350,
649649
},
650650
{
651651
"height": 68,
@@ -994,7 +994,7 @@ exports[`Story Snapshots: SwipeActions should match snapshot 1`] = `
994994
"top": 0,
995995
},
996996
{
997-
"width": undefined,
997+
"width": 350,
998998
},
999999
{
10001000
"height": 68,

app/containers/UIKit/Actions.tsx

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,44 @@
11
import React, { useState } from 'react';
2+
import { View, StyleSheet } from 'react-native';
23
import { BlockContext } from '@rocket.chat/ui-kit';
34

45
import Button from '../Button';
56
import I18n from '../../i18n';
67
import { type IActions } from './interfaces';
78

9+
const styles = StyleSheet.create({
10+
hidden: {
11+
overflow: 'hidden',
12+
height: 0
13+
}
14+
});
15+
816
export const Actions = ({ blockId, appId, elements, parser }: IActions) => {
917
const [showMoreVisible, setShowMoreVisible] = useState(() => elements && elements.length > 5);
10-
const renderedElements = showMoreVisible ? elements?.slice(0, 5) : elements;
1118

19+
const shouldShowMore = elements && elements.length > 5;
20+
const maxVisible = 5;
21+
22+
if (!elements || !parser) {
23+
return null;
24+
}
25+
26+
// Always render all elements to maintain consistent hook calls
27+
// This ensures hooks are always called in the same order
28+
// Use View wrapper to conditionally hide elements instead of conditionally rendering
1229
return (
1330
<>
14-
<>
15-
{renderedElements
16-
? renderedElements?.map(element => parser?.renderActions({ blockId, appId, ...element }, BlockContext.ACTION, parser))
17-
: null}
18-
</>
19-
{showMoreVisible && <Button title={I18n.t('Show_more')} onPress={() => setShowMoreVisible(false)} />}
31+
{elements.map((element, index) => {
32+
const isVisible = !showMoreVisible || index < maxVisible;
33+
const component = parser?.renderActions({ blockId, appId, ...element }, BlockContext.ACTION);
34+
// Always render the component, but hide it with styles if needed
35+
return (
36+
<View key={element.actionId || `action-${index}`} style={!isVisible ? styles.hidden : undefined}>
37+
{component}
38+
</View>
39+
);
40+
})}
41+
{shouldShowMore && showMoreVisible && <Button title={I18n.t('Show_more')} onPress={() => setShowMoreVisible(false)} />}
2042
</>
2143
);
2244
};

app/containers/UIKit/Context.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,11 @@ const styles = StyleSheet.create({
1313
});
1414

1515
export const Context = ({ elements, parser }: IContext) => (
16-
<View style={styles.container}>{elements?.map(element => parser?.renderContext(element, BlockContext.CONTEXT, parser))}</View>
16+
<View style={styles.container}>
17+
{elements?.map((element, index) => (
18+
<React.Fragment key={(element as any).type ? `${(element as any).type}-${index}` : `context-${index}`}>
19+
{parser?.renderContext(element, BlockContext.CONTEXT)}
20+
</React.Fragment>
21+
))}
22+
</View>
1723
);

app/containers/UIKit/Icon.test.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import React from 'react';
2+
import { Text } from 'react-native';
3+
import { render } from '@testing-library/react-native';
4+
5+
import { Icon, resolveIconName } from './Icon';
6+
7+
const mockHasIcon = jest.fn();
8+
const mockCustomIcon = jest.fn(() => <Text testID='custom-icon'>icon</Text>);
9+
10+
jest.mock('../CustomIcon', () => ({
11+
hasIcon: (...args: unknown[]) => mockHasIcon(...args),
12+
CustomIcon: (...props: Parameters<typeof mockCustomIcon>) => mockCustomIcon(...props)
13+
}));
14+
15+
jest.mock('../../theme', () => ({
16+
useTheme: () => ({
17+
colors: {
18+
fontDefault: '#000000',
19+
fontDanger: '#d00000',
20+
fontSecondaryInfo: '#0060d0',
21+
statusFontWarning: '#d09000',
22+
statusFontDanger: '#ff2020',
23+
surfaceTint: '#f2f2f2'
24+
}
25+
})
26+
}));
27+
28+
describe('UIKit Icon', () => {
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
});
32+
33+
describe('resolveIconName', () => {
34+
it('returns original icon when available', () => {
35+
mockHasIcon.mockImplementation((name: string) => name === 'bell');
36+
37+
expect(resolveIconName('bell')).toBe('bell');
38+
});
39+
40+
it('resolves known alias when alias icon exists', () => {
41+
mockHasIcon.mockImplementation((name: string) => name === 'phone-off');
42+
43+
expect(resolveIconName('phone-end')).toBe('phone-off');
44+
});
45+
46+
it('falls back to info when icon and alias are unavailable', () => {
47+
mockHasIcon.mockReturnValue(false);
48+
49+
expect(resolveIconName('unknown')).toBe('info');
50+
});
51+
});
52+
53+
it('renders secondary variant color', () => {
54+
mockHasIcon.mockReturnValue(true);
55+
render(<Icon element={{ icon: 'bell', type: 'icon', variant: 'secondary' } as any} />);
56+
57+
expect(mockCustomIcon).toHaveBeenCalledTimes(1);
58+
const firstCallArg = (mockCustomIcon.mock.calls[0] as any[])[0];
59+
expect(firstCallArg).toEqual(
60+
expect.objectContaining({
61+
name: 'bell',
62+
color: '#0060d0',
63+
size: 20
64+
})
65+
);
66+
});
67+
68+
it('uses framed danger color and frame background', () => {
69+
mockHasIcon.mockReturnValue(true);
70+
const { toJSON } = render(<Icon element={{ icon: 'bell', type: 'icon', variant: 'danger', framed: true } as any} />);
71+
72+
expect(mockCustomIcon).toHaveBeenCalledTimes(1);
73+
const firstCallArg = (mockCustomIcon.mock.calls[0] as any[])[0];
74+
expect(firstCallArg).toEqual(
75+
expect.objectContaining({
76+
name: 'bell',
77+
color: '#ff2020',
78+
size: 20
79+
})
80+
);
81+
expect(toJSON()).toMatchObject({
82+
props: {
83+
style: expect.arrayContaining([expect.objectContaining({ backgroundColor: '#f2f2f2' })])
84+
}
85+
});
86+
});
87+
});

app/containers/UIKit/Icon.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react';
2+
import { StyleSheet, View } from 'react-native';
3+
4+
import { hasIcon, CustomIcon } from '../CustomIcon';
5+
import { useTheme } from '../../theme';
6+
import { type IIcon } from './interfaces';
7+
8+
const iconAliases: Record<string, string> = {
9+
'phone-end': 'phone-off'
10+
};
11+
12+
const styles = StyleSheet.create({
13+
frame: {
14+
width: 28,
15+
height: 28,
16+
borderRadius: 4,
17+
alignItems: 'center',
18+
justifyContent: 'center'
19+
}
20+
});
21+
22+
export const resolveIconName = (icon: string) => {
23+
if (hasIcon(icon)) {
24+
return icon as any;
25+
}
26+
27+
const aliasedIcon = iconAliases[icon];
28+
if (aliasedIcon && hasIcon(aliasedIcon)) {
29+
return aliasedIcon as any;
30+
}
31+
32+
return 'info' as any;
33+
};
34+
35+
const getIconColor = (variant: IIcon['variant'], colors: ReturnType<typeof useTheme>['colors'], framed?: boolean) => {
36+
switch (variant) {
37+
case 'danger':
38+
return framed ? colors.statusFontDanger : colors.fontDanger;
39+
case 'secondary':
40+
return colors.fontSecondaryInfo;
41+
case 'warning':
42+
return colors.statusFontWarning;
43+
default:
44+
return colors.fontDefault;
45+
}
46+
};
47+
48+
export const Icon = ({ element }: { element: IIcon }) => {
49+
const { colors } = useTheme();
50+
const { icon, variant = 'default', framed } = element;
51+
const color = getIconColor(variant, colors, framed);
52+
const renderedIcon = <CustomIcon name={resolveIconName(icon)} size={20} color={color} />;
53+
54+
if (!framed) {
55+
return renderedIcon;
56+
}
57+
58+
return <View style={[styles.frame, { backgroundColor: colors.surfaceTint }]}>{renderedIcon}</View>;
59+
};
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React from 'react';
2+
import { Pressable, StyleSheet } from 'react-native';
3+
import { type BlockContext } from '@rocket.chat/ui-kit';
4+
5+
import ActivityIndicator from '../ActivityIndicator';
6+
import { BUTTON_HIT_SLOP } from '../message/utils';
7+
import openLink from '../../lib/methods/helpers/openLink';
8+
import { useTheme } from '../../theme';
9+
import { useBlockContext } from './utils';
10+
import { Icon } from './Icon';
11+
import { type IIconButton, type IText } from './interfaces';
12+
13+
const styles = StyleSheet.create({
14+
button: {
15+
width: 32,
16+
height: 32,
17+
borderWidth: 1,
18+
borderRadius: 8,
19+
alignItems: 'center',
20+
justifyContent: 'center'
21+
},
22+
loading: {
23+
padding: 0
24+
}
25+
});
26+
27+
const getLabel = (label?: string | IText, fallback?: string) => {
28+
if (typeof label === 'string') {
29+
return label;
30+
}
31+
32+
if (label?.text) {
33+
return label.text;
34+
}
35+
36+
return fallback || 'icon button';
37+
};
38+
39+
export const IconButton = ({ element, context }: { element: IIconButton; context: BlockContext }) => {
40+
const { theme, colors } = useTheme();
41+
const [{ loading }, action] = useBlockContext(element, context);
42+
const label = getLabel(element.label, element.icon?.icon);
43+
44+
const onPress = async () => {
45+
if (element.url) {
46+
await Promise.allSettled([action({ value: element.value }), openLink(element.url, theme)]);
47+
return;
48+
}
49+
50+
await action({ value: element.value });
51+
};
52+
53+
return (
54+
<Pressable
55+
onPress={onPress}
56+
disabled={loading}
57+
hitSlop={BUTTON_HIT_SLOP}
58+
android_ripple={{ color: colors.surfaceNeutral, borderless: false }}
59+
style={({ pressed }) => [
60+
styles.button,
61+
{
62+
borderColor: colors.strokeLight,
63+
backgroundColor: colors.surfaceLight,
64+
opacity: pressed ? 0.7 : 1
65+
}
66+
]}
67+
accessibilityRole={element.url ? 'link' : 'button'}
68+
accessibilityLabel={label}>
69+
{loading ? <ActivityIndicator style={styles.loading} /> : <Icon element={element.icon} />}
70+
</Pressable>
71+
);
72+
};

0 commit comments

Comments
 (0)