Skip to content

Commit 9b9d52b

Browse files
committed
feat(uikit): added file viewer component
1 parent 70da6f1 commit 9b9d52b

File tree

18 files changed

+351
-19
lines changed

18 files changed

+351
-19
lines changed

packages/uikit-react-native-foundation/src/styles/HeaderStyleContext.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import React from 'react';
2-
import { StatusBar } from 'react-native';
2+
import { StatusBar, useWindowDimensions } from 'react-native';
33
import { useSafeAreaInsets } from 'react-native-safe-area-context';
44

55
import type { BaseHeaderProps, HeaderElement } from '../types';
6+
import getDefaultHeaderHeight from './getDefaultHeaderHeight';
67

78
export type HeaderStyleContextType = {
89
HeaderComponent: (
@@ -20,12 +21,14 @@ export type HeaderStyleContextType = {
2021
defaultTitleAlign: 'left' | 'center';
2122
statusBarTranslucent: boolean;
2223
topInset: number;
24+
defaultHeight: number;
2325
};
2426
export const HeaderStyleContext = React.createContext<HeaderStyleContextType>({
2527
HeaderComponent: () => null,
2628
defaultTitleAlign: 'left',
2729
statusBarTranslucent: true,
2830
topInset: StatusBar.currentHeight ?? 0,
31+
defaultHeight: getDefaultHeaderHeight(false),
2932
});
3033

3134
type Props = Pick<HeaderStyleContextType, 'statusBarTranslucent' | 'defaultTitleAlign' | 'HeaderComponent'>;
@@ -36,6 +39,7 @@ export const HeaderStyleProvider = ({
3639
statusBarTranslucent,
3740
}: React.PropsWithChildren<Props>) => {
3841
const { top } = useSafeAreaInsets();
42+
const { width, height } = useWindowDimensions();
3943

4044
return (
4145
<HeaderStyleContext.Provider
@@ -44,6 +48,7 @@ export const HeaderStyleProvider = ({
4448
defaultTitleAlign,
4549
statusBarTranslucent,
4650
topInset: statusBarTranslucent ? top : 0,
51+
defaultHeight: getDefaultHeaderHeight(width > height),
4752
}}
4853
>
4954
{children}

packages/uikit-react-native-foundation/src/ui/Header/index.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import React from 'react';
2-
import { TouchableOpacity, TouchableOpacityProps, View, useWindowDimensions } from 'react-native';
2+
import { TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
33
import { useSafeAreaInsets } from 'react-native-safe-area-context';
44

55
import { conditionChaining } from '@sendbird/uikit-utils';
66

77
import type { BaseHeaderProps } from '../../index';
88
import createStyleSheet from '../../styles/createStyleSheet';
9-
import getDefaultHeaderHeight from '../../styles/getDefaultHeaderHeight';
109
import useHeaderStyle from '../../styles/useHeaderStyle';
1110
import useUIKitTheme from '../../theme/useUIKitTheme';
1211
import Text, { TextProps } from '../Text';
@@ -38,8 +37,7 @@ const Header: ((props: HeaderProps) => JSX.Element) & {
3837
onPressRight,
3938
clearTitleMargin = false,
4039
}) => {
41-
const { topInset, defaultTitleAlign } = useHeaderStyle();
42-
const { width, height } = useWindowDimensions();
40+
const { topInset, defaultTitleAlign, defaultHeight } = useHeaderStyle();
4341
const { colors } = useUIKitTheme();
4442
const { left: paddingLeft, right: paddingRight } = useSafeAreaInsets();
4543

@@ -64,7 +62,7 @@ const Header: ((props: HeaderProps) => JSX.Element) & {
6462
},
6563
]}
6664
>
67-
<View style={[styles.header, { height: getDefaultHeaderHeight(width > height) }]}>
65+
<View style={[styles.header, { height: defaultHeight }]}>
6866
<LeftSide titleAlign={actualTitleAlign} left={left} onPressLeft={onPressLeft} />
6967
<View
7068
style={[

packages/uikit-react-native/src/SendbirdUIKitContainer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export type SendbirdUIKitContainerProps = React.PropsWithChildren<{
6161
theme?: UIKitTheme;
6262
statusBarTranslucent?: boolean;
6363
defaultHeaderTitleAlign?: 'left' | 'center';
64+
defaultHeaderHeight?: number;
6465
HeaderComponent?: HeaderStyleContextType['HeaderComponent'];
6566
};
6667
toast?: {
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { StatusBar, StyleSheet, View } from 'react-native';
3+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
4+
5+
import {
6+
Icon,
7+
Image,
8+
LoadingSpinner,
9+
Text,
10+
createStyleSheet,
11+
useAlert,
12+
useHeaderStyle,
13+
useToast,
14+
useUIKitTheme,
15+
} from '@sendbird/uikit-react-native-foundation';
16+
import type { SendbirdFileMessage } from '@sendbird/uikit-utils';
17+
import { Logger, getFileExtension, getFileType, isMyMessage, toMegabyte, useIIFE } from '@sendbird/uikit-utils';
18+
19+
import { useLocalization, usePlatformService, useSendbirdChat } from '../hooks/useContext';
20+
import SBUPressable from './SBUPressable';
21+
22+
type Props = {
23+
fileMessage: SendbirdFileMessage;
24+
deleteMessage: () => Promise<void>;
25+
26+
onClose: () => void;
27+
onPressDownload?: (message: SendbirdFileMessage) => void;
28+
onPressDelete?: (message: SendbirdFileMessage) => void;
29+
30+
headerShown?: boolean;
31+
headerTopInset?: number;
32+
};
33+
const FileViewer = ({
34+
headerShown = true,
35+
deleteMessage,
36+
headerTopInset,
37+
fileMessage,
38+
onPressDownload,
39+
onPressDelete,
40+
onClose,
41+
}: Props) => {
42+
const [loading, setLoading] = useState(true);
43+
44+
const { bottom } = useSafeAreaInsets();
45+
46+
const { currentUser } = useSendbirdChat();
47+
const { palette } = useUIKitTheme();
48+
const { topInset, statusBarTranslucent, defaultHeight } = useHeaderStyle();
49+
const { STRINGS } = useLocalization();
50+
const { fileService, mediaService } = usePlatformService();
51+
const toast = useToast();
52+
const { alert } = useAlert();
53+
54+
const basicTopInset = statusBarTranslucent ? topInset : 0;
55+
const canDelete = isMyMessage(fileMessage, currentUser?.userId);
56+
const fileType = getFileType(fileMessage.type || getFileExtension(fileMessage.url));
57+
58+
useEffect(() => {
59+
if (!mediaService?.VideoComponent || fileType === 'file') {
60+
onClose();
61+
}
62+
}, [mediaService]);
63+
64+
const fileViewer = useIIFE(() => {
65+
switch (fileType) {
66+
case 'image': {
67+
return (
68+
<Image
69+
source={{ uri: fileMessage.url }}
70+
style={StyleSheet.absoluteFill}
71+
resizeMode={'contain'}
72+
onLoadEnd={() => setLoading(false)}
73+
/>
74+
);
75+
}
76+
77+
case 'video':
78+
case 'audio': {
79+
if (!mediaService?.VideoComponent) return null;
80+
return (
81+
<mediaService.VideoComponent
82+
source={{ uri: fileMessage.url }}
83+
style={[StyleSheet.absoluteFill, { top: basicTopInset + defaultHeight, bottom: defaultHeight + bottom }]}
84+
resizeMode={'contain'}
85+
onLoad={() => setLoading(false)}
86+
/>
87+
);
88+
}
89+
90+
default: {
91+
return null;
92+
}
93+
}
94+
});
95+
96+
const _onPressDelete = () => {
97+
if (!canDelete) return;
98+
99+
if (onPressDelete) {
100+
onPressDelete(fileMessage);
101+
} else {
102+
alert({
103+
title: STRINGS.GROUP_CHANNEL.DIALOG_MESSAGE_DELETE_CONFIRM_TITLE,
104+
buttons: [
105+
{
106+
text: STRINGS.GROUP_CHANNEL.DIALOG_MESSAGE_DELETE_CONFIRM_CANCEL,
107+
},
108+
{
109+
text: STRINGS.GROUP_CHANNEL.DIALOG_MESSAGE_DELETE_CONFIRM_OK,
110+
style: 'destructive',
111+
onPress: () => {
112+
deleteMessage()
113+
.then(() => {
114+
onClose();
115+
})
116+
.catch(() => {
117+
toast.show(STRINGS.TOAST.DELETE_MSG_ERROR, 'error');
118+
});
119+
},
120+
},
121+
],
122+
});
123+
}
124+
};
125+
126+
const _onPressDownload = () => {
127+
if (onPressDownload) {
128+
onPressDownload(fileMessage);
129+
} else {
130+
if (toMegabyte(fileMessage.size) > 4) {
131+
toast.show(STRINGS.TOAST.DOWNLOAD_START, 'success');
132+
}
133+
134+
fileService
135+
.save({ fileUrl: fileMessage.url, fileName: fileMessage.name, fileType: fileMessage.type })
136+
.then((response) => {
137+
toast.show(STRINGS.TOAST.DOWNLOAD_OK, 'success');
138+
Logger.log('File saved to', response);
139+
})
140+
.catch((err) => {
141+
toast.show(STRINGS.TOAST.DOWNLOAD_ERROR, 'error');
142+
Logger.log('File save failure', err);
143+
});
144+
}
145+
};
146+
147+
return (
148+
<View style={{ flex: 1, backgroundColor: palette.background700 }}>
149+
<StatusBar barStyle={'light-content'} animated />
150+
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
151+
{fileViewer}
152+
{loading && <LoadingSpinner style={{ position: 'absolute' }} size={40} color={palette.primary300} />}
153+
</View>
154+
{headerShown && (
155+
<FileViewerHeader
156+
title={STRINGS.FILE_VIEWER.TITLE(fileMessage)}
157+
subtitle={STRINGS.FILE_VIEWER.SUBTITLE(fileMessage)}
158+
topInset={headerTopInset ?? basicTopInset}
159+
onClose={onClose}
160+
/>
161+
)}
162+
<FileViewerFooter
163+
bottomInset={bottom}
164+
deleteShown={canDelete}
165+
onPressDelete={_onPressDelete}
166+
onPressDownload={_onPressDownload}
167+
/>
168+
</View>
169+
);
170+
};
171+
172+
type HeaderProps = {
173+
topInset: number;
174+
onClose: () => void;
175+
title: string;
176+
subtitle: string;
177+
};
178+
const FileViewerHeader = ({ topInset, onClose, subtitle, title }: HeaderProps) => {
179+
const { palette } = useUIKitTheme();
180+
const { defaultHeight } = useHeaderStyle();
181+
const { left, right } = useSafeAreaInsets();
182+
183+
return (
184+
<View
185+
style={[
186+
styles.headerContainer,
187+
{
188+
paddingLeft: styles.headerContainer.paddingHorizontal + left,
189+
paddingRight: styles.headerContainer.paddingHorizontal + right,
190+
},
191+
{ paddingTop: topInset, height: defaultHeight + topInset, backgroundColor: palette.overlay01 },
192+
]}
193+
>
194+
<SBUPressable as={'TouchableOpacity'} onPress={onClose} style={styles.barButton}>
195+
<Icon icon={'close'} size={24} color={palette.onBackgroundDark01} />
196+
</SBUPressable>
197+
<View style={styles.barTitleContainer}>
198+
<Text h2 color={palette.onBackgroundDark01} style={styles.headerTitle}>
199+
{title}
200+
</Text>
201+
<Text caption2 color={palette.onBackgroundDark01}>
202+
{subtitle}
203+
</Text>
204+
</View>
205+
<View style={styles.barButton} />
206+
</View>
207+
);
208+
};
209+
210+
type FooterProps = {
211+
bottomInset: number;
212+
deleteShown: boolean;
213+
onPressDelete: () => void;
214+
onPressDownload: () => void;
215+
};
216+
const FileViewerFooter = ({ bottomInset, deleteShown, onPressDelete, onPressDownload }: FooterProps) => {
217+
const { palette } = useUIKitTheme();
218+
const { defaultHeight } = useHeaderStyle();
219+
const { left, right } = useSafeAreaInsets();
220+
221+
return (
222+
<View
223+
style={[
224+
styles.footerContainer,
225+
{
226+
paddingLeft: styles.headerContainer.paddingHorizontal + left,
227+
paddingRight: styles.headerContainer.paddingHorizontal + right,
228+
},
229+
{
230+
paddingBottom: bottomInset,
231+
height: defaultHeight + bottomInset,
232+
backgroundColor: palette.overlay01,
233+
},
234+
]}
235+
>
236+
<SBUPressable as={'TouchableOpacity'} onPress={onPressDownload} style={styles.barButton}>
237+
<Icon icon={'download'} size={24} color={palette.onBackgroundDark01} />
238+
</SBUPressable>
239+
<View style={styles.barTitleContainer} />
240+
<SBUPressable as={'TouchableOpacity'} onPress={onPressDelete} style={styles.barButton} disabled={!deleteShown}>
241+
{deleteShown && <Icon icon={'delete'} size={24} color={palette.onBackgroundDark01} />}
242+
</SBUPressable>
243+
</View>
244+
);
245+
};
246+
247+
const styles = createStyleSheet({
248+
headerContainer: {
249+
top: 0,
250+
left: 0,
251+
right: 0,
252+
position: 'absolute',
253+
flexDirection: 'row',
254+
alignItems: 'center',
255+
justifyContent: 'center',
256+
paddingHorizontal: 12,
257+
},
258+
barButton: {
259+
width: 32,
260+
height: 32,
261+
alignItems: 'center',
262+
justifyContent: 'center',
263+
},
264+
barTitleContainer: {
265+
flex: 1,
266+
alignItems: 'center',
267+
justifyContent: 'center',
268+
},
269+
headerTitle: {
270+
marginBottom: 2,
271+
},
272+
footerContainer: {
273+
position: 'absolute',
274+
left: 0,
275+
right: 0,
276+
bottom: 0,
277+
flexDirection: 'row',
278+
alignItems: 'center',
279+
justifyContent: 'center',
280+
paddingHorizontal: 12,
281+
},
282+
});
283+
284+
export default FileViewer;

packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
getFileType,
2020
isMyMessage,
2121
messageKeyExtractor,
22+
toMegabyte,
2223
useFreshCallback,
2324
} from '@sendbird/uikit-utils';
2425

@@ -138,7 +139,6 @@ const GroupChannelMessageList = ({
138139
};
139140

140141
type HandleableMessage = SendbirdUserMessage | SendbirdFileMessage;
141-
const toMegabyte = (byte: number) => byte / 1024 / 1024;
142142
const useGetMessagePressActions = ({
143143
onPressImageMessage,
144144
onPressMediaMessage,
@@ -266,7 +266,7 @@ const useGetMessagePressActions = ({
266266
Logger.warn(DEPRECATION_WARNING.GROUP_CHANNEL.ON_PRESS_IMAGE_MESSAGE);
267267
onPressImageMessage(msg, getAvailableUriFromFileMessage(msg));
268268
}
269-
onPressMediaMessage?.(msg, getAvailableUriFromFileMessage(msg), fileType);
269+
onPressMediaMessage?.(msg, () => onDeleteMessage(msg), getAvailableUriFromFileMessage(msg));
270270
};
271271
break;
272272
}

0 commit comments

Comments
 (0)