@ethora/chat-component-rn 26.10.8 → 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 (184) hide show
  1. package/CHANGELOG.md +7 -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/Menu/HeaderRoomListMenu.js +3 -3
  12. package/lib/commonjs/components/Menu/HeaderRoomListMenu.js.map +1 -1
  13. package/lib/commonjs/components/MessageBubble/Message.js +27 -1
  14. package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
  15. package/lib/commonjs/components/Modals/ChatProfileModal/ChatMediaGrid.js +159 -0
  16. package/lib/commonjs/components/Modals/ChatProfileModal/ChatMediaGrid.js.map +1 -0
  17. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +203 -134
  18. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  19. package/lib/commonjs/components/Modals/ChatProfileModal/LeaveChatModal.js +180 -0
  20. package/lib/commonjs/components/Modals/ChatProfileModal/LeaveChatModal.js.map +1 -0
  21. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js +100 -162
  22. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  23. package/lib/commonjs/components/Modals/FilePreviewModal/MediaViewer.js +824 -0
  24. package/lib/commonjs/components/Modals/FilePreviewModal/MediaViewer.js.map +1 -0
  25. package/lib/commonjs/components/Modals/FilePreviewModal/useSaveFile.js +179 -0
  26. package/lib/commonjs/components/Modals/FilePreviewModal/useSaveFile.js.map +1 -0
  27. package/lib/commonjs/components/Modals/MessageSearchModal/MessageSearchModal.js +22 -5
  28. package/lib/commonjs/components/Modals/MessageSearchModal/MessageSearchModal.js.map +1 -1
  29. package/lib/commonjs/components/VideoCalls/useCallKeep.js +1 -3
  30. package/lib/commonjs/components/VideoCalls/useCallKeep.js.map +1 -1
  31. package/lib/commonjs/components/styled/MessageImage.js +4 -1
  32. package/lib/commonjs/components/styled/MessageImage.js.map +1 -1
  33. package/lib/commonjs/components/styled/VideoMessage.js +5 -1
  34. package/lib/commonjs/components/styled/VideoMessage.js.map +1 -1
  35. package/lib/commonjs/context/MediaSourceContext.js +18 -0
  36. package/lib/commonjs/context/MediaSourceContext.js.map +1 -0
  37. package/lib/commonjs/helpers/constants/MODAL_COMPONENTS.js +3 -1
  38. package/lib/commonjs/helpers/constants/MODAL_COMPONENTS.js.map +1 -1
  39. package/lib/commonjs/helpers/constants/MODAL_TYPES.js +2 -0
  40. package/lib/commonjs/helpers/constants/MODAL_TYPES.js.map +1 -1
  41. package/lib/commonjs/helpers/publicChatsEntry.js +8 -6
  42. package/lib/commonjs/helpers/publicChatsEntry.js.map +1 -1
  43. package/lib/commonjs/helpers/roomMedia.js +118 -0
  44. package/lib/commonjs/helpers/roomMedia.js.map +1 -0
  45. package/lib/commonjs/i18n/strings.js +36 -0
  46. package/lib/commonjs/i18n/strings.js.map +1 -1
  47. package/lib/commonjs/networking/api-requests/rooms.api.js +9 -13
  48. package/lib/commonjs/networking/api-requests/rooms.api.js.map +1 -1
  49. package/lib/commonjs/networking/callTokenStanza.js +13 -1
  50. package/lib/commonjs/networking/callTokenStanza.js.map +1 -1
  51. package/lib/commonjs/push/sealedPushTask.js +20 -1
  52. package/lib/commonjs/push/sealedPushTask.js.map +1 -1
  53. package/lib/commonjs/version.js +1 -1
  54. package/lib/commonjs/version.js.map +1 -1
  55. package/lib/module/components/MainComponents/AlbumGrid.js +6 -2
  56. package/lib/module/components/MainComponents/AlbumGrid.js.map +1 -1
  57. package/lib/module/components/MainComponents/ChatHeader.js +18 -13
  58. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  59. package/lib/module/components/MainComponents/MediaMessage.js +13 -0
  60. package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
  61. package/lib/module/components/MainComponents/RoomList.js +7 -16
  62. package/lib/module/components/MainComponents/RoomList.js.map +1 -1
  63. package/lib/module/components/Menu/HeaderRoomListMenu.js +3 -3
  64. package/lib/module/components/Menu/HeaderRoomListMenu.js.map +1 -1
  65. package/lib/module/components/MessageBubble/Message.js +27 -1
  66. package/lib/module/components/MessageBubble/Message.js.map +1 -1
  67. package/lib/module/components/Modals/ChatProfileModal/ChatMediaGrid.js +154 -0
  68. package/lib/module/components/Modals/ChatProfileModal/ChatMediaGrid.js.map +1 -0
  69. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +204 -136
  70. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  71. package/lib/module/components/Modals/ChatProfileModal/LeaveChatModal.js +175 -0
  72. package/lib/module/components/Modals/ChatProfileModal/LeaveChatModal.js.map +1 -0
  73. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js +100 -163
  74. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  75. package/lib/module/components/Modals/FilePreviewModal/MediaViewer.js +819 -0
  76. package/lib/module/components/Modals/FilePreviewModal/MediaViewer.js.map +1 -0
  77. package/lib/module/components/Modals/FilePreviewModal/useSaveFile.js +172 -0
  78. package/lib/module/components/Modals/FilePreviewModal/useSaveFile.js.map +1 -0
  79. package/lib/module/components/Modals/MessageSearchModal/MessageSearchModal.js +20 -4
  80. package/lib/module/components/Modals/MessageSearchModal/MessageSearchModal.js.map +1 -1
  81. package/lib/module/components/VideoCalls/useCallKeep.js +2 -4
  82. package/lib/module/components/VideoCalls/useCallKeep.js.map +1 -1
  83. package/lib/module/components/styled/MessageImage.js +4 -1
  84. package/lib/module/components/styled/MessageImage.js.map +1 -1
  85. package/lib/module/components/styled/VideoMessage.js +5 -1
  86. package/lib/module/components/styled/VideoMessage.js.map +1 -1
  87. package/lib/module/context/MediaSourceContext.js +13 -0
  88. package/lib/module/context/MediaSourceContext.js.map +1 -0
  89. package/lib/module/helpers/constants/MODAL_COMPONENTS.js +2 -1
  90. package/lib/module/helpers/constants/MODAL_COMPONENTS.js.map +1 -1
  91. package/lib/module/helpers/constants/MODAL_TYPES.js +2 -0
  92. package/lib/module/helpers/constants/MODAL_TYPES.js.map +1 -1
  93. package/lib/module/helpers/publicChatsEntry.js +8 -6
  94. package/lib/module/helpers/publicChatsEntry.js.map +1 -1
  95. package/lib/module/helpers/roomMedia.js +112 -0
  96. package/lib/module/helpers/roomMedia.js.map +1 -0
  97. package/lib/module/i18n/strings.js +36 -0
  98. package/lib/module/i18n/strings.js.map +1 -1
  99. package/lib/module/networking/api-requests/rooms.api.js +9 -13
  100. package/lib/module/networking/api-requests/rooms.api.js.map +1 -1
  101. package/lib/module/networking/callTokenStanza.js +13 -1
  102. package/lib/module/networking/callTokenStanza.js.map +1 -1
  103. package/lib/module/push/sealedPushTask.js +20 -1
  104. package/lib/module/push/sealedPushTask.js.map +1 -1
  105. package/lib/module/version.js +1 -1
  106. package/lib/module/version.js.map +1 -1
  107. package/lib/typescript/components/MainComponents/AlbumGrid.d.ts.map +1 -1
  108. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  109. package/lib/typescript/components/MainComponents/MediaMessage.d.ts +3 -1
  110. package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
  111. package/lib/typescript/components/MainComponents/RoomList.d.ts.map +1 -1
  112. package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts.map +1 -1
  113. package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
  114. package/lib/typescript/components/Modals/ChatProfileModal/ChatMediaGrid.d.ts +16 -0
  115. package/lib/typescript/components/Modals/ChatProfileModal/ChatMediaGrid.d.ts.map +1 -0
  116. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  117. package/lib/typescript/components/Modals/ChatProfileModal/LeaveChatModal.d.ts +18 -0
  118. package/lib/typescript/components/Modals/ChatProfileModal/LeaveChatModal.d.ts.map +1 -0
  119. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts +16 -0
  120. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts.map +1 -1
  121. package/lib/typescript/components/Modals/FilePreviewModal/MediaViewer.d.ts +27 -0
  122. package/lib/typescript/components/Modals/FilePreviewModal/MediaViewer.d.ts.map +1 -0
  123. package/lib/typescript/components/Modals/FilePreviewModal/useSaveFile.d.ts +9 -0
  124. package/lib/typescript/components/Modals/FilePreviewModal/useSaveFile.d.ts.map +1 -0
  125. package/lib/typescript/components/Modals/MessageSearchModal/MessageSearchModal.d.ts +8 -0
  126. package/lib/typescript/components/Modals/MessageSearchModal/MessageSearchModal.d.ts.map +1 -1
  127. package/lib/typescript/components/VideoCalls/useCallKeep.d.ts.map +1 -1
  128. package/lib/typescript/components/styled/MessageImage.d.ts.map +1 -1
  129. package/lib/typescript/components/styled/VideoMessage.d.ts.map +1 -1
  130. package/lib/typescript/context/MediaSourceContext.d.ts +4 -0
  131. package/lib/typescript/context/MediaSourceContext.d.ts.map +1 -0
  132. package/lib/typescript/helpers/constants/MODAL_COMPONENTS.d.ts.map +1 -1
  133. package/lib/typescript/helpers/constants/MODAL_TYPES.d.ts +2 -0
  134. package/lib/typescript/helpers/constants/MODAL_TYPES.d.ts.map +1 -1
  135. package/lib/typescript/helpers/publicChatsEntry.d.ts +7 -5
  136. package/lib/typescript/helpers/publicChatsEntry.d.ts.map +1 -1
  137. package/lib/typescript/helpers/roomMedia.d.ts +26 -0
  138. package/lib/typescript/helpers/roomMedia.d.ts.map +1 -0
  139. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  140. package/lib/typescript/main.d.ts +1 -1
  141. package/lib/typescript/main.d.ts.map +1 -1
  142. package/lib/typescript/networking/api-requests/rooms.api.d.ts +6 -5
  143. package/lib/typescript/networking/api-requests/rooms.api.d.ts.map +1 -1
  144. package/lib/typescript/networking/callTokenStanza.d.ts.map +1 -1
  145. package/lib/typescript/push/sealedPushTask.d.ts +13 -0
  146. package/lib/typescript/push/sealedPushTask.d.ts.map +1 -1
  147. package/lib/typescript/types/models/modal.model.d.ts +18 -0
  148. package/lib/typescript/types/models/modal.model.d.ts.map +1 -1
  149. package/lib/typescript/types/types.d.ts +18 -0
  150. package/lib/typescript/types/types.d.ts.map +1 -1
  151. package/lib/typescript/version.d.ts +1 -1
  152. package/lib/typescript/version.d.ts.map +1 -1
  153. package/package.json +9 -2
  154. package/plugins/notification-service/NotificationService.swift +105 -19
  155. package/plugins/withNotificationServiceExtension.js +44 -4
  156. package/src/components/MainComponents/AlbumGrid.tsx +5 -1
  157. package/src/components/MainComponents/ChatHeader.tsx +16 -11
  158. package/src/components/MainComponents/MediaMessage.tsx +13 -2
  159. package/src/components/MainComponents/RoomList.tsx +8 -19
  160. package/src/components/Menu/HeaderRoomListMenu.tsx +13 -8
  161. package/src/components/MessageBubble/Message.tsx +24 -1
  162. package/src/components/Modals/ChatProfileModal/ChatMediaGrid.tsx +174 -0
  163. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +119 -48
  164. package/src/components/Modals/ChatProfileModal/LeaveChatModal.tsx +191 -0
  165. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +115 -185
  166. package/src/components/Modals/FilePreviewModal/MediaViewer.tsx +880 -0
  167. package/src/components/Modals/FilePreviewModal/useSaveFile.ts +207 -0
  168. package/src/components/Modals/MessageSearchModal/MessageSearchModal.tsx +56 -30
  169. package/src/components/VideoCalls/useCallKeep.ts +2 -4
  170. package/src/components/styled/MessageImage.tsx +3 -1
  171. package/src/components/styled/VideoMessage.tsx +5 -1
  172. package/src/context/MediaSourceContext.tsx +14 -0
  173. package/src/helpers/constants/MODAL_COMPONENTS.tsx +4 -1
  174. package/src/helpers/constants/MODAL_TYPES.ts +2 -0
  175. package/src/helpers/publicChatsEntry.ts +8 -7
  176. package/src/helpers/roomMedia.ts +126 -0
  177. package/src/i18n/strings.ts +36 -0
  178. package/src/main.ts +1 -1
  179. package/src/networking/api-requests/rooms.api.ts +13 -12
  180. package/src/networking/callTokenStanza.ts +10 -1
  181. package/src/push/sealedPushTask.ts +34 -1
  182. package/src/types/models/modal.model.ts +16 -0
  183. package/src/types/types.ts +16 -0
  184. package/src/version.ts +1 -1
@@ -0,0 +1,207 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { Alert, Platform, Share } from "react-native";
3
+ import * as FileSystem from "expo-file-system/legacy";
4
+ import type { ModalFile } from "../../../types/types";
5
+ import { withFileToken } from "../../../helpers/secureFileUrl";
6
+ import { getMediaLibrary } from "../../../helpers/mediaLibraryRuntime";
7
+ import { useToast } from "../../../context/ToastContext";
8
+ import { useT } from "../../../i18n/useT";
9
+ import {
10
+ getDisplayFileName,
11
+ getUniqueFileName,
12
+ sanitizeFileNameForPath,
13
+ } from "../../../helpers/getDisplayFileName";
14
+
15
+ export const isLocalFile = (url: string | undefined | null) =>
16
+ /^file:\/\//i.test(url || "");
17
+
18
+ const fetchToCache = async (url: string, cachePath: string) =>
19
+ isLocalFile(url)
20
+ ? { status: 200, uri: url }
21
+ : FileSystem.downloadAsync(withFileToken(url), cachePath);
22
+
23
+ /**
24
+ * Save button of the file viewers: pictures and videos to the gallery,
25
+ * anything else to Downloads (Android, through SAF) or the share sheet (iOS).
26
+ * Shared by the preview page and the media viewer opened from a chat profile.
27
+ */
28
+ export const useSaveFile = () => {
29
+ const { showToast } = useToast();
30
+ const t = useT();
31
+ // Cache the SAF directory the user granted so saving several documents
32
+ // in a row doesn't re-prompt for a folder every time (Android only).
33
+ const safDirUriRef = useRef<string | null>(null);
34
+
35
+ return useCallback(
36
+ async (activeFile: ModalFile) => {
37
+ // Bug #40: prefer the sender's original name over the server's stored
38
+ // hash name when saving/sharing the file. Sanitized so it's safe to use
39
+ // as an actual path segment (strips path separators/control chars).
40
+ const displayFileName = sanitizeFileNameForPath(
41
+ getDisplayFileName({
42
+ originalName: activeFile.originalName,
43
+ fileName: activeFile.fileName,
44
+ location: activeFile.fileURL,
45
+ mimetype: activeFile.mimetype,
46
+ })
47
+ );
48
+
49
+ const requestStoragePermission = async () => {
50
+ const MediaLibrary = getMediaLibrary();
51
+ if (!MediaLibrary?.requestPermissionsAsync) {
52
+ return false;
53
+ }
54
+ try {
55
+ const { status } = await MediaLibrary.requestPermissionsAsync();
56
+ return status === "granted";
57
+ } catch (error) {
58
+ console.error("Error requesting permission:", error);
59
+ return false;
60
+ }
61
+ };
62
+
63
+ const saveToGallery = async () => {
64
+ const hasPermission = await requestStoragePermission();
65
+ if (!hasPermission) {
66
+ Alert.alert(
67
+ "Permission Denied",
68
+ "Storage permission is required to save files to the gallery."
69
+ );
70
+ return;
71
+ }
72
+
73
+ try {
74
+ const filePath = FileSystem.cacheDirectory + displayFileName;
75
+ const download = await fetchToCache(activeFile.fileURL, filePath);
76
+
77
+ if (download.status === 200) {
78
+ await getMediaLibrary()?.saveToLibraryAsync(download.uri);
79
+ showToast({
80
+ id: Date.now().toString(),
81
+ title: t("toast.successTitle"),
82
+ message: t("toast.saveSuccessful"),
83
+ type: "success",
84
+ });
85
+ } else {
86
+ Alert.alert("Error", "Failed to save the file.");
87
+ }
88
+ } catch (err) {
89
+ Alert.alert("Error", `Failed to save the file: ${displayFileName}`);
90
+ }
91
+ };
92
+
93
+ // Best-effort existing-name lookup for the Android SAF branch below: SAF
94
+ // hands back content URIs, not plain names, so this decodes each one and
95
+ // takes its last path segment. If the provider's URI shape doesn't match
96
+ // what we expect, we fail open (empty set) rather than block the save -
97
+ // worst case we skip de-duplication, we never crash the save flow.
98
+ const listSafDirectoryNames = async (
99
+ saf: any,
100
+ dirUri: string
101
+ ): Promise<Set<string>> => {
102
+ try {
103
+ const uris: string[] = await saf.readDirectoryAsync(dirUri);
104
+ const names = uris.map((uri) => {
105
+ try {
106
+ const decoded = decodeURIComponent(uri);
107
+ const last = decoded.split("/").pop() || "";
108
+ return last.includes(":") ? last.split(":").pop() || "" : last;
109
+ } catch {
110
+ return "";
111
+ }
112
+ });
113
+ return new Set(names.filter(Boolean));
114
+ } catch {
115
+ return new Set<string>();
116
+ }
117
+ };
118
+
119
+ const saveFileToDownloads = async () => {
120
+ try {
121
+ const filePath = FileSystem.cacheDirectory + displayFileName;
122
+ const download = await fetchToCache(activeFile.fileURL, filePath);
123
+
124
+ if (download.status !== 200) {
125
+ Alert.alert("Error", "Failed to save the file.");
126
+ return;
127
+ }
128
+
129
+ if (Platform.OS === "android") {
130
+ const saf = (FileSystem as any).StorageAccessFramework;
131
+ let dirUri = safDirUriRef.current;
132
+ if (!dirUri) {
133
+ const perm = await saf.requestDirectoryPermissionsAsync();
134
+ if (!perm.granted) {
135
+ Alert.alert(
136
+ "Permission Denied",
137
+ "Storage permission is required to save the file."
138
+ );
139
+ return;
140
+ }
141
+ dirUri = perm.directoryUri;
142
+ safDirUriRef.current = dirUri;
143
+ }
144
+
145
+ // Avoid silently overwriting a different attachment that happens
146
+ // to share the same display name (common now that we prefer the
147
+ // human-picked name over the unique server hash - bug #40) by
148
+ // suffixing " (1)", " (2)", ... on a collision.
149
+ const existingNames = await listSafDirectoryNames(
150
+ saf,
151
+ dirUri as string
152
+ );
153
+ const uniqueName = await getUniqueFileName(
154
+ displayFileName,
155
+ async (candidate) => existingNames.has(candidate)
156
+ );
157
+ const baseName = uniqueName.replace(/\.[^/.]+$/, "");
158
+ const mime = activeFile.mimetype || "application/octet-stream";
159
+ const base64 = await FileSystem.readAsStringAsync(download.uri, {
160
+ encoding: FileSystem.EncodingType.Base64,
161
+ });
162
+ const destUri = await saf.createFileAsync(dirUri, baseName, mime);
163
+ await FileSystem.writeAsStringAsync(destUri, base64, {
164
+ encoding: FileSystem.EncodingType.Base64,
165
+ });
166
+
167
+ showToast({
168
+ id: Date.now().toString(),
169
+ title: t("toast.successTitle"),
170
+ message: t("toast.saveSuccessful"),
171
+ type: "success",
172
+ });
173
+ return;
174
+ }
175
+
176
+ // iOS (and any non-Android): there is no app-writable "Downloads"
177
+ // folder, and MediaLibrary only accepts images/videos — which is why
178
+ // documents previously hit the success toast without ever being
179
+ // saved. Present the system share sheet so the user can "Save to
180
+ // Files", AirDrop, etc. The sheet itself is the confirmation (the
181
+ // user may cancel), so we don't show a "Save successful" toast here.
182
+ await Share.share({
183
+ url: download.uri,
184
+ title: displayFileName,
185
+ });
186
+ } catch (err) {
187
+ console.error("Error saving file:", err);
188
+ Alert.alert("Error", "Failed to save the file.");
189
+ }
190
+ };
191
+
192
+ const saveClick = async () => {
193
+ if (
194
+ activeFile.mimetype.startsWith("image/") ||
195
+ activeFile.mimetype.startsWith("video/")
196
+ ) {
197
+ await saveToGallery();
198
+ } else {
199
+ await saveFileToDownloads();
200
+ }
201
+ };
202
+
203
+ await saveClick();
204
+ },
205
+ [showToast, t]
206
+ );
207
+ };
@@ -46,13 +46,18 @@ import {
46
46
  useMessageSearch,
47
47
  } from './useMessageSearch';
48
48
  import { openSearchHit } from './openHit';
49
+ import { setActiveModal } from '../../../roomStore/chatSettingsSlice';
50
+ import { MODAL_TYPES } from '../../../helpers/constants/MODAL_TYPES';
49
51
 
50
52
  interface MessageSearchModalProps {
51
53
  handleCloseModal: () => void;
54
+ /** Search the open chat only: no "All chats" scope to switch to. */
55
+ chatOnly?: boolean;
52
56
  }
53
57
 
54
58
  const MessageSearchModalContent: React.FC<MessageSearchModalProps> = ({
55
59
  handleCloseModal,
60
+ chatOnly = false,
56
61
  }) => {
57
62
  const t = useT();
58
63
  const dispatch = useDispatch();
@@ -71,7 +76,7 @@ const MessageSearchModalContent: React.FC<MessageSearchModalProps> = ({
71
76
  const [query, setQuery] = useState('');
72
77
  const roomName = activeRoomJID ? activeRoomJID.split('@')[0] : undefined;
73
78
  const [scope, setScope] = useState<SearchScope>(
74
- activeRoomJID ? 'chat' : 'all'
79
+ chatOnly || activeRoomJID ? 'chat' : 'all'
75
80
  );
76
81
 
77
82
  const [filtersOpen, setFiltersOpen] = useState(false);
@@ -182,34 +187,36 @@ const MessageSearchModalContent: React.FC<MessageSearchModalProps> = ({
182
187
  />
183
188
  </View>
184
189
 
185
- <View style={styles.scopeRow} accessibilityLabel={t('search.messages.scope')}>
186
- {(['chat', 'all'] as SearchScope[]).map((value) => {
187
- const active = scope === value;
188
- const disabled = value === 'chat' && !roomName;
189
- return (
190
- <TouchableOpacity
191
- key={value}
192
- testID={`message-search-scope-${value}`}
193
- disabled={disabled}
194
- onPress={() => setScope(value)}
195
- accessibilityState={{ selected: active, disabled }}
196
- style={[
197
- styles.scopeButton,
198
- active && { borderColor: primary, backgroundColor: primary + '1F' },
199
- disabled && styles.hitDisabled,
200
- ]}
201
- >
202
- <Text style={[styles.scopeLabel, active && { color: primary }]}>
203
- {t(
204
- value === 'chat'
205
- ? 'search.messages.scopeChat'
206
- : 'search.messages.scopeAll'
207
- )}
208
- </Text>
209
- </TouchableOpacity>
210
- );
211
- })}
212
- </View>
190
+ {!chatOnly && (
191
+ <View style={styles.scopeRow} accessibilityLabel={t('search.messages.scope')}>
192
+ {(['chat', 'all'] as SearchScope[]).map((value) => {
193
+ const active = scope === value;
194
+ const disabled = value === 'chat' && !roomName;
195
+ return (
196
+ <TouchableOpacity
197
+ key={value}
198
+ testID={`message-search-scope-${value}`}
199
+ disabled={disabled}
200
+ onPress={() => setScope(value)}
201
+ accessibilityState={{ selected: active, disabled }}
202
+ style={[
203
+ styles.scopeButton,
204
+ active && { borderColor: primary, backgroundColor: primary + '1F' },
205
+ disabled && styles.hitDisabled,
206
+ ]}
207
+ >
208
+ <Text style={[styles.scopeLabel, active && { color: primary }]}>
209
+ {t(
210
+ value === 'chat'
211
+ ? 'search.messages.scopeChat'
212
+ : 'search.messages.scopeAll'
213
+ )}
214
+ </Text>
215
+ </TouchableOpacity>
216
+ );
217
+ })}
218
+ </View>
219
+ )}
213
220
 
214
221
  <TouchableOpacity
215
222
  testID="message-search-filters-toggle"
@@ -368,7 +375,11 @@ const MessageSearchModalContent: React.FC<MessageSearchModalProps> = ({
368
375
  <ModalContainerFullScreen style={styles.screen}>
369
376
  <ModalHeaderComponent
370
377
  handleCloseModal={handleCloseModal}
371
- headerTitle={t('search.messages.title')}
378
+ headerTitle={
379
+ chatOnly && searchedRoom?.title
380
+ ? searchedRoom.title
381
+ : t('search.messages.title')
382
+ }
372
383
  />
373
384
  <FlatList
374
385
  style={styles.list}
@@ -484,4 +495,19 @@ const MessageSearchModal: React.FC<MessageSearchModalProps> = (props) => {
484
495
  return <MessageSearchModalContent {...props} />;
485
496
  };
486
497
 
498
+ /**
499
+ * The chat profile's Search: the same search as the room list's, inside this
500
+ * chat only. Back returns to the profile it was opened from; opening a hit
501
+ * closes both and lands on the message in the chat.
502
+ */
503
+ export const ChatMessageSearchModal: React.FC<MessageSearchModalProps> = () => {
504
+ const dispatch = useDispatch();
505
+ return (
506
+ <MessageSearchModal
507
+ chatOnly
508
+ handleCloseModal={() => dispatch(setActiveModal(MODAL_TYPES.CHAT_PROFILE))}
509
+ />
510
+ );
511
+ };
512
+
487
513
  export default MessageSearchModal;
@@ -2,8 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { Platform } from 'react-native';
3
3
  import { useDispatch, useSelector } from 'react-redux';
4
4
  import { RootState, store } from '../../roomStore';
5
- import { acceptIncomingCall, declineIncomingCall } from '../../roomStore/callSlice';
6
- import { sendCallStateSignal } from '../../networking/callTokenStanza';
5
+ import { acceptIncomingCall } from '../../roomStore/callSlice';
7
6
  import { declineActiveCallWhenOnline, terminateActiveCall } from './callActions';
8
7
  import { setCallMicEnabled, useCallSession } from './callSessionController';
9
8
  import { loadLiveKit } from './livekitRuntime';
@@ -162,8 +161,7 @@ export const useCallKeep = (): void => {
162
161
  snapshot.direction === 'incoming' &&
163
162
  snapshot.phase === 'ringing-incoming'
164
163
  ) {
165
- sendCallStateSignal('declined');
166
- dispatch(declineIncomingCall());
164
+ declineActiveCallWhenOnline();
167
165
  return;
168
166
  }
169
167
  // Ended from the Dynamic Island, the lock screen or a headset.
@@ -11,6 +11,7 @@ import {
11
11
  setActiveModal,
12
12
  } from '../../roomStore/chatSettingsSlice';
13
13
  import { MODAL_TYPES } from '../../helpers/constants/MODAL_TYPES';
14
+ import { useMediaSource } from '../../context/MediaSourceContext';
14
15
  import { ActivityIndicator, Image, TouchableOpacity, View } from 'react-native';
15
16
  import {
16
17
  defaultMediaDims,
@@ -72,6 +73,7 @@ const CustomMessageImage: React.FC<CustomMessageImageProps> = ({
72
73
  const dims: MediaDims = tile ?? measured;
73
74
 
74
75
  const dispatch = useDispatch();
76
+ const source = useMediaSource();
75
77
 
76
78
  useEffect(() => {
77
79
  if (!displayURL || tile) {
@@ -105,7 +107,7 @@ const CustomMessageImage: React.FC<CustomMessageImageProps> = ({
105
107
  onOpen();
106
108
  return;
107
109
  }
108
- dispatch(setActiveFile({ fileName, fileURL, mimetype, originalName }));
110
+ dispatch(setActiveFile({ fileName, fileURL, mimetype, originalName, source }));
109
111
  dispatch(setActiveModal(MODAL_TYPES.FILE_PREVIEW));
110
112
  };
111
113
 
@@ -10,6 +10,7 @@ import {
10
10
  setActiveModal,
11
11
  } from '../../roomStore/chatSettingsSlice';
12
12
  import { MODAL_TYPES } from '../../helpers/constants/MODAL_TYPES';
13
+ import { useMediaSource } from '../../context/MediaSourceContext';
13
14
  import { PlayIcon } from '../../assets/icons';
14
15
  import {
15
16
  defaultMediaDims,
@@ -155,6 +156,7 @@ const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
155
156
  onOpen,
156
157
  }) => {
157
158
  const dispatch = useDispatch();
159
+ const source = useMediaSource();
158
160
  const [posterFailed, setPosterFailed] = useState(false);
159
161
 
160
162
  const handleOpen = () => {
@@ -162,7 +164,9 @@ const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
162
164
  onOpen();
163
165
  return;
164
166
  }
165
- dispatch(setActiveFile({ fileName, fileURL, mimetype, originalName }));
167
+ dispatch(
168
+ setActiveFile({ fileName, fileURL, mimetype, originalName, previewURL, source })
169
+ );
166
170
  dispatch(setActiveModal(MODAL_TYPES.FILE_PREVIEW));
167
171
  };
168
172
 
@@ -0,0 +1,14 @@
1
+ import { createContext, useContext } from 'react';
2
+ import type { ModalFileSource } from '../types/types';
3
+
4
+ /**
5
+ * The message a bubble's pictures and videos belong to. MediaMessage provides
6
+ * it, and the image / video / album components that open the viewer read it,
7
+ * so the viewer knows what its Reply and Delete act on without every card in
8
+ * between passing the message along.
9
+ */
10
+ const MediaSourceContext = createContext<ModalFileSource | undefined>(undefined);
11
+
12
+ export const MediaSourceProvider = MediaSourceContext.Provider;
13
+
14
+ export const useMediaSource = () => useContext(MediaSourceContext);
@@ -7,7 +7,9 @@ import React from 'react';
7
7
  import { MODAL_TYPES } from './MODAL_TYPES';
8
8
  import ChatProfileModal from '../../components/Modals/ChatProfileModal/ChatProfileModal';
9
9
  import FilePreviewModal from '../../components/Modals/FilePreviewModal/FilePreviewModal';
10
- import MessageSearchModal from '../../components/Modals/MessageSearchModal/MessageSearchModal';
10
+ import MessageSearchModal, {
11
+ ChatMessageSearchModal,
12
+ } from '../../components/Modals/MessageSearchModal/MessageSearchModal';
11
13
  import PublicChatsModal from '../../components/Modals/PublicChatsModal/PublicChatsModal';
12
14
  import NewChatModal from '../../components/Modals/NewChatModal/NewChatModal';
13
15
  import BlockedUsersModal from '../../components/Modals/SettingsModals/BlockedUsers/BlockedUsersModal';
@@ -37,5 +39,6 @@ export const MODAL_COMPONENTS: Record<
37
39
  [MODAL_TYPES.BLOCKED_USERS]: BlockedUsersModal,
38
40
  [MODAL_TYPES.FILE_PREVIEW]: FilePreviewModal,
39
41
  [MODAL_TYPES.MESSAGE_SEARCH]: MessageSearchModal,
42
+ [MODAL_TYPES.CHAT_MESSAGE_SEARCH]: ChatMessageSearchModal,
40
43
  [MODAL_TYPES.PUBLIC_CHATS]: PublicChatsModal,
41
44
  };
@@ -28,5 +28,7 @@ export const MODAL_TYPES = {
28
28
  NEW_CHAT: 'new_chat',
29
29
 
30
30
  MESSAGE_SEARCH: 'message_search',
31
+ /** Message search locked to the open chat, reached from its profile. */
32
+ CHAT_MESSAGE_SEARCH: 'chat_message_search',
31
33
  PUBLIC_CHATS: 'public_chats',
32
34
  };
@@ -2,14 +2,15 @@ import type { IConfig } from '../types/types';
2
2
 
3
3
  /**
4
4
  * "Discover chats" lives in the room list menu sheet. A host that takes the
5
- * burger over (`headerMenu` as a function) or hides the menu
6
- * (`chatHeaderSettings.disableMenu`) would otherwise have no obvious way to
7
- * reach the directory, so it also gets a button of its own in the room list
8
- * header. `disableRoomMenu` removes the menu entry point, and with it the
9
- * button; `disablePublicChatsDirectory` removes the feature altogether.
5
+ * burger over (`headerMenu` as a function) would otherwise have no obvious
6
+ * way to reach the directory, so it also gets a button of its own in the
7
+ * room list header. `chatHeaderSettings.disableMenu` does not count: the
8
+ * header avatar still opens the sheet, Discover included, and a second
9
+ * compass next to it was a duplicate. `disableRoomMenu` removes the menu
10
+ * entry point, and with it the button; `disablePublicChatsDirectory`
11
+ * removes the feature altogether.
10
12
  */
11
13
  export const showsStandaloneDiscoverButton = (config?: IConfig): boolean =>
12
14
  !config?.disablePublicChatsDirectory &&
13
15
  !config?.disableRoomMenu &&
14
- (Boolean(config?.chatHeaderSettings?.disableMenu) ||
15
- typeof config?.headerMenu === 'function');
16
+ typeof config?.headerMenu === 'function';
@@ -0,0 +1,126 @@
1
+ import type { IMessage, ModalFile } from '../types/types';
2
+ import { getAttachmentName, getMessageAttachments } from './attachments';
3
+ import { getFileKind } from './fileKind';
4
+ import { appendFileToken } from './secureFileUrl';
5
+ import { parseMessageReference } from './parseMessageReference';
6
+ import { composeName } from './displayName';
7
+ import { isOwnMessage } from './isOwnMessage';
8
+
9
+ export interface MediaOptions {
10
+ /** The signed-in user, as isOwnMessage takes it. */
11
+ me: Parameters<typeof isOwnMessage>[1];
12
+ fileToken?: string;
13
+ interactive: boolean;
14
+ repliesOn: boolean;
15
+ }
16
+
17
+ /**
18
+ * Every picture and video of the room's loaded history, newest first, each
19
+ * carrying the message it came from so the viewer's Reply and Delete act on
20
+ * the right one (same rules as a bubble: Delete on one's own, Reply where
21
+ * replies are on).
22
+ */
23
+ export const collectRoomMedia = (
24
+ messages: IMessage[] | undefined,
25
+ roomJid: string,
26
+ { me, fileToken, interactive, repliesOn }: MediaOptions
27
+ ): ModalFile[] => {
28
+ const files: ModalFile[] = [];
29
+ for (let i = (messages?.length ?? 0) - 1; i >= 0; i--) {
30
+ const message = messages![i]!;
31
+ if (
32
+ message.isMediafile !== 'true' ||
33
+ message.isDeleted ||
34
+ message.pending ||
35
+ message.clientEncrypted === 'true' ||
36
+ !message.id
37
+ ) {
38
+ continue;
39
+ }
40
+ const parent = parseMessageReference(message);
41
+ const sentAt = new Date(message.date as any).getTime();
42
+ const source = {
43
+ messageId: message.id,
44
+ roomJid: message.roomJid || roomJid,
45
+ own: interactive && isOwnMessage(message, me),
46
+ replyTo:
47
+ interactive && repliesOn
48
+ ? parent
49
+ ? { id: parent.id, chatJID: parent.roomJid || message.roomJid || roomJid }
50
+ : { id: message.id, chatJID: message.roomJid || roomJid }
51
+ : undefined,
52
+ senderName:
53
+ composeName(message.user?.firstName, message.user?.lastName) ||
54
+ message.user?.name ||
55
+ undefined,
56
+ timestamp: Number.isFinite(sentAt) ? sentAt : undefined,
57
+ };
58
+ for (const attachment of getMessageAttachments(message)) {
59
+ if (!attachment.location) {continue;}
60
+ const kind = getFileKind({
61
+ mimetype: attachment.mimetype,
62
+ name: getAttachmentName(attachment),
63
+ url: attachment.location,
64
+ duration: attachment.duration,
65
+ });
66
+ if (kind !== 'image' && kind !== 'video') {continue;}
67
+ files.push({
68
+ fileName: getAttachmentName(attachment),
69
+ originalName: attachment.originalName,
70
+ fileURL: appendFileToken(attachment.location, fileToken) || attachment.location,
71
+ mimetype: attachment.mimetype || (kind === 'image' ? 'image/jpeg' : 'video/mp4'),
72
+ duration: attachment.duration,
73
+ previewURL: appendFileToken(attachment.locationPreview, fileToken),
74
+ source,
75
+ });
76
+ }
77
+ }
78
+ return files;
79
+ };
80
+
81
+ const sentAt = (message: IMessage) => new Date(message.date as any).getTime() || 0;
82
+
83
+ /** A thread-only reply: shown in its thread, not in the channel. */
84
+ const threadParentOf = (message: IMessage): string | null =>
85
+ message.mainMessage && message.showInChannel !== 'true'
86
+ ? parseMessageReference(message)?.id ?? null
87
+ : null;
88
+
89
+ /** The file's address without its access token, which may differ per copy. */
90
+ const bareUrl = (url: string | undefined) => (url || '').split('?')[0];
91
+
92
+ /**
93
+ * The media feed the viewer pages through when a picture or video is opened
94
+ * from a bubble: every picture and video of the list that bubble sits in (the
95
+ * channel, or the one thread for a thread-only reply), oldest first so a
96
+ * swipe to the left goes forward in time, the way the chat reads. The opened
97
+ * file comes back with that feed as its siblings; null when the file is not
98
+ * in the loaded history (the caller then keeps what it was opened with).
99
+ */
100
+ export const withChatSiblings = (
101
+ file: ModalFile,
102
+ messages: IMessage[] | undefined,
103
+ options: MediaOptions
104
+ ): ModalFile | null => {
105
+ const source = file.source;
106
+ if (!source || !messages?.length) {return null;}
107
+ const opened = messages.find((m) => String(m.id) === String(source.messageId));
108
+ if (!opened) {return null;}
109
+
110
+ const thread = threadParentOf(opened);
111
+ // collectRoomMedia walks from the end, so newest-first in gives oldest
112
+ // first out, with an album's files still in their own order.
113
+ const scoped = messages
114
+ .filter((m) => threadParentOf(m) === thread)
115
+ .sort((a, b) => sentAt(b) - sentAt(a));
116
+ const feed = collectRoomMedia(scoped, source.roomJid, options);
117
+
118
+ const url = bareUrl(file.fileURL);
119
+ const index = feed.findIndex(
120
+ (f) =>
121
+ f.source?.messageId === source.messageId && bareUrl(f.fileURL) === url
122
+ );
123
+ if (index < 0) {return null;}
124
+ // The bubble's own rules for Reply and Delete stay with the opened file.
125
+ return { ...feed[index]!, source, siblings: feed, index };
126
+ };