[Gekidou] Post input (#5844)

* Initial commit post input

* Fix message posting, add create direct channel and minor fixes

* Fix "is typing" and "react to last post" behaviour

* Some reordering, better handling of upload error, properly clear draft on send message, and fix minor progress bar misbehavior

* Add keyboard listener for shift-enter, add selection between video or photo while attaching, add alert when trying to attach more than you are allowed, add paste functionality, minor fixes and reordering

* Add library patch

* Fix lint

* Address feedback

* Address feedback

* Add missing negation

* Check for group name and fix typo on draft comparisons

* Address feedback

* Address feedback

* Address feedback

* Address feedback

* Fix several bugs

* Remove @app imports

* Address feedback

* fix post list & post draft layout on iOS

* Fix post draft cursor position

* Fix file upload route

* Allow to pick multiple images using the image picker

* accurately get the channel member count

* remove android cursor workaround

* Remove local const INPUT_LINE_HEIGHT

* move getPlaceHolder out of the component

* use substring instead of legacy substr for hardward keyboard

* Move onAppStateChange above the effects

* Fix camera action bottom sheet

* no need to memo SendButton

* properly use memberCount in sender handler

* Refactor how to get memberCount

* Fix queryRecentPostsInThread

* Remove unused isDirectChannelVisible && isGroupChannelVisible util functions

* rename errorBadUser to errorUnkownUser

* extract localized strings

* use ClientErrorProps instead of ClientError

* Minor improvements

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Daniel Espino García 2022-02-03 12:59:15 +01:00 committed by GitHub
parent f815f6b3e5
commit 55324127e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
100 changed files with 4232 additions and 243 deletions

View file

@ -76,8 +76,6 @@ public class MainApplication extends NavigationApplication implements INotificat
return new ShareModule(instance, reactContext);
case "NotificationPreferences":
return NotificationPreferencesModule.getInstance(instance, reactContext);
case "RNTextInputReset":
return new RNTextInputResetModule(reactContext);
default:
throw new IllegalArgumentException("Could not find module " + name);
}
@ -90,7 +88,6 @@ public class MainApplication extends NavigationApplication implements INotificat
map.put("MattermostManaged", new ReactModuleInfo("MattermostManaged", "com.mattermost.rnbeta.MattermostManagedModule", false, false, false, false, false));
map.put("MattermostShare", new ReactModuleInfo("MattermostShare", "com.mattermost.share.ShareModule", false, false, true, false, false));
map.put("NotificationPreferences", new ReactModuleInfo("NotificationPreferences", "com.mattermost.rnbeta.NotificationPreferencesModule", false, false, false, false, false));
map.put("RNTextInputReset", new ReactModuleInfo("RNTextInputReset", "com.mattermost.rnbeta.RNTextInputResetModule", false, false, false, false, false));
return map;
};
}

View file

@ -1,42 +0,0 @@
package com.mattermost.rnbeta;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.uimanager.UIManagerModule;
import com.facebook.react.uimanager.UIBlock;
import com.facebook.react.uimanager.NativeViewHierarchyManager;
import android.content.Context;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
public class RNTextInputResetModule extends ReactContextBaseJavaModule {
private final ReactApplicationContext reactContext;
public RNTextInputResetModule(ReactApplicationContext reactContext) {
super(reactContext);
this.reactContext = reactContext;
}
@Override
public String getName() {
return "RNTextInputReset";
}
// https://github.com/facebook/react-native/pull/12462#issuecomment-298812731
@ReactMethod
public void resetKeyboardInput(final int reactTagToReset) {
UIManagerModule uiManager = getReactApplicationContext().getNativeModule(UIManagerModule.class);
uiManager.addUIBlock(new UIBlock() {
@Override
public void execute(NativeViewHierarchyManager nativeViewHierarchyManager) {
InputMethodManager imm = (InputMethodManager) getReactApplicationContext().getBaseContext().getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) {
View viewToReset = nativeViewHierarchyManager.resolveView(reactTagToReset);
imm.restartInput(viewToReset);
}
}
});
}
}

View file

@ -1,8 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {queryDraft} from '@app/queries/servers/drafts';
import DatabaseManager from '@database/manager';
import {queryDraft} from '@queries/servers/drafts';
export const updateDraftFile = async (serverUrl: string, channelId: string, rootId: string, file: FileInfo, prepareRecordsOnly = false) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
@ -20,8 +20,11 @@ export const updateDraftFile = async (serverUrl: string, channelId: string, root
return {error: 'file not found'};
}
// We create a new list to make sure we re-render the draft input.
const newFiles = [...draft.files];
newFiles[i] = file;
draft.prepareUpdate((d) => {
d.files[i] = file;
d.files = newFiles;
});
try {
@ -34,3 +37,131 @@ export const updateDraftFile = async (serverUrl: string, channelId: string, root
return {error};
}
};
export const removeDraftFile = async (serverUrl: string, channelId: string, rootId: string, clientId: string, prepareRecordsOnly = false) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const draft = await queryDraft(operator.database, channelId, rootId);
if (!draft) {
return {error: 'no draft'};
}
const i = draft.files.findIndex((v) => v.clientId === clientId);
if (i === -1) {
return {error: 'file not found'};
}
if (draft.files.length === 1 && !draft.message) {
draft.prepareDestroyPermanently();
} else {
draft.prepareUpdate((d) => {
d.files = draft.files.filter((v, index) => index !== i);
});
}
try {
if (!prepareRecordsOnly) {
await operator.batchRecords([draft]);
}
return {draft};
} catch (error) {
return {error};
}
};
export const updateDraftMessage = async (serverUrl: string, channelId: string, rootId: string, message: string, prepareRecordsOnly = false) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const draft = await queryDraft(operator.database, channelId, rootId);
if (!draft) {
if (!message) {
return {};
}
const newDraft: Draft = {
channel_id: channelId,
root_id: rootId,
message,
};
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
if (draft.message === message) {
return {draft};
}
if (draft.files.length === 0 && !message) {
draft.prepareDestroyPermanently();
} else {
draft.prepareUpdate((d) => {
d.message = message;
});
}
try {
if (!prepareRecordsOnly) {
await operator.batchRecords([draft]);
}
return {draft};
} catch (error) {
return {error};
}
};
export const addFilesToDraft = async (serverUrl: string, channelId: string, rootId: string, files: FileInfo[], prepareRecordsOnly = false) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const draft = await queryDraft(operator.database, channelId, rootId);
if (!draft) {
const newDraft: Draft = {
channel_id: channelId,
root_id: rootId,
files,
message: '',
};
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
draft.prepareUpdate((d) => {
d.files = [...draft.files, ...files];
});
try {
if (!prepareRecordsOnly) {
await operator.batchRecords([draft]);
}
return {draft};
} catch (error) {
return {error};
}
};
export const removeDraft = async (serverUrl: string, channelId: string, rootId = '') => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const draft = await queryDraft(database, channelId, rootId);
if (draft) {
await database.write(async () => {
await draft.destroyPermanently();
});
}
return {draft};
};

View file

@ -9,12 +9,16 @@ import type SystemModel from '@typings/database/models/servers/system';
const MAXIMUM_RECENT_EMOJI = 27;
export const addRecentReaction = async (serverUrl: string, emojiName: string, prepareRecordsOnly = false) => {
export const addRecentReaction = async (serverUrl: string, emojiNames: string[], prepareRecordsOnly = false) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
if (!emojiNames.length) {
return [];
}
let recent: string[] = [];
try {
const emojis = await operator.database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).find(SYSTEM_IDENTIFIERS.RECENT_REACTIONS);
@ -25,11 +29,17 @@ export const addRecentReaction = async (serverUrl: string, emojiName: string, pr
try {
const recentEmojis = new Set(recent);
if (recentEmojis.has(emojiName)) {
recentEmojis.delete(emojiName);
for (const name of emojiNames) {
if (recentEmojis.has(name)) {
recentEmojis.delete(name);
}
}
recent = Array.from(recentEmojis);
recent.unshift(emojiName);
for (const name of emojiNames) {
recent.unshift(name);
}
return operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.RECENT_REACTIONS,

View file

@ -57,7 +57,7 @@ export const updateLocalCustomStatus = async (serverUrl: string, user: UserModel
}
if (customStatus.emoji) {
const recentEmojis = await addRecentReaction(serverUrl, customStatus.emoji, true);
const recentEmojis = await addRecentReaction(serverUrl, [customStatus.emoji], true);
if (Array.isArray(recentEmojis)) {
models.push(...recentEmojis);
}

View file

@ -128,7 +128,7 @@ export function postEphemeralCallResponseForCommandArgs(serverUrl: string, respo
);
}
const showAppForm = async (form: AppForm, call: AppCallRequest, theme: Theme) => {
export const showAppForm = async (form: AppForm, call: AppCallRequest, theme: Theme) => {
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
let submitButtons = [{

View file

@ -9,12 +9,10 @@ import {General} from '@constants';
import DatabaseManager from '@database/manager';
import {privateChannelJoinPrompt} from '@helpers/api/channel';
import NetworkManager from '@init/network_manager';
import {prepareMyChannelsForTeam, queryChannelById, queryMyChannel} from '@queries/servers/channel';
import {queryCommonSystemValues, queryCurrentUserId} from '@queries/servers/system';
import {prepareMyTeams, queryMyTeamById, queryTeamById, queryTeamByName} from '@queries/servers/team';
import MyChannelModel from '@typings/database/models/servers/my_channel';
import MyTeamModel from '@typings/database/models/servers/my_team';
import TeamModel from '@typings/database/models/servers/team';
import {prepareMyChannelsForTeam, queryChannelById, queryChannelByName, queryMyChannel} from '@queries/servers/channel';
import {queryCommonSystemValues, queryCurrentTeamId, queryCurrentUserId} from '@queries/servers/system';
import {prepareMyTeams, queryNthLastChannelFromTeam, queryMyTeamById, queryTeamById, queryTeamByName} from '@queries/servers/team';
import {getDirectChannelName} from '@utils/channel';
import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url';
import {displayGroupMessageName, displayUsername} from '@utils/user';
@ -26,6 +24,9 @@ import {fetchProfilesPerChannels, fetchUsersByIds} from './user';
import type {Client} from '@client/rest';
import type ChannelInfoModel from '@typings/database/models/servers/channel_info';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
import type MyTeamModel from '@typings/database/models/servers/my_team';
import type TeamModel from '@typings/database/models/servers/team';
export type MyChannelsRequest = {
channels?: Channel[];
@ -519,6 +520,92 @@ export const switchToChannelByName = async (serverUrl: string, channelName: stri
}
};
export async function getChannelMemberCountsByGroup(serverUrl: string, channelId: string, includeTimezones: boolean) {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const channelMemberCountsByGroup = await client.getChannelMemberCountsByGroup(channelId, includeTimezones);
return {channelMemberCountsByGroup};
} catch (error) {
return {error};
}
}
export async function getChannelTimezones(serverUrl: string, channelId: string) {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const channelTimezones = await client.getChannelTimezones(channelId);
return {channelTimezones};
} catch (error) {
return {error};
}
}
export async function getOrCreateDirectChannel(serverUrl: string, otherUserId: string, shouldSwitchToChannel = true) {
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};
}
const currentUserId = await queryCurrentUserId(operator.database);
const channelName = getDirectChannelName(currentUserId, otherUserId);
const channel = await queryChannelByName(operator.database, channelName);
let result;
if (channel) {
result = {channel};
} else {
try {
const newChannel = await client.createDirectChannel([currentUserId, otherUserId]);
result = {channel: newChannel};
const member = await client.getMyChannelMember(newChannel.id);
const modelPromises: Array<Promise<Model[]>> = [];
const prepare = await prepareMyChannelsForTeam(operator, '', [newChannel], [member]);
if (prepare?.length) {
modelPromises.push(...prepare);
const models = await Promise.all(modelPromises);
const flattenedModels = models.flat() as Model[];
if (flattenedModels?.length > 0) {
try {
await operator.batchRecords(flattenedModels);
} catch {
// eslint-disable-next-line no-console
console.log('FAILED TO BATCH CHANNELS');
}
}
}
} catch (error) {
return {error};
}
}
if (shouldSwitchToChannel) {
switchToChannelById(serverUrl, result.channel.id);
}
return result;
}
export const switchToChannelById = async (serverUrl: string, channelId: string, teamId?: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
@ -532,3 +619,18 @@ export const switchToChannelById = async (serverUrl: string, channelId: string,
return {};
};
export const switchToPenultimateChannel = async (serverUrl: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
const currentTeam = await queryCurrentTeamId(database);
const channelId = await queryNthLastChannelFromTeam(database, currentTeam, 1);
return switchToChannelById(serverUrl, channelId);
} catch (error) {
return {error};
}
};

View file

@ -0,0 +1,202 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IntlShape} from 'react-intl';
import {Alert} from 'react-native';
import {showPermalink} from '@actions/local/permalink';
import {Client} from '@client/rest';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DeepLinkTypes from '@constants/deep_linking';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {queryChannelsById} from '@queries/servers/channel';
import {queryConfig, queryCurrentTeamId} from '@queries/servers/system';
import {queryUsersByUsername} from '@queries/servers/user';
import {showModal} from '@screens/navigation';
import * as DraftUtils from '@utils/draft';
import {matchDeepLink, tryOpenURL} from '@utils/url';
import {getOrCreateDirectChannel, switchToChannelById, switchToChannelByName} from './channel';
import type {DeepLinkChannel, DeepLinkPermalink, DeepLinkDM, DeepLinkGM, DeepLinkPlugin} from '@typings/launch';
export const executeCommand = async (serverUrl: string, intl: IntlShape, message: string, channelId: string, rootId?: string) => {
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: error as ClientErrorProps};
}
// TODO https://mattermost.atlassian.net/browse/MM-41234
// const config = await queryConfig(operator.database)
// if (config.FeatureFlagAppsEnabled) {
// const parser = new AppCommandParser(serverUrl, intl, channelId, rootId);
// if (parser.isAppCommand(msg)) {
// return executeAppCommand(serverUrl, intl, parser);
// }
// }
const channel = (await queryChannelsById(operator.database, [channelId]))?.[0];
const teamId = channel?.teamId || (await queryCurrentTeamId(operator.database));
const args: CommandArgs = {
channel_id: channelId,
team_id: teamId,
root_id: rootId,
parent_id: rootId,
};
let msg = filterEmDashForCommand(message);
let cmdLength = msg.indexOf(' ');
if (cmdLength < 0) {
cmdLength = msg.length;
}
const cmd = msg.substring(0, cmdLength).toLowerCase();
msg = cmd + msg.substring(cmdLength);
let data;
try {
data = await client.executeCommand(msg, args);
} catch (error) {
return {error: error as ClientErrorProps};
}
if (data?.trigger_id) { //eslint-disable-line camelcase
operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.INTEGRATION_TRIGGER_ID, value: data.trigger_id}],
prepareRecordsOnly: false,
});
}
return {data};
};
// TODO https://mattermost.atlassian.net/browse/MM-41234
// const executeAppCommand = (serverUrl: string, intl: IntlShape, parser: any) => {
// const {call, errorMessage} = await parser.composeCallFromCommand(msg);
// const createErrorMessage = (errMessage: string) => {
// return {error: {message: errMessage}};
// };
// if (!call) {
// return createErrorMessage(errorMessage!);
// }
// const res = await dispatch(doAppCall(call, AppCallTypes.SUBMIT, intl));
// if (res.error) {
// const errorResponse = res.error as AppCallResponse;
// return createErrorMessage(errorResponse.error || intl.formatMessage({
// id: 'apps.error.unknown',
// defaultMessage: 'Unknown error.',
// }));
// }
// const callResp = res.data as AppCallResponse;
// switch (callResp.type) {
// case AppCallResponseTypes.OK:
// if (callResp.markdown) {
// dispatch(postEphemeralCallResponseForCommandArgs(callResp, callResp.markdown, args));
// }
// return {data: {}};
// case AppCallResponseTypes.FORM:
// case AppCallResponseTypes.NAVIGATE:
// return {data: {}};
// default:
// return createErrorMessage(intl.formatMessage({
// id: 'apps.error.responses.unknown_type',
// defaultMessage: 'App response type not supported. Response type: {type}.',
// }, {
// type: callResp.type,
// }));
// }
// };
const filterEmDashForCommand = (command: string): string => {
return command.replace(/\u2014/g, '--');
};
export const handleGotoLocation = async (serverUrl: string, intl: IntlShape, location: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const config = await queryConfig(operator.database);
const match = matchDeepLink(location, serverUrl, config.SiteURL);
if (match) {
switch (match.type) {
case DeepLinkTypes.CHANNEL: {
const data = match.data as DeepLinkChannel;
switchToChannelByName(data.serverUrl, data.channelName, data.teamName, DraftUtils.errorBadChannel, intl);
break;
}
case DeepLinkTypes.PERMALINK: {
const data = match.data as DeepLinkPermalink;
showPermalink(serverUrl, data.teamName, data.postId, intl);
break;
}
case DeepLinkTypes.DMCHANNEL: {
const data = match.data as DeepLinkDM;
if (!data.userName) {
DraftUtils.errorUnkownUser(intl);
return {data: false};
}
let serverDatabase = operator.database;
if (data.serverUrl !== serverUrl) {
serverDatabase = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!serverDatabase) {
return {error: `${serverUrl} database not found`};
}
}
const user = (await queryUsersByUsername(serverDatabase, [data.userName]))?.[0];
if (!user) {
DraftUtils.errorUnkownUser(intl);
return {data: false};
}
getOrCreateDirectChannel(data.serverUrl, user.id);
break;
}
case DeepLinkTypes.GROUPCHANNEL: {
const data = match.data as DeepLinkGM;
if (!data.channelId) {
DraftUtils.errorBadChannel(intl);
return {data: false};
}
switchToChannelById(data.serverUrl, data.channelId);
break;
}
case DeepLinkTypes.PLUGIN: {
const data = match.data as DeepLinkPlugin;
showModal('PluginInternal', data.id, {link: location});
break;
}
}
} else {
const {formatMessage} = intl;
const onError = () => Alert.alert(
formatMessage({
id: 'mobile.server_link.error.title',
defaultMessage: 'Link Error',
}),
formatMessage({
id: 'mobile.server_link.error.text',
defaultMessage: 'The link could not be found on this server.',
}),
);
tryOpenURL(location, onError);
}
return {data: true};
};

View file

@ -5,16 +5,19 @@
import {DeviceEventEmitter} from 'react-native';
import {updateLastPostAt} from '@actions/local/channel';
import {processPostsFetched} from '@actions/local/post';
import {ActionType, Events, General} from '@constants';
import {processPostsFetched, removePost} from '@actions/local/post';
import {addRecentReaction} from '@actions/local/reactions';
import {ActionType, Events, General, ServerErrors} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getNeededAtMentionedUsernames} from '@helpers/api/user';
import NetworkManager from '@init/network_manager';
import {prepareMissingChannelsForAllTeams, queryAllMyChannelIds} from '@queries/servers/channel';
import {queryRecentPostsInChannel} from '@queries/servers/post';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {queryPostById, queryRecentPostsInChannel} from '@queries/servers/post';
import {queryCurrentUserId, queryCurrentChannelId} from '@queries/servers/system';
import {queryAllUsers} from '@queries/servers/user';
import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers';
import {forceLogoutIfNecessary} from './session';
@ -33,6 +36,114 @@ type AuthorsRequest = {
error?: unknown;
}
export const createPost = async (serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; error?: any}> => {
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};
}
const currentUserId = queryCurrentUserId(operator.database);
const timestamp = Date.now();
const pendingPostId = post.pending_post_id || `${currentUserId}:${timestamp}`;
const existing = await queryPostById(operator.database, pendingPostId);
if (existing && !existing.props.failed) {
return {data: false};
}
let newPost = {
...post,
id: '',
pending_post_id: pendingPostId,
create_at: timestamp,
update_at: timestamp,
} as Post;
if (files.length) {
const fileIds = files.map((file) => file.id);
newPost = {
...newPost,
file_ids: fileIds,
};
}
const databasePost = {
...newPost,
id: pendingPostId,
};
const initialPostModels: Model[] = [];
const filesModels = await operator.handleFiles({files, prepareRecordsOnly: true});
if (filesModels.length) {
initialPostModels.push(...filesModels);
}
const postModels = await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [databasePost.id],
posts: [databasePost],
prepareRecordsOnly: true,
});
if (postModels.length) {
initialPostModels.push(...postModels);
}
const customEmojis = await queryAllCustomEmojis(operator.database);
const emojisInMessage = matchEmoticons(newPost.message);
const reactionModels = await addRecentReaction(serverUrl, getValidEmojis(emojisInMessage, customEmojis), true);
if (!('error' in reactionModels) && reactionModels.length) {
initialPostModels.push(...reactionModels);
}
operator.batchRecords(initialPostModels);
try {
const created = await client.createPost(newPost);
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [created.id],
posts: [created],
});
newPost = created;
} catch (error: any) {
const errorPost = {
...newPost,
id: pendingPostId,
props: {
...newPost.props,
failed: true,
},
update_at: Date.now(),
};
// If the failure was because: the root post was deleted or
// TownSquareIsReadOnly=true then remove the post
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, databasePost);
} else {
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [errorPost.id],
posts: [errorPost],
});
}
}
return {data: true};
};
export const fetchPostsForCurrentChannel = async (serverUrl: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {

View file

@ -7,11 +7,13 @@ import {addRecentReaction} from '@actions/local/reactions';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {queryCurrentUserId} from '@queries/servers/system';
import {queryRecentPostsInChannel, queryRecentPostsInThread} from '@queries/servers/post';
import {queryCurrentChannelId, queryCurrentUserId} from '@queries/servers/system';
import {forceLogoutIfNecessary} from './session';
import type {Client} from '@client/rest';
import type PostModel from '@typings/database/models/servers/post';
export const addReaction = async (serverUrl: string, postId: string, emojiName: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
@ -41,7 +43,7 @@ export const addReaction = async (serverUrl: string, postId: string, emojiName:
});
models.push(...reactions);
const recent = await addRecentReaction(serverUrl, emojiName, true);
const recent = await addRecentReaction(serverUrl, [emojiName], true);
if (Array.isArray(recent)) {
models.push(...recent);
}
@ -93,3 +95,27 @@ export const removeReaction = async (serverUrl: string, postId: string, emojiNam
return {error};
}
};
export const handleReactionToLatestPost = async (serverUrl: string, emojiName: string, add: boolean, rootId?: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
let posts: PostModel[];
if (rootId) {
posts = await queryRecentPostsInThread(operator.database, rootId);
} else {
const channelId = await queryCurrentChannelId(operator.database);
posts = await queryRecentPostsInChannel(operator.database, channelId);
}
if (add) {
return addReaction(serverUrl, posts[0].id, emojiName);
}
return removeReaction(serverUrl, posts[0].id, emojiName);
} catch (error) {
return {error};
}
};

View file

@ -10,7 +10,7 @@ import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {prepareMyChannelsForTeam, queryDefaultChannelForTeam} from '@queries/servers/channel';
import {prepareCommonSystemValues, queryCurrentTeamId, queryWebSocketLastDisconnected} from '@queries/servers/system';
import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, queryLastChannelFromTeam, queryTeamsById, syncTeamTable} from '@queries/servers/team';
import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, queryNthLastChannelFromTeam, queryTeamsById, syncTeamTable} from '@queries/servers/team';
import {isTablet} from '@utils/helpers';
import {fetchMyChannelsForTeam, switchToChannelById} from './channel';
@ -272,7 +272,7 @@ export const handleTeamChange = async (serverUrl: string, teamId: string) => {
let channelId = '';
if (await isTablet()) {
channelId = await queryLastChannelFromTeam(database, teamId);
channelId = await queryNthLastChannelFromTeam(database, teamId);
if (channelId) {
await switchToChannelById(serverUrl, channelId, teamId);
return;

View file

@ -13,7 +13,7 @@ import DatabaseManager from '@database/manager';
import {queryActiveServer} from '@queries/app/servers';
import {deleteChannelMembership, prepareMyChannelsForTeam, queryChannelsById, queryCurrentChannel} from '@queries/servers/channel';
import {prepareCommonSystemValues, queryConfig, setCurrentChannelId} from '@queries/servers/system';
import {queryLastChannelFromTeam} from '@queries/servers/team';
import {queryNthLastChannelFromTeam} from '@queries/servers/team';
import {queryCurrentUser, queryUserById} from '@queries/servers/user';
import {dismissAllModals, popToRoot} from '@screens/navigation';
import {isTablet} from '@utils/helpers';
@ -122,7 +122,7 @@ export async function handleUserRemovedFromChannelEvent(serverUrl: string, msg:
await popToRoot();
if (await isTablet()) {
const channelToJumpTo = await queryLastChannelFromTeam(database.database, channel?.teamId);
const channelToJumpTo = await queryNthLastChannelFromTeam(database.database, channel?.teamId);
if (channelToJumpTo) {
const {models: switchChannelModels} = await switchToChannel(serverUrl, channelToJumpTo, '', true);
if (switchChannelModels) {
@ -179,7 +179,7 @@ export async function handleChannelDeletedEvent(serverUrl: string, msg: WebSocke
await popToRoot();
if (await isTablet()) {
const channelToJumpTo = await queryLastChannelFromTeam(database.database, currentChannel?.teamId);
const channelToJumpTo = await queryNthLastChannelFromTeam(database.database, currentChannel?.teamId);
if (channelToJumpTo) {
switchToChannel(serverUrl, channelToJumpTo);
} // TODO else jump to "join a channel" screen

View file

@ -25,7 +25,7 @@ import {handlePreferenceChangedEvent, handlePreferencesChangedEvent, handlePrefe
import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReactionAddedToPostEvent} from './reactions';
import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRoleUpdatedEvent} from './roles';
import {handleLeaveTeamEvent, handleUserAddedToTeamEvent, handleUpdateTeamEvent} from './teams';
import {handleUserUpdatedEvent} from './users';
import {handleUserUpdatedEvent, handleUserTypingEvent} from './users';
import type {Model} from '@nozbe/watermelondb';
@ -252,9 +252,8 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
// return dispatch(handleStatusChangedEvent(msg));
case WebsocketEvents.TYPING:
handleUserTypingEvent(serverUrl, msg);
break;
// return dispatch(handleUserTypingEvent(msg));
case WebsocketEvents.HELLO:
break;

View file

@ -2,15 +2,22 @@
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {DeviceEventEmitter} from 'react-native';
import {updateChannelsDisplayName} from '@actions/local/channel';
import {fetchMe} from '@actions/remote/user';
import {General} from '@constants';
import {fetchMe, fetchUsersByIds} from '@actions/remote/user';
import {General, Events, Preferences} from '@constants';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import {queryCurrentUser} from '@queries/servers/user';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import WebsocketManager from '@init/websocket_manager';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {queryCommonSystemValues} from '@queries/servers/system';
import {queryCurrentUser, queryUserById} from '@queries/servers/user';
import {displayUsername} from '@utils/user';
import type ChannelModel from '@typings/database/models/servers/channel';
import type UserModel from '@typings/database/models/servers/user';
const {SERVER: {CHANNEL, CHANNEL_MEMBERSHIP}} = MM_TABLES;
@ -60,3 +67,42 @@ export async function handleUserUpdatedEvent(serverUrl: string, msg: any) {
}
}
}
export async function handleUserTypingEvent(serverUrl: string, msg: any) {
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
if (currentServerUrl === serverUrl) {
const database = DatabaseManager.serverDatabases[serverUrl];
if (!database) {
return;
}
const {config, license} = await queryCommonSystemValues(database.database);
let user: UserModel | UserProfile | undefined = await queryUserById(database.database, msg.data.user_id);
if (!user) {
const {users} = await fetchUsersByIds(serverUrl, [msg.data.user_id]);
user = users?.[0];
}
const namePreference = await queryPreferencesByCategoryAndName(database.database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT);
const teammateDisplayNameSetting = await getTeammateNameDisplaySetting(namePreference, config, license);
const currentUser = await queryCurrentUser(database.database);
const username = displayUsername(user, currentUser?.locale, teammateDisplayNameSetting);
const data = {
channelId: msg.broadcast.channel_id,
rootId: msg.data.parent_id,
userId: msg.data.user_id,
username,
now: Date.now(),
};
DeviceEventEmitter.emit(Events.USER_TYPING, data);
setTimeout(() => {
DeviceEventEmitter.emit(Events.USER_STOP_TYPING, data);
}, parseInt(config.TimeBetweenUserTypingUpdatesMilliseconds, 10));
}
}
export const userTyping = async (serverUrl: string, channelId: string, rootId?: string) => {
const client = WebsocketManager.getClient(serverUrl);
client?.sendUserTypingEvent(channelId, rootId);
};

View file

@ -214,6 +214,11 @@ export default class ClientBase {
body: options.body,
headers: this.getRequestHeaders(method),
};
if (options.noRetry) {
requestOptions.retryPolicyConfiguration = {
retryLimit: 0,
};
}
let response: ClientResponse;
try {
response = await request!(url, requestOptions);

View file

@ -61,7 +61,7 @@ const ClientFiles = (superclass: any) => class extends superclass {
onError: (response: ClientResponseError) => void,
skipBytes = 0,
) => {
const url = `${this.apiClient.baseUrl}${this.getFilesRoute()}`;
const url = this.getFilesRoute();
const options: UploadRequestOptions = {
skipBytes,
method: 'POST',

View file

@ -7,9 +7,9 @@ import {PER_PAGE_DEFAULT} from './constants';
export interface ClientIntegrationsMix {
getCommandsList: (teamId: string) => Promise<Command[]>;
getCommandAutocompleteSuggestionsList: (userInput: string, teamId: string, commandArgs?: Record<string, any>) => Promise<Command[]>;
getCommandAutocompleteSuggestionsList: (userInput: string, teamId: string, commandArgs?: CommandArgs) => Promise<Command[]>;
getAutocompleteCommandsList: (teamId: string, page?: number, perPage?: number) => Promise<Command[]>;
executeCommand: (command: Command, commandArgs?: Record<string, any>) => Promise<any>;
executeCommand: (command: string, commandArgs?: CommandArgs) => Promise<CommandResponse>;
addCommand: (command: Command) => Promise<Command>;
submitInteractiveDialog: (data: DialogSubmission) => Promise<any>;
}
@ -36,7 +36,7 @@ const ClientIntegrations = (superclass: any) => class extends superclass {
);
};
executeCommand = async (command: Command, commandArgs = {}) => {
executeCommand = async (command: string, commandArgs = {}) => {
this.analytics.trackAPI('api_integrations_used');
return this.doFetch(

View file

@ -41,7 +41,7 @@ const ClientPosts = (superclass: any) => class extends superclass {
return this.doFetch(
`${this.getPostsRoute()}`,
{method: 'post', body: post},
{method: 'post', body: post, noRetry: true},
);
};

View file

@ -326,7 +326,7 @@ export default class WebSocketClient {
}
}
public sendUserTypingEvent(channelId: string, parentId: string) {
public sendUserTypingEvent(channelId: string, parentId?: string) {
this.sendMessage('user_typing', {
channel_id: channelId,
parent_id: parentId,

View file

@ -6,7 +6,7 @@ import React from 'react';
import {Preferences} from '@constants';
import ErrorText from './index';
import ErrorTextComponent from './index';
describe('ErrorText', () => {
const baseProps = {
@ -21,7 +21,7 @@ describe('ErrorText', () => {
test('should match snapshot', () => {
const wrapper = render(
<ErrorText {...baseProps}/>,
<ErrorTextComponent {...baseProps}/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();

View file

@ -7,16 +7,14 @@ import {StyleProp, Text, TextStyle, ViewStyle} from 'react-native';
import FormattedText from '@components/formatted_text';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type {ErrorText as ErrorType} from '@typings/utils/file';
type ErrorProps = {
error: ErrorType;
error: ErrorText;
testID?: string;
textStyle?: StyleProp<ViewStyle> | StyleProp<TextStyle>;
theme: Theme;
}
const ErrorText = ({error, testID, textStyle, theme}: ErrorProps) => {
const ErrorTextComponent = ({error, testID, textStyle, theme}: ErrorProps) => {
const style = getStyleSheet(theme);
const message = typeof (error) === 'string' ? error : error.message;
@ -55,4 +53,4 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
export default ErrorText;
export default ErrorTextComponent;

View file

@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {View} from 'react-native';
import Button from 'react-native-button';
import {switchToPenultimateChannel} from '@actions/remote/channel';
import FormattedMarkdownText from '@components/formatted_markdown_text';
import FormattedText from '@components/formatted_text';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {t} from '@i18n';
import {popToRoot} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
testID?: string;
deactivated?: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
archivedWrapper: {
paddingHorizontal: 20,
paddingVertical: 10,
borderTopWidth: 1,
backgroundColor: theme.centerChannelBg,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.20),
},
archivedText: {
textAlign: 'center',
color: theme.centerChannelColor,
},
closeButton: {
backgroundColor: theme.buttonBg,
alignItems: 'center',
paddingVertical: 5,
borderRadius: 4,
marginTop: 10,
height: 40,
},
closeButtonText: {
marginTop: 7,
color: 'white',
fontWeight: 'bold',
},
}));
export default function Archived({
testID,
deactivated,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
const isTablet = useIsTablet();
const serverUrl = useServerUrl();
const onCloseChannelPress = useCallback(() => {
if (isTablet) {
switchToPenultimateChannel(serverUrl);
} else {
popToRoot();
}
}, [serverUrl, isTablet]);
let message = {
id: t('archivedChannelMessage'),
defaultMessage: 'You are viewing an **archived channel**. New messages cannot be posted.',
};
if (deactivated) {
// only applies to DM's when the user was deactivated
message = {
id: t('create_post.deactivated'),
defaultMessage: 'You are viewing an archived channel with a deactivated user.',
};
}
return (
<View
testID={testID}
style={style.archivedWrapper}
>
<FormattedMarkdownText
{...message}
style={style.archivedText}
baseTextStyle={style.baseTextStyle}
textStyles={style.textStyles}
/>
<Button
containerStyle={style.closeButton}
onPress={onCloseChannelPress}
>
<FormattedText
id='center_panel.archived.closeChannel'
defaultMessage='Close Channel'
style={style.closeButtonText}
/>
</Button>
</View>
);
}

View file

@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import DraftInput from '../draft_input';
type Props = {
testID?: string;
channelId: string;
rootId: string;
// Send Handler
sendMessage: () => void;
maxMessageLength: number;
canSend: boolean;
// Draft Handler
value: string;
uploadFileError: React.ReactNode;
files: FileInfo[];
clearDraft: () => void;
updateValue: (value: string) => void;
addFiles: (files: FileInfo[]) => void;
}
export default function CursorPositionHandler(props: Props) {
const [pos, setCursorPosition] = useState(0);
return (
<DraftInput
{...props}
cursorPosition={pos}
updateCursorPosition={setCursorPosition}
/>
);
}

View file

@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {addFilesToDraft, removeDraft} from '@actions/local/draft';
import {useServerUrl} from '@context/server';
import DraftUploadManager from '@init/draft_upload_manager';
import {fileMaxWarning, fileSizeWarning, uploadDisabledWarning} from '@utils/file';
import SendHandler from '../send_handler';
type Props = {
testID?: string;
channelId: string;
rootId?: string;
files?: FileInfo[];
message?: string;
maxFileSize: number;
maxFileCount: number;
canUploadFiles: boolean;
}
const emptyFileList: FileInfo[] = [];
const UPLOAD_ERROR_SHOW_INTERVAL = 5000;
type ErrorHandlers = {
[clientId: string]: (() => void) | null;
}
export default function DraftHandler(props: Props) {
const {
testID,
channelId,
rootId = '',
files,
message,
maxFileSize,
maxFileCount,
canUploadFiles,
} = props;
const serverUrl = useServerUrl();
const intl = useIntl();
const [currentValue, setCurrentValue] = useState(message || '');
const [uploadError, setUploadError] = useState<React.ReactNode>(null);
const uploadErrorTimeout = useRef<NodeJS.Timeout>();
const uploadErrorHandlers = useRef<ErrorHandlers>({});
const clearDraft = useCallback(() => {
removeDraft(serverUrl, channelId, rootId);
setCurrentValue('');
}, [serverUrl, channelId, rootId]);
const newUploadError = useCallback((error: React.ReactNode) => {
if (uploadErrorTimeout.current) {
clearTimeout(uploadErrorTimeout.current);
}
setUploadError(error);
uploadErrorTimeout.current = setTimeout(() => {
setUploadError(null);
}, UPLOAD_ERROR_SHOW_INTERVAL);
}, []);
const addFiles = useCallback((newFiles: FileInfo[]) => {
if (!newFiles.length) {
return;
}
if (!canUploadFiles) {
newUploadError(uploadDisabledWarning(intl));
return;
}
const currentFileCount = files?.length || 0;
const availableCount = maxFileCount - currentFileCount;
if (newFiles.length > availableCount) {
newUploadError(fileMaxWarning(intl, maxFileCount));
return;
}
const largeFile = newFiles.find((file) => file.size > maxFileSize);
if (largeFile) {
newUploadError(fileSizeWarning(intl, maxFileSize));
return;
}
addFilesToDraft(serverUrl, channelId, rootId, newFiles);
for (const file of newFiles) {
DraftUploadManager.prepareUpload(serverUrl, file, channelId, rootId);
uploadErrorHandlers.current[file.clientId!] = DraftUploadManager.registerErrorHandler(file.clientId!, newUploadError);
}
newUploadError(null);
}, [intl, newUploadError, maxFileCount, maxFileSize, serverUrl, files?.length, channelId, rootId]);
// This effect mainly handles keeping clean the uploadErrorHandlers, and
// reinstantiate them on component mount and file retry.
useEffect(() => {
let loadingFiles: FileInfo[] = [];
if (files) {
loadingFiles = files.filter((v) => v.clientId && DraftUploadManager.isUploading(v.clientId));
}
for (const key of Object.keys(uploadErrorHandlers.current)) {
if (!loadingFiles.find((v) => v.clientId === key)) {
uploadErrorHandlers.current[key]?.();
delete (uploadErrorHandlers.current[key]);
}
}
for (const file of loadingFiles) {
if (!uploadErrorHandlers.current[file.clientId!]) {
uploadErrorHandlers.current[file.clientId!] = DraftUploadManager.registerErrorHandler(file.clientId!, newUploadError);
}
}
}, [files]);
return (
<SendHandler
testID={testID}
channelId={channelId}
rootId={rootId}
// From draft handler
value={currentValue}
files={files || emptyFileList}
clearDraft={clearDraft}
updateValue={setCurrentValue}
addFiles={addFiles}
uploadFileError={uploadError}
/>
);
}

View file

@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {DEFAULT_SERVER_MAX_FILE_SIZE} from '@constants/post_draft';
import {isMinimumServerVersion} from '@utils/helpers';
import DraftHandler from './draft_handler';
import type {WithDatabaseArgs} from '@typings/database/database';
import type DraftModel from '@typings/database/models/servers/draft';
import type SystemModel from '@typings/database/models/servers/system';
const {SERVER: {SYSTEM, DRAFT}} = MM_TABLES;
type OwnProps = {
channelId: string;
rootId?: string;
}
const enhanced = withObservables([], ({database, channelId, rootId = ''}: WithDatabaseArgs & OwnProps) => {
const draft = database.get<DraftModel>(DRAFT).query(
Q.where('channel_id', channelId),
Q.where('root_id', rootId),
).observeWithColumns(['message', 'files']).pipe(switchMap((v) => of$(v[0])));
const files = draft.pipe(switchMap((d) => of$(d?.files)));
const message = draft.pipe(switchMap((d) => of$(d?.message)));
const config = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap(({value}) => of$(value as ClientConfig)),
);
const license = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(
switchMap(({value}) => of$(value as ClientLicense)),
);
const canUploadFiles = combineLatest([config, license]).pipe(
switchMap(([c, l]) => of$(
c.EnableFileAttachments !== 'false' &&
(l.IsLicensed === 'false' || l.Compliance === 'false' || c.EnableMobileFileUpload !== 'false'),
),
),
);
const maxFileSize = config.pipe(
switchMap((cfg) => of$(parseInt(cfg.MaxFileSize || '0', 10) || DEFAULT_SERVER_MAX_FILE_SIZE)),
);
const maxFileCount = config.pipe(
switchMap((cfg) => of$(isMinimumServerVersion(cfg.Version, 6, 0) ? 10 : 5)),
);
return {
files,
message,
maxFileSize,
maxFileCount,
canUploadFiles,
};
});
export default withDatabase(enhanced(DraftHandler));

View file

@ -0,0 +1,174 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Platform, ScrollView, View} from 'react-native';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import PostInput from '../post_input';
import QuickActions from '../quick_actions';
import SendAction from '../send_action';
import Typing from '../typing';
import Uploads from '../uploads';
type Props = {
testID?: string;
channelId: string;
rootId?: string;
// Cursor Position Handler
updateCursorPosition: (pos: number) => void;
cursorPosition: number;
// Send Handler
sendMessage: () => void;
canSend: boolean;
maxMessageLength: number;
// Draft Handler
files: FileInfo[];
value: string;
uploadFileError: React.ReactNode;
updateValue: (value: string) => void;
addFiles: (files: FileInfo[]) => void;
}
const SAFE_AREA_VIEW_EDGES: Edge[] = ['left', 'right'];
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
actionsContainer: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: Platform.select({
ios: 1,
android: 2,
}),
},
inputContainer: {
flex: 1,
flexDirection: 'column',
},
inputContentContainer: {
alignItems: 'stretch',
paddingTop: Platform.select({
ios: 7,
android: 0,
}),
},
inputWrapper: {
alignItems: 'flex-end',
flexDirection: 'row',
justifyContent: 'center',
paddingBottom: 2,
backgroundColor: theme.centerChannelBg,
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.20),
},
};
});
export default function DraftInput({
testID,
channelId,
files,
maxMessageLength,
rootId = '',
value,
uploadFileError,
sendMessage,
canSend,
updateValue,
addFiles,
updateCursorPosition,
cursorPosition,
}: Props) {
const theme = useTheme();
// const [top, setTop] = useState(0);
// const handleLayout = useCallback((e: LayoutChangeEvent) => {
// setTop(e.nativeEvent.layout.y);
// }, []);
// Render
const postInputTestID = `${testID}.post.input`;
const quickActionsTestID = `${testID}.quick_actions`;
const sendActionTestID = `${testID}.send_action`;
const style = getStyleSheet(theme);
return (
<>
<Typing
channelId={channelId}
rootId={rootId}
/>
{/* {Platform.OS === 'android' &&
<Autocomplete
maxHeight={Math.min(top - AUTOCOMPLETE_MARGIN, DEVICE.AUTOCOMPLETE_MAX_HEIGHT)}
onChangeText={handleInputQuickAction}
rootId={rootId}
channelId={channelId}
offsetY={0}
/>
} */}
<SafeAreaView
edges={SAFE_AREA_VIEW_EDGES}
// onLayout={handleLayout}
style={style.inputWrapper}
testID={testID}
>
<ScrollView
style={style.inputContainer}
contentContainerStyle={style.inputContentContainer}
keyboardShouldPersistTaps={'always'}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
pinchGestureEnabled={false}
overScrollMode={'never'}
disableScrollViewPanResponder={true}
>
<PostInput
testID={postInputTestID}
channelId={channelId}
maxMessageLength={maxMessageLength}
rootId={rootId}
cursorPosition={cursorPosition}
updateCursorPosition={updateCursorPosition}
updateValue={updateValue}
value={value}
addFiles={addFiles}
sendMessage={sendMessage}
/>
<Uploads
files={files}
uploadFileError={uploadFileError}
channelId={channelId}
rootId={rootId}
/>
<View style={style.actionsContainer}>
<QuickActions
testID={quickActionsTestID}
fileCount={files.length}
addFiles={addFiles}
updateValue={updateValue}
value={value}
/>
<SendAction
testID={sendActionTestID}
disabled={!canSend}
sendMessage={sendMessage}
/>
</View>
</ScrollView>
</SafeAreaView>
</>
);
}

View file

@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$, from as from$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {General, Permissions} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {hasPermissionForChannel} from '@utils/role';
import {isSystemAdmin, getUserIdFromChannelName} from '@utils/user';
import PostDraft from './post_draft';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
const {SERVER: {SYSTEM, USER, CHANNEL}} = MM_TABLES;
type OwnProps = {
channelId?: string;
channelIsArchived?: boolean;
}
const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => {
const database = ownProps.database;
const currentUser = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap(({value}) => database.get<UserModel>(USER).findAndObserve(value)),
);
let channelId = of$(ownProps.channelId);
if (!ownProps.channelId) {
channelId = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID).pipe(
switchMap((t) => of$(t.value)),
);
}
const channel = channelId.pipe(
switchMap((id) => database.get<ChannelModel>(CHANNEL).findAndObserve(id!)),
);
const canPost = combineLatest([channel, currentUser]).pipe(switchMap(([c, u]) => from$(hasPermissionForChannel(c, u, Permissions.CREATE_POST, false))));
let channelIsArchived = of$(ownProps.channelIsArchived);
if (!channelIsArchived) {
channelIsArchived = channel.pipe(switchMap((c) => of$(c.deleteAt !== 0)));
}
const experimentalTownSquareIsReadOnly = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap(({value}: {value: ClientConfig}) => of$(value.ExperimentalTownSquareIsReadOnly === 'true')),
);
const channelIsReadOnly = combineLatest([currentUser, channel, experimentalTownSquareIsReadOnly]).pipe(
switchMap(([u, c, readOnly]) => of$(c?.name === General.DEFAULT_CHANNEL && !isSystemAdmin(u.roles) && readOnly)),
);
const deactivatedChannel = combineLatest([currentUser, channel]).pipe(
switchMap(([u, c]) => {
if (c.type !== General.DM_CHANNEL) {
return of$(false);
}
const teammateId = getUserIdFromChannelName(u.id, c.name);
if (teammateId) {
return database.get<UserModel>(USER).findAndObserve(teammateId).pipe(
switchMap((u2) => of$(Boolean(u2.deleteAt))), // eslint-disable-line max-nested-callbacks
);
}
return of$(true);
}),
);
return {
canPost,
channelIsArchived,
channelIsReadOnly,
deactivatedChannel,
};
});
export default withDatabase(enhanced(PostDraft));

View file

@ -0,0 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef} from 'react';
import {DeviceEventEmitter, Platform} from 'react-native';
import {KeyboardTrackingView, KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view';
import {PostDraft as PostDraftConstants, View as ViewConstants} from '@constants';
import {useIsTablet} from '@hooks/device';
import Archived from './archived';
import DraftHandler from './draft_handler';
import ReadOnly from './read_only';
type Props = {
testID?: string;
accessoriesContainerID?: string;
canPost: boolean;
channelId: string;
channelIsArchived?: boolean;
channelIsReadOnly: boolean;
deactivatedChannel: boolean;
rootId?: string;
scrollViewNativeID?: string;
}
export default function PostDraft({
testID,
accessoriesContainerID,
canPost,
channelId,
channelIsArchived,
channelIsReadOnly,
deactivatedChannel,
rootId,
scrollViewNativeID,
}: Props) {
const keyboardTracker = useRef<KeyboardTrackingViewRef>(null);
const resetScrollViewAnimationFrame = useRef<number>();
const isTablet = useIsTablet();
const updateNativeScrollView = useCallback((scrollViewNativeIDToUpdate: string) => {
if (keyboardTracker?.current && scrollViewNativeID === scrollViewNativeIDToUpdate) {
resetScrollViewAnimationFrame.current = requestAnimationFrame(() => {
keyboardTracker.current?.resetScrollView(scrollViewNativeIDToUpdate);
if (resetScrollViewAnimationFrame.current != null) {
cancelAnimationFrame(resetScrollViewAnimationFrame.current);
}
resetScrollViewAnimationFrame.current = undefined;
});
}
}, [scrollViewNativeID]);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(PostDraftConstants.UPDATE_NATIVE_SCROLLVIEW, updateNativeScrollView);
return () => {
listener.remove();
if (resetScrollViewAnimationFrame.current) {
cancelAnimationFrame(resetScrollViewAnimationFrame.current);
}
};
}, [updateNativeScrollView]);
if (channelIsArchived || deactivatedChannel) {
const archivedTestID = `${testID}.archived`;
return (
<Archived
testID={archivedTestID}
deactivated={deactivatedChannel}
/>
);
}
if (channelIsReadOnly || !canPost) {
const readOnlyTestID = `${testID}.read_only`;
return (
<ReadOnly
testID={readOnlyTestID}
/>
);
}
const draftHandler = (
<DraftHandler
testID={testID}
channelId={channelId}
rootId={rootId}
/>
);
if (Platform.OS === 'android') {
return draftHandler;
}
return (
<KeyboardTrackingView
accessoriesContainerID={accessoriesContainerID}
ref={keyboardTracker}
scrollViewNativeID={scrollViewNativeID}
viewInitialOffsetY={isTablet ? ViewConstants.BOTTOM_TAB_HEIGHT : 0}
>
{draftHandler}
</KeyboardTrackingView>
);
}

View file

@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import PostInput from './post_input';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type ChannelInfoModel from '@typings/database/models/servers/channel_info';
import type SystemModel from '@typings/database/models/servers/system';
const {SERVER: {SYSTEM, CHANNEL}} = MM_TABLES;
type OwnProps = {
channelId: string;
rootId?: string;
}
const enhanced = withObservables([], ({database, channelId, rootId}: WithDatabaseArgs & OwnProps) => {
const config = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG);
const timeBetweenUserTypingUpdatesMilliseconds = config.pipe(
switchMap(({value}: {value: ClientConfig}) => of$(parseInt(value.TimeBetweenUserTypingUpdatesMilliseconds, 10))),
);
const enableUserTypingMessage = config.pipe(
switchMap(({value}: {value: ClientConfig}) => of$(value.EnableUserTypingMessages === 'true')),
);
const maxNotificationsPerChannel = config.pipe(
switchMap(({value}: {value: ClientConfig}) => of$(parseInt(value.MaxNotificationsPerChannel, 10))),
);
let channel;
if (rootId) {
channel = database.get<ChannelModel>(CHANNEL).findAndObserve(channelId);
} else {
channel = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID).pipe(
switchMap((t) => database.get<ChannelModel>(CHANNEL).findAndObserve(t.value)),
);
}
const channelDisplayName = channel.pipe(
switchMap((c) => of$(c.displayName)),
);
const channelInfo = channel.pipe(switchMap((c) => c.info.observe()));
const membersInChannel = channelInfo.pipe(
switchMap((i: ChannelInfoModel) => of$(i.memberCount)),
);
return {
timeBetweenUserTypingUpdatesMilliseconds,
enableUserTypingMessage,
maxNotificationsPerChannel,
channelDisplayName,
membersInChannel,
};
});
export default withDatabase(enhanced(PostInput));

View file

@ -0,0 +1,319 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useManagedConfig} from '@mattermost/react-native-emm';
import PasteableTextInput, {PastedFile, PasteInputRef} from '@mattermost/react-native-paste-input';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {
Alert, AppState, AppStateStatus, EmitterSubscription, Keyboard,
KeyboardTypeOptions, NativeSyntheticEvent, Platform, TextInputSelectionChangeEventData,
} from 'react-native';
import HWKeyboardEvent from 'react-native-hw-keyboard-event';
import {updateDraftMessage} from '@actions/local/draft';
import {userTyping} from '@actions/websocket/users';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {t} from '@i18n';
import {extractFileInfo} from '@utils/file';
import {switchKeyboardForCodeBlocks} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme, getKeyboardAppearanceFromTheme} from '@utils/theme';
const HW_EVENT_IN_SCREEN = ['Channel', 'Thread'];
type Props = {
testID?: string;
channelDisplayName?: string;
channelId: string;
maxMessageLength: number;
rootId: string;
timeBetweenUserTypingUpdatesMilliseconds: number;
maxNotificationsPerChannel: number;
enableUserTypingMessage: boolean;
membersInChannel: number;
value: string;
updateValue: (value: string) => void;
addFiles: (files: ExtractedFileInfo[]) => void;
cursorPosition: number;
updateCursorPosition: (pos: number) => void;
sendMessage: () => void;
}
const showPasteFilesErrorDialog = (intl: IntlShape) => {
Alert.alert(
intl.formatMessage({
id: 'mobile.files_paste.error_title',
defaultMessage: 'Paste failed',
}),
intl.formatMessage({
id: 'mobile.files_paste.error_description',
defaultMessage: 'An error occurred while pasting the file(s). Please try again.',
}),
[
{
text: intl.formatMessage({
id: 'mobile.files_paste.error_dismiss',
defaultMessage: 'Dismiss',
}),
},
],
);
};
const getPlaceHolder = (rootId?: string) => {
let placeholder;
if (rootId) {
placeholder = {id: t('create_comment.addComment'), defaultMessage: 'Add a comment...'};
} else {
placeholder = {id: t('create_post.write'), defaultMessage: 'Write to {channelDisplayName}'};
}
return placeholder;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
input: {
color: theme.centerChannelColor,
fontSize: 15,
lineHeight: 20,
paddingHorizontal: 12,
paddingTop: Platform.select({
ios: 6,
android: 8,
}),
paddingBottom: Platform.select({
ios: 6,
android: 2,
}),
minHeight: 30,
},
}));
export default function PostInput({
testID,
channelDisplayName,
channelId,
maxMessageLength,
rootId,
timeBetweenUserTypingUpdatesMilliseconds,
maxNotificationsPerChannel,
enableUserTypingMessage,
membersInChannel,
value,
updateValue,
addFiles,
cursorPosition,
updateCursorPosition,
sendMessage,
}: Props) {
const intl = useIntl();
const isTablet = useIsTablet();
const theme = useTheme();
const style = getStyleSheet(theme);
const serverUrl = useServerUrl();
const managedConfig = useManagedConfig();
const lastTypingEventSent = useRef(0);
const input = useRef<PasteInputRef>();
const lastNativeValue = useRef('');
const previousAppState = useRef(AppState.currentState);
const [keyboardType, setKeyboardType] = useState<KeyboardTypeOptions>('default');
const [longMessageAlertShown, setLongMessageAlertShown] = useState(false);
const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true';
const maxHeight = isTablet ? 150 : 88;
const pasteInputStyle = useMemo(() => {
return {...style.input, maxHeight};
}, [maxHeight]);
const blur = () => {
input.current?.blur();
};
const handleAndroidKeyboard = () => {
blur();
};
const onBlur = useCallback(() => {
updateDraftMessage(serverUrl, channelId, rootId, value);
}, [channelId, rootId, value]);
const checkMessageLength = useCallback((newValue: string) => {
const valueLength = newValue.trim().length;
if (valueLength > maxMessageLength) {
// Check if component is already aware message is too long
if (!longMessageAlertShown) {
Alert.alert(
intl.formatMessage({
id: 'mobile.message_length.title',
defaultMessage: 'Message Length',
}),
intl.formatMessage({
id: 'mobile.message_length.message',
defaultMessage: 'Your current message is too long. Current character count: {count}/{max}',
}, {
max: maxMessageLength,
count: valueLength,
}),
);
setLongMessageAlertShown(true);
}
} else if (longMessageAlertShown) {
setLongMessageAlertShown(false);
}
}, [intl, longMessageAlertShown, maxMessageLength]);
const handlePostDraftSelectionChanged = useCallback((event: NativeSyntheticEvent<TextInputSelectionChangeEventData> | null, fromHandleTextChange = false) => {
const cp = fromHandleTextChange ? cursorPosition : event!.nativeEvent.selection.end;
if (Platform.OS === 'ios') {
const newKeyboardType = switchKeyboardForCodeBlocks(value, cp);
setKeyboardType(newKeyboardType);
}
updateCursorPosition(cp);
}, [updateCursorPosition, cursorPosition]);
const handleTextChange = useCallback((newValue: string) => {
updateValue(newValue);
lastNativeValue.current = newValue;
checkMessageLength(newValue);
// Workaround to avoid iOS emdash autocorrect in Code Blocks
if (Platform.OS === 'ios') {
handlePostDraftSelectionChanged(null, true);
}
if (
newValue &&
lastTypingEventSent.current + timeBetweenUserTypingUpdatesMilliseconds < Date.now() &&
membersInChannel < maxNotificationsPerChannel &&
enableUserTypingMessage
) {
userTyping(serverUrl, channelId, rootId);
lastTypingEventSent.current = Date.now();
}
}, [
updateValue,
checkMessageLength,
handlePostDraftSelectionChanged,
timeBetweenUserTypingUpdatesMilliseconds,
channelId,
rootId,
(membersInChannel < maxNotificationsPerChannel) && enableUserTypingMessage,
]);
const onPaste = useCallback(async (error: string | null | undefined, files: PastedFile[]) => {
if (error) {
showPasteFilesErrorDialog(intl);
}
addFiles(await extractFileInfo(files));
}, [addFiles, intl]);
const handleHardwareEnterPress = useCallback((keyEvent: {pressedKey: string}) => {
if (HW_EVENT_IN_SCREEN.includes(rootId ? Screens.THREAD : Screens.CHANNEL)) {
switch (keyEvent.pressedKey) {
case 'enter':
sendMessage();
break;
case 'shift-enter':
updateValue(value.substring(0, cursorPosition) + '\n' + value.substring(cursorPosition));
updateCursorPosition(cursorPosition + 1);
break;
}
}
}, [sendMessage, updateValue, value, cursorPosition]);
const onAppStateChange = useCallback((appState: AppStateStatus) => {
if (appState !== 'active' && previousAppState.current === 'active') {
updateDraftMessage(serverUrl, channelId, rootId, value);
}
previousAppState.current = appState;
}, [serverUrl, channelId, rootId, value]);
useEffect(() => {
let keyboardListener: EmitterSubscription | undefined;
if (Platform.OS === 'android') {
keyboardListener = Keyboard.addListener('keyboardDidHide', handleAndroidKeyboard);
}
return (() => {
keyboardListener?.remove();
});
}, []);
useEffect(() => {
const listener = AppState.addEventListener('change', onAppStateChange);
return () => {
listener.remove();
};
}, [onAppStateChange]);
useEffect(() => {
if (value !== lastNativeValue.current) {
// May change when we implement Fabric
input.current?.setNativeProps({
text: value,
selection: {start: cursorPosition},
});
lastNativeValue.current = value;
}
}, [value]);
useEffect(() => {
HWKeyboardEvent.onHWKeyPressed(handleHardwareEnterPress);
return () => {
HWKeyboardEvent.removeOnHWKeyPressed();
};
}, [handleHardwareEnterPress]);
useDidUpdate(() => {
if (!value) {
if (Platform.OS === 'android') {
// Fixes the issue where Android predictive text would prepend suggestions to the post draft when messages
// are typed successively without blurring the input
setKeyboardType('email-address');
}
}
}, [value]);
useDidUpdate(() => {
if (Platform.OS === 'android' && keyboardType === 'email-address') {
setKeyboardType('default');
}
}, [keyboardType]);
return (
<PasteableTextInput
allowFontScaling={true}
testID={testID}
ref={input}
disableCopyPaste={disableCopyAndPaste}
style={pasteInputStyle}
onChangeText={handleTextChange}
onSelectionChange={handlePostDraftSelectionChanged}
placeholder={intl.formatMessage(getPlaceHolder(rootId), {channelDisplayName})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
multiline={true}
onBlur={onBlur}
blurOnSubmit={false}
underlineColorAndroid='transparent'
keyboardType={keyboardType}
onPaste={onPaste}
disableFullscreenUI={true}
textContentType='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
/>
);
}

View file

@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View, DeviceEventEmitter} from 'react-native';
import {CameraOptions} from 'react-native-image-picker';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Navigation} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
onPress: (options: CameraOptions) => void;
}
const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
center: {
alignItems: 'center',
},
container: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 200,
paddingVertical: 10,
},
flex: {
flex: 1,
},
options: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
justifyContent: 'space-evenly',
width: '100%',
marginBottom: 50,
},
optionContainer: {
alignItems: 'flex-start',
},
title: {
color: theme.centerChannelColor,
fontSize: 18,
fontWeight: 'bold',
},
text: {
color: theme.centerChannelColor,
fontSize: 15,
},
}));
const CameraType = ({onPress}: Props) => {
const theme = useTheme();
const isTablet = useIsTablet();
const style = getStyle(theme);
const onPhoto = () => {
const options: CameraOptions = {
quality: 0.8,
mediaType: 'photo',
saveToPhotos: true,
};
onPress(options);
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
};
const onVideo = () => {
const options: CameraOptions = {
videoQuality: 'high',
mediaType: 'video',
saveToPhotos: true,
};
onPress(options);
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
};
return (
<View style={style.container}>
{!isTablet &&
<FormattedText
id='camera_type.title'
defaultMessage='Choose an action'
style={style.title}
/>
}
<View style={style.options}>
<View style={style.flex}>
<TouchableWithFeedback
onPress={onPhoto}
testID='camera_type.photo'
>
<View style={style.center}>
<CompassIcon
color={theme.centerChannelColor}
name='camera-outline'
size={38}
/>
<FormattedText
id='camera_type.photo.option'
defaultMessage='Capture Photo'
style={style.text}
/>
</View>
</TouchableWithFeedback>
</View>
<View style={style.flex}>
<TouchableWithFeedback
onPress={onVideo}
testID='camera_type.video'
>
<View style={style.center}>
<CompassIcon
color={theme.centerChannelColor}
name='video-outline'
size={38}
/>
<FormattedText
id='camera_type.video.option'
defaultMessage='Record Video'
style={style.text}
/>
</View>
</TouchableWithFeedback>
</View>
</View>
</View>
);
};
export default CameraType;

View file

@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import {CameraOptions} from 'react-native-image-picker';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {bottomSheet} from '@screens/navigation';
import {fileMaxWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {changeOpacity} from '@utils/theme';
import CameraType from './camera_type';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function CameraQuickAction({
disabled,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID,
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const handleButtonPress = useCallback((options: CameraOptions) => {
const picker = new PickerUtil(intl,
onUploadFiles);
picker.attachFileFromCamera(options);
}, [intl, onUploadFiles]);
const renderContent = useCallback(() => {
return (
<CameraType
onPress={handleButtonPress}
/>
);
}, [handleButtonPress]);
const openSelectorModal = useCallback(() => {
if (maxFilesReached) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return;
}
bottomSheet({
title: intl.formatMessage({id: 'camera_type.title', defaultMessage: 'Choose an action'}),
renderContent,
snapPoints: [200, 10],
theme,
closeButtonId: 'camera-close-id',
});
}, [intl, theme, renderContent, maxFilesReached, maxFileCount]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={openSelectorModal}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='camera-outline'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View file

@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {fileMaxWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {changeOpacity} from '@utils/theme';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function FileQuickAction({
disabled,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID = '',
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const handleButtonPress = useCallback(() => {
if (maxFilesReached) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return;
}
const picker = new PickerUtil(intl,
onUploadFiles);
picker.attachFileFromFiles();
}, [onUploadFiles]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={handleButtonPress}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='file-generic-outline'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View file

@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {fileMaxWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {changeOpacity} from '@utils/theme';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function ImageQuickAction({
disabled,
fileCount = 0,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID = '',
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const handleButtonPress = useCallback(() => {
if (maxFilesReached) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return;
}
const picker = new PickerUtil(intl,
onUploadFiles);
picker.attachFileFromPhotoGallery(maxFileCount - fileCount);
}, [onUploadFiles, fileCount, maxFileCount]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={handleButtonPress}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='image-outline'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View file

@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {isMinimumServerVersion} from '@utils/helpers';
import QuickActions from './quick_actions';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
const {SERVER: {SYSTEM}} = MM_TABLES;
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const config = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap(({value}) => of$(value as ClientConfig)),
);
const license = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(
switchMap(({value}) => of$(value as ClientLicense)),
);
const canUploadFiles = combineLatest([config, license]).pipe(
switchMap(([c, l]) => of$(
c.EnableFileAttachments !== 'false' &&
(l.IsLicensed === 'false' || l.Compliance === 'false' || c.EnableMobileFileUpload !== 'false'),
),
),
);
const maxFileCount = config.pipe(
switchMap((cfg) => of$(isMinimumServerVersion(cfg.Version, 6, 0) ? 10 : 5)),
);
return {
canUploadFiles,
maxFileCount,
};
});
export default withDatabase(enhanced(QuickActions));

View file

@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
testID?: string;
disabled?: boolean;
inputType: 'at' | 'slash';
onTextChange: (value: string) => void;
value: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
disabled: {
tintColor: changeOpacity(theme.centerChannelColor, 0.16),
},
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
};
});
export default function InputQuickAction({
testID,
disabled,
inputType,
onTextChange,
value,
}: Props) {
const theme = useTheme();
const onPress = useCallback(() => {
let newValue = '/';
if (inputType === 'at') {
newValue = `${value}@`;
}
onTextChange(newValue);
}, [value, inputType]);
const actionTestID = disabled ?
`${testID}.disabled` :
testID;
const style = getStyleSheet(theme);
const iconName = inputType === 'at' ? inputType : 'slash-forward-box-outline';
const iconColor = disabled ?
changeOpacity(theme.centerChannelColor, 0.16) :
changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={onPress}
style={style.icon}
type={'opacity'}
>
<CompassIcon
name={iconName}
color={iconColor}
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View file

@ -0,0 +1,101 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Platform, StyleSheet, View} from 'react-native';
import CameraAction from './camera_quick_action';
import FileAction from './file_quick_action';
import ImageAction from './image_quick_action';
import InputAction from './input_quick_action';
type Props = {
testID?: string;
canUploadFiles: boolean;
fileCount: number;
maxFileCount: number;
// Draft Handler
value: string;
updateValue: (value: string) => void;
addFiles: (file: FileInfo[]) => void;
}
const style = StyleSheet.create({
container: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: Platform.select({
ios: 1,
android: 2,
}),
},
quickActionsContainer: {
display: 'flex',
flexDirection: 'row',
height: 44,
},
});
export default function QuickActions({
testID,
canUploadFiles,
value,
fileCount,
maxFileCount,
updateValue,
addFiles,
}: Props) {
const atDisabled = value[value.length - 1] === '@';
const slashDisabled = value.length > 0;
const atInputActionTestID = `${testID}.at_input_action`;
const slashInputActionTestID = `${testID}.slash_input_action`;
const fileActionTestID = `${testID}.file_action`;
const imageActionTestID = `${testID}.image_action`;
const cameraActionTestID = `${testID}.camera_action`;
const uploadProps = {
disabled: !canUploadFiles,
fileCount,
maxFileCount,
maxFilesReached: fileCount >= maxFileCount,
onUploadFiles: addFiles,
};
return (
<View
testID={testID}
style={style.quickActionsContainer}
>
<InputAction
testID={atInputActionTestID}
disabled={atDisabled}
inputType='at'
onTextChange={updateValue}
value={value}
/>
<InputAction
testID={slashInputActionTestID}
disabled={slashDisabled}
inputType='slash'
onTextChange={updateValue}
value={''} // Only enabled when value == ''
/>
<FileAction
testID={fileActionTestID}
{...uploadProps}
/>
<ImageAction
testID={imageActionTestID}
{...uploadProps}
/>
<CameraAction
testID={cameraActionTestID}
{...uploadProps}
/>
</View>
);
}

View file

@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
interface ReadOnlyProps {
testID?: string;
}
const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
background: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
},
container: {
alignItems: 'center',
borderTopColor: changeOpacity(theme.centerChannelColor, 0.20),
borderTopWidth: 1,
flexDirection: 'row',
height: 50,
paddingHorizontal: 12,
},
icon: {
fontSize: 20,
lineHeight: 22,
opacity: 0.56,
},
text: {
color: theme.centerChannelColor,
fontSize: 15,
lineHeight: 20,
marginLeft: 9,
opacity: 0.56,
},
}));
const safeAreaEdges = ['bottom' as const];
const ReadOnlyChannnel = ({testID}: ReadOnlyProps) => {
const theme = useTheme();
const style = getStyle(theme);
return (
<SafeAreaView
edges={safeAreaEdges}
style={style.background}
>
<View
testID={testID}
style={style.container}
>
<CompassIcon
name='glasses'
style={style.icon}
color={theme.centerChannelColor}
/>
<FormattedText
id='mobile.create_post.read_only'
defaultMessage='This channel is read-only.'
style={style.text}
/>
</View>
</SafeAreaView>
);
};
export default ReadOnlyChannnel;

View file

@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
testID: string;
disabled: boolean;
sendMessage: () => void;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
disableButton: {
backgroundColor: changeOpacity(theme.buttonBg, 0.3),
},
sendButtonContainer: {
justifyContent: 'flex-end',
paddingRight: 8,
},
sendButton: {
backgroundColor: theme.buttonBg,
borderRadius: 4,
height: 32,
width: 80,
alignItems: 'center',
justifyContent: 'center',
},
};
});
function SendButton({
testID,
disabled,
sendMessage,
}: Props) {
const theme = useTheme();
const sendButtonTestID = `${testID}.send.button`;
const style = getStyleSheet(theme);
const viewStyle = useMemo(() => {
if (disabled) {
return [style.sendButton, style.disableButton];
}
return style.sendButton;
}, [disabled, style]);
const buttonColor = disabled ? changeOpacity(theme.buttonColor, 0.5) : theme.buttonColor;
return (
<TouchableWithFeedback
testID={sendButtonTestID}
onPress={sendMessage}
style={style.sendButtonContainer}
type={'opacity'}
disabled={disabled}
>
<View style={viewStyle}>
<CompassIcon
name='send'
size={24}
color={buttonColor}
/>
</View>
</TouchableWithFeedback>
);
}
export default SendButton;

View file

@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$, from as from$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {General, Permissions} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {hasPermissionForChannel} from '@utils/role';
import SendHandler from './send_handler';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type ChannelInfoModel from '@typings/database/models/servers/channel_info';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
import type GroupModel from '@typings/database/models/servers/group';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
const {SERVER: {SYSTEM, USER, CHANNEL, GROUP, GROUPS_TEAM, GROUPS_CHANNEL, CUSTOM_EMOJI}} = MM_TABLES;
type OwnProps = {
rootId: string;
channelId: string;
channelIsArchived?: boolean;
}
const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => {
const database = ownProps.database;
const {rootId, channelId} = ownProps;
let channel;
if (rootId) {
channel = database.get<ChannelModel>(CHANNEL).findAndObserve(channelId);
} else {
channel = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID).pipe(
switchMap((t) => database.get<ChannelModel>(CHANNEL).findAndObserve(t.value)),
);
}
const currentUserId = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap(({value}) => of$(value)),
);
const currentUser = currentUserId.pipe(
switchMap((id) => database.get<UserModel>(USER).findAndObserve(id)),
);
const userIsOutOfOffice = currentUser.pipe(
switchMap((u) => of$(u.status === General.OUT_OF_OFFICE)),
);
const config = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap(({value}) => of$(value as ClientConfig)),
);
const enableConfirmNotificationsToChannel = config.pipe(
switchMap((cfg) => of$(Boolean(cfg.EnableConfirmNotificationsToChannel === 'true'))),
);
const isTimezoneEnabled = config.pipe(
switchMap((cfg) => of$(Boolean(cfg.ExperimentalTimezone === 'true'))),
);
const maxMessageLength = config.pipe(
switchMap((cfg) => of$(parseInt(cfg.MaxPostSize || '0', 10) || MAX_MESSAGE_LENGTH_FALLBACK)),
);
const useChannelMentions = combineLatest([channel, currentUser]).pipe(
switchMap(([c, u]) => {
if (!c) {
return of$(true);
}
return from$(hasPermissionForChannel(c, u, Permissions.USE_CHANNEL_MENTIONS, false));
}),
);
const license = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(
switchMap(({value}) => of$(value as ClientLicense)),
);
const useGroupMentions = combineLatest([channel, currentUser, license]).pipe(
switchMap(([c, u, l]) => {
if (!c || l?.IsLicensed !== 'true') {
return of$(false);
}
return from$(hasPermissionForChannel(c, u, Permissions.USE_GROUP_MENTIONS, true));
}),
);
const groupsWithAllowReference = channel.pipe(switchMap(
(c) => database.get<GroupModel>(GROUP).query(
Q.experimentalJoinTables([GROUPS_TEAM, GROUPS_CHANNEL]),
Q.or(Q.on(GROUPS_TEAM, 'team_id', c.teamId), Q.on(GROUPS_CHANNEL, 'channel_id', c.id)),
).observeWithColumns(['name'])),
);
const channelInfo = channel.pipe(switchMap((c) => c.info.observe()));
const membersCount = channelInfo.pipe(
switchMap((i: ChannelInfoModel) => of$(i.memberCount)),
);
const customEmojis = database.get<CustomEmojiModel>(CUSTOM_EMOJI).query().observe();
return {
currentUserId,
enableConfirmNotificationsToChannel,
isTimezoneEnabled,
maxMessageLength,
membersCount,
userIsOutOfOffice,
useChannelMentions,
useGroupMentions,
groupsWithAllowReference,
customEmojis,
};
});
export default withDatabase(enhanced(SendHandler));

View file

@ -0,0 +1,286 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter} from 'react-native';
import {getChannelMemberCountsByGroup, getChannelTimezones} from '@actions/remote/channel';
import {executeCommand, handleGotoLocation} from '@actions/remote/command';
import {createPost} from '@actions/remote/post';
import {handleReactionToLatestPost} from '@actions/remote/reactions';
import {setStatus} from '@actions/remote/user';
import {Events, Screens} from '@constants';
import {NOTIFY_ALL_MEMBERS} from '@constants/post_draft';
import {useServerUrl} from '@context/server';
import DraftUploadManager from '@init/draft_upload_manager';
import * as DraftUtils from '@utils/draft';
import {isReactionMatch} from '@utils/emoji/helpers';
import {preventDoubleTap} from '@utils/tap';
import {confirmOutOfOfficeDisabled} from '@utils/user';
import CursorPositionHandler from '../cursor_position_handler';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
import type GroupModel from '@typings/database/models/servers/group';
type Props = {
testID?: string;
channelId: string;
rootId: string;
// From database
currentUserId: string;
enableConfirmNotificationsToChannel?: boolean;
isTimezoneEnabled: boolean;
maxMessageLength: number;
membersCount?: number;
useChannelMentions: boolean;
userIsOutOfOffice: boolean;
useGroupMentions: boolean;
groupsWithAllowReference: GroupModel[];
customEmojis: CustomEmojiModel[];
// DRAFT Handler
value: string;
files: FileInfo[];
clearDraft: () => void;
updateValue: (message: string) => void;
addFiles: (file: FileInfo[]) => void;
uploadFileError: React.ReactNode;
}
export default function SendHandler({
testID,
channelId,
currentUserId,
enableConfirmNotificationsToChannel,
files,
isTimezoneEnabled,
maxMessageLength,
membersCount = 0,
rootId,
useChannelMentions,
userIsOutOfOffice,
customEmojis,
value,
useGroupMentions,
groupsWithAllowReference,
clearDraft,
updateValue,
addFiles,
uploadFileError,
}: Props) {
const intl = useIntl();
const serverUrl = useServerUrl();
const [channelTimezoneCount, setChannelTimezoneCount] = useState(0);
const [sendingMessage, setSendingMessage] = useState(false);
const [channelMemberCountsByGroup, setChannelMemberCountsByGroup] = useState<ChannelMemberCountByGroup[]>([]);
const canSend = useCallback(() => {
if (sendingMessage) {
return false;
}
const messageLength = value.trim().length;
if (messageLength > maxMessageLength) {
return false;
}
if (files.length) {
const loadingComplete = !files.some((file) => DraftUploadManager.isUploading(file.clientId!));
return loadingComplete;
}
return messageLength > 0;
}, [sendingMessage, value, files, maxMessageLength]);
const handleReaction = useCallback((emoji: string, add: boolean) => {
handleReactionToLatestPost(serverUrl, emoji, add, rootId);
clearDraft();
setSendingMessage(false);
}, [serverUrl, rootId, clearDraft]);
const doSubmitMessage = useCallback(() => {
const postFiles = files.filter((f) => !f.failed);
const post = {
user_id: currentUserId,
channel_id: channelId,
root_id: rootId,
message: value,
};
createPost(serverUrl, post, postFiles);
clearDraft();
setSendingMessage(false);
DeviceEventEmitter.emit(Events.POST_LIST_SCROLL_TO_BOTTOM, rootId ? Screens.THREAD : Screens.CHANNEL);
}, [files, currentUserId, channelId, rootId, value, clearDraft]);
const showSendToAllOrChannelOrHereAlert = useCallback((calculatedMembersCount: number, atHere: boolean) => {
const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(intl, calculatedMembersCount, Boolean(isTimezoneEnabled), channelTimezoneCount, atHere);
const cancel = () => {
setSendingMessage(false);
};
DraftUtils.alertChannelWideMention(intl, notifyAllMessage, doSubmitMessage, cancel);
}, [intl, isTimezoneEnabled, channelTimezoneCount, doSubmitMessage]);
const showSendToGroupsAlert = useCallback((groupMentions: string[], memberNotifyCount: number, calculatedChannelTimezoneCount: number) => {
const notifyAllMessage = DraftUtils.buildGroupMentionsMessage(intl, groupMentions, memberNotifyCount, calculatedChannelTimezoneCount);
const cancel = () => {
setSendingMessage(false);
};
DraftUtils.alertSendToGroups(intl, notifyAllMessage, doSubmitMessage, cancel);
}, [intl, doSubmitMessage]);
const sendCommand = useCallback(async () => {
const status = DraftUtils.getStatusFromSlashCommand(value);
if (userIsOutOfOffice && status) {
const updateStatus = (newStatus: string) => {
setStatus(serverUrl, {
status: newStatus,
last_activity_at: Date.now(),
manual: true,
user_id: currentUserId,
});
};
confirmOutOfOfficeDisabled(intl, status, updateStatus);
setSendingMessage(false);
return;
}
const {data, error} = await executeCommand(serverUrl, intl, value, channelId, rootId);
setSendingMessage(false);
if (error) {
const errorMessage = typeof (error) === 'string' ? error : error.message;
DraftUtils.alertSlashCommandFailed(intl, errorMessage);
return;
}
clearDraft();
// TODO Apps related https://mattermost.atlassian.net/browse/MM-41233
// if (data?.form) {
// showAppForm(data.form, data.call, theme);
// }
if (data?.goto_location) {
handleGotoLocation(serverUrl, intl, data.goto_location);
}
}, [userIsOutOfOffice, currentUserId, intl, value, serverUrl, channelId, rootId]);
const sendMessage = useCallback(() => {
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
const notificationsToGroups = enableConfirmNotificationsToChannel && useGroupMentions;
const toAllOrChannel = DraftUtils.textContainsAtAllAtChannel(value);
const toHere = DraftUtils.textContainsAtHere(value);
const groupMentions = (!toAllOrChannel && !toHere && notificationsToGroups) ? DraftUtils.groupsMentionedInText(groupsWithAllowReference, value) : [];
if (value.indexOf('/') === 0) {
sendCommand();
} else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && (toAllOrChannel || toHere)) {
showSendToAllOrChannelOrHereAlert(membersCount, toHere && !toAllOrChannel);
} else if (groupMentions.length > 0) {
const {
groupMentionsSet,
memberNotifyCount,
channelTimezoneCount: calculatedChannelTimezoneCount,
} = DraftUtils.mapGroupMentions(channelMemberCountsByGroup, groupMentions);
if (memberNotifyCount > 0) {
showSendToGroupsAlert(Array.from(groupMentionsSet), memberNotifyCount, calculatedChannelTimezoneCount);
} else {
doSubmitMessage();
}
} else {
doSubmitMessage();
}
}, [
enableConfirmNotificationsToChannel,
useChannelMentions,
useGroupMentions,
value,
groupsWithAllowReference,
channelTimezoneCount,
channelMemberCountsByGroup,
sendCommand,
showSendToAllOrChannelOrHereAlert,
showSendToGroupsAlert,
doSubmitMessage,
]);
const handleSendMessage = useCallback(preventDoubleTap(() => {
if (!canSend()) {
return;
}
setSendingMessage(true);
const match = isReactionMatch(value, customEmojis);
if (match && !files.length) {
handleReaction(match.emoji, match.add);
return;
}
const hasFailedAttachments = files.some((f) => f.failed);
if (hasFailedAttachments) {
const cancel = () => {
setSendingMessage(false);
};
const accept = () => {
// Files are filtered on doSubmitMessage
sendMessage();
};
DraftUtils.alertAttachmentFail(intl, accept, cancel);
} else {
sendMessage();
}
}), [canSend, value, handleReaction, files, sendMessage, customEmojis]);
useEffect(() => {
if (useGroupMentions) {
getChannelMemberCountsByGroup(serverUrl, channelId, isTimezoneEnabled).then((resp) => {
if (resp.error) {
return;
}
const received = resp.channelMemberCountsByGroup || [];
if (received.length || channelMemberCountsByGroup.length) {
setChannelMemberCountsByGroup(received);
}
});
}
}, [useGroupMentions, channelId, isTimezoneEnabled, channelMemberCountsByGroup.length]);
useEffect(() => {
getChannelTimezones(serverUrl, channelId).then(({channelTimezones}) => {
setChannelTimezoneCount(channelTimezones?.length || 0);
});
}, [serverUrl, channelId]);
return (
<CursorPositionHandler
testID={testID}
channelId={channelId}
rootId={rootId}
// From draft handler
value={value}
files={files}
clearDraft={clearDraft}
updateValue={updateValue}
addFiles={addFiles}
uploadFileError={uploadFileError}
// From send handler
sendMessage={handleSendMessage}
canSend={canSend()}
maxMessageLength={maxMessageLength}
/>
);
}

View file

@ -0,0 +1,157 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {
DeviceEventEmitter,
Platform,
Text,
} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import FormattedText from '@components/formatted_text';
import {Events} from '@constants';
import {TYPING_HEIGHT} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
channelId: string;
rootId: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
typing: {
position: 'absolute',
paddingLeft: 10,
paddingTop: 3,
fontSize: 11,
...Platform.select({
android: {
marginBottom: 5,
},
ios: {
marginBottom: 2,
},
}),
color: theme.centerChannelColor,
backgroundColor: 'transparent',
},
};
});
export default function Typing({
channelId,
rootId,
}: Props) {
const typingHeight = useSharedValue(0);
const typing = useRef<Array<{id: string; now: number; username: string}>>([]);
const [refresh, setRefresh] = useState(0);
const theme = useTheme();
const style = getStyleSheet(theme);
// This moves the list of post up. This may be rethought by UX in https://mattermost.atlassian.net/browse/MM-39681
const typingAnimatedStyle = useAnimatedStyle(() => {
return {
height: withTiming(typingHeight.value),
};
});
const onUserStartTyping = useCallback((msg: any) => {
if (channelId !== msg.channelId) {
return;
}
const msgRootId = msg.parentId || '';
if (rootId !== msgRootId) {
return;
}
typing.current = typing.current.filter(({id}) => id !== msg.userId);
typing.current.push({id: msg.userId, now: msg.now, username: msg.username});
setRefresh(Date.now());
}, [channelId, rootId]);
const onUserStopTyping = useCallback((msg: any) => {
if (channelId !== msg.channelId) {
return;
}
const msgRootId = msg.parentId || '';
if (rootId !== msgRootId) {
return;
}
typing.current = typing.current.filter(({id, now}) => id !== msg.userId && now !== msg.now);
setRefresh(Date.now());
}, []);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.USER_TYPING, onUserStartTyping);
return () => {
listener.remove();
};
}, [onUserStartTyping]);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.USER_STOP_TYPING, onUserStopTyping);
return () => {
listener.remove();
};
}, [onUserStopTyping]);
useEffect(() => {
typingHeight.value = typing.current.length ? TYPING_HEIGHT : 0;
}, [refresh]);
const renderTyping = () => {
const nextTyping = typing.current.map(({username}) => username);
// Max three names
nextTyping.splice(3);
const numUsers = nextTyping.length;
switch (numUsers) {
case 0:
return null;
case 1:
return (
<FormattedText
id='msg_typing.isTyping'
defaultMessage='{user} is typing...'
values={{
user: nextTyping[0],
}}
/>
);
default: {
const last = nextTyping.pop();
return (
<FormattedText
id='msg_typing.areTyping'
defaultMessage='{users} and {last} are typing...'
values={{
users: (nextTyping.join(', ')),
last,
}}
/>
);
}
}
};
return (
<Animated.View style={typingAnimatedStyle}>
<Text
style={style.typing}
ellipsizeMode='tail'
numberOfLines={1}
>
{renderTyping()}
</Text>
</Animated.View>
);
}

View file

@ -0,0 +1,163 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import {
ScrollView,
Text,
View,
Platform,
} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {useTheme} from '@context/theme';
import DraftUploadManager from '@init/draft_upload_manager';
import {openGalleryAtIndex} from '@utils/gallery';
import {makeStyleSheetFromTheme} from '@utils/theme';
import UploadItem from './upload_item';
const CONTAINER_HEIGHT_MAX = 67;
const CONATINER_HEIGHT_MIN = 0;
const ERROR_HEIGHT_MAX = 20;
const ERROR_HEIGHT_MIN = 0;
type Props = {
files: FileInfo[];
uploadFileError: React.ReactNode;
channelId: string;
rootId: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
previewContainer: {
display: 'flex',
flexDirection: 'column',
},
fileContainer: {
display: 'flex',
flexDirection: 'row',
height: 0,
},
errorContainer: {
height: 0,
},
errorTextContainer: {
marginTop: Platform.select({
ios: 4,
android: 2,
}),
marginHorizontal: 12,
flex: 1,
},
scrollView: {
flex: 1,
},
scrollViewContent: {
alignItems: 'flex-end',
paddingRight: 12,
},
warning: {
color: theme.errorTextColor,
flex: 1,
flexWrap: 'wrap',
},
};
});
export default function Uploads({
files,
uploadFileError,
channelId,
rootId,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
const errorHeight = useSharedValue(ERROR_HEIGHT_MIN);
const containerHeight = useSharedValue(CONTAINER_HEIGHT_MAX);
const errorAnimatedStyle = useAnimatedStyle(() => {
return {
height: withTiming(errorHeight.value),
};
});
const containerAnimatedStyle = useAnimatedStyle(() => {
return {
height: withTiming(containerHeight.value),
};
});
const fileContainerStyle = {
paddingBottom: files.length ? 5 : 0,
};
useEffect(() => {
if (uploadFileError) {
errorHeight.value = ERROR_HEIGHT_MAX;
} else {
errorHeight.value = ERROR_HEIGHT_MIN;
}
}, [uploadFileError]);
useEffect(() => {
if (files.length) {
containerHeight.value = CONTAINER_HEIGHT_MAX;
return;
}
containerHeight.value = CONATINER_HEIGHT_MIN;
}, [files.length > 0]);
const openGallery = useCallback((file: FileInfo) => {
const galleryFiles = files.filter((f) => !f.failed && !DraftUploadManager.isUploading(f.clientId!));
const index = galleryFiles.indexOf(file);
openGalleryAtIndex(index, galleryFiles);
}, [files]);
const buildFilePreviews = () => {
return files.map((file) => {
return (
<UploadItem
key={file.clientId}
file={file}
openGallery={openGallery}
channelId={channelId}
rootId={rootId}
/>
);
});
};
return (
<View style={style.previewContainer}>
<Animated.View
style={[style.fileContainer, fileContainerStyle, containerAnimatedStyle]}
>
<ScrollView
horizontal={true}
style={style.scrollView}
contentContainerStyle={style.scrollViewContent}
keyboardShouldPersistTaps={'handled'}
>
{buildFilePreviews()}
</ScrollView>
</Animated.View>
<Animated.View
style={[style.errorContainer, errorAnimatedStyle]}
>
{Boolean(uploadFileError) &&
<View style={style.errorTextContainer}>
<Text style={style.warning}>
{uploadFileError}
</Text>
</View>
}
</Animated.View>
</View>
);
}

View file

@ -0,0 +1,155 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {StyleSheet, TouchableOpacity, View} from 'react-native';
import {updateDraftFile} from '@actions/local/draft';
import FileIcon from '@components/post_list/post/body/files/file_icon';
import ImageFile from '@components/post_list/post/body/files/image_file';
import ProgressBar from '@components/progress_bar';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useDidUpdate from '@hooks/did_update';
import DraftUploadManager from '@init/draft_upload_manager';
import {isImage} from '@utils/file';
import {changeOpacity} from '@utils/theme';
import UploadRemove from './upload_remove';
import UploadRetry from './upload_retry';
type Props = {
file: FileInfo;
channelId: string;
rootId: string;
openGallery: (file: FileInfo) => void;
}
const styles = StyleSheet.create({
preview: {
paddingTop: 5,
marginLeft: 12,
},
previewContainer: {
height: 56,
width: 56,
borderRadius: 4,
},
progress: {
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.1)',
height: 53,
width: 53,
justifyContent: 'flex-end',
position: 'absolute',
borderRadius: 4,
paddingLeft: 3,
},
filePreview: {
width: 56,
height: 56,
},
});
export default function UploadItem({
file,
channelId,
rootId,
openGallery,
}: Props) {
const theme = useTheme();
const serverUrl = useServerUrl();
const removeCallback = useRef<(() => void)|null>(null);
const [progress, setProgress] = useState(0);
const loading = DraftUploadManager.isUploading(file.clientId!);
const handlePress = useCallback(() => {
openGallery(file);
}, [openGallery, file]);
useEffect(() => {
if (file.clientId) {
removeCallback.current = DraftUploadManager.registerProgressHandler(file.clientId, setProgress);
}
return () => {
removeCallback.current?.();
removeCallback.current = null;
};
}, []);
useDidUpdate(() => {
if (loading && file.clientId) {
removeCallback.current = DraftUploadManager.registerProgressHandler(file.clientId, setProgress);
}
return () => {
removeCallback.current?.();
removeCallback.current = null;
};
}, [file.failed, file.id]);
const retryFileUpload = useCallback(() => {
if (!file.failed) {
return;
}
const newFile = {...file};
newFile.failed = false;
updateDraftFile(serverUrl, channelId, rootId, newFile);
DraftUploadManager.prepareUpload(serverUrl, newFile, channelId, rootId, newFile.bytesRead);
DraftUploadManager.registerProgressHandler(newFile.clientId!, setProgress);
}, [serverUrl, channelId, rootId, file]);
const filePreviewComponent = useMemo(() => {
if (isImage(file)) {
return (
<ImageFile
file={file}
resizeMode='cover'
/>
);
}
return (
<FileIcon
backgroundColor={changeOpacity(theme.centerChannelColor, 0.08)}
iconSize={60}
file={file}
/>
);
}, [file]);
return (
<View
key={file.clientId}
style={styles.preview}
>
<View style={styles.previewContainer}>
<TouchableOpacity onPress={handlePress}>
<View style={styles.filePreview}>
{filePreviewComponent}
</View>
</TouchableOpacity>
{file.failed &&
<UploadRetry
onPress={retryFileUpload}
/>
}
{loading && !file.failed &&
<View style={styles.progress}>
<ProgressBar
progress={progress || 0}
color={theme.buttonBg}
/>
</View>
}
</View>
<UploadRemove
clientId={file.clientId!}
channelId={channelId}
rootId={rootId}
/>
</View>
);
}

View file

@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View, Platform} from 'react-native';
import {removeDraftFile} from '@actions/local/draft';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import DraftUploadManager from '@init/draft_upload_manager';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
type Props = {
channelId: string;
rootId: string;
clientId: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
tappableContainer: {
position: 'absolute',
elevation: 11,
top: -7,
right: -8,
width: 24,
height: 24,
},
removeButton: {
borderRadius: 12,
alignSelf: 'center',
marginTop: Platform.select({
ios: 5.4,
android: 4.75,
}),
backgroundColor: theme.centerChannelBg,
width: 24,
height: 25,
},
};
});
export default function UploadRemove({
channelId,
rootId,
clientId,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
const serverUrl = useServerUrl();
const onPress = () => {
DraftUploadManager.cancel(clientId);
removeDraftFile(serverUrl, channelId, rootId, clientId);
};
return (
<TouchableWithFeedback
style={style.tappableContainer}
onPress={onPress}
type={'opacity'}
>
<View style={style.removeButton}>
<CompassIcon
name='close-circle'
color={changeOpacity(theme.centerChannelColor, 0.64)}
size={24}
style={style.removeIcon}
/>
</View>
</TouchableWithFeedback>
);
}

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
type Props = {
onPress: () => void;
}
const style = StyleSheet.create({
failed: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
position: 'absolute',
height: '100%',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
},
});
export default function UploadRetry({
onPress,
}: Props) {
return (
<TouchableWithFeedback
style={style.failed}
onPress={onPress}
type='opacity'
>
<CompassIcon
name='refresh'
size={25}
color='#fff'
/>
</TouchableWithFeedback>
);
}

View file

@ -11,7 +11,7 @@ import CombinedUserActivity from '@components/post_list/combined_user_activity';
import DateSeparator from '@components/post_list/date_separator';
import NewMessagesLine from '@components/post_list/new_message_line';
import Post from '@components/post_list/post';
import {Screens} from '@constants';
import {Events, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList, START_OF_NEW_MESSAGES} from '@utils/post_list';
@ -126,7 +126,7 @@ const PostList = ({
}
};
const scrollBottomListener = DeviceEventEmitter.addListener('scroll-to-bottom', scrollToBottom);
const scrollBottomListener = DeviceEventEmitter.addListener(Events.POST_LIST_SCROLL_TO_BOTTOM, scrollToBottom);
return () => {
scrollBottomListener.remove();

View file

@ -93,7 +93,6 @@ const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme}
<View style={[styles.image, {width: dimensions.width, height: dimensions.height}]}>
<FileIcon
failed={true}
theme={theme}
/>
</View>
</View>

View file

@ -66,7 +66,6 @@ const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => {
<View style={[style.image, {width, height}]}>
<FileIcon
failed={true}
theme={theme}
/>
</View>
</View>

View file

@ -171,7 +171,6 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
<FileIcon
backgroundColor={backgroundColor}
file={file}
theme={theme}
/>
);

View file

@ -76,7 +76,6 @@ const File = ({
wrapperWidth={wrapperWidth}
isSingleImage={isSingleImage}
resizeMode={'cover'}
theme={theme}
/>
{Boolean(nonVisibleImagesCount) &&
<ImageFileOverlay
@ -117,7 +116,6 @@ const File = ({
>
<FileIcon
file={file}
theme={theme}
/>
</TouchableWithFeedback>
</View>

View file

@ -5,6 +5,7 @@ import React from 'react';
import {View, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {getFileType} from '@utils/file';
type FileIconProps = {
@ -15,7 +16,6 @@ type FileIconProps = {
iconColor?: string;
iconSize?: number;
smallImage?: boolean;
theme: Theme;
}
const BLUE_ICON = '#338AFF';
@ -49,8 +49,9 @@ const styles = StyleSheet.create({
const FileIcon = ({
backgroundColor, defaultImage = false, failed = false, file,
iconColor, iconSize = 48, smallImage = false, theme,
iconColor, iconSize = 48, smallImage = false,
}: FileIconProps) => {
const theme = useTheme();
const getFileIconNameAndColor = () => {
if (failed) {
return FAILED_ICON_NAME_AND_COLOR;

View file

@ -6,6 +6,7 @@ import {StyleProp, StyleSheet, useWindowDimensions, View, ViewStyle} from 'react
import ProgressiveImage from '@components/progressive_image';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import NetworkManager from '@init/network_manager';
import {calculateDimensions} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -21,7 +22,6 @@ type ImageFileProps = {
inViewPort?: boolean;
isSingleImage?: boolean;
resizeMode?: ResizeMode;
theme: Theme;
wrapperWidth?: number;
}
@ -71,11 +71,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
const ImageFile = ({
backgroundColor, file, inViewPort, isSingleImage,
resizeMode = 'cover', theme, wrapperWidth,
resizeMode = 'cover', wrapperWidth,
}: ImageFileProps) => {
const serverUrl = useServerUrl();
const [failed, setFailed] = useState(false);
const dimensions = useWindowDimensions();
const theme = useTheme();
const style = getStyleSheet(theme);
let image;
let client: Client | undefined;
@ -142,7 +143,6 @@ const ImageFile = ({
failed={failed}
file={file}
backgroundColor={backgroundColor}
theme={theme}
/>
);
}
@ -185,7 +185,6 @@ const ImageFile = ({
failed={failed}
file={file}
backgroundColor={backgroundColor}
theme={theme}
/>
</View>
);

View file

@ -1,8 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Animated, LayoutChangeEvent, StyleSheet, StyleProp, View, ViewStyle} from 'react-native';
import React, {useCallback, useEffect, useState} from 'react';
import {LayoutChangeEvent, StyleSheet, StyleProp, View, ViewStyle} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
type ProgressBarProps = {
color: string;
@ -24,35 +25,27 @@ const styles = StyleSheet.create({
});
const ProgressBar = ({color, progress, style}: ProgressBarProps) => {
const timer = useRef(new Animated.Value(progress)).current;
const [width, setWidth] = useState(0);
const progressValue = useSharedValue(progress);
const progressAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{translateX: withTiming(((progressValue.value * 0.5) - 0.5) * width, {duration: 200})},
{scaleX: withTiming(progressValue.value ? progressValue.value : 0.0001, {duration: 200})},
],
};
}, [width]);
useEffect(() => {
const animation = Animated.timing(timer, {
duration: 200,
useNativeDriver: true,
isInteraction: false,
toValue: progress,
});
animation.start();
return animation.stop;
progressValue.value = progress;
}, [progress]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
setWidth(e.nativeEvent.layout.width);
}, []);
const translateX = timer.interpolate({
inputRange: [0, 1],
outputRange: [(-0.5 * width), 0],
});
const scaleX = timer.interpolate({
inputRange: [0, 1],
outputRange: [0.0001, 1],
});
return (
<View
onLayout={onLayout}
@ -60,14 +53,12 @@ const ProgressBar = ({color, progress, style}: ProgressBarProps) => {
>
<Animated.View
style={[
styles.progressBar, {
styles.progressBar,
{
backgroundColor: color,
width,
transform: [
{translateX},
{scaleX},
],
},
progressAnimatedStyle,
]}
/>
</View>

View file

@ -6,5 +6,6 @@ export default {
DMCHANNEL: 'dmchannel',
GROUPCHANNEL: 'groupchannel',
PERMALINK: 'permalink',
PLUGIN: 'plugin',
OTHER: 'other',
};

View file

@ -16,5 +16,6 @@ export default keyMirror({
TEAM_LOAD_ERROR: null,
USER_TYPING: null,
USER_STOP_TYPING: null,
POST_LIST_SCROLL_TO_BOTTOM: null,
SWIPEABLE: null,
});

View file

@ -12,6 +12,7 @@ export default {
AWAY: 'away',
ONLINE: 'online',
DND: 'dnd',
STATUS_COMMANDS: ['offline', 'away', 'online', 'dnd'],
DEFAULT_CHANNEL: 'town-square',
DM_CHANNEL: 'D',
OPEN_CHANNEL: 'O',

View file

@ -15,9 +15,11 @@ import Navigation from './navigation';
import Network from './network';
import Permissions from './permissions';
import Post from './post';
import PostDraft from './post_draft';
import Preferences from './preferences';
import Profile from './profile';
import Screens from './screens';
import ServerErrors from './server_errors';
import Sso from './sso';
import SupportedServer from './supported_server';
import View from './view';
@ -38,9 +40,11 @@ export {
Network,
Permissions,
Post,
PostDraft,
Preferences,
Profile,
Screens,
ServerErrors,
SupportedServer,
Sso,
View,

View file

@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const MAX_MESSAGE_LENGTH_FALLBACK = 4000;
export const DEFAULT_SERVER_MAX_FILE_SIZE = 50 * 1024 * 1024;// 50 Mb
export const ICON_SIZE = 24;
export const UPDATE_NATIVE_SCROLLVIEW = 'onUpdateNativeScrollView';
export const TYPING_HEIGHT = 26;
export const ACCESSORIES_CONTAINER_NATIVE_ID = 'channelAccessoriesContainer';
export const NOTIFY_ALL_MEMBERS = 5;
export default {
ACCESSORIES_CONTAINER_NATIVE_ID,
DEFAULT_SERVER_MAX_FILE_SIZE,
ICON_SIZE,
MAX_MESSAGE_LENGTH_FALLBACK,
NOTIFY_ALL_MEMBERS,
TYPING_HEIGHT,
UPDATE_NATIVE_SCROLLVIEW,
};

View file

@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export default {
DELETED_ROOT_POST_ERROR: 'api.post.create_post.root_id.app_error',
TOWN_SQUARE_READ_ONLY_ERROR: 'api.post.create_post.town_square_read_only',
PLUGIN_DISMISSED_POST_ERROR: 'plugin.message_will_be_posted.dismiss_post',
};

View file

@ -3,6 +3,7 @@
import {Platform} from 'react-native';
export const BOTTOM_TAB_HEIGHT = 52;
export const BOTTOM_TAB_ICON_SIZE = 31.2;
export const PROFILE_PICTURE_SIZE = 32;
export const PROFILE_PICTURE_EMOJI_SIZE = 28;
@ -24,6 +25,7 @@ export const ANDROID_HEADER_SEARCH_INSET = 11;
export const INDICATOR_BAR_HEIGHT = 38;
export default {
BOTTOM_TAB_HEIGHT,
BOTTOM_TAB_ICON_SIZE,
PROFILE_PICTURE_SIZE,
PROFILE_PICTURE_EMOJI_SIZE,
@ -43,3 +45,4 @@ export default {
ANDROID_HEADER_SEARCH_INSET,
INDICATOR_BAR_HEIGHT,
};

View file

@ -56,7 +56,7 @@ export const isRecordTermsOfServiceEqualToRaw = (record: TermsOfServiceModel, ra
};
export const isRecordDraftEqualToRaw = (record: DraftModel, raw: Draft) => {
return raw.channel_id === record.channelId;
return raw.channel_id === record.channelId && raw.root_id === record.rootId;
};
export const isRecordPostEqualToRaw = (record: PostModel, raw: Post) => {

View file

@ -92,7 +92,7 @@ const PostHandler = (superclass: any) => class extends superclass {
// Let's process the post data
for (const post of posts) {
// Find any pending posts that matches the ones received to mark for deletion
if (post.pending_post_id) {
if (post.pending_post_id && post.id !== post.pending_post_id) {
pendingPostsToDelete.push({
...post,
id: post.pending_post_id,

View file

@ -42,7 +42,7 @@ export const transformPostRecord = ({action, database, value}: TransformerArgs):
post.updateAt = raw.update_at;
post.isPinned = Boolean(raw.is_pinned);
post.message = raw.message;
post.metadata = Object.keys(raw.metadata).length ? raw.metadata : null;
post.metadata = raw.metadata && Object.keys(raw.metadata).length ? raw.metadata : null;
post.userId = raw.user_id;
post.originalId = raw.original_id;
post.pendingPostId = raw.pending_post_id;

View file

@ -31,7 +31,7 @@ export function getPreferenceAsInt(preferences: PreferenceType[] | PreferenceMod
export function getTeammateNameDisplaySetting(preferences: PreferenceType[] | PreferenceModel[], config?: ClientConfig, license?: ClientLicense) {
const useAdminTeammateNameDisplaySetting = license?.LockTeammateNameDisplay === 'true' && config?.LockTeammateNameDisplay === 'true';
const preference = getPreferenceValue(preferences as PreferenceType[], Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT, '') as string;
const preference = getPreferenceValue(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT, '') as string;
if (preference && !useAdminTeammateNameDisplaySetting) {
return preference;
} else if (config?.TeammateNameDisplay) {

View file

@ -56,7 +56,8 @@ class DraftUploadManager {
};
const onError = (response: ClientResponseError) => {
this.handleError(response.message, file.clientId!);
const message = response.message || 'Unkown error';
this.handleError(message, file.clientId!);
};
const {error, cancel} = uploadFile(serverUrl, file, channelId, onProgress, onComplete, onError, skipBytes);
@ -68,9 +69,9 @@ class DraftUploadManager {
};
public cancel = (clientId: string) => {
const {cancel} = this.handlers[clientId];
const h = this.handlers[clientId];
delete this.handlers[clientId];
cancel?.();
h?.cancel?.();
};
public isUploading = (clientId: string) => {
@ -113,7 +114,6 @@ class DraftUploadManager {
return;
}
h.fileInfo.progress = progress;
h.fileInfo.bytesRead = bytes;
h.onProgress.forEach((c) => c(progress, bytes));
@ -142,24 +142,28 @@ class DraftUploadManager {
return;
}
delete this.handlers[clientId!];
delete this.handlers[clientId];
const fileInfo = data[0];
fileInfo.clientId = h.fileInfo.clientId;
fileInfo.localPath = h.fileInfo.localPath;
updateDraftFile(h.serverUrl, h.channelId, h.rootId, this.handlers[clientId].fileInfo);
updateDraftFile(h.serverUrl, h.channelId, h.rootId, fileInfo);
};
private handleError = (errorMessage: string, clientId: string) => {
const h = this.handlers[clientId];
if (!h) {
return;
}
delete this.handlers[clientId];
h.onError.forEach((c) => c(errorMessage));
const fileInfo = {...h.fileInfo};
fileInfo.failed = true;
updateDraftFile(h.serverUrl, h.channelId, h.rootId, this.handlers[clientId].fileInfo);
updateDraftFile(h.serverUrl, h.channelId, h.rootId, fileInfo);
};
private onAppStateChange = async (appState: AppStateStatus) => {

View file

@ -180,6 +180,10 @@ class WebsocketManager {
this.closeAll();
};
public getClient = (serverUrl: string): WebSocketClient | undefined => {
return this.clients[serverUrl];
};
}
export default new WebsocketManager();

View file

@ -7,8 +7,9 @@ import {MM_TABLES} from '@constants/database';
import type PostModel from '@typings/database/models/servers/post';
import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel';
import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread';
const {SERVER: {POST, POSTS_IN_CHANNEL}} = MM_TABLES;
const {SERVER: {POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES;
export const prepareDeletePost = async (post: PostModel): Promise<Model[]> => {
const preparedModels: Model[] = [post.prepareDestroyPermanently()];
@ -48,12 +49,39 @@ export const queryPostById = async (database: Database, postId: string) => {
export const queryPostsInChannel = (database: Database, channelId: string): Promise<PostInChannelModel[]> => {
try {
return database.get(POSTS_IN_CHANNEL).query(
return database.get<PostInChannelModel>(POSTS_IN_CHANNEL).query(
Q.where('channel_id', channelId),
Q.sortBy('latest', Q.desc),
).fetch() as Promise<PostInChannelModel[]>;
).fetch();
} catch {
return Promise.resolve([] as PostInChannelModel[]);
return Promise.resolve([]);
}
};
export const queryPostsInThread = (database: Database, rootId: string): Promise<PostsInThreadModel[]> => {
try {
return database.get<PostsInThreadModel>(POSTS_IN_THREAD).query(
Q.where('root_id', rootId),
Q.sortBy('latest', Q.desc),
).fetch();
} catch {
return Promise.resolve([]);
}
};
export const queryRecentPostsInThread = async (database: Database, rootId: string): Promise<PostModel[]> => {
try {
const chunks = await queryPostsInThread(database, rootId);
if (chunks.length) {
const recent = chunks[0];
const post = await queryPostById(database, rootId);
if (post) {
return queryPostsChunk(database, post.channelId, recent.earliest, recent.latest);
}
}
return Promise.resolve([]);
} catch {
return Promise.resolve([]);
}
};
@ -68,7 +96,7 @@ export const queryPostsChunk = (database: Database, channelId: string, earliest:
Q.sortBy('create_at', Q.desc),
).fetch() as Promise<PostModel[]>;
} catch {
return Promise.resolve([] as PostModel[]);
return Promise.resolve([]);
}
};
@ -79,9 +107,9 @@ export const queryRecentPostsInChannel = async (database: Database, channelId: s
const recent = chunks[0];
return queryPostsChunk(database, channelId, recent.earliest, recent.latest);
}
return Promise.resolve([] as PostModel[]);
return Promise.resolve([]);
} catch {
return Promise.resolve([] as PostModel[]);
return Promise.resolve([]);
}
};

View file

@ -51,19 +51,21 @@ export const addChannelToTeamHistory = async (operator: ServerDataOperator, team
return operator.handleTeamChannelHistory({teamChannelHistories: [tch], prepareRecordsOnly});
};
export const queryLastChannelFromTeam = async (database: Database, teamId: string) => {
export const queryNthLastChannelFromTeam = async (database: Database, teamId: string, n = 0) => {
let channelId = '';
try {
const teamChannelHistory = await database.get<TeamChannelHistoryModel>(TEAM_CHANNEL_HISTORY).find(teamId);
if (teamChannelHistory.channelIds.length) {
channelId = teamChannelHistory.channelIds[0];
if (teamChannelHistory.channelIds.length > n + 1) {
channelId = teamChannelHistory.channelIds[n];
}
} catch {
// No channel history for the team
const channel = await queryDefaultChannelForTeam(database, teamId);
if (channel) {
channelId = channel.id;
} finally {
if (!channelId) {
// No channel history for the team
const channel = await queryDefaultChannelForTeam(database, teamId);
if (channel) {
channelId = channel.id;
}
}
}

View file

@ -8,7 +8,9 @@ import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-cont
import CompassIcon from '@components/compass_icon';
import NavigationHeader from '@components/navigation_header';
import PostDraft from '@components/post_draft';
import {Navigation} from '@constants';
import {ACCESSORIES_CONTAINER_NATIVE_ID} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {useAppState, useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
@ -101,6 +103,7 @@ const Channel = ({channelId, componentId, displayName, isOwnDirectMessage, membe
}
const marginTop = defaultHeight + (isTablet ? insets.top : 0);
const channelIsSet = Boolean(channelId);
return (
<>
@ -120,12 +123,22 @@ const Channel = ({channelId, componentId, displayName, isOwnDirectMessage, membe
subtitleCompanion={subtitleCompanion}
title={title}
/>
<View style={[styles.flex, {marginTop}]}>
<ChannelPostList
{channelIsSet &&
<>
<View style={[styles.flex, {marginTop}]}>
<ChannelPostList
channelId={channelId}
forceQueryAfterAppState={appState}
nativeID={channelId}
/>
</View>
<PostDraft
channelId={channelId}
forceQueryAfterAppState={appState}
scrollViewNativeID={channelId}
accessoriesContainerID={ACCESSORIES_CONTAINER_NATIVE_ID}
/>
</View>
</>
}
</SafeAreaView>
</>
);

View file

@ -1,14 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useRef} from 'react';
import {StyleProp, ViewStyle} from 'react-native';
import React, {useCallback, useRef} from 'react';
import {StyleProp, StyleSheet, ViewStyle} from 'react-native';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {fetchPostsBefore} from '@actions/remote/post';
import PostList from '@components/post_list';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {debounce} from '@helpers/api/general';
import {useIsTablet} from '@hooks/device';
import {sortPostsByNewest} from '@utils/post';
import Intro from './intro';
@ -22,11 +24,21 @@ type Props = {
currentUsername: string;
isTimezoneEnabled: boolean;
lastViewedAt: number;
nativeID: string;
posts: PostModel[];
shouldShowJoinLeaveMessages: boolean;
}
const ChannelPostList = ({channelId, contentContainerStyle, currentTimezone, currentUsername, isTimezoneEnabled, lastViewedAt, posts, shouldShowJoinLeaveMessages}: Props) => {
const edges: Edge[] = ['bottom'];
const styles = StyleSheet.create({
flex: {flex: 1},
});
const ChannelPostList = ({
channelId, contentContainerStyle, currentTimezone, currentUsername,
isTimezoneEnabled, lastViewedAt, nativeID, posts, shouldShowJoinLeaveMessages,
}: Props) => {
const isTablet = useIsTablet();
const serverUrl = useServerUrl();
const canLoadPosts = useRef(true);
const fetchingPosts = useRef(false);
@ -41,11 +53,9 @@ const ChannelPostList = ({channelId, contentContainerStyle, currentTimezone, cur
}
}, 500), [channelId, posts]);
const intro = useMemo(() => (
<Intro channelId={channelId}/>
), [channelId]);
const intro = <Intro channelId={channelId}/>;
return (
const postList = (
<PostList
channelId={channelId}
contentContainerStyle={contentContainerStyle}
@ -55,7 +65,7 @@ const ChannelPostList = ({channelId, contentContainerStyle, currentTimezone, cur
footer={intro}
lastViewedAt={lastViewedAt}
location={Screens.CHANNEL}
nativeID={`${Screens.CHANNEL}-${channelId}`}
nativeID={nativeID}
onEndReached={onEndReached}
posts={posts}
shouldShowJoinLeaveMessages={shouldShowJoinLeaveMessages}
@ -63,6 +73,19 @@ const ChannelPostList = ({channelId, contentContainerStyle, currentTimezone, cur
testID='channel.post_list'
/>
);
if (isTablet) {
return postList;
}
return (
<SafeAreaView
edges={edges}
style={styles.flex}
>
{postList}
</SafeAreaView>
);
};
export default ChannelPostList;

View file

@ -5,7 +5,7 @@ import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import {useTheme} from '@app/context/theme';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';

View file

@ -5,9 +5,9 @@ import React, {useCallback, useMemo, useRef} from 'react';
import {MessageDescriptor, useIntl} from 'react-intl';
import {Keyboard, StyleSheet, View} from 'react-native';
import {FloatingTextInputRef} from '@app/components/floating_text_input_label';
import {t} from '@app/i18n';
import {FloatingTextInputRef} from '@components/floating_text_input_label';
import {useTheme} from '@context/theme';
import {t} from '@i18n';
import DisabledFields from './disabled_fields';
import EmailField from './email_field';

View file

@ -8,7 +8,6 @@ import DocumentPicker from 'react-native-document-picker';
import SlideUpPanelItem from '@components/slide_up_panel_item';
import type {MessageDescriptor} from '@formatjs/intl/src/types';
import type {UploadExtractedFile} from '@typings/utils/file';
import type PickerUtil from '@utils/file/file_picker';
import type {Source} from 'react-native-fast-image';

View file

@ -5,7 +5,7 @@ import React from 'react';
import {View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import ErrorText from '@components/error_text';
import ErrorTextComponent from '@components/error_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -47,7 +47,7 @@ const ProfileError = ({error}: DisplayErrorProps) => {
size={18}
name='alert-outline'
/>
<ErrorText
<ErrorTextComponent
theme={theme}
testID='edit_profile.error.text'
error={error}

View file

@ -17,7 +17,6 @@ import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type UserModel from '@typings/database/models/servers/user';
import type {UploadExtractedFile} from '@typings/utils/file';
const hitSlop = {top: 100, bottom: 20, right: 20, left: 100};
const ACTION_HEIGHT = 55;

View file

@ -24,7 +24,6 @@ import Updating from './components/updating';
import UserProfilePicture from './components/user_profile_picture';
import type {EditProfileProps, NewProfileImage, UserInfo} from '@typings/screens/edit_profile';
import type {ErrorText} from '@typings/utils/file';
const edges: Edge[] = ['bottom', 'left', 'right'];

View file

@ -7,7 +7,7 @@ import {Shadow} from 'react-native-neomorph-shadows';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {Navigation as NavigationConstants, Screens} from '@constants';
import {Navigation as NavigationConstants, Screens, View as ViewConstants} from '@constants';
import EphemeralStore from '@store/ephemeral_store';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -23,7 +23,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
backgroundColor: theme.centerChannelBg,
alignContent: 'center',
flexDirection: 'row',
height: 52,
height: ViewConstants.BOTTOM_TAB_HEIGHT,
justifyContent: 'center',
},
item: {

View file

@ -5,8 +5,22 @@ import {General, Permissions} from '@constants';
import {DEFAULT_LOCALE} from '@i18n';
import {hasPermission} from '@utils/role';
export function selectDefaultChannelForTeam(channels: Channel[], memberships: ChannelMembership[], teamId: string, roles?: Role[], locale = DEFAULT_LOCALE) {
let channel: Channel|undefined;
import type ChannelModel from '@typings/database/models/servers/channel';
export function getDirectChannelName(id: string, otherId: string): string {
let handle;
if (otherId > id) {
handle = id + '__' + otherId;
} else {
handle = otherId + '__' + id;
}
return handle;
}
export function selectDefaultChannelForTeam<T extends Channel|ChannelModel>(channels: T[], memberships: ChannelMembership[], teamId: string, roles?: Role[], locale = DEFAULT_LOCALE) {
let channel: T|undefined;
let canIJoinPublicChannelsInTeam = false;
if (roles) {
@ -14,8 +28,11 @@ export function selectDefaultChannelForTeam(channels: Channel[], memberships: Ch
}
const defaultChannel = channels?.find((c) => c.name === General.DEFAULT_CHANNEL);
const iAmMemberOfTheTeamDefaultChannel = Boolean(defaultChannel && memberships?.find((m) => m.channel_id === defaultChannel.id));
const myFirstTeamChannel = channels?.filter((c) => c.team_id === teamId && c.type === General.OPEN_CHANNEL && Boolean(memberships?.find((m) => c.id === m.channel_id))).
sort(sortChannelsByDisplayName.bind(null, locale))[0];
const myFirstTeamChannel = channels?.filter((c) =>
(('team_id' in c) ? c.team_id : c.teamId) === teamId &&
c.type === General.OPEN_CHANNEL &&
Boolean(memberships?.find((m) => c.id === m.channel_id),
)).sort(sortChannelsByDisplayName.bind(null, locale))[0];
if (iAmMemberOfTheTeamDefaultChannel || canIJoinPublicChannelsInTeam) {
channel = defaultChannel;
@ -26,10 +43,21 @@ export function selectDefaultChannelForTeam(channels: Channel[], memberships: Ch
return channel;
}
export function sortChannelsByDisplayName(locale: string, a: Channel, b: Channel): number {
export function sortChannelsByDisplayName<T extends Channel|ChannelModel>(locale: string, a: T, b: T): number {
// if both channels have the display_name defined
if (a.display_name && b.display_name && a.display_name !== b.display_name) {
return a.display_name.toLowerCase().localeCompare(b.display_name.toLowerCase(), locale, {numeric: true});
const aDisplayName = 'display_name' in a ? a.display_name : a.displayName;
const bDisplayName = 'display_name' in b ? b.display_name : b.displayName;
if (aDisplayName && bDisplayName && aDisplayName !== bDisplayName) {
return aDisplayName.toLowerCase().localeCompare(bDisplayName.toLowerCase(), locale, {numeric: true});
}
return a.name.toLowerCase().localeCompare(b.name.toLowerCase(), locale, {numeric: true});
}
export function sortChannelsModelByDisplayName(locale: string, a: ChannelModel, b: ChannelModel): number {
// if both channels have the display_name defined
if (a.displayName && b.displayName && a.displayName !== b.displayName) {
return a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase(), locale, {numeric: true});
}
return a.name.toLowerCase().localeCompare(b.name.toLowerCase(), locale, {numeric: true});

View file

@ -4,10 +4,16 @@
import {MessageDescriptor} from '@formatjs/intl/src/types';
import {Alert, AlertButton} from 'react-native';
import {General} from '@constants';
import {AT_MENTION_REGEX_GLOBAL, CODE_REGEX} from '@constants/autocomplete';
import {NOTIFY_ALL_MEMBERS} from '@constants/post_draft';
import {t} from '@i18n';
import type GroupModel from '@typings/database/models/servers/group';
import type {IntlShape} from 'react-intl';
type AlertCallback = (value?: string) => void;
export function errorBadChannel(intl: IntlShape) {
const message = {
id: t('mobile.server_link.unreachable_channel.error'),
@ -17,6 +23,15 @@ export function errorBadChannel(intl: IntlShape) {
return alertErrorWithFallback(intl, {}, message);
}
export function errorUnkownUser(intl: IntlShape) {
const message = {
id: t('mobile.server_link.unreachable_user.error'),
defaultMessage: 'We can\'t redirect you to the DM. The user specified is unknown.',
};
alertErrorWithFallback(intl, {}, message);
}
export function permalinkBadTeam(intl: IntlShape) {
const message = {
id: t('mobile.server_link.unreachable_team.error'),
@ -33,3 +48,228 @@ export function alertErrorWithFallback(intl: IntlShape, error: any, fallback: Me
}
Alert.alert('', msg, buttons);
}
export function alertAttachmentFail(intl: IntlShape, accept: AlertCallback, cancel: AlertCallback) {
Alert.alert(
intl.formatMessage({
id: 'mobile.post_textbox.uploadFailedTitle',
defaultMessage: 'Attachment failure',
}),
intl.formatMessage({
id: 'mobile.post_textbox.uploadFailedDesc',
defaultMessage: 'Some attachments failed to upload to the server. Are you sure you want to post the message?',
}),
[{
text: intl.formatMessage({id: 'mobile.channel_info.alertNo', defaultMessage: 'No'}),
onPress: cancel,
}, {
text: intl.formatMessage({id: 'mobile.channel_info.alertYes', defaultMessage: 'Yes'}),
onPress: accept,
}],
);
}
export const textContainsAtAllAtChannel = (text: string) => {
const textWithoutCode = text.replace(CODE_REGEX, '');
return (/(?:\B|\b_+)@(channel|all)(?!(\.|-|_)*[^\W_])/i).test(textWithoutCode);
};
export const textContainsAtHere = (text: string) => {
const textWithoutCode = text.replace(CODE_REGEX, '');
return (/(?:\B|\b_+)@(here)(?!(\.|-|_)*[^\W_])/i).test(textWithoutCode);
};
export const groupsMentionedInText = (groupsWithAllowReference: GroupModel[], text: string) => {
if (!groupsWithAllowReference.length) {
return [];
}
const textWithoutCode = text.replace(CODE_REGEX, '');
const mentions = textWithoutCode.match(AT_MENTION_REGEX_GLOBAL) || [];
return groupsWithAllowReference.filter((g) => mentions.includes(g.id));
};
// mapGroupMentions remove duplicates from the groupMentions, and if any of the
// groups has more members than the NOTIFY_ALL_MEMBERS, return the highest
// number of notifications and the timezones of that group.
export const mapGroupMentions = (channelMemberCountsByGroup: ChannelMemberCountByGroup[], groupMentions: GroupModel[]) => {
let memberNotifyCount = 0;
let channelTimezoneCount = 0;
const groupMentionsSet = new Set<string>();
const mappedChannelMemberCountsByGroup: ChannelMemberCountsByGroup = {};
channelMemberCountsByGroup.forEach((group) => {
mappedChannelMemberCountsByGroup[group.group_id] = group;
});
groupMentions.
forEach((group) => {
const mappedValue = mappedChannelMemberCountsByGroup[group.id];
if (mappedValue?.channel_member_count > NOTIFY_ALL_MEMBERS && mappedValue?.channel_member_count > memberNotifyCount) {
memberNotifyCount = mappedValue.channel_member_count;
channelTimezoneCount = mappedValue.channel_member_timezones_count;
}
if (group.name) {
groupMentionsSet.add(`@${group.name}`);
}
});
return {groupMentionsSet, memberNotifyCount, channelTimezoneCount};
};
export function buildGroupMentionsMessage(intl: IntlShape, groupMentions: string[], memberNotifyCount: number, channelTimezoneCount: number) {
let notifyAllMessage = '';
if (groupMentions.length === 1) {
if (channelTimezoneCount > 0) {
notifyAllMessage = (
intl.formatMessage(
{
id: 'mobile.post_textbox.one_group.message.with_timezones',
defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?',
},
{
mention: groupMentions[0],
totalMembers: memberNotifyCount,
timezones: channelTimezoneCount,
},
)
);
} else {
notifyAllMessage = (
intl.formatMessage(
{
id: 'mobile.post_textbox.one_group.message.without_timezones',
defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people. Are you sure you want to do this?',
},
{
mention: groupMentions[0],
totalMembers: memberNotifyCount,
},
)
);
}
} else if (channelTimezoneCount > 0) {
notifyAllMessage = (
intl.formatMessage(
{
id: 'mobile.post_textbox.multi_group.message.with_timezones',
defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?',
},
{
mentions: groupMentions.slice(0, -1).join(', '),
finalMention: groupMentions[groupMentions.length - 1],
totalMembers: memberNotifyCount,
timezones: channelTimezoneCount,
},
)
);
} else {
notifyAllMessage = (
intl.formatMessage(
{
id: 'mobile.post_textbox.multi_group.message.without_timezones',
defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people. Are you sure you want to do this?',
},
{
mentions: groupMentions.slice(0, -1).join(', '),
finalMention: groupMentions[groupMentions.length - 1],
totalMembers: memberNotifyCount,
},
)
);
}
return notifyAllMessage;
}
export function buildChannelWideMentionMessage(intl: IntlShape, membersCount: number, isTimezoneEnabled: boolean, channelTimezoneCount: number, atHere: boolean) {
let notifyAllMessage = '';
if (isTimezoneEnabled && channelTimezoneCount) {
const msgID = atHere ? t('mobile.post_textbox.entire_channel_here.message.with_timezones') : t('mobile.post_textbox.entire_channel.message.with_timezones');
const atHereMsg = 'By using @here you are about to send notifications up to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?';
const atAllOrChannelMsg = 'By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?';
notifyAllMessage = (
intl.formatMessage(
{
id: msgID,
defaultMessage: atHere ? atHereMsg : atAllOrChannelMsg,
},
{
totalMembers: membersCount - 1,
timezones: channelTimezoneCount,
},
)
);
} else {
const msgID = atHere ? t('mobile.post_textbox.entire_channel_here.message') : t('mobile.post_textbox.entire_channel.message');
const atHereMsg = 'By using @here you are about to send notifications to up to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?';
const atAllOrChannelMsg = 'By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?';
notifyAllMessage = (
intl.formatMessage(
{
id: msgID,
defaultMessage: atHere ? atHereMsg : atAllOrChannelMsg,
},
{
totalMembers: membersCount - 1,
},
)
);
}
return notifyAllMessage;
}
export function alertChannelWideMention(intl: IntlShape, notifyAllMessage: string, accept: AlertCallback, cancel: AlertCallback) {
const message = intl.formatMessage({
id: 'mobile.post_textbox.entire_channel.title',
defaultMessage: 'Confirm sending notifications to entire channel',
});
alertMessage(intl, message, notifyAllMessage, accept, cancel);
}
export function alertSendToGroups(intl: IntlShape, notifyAllMessage: string, accept: AlertCallback, cancel: AlertCallback) {
const message = intl.formatMessage({
id: 'mobile.post_textbox.groups.title',
defaultMessage: 'Confirm sending notifications to groups',
});
alertMessage(intl, message, notifyAllMessage, accept, cancel);
}
function alertMessage(intl: IntlShape, message: string, notifyAllMessage: string, accept: AlertCallback, cancel: AlertCallback) {
Alert.alert(
message,
notifyAllMessage,
[
{
text: intl.formatMessage({
id: 'mobile.post_textbox.entire_channel.cancel',
defaultMessage: 'Cancel',
}),
onPress: cancel,
},
{
text: intl.formatMessage({
id: 'mobile.post_textbox.entire_channel.confirm',
defaultMessage: 'Confirm',
}),
onPress: accept,
},
],
);
}
export const getStatusFromSlashCommand = (message: string) => {
const tokens = message.split(' ');
const command = tokens[0]?.substring(1);
return General.STATUS_COMMANDS.includes(command) ? command : '';
};
export function alertSlashCommandFailed(intl: IntlShape, error: string) {
Alert.alert(
intl.formatMessage({
id: 'mobile.commands.error_title',
defaultMessage: 'Error Executing Command',
}),
error,
);
}

View file

@ -5,7 +5,9 @@ import emojiRegex from 'emoji-regex';
import SystemModel from '@database/models/server/system';
import {Emojis, EmojiIndicesByAlias} from './';
import {Emojis, EmojiIndicesByAlias, EmojiIndicesByUnicode} from './';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
const RE_NAMED_EMOJI = /(:([a-zA-Z0-9_+-]+):)/g;
@ -32,6 +34,9 @@ const RE_EMOTICON: Record<string, RegExp> = {
broken_heart: /(^|\s)(<\/3|&lt;&#x2F;3)(?=$|\s)/g, // </3
};
// TODO This only check for named emojis: https://mattermost.atlassian.net/browse/MM-41505
const RE_REACTION = /^(\+|-):([^:\s]+):\s*$/;
const MAX_JUMBO_EMOJIS = 8;
function isEmoticon(text: string) {
@ -50,6 +55,95 @@ export function getEmoticonName(value: string) {
return Object.keys(RE_EMOTICON).find((key) => value.match(RE_EMOTICON[key]) !== null);
}
export function matchEmoticons(text: string): string[] {
let emojis: string[] = text.match(RE_NAMED_EMOJI) || [];
for (const name of Object.keys(RE_EMOTICON)) {
const pattern = RE_EMOTICON[name];
const matches = text.match(pattern);
if (matches) {
emojis = emojis.concat(matches);
}
}
const matchUnicodeEmoji = text.match(RE_UNICODE_EMOJI);
if (matchUnicodeEmoji) {
emojis = emojis.concat(matchUnicodeEmoji);
}
return emojis;
}
export function getValidEmojis(emojis: string[], customEmojis: CustomEmojiModel[]) {
const emojiNames = new Set<string>();
const customEmojiNames = customEmojis.map((v) => v.name);
for (const emoji of emojis) {
const emojiName = getEmojiName(emoji, customEmojiNames);
if (emojiName) {
emojiNames.add(emojiName);
}
}
return Array.from(emojiNames);
}
export function getEmojiName(emoji: string, customEmojiNames: string[]) {
if (doesMatchNamedEmoji(emoji)) {
const emojiName = emoji.substring(1, emoji.length - 1);
if (isValidNamedEmoji(emojiName, customEmojiNames)) {
return emojiName;
}
}
const matchUnicodeEmoji = emoji.match(RE_UNICODE_EMOJI);
if (matchUnicodeEmoji) {
const index = EmojiIndicesByUnicode.get(matchUnicodeEmoji[0]);
if (index != null) {
return fillEmoji(Emojis[index]).name;
}
return undefined;
}
const emojiName = getEmoticonName(emoji);
if (emojiName) {
return emojiName;
}
return undefined;
}
export function isReactionMatch(value: string, customEmojis: CustomEmojiModel[]) {
const customEmojiNames = customEmojis.map((v) => v.name);
const match = value.match(RE_REACTION);
if (!match) {
return null;
}
if (!isValidNamedEmoji(match[2], customEmojiNames)) {
return null;
}
return {
add: match[1] === '+',
emoji: match[2],
};
}
export function isValidNamedEmoji(emojiName: string, customEmojiNames: string[]) {
if (EmojiIndicesByAlias.has(emojiName)) {
return true;
}
if (customEmojiNames.includes(emojiName)) {
return true;
}
return false;
}
export function hasJumboEmojiOnly(message: string, customEmojis: string[]) {
let emojiCount = 0;
const chunks = message.trim().replace(/\n/g, ' ').split(' ').filter((m) => m && m.length > 0);

View file

@ -12,8 +12,6 @@ import Permissions, {AndroidPermission, IOSPermission} from 'react-native-permis
import {Navigation} from '@constants';
import {extractFileInfo, lookupMimeType} from '@utils/file';
import type {ExtractedFileInfo} from '@typings/utils/file';
const ShareExtension = NativeModules.MattermostShare;
type PermissionSource = 'camera' | 'storage' | 'denied_android' | 'denied_ios' | 'photo';

View file

@ -17,7 +17,6 @@ import {hashCode} from '@utils/security';
import {removeProtocol} from '@utils/url';
import type FileModel from '@typings/database/models/servers/file';
import type {ExtractedFileInfo} from '@typings/utils/file';
const EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/;
const CONTENT_DISPOSITION_REGEXP = /inline;filename=".*\.([a-z]+)";/i;

View file

@ -24,32 +24,39 @@ export function hasPermission(roles: RoleModel[] | Role[], permission: string, d
return defaultValue === true || exists;
}
export async function hasPermissionForPost(post: PostModel, user: UserModel, permission: string, defaultValue: boolean) {
export async function hasPermissionForChannel(channel: ChannelModel, user: UserModel, permission: string, defaultValue: boolean) {
const rolesArray = [...user.roles.split(' ')];
const channel = await post.channel.fetch() as ChannelModel | undefined;
if (channel) {
const myChannel = await channel.membership.fetch() as MyChannelModel | undefined;
if (myChannel) {
rolesArray.push(...myChannel.roles.split(' '));
}
const team = await channel.team.fetch() as TeamModel | undefined;
if (team) {
const myTeam = await team.myTeam.fetch() as MyTeamModel | undefined;
if (myTeam) {
rolesArray.push(...myTeam.roles.split(' '));
}
const myChannel = await channel.membership.fetch() as MyChannelModel | undefined;
if (myChannel) {
rolesArray.push(...myChannel.roles.split(' '));
}
const team = await channel.team.fetch() as TeamModel | undefined;
if (team) {
const myTeam = await team.myTeam.fetch() as MyTeamModel | undefined;
if (myTeam) {
rolesArray.push(...myTeam.roles.split(' '));
}
}
if (rolesArray.length) {
const roles = await post.collections.get(MM_TABLES.SERVER.ROLE).query(Q.where('name', Q.oneOf(rolesArray))).fetch() as RoleModel[];
const roles = await user.collections.get(MM_TABLES.SERVER.ROLE).query(Q.where('name', Q.oneOf(rolesArray))).fetch() as RoleModel[];
return hasPermission(roles, permission, defaultValue);
}
return defaultValue;
}
export async function hasPermissionForPost(post: PostModel, user: UserModel, permission: string, defaultValue: boolean) {
const channel = await post.channel.fetch() as ChannelModel | undefined;
if (channel) {
return hasPermissionForChannel(channel, user, permission, defaultValue);
}
return defaultValue;
}
export async function canManageChannelMembers(post: PostModel, user: UserModel) {
const rolesArray = [...user.roles.split(' ')];
const channel = await post.channel.fetch() as ChannelModel | undefined;

View file

@ -159,6 +159,11 @@ export function parseDeepLink(deepLinkUrl: string): DeepLinkWithData {
return {type: DeepLinkType.GroupMessage, data: {serverUrl: match[1], teamName: match[2], channelId: match[3]}};
}
match = new RegExp('(.*)\\/plugins\\/([^\\/]+)\\/(\\S+)').exec(url);
if (match) {
return {type: DeepLinkType.Plugin, data: {serverUrl: match[1], id: match[2], teamName: ''}};
}
return {type: DeepLinkType.Invalid};
}

View file

@ -24,6 +24,11 @@
"apps.error.responses.unexpected_error": "Received an unexpected error.",
"apps.error.responses.unknown_type": "App response type not supported. Response type: {type}.",
"apps.error.unknown": "Unknown error occurred.",
"archivedChannelMessage": "You are viewing an **archived channel**. New messages cannot be posted.",
"camera_type.photo.option": "Capture Photo",
"camera_type.title": "Choose an action",
"camera_type.video.option": "Record Video",
"center_panel.archived.closeChannel": "Close Channel",
"channel": "{count, plural, one {# member} other {# members}}",
"channel_header.directchannel.you": "{displayname} (you)",
"channel_loader.someone": "Someone",
@ -61,6 +66,9 @@
"combined_system_message.removed_from_team.one_you": "You were **removed from the team**.",
"combined_system_message.removed_from_team.two": "{firstUser} and {secondUser} were **removed from the team**.",
"combined_system_message.you": "You",
"create_comment.addComment": "Add a comment...",
"create_post.deactivated": "You are viewing an archived channel with a deactivated user.",
"create_post.write": "Write to {channelDisplayName}",
"custom_status.expiry_dropdown.custom": "Custom",
"custom_status.expiry_dropdown.date_and_time": "Date and Time",
"custom_status.expiry_dropdown.dont_clear": "Don't clear",
@ -102,6 +110,7 @@
"emoji_skin.medium_dark_skin_tone": "medium dark skin tone",
"emoji_skin.medium_light_skin_tone": "medium light skin tone",
"emoji_skin.medium_skin_tone": "medium skin tone",
"file_upload.fileAbove": "Files must be less than {max}",
"intro.add_people": "Add People",
"intro.channel_details": "Details",
"intro.created_by": "created by {creator} on {date}.",
@ -154,6 +163,13 @@
"mobile.action_menu.select": "Select an option",
"mobile.add_team.create_team": "Create a New Team",
"mobile.add_team.join_team": "Join Another Team",
"mobile.android.photos_permission_denied_description": "Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.",
"mobile.android.photos_permission_denied_title": "{applicationName} would like to access your photos",
"mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your camera.",
"mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera",
"mobile.channel_info.alertNo": "No",
"mobile.channel_info.alertYes": "Yes",
"mobile.commands.error_title": "Error Executing Command",
"mobile.components.select_server_view.connect": "Connect",
"mobile.components.select_server_view.connecting": "Connecting",
"mobile.components.select_server_view.displayHelp": "Choose a display name for your server",
@ -163,6 +179,7 @@
"mobile.components.select_server_view.msg_description": "A Server is your team's communication hub which is accessed through a unique URL",
"mobile.components.select_server_view.msg_welcome": "Welcome",
"mobile.components.select_server_view.proceed": "Proceed",
"mobile.create_post.read_only": "This channel is read-only.",
"mobile.custom_status.choose_emoji": "Choose an emoji",
"mobile.custom_status.clear_after": "Clear After",
"mobile.custom_status.clear_after.title": "Clear Custom Status After",
@ -178,6 +195,13 @@
"mobile.error_handler.button": "Relaunch",
"mobile.error_handler.description": "\nTap relaunch to open the app again. After restart, you can report the problem from the settings menu.\n",
"mobile.error_handler.title": "Unexpected error occurred",
"mobile.file_upload.disabled2": "File uploads from mobile are disabled.",
"mobile.file_upload.max_warning": "Uploads limited to {count} files maximum.",
"mobile.files_paste.error_description": "An error occurred while pasting the file(s). Please try again.",
"mobile.files_paste.error_dismiss": "Dismiss",
"mobile.files_paste.error_title": "Paste failed",
"mobile.ios.photos_permission_denied_description": "Upload photos and videos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo and video library.",
"mobile.ios.photos_permission_denied_title": "{applicationName} would like to access your photos",
"mobile.join_channel.error": "We couldn't join the channel {displayName}. Please check your connection and try again.",
"mobile.link.error.text": "Unable to open the link.",
"mobile.link.error.title": "Error",
@ -206,6 +230,8 @@
"mobile.markdown.image.too_large": "Image exceeds max dimensions of {maxWidth} by {maxHeight}:",
"mobile.markdown.link.copy_url": "Copy URL",
"mobile.mention.copy_mention": "Copy Mention",
"mobile.message_length.message": "Your current message is too long. Current character count: {count}/{max}",
"mobile.message_length.title": "Message Length",
"mobile.notice_mobile_link": "mobile apps",
"mobile.notice_platform_link": "server",
"mobile.notice_text": "Mattermost is made possible by the open source software used in our {platform} and {mobile}.",
@ -218,10 +244,26 @@
"mobile.oauth.switch_to_browser.error_title": "Sign in error",
"mobile.oauth.switch_to_browser.title": "Redirecting...",
"mobile.oauth.try_again": "Try again",
"mobile.permission_denied_dismiss": "Don't Allow",
"mobile.permission_denied_retry": "Settings",
"mobile.post_info.add_reaction": "Add Reaction",
"mobile.post_pre_header.flagged": "Saved",
"mobile.post_pre_header.pinned": "Pinned",
"mobile.post_pre_header.pinned_flagged": "Pinned and Saved",
"mobile.post_textbox.entire_channel_here.message": "By using @here you are about to send notifications to up to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?",
"mobile.post_textbox.entire_channel_here.message.with_timezones": "By using @here you are about to send notifications up to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?",
"mobile.post_textbox.entire_channel.cancel": "Cancel",
"mobile.post_textbox.entire_channel.confirm": "Confirm",
"mobile.post_textbox.entire_channel.message": "By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?",
"mobile.post_textbox.entire_channel.message.with_timezones": "By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?",
"mobile.post_textbox.entire_channel.title": "Confirm sending notifications to entire channel",
"mobile.post_textbox.groups.title": "Confirm sending notifications to groups",
"mobile.post_textbox.multi_group.message.with_timezones": "By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?",
"mobile.post_textbox.multi_group.message.without_timezones": "By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people. Are you sure you want to do this?",
"mobile.post_textbox.one_group.message.with_timezones": "By using {mention} you are about to send notifications to {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?",
"mobile.post_textbox.one_group.message.without_timezones": "By using {mention} you are about to send notifications to {totalMembers} people. Are you sure you want to do this?",
"mobile.post_textbox.uploadFailedDesc": "Some attachments failed to upload to the server. Are you sure you want to post the message?",
"mobile.post_textbox.uploadFailedTitle": "Attachment failure",
"mobile.post.cancel": "Cancel",
"mobile.post.failed_delete": "Delete Message",
"mobile.post.failed_retry": "Try Again",
@ -242,8 +284,11 @@
"mobile.routes.user_profile": "Profile",
"mobile.screen.your_profile": "Your Profile",
"mobile.server_identifier.exists": "You are already connected to this server.",
"mobile.server_link.error.text": "The link could not be found on this server.",
"mobile.server_link.error.title": "Link Error",
"mobile.server_link.unreachable_channel.error": "This link belongs to a deleted channel or to a channel to which you do not have access.",
"mobile.server_link.unreachable_team.error": "This link belongs to a deleted team or to a team to which you do not have access.",
"mobile.server_link.unreachable_user.error": "We can't redirect you to the DM. The user specified is unknown.",
"mobile.server_name.exists": "You are using this name for another server.",
"mobile.server_ping_failed": "Cannot connect to the server.",
"mobile.server_requires_client_certificate": "Server requires client certificate for authentication.",
@ -261,6 +306,8 @@
"mobile.set_status.dnd": "Do Not Disturb",
"mobile.set_status.offline": "Offline",
"mobile.set_status.online": "Online",
"mobile.storage_permission_denied_description": "Upload files to your server. Open Settings to grant {applicationName} Read and Write access to files on this device.",
"mobile.storage_permission_denied_title": "{applicationName} would like to access your files",
"mobile.system_message.channel_archived_message": "{username} archived the channel",
"mobile.system_message.channel_unarchived_message": "{username} unarchived the channel",
"mobile.system_message.update_channel_displayname_message_and_forget.updated_from": "{username} updated the channel display name from: {oldDisplayName} to: {newDisplayName}",
@ -282,6 +329,8 @@
"modal.manual_status.auto_responder.message_offline": "Would you like to switch your status to \"Offline\" and disable Automatic Replies?",
"modal.manual_status.auto_responder.message_online": "Would you like to switch your status to \"Online\" and disable Automatic Replies?",
"more_messages.text": "{count} new {count, plural, one {message} other {messages}}",
"msg_typing.areTyping": "{users} and {last} are typing...",
"msg_typing.isTyping": "{user} is typing...",
"notification.message_not_found": "Message not found",
"notification.not_channel_member": "This message belongs to a channel where you are not a member.",
"notification.not_team_member": "This message belongs to a team where you are not a member.",

View file

@ -281,7 +281,7 @@ PODS:
- SwiftyJSON (~> 5.0)
- react-native-notifications (4.1.3):
- React-Core
- react-native-paste-input (0.3.6):
- react-native-paste-input (0.3.7):
- React-Core
- Swime (= 3.0.6)
- react-native-safe-area-context (3.3.2):
@ -782,7 +782,7 @@ SPEC CHECKSUMS:
react-native-netinfo: 87e5bfaf21ea5c6c110941290aa481dd8e849f98
react-native-network-client: 30ab97e7e6c8d6f2d2b10cc1ebad0cbf9c894c6e
react-native-notifications: 805108822ceff3440644d5701944f0cda35f5b4b
react-native-paste-input: 80c06e2c5c65afd696f9bd43cfd371141d8b3a1b
react-native-paste-input: 7d19610119115a3434c145867775723a06052362
react-native-safe-area-context: 584dc04881deb49474363f3be89e4ca0e854c057
react-native-video: a4c2635d0802f983594b7057e1bce8f442f0ad28
react-native-webview: 162b6453d074e0b1c7025242bb7a939b6f72b9e7

14
package-lock.json generated
View file

@ -17,7 +17,7 @@
"@formatjs/intl-relativetimeformat": "9.5.1",
"@mattermost/react-native-emm": "1.1.8",
"@mattermost/react-native-network-client": "github:mattermost/react-native-network-client",
"@mattermost/react-native-paste-input": "0.3.6",
"@mattermost/react-native-paste-input": "0.3.7",
"@nozbe/watermelondb": "0.24.0",
"@nozbe/with-observables": "1.4.0",
"@react-native-async-storage/async-storage": "1.15.17",
@ -3562,9 +3562,9 @@
}
},
"node_modules/@mattermost/react-native-paste-input": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-paste-input/-/react-native-paste-input-0.3.6.tgz",
"integrity": "sha512-/XwUkWfkXDPv1/N+3sILKRoqa4sElqN/fADQzkC2KHYxVKN72297vMm8s+X1n2l+y7phNQ8ZmhjjL0ghuz/1og==",
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-paste-input/-/react-native-paste-input-0.3.7.tgz",
"integrity": "sha512-QrvKjA7m963ad8q5BRCXeHv7RID9YmWrnUe+nxBJrqiJI0lomepxHoDd9hhlCADqwKU2ufYEnpeGNblwzG+BaA==",
"peerDependencies": {
"react": "*",
"react-native": "*"
@ -26806,9 +26806,9 @@
}
},
"@mattermost/react-native-paste-input": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-paste-input/-/react-native-paste-input-0.3.6.tgz",
"integrity": "sha512-/XwUkWfkXDPv1/N+3sILKRoqa4sElqN/fADQzkC2KHYxVKN72297vMm8s+X1n2l+y7phNQ8ZmhjjL0ghuz/1og==",
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-paste-input/-/react-native-paste-input-0.3.7.tgz",
"integrity": "sha512-QrvKjA7m963ad8q5BRCXeHv7RID9YmWrnUe+nxBJrqiJI0lomepxHoDd9hhlCADqwKU2ufYEnpeGNblwzG+BaA==",
"requires": {}
},
"@nicolo-ribaudo/chokidar-2": {

View file

@ -15,7 +15,7 @@
"@formatjs/intl-relativetimeformat": "9.5.1",
"@mattermost/react-native-emm": "1.1.8",
"@mattermost/react-native-network-client": "github:mattermost/react-native-network-client",
"@mattermost/react-native-paste-input": "0.3.6",
"@mattermost/react-native-paste-input": "0.3.7",
"@nozbe/watermelondb": "0.24.0",
"@nozbe/with-observables": "1.4.0",
"@react-native-async-storage/async-storage": "1.15.17",

View file

@ -0,0 +1,10 @@
diff --git a/node_modules/react-native-hw-keyboard-event/index.d.ts b/node_modules/react-native-hw-keyboard-event/index.d.ts
index 91999f1..53c7a42 100644
--- a/node_modules/react-native-hw-keyboard-event/index.d.ts
+++ b/node_modules/react-native-hw-keyboard-event/index.d.ts
@@ -1,4 +1,4 @@
declare module "react-native-hw-keyboard-event";
-export function onHWKeyPressed(hwKeyEvent: { pressedKey: string }): void;
+export function onHWKeyPressed(callback: (hwKeyEvent: { pressedKey: string }) => void): void;
export function removeOnHWKeyPressed(): void;

View file

@ -1,5 +1,5 @@
diff --git a/node_modules/react-native-keyboard-tracking-view/lib/KeyboardTrackingViewManager.m b/node_modules/react-native-keyboard-tracking-view/lib/KeyboardTrackingViewManager.m
index 1333a10..6922a17 100644
index 1333a10..f0515fd 100644
--- a/node_modules/react-native-keyboard-tracking-view/lib/KeyboardTrackingViewManager.m
+++ b/node_modules/react-native-keyboard-tracking-view/lib/KeyboardTrackingViewManager.m
@@ -23,7 +23,7 @@
@ -11,7 +11,7 @@ index 1333a10..6922a17 100644
typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
KeyboardTrackingScrollBehaviorNone,
@@ -40,6 +40,7 @@ @interface KeyboardTrackingView : UIView
@@ -40,6 +40,7 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
CGFloat _bottomViewHeight;
}
@ -19,13 +19,14 @@ index 1333a10..6922a17 100644
@property (nonatomic, strong) UIScrollView *scrollViewToManage;
@property (nonatomic) BOOL scrollIsInverted;
@property (nonatomic) BOOL revealKeyboardInteractive;
@@ -53,6 +54,13 @@ @interface KeyboardTrackingView : UIView
@@ -53,6 +54,14 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
@property (nonatomic) BOOL scrollToFocusedInput;
@property (nonatomic) BOOL allowHitsOutsideBounds;
+@property (nonatomic) BOOL normalList;
+@property (nonatomic) NSString* scrollViewNativeID;
+@property (nonatomic) CGFloat initialOffsetY;
+@property (nonatomic) NSInteger viewInitialOffsetY;
+@property (nonatomic) BOOL initialOffsetIsSet;
+@property (nonatomic, strong) UIView *accessoriesContainer;
+@property (nonatomic) NSString* accessoriesContainerID;
@ -33,7 +34,7 @@ index 1333a10..6922a17 100644
@end
@interface KeyboardTrackingView () <ObservingInputAccessoryViewDelegate, UIScrollViewDelegate>
@@ -70,12 +78,17 @@ -(instancetype)init
@@ -70,12 +79,18 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
[self addObserver:self forKeyPath:@"bounds" options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew context:NULL];
_inputViewsMap = [NSMapTable weakToWeakObjectsMapTable];
_deferedInitializeAccessoryViewsCount = 0;
@ -44,6 +45,7 @@ index 1333a10..6922a17 100644
+ _initialOffsetY = 0;
+ _initialOffsetIsSet = NO;
+ _viewInitialOffsetY = 0;
+
_manageScrollView = YES;
_allowHitsOutsideBounds = NO;
@ -51,7 +53,7 @@ index 1333a10..6922a17 100644
_bottomViewHeight = kBottomViewHeight;
@@ -134,7 +147,7 @@ -(void)_swizzleWebViewInputAccessory:(WKWebView*)webview
@@ -134,7 +149,7 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
UIView* subview;
for (UIView* view in webview.scrollView.subviews)
{
@ -60,7 +62,7 @@ index 1333a10..6922a17 100644
{
subview = view;
}
@@ -167,33 +180,32 @@ -(void)layoutSubviews
@@ -167,33 +182,32 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
- (void)initializeAccessoryViewsAndHandleInsets
{
NSArray<UIView*>* allSubviews = [self getBreadthFirstSubviewsForView:[self getRootView]];
@ -109,7 +111,7 @@ index 1333a10..6922a17 100644
}
if ([subview isKindOfClass:NSClassFromString(@"RCTTextField")])
@@ -242,7 +254,7 @@ - (void)initializeAccessoryViewsAndHandleInsets
@@ -242,7 +256,7 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
}
}
@ -118,7 +120,7 @@ index 1333a10..6922a17 100644
{
if(scrollView.scrollView == _scrollViewToManage)
{
@@ -267,6 +279,21 @@ - (void)initializeAccessoryViewsAndHandleInsets
@@ -267,6 +281,21 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
[self addBottomViewIfNecessary];
}
@ -140,7 +142,7 @@ index 1333a10..6922a17 100644
- (void)setupTextView:(UITextView*)textView
{
if (textView != nil)
@@ -343,7 +370,7 @@ - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(N
@@ -343,7 +372,7 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
- (void)observingInputAccessoryViewKeyboardWillDisappear:(ObservingInputAccessoryView *)observingInputAccessoryView
{
@ -149,7 +151,7 @@ index 1333a10..6922a17 100644
[self updateBottomViewFrame];
}
@@ -388,32 +415,42 @@ - (void)_updateScrollViewInsets
@@ -388,32 +417,42 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
{
if(self.scrollViewToManage != nil)
{
@ -197,7 +199,7 @@ index 1333a10..6922a17 100644
}
}
else if(self.scrollBehavior == KeyboardTrackingScrollBehaviorFixedOffset && !self.isDraggingScrollView)
@@ -422,16 +459,21 @@ - (void)_updateScrollViewInsets
@@ -422,16 +461,21 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
self.scrollViewToManage.contentOffset = CGPointMake(originalOffset.x, originalOffset.y + insetsDiff);
}
@ -207,7 +209,7 @@ index 1333a10..6922a17 100644
- insets.top = bottomInset;
+ CGFloat kHeight = _observingInputAccessoryView.keyboardHeight;
+ if (kHeight != 0 && (_observingInputAccessoryView.keyboardState == KeyboardStateShown || _observingInputAccessoryView.keyboardState == KeyboardStateWillShow)) {
+ kHeight -= bottomSafeArea;
+ kHeight -= (bottomSafeArea + _viewInitialOffsetY);
}
- else
- {
@ -219,7 +221,7 @@ index 1333a10..6922a17 100644
+ self.scrollViewToManage.frame = frame;
+
+ if (self.accessoriesContainer) {
+ CGFloat containerPositionY = self.normalList ? 0 : _observingInputAccessoryView.keyboardHeight;
+ CGFloat containerPositionY = self.normalList ? 0 : kHeight;
+ self.accessoriesContainer.bounds = CGRectMake(self.accessoriesContainer.bounds.origin.x, containerPositionY,
+ self.accessoriesContainer.bounds.size.width, self.accessoriesContainer.bounds.size.height);
}
@ -227,7 +229,7 @@ index 1333a10..6922a17 100644
}
}
@@ -448,7 +490,6 @@ -(void)addBottomViewIfNecessary
@@ -448,7 +492,6 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
if (self.addBottomView && _bottomView == nil)
{
_bottomView = [UIView new];
@ -235,7 +237,7 @@ index 1333a10..6922a17 100644
[self addSubview:_bottomView];
[self updateBottomViewFrame];
}
@@ -467,6 +508,12 @@ -(void)updateBottomViewFrame
@@ -467,6 +510,12 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
}
}
@ -248,8 +250,12 @@ index 1333a10..6922a17 100644
#pragma mark - safe area
-(void)safeAreaInsetsDidChange
@@ -510,7 +557,7 @@ -(void)updateTransformAndInsets
CGFloat accessoryTranslation = MIN(-bottomSafeArea, -_observingInputAccessoryView.keyboardHeight);
@@ -507,10 +556,10 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
-(void)updateTransformAndInsets
{
CGFloat bottomSafeArea = [self getBottomSafeArea];
- CGFloat accessoryTranslation = MIN(-bottomSafeArea, -_observingInputAccessoryView.keyboardHeight);
+ CGFloat accessoryTranslation = MIN(-bottomSafeArea, -(_observingInputAccessoryView.keyboardHeight - _viewInitialOffsetY));
if (_observingInputAccessoryView.keyboardHeight <= bottomSafeArea) {
- _bottomViewHeight = kBottomViewHeight;
@ -257,7 +263,7 @@ index 1333a10..6922a17 100644
} else if (_observingInputAccessoryView.keyboardState != KeyboardStateWillHide) {
_bottomViewHeight = 0;
}
@@ -582,6 +629,8 @@ - (void)scrollViewDidScroll:(UIScrollView *)scrollView
@@ -582,6 +631,8 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
{
self.isDraggingScrollView = YES;
@ -266,7 +272,7 @@ index 1333a10..6922a17 100644
}
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset
@@ -592,6 +641,15 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoi
@@ -592,6 +643,15 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) {
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
self.isDraggingScrollView = NO;
@ -282,11 +288,12 @@ index 1333a10..6922a17 100644
}
- (CGFloat)getKeyboardHeight
@@ -634,6 +692,12 @@ @implementation KeyboardTrackingViewManager
@@ -634,6 +694,13 @@ RCT_REMAP_VIEW_PROPERTY(requiresSameParentToManageScrollView, requiresSameParent
RCT_REMAP_VIEW_PROPERTY(addBottomView, addBottomView, BOOL)
RCT_REMAP_VIEW_PROPERTY(scrollToFocusedInput, scrollToFocusedInput, BOOL)
RCT_REMAP_VIEW_PROPERTY(allowHitsOutsideBounds, allowHitsOutsideBounds, BOOL)
+RCT_REMAP_VIEW_PROPERTY(normalList, normalList, BOOL)
+RCT_REMAP_VIEW_PROPERTY(viewInitialOffsetY, viewInitialOffsetY, NSInteger)
+RCT_EXPORT_VIEW_PROPERTY(scrollViewNativeID, NSString)
+RCT_EXPORT_VIEW_PROPERTY(accessoriesContainerID, NSString)
+RCT_CUSTOM_VIEW_PROPERTY(backgroundColor, UIColor, KeyboardTrackingView) {
@ -295,7 +302,7 @@ index 1333a10..6922a17 100644
+ (BOOL)requiresMainQueueSetup
{
@@ -654,6 +718,20 @@ - (UIView *)view
@@ -654,6 +721,20 @@ RCT_REMAP_VIEW_PROPERTY(allowHitsOutsideBounds, allowHitsOutsideBounds, BOOL)
return [[KeyboardTrackingView alloc] init];
}

View file

@ -6,6 +6,7 @@ type logLevel = 'ERROR' | 'WARNING' | 'INFO';
type ClientOptions = {
body?: any;
method?: string;
noRetry?: boolean;
};
interface ClientErrorProps extends Error {

7
types/api/commands.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
interface CommandResponse {
goto_location?: string;
trigger_id?: string;
}

View file

@ -133,6 +133,7 @@ interface ClientConfig {
LdapPositionAttributeSet: string;
LockTeammateNameDisplay: string;
MaxFileSize: string;
MaxPostSize: string;
MaxNotificationsPerChannel: string;
MinimumHashtagLength: string;
OpenIdButtonColor: string;

View file

@ -3,6 +3,7 @@
type FileInfo = {
id?: string;
bytesRead?: number;
clientId?: string;
create_at: number;
delete_at: number;
@ -10,14 +11,11 @@ type FileInfo = {
failed?: boolean;
has_preview_image: boolean;
height: number;
loading?: boolean;
localPath?: string;
mime_type: string;
mini_preview?: string;
name: string;
post_id: string;
progress?: number;
bytesRead?: number;
size: number;
update_at: number;
uri?: string;

View file

@ -61,7 +61,6 @@ type Post = {
file_ids?: any[];
metadata: PostMetadata;
last_reply_at?: number;
failed?: boolean;
user_activity_posts?: Post[];
state?: 'DELETED';
prev_post_id?: string;

View file

@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Asset} from 'react-native-image-picker';
export interface QuickActionAttachmentProps {
disabled: boolean;
fileCount?: number;
maxFilesReached: boolean;
maxFileCount: number;
onUploadFiles: (files: Asset[]) => void;
testID?: string;
}

View file

@ -22,19 +22,24 @@ export interface DeepLinkGM extends DeepLink {
channelId: string;
}
export interface DeepLinkPlugin extends DeepLink {
id: string;
}
export const DeepLinkType = {
Channel: 'channel',
DirectMessage: 'dm',
GroupMessage: 'gm',
Invalid: 'invalid',
Permalink: 'permalink',
Plugin: 'plugin',
} as const;
export type DeepLinkType = typeof DeepLinkType[keyof typeof DeepLinkType];
export interface DeepLinkWithData {
type: DeepLinkType;
data?: DeepLinkChannel | DeepLinkDM | DeepLinkGM | DeepLinkPermalink;
data?: DeepLinkChannel | DeepLinkDM | DeepLinkGM | DeepLinkPermalink | DeepLinkPlugin;
}
export const LaunchType = {

View file

@ -1,4 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
declare module 'react-native-keyboard-tracking-view'
declare module 'react-native-keyboard-tracking-view' {
import {ViewProps} from 'react-native';
export interface KeyboardTrackingViewRef {
resetScrollView: (id: string) => void;
setNativeProps(nativeProps: object): void;
}
interface KeyboardTrackingViewProps extends ViewProps{
accessoriesContainerID?: string;
normalList?: boolean;
scrollViewNativeID?: string;
viewInitialOffsetY?: number;
}
export const KeyboardTrackingView: React.ForwardRefExoticComponent<KeyboardTrackingViewProps & React.RefAttributes<KeyboardTrackingViewRef>>;
}

View file

@ -1,10 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as FileSystem from 'expo-file-system';
type ErrorText = Partial<ClientErrorProps> | string;
export type ErrorText = Partial<ClientErrorProps> | string;
type ExtractedFileInfo = Partial<FileInfo> & { name: string; mime_type: string}
export type ExtractedFileInfo = Partial<FileSystem.FileInfo> & { name: string; mime_type: string}
export type UploadExtractedFile = (files?: ExtractedFileInfo[]) => void;
type UploadExtractedFile = (files?: ExtractedFileInfo[]) => void;