Skip to content

Commit fef83b0

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

63 files changed

Lines changed: 6882 additions & 277 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: 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)