@ethora/chat-component-rn 26.10.9 → 26.10.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/README.md +1 -1
  3. package/lib/commonjs/components/MainComponents/AlbumGrid.js +6 -2
  4. package/lib/commonjs/components/MainComponents/AlbumGrid.js.map +1 -1
  5. package/lib/commonjs/components/MainComponents/ChatHeader.js +16 -11
  6. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/MediaMessage.js +13 -0
  8. package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
  9. package/lib/commonjs/components/MainComponents/RoomList.js +7 -16
  10. package/lib/commonjs/components/MainComponents/RoomList.js.map +1 -1
  11. package/lib/commonjs/components/MessageBubble/Message.js +27 -1
  12. package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
  13. package/lib/commonjs/components/Modals/ChatProfileModal/ChatMediaGrid.js +159 -0
  14. package/lib/commonjs/components/Modals/ChatProfileModal/ChatMediaGrid.js.map +1 -0
  15. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +203 -134
  16. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  17. package/lib/commonjs/components/Modals/ChatProfileModal/LeaveChatModal.js +180 -0
  18. package/lib/commonjs/components/Modals/ChatProfileModal/LeaveChatModal.js.map +1 -0
  19. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js +100 -162
  20. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  21. package/lib/commonjs/components/Modals/FilePreviewModal/MediaViewer.js +824 -0
  22. package/lib/commonjs/components/Modals/FilePreviewModal/MediaViewer.js.map +1 -0
  23. package/lib/commonjs/components/Modals/FilePreviewModal/useSaveFile.js +179 -0
  24. package/lib/commonjs/components/Modals/FilePreviewModal/useSaveFile.js.map +1 -0
  25. package/lib/commonjs/components/Modals/MessageSearchModal/MessageSearchModal.js +22 -5
  26. package/lib/commonjs/components/Modals/MessageSearchModal/MessageSearchModal.js.map +1 -1
  27. package/lib/commonjs/components/styled/MessageImage.js +4 -1
  28. package/lib/commonjs/components/styled/MessageImage.js.map +1 -1
  29. package/lib/commonjs/components/styled/VideoMessage.js +5 -1
  30. package/lib/commonjs/components/styled/VideoMessage.js.map +1 -1
  31. package/lib/commonjs/context/MediaSourceContext.js +18 -0
  32. package/lib/commonjs/context/MediaSourceContext.js.map +1 -0
  33. package/lib/commonjs/helpers/constants/MODAL_COMPONENTS.js +3 -1
  34. package/lib/commonjs/helpers/constants/MODAL_COMPONENTS.js.map +1 -1
  35. package/lib/commonjs/helpers/constants/MODAL_TYPES.js +2 -0
  36. package/lib/commonjs/helpers/constants/MODAL_TYPES.js.map +1 -1
  37. package/lib/commonjs/helpers/publicChatsEntry.js +8 -6
  38. package/lib/commonjs/helpers/publicChatsEntry.js.map +1 -1
  39. package/lib/commonjs/helpers/roomMedia.js +118 -0
  40. package/lib/commonjs/helpers/roomMedia.js.map +1 -0
  41. package/lib/commonjs/i18n/strings.js +36 -0
  42. package/lib/commonjs/i18n/strings.js.map +1 -1
  43. package/lib/commonjs/networking/api-requests/rooms.api.js +9 -13
  44. package/lib/commonjs/networking/api-requests/rooms.api.js.map +1 -1
  45. package/lib/commonjs/version.js +1 -1
  46. package/lib/commonjs/version.js.map +1 -1
  47. package/lib/module/components/MainComponents/AlbumGrid.js +6 -2
  48. package/lib/module/components/MainComponents/AlbumGrid.js.map +1 -1
  49. package/lib/module/components/MainComponents/ChatHeader.js +18 -13
  50. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  51. package/lib/module/components/MainComponents/MediaMessage.js +13 -0
  52. package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
  53. package/lib/module/components/MainComponents/RoomList.js +7 -16
  54. package/lib/module/components/MainComponents/RoomList.js.map +1 -1
  55. package/lib/module/components/MessageBubble/Message.js +27 -1
  56. package/lib/module/components/MessageBubble/Message.js.map +1 -1
  57. package/lib/module/components/Modals/ChatProfileModal/ChatMediaGrid.js +154 -0
  58. package/lib/module/components/Modals/ChatProfileModal/ChatMediaGrid.js.map +1 -0
  59. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +204 -136
  60. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  61. package/lib/module/components/Modals/ChatProfileModal/LeaveChatModal.js +175 -0
  62. package/lib/module/components/Modals/ChatProfileModal/LeaveChatModal.js.map +1 -0
  63. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js +100 -163
  64. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  65. package/lib/module/components/Modals/FilePreviewModal/MediaViewer.js +819 -0
  66. package/lib/module/components/Modals/FilePreviewModal/MediaViewer.js.map +1 -0
  67. package/lib/module/components/Modals/FilePreviewModal/useSaveFile.js +172 -0
  68. package/lib/module/components/Modals/FilePreviewModal/useSaveFile.js.map +1 -0
  69. package/lib/module/components/Modals/MessageSearchModal/MessageSearchModal.js +20 -4
  70. package/lib/module/components/Modals/MessageSearchModal/MessageSearchModal.js.map +1 -1
  71. package/lib/module/components/styled/MessageImage.js +4 -1
  72. package/lib/module/components/styled/MessageImage.js.map +1 -1
  73. package/lib/module/components/styled/VideoMessage.js +5 -1
  74. package/lib/module/components/styled/VideoMessage.js.map +1 -1
  75. package/lib/module/context/MediaSourceContext.js +13 -0
  76. package/lib/module/context/MediaSourceContext.js.map +1 -0
  77. package/lib/module/helpers/constants/MODAL_COMPONENTS.js +2 -1
  78. package/lib/module/helpers/constants/MODAL_COMPONENTS.js.map +1 -1
  79. package/lib/module/helpers/constants/MODAL_TYPES.js +2 -0
  80. package/lib/module/helpers/constants/MODAL_TYPES.js.map +1 -1
  81. package/lib/module/helpers/publicChatsEntry.js +8 -6
  82. package/lib/module/helpers/publicChatsEntry.js.map +1 -1
  83. package/lib/module/helpers/roomMedia.js +112 -0
  84. package/lib/module/helpers/roomMedia.js.map +1 -0
  85. package/lib/module/i18n/strings.js +36 -0
  86. package/lib/module/i18n/strings.js.map +1 -1
  87. package/lib/module/networking/api-requests/rooms.api.js +9 -13
  88. package/lib/module/networking/api-requests/rooms.api.js.map +1 -1
  89. package/lib/module/version.js +1 -1
  90. package/lib/module/version.js.map +1 -1
  91. package/lib/typescript/components/MainComponents/AlbumGrid.d.ts.map +1 -1
  92. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  93. package/lib/typescript/components/MainComponents/MediaMessage.d.ts +3 -1
  94. package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
  95. package/lib/typescript/components/MainComponents/RoomList.d.ts.map +1 -1
  96. package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
  97. package/lib/typescript/components/Modals/ChatProfileModal/ChatMediaGrid.d.ts +16 -0
  98. package/lib/typescript/components/Modals/ChatProfileModal/ChatMediaGrid.d.ts.map +1 -0
  99. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  100. package/lib/typescript/components/Modals/ChatProfileModal/LeaveChatModal.d.ts +18 -0
  101. package/lib/typescript/components/Modals/ChatProfileModal/LeaveChatModal.d.ts.map +1 -0
  102. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts +16 -0
  103. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts.map +1 -1
  104. package/lib/typescript/components/Modals/FilePreviewModal/MediaViewer.d.ts +27 -0
  105. package/lib/typescript/components/Modals/FilePreviewModal/MediaViewer.d.ts.map +1 -0
  106. package/lib/typescript/components/Modals/FilePreviewModal/useSaveFile.d.ts +9 -0
  107. package/lib/typescript/components/Modals/FilePreviewModal/useSaveFile.d.ts.map +1 -0
  108. package/lib/typescript/components/Modals/MessageSearchModal/MessageSearchModal.d.ts +8 -0
  109. package/lib/typescript/components/Modals/MessageSearchModal/MessageSearchModal.d.ts.map +1 -1
  110. package/lib/typescript/components/styled/MessageImage.d.ts.map +1 -1
  111. package/lib/typescript/components/styled/VideoMessage.d.ts.map +1 -1
  112. package/lib/typescript/context/MediaSourceContext.d.ts +4 -0
  113. package/lib/typescript/context/MediaSourceContext.d.ts.map +1 -0
  114. package/lib/typescript/helpers/constants/MODAL_COMPONENTS.d.ts.map +1 -1
  115. package/lib/typescript/helpers/constants/MODAL_TYPES.d.ts +2 -0
  116. package/lib/typescript/helpers/constants/MODAL_TYPES.d.ts.map +1 -1
  117. package/lib/typescript/helpers/publicChatsEntry.d.ts +7 -5
  118. package/lib/typescript/helpers/publicChatsEntry.d.ts.map +1 -1
  119. package/lib/typescript/helpers/roomMedia.d.ts +26 -0
  120. package/lib/typescript/helpers/roomMedia.d.ts.map +1 -0
  121. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  122. package/lib/typescript/networking/api-requests/rooms.api.d.ts +6 -5
  123. package/lib/typescript/networking/api-requests/rooms.api.d.ts.map +1 -1
  124. package/lib/typescript/types/models/modal.model.d.ts +18 -0
  125. package/lib/typescript/types/models/modal.model.d.ts.map +1 -1
  126. package/lib/typescript/types/types.d.ts +18 -0
  127. package/lib/typescript/types/types.d.ts.map +1 -1
  128. package/lib/typescript/version.d.ts +1 -1
  129. package/lib/typescript/version.d.ts.map +1 -1
  130. package/package.json +9 -2
  131. package/src/components/MainComponents/AlbumGrid.tsx +5 -1
  132. package/src/components/MainComponents/ChatHeader.tsx +16 -11
  133. package/src/components/MainComponents/MediaMessage.tsx +13 -2
  134. package/src/components/MainComponents/RoomList.tsx +8 -19
  135. package/src/components/MessageBubble/Message.tsx +24 -1
  136. package/src/components/Modals/ChatProfileModal/ChatMediaGrid.tsx +174 -0
  137. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +119 -48
  138. package/src/components/Modals/ChatProfileModal/LeaveChatModal.tsx +191 -0
  139. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +115 -185
  140. package/src/components/Modals/FilePreviewModal/MediaViewer.tsx +880 -0
  141. package/src/components/Modals/FilePreviewModal/useSaveFile.ts +207 -0
  142. package/src/components/Modals/MessageSearchModal/MessageSearchModal.tsx +56 -30
  143. package/src/components/styled/MessageImage.tsx +3 -1
  144. package/src/components/styled/VideoMessage.tsx +5 -1
  145. package/src/context/MediaSourceContext.tsx +14 -0
  146. package/src/helpers/constants/MODAL_COMPONENTS.tsx +4 -1
  147. package/src/helpers/constants/MODAL_TYPES.ts +2 -0
  148. package/src/helpers/publicChatsEntry.ts +8 -7
  149. package/src/helpers/roomMedia.ts +126 -0
  150. package/src/i18n/strings.ts +36 -0
  151. package/src/networking/api-requests/rooms.api.ts +13 -12
  152. package/src/types/models/modal.model.ts +16 -0
  153. package/src/types/types.ts +16 -0
  154. package/src/version.ts +1 -1
@@ -0,0 +1,880 @@
1
+ /** @format */
2
+
3
+ import React, {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+ import {
11
+ ActivityIndicator,
12
+ Animated,
13
+ BackHandler,
14
+ Image,
15
+ LayoutChangeEvent,
16
+ Pressable,
17
+ StatusBar,
18
+ StyleSheet,
19
+ Text,
20
+ TouchableOpacity,
21
+ useWindowDimensions,
22
+ View,
23
+ } from 'react-native';
24
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
25
+ import {
26
+ Gesture,
27
+ GestureDetector,
28
+ type GestureType,
29
+ } from 'react-native-gesture-handler';
30
+ import { useVideoPlayer, VideoView } from 'expo-video';
31
+ import { useDispatch } from 'react-redux';
32
+ import type { ModalFile } from '../../../types/types';
33
+ import {
34
+ BackIcon,
35
+ DeleteIcon,
36
+ PauseIcon,
37
+ PlayIcon,
38
+ ReplyIcon,
39
+ SaveIcon,
40
+ } from '../../../assets/icons';
41
+ import { withFileToken } from '../../../helpers/secureFileUrl';
42
+ import {
43
+ downloadVideoToCache,
44
+ findCachedVideo,
45
+ isRemoteUrl,
46
+ isStreamingUnsupported,
47
+ markStreamingUnsupported,
48
+ } from '../../../helpers/videoCache';
49
+ import { setDeleteModal } from '../../../roomStore/chatSettingsSlice';
50
+ import { setActiveMessage, setEditAction } from '../../../roomStore/roomsSlice';
51
+ import { useT, useUiLocale } from '../../../i18n/useT';
52
+
53
+ const WHITE = '#FFFFFF';
54
+ const BAR_BACKGROUND = 'rgba(0, 0, 0, 0.45)';
55
+ /** Drag (fraction of the height) or fling speed that closes the viewer. */
56
+ const CLOSE_DISTANCE = 0.15;
57
+ const CLOSE_VELOCITY = 900;
58
+ /** Swipe (fraction of the width) or fling speed that turns the page. */
59
+ const PAGE_DISTANCE = 0.25;
60
+ const PAGE_VELOCITY = 700;
61
+ /** A playing video takes the chrome away after this long without a touch. */
62
+ const AUTO_HIDE_MS = 3000;
63
+
64
+ const formatTime = (seconds: number) => {
65
+ const total = Math.max(0, Math.floor(seconds || 0));
66
+ const m = Math.floor(total / 60);
67
+ const s = total % 60;
68
+ return `${m}:${s < 10 ? '0' : ''}${s}`;
69
+ };
70
+
71
+ interface MediaViewerProps {
72
+ /** A picture or a video. */
73
+ file: ModalFile;
74
+ /** Called once the viewer has animated out. */
75
+ onClose: () => void;
76
+ /** Turn to another file of the album (an index into `file.siblings`). */
77
+ onPage?: (index: number) => void;
78
+ onSave: () => void;
79
+ /** After Reply opened the thread, for a host that has more to close. */
80
+ onReplyOpened?: () => void;
81
+ }
82
+
83
+ /**
84
+ * Full-screen photo / video viewer that behaves like Telegram's. A tap shows
85
+ * or hides everything but the media (a fade both ways); a drag up or down
86
+ * pulls the media off with the backdrop fading behind it, and a long enough
87
+ * drag or a fling closes the viewer; a sideways swipe turns the page in an
88
+ * album. The bottom bar carries Reply (where replies are on) and Delete (on
89
+ * one's own message). A video has its own controls in the chrome: tapping
90
+ * them works the player instead of hiding the chrome, and while it plays the
91
+ * chrome goes away by itself after a few seconds.
92
+ */
93
+ const MediaViewer: React.FC<MediaViewerProps> = ({
94
+ file,
95
+ onClose,
96
+ onPage,
97
+ onSave,
98
+ onReplyOpened,
99
+ }) => {
100
+ const t = useT();
101
+ const locale = useUiLocale();
102
+ const dispatch = useDispatch();
103
+ const insets = useSafeAreaInsets();
104
+ const { width, height } = useWindowDimensions();
105
+
106
+ const appear = useRef(new Animated.Value(0)).current;
107
+ const dragX = useRef(new Animated.Value(0)).current;
108
+ const dragY = useRef(new Animated.Value(0)).current;
109
+ const chrome = useRef(new Animated.Value(1)).current;
110
+ const [chromeVisible, setChromeVisible] = useState(true);
111
+ const [bottomBarHeight, setBottomBarHeight] = useState(0);
112
+
113
+ const closing = useRef(false);
114
+ const axis = useRef<'x' | 'y' | null>(null);
115
+ const pendingSlide = useRef<number>(0);
116
+ const playing = useRef(false);
117
+ const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
118
+
119
+ const siblings = file.siblings ?? [];
120
+ const index = file.index ?? 0;
121
+ const hasPrev = siblings.length > 1 && index > 0;
122
+ const hasNext = siblings.length > 1 && index < siblings.length - 1;
123
+ const isVideo = (file.mimetype || '').startsWith('video/');
124
+ const source = file.source;
125
+ const canReply = !!source?.replyTo;
126
+ const canDelete = !!source?.own;
127
+
128
+ useEffect(() => {
129
+ Animated.timing(appear, {
130
+ toValue: 1,
131
+ duration: 220,
132
+ useNativeDriver: true,
133
+ }).start();
134
+ }, [appear]);
135
+
136
+ // ---- chrome ------------------------------------------------------------
137
+
138
+ const cancelAutoHide = useCallback(() => {
139
+ if (hideTimer.current) {clearTimeout(hideTimer.current);}
140
+ hideTimer.current = null;
141
+ }, []);
142
+
143
+ const showChrome = useCallback(
144
+ (visible: boolean) => {
145
+ setChromeVisible(visible);
146
+ Animated.timing(chrome, {
147
+ toValue: visible ? 1 : 0,
148
+ duration: 180,
149
+ useNativeDriver: true,
150
+ }).start();
151
+ },
152
+ [chrome]
153
+ );
154
+
155
+ const scheduleAutoHide = useCallback(() => {
156
+ cancelAutoHide();
157
+ if (!playing.current) {return;}
158
+ hideTimer.current = setTimeout(() => showChrome(false), AUTO_HIDE_MS);
159
+ }, [cancelAutoHide, showChrome]);
160
+
161
+ useEffect(() => cancelAutoHide, [cancelAutoHide]);
162
+
163
+ const toggleChrome = () => {
164
+ const next = !chromeVisible;
165
+ showChrome(next);
166
+ if (next) {scheduleAutoHide();} else {cancelAutoHide();}
167
+ };
168
+
169
+ const handlePlayback = useCallback(
170
+ (isPlaying: boolean) => {
171
+ playing.current = isPlaying;
172
+ if (isPlaying) {scheduleAutoHide();} else {cancelAutoHide();}
173
+ },
174
+ [cancelAutoHide, scheduleAutoHide]
175
+ );
176
+
177
+ // ---- close / page --------------------------------------------------------
178
+
179
+ const close = useCallback(
180
+ (direction = 0) => {
181
+ if (closing.current) {return;}
182
+ closing.current = true;
183
+ cancelAutoHide();
184
+ const animations = [
185
+ Animated.timing(appear, { toValue: 0, duration: 200, useNativeDriver: true }),
186
+ ];
187
+ if (direction !== 0) {
188
+ animations.push(
189
+ Animated.timing(dragY, {
190
+ toValue: direction * height,
191
+ duration: 200,
192
+ useNativeDriver: true,
193
+ })
194
+ );
195
+ }
196
+ Animated.parallel(animations).start(() => onClose());
197
+ },
198
+ [appear, cancelAutoHide, dragY, height, onClose]
199
+ );
200
+
201
+ useEffect(() => {
202
+ const sub = BackHandler.addEventListener('hardwareBackPress', () => {
203
+ close();
204
+ return true;
205
+ });
206
+ return () => sub.remove();
207
+ }, [close]);
208
+
209
+ const springBack = (value: Animated.Value) =>
210
+ Animated.spring(value, {
211
+ toValue: 0,
212
+ useNativeDriver: true,
213
+ bounciness: 4,
214
+ }).start();
215
+
216
+ const turnPage = useCallback(
217
+ (direction: 1 | -1) => {
218
+ Animated.timing(dragX, {
219
+ toValue: -direction * width,
220
+ duration: 160,
221
+ useNativeDriver: true,
222
+ }).start(() => {
223
+ pendingSlide.current = direction;
224
+ onPage?.(index + direction);
225
+ });
226
+ },
227
+ [dragX, index, onPage, width]
228
+ );
229
+
230
+ // The new file mounts off-screen on the side it comes from, then slides in.
231
+ useEffect(() => {
232
+ const direction = pendingSlide.current;
233
+ if (!direction) {return;}
234
+ pendingSlide.current = 0;
235
+ playing.current = false;
236
+ dragX.setValue(direction * width);
237
+ Animated.timing(dragX, {
238
+ toValue: 0,
239
+ duration: 180,
240
+ useNativeDriver: true,
241
+ }).start();
242
+ }, [dragX, file.fileURL, width]);
243
+
244
+ // ---- gestures ------------------------------------------------------------
245
+
246
+ const latest = useRef({ hasPrev, hasNext, width, height, close, turnPage });
247
+ latest.current = { hasPrev, hasNext, width, height, close, turnPage };
248
+ const pageable = siblings.length > 1;
249
+
250
+ const pan = useMemo(
251
+ () =>
252
+ Gesture.Pan()
253
+ .runOnJS(true)
254
+ .activeOffsetY([-12, 12])
255
+ .activeOffsetX(pageable ? [-12, 12] : [-10000, 10000])
256
+ .onStart((e) => {
257
+ axis.current =
258
+ pageable && Math.abs(e.translationX) > Math.abs(e.translationY)
259
+ ? 'x'
260
+ : 'y';
261
+ })
262
+ .onUpdate((e) => {
263
+ const l = latest.current;
264
+ if (axis.current === 'x') {
265
+ // Past the first or last file the page only gives a little.
266
+ const edge =
267
+ (e.translationX > 0 && !l.hasPrev) || (e.translationX < 0 && !l.hasNext);
268
+ dragX.setValue(edge ? e.translationX * 0.3 : e.translationX);
269
+ } else {
270
+ dragY.setValue(e.translationY);
271
+ }
272
+ })
273
+ .onEnd((e) => {
274
+ const l = latest.current;
275
+ if (axis.current === 'x') {
276
+ if ((e.translationX < -l.width * PAGE_DISTANCE || e.velocityX < -PAGE_VELOCITY) && l.hasNext) {
277
+ l.turnPage(1);
278
+ } else if ((e.translationX > l.width * PAGE_DISTANCE || e.velocityX > PAGE_VELOCITY) && l.hasPrev) {
279
+ l.turnPage(-1);
280
+ } else {
281
+ springBack(dragX);
282
+ }
283
+ } else if (
284
+ Math.abs(e.translationY) > l.height * CLOSE_DISTANCE ||
285
+ Math.abs(e.velocityY) > CLOSE_VELOCITY
286
+ ) {
287
+ l.close(Math.sign(e.translationY || e.velocityY) || 1);
288
+ } else {
289
+ springBack(dragY);
290
+ }
291
+ axis.current = null;
292
+ }),
293
+ // eslint-disable-next-line react-hooks/exhaustive-deps
294
+ [pageable]
295
+ );
296
+
297
+ // ---- actions -------------------------------------------------------------
298
+
299
+ const handleReply = () => {
300
+ if (!source?.replyTo) {return;}
301
+ const target = source.replyTo;
302
+ close();
303
+ dispatch(setEditAction({ isEdit: false }));
304
+ dispatch(setActiveMessage({ id: target.id, chatJID: target.chatJID }));
305
+ onReplyOpened?.();
306
+ };
307
+
308
+ const handleDelete = () => {
309
+ if (!source?.own) {return;}
310
+ const { roomJid, messageId } = source;
311
+ close();
312
+ dispatch(setDeleteModal({ isDeleteModal: true, roomJid, messageId }));
313
+ };
314
+
315
+ // ---- styles from the animated values -------------------------------------
316
+
317
+ const dragFade = dragY.interpolate({
318
+ inputRange: [-height / 2, 0, height / 2],
319
+ outputRange: [0.15, 1, 0.15],
320
+ extrapolate: 'clamp',
321
+ });
322
+ const dragShrink = dragY.interpolate({
323
+ inputRange: [-height, 0, height],
324
+ outputRange: [0.85, 1, 0.85],
325
+ extrapolate: 'clamp',
326
+ });
327
+ const backdropOpacity = Animated.multiply(appear, dragFade);
328
+ const mediaStyle = {
329
+ transform: [
330
+ { translateX: dragX },
331
+ { translateY: dragY },
332
+ {
333
+ scale: Animated.multiply(
334
+ appear.interpolate({ inputRange: [0, 1], outputRange: [0.92, 1] }),
335
+ dragShrink
336
+ ),
337
+ },
338
+ ],
339
+ opacity: appear,
340
+ };
341
+ // The chrome steps aside while the media is being dragged.
342
+ const chromeOpacity = Animated.multiply(
343
+ Animated.multiply(chrome, appear),
344
+ dragY.interpolate({
345
+ inputRange: [-60, 0, 60],
346
+ outputRange: [0, 1, 0],
347
+ extrapolate: 'clamp',
348
+ })
349
+ );
350
+ const chromePointerEvents = chromeVisible ? 'box-none' : 'none';
351
+
352
+ const subtitle = [
353
+ source?.timestamp
354
+ ? new Date(source.timestamp).toLocaleString(locale, {
355
+ day: 'numeric',
356
+ month: 'short',
357
+ hour: '2-digit',
358
+ minute: '2-digit',
359
+ })
360
+ : null,
361
+ siblings.length > 1
362
+ ? t('attachment.position', { index: index + 1, total: siblings.length })
363
+ : null,
364
+ ]
365
+ .filter(Boolean)
366
+ .join(' · ');
367
+
368
+ const showBottomBar = canReply || canDelete;
369
+
370
+ return (
371
+ <View style={StyleSheet.absoluteFill} testID="media-viewer">
372
+ {/* Light text on the black backdrop; it leaves with the chrome. The
373
+ previous bar style comes back when the viewer unmounts. */}
374
+ <StatusBar barStyle="light-content" hidden={!chromeVisible} animated />
375
+ <Animated.View
376
+ style={[StyleSheet.absoluteFill, styles.backdrop, { opacity: backdropOpacity }]}
377
+ />
378
+ <GestureDetector gesture={pan}>
379
+ <View style={StyleSheet.absoluteFill}>
380
+ <Pressable
381
+ testID="media-viewer-surface"
382
+ style={StyleSheet.absoluteFill}
383
+ onPress={toggleChrome}
384
+ >
385
+ <Animated.View style={[StyleSheet.absoluteFill, mediaStyle]} pointerEvents="none">
386
+ {isVideo ? null : <ViewerImage key={file.fileURL} uri={file.fileURL} />}
387
+ </Animated.View>
388
+ </Pressable>
389
+
390
+ {isVideo && (
391
+ <ViewerVideo
392
+ key={file.fileURL}
393
+ uri={withFileToken(file.fileURL)}
394
+ mimetype={file.mimetype}
395
+ mediaStyle={mediaStyle}
396
+ chromeOpacity={chromeOpacity}
397
+ chromePointerEvents={chromePointerEvents}
398
+ bottomOffset={(showBottomBar ? bottomBarHeight : insets.bottom) + 8}
399
+ outerGesture={pan}
400
+ onPlayback={handlePlayback}
401
+ onActivity={scheduleAutoHide}
402
+ />
403
+ )}
404
+
405
+ <Animated.View
406
+ pointerEvents={chromePointerEvents}
407
+ style={[styles.topBar, { paddingTop: insets.top + 6, opacity: chromeOpacity }]}
408
+ >
409
+ <TouchableOpacity
410
+ testID="media-viewer-back"
411
+ accessibilityRole="button"
412
+ accessibilityLabel={t('action.close')}
413
+ hitSlop={12}
414
+ style={styles.iconButton}
415
+ onPress={() => close()}
416
+ >
417
+ <BackIcon color={WHITE} />
418
+ </TouchableOpacity>
419
+ <View style={styles.titleBlock}>
420
+ <Text numberOfLines={1} style={styles.title}>
421
+ {source?.senderName || t('modal.filePreview.title')}
422
+ </Text>
423
+ {!!subtitle && (
424
+ <Text numberOfLines={1} style={styles.subtitle}>
425
+ {subtitle}
426
+ </Text>
427
+ )}
428
+ </View>
429
+ <TouchableOpacity
430
+ testID="media-viewer-save"
431
+ accessibilityRole="button"
432
+ accessibilityLabel={t('action.save')}
433
+ hitSlop={12}
434
+ style={styles.iconButton}
435
+ onPress={onSave}
436
+ >
437
+ <SaveIcon color={WHITE} />
438
+ </TouchableOpacity>
439
+ </Animated.View>
440
+
441
+ {showBottomBar && (
442
+ <Animated.View
443
+ pointerEvents={chromePointerEvents}
444
+ onLayout={(e: LayoutChangeEvent) =>
445
+ setBottomBarHeight(e.nativeEvent.layout.height)
446
+ }
447
+ style={[
448
+ styles.bottomBar,
449
+ { paddingBottom: insets.bottom + 8, opacity: chromeOpacity },
450
+ ]}
451
+ >
452
+ {canReply ? (
453
+ <TouchableOpacity
454
+ testID="media-viewer-reply"
455
+ accessibilityRole="button"
456
+ accessibilityLabel={t('action.reply')}
457
+ hitSlop={12}
458
+ style={styles.iconButton}
459
+ onPress={handleReply}
460
+ >
461
+ <ReplyIcon color={WHITE} />
462
+ </TouchableOpacity>
463
+ ) : (
464
+ <View style={styles.iconButton} />
465
+ )}
466
+ {canDelete && (
467
+ <TouchableOpacity
468
+ testID="media-viewer-delete"
469
+ accessibilityRole="button"
470
+ accessibilityLabel={t('action.delete')}
471
+ hitSlop={12}
472
+ style={styles.iconButton}
473
+ onPress={handleDelete}
474
+ >
475
+ <DeleteIcon fill={WHITE} />
476
+ </TouchableOpacity>
477
+ )}
478
+ </Animated.View>
479
+ )}
480
+ </View>
481
+ </GestureDetector>
482
+ </View>
483
+ );
484
+ };
485
+
486
+ const ViewerImage: React.FC<{ uri: string }> = ({ uri }) => {
487
+ const [loaded, setLoaded] = useState(false);
488
+ return (
489
+ <>
490
+ <Image
491
+ source={{ uri: withFileToken(uri) }}
492
+ style={StyleSheet.absoluteFill}
493
+ resizeMode="contain"
494
+ onLoad={() => setLoaded(true)}
495
+ onError={() => setLoaded(true)}
496
+ />
497
+ {!loaded && (
498
+ <View style={styles.center}>
499
+ <ActivityIndicator color={WHITE} />
500
+ </View>
501
+ )}
502
+ </>
503
+ );
504
+ };
505
+
506
+ interface ViewerVideoProps {
507
+ uri: string;
508
+ mimetype?: string;
509
+ mediaStyle: any;
510
+ chromeOpacity: Animated.AnimatedInterpolation<number> | Animated.AnimatedMultiplication<number>;
511
+ chromePointerEvents: 'box-none' | 'none';
512
+ /** Distance of the scrubber from the bottom edge (above the action bar). */
513
+ bottomOffset: number;
514
+ /** The viewer's pan, which waits for the scrubber's own drag to fail. */
515
+ outerGesture: GestureType;
516
+ onPlayback: (playing: boolean) => void;
517
+ /** Any touch on the controls: restarts the chrome's auto-hide. */
518
+ onActivity: () => void;
519
+ }
520
+
521
+ /**
522
+ * The video page: the surface in the media layer, the play / pause button
523
+ * and the scrubber in the chrome layer. Streams when the server allows it
524
+ * and falls back to a downloaded copy (AVPlayer refuses servers that ignore
525
+ * byte ranges), which is also what a reopened video plays from.
526
+ */
527
+ const ViewerVideo: React.FC<ViewerVideoProps> = ({
528
+ uri,
529
+ mimetype,
530
+ mediaStyle,
531
+ chromeOpacity,
532
+ chromePointerEvents,
533
+ bottomOffset,
534
+ outerGesture,
535
+ onPlayback,
536
+ onActivity,
537
+ }) => {
538
+ const t = useT();
539
+ const remote = isRemoteUrl(uri);
540
+ // What the player is given; null until we know where to play from.
541
+ const [source, setSource] = useState<string | null>(remote ? null : uri);
542
+ // null = not downloading, otherwise 0..1 (0 while the size is unknown).
543
+ const [progress, setProgress] = useState<number | null>(null);
544
+ const [failed, setFailed] = useState(false);
545
+ const [isPlaying, setIsPlaying] = useState(false);
546
+ const [time, setTime] = useState(0);
547
+ const [duration, setDuration] = useState(0);
548
+ const [scrubTime, setScrubTime] = useState<number | null>(null);
549
+ const [trackWidth, setTrackWidth] = useState(0);
550
+ const ended = useRef(false);
551
+ // Opens playing, like Telegram; a pause sticks across a fallback copy.
552
+ const wantsPlay = useRef(true);
553
+ const downloadStarted = useRef(false);
554
+ const mounted = useRef(true);
555
+ useEffect(
556
+ () => () => {
557
+ mounted.current = false;
558
+ },
559
+ []
560
+ );
561
+
562
+ const download = useCallback(() => {
563
+ if (downloadStarted.current) {return;}
564
+ downloadStarted.current = true;
565
+ setProgress(0);
566
+ downloadVideoToCache(uri, mimetype, (fraction) => {
567
+ if (mounted.current) {setProgress(fraction);}
568
+ })
569
+ .then((localUri) => {
570
+ if (!mounted.current) {return;}
571
+ setProgress(null);
572
+ setSource(localUri);
573
+ })
574
+ .catch((err) => {
575
+ console.warn('video download failed', err);
576
+ if (!mounted.current) {return;}
577
+ setProgress(null);
578
+ setFailed(true);
579
+ });
580
+ }, [mimetype, uri]);
581
+
582
+ useEffect(() => {
583
+ if (!remote) {return;}
584
+ let active = true;
585
+ findCachedVideo(uri, mimetype).then((cached) => {
586
+ if (!active) {return;}
587
+ if (cached) {
588
+ setSource(cached);
589
+ } else if (isStreamingUnsupported(uri)) {
590
+ download();
591
+ } else {
592
+ setSource(uri);
593
+ }
594
+ });
595
+ return () => {
596
+ active = false;
597
+ };
598
+ }, [download, mimetype, remote, uri]);
599
+
600
+ const player = useVideoPlayer(source, (p) => {
601
+ p.muted = false;
602
+ p.timeUpdateEventInterval = 0.25;
603
+ if (wantsPlay.current) {p.play();}
604
+ });
605
+
606
+ useEffect(() => {
607
+ const subs = [
608
+ player.addListener('playingChange', ({ isPlaying: next }) => {
609
+ setIsPlaying(next);
610
+ onPlayback(next);
611
+ }),
612
+ player.addListener('timeUpdate', ({ currentTime }) => setTime(currentTime)),
613
+ player.addListener('playToEnd', () => {
614
+ ended.current = true;
615
+ }),
616
+ player.addListener('statusChange', (event) => {
617
+ if (event.status === 'readyToPlay') {
618
+ setDuration(player.duration || 0);
619
+ return;
620
+ }
621
+ if (event.status !== 'error') {return;}
622
+ if (source === uri && remote) {
623
+ markStreamingUnsupported(uri);
624
+ download();
625
+ } else {
626
+ setFailed(true);
627
+ }
628
+ }),
629
+ ];
630
+ return () => subs.forEach((sub) => sub.remove());
631
+ }, [download, onPlayback, player, remote, source, uri]);
632
+
633
+ useEffect(() => () => onPlayback(false), [onPlayback]);
634
+
635
+ const togglePlay = () => {
636
+ onActivity();
637
+ if (isPlaying) {
638
+ wantsPlay.current = false;
639
+ player.pause();
640
+ return;
641
+ }
642
+ if (ended.current) {
643
+ ended.current = false;
644
+ player.currentTime = 0;
645
+ }
646
+ wantsPlay.current = true;
647
+ player.play();
648
+ };
649
+
650
+ const latest = useRef({ trackWidth, duration, player, onActivity });
651
+ latest.current = { trackWidth, duration, player, onActivity };
652
+ const timeAt = (x: number) => {
653
+ const l = latest.current;
654
+ if (l.trackWidth <= 0 || l.duration <= 0) {return 0;}
655
+ return Math.min(1, Math.max(0, x / l.trackWidth)) * l.duration;
656
+ };
657
+ const seek = (seconds: number) => {
658
+ const l = latest.current;
659
+ ended.current = false;
660
+ l.player.currentTime = seconds;
661
+ setTime(seconds);
662
+ l.onActivity();
663
+ };
664
+
665
+ const scrub = useMemo(() => {
666
+ const drag = Gesture.Pan()
667
+ .runOnJS(true)
668
+ .activeOffsetX([-2, 2])
669
+ .onUpdate((e) => setScrubTime(timeAt(e.x)))
670
+ .onEnd((e) => seek(timeAt(e.x)))
671
+ .onFinalize(() => setScrubTime(null))
672
+ .blocksExternalGesture(outerGesture);
673
+ const tap = Gesture.Tap()
674
+ .runOnJS(true)
675
+ .onEnd((e) => seek(timeAt(e.x)))
676
+ .blocksExternalGesture(outerGesture);
677
+ return Gesture.Exclusive(drag, tap);
678
+ }, [outerGesture]);
679
+
680
+ const loading = progress !== null || source === null;
681
+ const shown = scrubTime ?? time;
682
+ const fraction = duration > 0 ? Math.min(1, shown / duration) : 0;
683
+
684
+ return (
685
+ <>
686
+ <Animated.View
687
+ style={[StyleSheet.absoluteFill, mediaStyle]}
688
+ pointerEvents="none"
689
+ >
690
+ {/* No surface while the copy downloads: the failed stream would
691
+ show the system's "can't play" glyph under the spinner. */}
692
+ {!loading && !failed && (
693
+ <VideoView
694
+ player={player}
695
+ style={StyleSheet.absoluteFill}
696
+ contentFit="contain"
697
+ nativeControls={false}
698
+ surfaceType="textureView"
699
+ />
700
+ )}
701
+ </Animated.View>
702
+
703
+ {failed ? (
704
+ <View style={styles.center} pointerEvents="none">
705
+ <Text style={styles.failedTitle}>{t('video.cantPlay')}</Text>
706
+ <Text style={styles.failedHint}>{t('video.cantPlayHint')}</Text>
707
+ </View>
708
+ ) : loading ? (
709
+ <View style={styles.center} pointerEvents="none">
710
+ <View style={styles.playButton}>
711
+ <ActivityIndicator color={WHITE} />
712
+ </View>
713
+ {!!progress && (
714
+ <Text style={styles.progress}>{Math.round(progress * 100)}%</Text>
715
+ )}
716
+ </View>
717
+ ) : (
718
+ <>
719
+ <Animated.View
720
+ pointerEvents={chromePointerEvents}
721
+ style={[styles.center, { opacity: chromeOpacity }]}
722
+ >
723
+ <TouchableOpacity
724
+ testID="media-viewer-play"
725
+ accessibilityRole="button"
726
+ activeOpacity={0.8}
727
+ style={styles.playButton}
728
+ onPress={togglePlay}
729
+ >
730
+ {isPlaying ? (
731
+ <PauseIcon width={30} height={30} color={WHITE} />
732
+ ) : (
733
+ <PlayIcon width={30} height={30} color={WHITE} />
734
+ )}
735
+ </TouchableOpacity>
736
+ </Animated.View>
737
+
738
+ <Animated.View
739
+ pointerEvents={chromePointerEvents}
740
+ style={[styles.scrubberRow, { bottom: bottomOffset, opacity: chromeOpacity }]}
741
+ >
742
+ <Text style={styles.time}>{formatTime(shown)}</Text>
743
+ <GestureDetector gesture={scrub}>
744
+ <View
745
+ testID="media-viewer-scrubber"
746
+ style={styles.track}
747
+ onLayout={(e) => setTrackWidth(e.nativeEvent.layout.width)}
748
+ >
749
+ <View style={styles.trackLine}>
750
+ <View style={[styles.trackFill, { width: `${fraction * 100}%` }]} />
751
+ </View>
752
+ <View style={[styles.thumb, { left: fraction * trackWidth - 7 }]} />
753
+ </View>
754
+ </GestureDetector>
755
+ <Text style={styles.time}>{formatTime(duration)}</Text>
756
+ </Animated.View>
757
+ </>
758
+ )}
759
+ </>
760
+ );
761
+ };
762
+
763
+ const styles = StyleSheet.create({
764
+ backdrop: {
765
+ backgroundColor: '#000000',
766
+ },
767
+ center: {
768
+ ...StyleSheet.absoluteFill,
769
+ alignItems: 'center',
770
+ justifyContent: 'center',
771
+ },
772
+ topBar: {
773
+ position: 'absolute',
774
+ top: 0,
775
+ left: 0,
776
+ right: 0,
777
+ flexDirection: 'row',
778
+ alignItems: 'center',
779
+ paddingHorizontal: 8,
780
+ paddingBottom: 10,
781
+ backgroundColor: BAR_BACKGROUND,
782
+ },
783
+ bottomBar: {
784
+ position: 'absolute',
785
+ left: 0,
786
+ right: 0,
787
+ bottom: 0,
788
+ flexDirection: 'row',
789
+ alignItems: 'center',
790
+ justifyContent: 'space-between',
791
+ paddingHorizontal: 8,
792
+ paddingTop: 10,
793
+ backgroundColor: BAR_BACKGROUND,
794
+ },
795
+ iconButton: {
796
+ width: 44,
797
+ height: 44,
798
+ alignItems: 'center',
799
+ justifyContent: 'center',
800
+ },
801
+ titleBlock: {
802
+ flex: 1,
803
+ alignItems: 'center',
804
+ paddingHorizontal: 8,
805
+ },
806
+ title: {
807
+ color: WHITE,
808
+ fontSize: 16,
809
+ fontWeight: '600',
810
+ },
811
+ subtitle: {
812
+ color: 'rgba(255, 255, 255, 0.7)',
813
+ fontSize: 13,
814
+ marginTop: 2,
815
+ },
816
+ playButton: {
817
+ width: 64,
818
+ height: 64,
819
+ borderRadius: 32,
820
+ backgroundColor: 'rgba(0, 0, 0, 0.55)',
821
+ alignItems: 'center',
822
+ justifyContent: 'center',
823
+ },
824
+ progress: {
825
+ marginTop: 10,
826
+ color: WHITE,
827
+ fontSize: 13,
828
+ fontWeight: '600',
829
+ },
830
+ failedTitle: {
831
+ color: WHITE,
832
+ fontSize: 16,
833
+ fontWeight: '600',
834
+ },
835
+ failedHint: {
836
+ marginTop: 8,
837
+ color: 'rgba(255, 255, 255, 0.7)',
838
+ textAlign: 'center',
839
+ paddingHorizontal: 32,
840
+ },
841
+ scrubberRow: {
842
+ position: 'absolute',
843
+ left: 12,
844
+ right: 12,
845
+ flexDirection: 'row',
846
+ alignItems: 'center',
847
+ gap: 10,
848
+ },
849
+ time: {
850
+ color: WHITE,
851
+ fontSize: 12,
852
+ fontVariant: ['tabular-nums'],
853
+ minWidth: 36,
854
+ textAlign: 'center',
855
+ },
856
+ track: {
857
+ flex: 1,
858
+ height: 32,
859
+ justifyContent: 'center',
860
+ },
861
+ trackLine: {
862
+ height: 3,
863
+ borderRadius: 1.5,
864
+ backgroundColor: 'rgba(255, 255, 255, 0.35)',
865
+ overflow: 'hidden',
866
+ },
867
+ trackFill: {
868
+ height: '100%',
869
+ backgroundColor: WHITE,
870
+ },
871
+ thumb: {
872
+ position: 'absolute',
873
+ width: 14,
874
+ height: 14,
875
+ borderRadius: 7,
876
+ backgroundColor: WHITE,
877
+ },
878
+ });
879
+
880
+ export default MediaViewer;