[Gekidou] retry post (#6293)

* Add try again functionality to failed posts

* Fix attach files on Android

* feedback review

* Prevent android crash when uploading files for the first time

* Update the timestamp for updateAt when retrying to post

* Add POST TIME TO FAIL

* use function isPostFailed
This commit is contained in:
Elias Nahum
2022-05-20 13:23:19 -04:00
committed by GitHub
parent 502efbcdc0
commit e883186fde
10 changed files with 152 additions and 15 deletions

View File

@@ -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();

View File

@@ -101,8 +101,14 @@ export async function createPost(serverUrl: string, post: Partial<Post>, 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<Post>, 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) {

View File

@@ -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'})}

View File

@@ -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}`;

View File

@@ -33,6 +33,8 @@ export const PostTypes: Record<string, string> = {
SYSTEM_AUTO_RESPONDER: 'system_auto_responder',
};
export const POST_TIME_TO_FAIL = 10000;
export default {
POST_COLLAPSE_TIMEOUT: 1000 * 60 * 5,
POST_TYPES: PostTypes,

View File

@@ -155,4 +155,25 @@ export default class PostModel extends Model implements PostModelInterface {
return false;
}
toApi = async (): Promise<Post> => ({
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,
});
}

View File

@@ -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};

View File

@@ -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 {

5
package-lock.json generated
View File

@@ -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",

View File

@@ -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<boolean>;
toApi: () => Promise<Post>;
}