diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MattermostManagedModule.java b/android/app/src/main/java/com/mattermost/rnbeta/MattermostManagedModule.java index 33f3a06435..77df51b6e5 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MattermostManagedModule.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/MattermostManagedModule.java @@ -110,6 +110,23 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule { return "MattermostManaged"; } + @ReactMethod + public void getFilePath(String filePath, Promise promise) { + Activity currentActivity = getCurrentActivity(); + WritableMap map = Arguments.createMap(); + + if (currentActivity != null) { + Uri uri = Uri.parse(filePath); + String path = RealPathUtil.getRealPathFromURI(currentActivity, uri); + if (path != null) { + String text = "file://" + path; + map.putString("filePath", text); + } + } + + promise.resolve(map); + } + @ReactMethod public void isRunningInSplitView(final Promise promise) { WritableMap result = Arguments.createMap(); diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index 256cbd8d01..8e01a5021e 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -101,8 +101,14 @@ export async function createPost(serverUrl: string, post: Partial, files: const initialPostModels: Model[] = []; - const filesModels = await operator.handleFiles({files, prepareRecordsOnly: true}); - initialPostModels.push(...filesModels); + if (files.length) { + for (const f of files) { + // Set the pending post Id + f.post_id = pendingPostId; + } + const filesModels = await operator.handleFiles({files, prepareRecordsOnly: true}); + initialPostModels.push(...filesModels); + } const postModels = await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, @@ -182,6 +188,77 @@ export async function createPost(serverUrl: string, post: Partial, files: return {data: true}; } +export const retryFailedPost = async (serverUrl: string, post: PostModel) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const timestamp = Date.now(); + const apiPost = await post.toApi(); + const newPost = { + ...apiPost, + props: { + ...apiPost.props, + failed: false, + }, + id: '', + create_at: timestamp, + update_at: timestamp, + delete_at: 0, + } as Post; + + // Update the local post to reflect the pending state in the UI + // timestamps will remain the same as the initial attempt for createAt + // but updateAt will be use for the optimistic post UI + post.prepareUpdate((p) => { + p.props = newPost.props; + p.updateAt = timestamp; + }); + await operator.batchRecords([post]); + + const created = await client.createPost(newPost); + const models = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [created.id], + posts: [created], + prepareRecordsOnly: true, + }); + const {member} = await updateLastPostAt(serverUrl, created.channel_id, created.create_at, true); + if (member) { + models.push(member); + } + await operator.batchRecords(models); + } catch (error: any) { + if (error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR || + error.server_error_id === ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR || + error.server_error_id === ServerErrors.PLUGIN_DISMISSED_POST_ERROR + ) { + await removePost(serverUrl, post); + } else { + post.prepareUpdate((p) => { + p.props = { + ...p.props, + failed: true, + }; + }); + await operator.batchRecords([post]); + } + + return {error}; + } + + return {error: undefined}; +}; + export const fetchPostsForCurrentChannel = async (serverUrl: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { diff --git a/app/components/post_list/post/body/failed/index.tsx b/app/components/post_list/post/body/failed/index.tsx index 2675d9c296..ef891abcd6 100644 --- a/app/components/post_list/post/body/failed/index.tsx +++ b/app/components/post_list/post/body/failed/index.tsx @@ -7,6 +7,7 @@ import {StyleSheet, TouchableOpacity, View} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {removePost} from '@actions/local/post'; +import {retryFailedPost} from '@actions/remote/post'; import CompassIcon from '@components/compass_icon'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; import {useServerUrl} from '@context/server'; @@ -30,9 +31,6 @@ const styles = StyleSheet.create({ }, }); -// TODO: Add Create post local action -const retryPost = (serverUrl: string, post: PostModel) => post; - const Failed = ({post, theme}: FailedProps) => { const intl = useIntl(); const insets = useSafeAreaInsets(); @@ -49,7 +47,7 @@ const Failed = ({post, theme}: FailedProps) => { icon='send-outline' onPress={() => { dismissBottomSheet(); - retryPost(serverUrl, post); + retryFailedPost(serverUrl, post); }} testID='post.failed.retry' text={intl.formatMessage({id: 'mobile.post.failed_retry', defaultMessage: 'Try Again'})} diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index 8dcc2a736c..c255c6dded 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {ReactNode, useMemo, useRef} from 'react'; +import React, {ReactNode, useEffect, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {Keyboard, Platform, StyleProp, View, ViewStyle, TouchableHighlight} from 'react-native'; @@ -10,12 +10,13 @@ import {showPermalink} from '@actions/remote/permalink'; import {fetchAndSwitchToThread} from '@actions/remote/thread'; import SystemAvatar from '@components/system_avatar'; import SystemHeader from '@components/system_header'; +import {POST_TIME_TO_FAIL} from '@constants/post'; import * as Screens from '@constants/screens'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation'; -import {fromAutoResponder, isFromWebhook, isPostPendingOrFailed, isSystemMessage} from '@utils/post'; +import {fromAutoResponder, isFromWebhook, isPostFailed, isPostPendingOrFailed, isSystemMessage} from '@utils/post'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -114,6 +115,7 @@ const Post = ({ const styles = getStyleSheet(theme); const isAutoResponder = fromAutoResponder(post); const isPendingOrFailed = isPostPendingOrFailed(post); + const isFailed = isPostFailed(post); const isSystemPost = isSystemMessage(post); const isWebHook = isFromWebhook(post); const hasSameRoot = useMemo(() => { @@ -184,6 +186,20 @@ const Post = ({ } }; + const [, rerender] = useState(false); + useEffect(() => { + let t: NodeJS.Timeout|undefined; + if (post.pendingPostId === post.id && !isFailed) { + t = setTimeout(() => rerender(true), POST_TIME_TO_FAIL - (Date.now() - post.updateAt)); + } + + return () => { + if (t) { + clearTimeout(t); + } + }; + }, [post.id]); + const highlightSaved = isSaved && !skipSavedHeader; const hightlightPinned = post.isPinned && !skipPinnedHeader; const itemTestID = `${testID}.${post.id}`; diff --git a/app/constants/post.ts b/app/constants/post.ts index ff8eb08f27..a4352a25b2 100644 --- a/app/constants/post.ts +++ b/app/constants/post.ts @@ -33,6 +33,8 @@ export const PostTypes: Record = { SYSTEM_AUTO_RESPONDER: 'system_auto_responder', }; +export const POST_TIME_TO_FAIL = 10000; + export default { POST_COLLAPSE_TIMEOUT: 1000 * 60 * 5, POST_TYPES: PostTypes, diff --git a/app/database/models/server/post.ts b/app/database/models/server/post.ts index aceaa0cb87..e829ffb766 100644 --- a/app/database/models/server/post.ts +++ b/app/database/models/server/post.ts @@ -155,4 +155,25 @@ export default class PostModel extends Model implements PostModelInterface { return false; } + + toApi = async (): Promise => ({ + id: this.id, + create_at: this.createAt, + update_at: this.updateAt, + edit_at: this.editAt, + delete_at: this.deleteAt, + is_pinned: this.isPinned, + user_id: this.userId, + channel_id: this.channelId, + root_id: this.rootId, + original_id: this.originalId, + message: this.message, + type: this.type, + props: this.props, + pending_post_id: this.pendingPostId, + file_ids: (await this.files.fetchIds()), + metadata: (this.metadata ? this.metadata : {}) as PostMetadata, + hashtags: '', + reply_count: 0, + }); } diff --git a/app/utils/file/file_picker/index.ts b/app/utils/file/file_picker/index.ts index bb1d564b74..700acc5890 100644 --- a/app/utils/file/file_picker/index.ts +++ b/app/utils/file/file_picker/index.ts @@ -12,7 +12,7 @@ import Permissions from 'react-native-permissions'; import {dismissBottomSheet} from '@screens/navigation'; import {extractFileInfo, lookupMimeType} from '@utils/file'; -const ShareExtension = NativeModules.MattermostShare; +const MattermostManaged = NativeModules.MattermostManaged; type PermissionSource = 'camera' | 'storage' | 'denied_android' | 'denied_ios' | 'photo'; @@ -126,7 +126,7 @@ export default class FilePickerUtil { files.push(file); } else { // For android we need to retrieve the realPath in case the file being imported is from the cloud - const uri = (await ShareExtension.getFilePath(file.uri)).filePath; + const uri = (await MattermostManaged.getFilePath(file.uri)).filePath; const type = file.type || lookupMimeType(uri); let fileName = file.fileName; if (type.includes('video/')) { @@ -224,7 +224,7 @@ export default class FilePickerUtil { if (Platform.OS === 'android') { // For android we need to retrieve the realPath in case the file being imported is from the cloud - const newUri = await ShareExtension.getFilePath(doc.uri); + const newUri = await MattermostManaged.getFilePath(doc.uri); uri = newUri?.filePath; if (uri === undefined) { return {doc: undefined}; diff --git a/app/utils/post/index.ts b/app/utils/post/index.ts index b3e9756a67..542585c3a7 100644 --- a/app/utils/post/index.ts +++ b/app/utils/post/index.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {Post} from '@constants'; +import {POST_TIME_TO_FAIL} from '@constants/post'; import {DEFAULT_LOCALE} from '@i18n'; import {displayUsername} from '@utils/user'; @@ -38,8 +39,12 @@ export function isPostEphemeral(post: PostModel): boolean { return post.type === Post.POST_TYPES.EPHEMERAL || post.type === Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL || post.deleteAt > 0; } +export function isPostFailed(post: PostModel): boolean { + return post.props?.failed || ((post.pendingPostId === post.id) && (Date.now() > post.updateAt + POST_TIME_TO_FAIL)); +} + export function isPostPendingOrFailed(post: PostModel): boolean { - return post.pendingPostId === post.id || post.props?.failed; + return post.pendingPostId === post.id || isPostFailed(post); } export function isSystemMessage(post: PostModel | Post): boolean { diff --git a/package-lock.json b/package-lock.json index 77184dc7f5..7fc15076a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,6 @@ "requires": true, "packages": { "": { - "name": "mattermost-mobile", "version": "2.0.0", "hasInstallScript": true, "license": "Apache 2.0", @@ -3074,7 +3073,7 @@ }, "node_modules/@mattermost/react-native-network-client": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/mattermost/react-native-network-client.git#aaf88d2142e8b5c3b89b17aa19605232c490b763", + "resolved": "git+ssh://git@github.com/mattermost/react-native-network-client.git#c17f1c042ac8c29a16e9222d4c1b172e93987b64", "license": "MIT", "dependencies": { "validator": "13.7.0", @@ -25689,7 +25688,7 @@ "requires": {} }, "@mattermost/react-native-network-client": { - "version": "git+ssh://git@github.com/mattermost/react-native-network-client.git#aaf88d2142e8b5c3b89b17aa19605232c490b763", + "version": "git+ssh://git@github.com/mattermost/react-native-network-client.git#c17f1c042ac8c29a16e9222d4c1b172e93987b64", "from": "@mattermost/react-native-network-client@github:mattermost/react-native-network-client", "requires": { "validator": "13.7.0", diff --git a/types/database/models/servers/post.d.ts b/types/database/models/servers/post.d.ts index c5245c331e..a87842e73e 100644 --- a/types/database/models/servers/post.d.ts +++ b/types/database/models/servers/post.d.ts @@ -92,4 +92,6 @@ export default class PostModel extends Model { /** hasReplies: Async function to determine if the post is part of a thread */ hasReplies: () => Promise; + + toApi: () => Promise; }