@amityco/react-native-social-uikit 4.1.1-fe2678f6.0 → 4.2.0

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 (205) hide show
  1. package/README.md +7 -0
  2. package/lib/commonjs/core/assets/icons/post.js +1 -1
  3. package/lib/commonjs/core/assets/icons/post.js.map +1 -1
  4. package/lib/commonjs/core/assets/icons/story.js +1 -1
  5. package/lib/commonjs/core/assets/icons/story.js.map +1 -1
  6. package/lib/commonjs/core/assets/icons/xml.js +16 -2
  7. package/lib/commonjs/core/assets/icons/xml.js.map +1 -1
  8. package/lib/commonjs/core/legacy/file.js +145 -62
  9. package/lib/commonjs/core/legacy/file.js.map +1 -1
  10. package/lib/commonjs/core/stores/slices/feedSlice.js +3 -0
  11. package/lib/commonjs/core/stores/slices/feedSlice.js.map +1 -1
  12. package/lib/commonjs/core/stores/slices/globalfeedSlice.js +5 -0
  13. package/lib/commonjs/core/stores/slices/globalfeedSlice.js.map +1 -1
  14. package/lib/commonjs/core/stores/slices/localVideoThumbnailSlice.js +46 -0
  15. package/lib/commonjs/core/stores/slices/localVideoThumbnailSlice.js.map +1 -0
  16. package/lib/commonjs/core/stores/slices/uiSlice.js +5 -1
  17. package/lib/commonjs/core/stores/slices/uiSlice.js.map +1 -1
  18. package/lib/commonjs/core/stores/store/index.js +3 -1
  19. package/lib/commonjs/core/stores/store/index.js.map +1 -1
  20. package/lib/commonjs/social/components/LoadingImage/index.js +73 -12
  21. package/lib/commonjs/social/components/LoadingImage/index.js.map +1 -1
  22. package/lib/commonjs/social/components/LoadingImage/styles.js +8 -3
  23. package/lib/commonjs/social/components/LoadingImage/styles.js.map +1 -1
  24. package/lib/commonjs/social/components/LoadingVideo/index.js +90 -16
  25. package/lib/commonjs/social/components/LoadingVideo/index.js.map +1 -1
  26. package/lib/commonjs/social/components/LoadingVideo/styles.js +14 -5
  27. package/lib/commonjs/social/components/LoadingVideo/styles.js.map +1 -1
  28. package/lib/commonjs/social/components/MediaViewer/MediaViewer.js +13 -13
  29. package/lib/commonjs/social/components/MediaViewer/MediaViewer.js.map +1 -1
  30. package/lib/commonjs/social/components/MediaViewer/styles.js +11 -1
  31. package/lib/commonjs/social/components/MediaViewer/styles.js.map +1 -1
  32. package/lib/commonjs/social/components/PostContent/index.js +8 -29
  33. package/lib/commonjs/social/components/PostContent/index.js.map +1 -1
  34. package/lib/commonjs/social/components/SelectedMediaComponent/SelectedMediaComponent.js +109 -17
  35. package/lib/commonjs/social/components/SelectedMediaComponent/SelectedMediaComponent.js.map +1 -1
  36. package/lib/commonjs/social/components/legacy/LoadingVideo/index.js +17 -6
  37. package/lib/commonjs/social/components/legacy/LoadingVideo/index.js.map +1 -1
  38. package/lib/commonjs/social/elements/PostMediaElement/PostMediaElement.js +121 -23
  39. package/lib/commonjs/social/elements/PostMediaElement/PostMediaElement.js.map +1 -1
  40. package/lib/commonjs/social/elements/PostMediaElement/styles.js +7 -0
  41. package/lib/commonjs/social/elements/PostMediaElement/styles.js.map +1 -1
  42. package/lib/commonjs/social/features/community/PendingRequest/components/PendingPostList/PendingPostList.js +11 -5
  43. package/lib/commonjs/social/features/community/PendingRequest/components/PendingPostList/PendingPostList.js.map +1 -1
  44. package/lib/commonjs/social/features/feed/components/NewsFeed/NewsFeed.js +14 -0
  45. package/lib/commonjs/social/features/feed/components/NewsFeed/NewsFeed.js.map +1 -1
  46. package/lib/commonjs/social/features/post/Composer/PostComposer.js +234 -86
  47. package/lib/commonjs/social/features/post/Composer/PostComposer.js.map +1 -1
  48. package/lib/commonjs/social/hooks/index.js +11 -0
  49. package/lib/commonjs/social/hooks/index.js.map +1 -1
  50. package/lib/commonjs/social/hooks/useCommunities.js +5 -3
  51. package/lib/commonjs/social/hooks/useCommunities.js.map +1 -1
  52. package/lib/commonjs/social/hooks/useCommunityPermission.js +43 -0
  53. package/lib/commonjs/social/hooks/useCommunityPermission.js.map +1 -0
  54. package/lib/commonjs/social/hooks/useCustomRankingGlobalFeed.js +10 -1
  55. package/lib/commonjs/social/hooks/useCustomRankingGlobalFeed.js.map +1 -1
  56. package/lib/commonjs/social/hooks/useFile.js +11 -0
  57. package/lib/commonjs/social/hooks/useFile.js.map +1 -1
  58. package/lib/commonjs/social/screens/CommunityProfile/components/Header/Header.js +18 -7
  59. package/lib/commonjs/social/screens/CommunityProfile/components/Header/Header.js.map +1 -1
  60. package/lib/commonjs/social/utils/mediaAttachments.js +150 -0
  61. package/lib/commonjs/social/utils/mediaAttachments.js.map +1 -0
  62. package/lib/commonjs/social/utils/postChildren.js +72 -0
  63. package/lib/commonjs/social/utils/postChildren.js.map +1 -0
  64. package/lib/module/core/assets/icons/post.js +1 -1
  65. package/lib/module/core/assets/icons/post.js.map +1 -1
  66. package/lib/module/core/assets/icons/story.js +1 -1
  67. package/lib/module/core/assets/icons/story.js.map +1 -1
  68. package/lib/module/core/assets/icons/xml.js +13 -0
  69. package/lib/module/core/assets/icons/xml.js.map +1 -1
  70. package/lib/module/core/legacy/file.js +145 -62
  71. package/lib/module/core/legacy/file.js.map +1 -1
  72. package/lib/module/core/stores/slices/feedSlice.js +3 -0
  73. package/lib/module/core/stores/slices/feedSlice.js.map +1 -1
  74. package/lib/module/core/stores/slices/globalfeedSlice.js +5 -0
  75. package/lib/module/core/stores/slices/globalfeedSlice.js.map +1 -1
  76. package/lib/module/core/stores/slices/localVideoThumbnailSlice.js +41 -0
  77. package/lib/module/core/stores/slices/localVideoThumbnailSlice.js.map +1 -0
  78. package/lib/module/core/stores/slices/uiSlice.js +5 -1
  79. package/lib/module/core/stores/slices/uiSlice.js.map +1 -1
  80. package/lib/module/core/stores/store/index.js +3 -1
  81. package/lib/module/core/stores/store/index.js.map +1 -1
  82. package/lib/module/social/components/LoadingImage/index.js +74 -14
  83. package/lib/module/social/components/LoadingImage/index.js.map +1 -1
  84. package/lib/module/social/components/LoadingImage/styles.js +8 -3
  85. package/lib/module/social/components/LoadingImage/styles.js.map +1 -1
  86. package/lib/module/social/components/LoadingVideo/index.js +91 -17
  87. package/lib/module/social/components/LoadingVideo/index.js.map +1 -1
  88. package/lib/module/social/components/LoadingVideo/styles.js +14 -5
  89. package/lib/module/social/components/LoadingVideo/styles.js.map +1 -1
  90. package/lib/module/social/components/MediaViewer/MediaViewer.js +13 -13
  91. package/lib/module/social/components/MediaViewer/MediaViewer.js.map +1 -1
  92. package/lib/module/social/components/MediaViewer/styles.js +11 -1
  93. package/lib/module/social/components/MediaViewer/styles.js.map +1 -1
  94. package/lib/module/social/components/PostContent/index.js +8 -29
  95. package/lib/module/social/components/PostContent/index.js.map +1 -1
  96. package/lib/module/social/components/SelectedMediaComponent/SelectedMediaComponent.js +110 -18
  97. package/lib/module/social/components/SelectedMediaComponent/SelectedMediaComponent.js.map +1 -1
  98. package/lib/module/social/components/legacy/LoadingVideo/index.js +17 -6
  99. package/lib/module/social/components/legacy/LoadingVideo/index.js.map +1 -1
  100. package/lib/module/social/elements/PostMediaElement/PostMediaElement.js +123 -25
  101. package/lib/module/social/elements/PostMediaElement/PostMediaElement.js.map +1 -1
  102. package/lib/module/social/elements/PostMediaElement/styles.js +7 -0
  103. package/lib/module/social/elements/PostMediaElement/styles.js.map +1 -1
  104. package/lib/module/social/features/community/PendingRequest/components/PendingPostList/PendingPostList.js +12 -6
  105. package/lib/module/social/features/community/PendingRequest/components/PendingPostList/PendingPostList.js.map +1 -1
  106. package/lib/module/social/features/feed/components/NewsFeed/NewsFeed.js +15 -1
  107. package/lib/module/social/features/feed/components/NewsFeed/NewsFeed.js.map +1 -1
  108. package/lib/module/social/features/post/Composer/PostComposer.js +236 -89
  109. package/lib/module/social/features/post/Composer/PostComposer.js.map +1 -1
  110. package/lib/module/social/hooks/index.js +1 -0
  111. package/lib/module/social/hooks/index.js.map +1 -1
  112. package/lib/module/social/hooks/useCommunities.js +5 -3
  113. package/lib/module/social/hooks/useCommunities.js.map +1 -1
  114. package/lib/module/social/hooks/useCommunityPermission.js +37 -0
  115. package/lib/module/social/hooks/useCommunityPermission.js.map +1 -0
  116. package/lib/module/social/hooks/useCustomRankingGlobalFeed.js +10 -1
  117. package/lib/module/social/hooks/useCustomRankingGlobalFeed.js.map +1 -1
  118. package/lib/module/social/hooks/useFile.js +11 -0
  119. package/lib/module/social/hooks/useFile.js.map +1 -1
  120. package/lib/module/social/screens/CommunityProfile/components/Header/Header.js +19 -8
  121. package/lib/module/social/screens/CommunityProfile/components/Header/Header.js.map +1 -1
  122. package/lib/module/social/utils/mediaAttachments.js +139 -0
  123. package/lib/module/social/utils/mediaAttachments.js.map +1 -0
  124. package/lib/module/social/utils/postChildren.js +66 -0
  125. package/lib/module/social/utils/postChildren.js.map +1 -0
  126. package/lib/typescript/core/assets/icons/xml.d.ts +1 -0
  127. package/lib/typescript/core/assets/icons/xml.d.ts.map +1 -1
  128. package/lib/typescript/core/legacy/file.d.ts +32 -0
  129. package/lib/typescript/core/legacy/file.d.ts.map +1 -1
  130. package/lib/typescript/core/stores/slices/feedSlice.d.ts.map +1 -1
  131. package/lib/typescript/core/stores/slices/globalfeedSlice.d.ts.map +1 -1
  132. package/lib/typescript/core/stores/slices/localVideoThumbnailSlice.d.ts +35 -0
  133. package/lib/typescript/core/stores/slices/localVideoThumbnailSlice.d.ts.map +1 -0
  134. package/lib/typescript/core/stores/slices/uiSlice.d.ts +5 -0
  135. package/lib/typescript/core/stores/slices/uiSlice.d.ts.map +1 -1
  136. package/lib/typescript/core/stores/store/index.d.ts.map +1 -1
  137. package/lib/typescript/core/types/mention.d.ts +34 -0
  138. package/lib/typescript/core/types/mention.d.ts.map +1 -1
  139. package/lib/typescript/social/components/LoadingImage/index.d.ts +8 -2
  140. package/lib/typescript/social/components/LoadingImage/index.d.ts.map +1 -1
  141. package/lib/typescript/social/components/LoadingImage/styles.d.ts.map +1 -1
  142. package/lib/typescript/social/components/LoadingVideo/index.d.ts +9 -2
  143. package/lib/typescript/social/components/LoadingVideo/index.d.ts.map +1 -1
  144. package/lib/typescript/social/components/LoadingVideo/styles.d.ts +1 -0
  145. package/lib/typescript/social/components/LoadingVideo/styles.d.ts.map +1 -1
  146. package/lib/typescript/social/components/MediaViewer/MediaViewer.d.ts.map +1 -1
  147. package/lib/typescript/social/components/MediaViewer/styles.d.ts +1 -0
  148. package/lib/typescript/social/components/MediaViewer/styles.d.ts.map +1 -1
  149. package/lib/typescript/social/components/PostContent/index.d.ts.map +1 -1
  150. package/lib/typescript/social/components/SelectedMediaComponent/SelectedMediaComponent.d.ts +2 -2
  151. package/lib/typescript/social/components/SelectedMediaComponent/SelectedMediaComponent.d.ts.map +1 -1
  152. package/lib/typescript/social/components/legacy/LoadingVideo/index.d.ts.map +1 -1
  153. package/lib/typescript/social/elements/PostMediaElement/PostMediaElement.d.ts.map +1 -1
  154. package/lib/typescript/social/elements/PostMediaElement/styles.d.ts +4 -0
  155. package/lib/typescript/social/elements/PostMediaElement/styles.d.ts.map +1 -1
  156. package/lib/typescript/social/features/community/PendingRequest/components/PendingPostList/PendingPostList.d.ts.map +1 -1
  157. package/lib/typescript/social/features/feed/components/NewsFeed/NewsFeed.d.ts.map +1 -1
  158. package/lib/typescript/social/features/post/Composer/PostComposer.d.ts.map +1 -1
  159. package/lib/typescript/social/hooks/index.d.ts +1 -0
  160. package/lib/typescript/social/hooks/index.d.ts.map +1 -1
  161. package/lib/typescript/social/hooks/useCommunities.d.ts +2 -1
  162. package/lib/typescript/social/hooks/useCommunities.d.ts.map +1 -1
  163. package/lib/typescript/social/hooks/useCommunityPermission.d.ts +14 -0
  164. package/lib/typescript/social/hooks/useCommunityPermission.d.ts.map +1 -0
  165. package/lib/typescript/social/hooks/useCustomRankingGlobalFeed.d.ts.map +1 -1
  166. package/lib/typescript/social/hooks/useFile.d.ts +11 -0
  167. package/lib/typescript/social/hooks/useFile.d.ts.map +1 -1
  168. package/lib/typescript/social/screens/CommunityProfile/components/Header/Header.d.ts.map +1 -1
  169. package/lib/typescript/social/utils/mediaAttachments.d.ts +91 -0
  170. package/lib/typescript/social/utils/mediaAttachments.d.ts.map +1 -0
  171. package/lib/typescript/social/utils/postChildren.d.ts +38 -0
  172. package/lib/typescript/social/utils/postChildren.d.ts.map +1 -0
  173. package/package.json +3 -3
  174. package/src/core/assets/icons/post.tsx +1 -1
  175. package/src/core/assets/icons/story.tsx +1 -1
  176. package/src/core/assets/icons/xml.ts +15 -0
  177. package/src/core/legacy/file.tsx +169 -84
  178. package/src/core/stores/slices/feedSlice.ts +3 -0
  179. package/src/core/stores/slices/globalfeedSlice.ts +5 -0
  180. package/src/core/stores/slices/localVideoThumbnailSlice.ts +58 -0
  181. package/src/core/stores/slices/uiSlice.ts +8 -0
  182. package/src/core/stores/store/index.ts +2 -0
  183. package/src/core/types/mention.ts +34 -0
  184. package/src/social/components/LoadingImage/index.tsx +81 -12
  185. package/src/social/components/LoadingImage/styles.ts +8 -3
  186. package/src/social/components/LoadingVideo/index.tsx +111 -21
  187. package/src/social/components/LoadingVideo/styles.ts +13 -4
  188. package/src/social/components/MediaViewer/MediaViewer.tsx +11 -12
  189. package/src/social/components/MediaViewer/styles.ts +9 -1
  190. package/src/social/components/PostContent/index.tsx +3 -35
  191. package/src/social/components/SelectedMediaComponent/SelectedMediaComponent.tsx +103 -13
  192. package/src/social/components/legacy/LoadingVideo/index.tsx +26 -15
  193. package/src/social/elements/PostMediaElement/PostMediaElement.tsx +139 -29
  194. package/src/social/elements/PostMediaElement/styles.ts +7 -0
  195. package/src/social/features/community/PendingRequest/components/PendingPostList/PendingPostList.tsx +11 -6
  196. package/src/social/features/feed/components/NewsFeed/NewsFeed.tsx +16 -1
  197. package/src/social/features/post/Composer/PostComposer.tsx +304 -91
  198. package/src/social/hooks/index.ts +1 -0
  199. package/src/social/hooks/useCommunities.ts +4 -2
  200. package/src/social/hooks/useCommunityPermission.ts +40 -0
  201. package/src/social/hooks/useCustomRankingGlobalFeed.ts +8 -1
  202. package/src/social/hooks/useFile.ts +11 -0
  203. package/src/social/screens/CommunityProfile/components/Header/Header.tsx +19 -10
  204. package/src/social/utils/mediaAttachments.ts +158 -0
  205. package/src/social/utils/postChildren.ts +85 -0
@@ -11,6 +11,40 @@ export interface IDisplayImage {
11
11
  fileId: string | undefined;
12
12
  fileName: string;
13
13
  isUploaded: boolean;
14
+ /** Stable local identity of a staged attachment, assigned once when the item
15
+ * is created (picker asset id / original file name / capture path) and
16
+ * carried unchanged through the upload. `url` and `fileName` are both
17
+ * rewritten the moment the upload finishes — the url becomes the remote
18
+ * `?size=medium` one and fileName becomes the SERVER's name — so neither can
19
+ * identify an item across its own upload. Two defects come out of that:
20
+ * de-duplicating a second pick against already-uploaded items compared local
21
+ * names to server names and never matched, and the completion
22
+ * handler had to fall back to the positional `index` it was rendered with,
23
+ * which lands on the wrong entry once the array's composition has moved
24
+ * underneath an in-flight upload. Optional because the legacy
25
+ * EditPostModal builds these objects too. */
26
+ localId?: string;
27
+ /** The local file path the item was picked or captured from, kept for
28
+ * DISPLAY only. `url` is replaced by the remote `?size=medium` url the
29
+ * instant the upload finishes, so the composer frame stops rendering the
30
+ * file on disk and starts fetching over the network — go offline and the
31
+ * already-uploaded frame turns grey, even though the local file
32
+ * is still sitting there. Rendering from this instead keeps the frame
33
+ * painted whatever the network is doing. Absent for edit-mode children,
34
+ * which have no local file, so callers fall back to `url`. */
35
+ localUrl?: string;
36
+ /** The picker's original `asset.fileName` for a library pick, kept as the
37
+ * secondary de-duplication key next to `localId`: `asset.id` is
38
+ * only populated on some picker configurations, so the name is what catches
39
+ * a re-pick when it is absent. Left unset for camera captures, whose temp
40
+ * path is unique per shot and must never de-duplicate against anything. */
41
+ localFileName?: string;
14
42
  thumbNail?: string;
15
43
  postId?: string;
44
+ /** Source dimensions used to classify the composer frame ratio. For video
45
+ * these are the STORED frame; `rotation` must be applied before classifying
46
+ * (SelectedMediaComponent REQ-003d1, AmityVideo REQ-SDK-002). */
47
+ width?: number;
48
+ height?: number;
49
+ rotation?: number;
16
50
  }
@@ -1,9 +1,10 @@
1
- import { useCallback, useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import NetInfo from '@react-native-community/netinfo';
2
3
  import { View, Image, TouchableOpacity } from 'react-native';
3
4
  import * as Progress from 'react-native-progress';
4
5
  import { SvgXml } from 'react-native-svg';
5
6
  import { deleteAmityFile, uploadImageFile } from '../../../core/legacy/file';
6
- import { closeIcon, toastIcon } from '../../../core/assets/icons/xml';
7
+ import { mediaRemoveIcon, toastIcon } from '../../../core/assets/icons/xml';
7
8
  import { useStyles } from './styles';
8
9
 
9
10
  interface OverlayImageProps {
@@ -19,11 +20,21 @@ interface OverlayImageProps {
19
20
  onUploadError?: (hasError: boolean, source: string) => void;
20
21
  index?: number;
21
22
  isUploaded: boolean;
23
+ /** Local path to render from, when the item still has one. `source` stays
24
+ * the identity/bookkeeping key and becomes the remote url once the upload
25
+ * finishes; painting from that url means an already-uploaded frame goes
26
+ * grey the moment the device is offline. Falls back to `source`
27
+ * for edit-mode children, which have no local file. */
28
+ displayUri?: string;
22
29
  fileId?: string;
23
30
  isEditMode?: boolean;
24
31
  fileCount?: number;
25
32
  postId?: string;
26
- setIsUploading?: (arg: boolean) => void;
33
+ // Reports this frame's upload state keyed by its own `source`, so the
34
+ // composer can gate Post on every frame at once. The old shared
35
+ // `setIsUploading` boolean was flipped back to false by whichever upload
36
+ // finished first, unlocking Post while the other frames were still in flight.
37
+ onUploadingChange?: (isUploading: boolean, source: string) => void;
27
38
  carousel?: boolean;
28
39
  }
29
40
  const LoadingImage = ({
@@ -33,11 +44,12 @@ const LoadingImage = ({
33
44
  onLoadFinish,
34
45
  onUploadError,
35
46
  isUploaded = false,
47
+ displayUri,
36
48
  fileId = '',
37
49
  isEditMode = false,
38
50
  fileCount,
39
51
  postId,
40
- setIsUploading,
52
+ onUploadingChange,
41
53
  carousel = false,
42
54
  }: OverlayImageProps) => {
43
55
  const [loading, setLoading] = useState(true);
@@ -47,8 +59,8 @@ const LoadingImage = ({
47
59
  const styles = useStyles();
48
60
  const handleLoadEnd = useCallback(() => {
49
61
  setLoading(false);
50
- setIsUploading(false);
51
- }, [setIsUploading]);
62
+ onUploadingChange?.(false, source);
63
+ }, [onUploadingChange, source]);
52
64
 
53
65
  useEffect(() => {
54
66
  if (progress === 100) {
@@ -57,8 +69,25 @@ const LoadingImage = ({
57
69
  }, [progress]);
58
70
 
59
71
  const uploadFileToAmity = useCallback(async () => {
60
- setIsUploading(true);
72
+ onUploadingChange?.(true, source);
61
73
  setIsUploadError(false);
74
+ // A retry re-enters this function with `loading` already false — the failed
75
+ // attempt's `handleLoadEnd` cleared it and nothing ever set it back, so
76
+ // `setLoading` only ever ran downwards in this component. The frame then
77
+ // re-uploaded with no spinner at all, which reads as "finished instantly"
78
+ // while Post stays disabled for the length of the upload.
79
+ // Progress and the processing flag are stale from the failed attempt too,
80
+ // so reset the whole visual upload state here rather than only in the
81
+ // mount effect, which does not re-run on a retry.
82
+ setLoading(true);
83
+ setProgress(0);
84
+ setIsProcess(false);
85
+ // Clearing the local flag alone left this source inside the parent's
86
+ // `imageErrors` set, and that set is otherwise only cleared by the
87
+ // mount effect below — which does not re-run on a retry, since neither
88
+ // `isUploaded` nor `source` changed. Post stayed disabled even after the
89
+ // retry uploaded fine, so tell the parent the error is gone up front.
90
+ onUploadError?.(false, source);
62
91
  try {
63
92
  const file: Amity.File<any>[] = await uploadImageFile(
64
93
  source,
@@ -78,7 +107,8 @@ const LoadingImage = ({
78
107
  source
79
108
  );
80
109
  } else {
81
- setIsUploading(false);
110
+ // `handleLoadEnd` already reports the upload as finished — the extra
111
+ // setter call it used to be paired with was redundant.
82
112
  handleLoadEnd();
83
113
  setIsProcess(false);
84
114
  setIsUploadError(true);
@@ -87,7 +117,6 @@ const LoadingImage = ({
87
117
  } catch (error) {
88
118
  handleLoadEnd();
89
119
  setIsProcess(false);
90
- setIsUploading(false);
91
120
  setIsUploadError(true);
92
121
  onUploadError?.(true, source);
93
122
  }
@@ -96,10 +125,34 @@ const LoadingImage = ({
96
125
  index,
97
126
  onLoadFinish,
98
127
  onUploadError,
99
- setIsUploading,
128
+ onUploadingChange,
100
129
  source,
101
130
  ]);
102
131
 
132
+ // The frame is expected to end up uploaded, and Post enabled, once
133
+ // connectivity returns. Nothing retried on its own —
134
+ // the error key only cleared on a manual tap on that specific frame, and the
135
+ // peek carousel can leave a failed frame off-screen entirely, so Post stayed
136
+ // disabled with no visible cause. Retry when the device regains a
137
+ // connection, using the same NetInfo listener idiom as post Detail and the
138
+ // livestream screens.
139
+ //
140
+ // Gated on a disconnected -> connected transition, not merely on being
141
+ // connected: addEventListener fires immediately with the current state, so a
142
+ // failure that happened while online (a server error, say) would otherwise
143
+ // re-fire on every resubscribe and spin.
144
+ const wasDisconnectedRef = useRef(false);
145
+ useEffect(() => {
146
+ const unsubscribe = NetInfo.addEventListener((state) => {
147
+ const isConnected = !!state.isConnected;
148
+ if (isConnected && wasDisconnectedRef.current && isUploadError) {
149
+ uploadFileToAmity();
150
+ }
151
+ wasDisconnectedRef.current = !isConnected;
152
+ });
153
+ return () => unsubscribe();
154
+ }, [isUploadError, uploadFileToAmity]);
155
+
103
156
  const handleDelete = async () => {
104
157
  if (fileId && !isEditMode) {
105
158
  await deleteAmityFile(fileId);
@@ -118,6 +171,17 @@ const LoadingImage = ({
118
171
  }
119
172
  }, [isUploaded, source]);
120
173
 
174
+ // A frame can be removed (or its source swapped) while its upload is still
175
+ // in flight, in which case `handleLoadEnd` never runs and the composer would
176
+ // keep waiting on an entry no mounted child owns any more — leaving Post
177
+ // disabled forever. Clearing it from this cleanup keeps the
178
+ // bookkeeping in the same component that added it.
179
+ useEffect(() => {
180
+ return () => {
181
+ onUploadingChange?.(false, source);
182
+ };
183
+ }, [onUploadingChange, source]);
184
+
121
185
  const onRetryUpload = () => {
122
186
  uploadFileToAmity();
123
187
  };
@@ -132,7 +196,7 @@ const LoadingImage = ({
132
196
  }
133
197
  >
134
198
  <Image
135
- source={{ uri: source }}
199
+ source={{ uri: displayUri ?? source }}
136
200
  resizeMode={carousel ? 'cover' : 'contain'}
137
201
  style={[
138
202
  styles.image,
@@ -168,10 +232,15 @@ const LoadingImage = ({
168
232
 
169
233
  <TouchableOpacity
170
234
  style={styles.closeButton}
235
+ // The button matches web at 28dp, which is still under Apple's 44pt
236
+ // minimum touch target. Grow the touch area rather than the black
237
+ // disc, so the visual stays in parity while the frame stays tappable:
238
+ // 28 + 8 on each side lands exactly on 44.
239
+ hitSlop={8}
171
240
  disabled={(loading || isProcess) && !isUploadError}
172
241
  onPress={handleDelete}
173
242
  >
174
- <SvgXml xml={closeIcon('white')} width="12" height="12" />
243
+ <SvgXml xml={mediaRemoveIcon()} width="20" height="20" />
175
244
  </TouchableOpacity>
176
245
  </View>
177
246
  );
@@ -55,9 +55,14 @@ export const useStyles = () => {
55
55
  },
56
56
  closeButton: {
57
57
  position: 'absolute',
58
- top: 7,
59
- right: 7,
60
- padding: 7,
58
+ // Matched to the web UIKit's remove button, which is 20px of icon inside
59
+ // 4px of padding at an 8px offset. The old 12/7 pair came to nearly the
60
+ // same 26dp outer size but filled only 46% of the circle with the glyph
61
+ // against web's 71%, so it read as a heavy black disc with a tiny cross
62
+ // in it.
63
+ top: 8,
64
+ right: 8,
65
+ padding: 4,
61
66
  backgroundColor: 'rgba(0, 0, 0, 0.4)',
62
67
  borderRadius: 72,
63
68
  zIndex: 10,
@@ -1,4 +1,5 @@
1
- import { useCallback, useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import NetInfo from '@react-native-community/netinfo';
2
3
  import {
3
4
  View,
4
5
  TouchableOpacity,
@@ -10,7 +11,7 @@ import * as Progress from 'react-native-progress';
10
11
  import { SvgXml } from 'react-native-svg';
11
12
  import { deleteAmityFile, uploadVideoFile } from '../../../core/legacy/file';
12
13
  import {
13
- closeIcon,
14
+ mediaRemoveIcon,
14
15
  playBtn,
15
16
  toastIcon,
16
17
  videoControlIcon,
@@ -25,6 +26,13 @@ import { useUIKitDispatch } from '../../../core/stores/store';
25
26
 
26
27
  interface OverlayImageProps {
27
28
  source: string;
29
+ /** Local path to render/decode from, when the item still has one. `source`
30
+ * stays the identity/bookkeeping key and becomes the remote url once the
31
+ * upload finishes; decoding the thumbnail from that url means an
32
+ * already-uploaded frame goes grey the moment the device is offline
33
+ * Falls back to `source` for edit-mode children, which have no
34
+ * local file. */
35
+ displayUri?: string;
28
36
  onClose?: (originalPath: string, fileId?: string, postId?: string) => void;
29
37
  onLoadFinish?: (
30
38
  fileId: string,
@@ -43,7 +51,11 @@ interface OverlayImageProps {
43
51
  isEditMode?: boolean;
44
52
  fileCount?: number;
45
53
  postId?: string;
46
- setIsUploading?: (arg: boolean) => void;
54
+ // Reports this frame's upload state keyed by its own `source`, so the
55
+ // composer can gate Post on every frame at once. The old shared
56
+ // `setIsUploading` boolean was flipped back to false by whichever upload
57
+ // finished first, unlocking Post while the other frames were still in flight.
58
+ onUploadingChange?: (isUploading: boolean, source: string) => void;
47
59
  carousel?: boolean;
48
60
  }
49
61
  const LoadingVideo = ({
@@ -53,13 +65,14 @@ const LoadingVideo = ({
53
65
  onLoadFinish,
54
66
  onUploadError,
55
67
  isUploaded = false,
68
+ displayUri,
56
69
  thumbNail,
57
70
  onPlay,
58
71
  fileId,
59
72
  isEditMode = false,
60
73
  fileCount,
61
74
  postId,
62
- setIsUploading,
75
+ onUploadingChange,
63
76
  carousel = false,
64
77
  }: OverlayImageProps) => {
65
78
  const dispatch = useUIKitDispatch();
@@ -69,6 +82,10 @@ const LoadingVideo = ({
69
82
  const [isProcess, setIsProcess] = useState<boolean>(false);
70
83
  const [isUploadError, setIsUploadError] = useState(false);
71
84
  const [thumbNailImage, setThumbNailImage] = useState(thumbNail ?? '');
85
+ // `uploadFileToAmity` is memoised on [source] only, so reading the state
86
+ // there would capture the empty first-render value. Mirror it into a ref so
87
+ // the upload callback always sees the frame that was actually decoded.
88
+ const thumbNailImageRef = useRef(thumbNail ?? '');
72
89
  const styles = useStyles();
73
90
  const [playingUri, setPlayingUri] = useState<string>('');
74
91
  const [isPause, setIsPause] = useState<boolean>(true);
@@ -89,11 +106,12 @@ const LoadingVideo = ({
89
106
 
90
107
  const handleLoadEnd = () => {
91
108
  setLoading(false);
92
- setIsUploading(false);
109
+ onUploadingChange?.(false, source);
93
110
  };
94
111
 
95
112
  const processThumbNail = async () => {
96
- const generatedThumbNail = await createVideoThumbnail(source);
113
+ const generatedThumbNail = await createVideoThumbnail(displayUri ?? source);
114
+ thumbNailImageRef.current = generatedThumbNail.path;
97
115
  setThumbNailImage(generatedThumbNail.path);
98
116
  };
99
117
  useEffect(() => {
@@ -107,8 +125,25 @@ const LoadingVideo = ({
107
125
  }, [progress]);
108
126
 
109
127
  const uploadFileToAmity = useCallback(async () => {
110
- setIsUploading(true);
128
+ onUploadingChange?.(true, source);
111
129
  setIsUploadError(false);
130
+ // A retry re-enters this function with `loading` already false — the failed
131
+ // attempt's `handleLoadEnd` cleared it and nothing ever set it back, so
132
+ // `setLoading` only ever ran downwards in this component. The frame then
133
+ // re-uploaded with no spinner at all, which reads as "finished instantly"
134
+ // while Post stays disabled for the length of the upload.
135
+ // Progress and the processing flag are stale from the failed attempt too,
136
+ // so reset the whole visual upload state here rather than only in the
137
+ // mount effect, which does not re-run on a retry.
138
+ setLoading(true);
139
+ setProgress(0);
140
+ setIsProcess(false);
141
+ // Clearing the local flag alone left this source inside the parent's
142
+ // `videoErrors` set, and that set is otherwise only cleared by the
143
+ // mount effect below — which does not re-run on a retry, since none of
144
+ // `fileId`/`isUploaded`/`source` changed. Post stayed disabled even after
145
+ // the retry uploaded fine, so tell the parent the error is gone up front.
146
+ onUploadError?.(false, source);
112
147
  try {
113
148
  const file: Amity.File<any>[] = await uploadVideoFile(
114
149
  source,
@@ -126,7 +161,12 @@ const LoadingVideo = ({
126
161
  file[0]?.attributes.name as string,
127
162
  index as number,
128
163
  source,
129
- thumbNail
164
+ // Hand back the frame this component decoded, not the incoming
165
+ // prop — for a newly picked video that prop is empty, so the
166
+ // generated thumbnail used to be dropped on the floor. The
167
+ // composer stores it so the feed can show it while the server is
168
+ // still transcoding (PDT-4904).
169
+ thumbNailImageRef.current || thumbNail
130
170
  );
131
171
  } else {
132
172
  handleLoadEnd();
@@ -144,6 +184,30 @@ const LoadingVideo = ({
144
184
  }
145
185
  }, [source]);
146
186
 
187
+ // The frame is expected to end up uploaded, and Post enabled, once
188
+ // connectivity returns. Nothing retried on its own —
189
+ // the error key only cleared on a manual tap on that specific frame, and the
190
+ // peek carousel can leave a failed frame off-screen entirely, so Post stayed
191
+ // disabled with no visible cause. Retry when the device regains a
192
+ // connection, using the same NetInfo listener idiom as post Detail and the
193
+ // livestream screens.
194
+ //
195
+ // Gated on a disconnected -> connected transition, not merely on being
196
+ // connected: addEventListener fires immediately with the current state, so a
197
+ // failure that happened while online (a server error, say) would otherwise
198
+ // re-fire on every resubscribe and spin.
199
+ const wasDisconnectedRef = useRef(false);
200
+ useEffect(() => {
201
+ const unsubscribe = NetInfo.addEventListener((state) => {
202
+ const isConnected = !!state.isConnected;
203
+ if (isConnected && wasDisconnectedRef.current && isUploadError) {
204
+ uploadFileToAmity();
205
+ }
206
+ wasDisconnectedRef.current = !isConnected;
207
+ });
208
+ return () => unsubscribe();
209
+ }, [isUploadError, uploadFileToAmity]);
210
+
147
211
  const handleDelete = async () => {
148
212
  if (fileId && !isEditMode) {
149
213
  await deleteAmityFile(fileId);
@@ -163,16 +227,30 @@ const LoadingVideo = ({
163
227
  }, [fileId, isUploaded, source]);
164
228
 
165
229
  const handleOnPlay = () => {
166
- setIsPause(!isPause);
167
- // In carousel mode the parent (onPlay) opens the full-screen media viewer,
168
- // so skip the default single-video route navigation.
230
+ // In carousel mode the parent (onPlay) owns the full-screen media viewer,
231
+ // so this component never mounts the inline <Video> and never receives a
232
+ // dismiss callback to unpause with. Leaving `isPause` alone keeps the play
233
+ // icon rendered, which is what the frame shows once the viewer closes
234
+ // (PDT-4904); toggling it here hid the icon for the rest of the session.
169
235
  if (onPlay) {
170
236
  onPlay(source);
171
237
  return;
172
238
  }
239
+ setIsPause(!isPause);
173
240
  playVideoFullScreen(source);
174
241
  };
175
242
 
243
+ // A frame can be removed (or its source swapped) while its upload is still
244
+ // in flight, in which case `handleLoadEnd` never runs and the composer would
245
+ // keep waiting on an entry no mounted child owns any more — leaving Post
246
+ // disabled forever. Clearing it from this cleanup keeps the
247
+ // bookkeeping in the same component that added it.
248
+ useEffect(() => {
249
+ return () => {
250
+ onUploadingChange?.(false, source);
251
+ };
252
+ }, [onUploadingChange, source]);
253
+
176
254
  const onRetryUpload = () => {
177
255
  uploadFileToAmity();
178
256
  };
@@ -217,7 +295,7 @@ const LoadingVideo = ({
217
295
  <View style={styles.image} />
218
296
  )}
219
297
 
220
- {loading ? (
298
+ {loading && (
221
299
  <View style={styles.overlay}>
222
300
  {isProcess ? (
223
301
  <Progress.CircleSnail
@@ -235,19 +313,31 @@ const LoadingVideo = ({
235
313
  />
236
314
  )}
237
315
  </View>
238
- ) : isUploadError ? (
316
+ )}
317
+ {!loading && isUploadError && (
239
318
  <TouchableOpacity style={styles.overlay} onPress={onRetryUpload}>
240
319
  <SvgXml xml={toastIcon()} width="24" height="24" />
241
320
  </TouchableOpacity>
242
- ) : (
243
- <TouchableOpacity
244
- style={styles.closeButton}
245
- disabled={(loading || isProcess) && !isUploadError}
246
- onPress={handleDelete}
247
- >
248
- <SvgXml xml={closeIcon('white')} width="12" height="12" />
249
- </TouchableOpacity>
250
321
  )}
322
+
323
+ {/* Sibling of the overlays, never an `else` branch of them: a failed
324
+ frame must keep its remove button, otherwise a video that cannot
325
+ upload can never be taken out of the composer. This is
326
+ the shape LoadingImage already uses, and it is what the `disabled`
327
+ guard below was written for — inside the old if/else chain that
328
+ `!isUploadError` term was unreachable. */}
329
+ <TouchableOpacity
330
+ style={styles.closeButton}
331
+ // The button matches web at 28dp, which is still under Apple's 44pt
332
+ // minimum touch target. Grow the touch area rather than the black
333
+ // disc, so the visual stays in parity while the frame stays tappable:
334
+ // 28 + 8 on each side lands exactly on 44.
335
+ hitSlop={8}
336
+ disabled={(loading || isProcess) && !isUploadError}
337
+ onPress={handleDelete}
338
+ >
339
+ <SvgXml xml={mediaRemoveIcon()} width="20" height="20" />
340
+ </TouchableOpacity>
251
341
  </View>
252
342
  );
253
343
  };
@@ -41,11 +41,20 @@ export const useStyles = () => {
41
41
  },
42
42
  closeButton: {
43
43
  position: 'absolute',
44
- top: 7,
45
- right: 7,
46
- padding: 7,
47
- backgroundColor: 'rgba(255, 255, 255, 0.4)',
44
+ // Matched to the web UIKit's remove button, which is 20px of icon inside
45
+ // 4px of padding at an 8px offset. The old 12/7 pair came to nearly the
46
+ // same 26dp outer size but filled only 46% of the circle with the glyph
47
+ // against web's 71%, so it read as a heavy black disc with a tiny cross
48
+ // in it.
49
+ top: 8,
50
+ right: 8,
51
+ padding: 4,
52
+ // Matches LoadingImage exactly: the icon is closeIcon('white') in both,
53
+ // so the white scrim this used to carry left a white-on-white button
54
+ // that all but disappeared over a light frame.
55
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
48
56
  borderRadius: 72,
57
+ zIndex: 10,
49
58
  },
50
59
  playButton: {
51
60
  width: 100,
@@ -69,11 +69,12 @@ export function MediaViewer({
69
69
  const listRef = useRef<FlatList<MediaViewerItem>>(null);
70
70
  const videoRef = useRef<VideoRef | null>(null);
71
71
  const [current, setCurrent] = useState(initialIndex);
72
- // Mute-carry (PDT-4309 / PDT-4312): the first video plays unmuted; each swipe
73
- // defaults the next video to muted — until the user unmutes, after which
74
- // subsequent videos stay unmuted.
72
+ // The sound setting belongs to the viewing session, not to one frame: the
73
+ // first video opens unmuted and swiping carries whatever the viewer last
74
+ // chose. Re-defaulting each new frame to muted meant a swipe silenced a
75
+ // session the viewer never muted, since opening unmuted is a default rather
76
+ // than an explicit choice.
75
77
  const [muted, setMuted] = useState(false);
76
- const keepUnmutedRef = useRef(false);
77
78
 
78
79
  // --- playback controls (ported from web VideoPlayerControls) ---
79
80
  const [userPaused, setUserPaused] = useState(false);
@@ -118,7 +119,6 @@ export function MediaViewer({
118
119
  }
119
120
  setCurrent(initialIndex);
120
121
  setMuted(false);
121
- keepUnmutedRef.current = false;
122
122
  resetPlayback();
123
123
  revealControls();
124
124
  }, [visible, initialIndex, resetPlayback, revealControls, clearHideTimer]);
@@ -129,7 +129,6 @@ export function MediaViewer({
129
129
  const idx = Math.round(e.nativeEvent.contentOffset.x / width);
130
130
  if (idx !== current) {
131
131
  setCurrent(idx);
132
- setMuted(!keepUnmutedRef.current);
133
132
  resetPlayback();
134
133
  revealControls();
135
134
  }
@@ -138,11 +137,7 @@ export function MediaViewer({
138
137
  );
139
138
 
140
139
  const toggleMute = useCallback(() => {
141
- setMuted((prev) => {
142
- const next = !prev;
143
- keepUnmutedRef.current = !next; // user unmuted -> keep unmuted afterwards
144
- return next;
145
- });
140
+ setMuted((prev) => !prev);
146
141
  revealControls();
147
142
  }, [revealControls]);
148
143
 
@@ -297,7 +292,11 @@ export function MediaViewer({
297
292
  style={styles.media}
298
293
  resizeMode="contain"
299
294
  paused={index !== current || !visible || userPaused}
300
- muted={muted && index === current}
295
+ // Only the frame on screen may be audible. `paused` alone is
296
+ // not a reliable gag on an off-screen native player, so an
297
+ // unmuted session must not leave a neighbouring frame able to
298
+ // emit sound.
299
+ muted={muted || index !== current}
301
300
  repeat
302
301
  playInBackground={false}
303
302
  playWhenInactive={false}
@@ -1,9 +1,11 @@
1
1
  import { StyleSheet } from 'react-native';
2
2
  import { useTheme } from 'react-native-paper';
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
3
4
  import type { MyMD3Theme } from '../../../core/providers/AmityUIKitProvider';
4
5
 
5
6
  export const useStyles = () => {
6
7
  const theme = useTheme<MyMD3Theme>();
8
+ const { top } = useSafeAreaInsets();
7
9
 
8
10
  const styles = StyleSheet.create({
9
11
  container: {
@@ -15,7 +17,13 @@ export const useStyles = () => {
15
17
  alignItems: 'center',
16
18
  justifyContent: 'space-between',
17
19
  paddingHorizontal: 16,
18
- height: 56,
20
+ // The viewer is a full-screen Modal, so its own content starts at y=0
21
+ // with nothing above it — the 56pt header sat under the notch / Dynamic
22
+ // Island and the close button could not be tapped at all. Inset the
23
+ // header instead of the container so the media itself still fills the
24
+ // screen behind it.
25
+ paddingTop: top,
26
+ height: 56 + top,
19
27
  zIndex: 2,
20
28
  },
21
29
  headerBtn: {
@@ -25,6 +25,7 @@ import RenderTextWithMention from '../RenderTextWithMention/RenderTextWithMentio
25
25
  import { IMentionPosition } from '../../../core/types';
26
26
  import PollContent from '../PollContent';
27
27
  import { MediaViewer, type MediaViewerItem } from '../MediaViewer';
28
+ import { classifyPostChildren } from '../../utils/postChildren';
28
29
 
29
30
  interface IPostContent {
30
31
  post: Amity.Post;
@@ -107,41 +108,8 @@ const PostContent: React.FC<IPostContent> = ({
107
108
  })
108
109
  );
109
110
 
110
- const images: string[] = [];
111
- const videos: IVideoPost[] = [];
112
- const polls: { pollId: string }[] = [];
113
- const livestreams: Amity.Room['roomId'][] = [];
114
- const media: Amity.Post[] = [];
115
-
116
- response.forEach((item) => {
117
- if (item?.dataType === 'image' && item?.data?.fileId) {
118
- const url: string = `https://api.${apiRegion}.amity.co/api/v3/files/${item?.data.fileId}/download?size=medium`;
119
- if (!images.includes(url)) {
120
- images.push(url);
121
- if (item.post) media.push(item.post);
122
- }
123
- } else if (
124
- item?.dataType === 'video' &&
125
- item?.data?.videoFileId.original
126
- ) {
127
- const isExisted = videos.some(
128
- (video) =>
129
- video.videoFileId.original === item.data.videoFileId.original
130
- );
131
- if (!isExisted) {
132
- videos.push(item.data);
133
- if (item.post) media.push(item.post);
134
- }
135
- } else if (item?.dataType === 'poll') {
136
- if (!polls.some((poll) => poll.pollId === item.data.pollId)) {
137
- polls.push(item.data);
138
- }
139
- } else if (item?.dataType === 'room') {
140
- if (!livestreams.includes(item.data.roomId)) {
141
- livestreams.push(item.data.roomId);
142
- }
143
- }
144
- });
111
+ const { images, videos, polls, livestreams, media } =
112
+ classifyPostChildren(response, apiRegion);
145
113
 
146
114
  // Set unconditionally so navigating to a post without media clears the
147
115
  // previous media (see the effect below which no longer eagerly resets).