Skip to content

Commit fef83b0

Browse files
authored
feat: Voice message blocks (#7057)
1 parent f9c99a2 commit fef83b0

Some content is hidden

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

63 files changed

+6882
-277
lines changed
1.01 KB
Binary file not shown.

app/containers/CustomIcon/mappedIcons.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,10 @@ export const mappedIcons = {
160160
'percentage': 59777,
161161
'phone': 59806,
162162
'phone-disabled': 59804,
163-
'phone-end': 59805,
164163
'phone-in': 59809,
165-
'phone-issue': 59835,
164+
'phone-issue': 59879,
165+
'phone-off': 59805,
166+
'phone-question-mark': 59835,
166167
'pin': 59808,
167168
'pin-map': 59807,
168169
'play': 59811,

app/containers/CustomIcon/selection.json

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

app/containers/UIKit/Actions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export const Actions = ({ blockId, appId, elements, parser }: IActions) => {
3030
<>
3131
{elements.map((element, index) => {
3232
const isVisible = !showMoreVisible || index < maxVisible;
33-
const component = parser.renderActions({ blockId, appId, ...element }, BlockContext.ACTION, parser);
33+
const component = parser?.renderActions({ blockId, appId, ...element }, BlockContext.ACTION);
3434
// Always render the component, but hide it with styles if needed
3535
return (
3636
<View key={element.actionId || `action-${index}`} style={!isVisible ? styles.hidden : undefined}>

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+
};

app/containers/UIKit/Image.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { BlockContext } from '@rocket.chat/ui-kit';
55

66
import ImageContainer from '../message/Components/Attachments/Image';
77
import Navigation from '../../lib/navigation/appNavigation';
8-
import { type IThumb, type IImage, type IElement } from './interfaces';
8+
import { type IThumb, type IImage } from './interfaces';
99
import { type IAttachment } from '../../definitions';
1010

1111
const styles = StyleSheet.create({
@@ -33,7 +33,7 @@ export const Media = ({ element }: IImage) => {
3333
return <ImageContainer file={{ image_url: imageUrl }} showAttachment={showAttachment} />;
3434
};
3535

36-
const genericImage = (element: IElement, context?: number) => {
36+
const genericImage = ({ element, context }: IImage) => {
3737
switch (context) {
3838
case BlockContext.SECTION:
3939
return <Thumb element={element} />;
@@ -44,4 +44,4 @@ const genericImage = (element: IElement, context?: number) => {
4444
}
4545
};
4646

47-
export const Image = ({ element, context }: IImage) => genericImage(element, context);
47+
export const Image = (props: IImage) => genericImage(props);
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import React from 'react';
2+
import { Text } from 'react-native';
3+
import { render } from '@testing-library/react-native';
4+
5+
import { InfoCard } from './InfoCard';
6+
7+
jest.mock('../../theme', () => ({
8+
useTheme: () => ({
9+
colors: {
10+
surfaceTint: '#f7f7f7',
11+
strokeExtraLight: '#e1e1e1',
12+
surfaceLight: '#ffffff'
13+
}
14+
})
15+
}));
16+
17+
describe('InfoCard', () => {
18+
it('renders row elements in order and applies row background', () => {
19+
const parser = {
20+
icon: jest.fn((element: any) => <Text>{`icon:${element.icon}`}</Text>),
21+
plain_text: jest.fn((element: any) => <Text>{`text:${element.text}`}</Text>),
22+
mrkdwn: jest.fn((element: any) => <Text>{`md:${element.text}`}</Text>),
23+
icon_button: jest.fn(() => <Text>action</Text>)
24+
};
25+
26+
const { getByText, toJSON, UNSAFE_getAllByType } = render(
27+
<InfoCard
28+
type='info_card'
29+
parser={parser as any}
30+
blockId='info-card'
31+
rows={[
32+
{
33+
background: 'default',
34+
elements: [
35+
{ type: 'icon', icon: 'info', variant: 'default' },
36+
{ type: 'plain_text', text: 'Plain text' },
37+
{ type: 'mrkdwn', text: '*Markdown*' }
38+
]
39+
}
40+
]}
41+
/>
42+
);
43+
44+
expect(getByText('icon:info')).toBeTruthy();
45+
expect(getByText('text:Plain text')).toBeTruthy();
46+
expect(getByText('md:*Markdown*')).toBeTruthy();
47+
48+
const allTexts = UNSAFE_getAllByType(Text).map(node => node.props.children);
49+
expect(allTexts).toEqual(expect.arrayContaining(['icon:info', 'text:Plain text', 'md:*Markdown*']));
50+
51+
expect(toJSON()).toMatchObject({
52+
children: expect.arrayContaining([
53+
expect.objectContaining({
54+
props: {
55+
style: expect.arrayContaining([expect.objectContaining({ backgroundColor: '#ffffff' })])
56+
}
57+
})
58+
])
59+
});
60+
});
61+
62+
it('ignores row action rendering for now (non-interactive)', () => {
63+
const parser = {
64+
icon: jest.fn((element: any) => <Text>{`icon:${element.icon}`}</Text>),
65+
plain_text: jest.fn((element: any) => <Text>{`text:${element.text}`}</Text>),
66+
mrkdwn: jest.fn((element: any) => <Text>{`md:${element.text}`}</Text>),
67+
icon_button: jest.fn(() => <Text>action</Text>)
68+
};
69+
70+
const { queryByText } = render(
71+
<InfoCard
72+
type='info_card'
73+
parser={parser as any}
74+
rows={[
75+
{
76+
background: 'default',
77+
elements: [{ type: 'plain_text', text: 'Line' }],
78+
action: {
79+
type: 'icon_button',
80+
actionId: 'act-id',
81+
icon: { type: 'icon', icon: 'phone' }
82+
} as any
83+
}
84+
]}
85+
/>
86+
);
87+
88+
expect(queryByText('text:Line')).toBeTruthy();
89+
expect(queryByText('action')).toBeNull();
90+
expect(parser.icon_button).not.toHaveBeenCalled();
91+
});
92+
});

0 commit comments

Comments
 (0)