[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:
parent
f815f6b3e5
commit
55324127e1
100 changed files with 4232 additions and 243 deletions
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [{
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
202
app/actions/remote/command.ts
Normal file
202
app/actions/remote/command.ts
Normal 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};
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
103
app/components/post_draft/archived/index.tsx
Normal file
103
app/components/post_draft/archived/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/components/post_draft/cursor_position_handler/index.tsx
Normal file
37
app/components/post_draft/cursor_position_handler/index.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
139
app/components/post_draft/draft_handler/draft_handler.tsx
Normal file
139
app/components/post_draft/draft_handler/draft_handler.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
68
app/components/post_draft/draft_handler/index.ts
Normal file
68
app/components/post_draft/draft_handler/index.ts
Normal 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));
|
||||
174
app/components/post_draft/draft_input/index.tsx
Normal file
174
app/components/post_draft/draft_input/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
app/components/post_draft/index.ts
Normal file
81
app/components/post_draft/index.ts
Normal 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));
|
||||
107
app/components/post_draft/post_draft.tsx
Normal file
107
app/components/post_draft/post_draft.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
app/components/post_draft/post_input/index.ts
Normal file
67
app/components/post_draft/post_input/index.ts
Normal 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));
|
||||
319
app/components/post_draft/post_input/post_input.tsx
Normal file
319
app/components/post_draft/post_input/post_input.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
45
app/components/post_draft/quick_actions/index.ts
Normal file
45
app/components/post_draft/quick_actions/index.ts
Normal 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));
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
101
app/components/post_draft/quick_actions/quick_actions.tsx
Normal file
101
app/components/post_draft/quick_actions/quick_actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
app/components/post_draft/read_only/index.tsx
Normal file
72
app/components/post_draft/read_only/index.tsx
Normal 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;
|
||||
75
app/components/post_draft/send_action/index.tsx
Normal file
75
app/components/post_draft/send_action/index.tsx
Normal 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;
|
||||
120
app/components/post_draft/send_handler/index.ts
Normal file
120
app/components/post_draft/send_handler/index.ts
Normal 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));
|
||||
286
app/components/post_draft/send_handler/send_handler.tsx
Normal file
286
app/components/post_draft/send_handler/send_handler.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
157
app/components/post_draft/typing/index.tsx
Normal file
157
app/components/post_draft/typing/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
app/components/post_draft/uploads/index.tsx
Normal file
163
app/components/post_draft/uploads/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
app/components/post_draft/uploads/upload_item/index.tsx
Normal file
155
app/components/post_draft/uploads/upload_item/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => {
|
|||
<View style={[style.image, {width, height}]}>
|
||||
<FileIcon
|
||||
failed={true}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -171,7 +171,6 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
|
|||
<FileIcon
|
||||
backgroundColor={backgroundColor}
|
||||
file={file}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -6,5 +6,6 @@ export default {
|
|||
DMCHANNEL: 'dmchannel',
|
||||
GROUPCHANNEL: 'groupchannel',
|
||||
PERMALINK: 'permalink',
|
||||
PLUGIN: 'plugin',
|
||||
OTHER: 'other',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
21
app/constants/post_draft.ts
Normal file
21
app/constants/post_draft.ts
Normal 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,
|
||||
};
|
||||
8
app/constants/server_errors.ts
Normal file
8
app/constants/server_errors.ts
Normal 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',
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -180,6 +180,10 @@ class WebsocketManager {
|
|||
|
||||
this.closeAll();
|
||||
};
|
||||
|
||||
public getClient = (serverUrl: string): WebSocketClient | undefined => {
|
||||
return this.clients[serverUrl];
|
||||
};
|
||||
}
|
||||
|
||||
export default new WebsocketManager();
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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|</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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
14
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
10
patches/react-native-hw-keyboard-event+0.0.4.patch
Normal file
10
patches/react-native-hw-keyboard-event+0.0.4.patch
Normal 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;
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
|||
1
types/api/client.d.ts
vendored
1
types/api/client.d.ts
vendored
|
|
@ -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
7
types/api/commands.d.ts
vendored
Normal 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;
|
||||
}
|
||||
1
types/api/config.d.ts
vendored
1
types/api/config.d.ts
vendored
|
|
@ -133,6 +133,7 @@ interface ClientConfig {
|
|||
LdapPositionAttributeSet: string;
|
||||
LockTeammateNameDisplay: string;
|
||||
MaxFileSize: string;
|
||||
MaxPostSize: string;
|
||||
MaxNotificationsPerChannel: string;
|
||||
MinimumHashtagLength: string;
|
||||
OpenIdButtonColor: string;
|
||||
|
|
|
|||
4
types/api/files.d.ts
vendored
4
types/api/files.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
1
types/api/posts.d.ts
vendored
1
types/api/posts.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
13
types/components/post_draft_quick_action.d.ts
vendored
Normal file
13
types/components/post_draft_quick_action.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
}
|
||||
|
|
|
|||
8
types/utils/file.d.ts
vendored
8
types/utils/file.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue