Skip to content

Commit e3a7a78

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

Some content is hidden

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

70 files changed

+7685
-922
lines changed
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)