forked from Ivasoft/mattermost-mobile
MM-36721 - [GEKIDOU] Porting Markdown components (#5586)
* Started with Channel Post List * Added markdown hashtag * Added TouchableWithFeedback component * Added utils/bottom_sheet * Removed BottomSheet in favor of future SlideUpPanel * Added markdown_block_quote * Added markdown_list_item * Added markdown_list * Added MarkDownTableCell component * Markdown_table - in progress - need to verify TS * Added markdown_table * Update Podfile.lock * Added deep_linking constant * Added utils/draft * Update config to include ExperimentalNormalizeMarkdownLinks * Added markdown_link * Added markdown_table_row * Added ProgressiveImage and RetriableImage components and images utils * Converted Retriable component to functional component * Added type definition for commonmark * Continuing with markdown TS * Markdown - Typing props [ in progress ] * Fix boolean flag with mardown block quote * Adding observable config to markdown_link * TS Fixes [ in progress ] * TS fixes * TS fixes - TextStyles * Update markdown.tsx * TS fixes on markdown * TS Fixes - AtMention component * AtMention [ IN PROGRESS ] * Add markdown support * Fix emoji and jumboEmoji on iOS * Fix handleMyTeam operator * Fix navigation style based on theme * Fix iOS MattermostManaged deleteDatabse return error type * wrap setNavigationStackStyles under a requestAnimationFrame * Add preventDoubleTap to channel mention * Increase double tap to 750ms * Fix handleReceivedPostsInChannel chunk query * Set initial navigation theme * Swizzle FastImage on iOS * fix preventDoubleTap test Co-authored-by: Avinash Lingaloo <> Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
@@ -75,6 +75,8 @@ public class MainApplication extends NavigationApplication implements INotificat
|
||||
@Override
|
||||
public NativeModule getModule(String name, ReactApplicationContext reactContext) {
|
||||
switch (name) {
|
||||
case "MattermostManaged":
|
||||
return MattermostManagedModule.getInstance(reactContext);
|
||||
case "MattermostShare":
|
||||
return new ShareModule(instance, reactContext);
|
||||
case "NotificationPreferences":
|
||||
@@ -90,6 +92,7 @@ public class MainApplication extends NavigationApplication implements INotificat
|
||||
public ReactModuleInfoProvider getReactModuleInfoProvider() {
|
||||
return () -> {
|
||||
Map<String, ReactModuleInfo> map = new HashMap<>();
|
||||
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));
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.Promise;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
||||
import com.facebook.react.bridge.ReactMethod;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
|
||||
public class MattermostManagedModule extends ReactContextBaseJavaModule {
|
||||
private static MattermostManagedModule instance;
|
||||
|
||||
private static final String TAG = MattermostManagedModule.class.getSimpleName();
|
||||
|
||||
private MattermostManagedModule(ReactApplicationContext reactContext) {
|
||||
super(reactContext);
|
||||
}
|
||||
|
||||
public static MattermostManagedModule getInstance(ReactApplicationContext reactContext) {
|
||||
if (instance == null) {
|
||||
instance = new MattermostManagedModule(reactContext);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static MattermostManagedModule getInstance() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public String getName() {
|
||||
return "MattermostManaged";
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void isRunningInSplitView(final Promise promise) {
|
||||
WritableMap result = Arguments.createMap();
|
||||
Activity current = getCurrentActivity();
|
||||
if (current != null) {
|
||||
result.putBoolean("isSplitView", current.isInMultiWindowMode());
|
||||
} else {
|
||||
result.putBoolean("isSplitView", false);
|
||||
}
|
||||
|
||||
promise.resolve(result);
|
||||
}
|
||||
}
|
||||
228
app/actions/local/channel.ts
Normal file
228
app/actions/local/channel.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Model} from '@nozbe/watermelondb';
|
||||
|
||||
import {fetchChannelByName, fetchMyChannelsForTeam, joinChannel, markChannelAsViewed} from '@actions/remote/channel';
|
||||
import {fetchPostsForChannel} from '@actions/remote/post';
|
||||
import {fetchRolesIfNeeded} from '@actions/remote/role';
|
||||
import {addUserToTeam, fetchTeamByName, removeUserFromTeam} from '@actions/remote/team';
|
||||
import {General} from '@constants';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {privateChannelJoinPrompt} from '@helpers/api/channel';
|
||||
import {prepareMyChannelsForTeam, queryMyChannel} from '@queries/servers/channel';
|
||||
import {addChannelToTeamHistory, prepareMyTeams, queryMyTeamById, queryTeamById, queryTeamByName} from '@queries/servers/team';
|
||||
import {queryCommonSystemValues, setCurrentChannelId, setCurrentTeamAndChannelId} from '@queries/servers/system';
|
||||
import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url';
|
||||
|
||||
import type {IntlShape} from 'react-intl';
|
||||
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
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 const switchToChannel = async (serverUrl: string, channelId: string) => {
|
||||
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
||||
if (!database) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
}
|
||||
|
||||
try {
|
||||
const dt = Date.now();
|
||||
const system = await queryCommonSystemValues(database);
|
||||
const member = await queryMyChannel(database, channelId);
|
||||
|
||||
if (member) {
|
||||
fetchPostsForChannel(serverUrl, channelId);
|
||||
|
||||
const channel: ChannelModel = await member.channel.fetch();
|
||||
const {operator} = DatabaseManager.serverDatabases[serverUrl];
|
||||
const result = await setCurrentChannelId(operator, channelId);
|
||||
|
||||
let previousChannelId: string | undefined;
|
||||
if (system.currentChannelId !== channelId) {
|
||||
previousChannelId = system.currentChannelId;
|
||||
await addChannelToTeamHistory(operator, system.currentTeamId, channelId, false);
|
||||
}
|
||||
await markChannelAsViewed(serverUrl, channelId, previousChannelId, true);
|
||||
|
||||
if (!result.error) {
|
||||
console.log('channel switch to', channel?.displayName, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
return {error: undefined};
|
||||
};
|
||||
|
||||
export const switchToChannelByName = async (serverUrl: string, channelName: string, teamName: string, errorHandler: (intl: IntlShape) => void, intl: IntlShape) => {
|
||||
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
||||
if (!database) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
}
|
||||
|
||||
try {
|
||||
let myChannel: MyChannelModel | ChannelMembership | undefined;
|
||||
let team: TeamModel | Team | undefined;
|
||||
let myTeam: MyTeamModel | TeamMembership | undefined;
|
||||
let unreads: TeamUnread | undefined;
|
||||
let name = teamName;
|
||||
const roles: string [] = [];
|
||||
const system = await queryCommonSystemValues(database);
|
||||
const currentTeam = await queryTeamById(database, system.currentTeamId);
|
||||
|
||||
if (name === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) {
|
||||
name = currentTeam!.name;
|
||||
} else {
|
||||
team = await queryTeamByName(database, teamName);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
const fetchTeam = await fetchTeamByName(serverUrl, name, true);
|
||||
if (fetchTeam.error) {
|
||||
errorHandler(intl);
|
||||
return {error: fetchTeam.error};
|
||||
}
|
||||
|
||||
team = fetchTeam.team!;
|
||||
}
|
||||
|
||||
let joinedNewTeam = false;
|
||||
myTeam = await queryMyTeamById(database, team.id);
|
||||
if (!myTeam) {
|
||||
const added = await addUserToTeam(serverUrl, team.id, system.currentUserId, true);
|
||||
if (added.error) {
|
||||
errorHandler(intl);
|
||||
return {error: added.error};
|
||||
}
|
||||
myTeam = added.member!;
|
||||
roles.push(...myTeam.roles.split(' '));
|
||||
unreads = added.unreads!;
|
||||
joinedNewTeam = true;
|
||||
}
|
||||
|
||||
if (!myTeam) {
|
||||
errorHandler(intl);
|
||||
return {error: 'Could not fetch team member'};
|
||||
}
|
||||
|
||||
let isArchived = false;
|
||||
const chReq = await fetchChannelByName(serverUrl, team.id, channelName);
|
||||
if (chReq.error) {
|
||||
errorHandler(intl);
|
||||
return {error: chReq.error};
|
||||
}
|
||||
const channel = chReq.channel;
|
||||
if (!channel) {
|
||||
errorHandler(intl);
|
||||
return {error: 'Could not fetch channel'};
|
||||
}
|
||||
|
||||
isArchived = channel.delete_at > 0;
|
||||
if (isArchived && system.config.ExperimentalViewArchivedChannels !== 'true') {
|
||||
errorHandler(intl);
|
||||
return {error: 'Channel is archived'};
|
||||
}
|
||||
|
||||
myChannel = await queryMyChannel(database, channel.id);
|
||||
|
||||
if (!myChannel) {
|
||||
if (channel.type === General.PRIVATE_CHANNEL) {
|
||||
const displayName = channel.display_name;
|
||||
const {join} = await privateChannelJoinPrompt(displayName, intl);
|
||||
if (!join) {
|
||||
if (joinedNewTeam) {
|
||||
await removeUserFromTeam(serverUrl, team.id, system.currentUserId, true);
|
||||
}
|
||||
errorHandler(intl);
|
||||
return {error: 'Refused to join Private channel'};
|
||||
}
|
||||
console.log('joining channel', displayName, channel.id); //eslint-disable-line
|
||||
const result = await joinChannel(serverUrl, system.currentUserId, team.id, channel.id, undefined, true);
|
||||
if (result.error || !result.channel) {
|
||||
if (joinedNewTeam) {
|
||||
await removeUserFromTeam(serverUrl, team.id, system.currentUserId, true);
|
||||
}
|
||||
|
||||
errorHandler(intl);
|
||||
return {error: result.error};
|
||||
}
|
||||
|
||||
myChannel = result.member!;
|
||||
roles.push(...myChannel.roles.split(' '));
|
||||
}
|
||||
}
|
||||
|
||||
if (!myChannel) {
|
||||
errorHandler(intl);
|
||||
return {error: 'could not fetch channel member'};
|
||||
}
|
||||
|
||||
const modelPromises: Array<Promise<Model[]>> = [];
|
||||
const {operator} = DatabaseManager.serverDatabases[serverUrl];
|
||||
if (!(team instanceof Model)) {
|
||||
const prepT = prepareMyTeams(operator, [team], [(myTeam as TeamMembership)], [unreads!]);
|
||||
if (prepT) {
|
||||
modelPromises.push(...prepT);
|
||||
}
|
||||
} else if (!(myTeam instanceof Model)) {
|
||||
const mt: MyTeam[] = [{
|
||||
id: myTeam.team_id,
|
||||
roles: myTeam.roles,
|
||||
is_unread: unreads!.msg_count > 0,
|
||||
mentions_count: unreads!.mention_count,
|
||||
}];
|
||||
modelPromises.push(
|
||||
operator.handleMyTeam({myTeams: mt, prepareRecordsOnly: true}),
|
||||
operator.handleTeamMemberships({teamMemberships: [myTeam], prepareRecordsOnly: true}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!(myChannel instanceof Model)) {
|
||||
const prepCh = await prepareMyChannelsForTeam(operator, team.id, [channel], [myChannel]);
|
||||
if (prepCh) {
|
||||
modelPromises.push(...prepCh);
|
||||
}
|
||||
}
|
||||
|
||||
let teamId;
|
||||
if (team.id !== system.currentTeamId) {
|
||||
teamId = team.id;
|
||||
}
|
||||
|
||||
let channelId;
|
||||
if (channel.id !== system.currentChannelId) {
|
||||
channelId = channel.id;
|
||||
}
|
||||
|
||||
if (modelPromises.length) {
|
||||
const models = await Promise.all(modelPromises);
|
||||
await operator.batchRecords(models.flat());
|
||||
}
|
||||
|
||||
if (channelId) {
|
||||
fetchPostsForChannel(serverUrl, channelId);
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
fetchMyChannelsForTeam(serverUrl, teamId, true, 0, false, true);
|
||||
}
|
||||
|
||||
await setCurrentTeamAndChannelId(operator, teamId, channelId);
|
||||
if (teamId && channelId) {
|
||||
await addChannelToTeamHistory(operator, teamId, channelId, false);
|
||||
}
|
||||
|
||||
if (roles.length) {
|
||||
fetchRolesIfNeeded(serverUrl, roles);
|
||||
}
|
||||
|
||||
return {error: undefined};
|
||||
} catch (error) {
|
||||
errorHandler(intl);
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
82
app/actions/local/permalink.ts
Normal file
82
app/actions/local/permalink.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Keyboard} from 'react-native';
|
||||
|
||||
import type {IntlShape} from 'react-intl';
|
||||
|
||||
import {fetchMyChannelsForTeam} from '@actions/remote/channel';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {queryCommonSystemValues} from '@queries/servers/system';
|
||||
import {queryTeamById, queryTeamByName} from '@queries/servers/team';
|
||||
import {dismissAllModals, showModalOverCurrentContext} from '@screens/navigation';
|
||||
import {permalinkBadTeam} from '@utils/draft';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url';
|
||||
|
||||
import type TeamModel from '@typings/database/models/servers/team';
|
||||
|
||||
let showingPermalink = false;
|
||||
|
||||
export const showPermalink = async (serverUrl: string, teamName: string, postId: string, intl: IntlShape, openAsPermalink = true) => {
|
||||
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
||||
if (!database) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
}
|
||||
|
||||
try {
|
||||
let name = teamName;
|
||||
let team: TeamModel | undefined;
|
||||
const system = await queryCommonSystemValues(database);
|
||||
if (!name || name === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) {
|
||||
team = await queryTeamById(database, system.currentTeamId);
|
||||
if (team) {
|
||||
name = team.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
team = await queryTeamByName(database, name);
|
||||
if (!team) {
|
||||
permalinkBadTeam(intl);
|
||||
return {error: 'Bad Permalink team'};
|
||||
}
|
||||
}
|
||||
|
||||
if (team.id !== system.currentTeamId) {
|
||||
const result = await fetchMyChannelsForTeam(serverUrl, team.id, true, 0, false, true);
|
||||
if (result.error) {
|
||||
return {error: result.error};
|
||||
}
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
if (showingPermalink) {
|
||||
await dismissAllModals();
|
||||
}
|
||||
|
||||
const screen = 'Permalink';
|
||||
const passProps = {
|
||||
isPermalink: openAsPermalink,
|
||||
teamName,
|
||||
postId,
|
||||
};
|
||||
|
||||
const options = {
|
||||
layout: {
|
||||
componentBackgroundColor: changeOpacity('#000', 0.2),
|
||||
},
|
||||
};
|
||||
|
||||
showingPermalink = true;
|
||||
showModalOverCurrentContext(screen, passProps, options);
|
||||
|
||||
return {error: undefined};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const closePermalink = () => {
|
||||
showingPermalink = false;
|
||||
};
|
||||
@@ -4,11 +4,12 @@
|
||||
import {General} from '@constants';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import NetworkManager from '@init/network_manager';
|
||||
import {prepareMyChannelsForTeam} from '@queries/servers/channel';
|
||||
import {prepareMyChannelsForTeam, queryMyChannel} from '@queries/servers/channel';
|
||||
import {displayGroupMessageName, displayUsername} from '@utils/user';
|
||||
|
||||
import type {Model} from '@nozbe/watermelondb';
|
||||
|
||||
import {fetchRolesIfNeeded} from './role';
|
||||
import {forceLogoutIfNecessary} from './session';
|
||||
import {fetchProfilesPerChannels} from './user';
|
||||
|
||||
@@ -18,6 +19,31 @@ export type MyChannelsRequest = {
|
||||
error?: never;
|
||||
}
|
||||
|
||||
export const fetchChannelByName = async (serverUrl: string, teamId: string, channelName: string, fetchOnly = false) => {
|
||||
let client;
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
try {
|
||||
const channel = await client.getChannelByName(teamId, channelName, true);
|
||||
|
||||
if (!fetchOnly) {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (operator) {
|
||||
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||
}
|
||||
}
|
||||
|
||||
return {channel};
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(serverUrl, error);
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchMyChannelsForTeam = async (serverUrl: string, teamId: string, includeDeleted = true, since = 0, fetchOnly = false, excludeDirect = false): Promise<MyChannelsRequest> => {
|
||||
let client;
|
||||
try {
|
||||
@@ -107,3 +133,119 @@ export const fetchMissingSidebarInfo = async (serverUrl: string, directChannels:
|
||||
operator.handleChannel({channels: directChannels, prepareRecordsOnly: false});
|
||||
}
|
||||
};
|
||||
|
||||
export const joinChannel = async (serverUrl: string, userId: string, teamId: string, channelId?: string, channelName?: string, fetchOnly = false) => {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
}
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
let member: ChannelMembership | undefined;
|
||||
let channel: Channel | undefined;
|
||||
try {
|
||||
if (channelId) {
|
||||
member = await client.addToChannel(userId, channelId);
|
||||
channel = await client.getChannel(channelId);
|
||||
} else if (channelName) {
|
||||
channel = await client.getChannelByName(teamId, channelName, true);
|
||||
if ([General.GM_CHANNEL, General.DM_CHANNEL].includes(channel.type)) {
|
||||
member = await client.getChannelMember(channel.id, userId);
|
||||
} else {
|
||||
member = await client.addToChannel(userId, channel.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(serverUrl, error);
|
||||
return {error};
|
||||
}
|
||||
|
||||
try {
|
||||
if (channel && member && !fetchOnly) {
|
||||
fetchRolesIfNeeded(serverUrl, member.roles.split(' '));
|
||||
|
||||
const modelPromises: Array<Promise<Model[]>> = [];
|
||||
const prepare = await prepareMyChannelsForTeam(operator, teamId, [channel], [member]);
|
||||
if (prepare) {
|
||||
modelPromises.push(...prepare);
|
||||
}
|
||||
if (modelPromises.length) {
|
||||
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};
|
||||
}
|
||||
|
||||
return {channel, member};
|
||||
};
|
||||
|
||||
export const markChannelAsViewed = async (serverUrl: string, channelId: string, previousChannelId = '', markOnServer = true) => {
|
||||
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
||||
if (!database) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
}
|
||||
|
||||
const member = await queryMyChannel(database, channelId);
|
||||
const prevMember = await queryMyChannel(database, previousChannelId);
|
||||
if (markOnServer) {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
client.viewMyChannel(channelId, prevMember?.manuallyUnread ? '' : previousChannelId).catch(() => {
|
||||
// do nothing just adding the handler to avoid the warning
|
||||
});
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
const models = [];
|
||||
const lastViewedAt = Date.now();
|
||||
if (member) {
|
||||
member.prepareUpdate((m) => {
|
||||
m.messageCount = 0;
|
||||
m.mentionsCount = 0;
|
||||
m.manuallyUnread = false;
|
||||
m.lastViewedAt = lastViewedAt;
|
||||
});
|
||||
|
||||
models.push(member);
|
||||
}
|
||||
|
||||
if (prevMember && !prevMember.manuallyUnread) {
|
||||
prevMember.prepareUpdate((m) => {
|
||||
m.messageCount = 0;
|
||||
m.mentionsCount = 0;
|
||||
m.manuallyUnread = false;
|
||||
m.lastViewedAt = lastViewedAt;
|
||||
});
|
||||
|
||||
models.push(prevMember);
|
||||
}
|
||||
|
||||
try {
|
||||
if (models.length) {
|
||||
const {operator} = DatabaseManager.serverDatabases[serverUrl];
|
||||
await operator.batchRecords(models);
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,11 +6,14 @@ import {Model} from '@nozbe/watermelondb';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import NetworkManager from '@init/network_manager';
|
||||
import {queryWebSocketLastDisconnected} from '@queries/servers/system';
|
||||
import {prepareMyTeams} from '@queries/servers/team';
|
||||
import {prepareMyTeams, queryMyTeamById} from '@queries/servers/team';
|
||||
|
||||
import {fetchMyChannelsForTeam} from './channel';
|
||||
import {fetchPostsForUnreadChannels} from './post';
|
||||
import {fetchRolesIfNeeded} from './role';
|
||||
import {forceLogoutIfNecessary} from './session';
|
||||
import TeamModel from '@typings/database/models/servers/team';
|
||||
import TeamMembershipModel from '@typings/database/models/servers/team_membership';
|
||||
|
||||
export type MyTeamsRequest = {
|
||||
teams?: Team[];
|
||||
@@ -19,6 +22,50 @@ export type MyTeamsRequest = {
|
||||
error?: never;
|
||||
}
|
||||
|
||||
export const addUserToTeam = async (serverUrl: string, teamId: string, userId: string, fetchOnly = false) => {
|
||||
let client;
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await client.addToTeam(teamId, userId);
|
||||
const unreads = await client.getTeamUnreads(teamId);
|
||||
|
||||
if (!fetchOnly) {
|
||||
fetchRolesIfNeeded(serverUrl, member.roles.split(' '));
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (operator) {
|
||||
const myTeams: MyTeam[] = [{
|
||||
id: member.team_id,
|
||||
roles: member.roles,
|
||||
is_unread: unreads.msg_count > 0,
|
||||
mentions_count: unreads.mention_count,
|
||||
}];
|
||||
|
||||
const models = await Promise.all([
|
||||
operator.handleMyTeam({myTeams, prepareRecordsOnly: true}),
|
||||
operator.handleTeamMemberships({teamMemberships: [member], prepareRecordsOnly: true}),
|
||||
]);
|
||||
|
||||
if (models.length) {
|
||||
const flattenedModels = models.flat() as Model[];
|
||||
if (flattenedModels?.length > 0) {
|
||||
await operator.batchRecords(flattenedModels);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {member, unreads};
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(serverUrl, error);
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchMyTeams = async (serverUrl: string, fetchOnly = false): Promise<MyTeamsRequest> => {
|
||||
let client;
|
||||
try {
|
||||
@@ -78,3 +125,71 @@ export const fetchTeamsChannelsAndUnreadPosts = async (serverUrl: string, teams:
|
||||
|
||||
return {error: undefined};
|
||||
};
|
||||
|
||||
export const fetchTeamByName = async (serverUrl: string, teamName: string, fetchOnly = false) => {
|
||||
let client;
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
try {
|
||||
const team = await client.getTeamByName(teamName);
|
||||
|
||||
if (!fetchOnly) {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (operator) {
|
||||
const model = await operator.handleTeam({teams: [team], prepareRecordsOnly: true});
|
||||
if (model) {
|
||||
await operator.batchRecords(model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {team};
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(serverUrl, error);
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const removeUserFromTeam = async (serverUrl: string, teamId: string, userId: string, fetchOnly = false) => {
|
||||
let client;
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
try {
|
||||
await client.removeFromTeam(teamId, userId);
|
||||
|
||||
if (!fetchOnly && DatabaseManager.serverDatabases[serverUrl]) {
|
||||
const {operator, database} = DatabaseManager.serverDatabases[serverUrl];
|
||||
const myTeam = await queryMyTeamById(database, teamId);
|
||||
const models: Model[] = [];
|
||||
if (myTeam) {
|
||||
const team = await myTeam.team.fetch() as TeamModel;
|
||||
const members: TeamMembershipModel[] = await team.members.fetch();
|
||||
const member = members.find((m) => m.userId === userId);
|
||||
|
||||
myTeam.prepareDestroyPermanently();
|
||||
models.push(myTeam);
|
||||
if (member) {
|
||||
member.prepareDestroyPermanently();
|
||||
models.push(member);
|
||||
}
|
||||
|
||||
if (models.length) {
|
||||
await operator.batchRecords(models);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {error: undefined};
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(serverUrl, error);
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,11 +6,9 @@ import {buildQueryString} from '@utils/helpers';
|
||||
import {PER_PAGE_DEFAULT} from './constants';
|
||||
|
||||
export interface ClientEmojisMix {
|
||||
createCustomEmoji: (emoji: CustomEmoji, imageData: any) => Promise<CustomEmoji>;
|
||||
getCustomEmoji: (id: string) => Promise<CustomEmoji>;
|
||||
getCustomEmojiByName: (name: string) => Promise<CustomEmoji>;
|
||||
getCustomEmojis: (page?: number, perPage?: number, sort?: string) => Promise<CustomEmoji[]>;
|
||||
deleteCustomEmoji: (emojiId: string) => Promise<any>;
|
||||
getSystemEmojiImageUrl: (filename: string) => string;
|
||||
getCustomEmojiImageUrl: (id: string) => string;
|
||||
searchCustomEmoji: (term: string, options?: Record<string, any>) => Promise<CustomEmoji[]>;
|
||||
@@ -18,31 +16,6 @@ export interface ClientEmojisMix {
|
||||
}
|
||||
|
||||
const ClientEmojis = (superclass: any) => class extends superclass {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
createCustomEmoji = async (emoji: CustomEmoji, imageData: any) => {
|
||||
this.analytics.trackAPI('api_emoji_custom_add');
|
||||
|
||||
// FIXME: Multipart upload with client
|
||||
// const formData = new FormData();
|
||||
// formData.append('image', imageData);
|
||||
// formData.append('emoji', JSON.stringify(emoji));
|
||||
// const request: any = {
|
||||
// method: 'post',
|
||||
// body: formData,
|
||||
// };
|
||||
|
||||
// if (formData.getBoundary) {
|
||||
// request.headers = {
|
||||
// 'Content-Type': `multipart/form-data; boundary=${formData.getBoundary()}`,
|
||||
// };
|
||||
// }
|
||||
|
||||
// return this.doFetch(
|
||||
// `${this.getEmojisRoute()}`,
|
||||
// request,
|
||||
// );
|
||||
};
|
||||
|
||||
getCustomEmoji = async (id: string) => {
|
||||
return this.doFetch(
|
||||
`${this.getEmojisRoute()}/${id}`,
|
||||
@@ -64,21 +37,12 @@ const ClientEmojis = (superclass: any) => class extends superclass {
|
||||
);
|
||||
};
|
||||
|
||||
deleteCustomEmoji = async (emojiId: string) => {
|
||||
this.analytics.trackAPI('api_emoji_custom_delete');
|
||||
|
||||
return this.doFetch(
|
||||
`${this.getEmojiRoute(emojiId)}`,
|
||||
{method: 'delete'},
|
||||
);
|
||||
};
|
||||
|
||||
getSystemEmojiImageUrl = (filename: string) => {
|
||||
return `${this.url}/static/emoji/${filename}.png`;
|
||||
return `${this.apiClient.baseUrl}static/emoji/${filename}.png`;
|
||||
};
|
||||
|
||||
getCustomEmojiImageUrl = (id: string) => {
|
||||
return `${this.getEmojiRoute(id)}/image`;
|
||||
return `${this.apiClient.baseUrl}${this.getEmojiRoute(id)}/image`;
|
||||
};
|
||||
|
||||
searchCustomEmoji = async (term: string, options = {}) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface ClientFilesMix {
|
||||
|
||||
const ClientFiles = (superclass: any) => class extends superclass {
|
||||
getFileUrl(fileId: string, timestamp: number) {
|
||||
let url = `${this.getFileRoute(fileId)}`;
|
||||
let url = `${this.apiClient.baseUrl}${this.getFileRoute(fileId)}`;
|
||||
if (timestamp) {
|
||||
url += `?${timestamp}`;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const ClientFiles = (superclass: any) => class extends superclass {
|
||||
}
|
||||
|
||||
getFileThumbnailUrl(fileId: string, timestamp: number) {
|
||||
let url = `${this.getFileRoute(fileId)}/thumbnail`;
|
||||
let url = `${this.apiClient.baseUrl}${this.getFileRoute(fileId)}/thumbnail`;
|
||||
if (timestamp) {
|
||||
url += `?${timestamp}`;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const ClientFiles = (superclass: any) => class extends superclass {
|
||||
}
|
||||
|
||||
getFilePreviewUrl(fileId: string, timestamp: number) {
|
||||
let url = `${this.getFileRoute(fileId)}/preview`;
|
||||
let url = `${this.apiClient.baseUrl}${this.getFileRoute(fileId)}/preview`;
|
||||
if (timestamp) {
|
||||
url += `?${timestamp}`;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface ClientTeamsMix {
|
||||
getTeamsForUser: (userId: string) => Promise<Team[]>;
|
||||
getMyTeamMembers: () => Promise<TeamMembership[]>;
|
||||
getMyTeamUnreads: () => Promise<TeamUnread[]>;
|
||||
getTeamUnreads: (teamId: string) => Promise<TeamUnread>;
|
||||
getTeamMembers: (teamId: string, page?: number, perPage?: number) => Promise<TeamMembership[]>;
|
||||
getTeamMember: (teamId: string, userId: string) => Promise<TeamMembership>;
|
||||
addToTeam: (teamId: string, userId: string) => Promise<TeamMembership>;
|
||||
@@ -114,6 +115,13 @@ const ClientTeams = (superclass: any) => class extends superclass {
|
||||
);
|
||||
};
|
||||
|
||||
getTeamUnreads = async (teamId: string) => {
|
||||
return this.doFetch(
|
||||
`${this.getUserRoute('me')}/${this.getTeamRoute(teamId)}/unread`,
|
||||
{method: 'get'},
|
||||
);
|
||||
}
|
||||
|
||||
getTeamMembers = async (teamId: string, page = 0, perPage = PER_PAGE_DEFAULT) => {
|
||||
return this.doFetch(
|
||||
`${this.getTeamMembersRoute(teamId)}${buildQueryString({page, per_page: perPage})}`,
|
||||
|
||||
167
app/components/emoji/index.tsx
Normal file
167
app/components/emoji/index.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
// 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 React from 'react';
|
||||
import {
|
||||
Platform,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextStyle,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import FastImage, {ImageStyle} from 'react-native-fast-image';
|
||||
import {of} from 'rxjs';
|
||||
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {useServerUrl} from '@context/server_url';
|
||||
import NetworkManager from '@init/network_manager';
|
||||
import {EmojiIndicesByAlias, Emojis} from '@utils/emoji';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
import CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
|
||||
|
||||
const assetImages = new Map([['mattermost.png', require('@assets/images/emojis/mattermost.png')]]);
|
||||
|
||||
type Props = {
|
||||
emojiName: string;
|
||||
displayTextOnly?: boolean;
|
||||
literal?: string;
|
||||
size?: number;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
customEmojiStyle?: StyleProp<ImageStyle>;
|
||||
customEmojis: CustomEmojiModel[];
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const Emoji = (props: Props) => {
|
||||
const {
|
||||
customEmojis,
|
||||
customEmojiStyle,
|
||||
displayTextOnly,
|
||||
emojiName,
|
||||
literal = '',
|
||||
testID,
|
||||
textStyle,
|
||||
} = props;
|
||||
const serverUrl = useServerUrl();
|
||||
let assetImage = '';
|
||||
let unicode;
|
||||
let imageUrl = '';
|
||||
|
||||
if (EmojiIndicesByAlias.has(emojiName)) {
|
||||
const emoji = Emojis[EmojiIndicesByAlias.get(emojiName)!];
|
||||
if (emoji.category === 'custom') {
|
||||
assetImage = emoji.fileName;
|
||||
} else {
|
||||
unicode = emoji.image;
|
||||
}
|
||||
} else {
|
||||
const custom = customEmojis.find((ce) => ce.name === emojiName);
|
||||
if (custom) {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
imageUrl = client.getCustomEmojiImageUrl(custom.id);
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let size = props.size;
|
||||
let fontSize = size;
|
||||
if (!size && textStyle) {
|
||||
const flatten = StyleSheet.flatten(textStyle);
|
||||
fontSize = flatten.fontSize;
|
||||
size = fontSize;
|
||||
}
|
||||
|
||||
if (displayTextOnly || (!imageUrl && !assetImage && !unicode)) {
|
||||
return (
|
||||
<Text
|
||||
style={textStyle}
|
||||
testID={testID}
|
||||
>
|
||||
{literal}
|
||||
</Text>);
|
||||
}
|
||||
|
||||
const width = size;
|
||||
const height = size;
|
||||
|
||||
if (unicode && !imageUrl) {
|
||||
const codeArray = unicode.split('-');
|
||||
const code = codeArray.reduce((acc: string, c: string) => {
|
||||
return acc + String.fromCodePoint(parseInt(c, 16));
|
||||
}, '');
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[textStyle, {fontSize: size}]}
|
||||
testID={testID}
|
||||
>
|
||||
{code}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (assetImage) {
|
||||
const key = Platform.OS === 'android' ? (`${assetImage}-${height}-${width}`) : null;
|
||||
|
||||
const image = assetImages.get(assetImage);
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={Platform.select({android: {flex: 1}})}>
|
||||
<FastImage
|
||||
key={key}
|
||||
source={image}
|
||||
style={[customEmojiStyle, {width, height}]}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
testID={testID}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Android can't change the size of an image after its first render, so
|
||||
// force a new image to be rendered when the size changes
|
||||
const key = Platform.OS === 'android' ? (`${imageUrl}-${height}-${width}`) : null;
|
||||
|
||||
return (
|
||||
<View style={Platform.select({android: {flex: 1}})}>
|
||||
<FastImage
|
||||
key={key}
|
||||
style={[customEmojiStyle, {width, height}]}
|
||||
source={{uri: imageUrl}}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
testID={testID}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const withSystemIds = withObservables([], ({database}: WithDatabaseArgs) => ({
|
||||
config: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG),
|
||||
}));
|
||||
|
||||
const withCustomEmojis = withObservables(['config', 'emojiName'], ({config, database, emojiName}: WithDatabaseArgs & {config: SystemModel; emojiName: string}) => {
|
||||
const cfg: ClientConfig = config.value;
|
||||
const displayTextOnly = cfg.EnableCustomEmoji !== 'true';
|
||||
|
||||
return {
|
||||
displayTextOnly: of(displayTextOnly),
|
||||
customEmojis: database.get(MM_TABLES.SERVER.CUSTOM_EMOJI).query(Q.where('name', emojiName)).observe(),
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(withSystemIds(withCustomEmojis(Emoji)));
|
||||
140
app/components/jumbo_emoji/index.tsx
Normal file
140
app/components/jumbo_emoji/index.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Node, Parser} from 'commonmark';
|
||||
import Renderer from 'commonmark-react-renderer';
|
||||
import React, {ReactElement, useRef} from 'react';
|
||||
import {Platform, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native';
|
||||
|
||||
import Emoji from '@components/emoji';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {blendColors, concatStyles, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type JumboEmojiProps = {
|
||||
baseTextStyle: StyleProp<TextStyle>;
|
||||
isEdited?: boolean;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
// Android has trouble giving text transparency depending on how it's nested,
|
||||
// so we calculate the resulting colour manually
|
||||
const editedOpacity = Platform.select({
|
||||
ios: 0.3,
|
||||
android: 1.0,
|
||||
});
|
||||
const editedColor = Platform.select({
|
||||
ios: theme.centerChannelColor,
|
||||
android: blendColors(theme.centerChannelBg, theme.centerChannelColor, 0.3),
|
||||
});
|
||||
|
||||
return {
|
||||
block: {
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
editedIndicatorText: {
|
||||
color: editedColor,
|
||||
opacity: editedOpacity,
|
||||
},
|
||||
jumboEmoji: {
|
||||
fontSize: 50,
|
||||
lineHeight: 60,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const JumboEmoji = ({baseTextStyle, isEdited, value}: JumboEmojiProps) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const renderEmoji = ({emojiName, literal}: {context: string[]; emojiName: string; literal: string}) => {
|
||||
const flat = StyleSheet.flatten(style.jumboEmoji);
|
||||
const size = flat.lineHeight - flat.fontSize;
|
||||
|
||||
return (
|
||||
<Emoji
|
||||
emojiName={emojiName}
|
||||
literal={literal}
|
||||
testID='markdown_emoji'
|
||||
textStyle={concatStyles(baseTextStyle, style.jumboEmoji)}
|
||||
customEmojiStyle={Platform.select({android: {marginRight: size, top: size}})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderParagraph = ({children}: {children: ReactElement}) => {
|
||||
return (
|
||||
<View style={style.block}><Text>{children}</Text></View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderText = ({literal}: {literal: string}) => {
|
||||
return <Text style={baseTextStyle}>{literal}</Text>;
|
||||
};
|
||||
|
||||
const renderNewLine = () => {
|
||||
return <Text style={baseTextStyle}>{'\n'}</Text>;
|
||||
};
|
||||
|
||||
const renderEditedIndicator = ({context}: {context: string[]}) => {
|
||||
let spacer = '';
|
||||
if (context[0] === 'paragraph') {
|
||||
spacer = ' ';
|
||||
}
|
||||
|
||||
const styles = [
|
||||
baseTextStyle,
|
||||
style.editedIndicatorText,
|
||||
];
|
||||
|
||||
return (
|
||||
<Text style={styles}>
|
||||
{spacer}
|
||||
<FormattedText
|
||||
id='post_message_view.edited'
|
||||
defaultMessage='(edited)'
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const createRenderer = () => {
|
||||
const renderers: any = {
|
||||
editedIndicator: renderEditedIndicator,
|
||||
emoji: renderEmoji,
|
||||
paragraph: renderParagraph,
|
||||
document: renderParagraph,
|
||||
text: renderText,
|
||||
hardbreak: renderNewLine,
|
||||
softBreak: renderNewLine,
|
||||
};
|
||||
|
||||
return new Renderer({
|
||||
renderers,
|
||||
renderParagraphsInLists: true,
|
||||
});
|
||||
};
|
||||
|
||||
const parser = useRef(new Parser()).current;
|
||||
const renderer = useRef(createRenderer()).current;
|
||||
const ast = parser.parse(value.replace(/\n*$/, ''));
|
||||
|
||||
if (isEdited) {
|
||||
const editIndicatorNode = new Node('edited_indicator');
|
||||
if (ast.lastChild && ['heading', 'paragraph'].includes(ast.lastChild.type)) {
|
||||
ast.lastChild.appendChild(editIndicatorNode);
|
||||
} else {
|
||||
const node = new Node('paragraph');
|
||||
node.appendChild(editIndicatorNode);
|
||||
|
||||
ast.appendChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
return renderer.render(ast) as ReactElement;
|
||||
};
|
||||
|
||||
export default JumboEmoji;
|
||||
275
app/components/markdown/at_mention/index.tsx
Normal file
275
app/components/markdown/at_mention/index.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useManagedConfig} from '@mattermost/react-native-emm';
|
||||
import {Database, Q} from '@nozbe/watermelondb';
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import Clipboard from '@react-native-community/clipboard';
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {DeviceEventEmitter, GestureResponderEvent, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native';
|
||||
import {of} from 'rxjs';
|
||||
|
||||
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {Navigation, Preferences} from '@constants';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import UserModel from '@database/models/server/user';
|
||||
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {showModal, showModalOverCurrentContext} from '@screens/navigation';
|
||||
import {displayUsername, getUserMentionKeys, getUsersByUsername} from '@utils/user';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type PreferenceModel from '@typings/database/models/servers/preference';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
import type UserModelType from '@typings/database/models/servers/user';
|
||||
|
||||
type AtMentionProps = {
|
||||
database: Database;
|
||||
groupsByName?: Record<string, Group>;
|
||||
isSearchResult?: boolean;
|
||||
mentionKeys: Array<{key: string }>;
|
||||
mentionName: string;
|
||||
mentionStyle: TextStyle;
|
||||
onPostPress?: (e: GestureResponderEvent) => void;
|
||||
teammateNameDisplay: string;
|
||||
textStyle: StyleProp<TextStyle>;
|
||||
users: UserModelType[];
|
||||
}
|
||||
|
||||
type AtMentionArgs = {
|
||||
config: SystemModel;
|
||||
license: SystemModel;
|
||||
preferences: PreferenceModel[];
|
||||
mentionName: string;
|
||||
}
|
||||
|
||||
const {SERVER: {PREFERENCE, SYSTEM, USER}} = MM_TABLES;
|
||||
|
||||
const style = StyleSheet.create({
|
||||
bottomSheet: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const AtMention = ({
|
||||
database,
|
||||
groupsByName,
|
||||
isSearchResult,
|
||||
mentionName,
|
||||
mentionKeys,
|
||||
mentionStyle,
|
||||
onPostPress,
|
||||
teammateNameDisplay,
|
||||
textStyle,
|
||||
users,
|
||||
}: AtMentionProps) => {
|
||||
const intl = useIntl();
|
||||
const managedConfig = useManagedConfig();
|
||||
const theme = useTheme();
|
||||
const user = useMemo(() => {
|
||||
const usersByUsername = getUsersByUsername(users);
|
||||
let mn = mentionName.toLowerCase();
|
||||
|
||||
while (mn.length > 0) {
|
||||
if (usersByUsername[mn]) {
|
||||
return usersByUsername[mn];
|
||||
}
|
||||
|
||||
// Repeatedly trim off trailing punctuation in case this is at the end of a sentence
|
||||
if ((/[._-]$/).test(mn)) {
|
||||
mn = mn.substring(0, mn.length - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error: The model constructor is hidden within WDB type definition
|
||||
return new UserModel(database.get(USER), {username: ''});
|
||||
}, [users, mentionName]);
|
||||
const userMentionKeys = useMemo(() => {
|
||||
if (mentionKeys) {
|
||||
return mentionKeys;
|
||||
}
|
||||
return getUserMentionKeys(user);
|
||||
}, [user, mentionKeys]);
|
||||
|
||||
const getGroupFromMentionName = () => {
|
||||
const mentionNameTrimmed = mentionName.toLowerCase().replace(/[._-]*$/, '');
|
||||
return groupsByName?.[mentionNameTrimmed];
|
||||
};
|
||||
|
||||
const goToUserProfile = useCallback(() => {
|
||||
const screen = 'UserProfile';
|
||||
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
|
||||
const passProps = {
|
||||
userId: user.id,
|
||||
};
|
||||
|
||||
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
|
||||
|
||||
const options = {
|
||||
topBar: {
|
||||
leftButtons: [{
|
||||
id: 'close-settings',
|
||||
icon: closeButton,
|
||||
testID: 'close.settings.button',
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
showModal(screen, title, passProps, options);
|
||||
}, [user]);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
if (managedConfig?.copyAndPasteProtection !== 'true') {
|
||||
const renderContent = () => {
|
||||
return (
|
||||
<View
|
||||
testID='at_mention.bottom_sheet'
|
||||
style={style.bottomSheet}
|
||||
>
|
||||
<SlideUpPanelItem
|
||||
icon='content-copy'
|
||||
onPress={() => {
|
||||
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
|
||||
let username = mentionName;
|
||||
if (user.username) {
|
||||
username = user.username;
|
||||
}
|
||||
|
||||
Clipboard.setString(`@${username}`);
|
||||
}}
|
||||
testID='at_mention.bottom_sheet.copy_mention'
|
||||
text={intl.formatMessage({id: 'mobile.mention.copy_mention', defaultMessage: 'Copy Mention'})}
|
||||
/>
|
||||
<SlideUpPanelItem
|
||||
destructive={true}
|
||||
icon='cancel'
|
||||
onPress={() => {
|
||||
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
|
||||
}}
|
||||
testID='at_mention.bottom_sheet.cancel'
|
||||
text={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
showModalOverCurrentContext('BottomSheet', {
|
||||
renderContent,
|
||||
snapPoints: [3 * ITEM_HEIGHT, 10],
|
||||
});
|
||||
}
|
||||
}, [managedConfig]);
|
||||
|
||||
const mentionTextStyle = [];
|
||||
|
||||
let backgroundColor;
|
||||
let canPress = false;
|
||||
let highlighted;
|
||||
let isMention = false;
|
||||
let mention;
|
||||
let onLongPress;
|
||||
let onPress;
|
||||
let suffix;
|
||||
let suffixElement;
|
||||
let styleText;
|
||||
|
||||
if (textStyle) {
|
||||
backgroundColor = theme.mentionHighlightBg;
|
||||
styleText = textStyle;
|
||||
}
|
||||
|
||||
if (user?.username) {
|
||||
suffix = mentionName.substring(user.username.length);
|
||||
highlighted = userMentionKeys.some((item) => item.key.includes(user.username));
|
||||
mention = displayUsername(user, user.locale, teammateNameDisplay);
|
||||
isMention = true;
|
||||
canPress = true;
|
||||
} else {
|
||||
// TODO: Add group functionality
|
||||
const group = getGroupFromMentionName();
|
||||
if (group?.allow_reference) {
|
||||
highlighted = userMentionKeys.some((item) => item.key === `@${group.name}`);
|
||||
isMention = true;
|
||||
mention = group.name;
|
||||
suffix = mentionName.substring(group.name.length);
|
||||
} else {
|
||||
const pattern = new RegExp(/\b(all|channel|here)(?:\.\B|_\b|\b)/, 'i');
|
||||
const mentionMatch = pattern.exec(mentionName);
|
||||
|
||||
if (mentionMatch) {
|
||||
mention = mentionMatch.length > 1 ? mentionMatch[1] : mentionMatch[0];
|
||||
suffix = mentionName.replace(mention, '');
|
||||
isMention = true;
|
||||
highlighted = true;
|
||||
} else {
|
||||
mention = mentionName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (canPress) {
|
||||
onLongPress = handleLongPress;
|
||||
onPress = isSearchResult ? onPostPress : goToUserProfile;
|
||||
}
|
||||
|
||||
if (suffix) {
|
||||
const suffixStyle = {...StyleSheet.flatten(styleText), color: theme.centerChannelColor};
|
||||
suffixElement = (
|
||||
<Text style={suffixStyle}>
|
||||
{suffix}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMention) {
|
||||
mentionTextStyle.push(mentionStyle);
|
||||
}
|
||||
|
||||
if (highlighted) {
|
||||
mentionTextStyle.push({backgroundColor, color: theme.mentionHighlightLink});
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={styleText}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
>
|
||||
<Text style={mentionTextStyle}>
|
||||
{'@' + mention}
|
||||
</Text>
|
||||
{suffixElement}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const withPreferences = withObservables([], ({database}: WithDatabaseArgs) => ({
|
||||
preferences: database.get(PREFERENCE).query(Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS)).observe(),
|
||||
config: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG),
|
||||
license: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE),
|
||||
}));
|
||||
|
||||
const withAtMention = withObservables(['mentionName', 'preferences', 'config', 'license'], ({database, mentionName, preferences, config, license}: WithDatabaseArgs & AtMentionArgs) => {
|
||||
let mn = mentionName.toLowerCase();
|
||||
if ((/[._-]$/).test(mn)) {
|
||||
mn = mn.substring(0, mn.length - 1);
|
||||
}
|
||||
|
||||
const teammateNameDisplay = of(getTeammateNameDisplaySetting(preferences, config.value, license.value));
|
||||
|
||||
return {
|
||||
teammateNameDisplay,
|
||||
users: database.get(USER).query(
|
||||
Q.where('username', Q.like(
|
||||
`%${Q.sanitizeLikeString(mn)}%`,
|
||||
)),
|
||||
).observeWithColumns(['username']),
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(withPreferences(withAtMention(AtMention)));
|
||||
134
app/components/markdown/channel_mention/index.tsx
Normal file
134
app/components/markdown/channel_mention/index.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
// 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 React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {StyleProp, Text, TextStyle} from 'react-native';
|
||||
|
||||
import {switchToChannel} from '@actions/local/channel';
|
||||
import {joinChannel} from '@actions/remote/channel';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {useServerUrl} from '@context/server_url';
|
||||
import {t} from '@i18n';
|
||||
import {dismissAllModals, popToRoot} from '@screens/navigation';
|
||||
import {alertErrorWithFallback} from '@utils/draft';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type ChannelModelType from '@typings/database/models/servers/channel';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
import type TeamModelType from '@typings/database/models/servers/team';
|
||||
|
||||
export type ChannelMentions = Record<string, {id?: string; display_name: string; name?: string; team_name: string}>;
|
||||
|
||||
type ChannelMentionProps = {
|
||||
channelMentions?: ChannelMentions;
|
||||
channelName: string;
|
||||
channels: ChannelModelType[];
|
||||
currentTeamId: SystemModel;
|
||||
currentUserId: SystemModel;
|
||||
linkStyle: StyleProp<TextStyle>;
|
||||
team: TeamModelType;
|
||||
textStyle: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
function getChannelFromChannelName(name: string, channels: ChannelModelType[], channelMentions: ChannelMentions = {}, teamName: string) {
|
||||
const channelsByName = channelMentions;
|
||||
let channelName = name;
|
||||
|
||||
channels.forEach((c) => {
|
||||
channelsByName[c.name] = {
|
||||
id: c.id,
|
||||
display_name: c.displayName,
|
||||
name: c.name,
|
||||
team_name: teamName,
|
||||
};
|
||||
});
|
||||
|
||||
while (channelName.length > 0) {
|
||||
if (channelsByName[channelName]) {
|
||||
return channelsByName[channelName];
|
||||
}
|
||||
|
||||
// Repeatedly trim off trailing punctuation in case this is at the end of a sentence
|
||||
if ((/[_-]$/).test(channelName)) {
|
||||
channelName = channelName.substring(0, channelName.length - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const ChannelMention = ({
|
||||
channelMentions, channelName, channels, currentTeamId, currentUserId,
|
||||
linkStyle, team, textStyle,
|
||||
}: ChannelMentionProps) => {
|
||||
const intl = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
const channel = getChannelFromChannelName(channelName, channels, channelMentions, team.name);
|
||||
|
||||
const handlePress = useCallback(preventDoubleTap(async () => {
|
||||
let c = channel;
|
||||
|
||||
if (!c?.id && c?.display_name) {
|
||||
const result = await joinChannel(serverUrl, currentUserId.value, currentTeamId.value, undefined, channelName);
|
||||
if (result.error || !result.channel) {
|
||||
const joinFailedMessage = {
|
||||
id: t('mobile.join_channel.error'),
|
||||
defaultMessage: "We couldn't join the channel {displayName}. Please check your connection and try again.",
|
||||
};
|
||||
alertErrorWithFallback(intl, result.error || {}, joinFailedMessage, {displayName: c.display_name});
|
||||
} else if (result.channel) {
|
||||
c = {
|
||||
...c,
|
||||
id: result.channel.id,
|
||||
name: result.channel.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (c?.id) {
|
||||
switchToChannel(serverUrl, c.id);
|
||||
await dismissAllModals();
|
||||
await popToRoot();
|
||||
}
|
||||
}), [channel?.display_name, channel?.id]);
|
||||
|
||||
if (!channel) {
|
||||
return <Text style={textStyle}>{`~${channelName}`}</Text>;
|
||||
}
|
||||
|
||||
let suffix;
|
||||
if (channel.name) {
|
||||
suffix = channelName.substring(channel.name.length);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text style={textStyle}>
|
||||
<Text
|
||||
onPress={handlePress}
|
||||
style={linkStyle}
|
||||
>
|
||||
{`~${channel.display_name}`}
|
||||
</Text>
|
||||
{suffix}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const withSystemIds = withObservables([], ({database}: WithDatabaseArgs) => ({
|
||||
currentTeamId: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID),
|
||||
currentUserId: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID),
|
||||
}));
|
||||
|
||||
const withChannelsForTeam = withObservables(['currentTeamId'], ({database, currentTeamId}: WithDatabaseArgs & {currentTeamId: SystemModel}) => ({
|
||||
channels: database.get(MM_TABLES.SERVER.CHANNEL).query(Q.where('team_id', currentTeamId.value)).observeWithColumns(['display_name']),
|
||||
team: database.get(MM_TABLES.SERVER.TEAM).findAndObserve(currentTeamId.value),
|
||||
}));
|
||||
|
||||
export default withDatabase(withSystemIds(withChannelsForTeam(ChannelMention)));
|
||||
33
app/components/markdown/hashtag/index.tsx
Normal file
33
app/components/markdown/hashtag/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Text, TextStyle} from 'react-native';
|
||||
|
||||
import {popToRoot, showSearchModal, dismissAllModals} from '@screens/navigation';
|
||||
|
||||
type HashtagProps = {
|
||||
hashtag: string;
|
||||
linkStyle: TextStyle;
|
||||
};
|
||||
|
||||
const Hashtag = ({hashtag, linkStyle}: HashtagProps) => {
|
||||
const handlePress = async () => {
|
||||
// Close thread view, permalink view, etc
|
||||
await dismissAllModals();
|
||||
await popToRoot();
|
||||
|
||||
showSearchModal('#' + hashtag);
|
||||
};
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={linkStyle}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{`#${hashtag}`}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hashtag;
|
||||
513
app/components/markdown/index.tsx
Normal file
513
app/components/markdown/index.tsx
Normal file
@@ -0,0 +1,513 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Parser, Node} from 'commonmark';
|
||||
import Renderer from 'commonmark-react-renderer';
|
||||
import React, {PureComponent, ReactElement} from 'react';
|
||||
import {GestureResponderEvent, Platform, StyleProp, StyleSheet, Text, TextStyle, View, ViewStyle} from 'react-native';
|
||||
|
||||
import Emoji from '@components/emoji';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import Hashtag from '@components/markdown/hashtag';
|
||||
import {blendColors, concatStyles, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {getScheme} from '@utils/url';
|
||||
|
||||
import AtMention from './at_mention';
|
||||
import ChannelMention, {ChannelMentions} from './channel_mention';
|
||||
import MarkdownBlockQuote from './markdown_block_quote';
|
||||
import MarkdownCodeBlock from './markdown_code_block';
|
||||
import MarkdownImage from './markdown_image';
|
||||
import MarkdownLink from './markdown_link';
|
||||
import MarkdownList from './markdown_list';
|
||||
import MarkdownListItem from './markdown_list_item';
|
||||
import MarkdownTable from './markdown_table';
|
||||
import MarkdownTableImage from './markdown_table_image';
|
||||
import MarkdownTableRow, {MarkdownTableRowProps} from './markdown_table_row';
|
||||
import MarkdownTableCell, {MarkdownTableCellProps} from './markdown_table_cell';
|
||||
import {addListItemIndices, combineTextNodes, highlightMentions, pullOutImages} from './transform';
|
||||
|
||||
type MarkdownProps = {
|
||||
autolinkedUrlSchemes?: string[];
|
||||
baseTextStyle: StyleProp<TextStyle>;
|
||||
blockStyles: {
|
||||
adjacentParagraph: ViewStyle;
|
||||
horizontalRule: ViewStyle;
|
||||
quoteBlockIcon: TextStyle;
|
||||
};
|
||||
channelMentions?: ChannelMentions;
|
||||
disableAtMentions?: boolean;
|
||||
disableChannelLink?: boolean;
|
||||
disableGallery?: boolean;
|
||||
disableHashtags?: boolean;
|
||||
imagesMetadata?: Record<string, PostImage>;
|
||||
isEdited?: boolean;
|
||||
isReplyPost?: boolean;
|
||||
isSearchResult?: boolean;
|
||||
mentionKeys?: UserMentionKey[];
|
||||
minimumHashtagLength?: number;
|
||||
onPostPress?: (event: GestureResponderEvent) => void;
|
||||
postId?: string;
|
||||
textStyles: {
|
||||
[key: string]: TextStyle;
|
||||
};
|
||||
theme: Theme;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
class Markdown extends PureComponent<MarkdownProps> {
|
||||
static defaultProps = {
|
||||
textStyles: {},
|
||||
blockStyles: {},
|
||||
disableHashtags: false,
|
||||
disableAtMentions: false,
|
||||
disableChannelLink: false,
|
||||
disableGallery: false,
|
||||
value: '',
|
||||
minimumHashtagLength: 3,
|
||||
mentionKeys: [],
|
||||
};
|
||||
|
||||
private parser: Parser;
|
||||
private renderer: Renderer.Renderer;
|
||||
|
||||
constructor(props: MarkdownProps) {
|
||||
super(props);
|
||||
|
||||
this.parser = this.createParser();
|
||||
this.renderer = this.createRenderer();
|
||||
}
|
||||
|
||||
createParser = () => {
|
||||
return new Parser({
|
||||
urlFilter: this.urlFilter,
|
||||
minimumHashtagLength: this.props.minimumHashtagLength,
|
||||
});
|
||||
};
|
||||
|
||||
urlFilter = (url: string) => {
|
||||
const scheme = getScheme(url);
|
||||
return !scheme || this.props.autolinkedUrlSchemes?.indexOf(scheme) !== -1;
|
||||
};
|
||||
|
||||
createRenderer = () => {
|
||||
const renderers: any = {
|
||||
text: this.renderText,
|
||||
|
||||
emph: Renderer.forwardChildren,
|
||||
strong: Renderer.forwardChildren,
|
||||
del: Renderer.forwardChildren,
|
||||
code: this.renderCodeSpan,
|
||||
link: this.renderLink,
|
||||
image: this.renderImage,
|
||||
atMention: this.renderAtMention,
|
||||
channelLink: this.renderChannelLink,
|
||||
emoji: this.renderEmoji,
|
||||
hashtag: this.renderHashtag,
|
||||
|
||||
paragraph: this.renderParagraph,
|
||||
heading: this.renderHeading,
|
||||
codeBlock: this.renderCodeBlock,
|
||||
blockQuote: this.renderBlockQuote,
|
||||
|
||||
list: this.renderList,
|
||||
item: this.renderListItem,
|
||||
|
||||
hardBreak: this.renderHardBreak,
|
||||
thematicBreak: this.renderThematicBreak,
|
||||
softBreak: this.renderSoftBreak,
|
||||
|
||||
htmlBlock: this.renderHtml,
|
||||
htmlInline: this.renderHtml,
|
||||
|
||||
table: this.renderTable,
|
||||
table_row: this.renderTableRow,
|
||||
table_cell: this.renderTableCell,
|
||||
|
||||
mention_highlight: Renderer.forwardChildren,
|
||||
|
||||
editedIndicator: this.renderEditedIndicator,
|
||||
};
|
||||
|
||||
return new Renderer({
|
||||
renderers,
|
||||
renderParagraphsInLists: true,
|
||||
getExtraPropsForNode: this.getExtraPropsForNode,
|
||||
});
|
||||
};
|
||||
|
||||
getExtraPropsForNode = (node: any) => {
|
||||
const extraProps: Record<string, any> = {
|
||||
continue: node.continue,
|
||||
index: node.index,
|
||||
};
|
||||
|
||||
if (node.type === 'image') {
|
||||
extraProps.reactChildren = node.react.children;
|
||||
extraProps.linkDestination = node.linkDestination;
|
||||
}
|
||||
|
||||
return extraProps;
|
||||
};
|
||||
|
||||
computeTextStyle = (baseStyle: StyleProp<TextStyle>, context: any) => {
|
||||
const {textStyles} = this.props;
|
||||
type TextType = keyof typeof textStyles;
|
||||
const contextStyles: TextStyle[] = context.map((type: any) => textStyles[type as TextType]).filter((f: any) => f !== undefined);
|
||||
return contextStyles.length ? concatStyles(baseStyle, contextStyles) : baseStyle;
|
||||
};
|
||||
|
||||
renderText = ({context, literal}: any) => {
|
||||
if (context.indexOf('image') !== -1) {
|
||||
// If this text is displayed, it will be styled by the image component
|
||||
return (
|
||||
<Text testID='markdown_text'>
|
||||
{literal}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Construct the text style based off of the parents of this node since RN's inheritance is limited
|
||||
const style = this.computeTextStyle(this.props.baseTextStyle, context);
|
||||
|
||||
return (
|
||||
<Text
|
||||
testID='markdown_text'
|
||||
style={style}
|
||||
>
|
||||
{literal}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
renderCodeSpan = ({context, literal}: {context: any; literal: any}) => {
|
||||
const {baseTextStyle, textStyles: {code}} = this.props;
|
||||
return <Text style={this.computeTextStyle([baseTextStyle, code], context)}>{literal}</Text>;
|
||||
};
|
||||
|
||||
renderImage = ({linkDestination, context, src}: {linkDestination?: string; context: string[]; src: string}) => {
|
||||
if (!this.props.imagesMetadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (context.indexOf('table') !== -1) {
|
||||
// We have enough problems rendering images as is, so just render a link inside of a table
|
||||
return (
|
||||
<MarkdownTableImage
|
||||
disabled={this.props.disableGallery}
|
||||
imagesMetadata={this.props.imagesMetadata}
|
||||
postId={this.props.postId!}
|
||||
source={src}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MarkdownImage
|
||||
disabled={this.props.disableGallery}
|
||||
errorTextStyle={[this.computeTextStyle(this.props.baseTextStyle, context), this.props.textStyles.error]}
|
||||
linkDestination={linkDestination}
|
||||
imagesMetadata={this.props.imagesMetadata}
|
||||
isReplyPost={this.props.isReplyPost}
|
||||
postId={this.props.postId!}
|
||||
source={src}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderAtMention = ({context, mentionName}: {context: string[]; mentionName: string}) => {
|
||||
if (this.props.disableAtMentions) {
|
||||
return this.renderText({context, literal: `@${mentionName}`});
|
||||
}
|
||||
|
||||
const style = getStyleSheet(this.props.theme);
|
||||
|
||||
return (
|
||||
<AtMention
|
||||
mentionStyle={this.props.textStyles.mention}
|
||||
textStyle={[this.computeTextStyle(this.props.baseTextStyle, context), style.atMentionOpacity]}
|
||||
isSearchResult={this.props.isSearchResult}
|
||||
mentionName={mentionName}
|
||||
onPostPress={this.props.onPostPress}
|
||||
mentionKeys={this.props.mentionKeys || []}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderChannelLink = ({context, channelName}: {context: string[]; channelName: string}) => {
|
||||
if (this.props.disableChannelLink) {
|
||||
return this.renderText({context, literal: `~${channelName}`});
|
||||
}
|
||||
|
||||
return (
|
||||
<ChannelMention
|
||||
linkStyle={this.props.textStyles.link}
|
||||
textStyle={this.computeTextStyle(this.props.baseTextStyle, context)}
|
||||
channelName={channelName}
|
||||
channelMentions={this.props.channelMentions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderEmoji = ({context, emojiName, literal}: {context: string[]; emojiName: string; literal: string}) => {
|
||||
let customEmojiStyle;
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
const flat = StyleSheet.flatten(this.props.baseTextStyle);
|
||||
|
||||
if (flat) {
|
||||
const size = Math.abs(((flat.lineHeight || 0) - (flat.fontSize || 0))) / 2;
|
||||
if (size > 0) {
|
||||
customEmojiStyle = {marginRight: size, top: size};
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Emoji
|
||||
emojiName={emojiName}
|
||||
literal={literal}
|
||||
testID='markdown_emoji'
|
||||
textStyle={this.computeTextStyle(this.props.baseTextStyle, context)}
|
||||
customEmojiStyle={customEmojiStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderHashtag = ({context, hashtag}: {context: string[]; hashtag: string}) => {
|
||||
if (this.props.disableHashtags) {
|
||||
return this.renderText({context, literal: `#${hashtag}`});
|
||||
}
|
||||
|
||||
return (
|
||||
<Hashtag
|
||||
hashtag={hashtag}
|
||||
linkStyle={this.props.textStyles.link}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderParagraph = ({children, first}: {children: ReactElement[]; first: boolean}) => {
|
||||
if (!children || children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleSheet(this.props.theme);
|
||||
const blockStyle = [style.block];
|
||||
if (!first) {
|
||||
blockStyle.push(this.props.blockStyles.adjacentParagraph);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={blockStyle}>
|
||||
<Text>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
renderHeading = ({children, level}: {children: ReactElement; level: string}) => {
|
||||
const {textStyles} = this.props;
|
||||
const containerStyle = [
|
||||
getStyleSheet(this.props.theme).block,
|
||||
textStyles[`heading${level}`],
|
||||
];
|
||||
const textStyle = textStyles[`heading${level}Text`];
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Text style={textStyle}>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
renderCodeBlock = (props: any) => {
|
||||
// These sometimes include a trailing newline
|
||||
const content = props.literal.replace(/\n$/, '');
|
||||
|
||||
return (
|
||||
<MarkdownCodeBlock
|
||||
content={content}
|
||||
language={props.language}
|
||||
textStyle={this.props.textStyles.codeBlock}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderBlockQuote = ({children, ...otherProps}: any) => {
|
||||
return (
|
||||
<MarkdownBlockQuote
|
||||
iconStyle={this.props.blockStyles.quoteBlockIcon}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</MarkdownBlockQuote>
|
||||
);
|
||||
};
|
||||
|
||||
renderList = ({children, start, tight, type}: any) => {
|
||||
return (
|
||||
<MarkdownList
|
||||
ordered={type !== 'bullet'}
|
||||
start={start}
|
||||
tight={tight}
|
||||
>
|
||||
{children}
|
||||
</MarkdownList>
|
||||
);
|
||||
};
|
||||
|
||||
renderListItem = ({children, context, ...otherProps}: any) => {
|
||||
const level = context.filter((type: string) => type === 'list').length;
|
||||
|
||||
return (
|
||||
<MarkdownListItem
|
||||
bulletStyle={this.props.baseTextStyle}
|
||||
level={level}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListItem>
|
||||
);
|
||||
};
|
||||
|
||||
renderHardBreak = () => {
|
||||
return <Text>{'\n'}</Text>;
|
||||
};
|
||||
|
||||
renderThematicBreak = () => {
|
||||
return (
|
||||
<View
|
||||
style={this.props.blockStyles.horizontalRule}
|
||||
testID='markdown_thematic_break'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderSoftBreak = () => {
|
||||
return <Text>{'\n'}</Text>;
|
||||
};
|
||||
|
||||
renderHtml = (props: any) => {
|
||||
let rendered = this.renderText(props);
|
||||
|
||||
if (props.isBlock) {
|
||||
const style = getStyleSheet(this.props.theme);
|
||||
|
||||
rendered = (
|
||||
<View style={style.block}>
|
||||
{rendered}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return rendered;
|
||||
};
|
||||
|
||||
renderTable = ({children, numColumns}: {children: ReactElement; numColumns: number}) => {
|
||||
return (
|
||||
<MarkdownTable
|
||||
numColumns={numColumns}
|
||||
theme={this.props.theme}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTable>
|
||||
);
|
||||
};
|
||||
|
||||
renderTableRow = (args: MarkdownTableRowProps) => {
|
||||
return <MarkdownTableRow {...args}/>;
|
||||
};
|
||||
|
||||
renderTableCell = (args: MarkdownTableCellProps) => {
|
||||
return <MarkdownTableCell {...args}/>;
|
||||
};
|
||||
|
||||
renderLink = ({children, href}: {children: ReactElement; href: string}) => {
|
||||
return (
|
||||
<MarkdownLink href={href}>
|
||||
{children}
|
||||
</MarkdownLink>
|
||||
);
|
||||
};
|
||||
|
||||
renderEditedIndicator = ({context}: {context: string[]}) => {
|
||||
let spacer = '';
|
||||
if (context[0] === 'paragraph') {
|
||||
spacer = ' ';
|
||||
}
|
||||
|
||||
const style = getStyleSheet(this.props.theme);
|
||||
const styles = [
|
||||
this.props.baseTextStyle,
|
||||
style.editedIndicatorText,
|
||||
];
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={styles}
|
||||
>
|
||||
{spacer}
|
||||
<FormattedText
|
||||
id='post_message_view.edited'
|
||||
defaultMessage='(edited)'
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
let ast = this.parser.parse(this.props.value.toString());
|
||||
|
||||
ast = combineTextNodes(ast);
|
||||
ast = addListItemIndices(ast);
|
||||
ast = pullOutImages(ast);
|
||||
if (this.props.mentionKeys) {
|
||||
ast = highlightMentions(ast, this.props.mentionKeys);
|
||||
}
|
||||
|
||||
if (this.props.isEdited) {
|
||||
const editIndicatorNode = new Node('edited_indicator');
|
||||
if (ast.lastChild && ['heading', 'paragraph'].includes(ast.lastChild.type)) {
|
||||
ast.lastChild.appendChild(editIndicatorNode);
|
||||
} else {
|
||||
const node = new Node('paragraph');
|
||||
node.appendChild(editIndicatorNode);
|
||||
|
||||
ast.appendChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
return this.renderer.render(ast);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
// Android has trouble giving text transparency depending on how it's nested,
|
||||
// so we calculate the resulting colour manually
|
||||
const editedOpacity = Platform.select({
|
||||
ios: 0.3,
|
||||
android: 1.0,
|
||||
});
|
||||
const editedColor = Platform.select({
|
||||
ios: theme.centerChannelColor,
|
||||
android: blendColors(theme.centerChannelBg, theme.centerChannelColor, 0.3),
|
||||
});
|
||||
|
||||
return {
|
||||
block: {
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
editedIndicatorText: {
|
||||
color: editedColor,
|
||||
opacity: editedOpacity,
|
||||
},
|
||||
atMentionOpacity: {
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default Markdown;
|
||||
49
app/components/markdown/markdown_block_quote/index.tsx
Normal file
49
app/components/markdown/markdown_block_quote/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {ReactNode} from 'react';
|
||||
|
||||
import {StyleSheet, TextStyle, View, ViewStyle} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
|
||||
type MarkdownBlockQuoteProps = {
|
||||
continueBlock?: boolean;
|
||||
iconStyle: ViewStyle | TextStyle;
|
||||
children: ReactNode | ReactNode[];
|
||||
};
|
||||
|
||||
const style = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
childContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
icon: {
|
||||
width: 23,
|
||||
},
|
||||
});
|
||||
|
||||
const MarkdownBlockQuote = ({children, continueBlock, iconStyle}: MarkdownBlockQuoteProps) => {
|
||||
return (
|
||||
<View
|
||||
style={style.container}
|
||||
testID='markdown_block_quote'
|
||||
>
|
||||
{!continueBlock && (
|
||||
<View style={style.icon}>
|
||||
<CompassIcon
|
||||
name='format-quote-open'
|
||||
style={iconStyle}
|
||||
size={20}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={style.childContainer}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownBlockQuote;
|
||||
250
app/components/markdown/markdown_code_block/index.tsx
Normal file
250
app/components/markdown/markdown_code_block/index.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useManagedConfig} from '@mattermost/react-native-emm';
|
||||
import Clipboard from '@react-native-community/clipboard';
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {DeviceEventEmitter, Keyboard, StyleSheet, Text, TextStyle, View} from 'react-native';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {useTheme} from '@context/theme';
|
||||
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {Navigation} from '@constants';
|
||||
import {goToScreen, showModalOverCurrentContext} from '@screens/navigation';
|
||||
import {getDisplayNameForLanguage} from '@utils/markdown';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type MarkdownCodeBlockProps = {
|
||||
language: string;
|
||||
content: string;
|
||||
textStyle: TextStyle;
|
||||
};
|
||||
|
||||
const MAX_LINES = 4;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
bottomSheet: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.15),
|
||||
borderRadius: 3,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
lineNumbers: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.05),
|
||||
borderRightColor: changeOpacity(theme.centerChannelColor, 0.15),
|
||||
borderRightWidth: StyleSheet.hairlineWidth,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
paddingVertical: 4,
|
||||
width: 21,
|
||||
},
|
||||
lineNumbersText: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.5),
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
rightColumn: {
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
code: {
|
||||
flexDirection: 'row',
|
||||
overflow: 'scroll', // Doesn't actually cause a scrollbar, but stops text from wrapping
|
||||
},
|
||||
codeText: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.65),
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
plusMoreLinesText: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.4),
|
||||
fontSize: 11,
|
||||
marginTop: 2,
|
||||
},
|
||||
language: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.sidebarHeaderBg,
|
||||
justifyContent: 'center',
|
||||
opacity: 0.8,
|
||||
padding: 6,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
},
|
||||
languageText: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
fontSize: 12,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBlockProps) => {
|
||||
const intl = useIntl();
|
||||
const managedConfig = useManagedConfig();
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const handlePress = preventDoubleTap(() => {
|
||||
const screen = 'Code';
|
||||
const passProps = {
|
||||
content,
|
||||
};
|
||||
|
||||
const languageDisplayName = getDisplayNameForLanguage(language);
|
||||
let title: string;
|
||||
if (languageDisplayName) {
|
||||
title = intl.formatMessage(
|
||||
{
|
||||
id: 'mobile.routes.code',
|
||||
defaultMessage: '{language} Code',
|
||||
},
|
||||
{
|
||||
language: languageDisplayName,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
title = intl.formatMessage({
|
||||
id: 'mobile.routes.code.noLanguage',
|
||||
defaultMessage: 'Code',
|
||||
});
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
requestAnimationFrame(() => {
|
||||
goToScreen(screen, title, passProps);
|
||||
});
|
||||
});
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
if (managedConfig?.copyAndPasteProtection !== 'true') {
|
||||
const renderContent = () => {
|
||||
return (
|
||||
<View
|
||||
testID='at_mention.bottom_sheet'
|
||||
style={style.bottomSheet}
|
||||
>
|
||||
<SlideUpPanelItem
|
||||
icon='content-copy'
|
||||
onPress={() => {
|
||||
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
|
||||
Clipboard.setString(content);
|
||||
}}
|
||||
testID='at_mention.bottom_sheet.copy_code'
|
||||
text={intl.formatMessage({id: 'mobile.markdown.code.copy_code', defaultMessage: 'Copy Code'})}
|
||||
/>
|
||||
<SlideUpPanelItem
|
||||
destructive={true}
|
||||
icon='cancel'
|
||||
onPress={() => {
|
||||
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
|
||||
}}
|
||||
testID='at_mention.bottom_sheet.cancel'
|
||||
text={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
showModalOverCurrentContext('BottomSheet', {
|
||||
renderContent,
|
||||
snapPoints: [3 * ITEM_HEIGHT, 10],
|
||||
});
|
||||
}
|
||||
}, [managedConfig]);
|
||||
|
||||
const trimContent = (text: string) => {
|
||||
const lines = text.split('\n');
|
||||
const numberOfLines = lines.length;
|
||||
|
||||
if (numberOfLines > MAX_LINES) {
|
||||
return {
|
||||
content: lines.slice(0, MAX_LINES).join('\n'),
|
||||
numberOfLines,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: text,
|
||||
numberOfLines,
|
||||
};
|
||||
};
|
||||
|
||||
const renderLanguageBlock = () => {
|
||||
if (language) {
|
||||
const languageDisplayName = getDisplayNameForLanguage(language);
|
||||
|
||||
if (languageDisplayName) {
|
||||
return (
|
||||
<View style={style.language}>
|
||||
<Text style={style.languageText}>
|
||||
{languageDisplayName}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const {content: codeContent, numberOfLines} = trimContent(content);
|
||||
|
||||
const getLineNumbers = () => {
|
||||
let lineNumbers = '1';
|
||||
for (let i = 1; i < Math.min(numberOfLines, MAX_LINES); i++) {
|
||||
const line = (i + 1).toString();
|
||||
lineNumbers += '\n' + line;
|
||||
}
|
||||
return lineNumbers;
|
||||
};
|
||||
|
||||
const renderPlusMoreLines = () => {
|
||||
if (numberOfLines > MAX_LINES) {
|
||||
return (
|
||||
<FormattedText
|
||||
style={style.plusMoreLinesText}
|
||||
id='mobile.markdown.code.plusMoreLines'
|
||||
defaultMessage='+{count, number} more {count, plural, one {line} other {lines}}'
|
||||
values={{
|
||||
count: numberOfLines - MAX_LINES,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
type={'opacity'}
|
||||
>
|
||||
<View style={style.container}>
|
||||
<View style={style.lineNumbers}>
|
||||
<Text style={style.lineNumbersText}>{getLineNumbers()}</Text>
|
||||
</View>
|
||||
<View style={style.rightColumn}>
|
||||
<View style={style.code}>
|
||||
<Text style={[style.codeText, textStyle]}>
|
||||
{codeContent}
|
||||
</Text>
|
||||
</View>
|
||||
{renderPlusMoreLines()}
|
||||
</View>
|
||||
{renderLanguageBlock()}
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownCodeBlock;
|
||||
225
app/components/markdown/markdown_image/index.tsx
Normal file
225
app/components/markdown/markdown_image/index.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useManagedConfig} from '@mattermost/react-native-emm';
|
||||
import Clipboard from '@react-native-community/clipboard';
|
||||
import React, {useCallback, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, DeviceEventEmitter, Platform, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native';
|
||||
import parseUrl from 'url-parse';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {Navigation} from '@constants';
|
||||
import {useServerUrl} from '@context/server_url';
|
||||
import {usePermanentSidebar, useSplitView} from '@hooks/device';
|
||||
import {showModalOverCurrentContext} from '@screens/navigation';
|
||||
import {openGallerWithMockFile} from '@utils/gallery';
|
||||
import {generateId} from '@utils/general';
|
||||
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
|
||||
import {normalizeProtocol, tryOpenURL} from '@utils/url';
|
||||
|
||||
type MarkdownImageProps = {
|
||||
disabled?: boolean;
|
||||
errorTextStyle: StyleProp<TextStyle>;
|
||||
imagesMetadata?: Record<string, PostImage>;
|
||||
isReplyPost?: boolean;
|
||||
linkDestination?: string;
|
||||
postId: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const ANDROID_MAX_HEIGHT = 4096;
|
||||
const ANDROID_MAX_WIDTH = 4096;
|
||||
|
||||
const style = StyleSheet.create({
|
||||
bottomSheet: {
|
||||
flex: 1,
|
||||
},
|
||||
brokenImageIcon: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
container: {
|
||||
marginBottom: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const MarkdownImage = ({
|
||||
disabled, errorTextStyle, imagesMetadata, isReplyPost = false,
|
||||
linkDestination, postId, source,
|
||||
}: MarkdownImageProps) => {
|
||||
const intl = useIntl();
|
||||
const isSplitView = useSplitView();
|
||||
const managedConfig = useManagedConfig();
|
||||
const permanentSidebar = usePermanentSidebar();
|
||||
const genericFileId = useRef(generateId()).current;
|
||||
const metadata = imagesMetadata?.[source] || Object.values(imagesMetadata || {})[0];
|
||||
const [failed, setFailed] = useState(isGifTooLarge(metadata));
|
||||
const originalSize = {width: metadata?.width || 0, height: metadata?.height || 0};
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
let uri = source;
|
||||
if (uri.startsWith('/')) {
|
||||
uri = serverUrl + uri;
|
||||
}
|
||||
|
||||
const link = decodeURIComponent(uri);
|
||||
let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', '');
|
||||
let extension = filename.split('.').pop();
|
||||
if (extension === filename) {
|
||||
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
|
||||
filename = `${filename}${ext}`;
|
||||
extension = ext;
|
||||
}
|
||||
|
||||
const fileInfo = {
|
||||
id: genericFileId,
|
||||
name: filename,
|
||||
extension,
|
||||
has_preview_image: true,
|
||||
post_id: postId,
|
||||
uri: link,
|
||||
width: originalSize.width,
|
||||
height: originalSize.height,
|
||||
};
|
||||
|
||||
const {height, width} = calculateDimensions(fileInfo.height, fileInfo.width, getViewPortWidth(isReplyPost, (permanentSidebar && !isSplitView)));
|
||||
|
||||
const handleLinkPress = useCallback(() => {
|
||||
if (linkDestination) {
|
||||
const url = normalizeProtocol(linkDestination);
|
||||
|
||||
const onError = () => {
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.title',
|
||||
defaultMessage: 'Error',
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: 'mobile.link.error.text',
|
||||
defaultMessage: 'Unable to open the link.',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
tryOpenURL(url, onError);
|
||||
}
|
||||
}, [linkDestination]);
|
||||
|
||||
const handleLinkLongPress = useCallback(() => {
|
||||
if (managedConfig?.copyAndPasteProtection !== 'true') {
|
||||
const renderContent = () => {
|
||||
return (
|
||||
<View
|
||||
testID='at_mention.bottom_sheet'
|
||||
style={style.bottomSheet}
|
||||
>
|
||||
<SlideUpPanelItem
|
||||
icon='content-copy'
|
||||
onPress={() => {
|
||||
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
|
||||
Clipboard.setString(linkDestination || source);
|
||||
}}
|
||||
testID='at_mention.bottom_sheet.copy_url'
|
||||
text={intl.formatMessage({id: 'mobile.markdown.link.copy_url', defaultMessage: 'Copy URL'})}
|
||||
/>
|
||||
<SlideUpPanelItem
|
||||
destructive={true}
|
||||
icon='cancel'
|
||||
onPress={() => {
|
||||
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
|
||||
}}
|
||||
testID='at_mention.bottom_sheet.cancel'
|
||||
text={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
showModalOverCurrentContext('BottomSheet', {
|
||||
renderContent,
|
||||
snapPoints: [3 * ITEM_HEIGHT, 10],
|
||||
});
|
||||
}
|
||||
}, [managedConfig]);
|
||||
|
||||
const handlePreviewImage = useCallback(() => {
|
||||
openGallerWithMockFile(fileInfo.uri, postId, fileInfo.height, fileInfo.width, fileInfo.id);
|
||||
}, []);
|
||||
|
||||
const handleOnError = useCallback(() => {
|
||||
setFailed(true);
|
||||
}, []);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<CompassIcon
|
||||
name='jumbo-attachment-image-broken'
|
||||
size={24}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let image;
|
||||
if (height && width) {
|
||||
if (Platform.OS === 'android' && (height > ANDROID_MAX_HEIGHT || width > ANDROID_MAX_WIDTH)) {
|
||||
// Android has a cap on the max image size that can be displayed
|
||||
image = (
|
||||
<Text style={errorTextStyle}>
|
||||
<FormattedText
|
||||
id='mobile.markdown.image.too_large'
|
||||
defaultMessage='Image exceeds max dimensions of {maxWidth} by {maxHeight}:'
|
||||
values={{
|
||||
maxWidth: ANDROID_MAX_WIDTH,
|
||||
maxHeight: ANDROID_MAX_HEIGHT,
|
||||
}}
|
||||
/>
|
||||
{' '}
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
image = (
|
||||
<TouchableWithFeedback
|
||||
disabled={disabled}
|
||||
onLongPress={handleLinkLongPress}
|
||||
onPress={handlePreviewImage}
|
||||
style={{width, height}}
|
||||
>
|
||||
<ProgressiveImage
|
||||
id={fileInfo.id}
|
||||
defaultSource={{uri: fileInfo.uri}}
|
||||
onError={handleOnError}
|
||||
resizeMode='contain'
|
||||
style={{width, height}}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (image && linkDestination && !disabled) {
|
||||
image = (
|
||||
<TouchableWithFeedback
|
||||
onPress={handleLinkPress}
|
||||
onLongPress={handleLinkLongPress}
|
||||
>
|
||||
{image}
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={style.container}
|
||||
testID='markdown_image'
|
||||
>
|
||||
{image}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownImage;
|
||||
184
app/components/markdown/markdown_link/index.tsx
Normal file
184
app/components/markdown/markdown_link/index.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useManagedConfig} from '@mattermost/react-native-emm';
|
||||
import Clipboard from '@react-native-community/clipboard';
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import React, {Children, ReactElement, useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, DeviceEventEmitter, StyleSheet, Text, View} from 'react-native';
|
||||
import {of} from 'rxjs';
|
||||
import urlParse from 'url-parse';
|
||||
|
||||
import {switchToChannelByName} from '@actions/local/channel';
|
||||
import {showPermalink} from '@actions/local/permalink';
|
||||
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
|
||||
import {Navigation} from '@constants';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import DeepLinkTypes from '@constants/deep_linking';
|
||||
import {useServerUrl} from '@context/server_url';
|
||||
import {dismissAllModals, popToRoot, showModalOverCurrentContext} from '@screens/navigation';
|
||||
import {errorBadChannel} from '@utils/draft';
|
||||
import {matchDeepLink, normalizeProtocol, tryOpenURL} from '@utils/url';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
import type {DeepLinkChannel, DeepLinkPermalink, DeepLinkWithData} from '@typings/launch';
|
||||
|
||||
type MarkdownLinkProps = {
|
||||
children: ReactElement;
|
||||
experimentalNormalizeMarkdownLinks: string;
|
||||
href: string;
|
||||
siteURL: string;
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
bottomSheet: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteURL}: MarkdownLinkProps) => {
|
||||
const intl = useIntl();
|
||||
const managedConfig = useManagedConfig();
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
const {formatMessage} = intl;
|
||||
|
||||
const handlePress = preventDoubleTap(async () => {
|
||||
const url = normalizeProtocol(href);
|
||||
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const match: DeepLinkWithData | null = matchDeepLink(url, serverUrl, siteURL);
|
||||
|
||||
if (match && match.data?.teamName) {
|
||||
if (match.type === DeepLinkTypes.CHANNEL) {
|
||||
const result = await switchToChannelByName(serverUrl, (match?.data as DeepLinkChannel).channelName, match.data?.teamName, errorBadChannel, intl);
|
||||
if (!result.error) {
|
||||
await dismissAllModals();
|
||||
await popToRoot();
|
||||
}
|
||||
} else if (match.type === DeepLinkTypes.PERMALINK) {
|
||||
showPermalink(serverUrl, match.data.teamName, (match.data as DeepLinkPermalink).postId, intl);
|
||||
}
|
||||
} else {
|
||||
const onError = () => {
|
||||
Alert.alert(
|
||||
formatMessage({
|
||||
id: 'mobile.link.error.title',
|
||||
defaultMessage: 'Error',
|
||||
}),
|
||||
formatMessage({
|
||||
id: 'mobile.link.error.text',
|
||||
defaultMessage: 'Unable to open the link.',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
tryOpenURL(url, onError);
|
||||
}
|
||||
});
|
||||
|
||||
const parseLinkLiteral = (literal: string) => {
|
||||
let nextLiteral = literal;
|
||||
|
||||
const WWW_REGEX = /\b^(?:www.)/i;
|
||||
if (nextLiteral.match(WWW_REGEX)) {
|
||||
nextLiteral = literal.replace(WWW_REGEX, 'www.');
|
||||
}
|
||||
|
||||
const parsed = urlParse(nextLiteral, {});
|
||||
|
||||
return parsed.href;
|
||||
};
|
||||
|
||||
const parseChildren = () => {
|
||||
return Children.map(children, (child: ReactElement) => {
|
||||
if (!child.props.literal || typeof child.props.literal !== 'string' || (child.props.context && child.props.context.length && !child.props.context.includes('link'))) {
|
||||
return child;
|
||||
}
|
||||
|
||||
const {props, ...otherChildProps} = child;
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const {literal, ...otherProps} = props;
|
||||
|
||||
const nextProps = {
|
||||
literal: parseLinkLiteral(literal),
|
||||
...otherProps,
|
||||
};
|
||||
|
||||
return {
|
||||
props: nextProps,
|
||||
...otherChildProps,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
if (managedConfig?.copyAndPasteProtection !== 'true') {
|
||||
const renderContent = () => {
|
||||
return (
|
||||
<View
|
||||
testID='at_mention.bottom_sheet'
|
||||
style={style.bottomSheet}
|
||||
>
|
||||
<SlideUpPanelItem
|
||||
icon='content-copy'
|
||||
onPress={() => {
|
||||
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
|
||||
Clipboard.setString(href);
|
||||
}}
|
||||
testID='at_mention.bottom_sheet.copy_url'
|
||||
text={intl.formatMessage({id: 'mobile.markdown.link.copy_url', defaultMessage: 'Copy URL'})}
|
||||
/>
|
||||
<SlideUpPanelItem
|
||||
destructive={true}
|
||||
icon='cancel'
|
||||
onPress={() => {
|
||||
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
|
||||
}}
|
||||
testID='at_mention.bottom_sheet.cancel'
|
||||
text={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
showModalOverCurrentContext('BottomSheet', {
|
||||
renderContent,
|
||||
snapPoints: [3 * ITEM_HEIGHT, 10],
|
||||
});
|
||||
}
|
||||
}, [managedConfig]);
|
||||
|
||||
const renderChildren = experimentalNormalizeMarkdownLinks ? parseChildren() : children;
|
||||
|
||||
return (
|
||||
<Text
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
>
|
||||
{renderChildren}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const withSystemIds = withObservables([], ({database}: WithDatabaseArgs) => ({
|
||||
config: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG),
|
||||
}));
|
||||
|
||||
const withConfigValues = withObservables(['config'], ({config}: {config: SystemModel}) => {
|
||||
const cfg: ClientConfig = config.value;
|
||||
|
||||
return {
|
||||
experimentalNormalizeMarkdownLinks: of(cfg.ExperimentalNormalizeMarkdownLinks),
|
||||
siteURL: of(cfg.SiteURL),
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(withSystemIds(withConfigValues(MarkdownLink)));
|
||||
35
app/components/markdown/markdown_list/index.tsx
Normal file
35
app/components/markdown/markdown_list/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {ReactElement} from 'react';
|
||||
|
||||
type MarkdownList = {
|
||||
children: ReactElement[];
|
||||
ordered: boolean;
|
||||
start: number;
|
||||
tight: boolean;
|
||||
};
|
||||
|
||||
const MarkdownList = ({start = 1, tight, ordered, children}: MarkdownList) => {
|
||||
let bulletWidth = 15;
|
||||
if (ordered) {
|
||||
const lastNumber = (start + children.length) - 1;
|
||||
bulletWidth = (9 * lastNumber.toString().length) + 7;
|
||||
}
|
||||
|
||||
const childrenElements = React.Children.map(children, (child) => {
|
||||
return React.cloneElement(child, {
|
||||
bulletWidth,
|
||||
ordered,
|
||||
tight,
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{childrenElements}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownList;
|
||||
57
app/components/markdown/markdown_list_item/index.tsx
Normal file
57
app/components/markdown/markdown_list_item/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {ReactNode} from 'react';
|
||||
import {StyleSheet, Text, TextStyle, View} from 'react-native';
|
||||
|
||||
type MarkdownListItemProps = {
|
||||
bulletStyle: TextStyle;
|
||||
bulletWidth: number;
|
||||
children: ReactNode | ReactNode[];
|
||||
continue: boolean;
|
||||
index: number;
|
||||
ordered: boolean;
|
||||
level: number;
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
bullet: {
|
||||
alignItems: 'flex-end',
|
||||
marginRight: 5,
|
||||
},
|
||||
contents: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const MarkdownListItem = ({index, level, bulletWidth, bulletStyle, children, continue: doContinue, ordered}: MarkdownListItemProps) => {
|
||||
let bullet;
|
||||
if (doContinue) {
|
||||
bullet = '';
|
||||
} else if (ordered) {
|
||||
bullet = index + '.';
|
||||
} else if (level % 2 === 0) {
|
||||
bullet = '◦';
|
||||
} else {
|
||||
bullet = '•';
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
<View style={[{width: bulletWidth}, style.bullet]}>
|
||||
<Text style={bulletStyle}>
|
||||
{bullet}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={style.contents}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownListItem;
|
||||
362
app/components/markdown/markdown_table/index.tsx
Normal file
362
app/components/markdown/markdown_table/index.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent, ReactNode} from 'react';
|
||||
import {injectIntl, IntlShape} from 'react-intl';
|
||||
import {Dimensions, LayoutChangeEvent, Platform, ScaledSize, ScrollView, View} from 'react-native';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {CELL_MAX_WIDTH, CELL_MIN_WIDTH} from '@components/markdown/markdown_table_cell';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import DeviceTypes from '@constants/device';
|
||||
import {goToScreen} from '@screens/navigation';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
const MAX_HEIGHT = 300;
|
||||
const MAX_PREVIEW_COLUMNS = 5;
|
||||
|
||||
type MarkdownTableState = {
|
||||
cellWidth: number;
|
||||
containerWidth: number;
|
||||
contentHeight: number;
|
||||
maxPreviewColumns: number;
|
||||
}
|
||||
|
||||
type MarkdownTableInputProps = {
|
||||
children: ReactNode;
|
||||
numColumns: number;
|
||||
}
|
||||
|
||||
type MarkdownTableProps = MarkdownTableInputProps & {
|
||||
intl: IntlShape;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
class MarkdownTable extends PureComponent<MarkdownTableProps, MarkdownTableState> {
|
||||
private rowsSliced: boolean | undefined;
|
||||
private colsSliced: boolean | undefined;
|
||||
|
||||
state = {
|
||||
containerWidth: 0,
|
||||
contentHeight: 0,
|
||||
cellWidth: 0,
|
||||
maxPreviewColumns: 0,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
Dimensions.addEventListener('change', this.setMaxPreviewColumns);
|
||||
|
||||
const window = Dimensions.get('window');
|
||||
this.setMaxPreviewColumns({window});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
Dimensions.removeEventListener('change', this.setMaxPreviewColumns);
|
||||
}
|
||||
|
||||
setMaxPreviewColumns = ({window}: {window: ScaledSize}) => {
|
||||
const maxPreviewColumns = Math.floor(window.width / CELL_MIN_WIDTH);
|
||||
this.setState({maxPreviewColumns});
|
||||
}
|
||||
|
||||
getTableWidth = (isFullView = false) => {
|
||||
const maxPreviewColumns = this.state.maxPreviewColumns || MAX_PREVIEW_COLUMNS;
|
||||
const columns = Math.min(this.props.numColumns, maxPreviewColumns);
|
||||
|
||||
return (isFullView || columns === 1) ? columns * CELL_MAX_WIDTH : columns * CELL_MIN_WIDTH;
|
||||
};
|
||||
|
||||
handlePress = preventDoubleTap(() => {
|
||||
const {intl} = this.context;
|
||||
const screen = 'Table';
|
||||
const title = intl.formatMessage({
|
||||
id: 'mobile.routes.table',
|
||||
defaultMessage: 'Table',
|
||||
});
|
||||
const passProps = {
|
||||
renderRows: this.renderRows,
|
||||
tableWidth: this.getTableWidth(true),
|
||||
renderAsFlex: this.shouldRenderAsFlex(true),
|
||||
};
|
||||
|
||||
goToScreen(screen, title, passProps);
|
||||
});
|
||||
|
||||
handleContainerLayout = (e: LayoutChangeEvent) => {
|
||||
this.setState({
|
||||
containerWidth: e.nativeEvent.layout.width,
|
||||
});
|
||||
};
|
||||
|
||||
handleContentSizeChange = (contentWidth: number, contentHeight: number) => {
|
||||
this.setState({
|
||||
contentHeight,
|
||||
});
|
||||
};
|
||||
|
||||
renderPreviewRows = (isFullView = false) => {
|
||||
return this.renderRows(isFullView, true);
|
||||
}
|
||||
|
||||
shouldRenderAsFlex = (isFullView = false) => {
|
||||
const {numColumns} = this.props;
|
||||
const {height, width} = Dimensions.get('window');
|
||||
const isLandscape = width > height;
|
||||
|
||||
// render as flex in the channel screen, only for mobile phones on the portrait mode,
|
||||
// and if tables have 2 ~ 4 columns
|
||||
if (!isFullView && numColumns > 1 && numColumns < 4 && !DeviceTypes.IS_TABLET) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// render a 4 column table as flex when in landscape mode only
|
||||
// otherwise it should expand beyond the device's full width
|
||||
if (!isFullView && isLandscape && numColumns === 4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// render as flex in full table screen, only for mobile phones on portrait mode,
|
||||
// and if tables have 3 or 4 columns
|
||||
if (isFullView && numColumns >= 3 && numColumns <= 4 && !DeviceTypes.IS_TABLET) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
getTableStyle = (isFullView: boolean) => {
|
||||
const {theme} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
const tableStyle = [style.table];
|
||||
|
||||
const renderAsFlex = this.shouldRenderAsFlex(isFullView);
|
||||
if (renderAsFlex) {
|
||||
tableStyle.push(style.displayFlex);
|
||||
return tableStyle;
|
||||
}
|
||||
|
||||
tableStyle.push({width: this.getTableWidth(isFullView)});
|
||||
return tableStyle;
|
||||
}
|
||||
|
||||
renderRows = (isFullView = false, isPreview = false) => {
|
||||
const tableStyle = this.getTableStyle(isFullView);
|
||||
|
||||
let rows = React.Children.toArray(this.props.children) as React.ReactElement[];
|
||||
if (isPreview) {
|
||||
const {maxPreviewColumns} = this.state;
|
||||
const prevRowLength = rows.length;
|
||||
const prevColLength = React.Children.toArray(rows[0].props.children).length;
|
||||
|
||||
rows = rows.slice(0, maxPreviewColumns).map((row) => {
|
||||
const children = React.Children.toArray(row.props.children).slice(0, maxPreviewColumns);
|
||||
return {
|
||||
...row,
|
||||
props: {
|
||||
...row.props,
|
||||
children,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.rowsSliced = prevRowLength > rows.length;
|
||||
this.colsSliced = prevColLength > React.Children.toArray(rows[0].props.children).length;
|
||||
}
|
||||
|
||||
// Add an extra prop to the last row of the table so that it knows not to render a bottom border
|
||||
// since the container should be rendering that
|
||||
rows[rows.length - 1] = React.cloneElement(rows[rows.length - 1], {
|
||||
isLastRow: true,
|
||||
});
|
||||
|
||||
// Add an extra prop to the first row of the table so that it can have a different background color
|
||||
rows[0] = React.cloneElement(rows[0], {
|
||||
isFirstRow: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={tableStyle}>
|
||||
{rows}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {containerWidth, contentHeight} = this.state;
|
||||
const {theme} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
const tableWidth = this.getTableWidth();
|
||||
const renderAsFlex = this.shouldRenderAsFlex();
|
||||
const previewRows = this.renderPreviewRows();
|
||||
|
||||
let leftOffset;
|
||||
if (renderAsFlex || tableWidth > containerWidth) {
|
||||
leftOffset = containerWidth - 20;
|
||||
} else {
|
||||
leftOffset = tableWidth - 20;
|
||||
}
|
||||
let expandButtonOffset = leftOffset;
|
||||
if (Platform.OS === 'android') {
|
||||
expandButtonOffset -= 10;
|
||||
}
|
||||
|
||||
// Renders when the columns were sliced, or the table width exceeds the container,
|
||||
// or if the columns exceed maximum allowed for previews
|
||||
let moreRight = null;
|
||||
if (this.colsSliced ||
|
||||
(containerWidth && tableWidth > containerWidth && !renderAsFlex) ||
|
||||
(this.props.numColumns > MAX_PREVIEW_COLUMNS)) {
|
||||
moreRight = (
|
||||
<LinearGradient
|
||||
colors={[
|
||||
changeOpacity(theme.centerChannelColor, 0.0),
|
||||
changeOpacity(theme.centerChannelColor, 0.1),
|
||||
]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 0}}
|
||||
style={[style.moreRight, {height: contentHeight, left: leftOffset}]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let moreBelow = null;
|
||||
if (this.rowsSliced || contentHeight > MAX_HEIGHT) {
|
||||
const width = renderAsFlex ? '100%' : Math.min(tableWidth, containerWidth);
|
||||
|
||||
moreBelow = (
|
||||
<LinearGradient
|
||||
colors={[
|
||||
changeOpacity(theme.centerChannelColor, 0.0),
|
||||
changeOpacity(theme.centerChannelColor, 0.1),
|
||||
]}
|
||||
style={[style.moreBelow, {width}]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let expandButton = null;
|
||||
if (expandButtonOffset > 0) {
|
||||
expandButton = (
|
||||
<TouchableWithFeedback
|
||||
type='opacity'
|
||||
onPress={this.handlePress}
|
||||
style={[style.expandButton, {left: expandButtonOffset}]}
|
||||
testID='markdown_table.expand.button'
|
||||
>
|
||||
<View style={[style.iconContainer, {width: this.getTableWidth()}]}>
|
||||
<View style={style.iconButton}>
|
||||
<CompassIcon
|
||||
name='arrow-expand'
|
||||
style={style.icon}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
style={style.tablePadding}
|
||||
onPress={this.handlePress}
|
||||
type='opacity'
|
||||
testID='markdown_table'
|
||||
>
|
||||
<ScrollView
|
||||
onContentSizeChange={this.handleContentSizeChange}
|
||||
onLayout={this.handleContainerLayout}
|
||||
scrollEnabled={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={style.container}
|
||||
>
|
||||
{previewRows}
|
||||
</ScrollView>
|
||||
{moreRight}
|
||||
{moreBelow}
|
||||
{expandButton}
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
maxHeight: MAX_HEIGHT,
|
||||
},
|
||||
expandButton: {
|
||||
height: 34,
|
||||
width: 34,
|
||||
},
|
||||
iconContainer: {
|
||||
maxWidth: '100%',
|
||||
alignItems: 'flex-end',
|
||||
paddingTop: 8,
|
||||
paddingBottom: 4,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
paddingRight: 14,
|
||||
},
|
||||
}),
|
||||
},
|
||||
iconButton: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
marginTop: -32,
|
||||
marginRight: -6,
|
||||
borderWidth: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 50,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
width: 34,
|
||||
height: 34,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 14,
|
||||
color: theme.linkColor,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
fontSize: 13,
|
||||
},
|
||||
}),
|
||||
},
|
||||
displayFlex: {
|
||||
flex: 1,
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
borderWidth: 1,
|
||||
},
|
||||
tablePadding: {
|
||||
paddingRight: 10,
|
||||
},
|
||||
moreBelow: {
|
||||
bottom: Platform.select({
|
||||
ios: 34,
|
||||
android: 33.75,
|
||||
}),
|
||||
height: 20,
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
},
|
||||
moreRight: {
|
||||
maxHeight: MAX_HEIGHT,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: 20,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default injectIntl(MarkdownTable);
|
||||
62
app/components/markdown/markdown_table_cell/index.tsx
Normal file
62
app/components/markdown/markdown_table_cell/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {ReactNode} from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
export type MarkdownTableCellProps = {
|
||||
align: 'left' | 'center' | 'right';
|
||||
children: ReactNode;
|
||||
isLastCell: boolean;
|
||||
};
|
||||
|
||||
export const CELL_MIN_WIDTH = 96;
|
||||
export const CELL_MAX_WIDTH = 192;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
cell: {
|
||||
flex: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
justifyContent: 'flex-start',
|
||||
padding: 8,
|
||||
},
|
||||
cellRightBorder: {
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
alignCenter: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
alignRight: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const MarkdownTableCell = ({isLastCell, align, children}: MarkdownTableCellProps) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const cellStyle = [style.cell];
|
||||
if (!isLastCell) {
|
||||
cellStyle.push(style.cellRightBorder);
|
||||
}
|
||||
|
||||
let textStyle = null;
|
||||
if (align === 'center') {
|
||||
textStyle = style.alignCenter;
|
||||
} else if (align === 'right') {
|
||||
textStyle = style.alignRight;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[cellStyle, textStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownTableCell;
|
||||
122
app/components/markdown/markdown_table_image/index.tsx
Normal file
122
app/components/markdown/markdown_table_image/index.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useServerUrl} from '@context/server_url';
|
||||
import {generateId} from '@utils/general';
|
||||
import React, {memo, useCallback, useRef, useState} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
import parseUrl from 'url-parse';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {openGallerWithMockFile} from '@utils/gallery';
|
||||
import {calculateDimensions, isGifTooLarge} from '@utils/images';
|
||||
|
||||
type MarkdownTableImageProps = {
|
||||
disabled?: boolean;
|
||||
imagesMetadata: Record<string, PostImage>;
|
||||
postId: string;
|
||||
serverURL?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const MarkTableImage = ({disabled, imagesMetadata, postId, serverURL, source}: MarkdownTableImageProps) => {
|
||||
const metadata = imagesMetadata[source];
|
||||
const fileId = useRef(generateId()).current;
|
||||
const [failed, setFailed] = useState(isGifTooLarge(metadata));
|
||||
const currentServerUrl = useServerUrl();
|
||||
|
||||
const getImageSource = () => {
|
||||
let uri = source;
|
||||
let server = serverURL;
|
||||
|
||||
if (!serverURL) {
|
||||
server = currentServerUrl;
|
||||
}
|
||||
|
||||
if (uri.startsWith('/')) {
|
||||
uri = server + uri;
|
||||
}
|
||||
|
||||
return uri;
|
||||
};
|
||||
|
||||
const getFileInfo = () => {
|
||||
const {height, width} = metadata;
|
||||
const link = decodeURIComponent(getImageSource());
|
||||
let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', '');
|
||||
let extension = filename.split('.').pop();
|
||||
|
||||
if (extension === filename) {
|
||||
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
|
||||
filename = `${filename}${ext}`;
|
||||
extension = ext;
|
||||
}
|
||||
|
||||
return {
|
||||
id: fileId,
|
||||
name: filename,
|
||||
extension,
|
||||
has_preview_image: true,
|
||||
post_id: postId,
|
||||
uri: link,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePreviewImage = useCallback(() => {
|
||||
const file = getFileInfo() as FileInfo;
|
||||
if (!file?.uri) {
|
||||
return;
|
||||
}
|
||||
openGallerWithMockFile(file.uri, file.post_id, file.height, file.width, file.id);
|
||||
}, []);
|
||||
|
||||
const onLoadFailed = useCallback(() => {
|
||||
setFailed(true);
|
||||
}, []);
|
||||
|
||||
let image;
|
||||
if (failed) {
|
||||
image = (
|
||||
<CompassIcon
|
||||
name='jumbo-attachment-image-broken'
|
||||
size={24}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const {height, width} = calculateDimensions(metadata.height, metadata.width, 100, 100);
|
||||
image = (
|
||||
<TouchableWithFeedback
|
||||
disabled={disabled}
|
||||
onPress={handlePreviewImage}
|
||||
style={{width, height}}
|
||||
>
|
||||
<ProgressiveImage
|
||||
id={fileId}
|
||||
defaultSource={{uri: source}}
|
||||
onError={onLoadFailed}
|
||||
resizeMode='contain'
|
||||
style={{width, height}}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{image}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MarkTableImage);
|
||||
55
app/components/markdown/markdown_table_row/index.tsx
Normal file
55
app/components/markdown/markdown_table_row/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {ReactElement, ReactNode} from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
export type MarkdownTableRowProps = {
|
||||
isFirstRow: boolean;
|
||||
isLastRow: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
row: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
rowTopBackground: {
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
|
||||
},
|
||||
rowBottomBorder: {
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const MarkdownTableRow = ({isFirstRow, isLastRow, children}: MarkdownTableRowProps) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const rowStyle = [style.row];
|
||||
if (!isLastRow) {
|
||||
rowStyle.push(style.rowBottomBorder);
|
||||
}
|
||||
|
||||
if (isFirstRow) {
|
||||
rowStyle.push(style.rowTopBackground);
|
||||
}
|
||||
|
||||
// Add an extra prop to the last cell so that it knows not to render a right border since the container
|
||||
// will handle that
|
||||
const renderChildren = React.Children.toArray(children) as ReactElement[];
|
||||
renderChildren[renderChildren.length - 1] = React.cloneElement(renderChildren[renderChildren.length - 1], {
|
||||
isLastCell: true,
|
||||
});
|
||||
|
||||
return <View style={rowStyle}>{renderChildren}</View>;
|
||||
};
|
||||
|
||||
export default MarkdownTableRow;
|
||||
252
app/components/markdown/transform.ts
Normal file
252
app/components/markdown/transform.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Node, NodeType} from 'commonmark';
|
||||
|
||||
import {escapeRegex} from '@utils/markdown';
|
||||
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
|
||||
const cjkPattern = /[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf\uac00-\ud7a3]/;
|
||||
|
||||
// Combines adjacent text nodes into a single text node to make further transformation easier
|
||||
export function combineTextNodes(ast: any) {
|
||||
const walker = ast.walker();
|
||||
|
||||
let e;
|
||||
while ((e = walker.next())) {
|
||||
if (!e.entering) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const node = e.node;
|
||||
|
||||
if (node.type !== 'text') {
|
||||
continue;
|
||||
}
|
||||
|
||||
while (node._next && node._next.type === 'text') {
|
||||
const next = node._next;
|
||||
|
||||
node.literal += next.literal;
|
||||
|
||||
node._next = next._next;
|
||||
if (node._next) {
|
||||
node._next._prev = node;
|
||||
}
|
||||
|
||||
if (node._parent._lastChild === next) {
|
||||
node._parent._lastChild = node;
|
||||
}
|
||||
}
|
||||
|
||||
// Resume parsing after the current node since otherwise the walker would continue to parse any old text nodes
|
||||
// that have been merged into this one
|
||||
walker.resumeAt(node, false);
|
||||
}
|
||||
|
||||
return ast;
|
||||
}
|
||||
|
||||
// Add indices to the items of every list
|
||||
export function addListItemIndices(ast: any) {
|
||||
const walker = ast.walker();
|
||||
|
||||
let e;
|
||||
while ((e = walker.next())) {
|
||||
if (e.entering) {
|
||||
const node = e.node;
|
||||
|
||||
if (node.type === 'list') {
|
||||
let i = node.listStart == null ? 1 : node.listStart; // List indices match what would be displayed in the UI
|
||||
|
||||
for (let child = node.firstChild; child; child = child.next) {
|
||||
child.index = i;
|
||||
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ast;
|
||||
}
|
||||
|
||||
// Take all images that aren't inside of tables and move them to be children of the root document node.
|
||||
// When this happens, their parent nodes are split into two, if necessary, with the version that follows
|
||||
// the image having its "continue" field set to true to indicate that things like bullet points don't
|
||||
// need to be rendered.
|
||||
export function pullOutImages(ast: any) {
|
||||
const walker = ast.walker();
|
||||
|
||||
let e;
|
||||
while ((e = walker.next())) {
|
||||
if (!e.entering) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const node = e.node;
|
||||
|
||||
// Skip tables because we'll render images inside of those as links
|
||||
if (node.type === 'table') {
|
||||
walker.resumeAt(node, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.type === 'image' && node.parent?.type !== 'document') {
|
||||
pullOutImage(node);
|
||||
}
|
||||
}
|
||||
|
||||
return ast;
|
||||
}
|
||||
|
||||
function pullOutImage(image: any) {
|
||||
const parent = image.parent;
|
||||
|
||||
if (parent?.type === 'link') {
|
||||
image.linkDestination = parent.destination;
|
||||
}
|
||||
}
|
||||
|
||||
export function highlightMentions(ast: Node, mentionKeys: UserMentionKey[]) {
|
||||
const walker = ast.walker();
|
||||
|
||||
let e;
|
||||
while ((e = walker.next())) {
|
||||
if (!e.entering) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const node = e.node;
|
||||
|
||||
if (node.type === 'text' && node.literal) {
|
||||
const {index, mention} = getFirstMention(node.literal, mentionKeys);
|
||||
|
||||
if (index === -1 || !mention) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mentionNode = highlightTextNode(node, index, index + mention.key.length, 'mention_highlight');
|
||||
|
||||
// Resume processing on the next node after the mention node which may include any remaining text
|
||||
// that was part of this one
|
||||
walker.resumeAt(mentionNode, false);
|
||||
} else if (node.type === 'at_mention') {
|
||||
const matches = mentionKeys.some((mention) => {
|
||||
const mentionName = '@' + node.mentionName;
|
||||
const flags = mention.caseSensitive ? '' : 'i';
|
||||
const pattern = new RegExp(`${escapeRegex(mention.key)}\\.?`, flags);
|
||||
|
||||
return pattern.test(mentionName);
|
||||
});
|
||||
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const wrapper = new Node('mention_highlight');
|
||||
wrapNode(wrapper, node);
|
||||
|
||||
// Skip processing the wrapper to prevent checking this node again as its child
|
||||
walker.resumeAt(wrapper, false);
|
||||
}
|
||||
}
|
||||
|
||||
return ast;
|
||||
}
|
||||
|
||||
// Given a string and an array of mention keys, returns the first mention that appears and its index.
|
||||
export function getFirstMention(str: string, mentionKeys: UserMentionKey[]) {
|
||||
let firstMention = null;
|
||||
let firstMentionIndex = -1;
|
||||
|
||||
for (const mention of mentionKeys) {
|
||||
if (mention.key.trim() === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const flags = mention.caseSensitive ? '' : 'i';
|
||||
let pattern;
|
||||
if (cjkPattern.test(mention.key)) {
|
||||
pattern = new RegExp(`${escapeRegex(mention.key)}`, flags);
|
||||
} else {
|
||||
pattern = new RegExp(`\\b${escapeRegex(mention.key)}_*\\b`, flags);
|
||||
}
|
||||
|
||||
const match = pattern.exec(str);
|
||||
if (!match || match[0] === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (firstMentionIndex === -1 || match.index < firstMentionIndex) {
|
||||
firstMentionIndex = match.index;
|
||||
firstMention = mention;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
index: firstMentionIndex,
|
||||
mention: firstMention,
|
||||
};
|
||||
}
|
||||
|
||||
// Given a text node, start/end indices, and a highlight node type, splits it into up to three nodes:
|
||||
// the text before the highlight (if any exists), the highlighted text, and the text after the highlight
|
||||
// the end of the highlight (if any exists). Returns a node containing the highlighted text.
|
||||
export function highlightTextNode(node: Node, start: number, end: number, type: NodeType) {
|
||||
const literal = node.literal;
|
||||
node.literal = literal!.substring(start, end);
|
||||
|
||||
// Start by wrapping the node and then re-insert any non-highlighted code around it
|
||||
const highlighted = new Node(type);
|
||||
wrapNode(highlighted, node);
|
||||
|
||||
if (start !== 0) {
|
||||
const before = new Node('text');
|
||||
before.literal = literal!.substring(0, start);
|
||||
|
||||
highlighted.insertBefore(before);
|
||||
}
|
||||
|
||||
if (end !== literal!.length) {
|
||||
const after = new Node('text');
|
||||
after.literal = literal!.substring(end);
|
||||
|
||||
highlighted.insertAfter(after);
|
||||
}
|
||||
|
||||
return highlighted;
|
||||
}
|
||||
|
||||
// Wraps a given node in another node of the given type. The wrapper will take the place of
|
||||
// the node in the AST relative to its parents and siblings, and it will have the node as
|
||||
// its only child.
|
||||
function wrapNode(wrapper: any, node: any) {
|
||||
// Set parent and update parent's children if necessary
|
||||
wrapper._parent = node._parent;
|
||||
if (node._parent._firstChild === node) {
|
||||
node._parent._firstChild = wrapper;
|
||||
}
|
||||
if (node._parent._lastChild === node) {
|
||||
node._parent._lastChild = wrapper;
|
||||
}
|
||||
|
||||
// Set siblings and update those if necessary
|
||||
wrapper._prev = node._prev;
|
||||
node._prev = null;
|
||||
if (wrapper._prev) {
|
||||
wrapper._prev._next = wrapper;
|
||||
}
|
||||
|
||||
wrapper._next = node._next;
|
||||
node._next = null;
|
||||
if (wrapper._next) {
|
||||
wrapper._next._prev = wrapper;
|
||||
}
|
||||
|
||||
// Make node a child of wrapper
|
||||
wrapper._firstChild = node;
|
||||
wrapper._lastChild = node;
|
||||
node._parent = wrapper;
|
||||
}
|
||||
161
app/components/progressive_image/index.tsx
Normal file
161
app/components/progressive_image/index.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {ReactNode, useEffect, useRef, useState} from 'react';
|
||||
import {Animated, ImageBackground, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
|
||||
import FastImage, {ImageStyle, ResizeMode, Source} from 'react-native-fast-image';
|
||||
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import Thumbnail from './thumbnail';
|
||||
|
||||
const AnimatedImageBackground = Animated.createAnimatedComponent(ImageBackground);
|
||||
const AnimatedFastImage = Animated.createAnimatedComponent(FastImage);
|
||||
|
||||
type ProgressiveImageProps = {
|
||||
children?: ReactNode | ReactNode[];
|
||||
defaultSource?: Source; // this should be provided by the component
|
||||
id: string;
|
||||
imageStyle?: StyleProp<ImageStyle>;
|
||||
imageUri?: string;
|
||||
inViewPort?: boolean;
|
||||
isBackgroundImage?: boolean;
|
||||
onError: () => void;
|
||||
resizeMode?: ResizeMode;
|
||||
style?: ViewStyle;
|
||||
thumbnailUri?: string;
|
||||
tintDefaultSource?: boolean;
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
defaultImageContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
},
|
||||
defaultImageTint: {
|
||||
flex: 1,
|
||||
tintColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const ProgressiveImage = ({
|
||||
children, defaultSource, id, imageStyle, imageUri, inViewPort, isBackgroundImage, onError, resizeMode = 'contain',
|
||||
style = {}, thumbnailUri, tintDefaultSource,
|
||||
}: ProgressiveImageProps) => {
|
||||
const intensity = useRef(new Animated.Value(0)).current;
|
||||
const [showHighResImage, setShowHighResImage] = useState(false);
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
const onLoadImageEnd = () => {
|
||||
Animated.timing(intensity, {
|
||||
duration: 300,
|
||||
toValue: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (inViewPort) {
|
||||
setShowHighResImage(true);
|
||||
}
|
||||
}, [inViewPort]);
|
||||
|
||||
if (isBackgroundImage && imageUri) {
|
||||
return (
|
||||
<View style={[styles.defaultImageContainer, style]}>
|
||||
<AnimatedImageBackground
|
||||
key={id}
|
||||
source={{uri: imageUri}}
|
||||
resizeMode={'cover'}
|
||||
style={[StyleSheet.absoluteFill, imageStyle]}
|
||||
>
|
||||
{children}
|
||||
</AnimatedImageBackground>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (defaultSource) {
|
||||
return (
|
||||
<View style={[styles.defaultImageContainer, style]}>
|
||||
<AnimatedFastImage
|
||||
source={defaultSource}
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
imageStyle,
|
||||
tintDefaultSource ? styles.defaultImageTint : null,
|
||||
]}
|
||||
resizeMode={resizeMode}
|
||||
onError={onError}
|
||||
nativeID={`image-${id}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const opacity = intensity.interpolate({
|
||||
inputRange: [20, 100],
|
||||
outputRange: [0.2, 1],
|
||||
});
|
||||
|
||||
const defaultOpacity = intensity.interpolate({inputRange: [0, 100], outputRange: [0.5, 0]});
|
||||
|
||||
const containerStyle = {backgroundColor: changeOpacity(theme.centerChannelColor, Number(defaultOpacity))};
|
||||
|
||||
let image;
|
||||
if (thumbnailUri) {
|
||||
if (showHighResImage && imageUri) {
|
||||
image = (
|
||||
<AnimatedFastImage
|
||||
nativeID={`image-${id}`}
|
||||
resizeMode={resizeMode}
|
||||
onError={onError}
|
||||
source={{uri: imageUri}}
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
imageStyle,
|
||||
{opacity},
|
||||
]}
|
||||
testID='progressive_image.highResImage'
|
||||
onLoadEnd={onLoadImageEnd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (imageUri) {
|
||||
image = (
|
||||
<AnimatedFastImage
|
||||
nativeID={`image-${id}`}
|
||||
resizeMode={resizeMode}
|
||||
onError={onError}
|
||||
source={{uri: imageUri}}
|
||||
style={[StyleSheet.absoluteFill, imageStyle, {opacity}]}
|
||||
onLoadEnd={onLoadImageEnd}
|
||||
testID='progressive_image.highResImage'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.defaultImageContainer, style, containerStyle]}
|
||||
>
|
||||
<Thumbnail
|
||||
onError={onError}
|
||||
opacity={defaultOpacity}
|
||||
source={{uri: thumbnailUri}}
|
||||
style={[
|
||||
thumbnailUri ? StyleSheet.absoluteFill : {tintColor: theme.centerChannelColor},
|
||||
(imageStyle as StyleProp<ImageStyle>),
|
||||
]}
|
||||
/>
|
||||
{image}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressiveImage;
|
||||
44
app/components/progressive_image/thumbnail.tsx
Normal file
44
app/components/progressive_image/thumbnail.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Animated, StyleProp, StyleSheet} from 'react-native';
|
||||
import FastImage, {ImageStyle, Source} from 'react-native-fast-image';
|
||||
|
||||
const AnimatedFastImage = Animated.createAnimatedComponent(FastImage);
|
||||
|
||||
type ThumbnailProps = {
|
||||
onError: () => void;
|
||||
opacity?: number | Animated.AnimatedInterpolation | Animated.AnimatedValue;
|
||||
source?: Source;
|
||||
style: StyleProp<ImageStyle>;
|
||||
}
|
||||
|
||||
const Thumbnail = ({onError, opacity, style, source}: ThumbnailProps) => {
|
||||
if (source?.uri) {
|
||||
return (
|
||||
<AnimatedFastImage
|
||||
onError={onError}
|
||||
resizeMode='cover'
|
||||
source={source}
|
||||
style={style}
|
||||
testID='progressive_image.miniPreview'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tintColor = StyleSheet.flatten(style).tintColor;
|
||||
|
||||
return (
|
||||
<AnimatedFastImage
|
||||
resizeMode='contain'
|
||||
onError={onError}
|
||||
source={require('@assets/images/thumb.png')}
|
||||
style={[style, {opacity}]}
|
||||
testID='progressive_image.thumbnail'
|
||||
tintColor={tintColor}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Thumbnail;
|
||||
132
app/components/slide_up_panel_item/index.tsx
Normal file
132
app/components/slide_up_panel_item/index.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {StyleProp, Text, View, ViewStyle} from 'react-native';
|
||||
import FastImage, {ImageStyle, Source} from 'react-native-fast-image';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {isValidUrl} from '@utils/url';
|
||||
|
||||
type SlideUpPanelProps = {
|
||||
destructive?: boolean;
|
||||
icon?: string | Source;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const ITEM_HEIGHT = 51;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
height: ITEM_HEIGHT,
|
||||
width: '100%',
|
||||
},
|
||||
destructive: {
|
||||
color: '#D0021B',
|
||||
},
|
||||
row: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
iconContainer: {
|
||||
alignItems: 'center',
|
||||
height: 50,
|
||||
justifyContent: 'center',
|
||||
width: 60,
|
||||
},
|
||||
noIconContainer: {
|
||||
height: 50,
|
||||
width: 18,
|
||||
},
|
||||
icon: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
},
|
||||
textContainer: {
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
height: 50,
|
||||
marginRight: 5,
|
||||
},
|
||||
text: {
|
||||
color: theme.centerChannelColor,
|
||||
fontSize: 16,
|
||||
lineHeight: 19,
|
||||
opacity: 0.9,
|
||||
letterSpacing: -0.45,
|
||||
},
|
||||
footer: {
|
||||
marginHorizontal: 17,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const SlideUpPanelItem = ({destructive, icon, onPress, testID, text}: SlideUpPanelProps) => {
|
||||
const theme = useTheme();
|
||||
const handleOnPress = useCallback(preventDoubleTap(onPress, 500), []);
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
let image;
|
||||
let iconStyle: StyleProp<ViewStyle> = [style.iconContainer];
|
||||
if (icon) {
|
||||
const imageStyle: StyleProp<ImageStyle> = [style.icon];
|
||||
if (destructive) {
|
||||
imageStyle.push(style.destructive);
|
||||
}
|
||||
if (typeof icon === 'object') {
|
||||
if (icon.uri && isValidUrl(icon.uri)) {
|
||||
imageStyle.push({width: 24, height: 24});
|
||||
image = (
|
||||
<FastImage
|
||||
source={icon}
|
||||
style={imageStyle}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
iconStyle = [style.noIconContainer];
|
||||
}
|
||||
} else {
|
||||
image = (
|
||||
<CompassIcon
|
||||
name={icon}
|
||||
size={24}
|
||||
style={imageStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
testID={testID}
|
||||
style={style.container}
|
||||
>
|
||||
<TouchableWithFeedback
|
||||
onPress={handleOnPress}
|
||||
style={style.row}
|
||||
type='native'
|
||||
underlayColor={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
>
|
||||
<View style={style.row}>
|
||||
{Boolean(image) &&
|
||||
<View style={iconStyle}>{image}</View>
|
||||
}
|
||||
<View style={style.textContainer}>
|
||||
<Text style={[style.text, destructive ? style.destructive : null]}>{text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
<View style={style.footer}/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlideUpPanelItem;
|
||||
8
app/components/touchable_with_feedback/index.tsx
Normal file
8
app/components/touchable_with_feedback/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import TouchableWithFeedback from './touchable_with_feedback';
|
||||
|
||||
export default TouchableWithFeedback;
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable new-cap */
|
||||
|
||||
import React, {memo} from 'react';
|
||||
import {TouchableOpacity, TouchableWithoutFeedback, View, StyleProp, ViewStyle} from 'react-native';
|
||||
import {TouchableNativeFeedback} from 'react-native-gesture-handler';
|
||||
|
||||
type TouchableProps = {
|
||||
testID: string;
|
||||
children: React.ReactNode | React.ReactNode[];
|
||||
underlayColor: string;
|
||||
type: 'native' | 'opacity' | 'none';
|
||||
style?: StyleProp<ViewStyle>;
|
||||
[x: string]: any;
|
||||
}
|
||||
|
||||
const TouchableWithFeedbackAndroid = ({testID, children, underlayColor, type = 'native', ...props}: TouchableProps) => {
|
||||
switch (type) {
|
||||
case 'native':
|
||||
return (
|
||||
<TouchableNativeFeedback
|
||||
testID={testID}
|
||||
{...props}
|
||||
style={[props.style, {flex: undefined, flexDirection: undefined, width: '100%', height: '100%'}]}
|
||||
background={TouchableNativeFeedback.Ripple(underlayColor || '#fff', false)}
|
||||
>
|
||||
<View>
|
||||
{children}
|
||||
</View>
|
||||
</TouchableNativeFeedback>
|
||||
);
|
||||
case 'opacity':
|
||||
return (
|
||||
<TouchableOpacity
|
||||
testID={testID}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
case 'none':
|
||||
return (
|
||||
<TouchableWithoutFeedback
|
||||
testID={testID}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableWithoutFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default memo(TouchableWithFeedbackAndroid);
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {memo} from 'react';
|
||||
import {PanResponder, TouchableHighlight, TouchableOpacity, TouchableWithoutFeedback, View} from 'react-native';
|
||||
|
||||
type TouchableProps = {
|
||||
cancelTouchOnPanning: boolean;
|
||||
children: React.ReactNode | React.ReactNode[];
|
||||
testID: string;
|
||||
type: 'native' | 'opacity' | 'none';
|
||||
[x: string]: any;
|
||||
}
|
||||
|
||||
const TouchableWithFeedbackIOS = ({testID, children, type = 'native', cancelTouchOnPanning, ...props}: TouchableProps) => {
|
||||
const panResponder = React.useRef(PanResponder.create({
|
||||
onMoveShouldSetPanResponderCapture: (evt, gestureState) => {
|
||||
return cancelTouchOnPanning && (gestureState.dx >= 5 || gestureState.dy >= 5 || gestureState.vx > 5);
|
||||
},
|
||||
}));
|
||||
|
||||
switch (type) {
|
||||
case 'native':
|
||||
return (
|
||||
<View
|
||||
testID={testID}
|
||||
{...panResponder?.current.panHandlers}
|
||||
>
|
||||
<TouchableHighlight
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableHighlight>
|
||||
</View>
|
||||
);
|
||||
case 'opacity':
|
||||
return (
|
||||
<TouchableOpacity
|
||||
testID={testID}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
case 'none':
|
||||
return (
|
||||
<TouchableWithoutFeedback
|
||||
testID={testID}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableWithoutFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default memo(TouchableWithFeedbackIOS);
|
||||
10
app/constants/deep_linking.ts
Normal file
10
app/constants/deep_linking.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export default {
|
||||
CHANNEL: 'channel',
|
||||
DMCHANNEL: 'dmchannel',
|
||||
GROUPCHANNEL: 'groupchannel',
|
||||
PERMALINK: 'permalink',
|
||||
OTHER: 'other',
|
||||
};
|
||||
@@ -21,7 +21,7 @@ export default {
|
||||
IMAGES_PATH: `${FileSystem.cacheDirectory}/Images`,
|
||||
IS_IPHONE_WITH_INSETS: Platform.OS === 'ios' && DeviceInfo.hasNotch(),
|
||||
IS_TABLET: DeviceInfo.isTablet(),
|
||||
PERMANENT_SIDEBAR_SETTINGS: '@PERMANENT_SIDEBAR_SETTINGS',
|
||||
PERMANENT_SIDEBAR_SETTINGS: 'PERMANENT_SIDEBAR_SETTINGS',
|
||||
PUSH_NOTIFY_ANDROID_REACT_NATIVE: 'android_rn',
|
||||
PUSH_NOTIFY_APPLE_REACT_NATIVE: 'apple_rn',
|
||||
TABLET_WIDTH: 250,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import ActionType from './action_type';
|
||||
import Attachment from './attachment';
|
||||
import Database from './database';
|
||||
import DeepLink from './deep_linking';
|
||||
import Device from './device';
|
||||
import Files from './files';
|
||||
import General from './general';
|
||||
@@ -12,8 +13,8 @@ import Navigation from './navigation';
|
||||
import Network from './network';
|
||||
import Permissions from './permissions';
|
||||
import Preferences from './preferences';
|
||||
import Screens from './screens';
|
||||
import SSO, {REDIRECT_URL_SCHEME, REDIRECT_URL_SCHEME_DEV} from './sso';
|
||||
import Screens from './screens';
|
||||
import View, {Upgrade} from './view';
|
||||
import WebsocketEvents from './websocket';
|
||||
|
||||
@@ -21,6 +22,7 @@ export {
|
||||
ActionType,
|
||||
Attachment,
|
||||
Database,
|
||||
DeepLink,
|
||||
Device,
|
||||
Files,
|
||||
General,
|
||||
@@ -31,8 +33,8 @@ export {
|
||||
Preferences,
|
||||
REDIRECT_URL_SCHEME,
|
||||
REDIRECT_URL_SCHEME_DEV,
|
||||
Screens,
|
||||
SSO,
|
||||
Screens,
|
||||
Upgrade,
|
||||
View,
|
||||
WebsocketEvents,
|
||||
|
||||
@@ -8,6 +8,8 @@ import {Appearance} from 'react-native';
|
||||
|
||||
import {Preferences} from '@constants';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import {setNavigationStackStyles} from '@utils/theme';
|
||||
|
||||
import type Database from '@nozbe/watermelondb/Database';
|
||||
import type {PreferenceModel, SystemModel} from '@database/models/server';
|
||||
@@ -42,6 +44,10 @@ const ThemeProvider = ({currentTeamId, children, themes}: Props) => {
|
||||
if (teamTheme?.value) {
|
||||
try {
|
||||
const theme = JSON.parse(teamTheme.value) as Theme;
|
||||
EphemeralStore.theme = theme;
|
||||
requestAnimationFrame(() => {
|
||||
setNavigationStackStyles(theme);
|
||||
});
|
||||
return theme;
|
||||
} catch {
|
||||
// no theme change
|
||||
@@ -62,7 +68,7 @@ const ThemeProvider = ({currentTeamId, children, themes}: Props) => {
|
||||
};
|
||||
|
||||
export function withTheme<T extends WithThemeProps>(Component: ComponentType<T>): ComponentType<T> {
|
||||
return function ServerUrlComponent(props) {
|
||||
return function ThemeComponent(props) {
|
||||
return (
|
||||
<Consumer>
|
||||
{(theme: Theme) => (
|
||||
|
||||
@@ -24,6 +24,9 @@ export default class MyChannelModel extends Model {
|
||||
/** last_viewed_at : The timestamp showing the user's last viewed post on this channel */
|
||||
@field('last_viewed_at') lastViewedAt!: number;
|
||||
|
||||
/** manually_unread : Determine if the user marked a post as unread */
|
||||
@field('manually_unread') manuallyUnread!: boolean;
|
||||
|
||||
/** mentions_count : The number of mentions on this channel */
|
||||
@field('mentions_count') mentionsCount!: number;
|
||||
|
||||
|
||||
@@ -142,6 +142,25 @@ describe('*** Operator: Channel Handlers tests ***', () => {
|
||||
expect.assertions(2);
|
||||
|
||||
const spyOnHandleRecords = jest.spyOn(operator, 'handleRecords');
|
||||
const channels: Channel[] = [{
|
||||
id: 'c',
|
||||
name: 'channel',
|
||||
display_name: 'Channel',
|
||||
type: 'O',
|
||||
create_at: 1,
|
||||
update_at: 1,
|
||||
delete_at: 0,
|
||||
team_id: '123',
|
||||
header: '',
|
||||
purpose: '',
|
||||
last_post_at: 2,
|
||||
creator_id: 'me',
|
||||
total_msg_count: 20,
|
||||
extra_update_at: 0,
|
||||
shared: false,
|
||||
scheme_id: null,
|
||||
group_constrained: false,
|
||||
}];
|
||||
const myChannels: ChannelMembership[] = [
|
||||
{
|
||||
id: 'c',
|
||||
@@ -164,6 +183,7 @@ describe('*** Operator: Channel Handlers tests ***', () => {
|
||||
];
|
||||
|
||||
await operator.handleMyChannel({
|
||||
channels,
|
||||
myChannels,
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface ChannelHandlerMix {
|
||||
handleChannel: ({channels, prepareRecordsOnly}: HandleChannelArgs) => Promise<ChannelModel[]>;
|
||||
handleMyChannelSettings: ({settings, prepareRecordsOnly}: HandleMyChannelSettingsArgs) => Promise<MyChannelSettingsModel[]>;
|
||||
handleChannelInfo: ({channelInfos, prepareRecordsOnly}: HandleChannelInfoArgs) => Promise<ChannelInfoModel[]>;
|
||||
handleMyChannel: ({myChannels, prepareRecordsOnly}: HandleMyChannelArgs) => Promise<MyChannelModel[]>;
|
||||
handleMyChannel: ({channels, myChannels, prepareRecordsOnly}: HandleMyChannelArgs) => Promise<MyChannelModel[]>;
|
||||
}
|
||||
|
||||
const ChannelHandler = (superclass: any) => class extends superclass {
|
||||
@@ -130,13 +130,20 @@ const ChannelHandler = (superclass: any) => class extends superclass {
|
||||
* @throws DataOperatorException
|
||||
* @returns {Promise<MyChannelModel[]>}
|
||||
*/
|
||||
handleMyChannel = ({myChannels, prepareRecordsOnly = true}: HandleMyChannelArgs): Promise<MyChannelModel[]> => {
|
||||
handleMyChannel = ({channels, myChannels, prepareRecordsOnly = true}: HandleMyChannelArgs): Promise<MyChannelModel[]> => {
|
||||
if (!myChannels.length) {
|
||||
throw new DataOperatorException(
|
||||
'An empty "myChannels" array has been passed to the handleMyChannel method',
|
||||
);
|
||||
}
|
||||
|
||||
myChannels.forEach((my) => {
|
||||
const channel = channels.find((c) => c.id === my.channel_id);
|
||||
if (channel) {
|
||||
my.msg_count = Math.max(0, channel.total_msg_count - my.msg_count);
|
||||
}
|
||||
});
|
||||
|
||||
const createOrUpdateRawValues = getUniqueRawsBy({
|
||||
raws: myChannels,
|
||||
key: 'id',
|
||||
|
||||
@@ -74,12 +74,10 @@ const PostsInChannelHandler = (superclass: any) => class extends superclass {
|
||||
const latest = lastPost.create_at;
|
||||
|
||||
// Find the records in the PostsInChannel table that have a matching channel_id
|
||||
// const chunks = (await database.collections.get(POSTS_IN_CHANNEL).query(Q.where('channel_id', channelId)).fetch()) as PostsInChannel[];
|
||||
const chunks = (await retrieveRecords({
|
||||
database: this.database,
|
||||
tableName: POSTS_IN_CHANNEL,
|
||||
condition: (Q.where('id', channelId), Q.experimentalSortBy('latest', Q.desc)),
|
||||
})) as PostsInChannelModel[];
|
||||
const chunks = (await this.database.get(POSTS_IN_CHANNEL).query(
|
||||
Q.where('id', channelId),
|
||||
Q.experimentalSortBy('latest', Q.desc),
|
||||
).fetch()) as PostsInChannelModel[];
|
||||
|
||||
// chunk length 0; then it's a new chunk to be added to the PostsInChannel table
|
||||
if (chunks.length === 0) {
|
||||
|
||||
@@ -201,7 +201,7 @@ const TeamHandler = (superclass: any) => class extends superclass {
|
||||
);
|
||||
}
|
||||
|
||||
const createOrUpdateRawValues = getUniqueRawsBy({raws: myTeams, key: 'team_id'});
|
||||
const createOrUpdateRawValues = getUniqueRawsBy({raws: myTeams, key: 'id'});
|
||||
|
||||
return this.handleRecords({
|
||||
fieldName: 'id',
|
||||
|
||||
@@ -12,6 +12,7 @@ export default tableSchema({
|
||||
columns: [
|
||||
{name: 'last_post_at', type: 'number'},
|
||||
{name: 'last_viewed_at', type: 'number'},
|
||||
{name: 'manually_unread', type: 'boolean'},
|
||||
{name: 'mentions_count', type: 'number'},
|
||||
{name: 'message_count', type: 'number'},
|
||||
{name: 'roles', type: 'string'},
|
||||
|
||||
@@ -112,6 +112,7 @@ describe('*** Test schema for SERVER database ***', () => {
|
||||
columns: {
|
||||
last_post_at: {name: 'last_post_at', type: 'number'},
|
||||
last_viewed_at: {name: 'last_viewed_at', type: 'number'},
|
||||
manually_unread: {name: 'manually_unread', type: 'boolean'},
|
||||
mentions_count: {name: 'mentions_count', type: 'number'},
|
||||
message_count: {name: 'message_count', type: 'number'},
|
||||
roles: {name: 'roles', type: 'string'},
|
||||
@@ -119,6 +120,7 @@ describe('*** Test schema for SERVER database ***', () => {
|
||||
columnArray: [
|
||||
{name: 'last_post_at', type: 'number'},
|
||||
{name: 'last_viewed_at', type: 'number'},
|
||||
{name: 'manually_unread', type: 'boolean'},
|
||||
{name: 'mentions_count', type: 'number'},
|
||||
{name: 'message_count', type: 'number'},
|
||||
{name: 'roles', type: 'string'},
|
||||
|
||||
47
app/helpers/api/channel.ts
Normal file
47
app/helpers/api/channel.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Alert} from 'react-native';
|
||||
|
||||
import type {IntlShape} from 'react-intl';
|
||||
|
||||
export function privateChannelJoinPrompt(displayName: string, intl: IntlShape): Promise<{join: boolean}> {
|
||||
return new Promise((resolve) => {
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'permalink.show_dialog_warn.title',
|
||||
defaultMessage: 'Join private channel',
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: 'permalink.show_dialog_warn.description',
|
||||
defaultMessage: 'You are about to join {channel} without explicitly being added by the channel admin. Are you sure you wish to join this private channel?',
|
||||
}, {
|
||||
channel: displayName,
|
||||
}),
|
||||
[
|
||||
{
|
||||
text: intl.formatMessage({
|
||||
id: 'permalink.show_dialog_warn.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
}),
|
||||
onPress: async () => {
|
||||
resolve({
|
||||
join: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({
|
||||
id: 'permalink.show_dialog_warn.join',
|
||||
defaultMessage: 'Join',
|
||||
}),
|
||||
onPress: async () => {
|
||||
resolve({
|
||||
join: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import {General, Preferences} from '@constants';
|
||||
|
||||
import type PreferenceModel from '@typings/database/models/servers/preference';
|
||||
|
||||
export function getPreferenceValue(preferences: PreferenceType[], category: string, name: string, defaultValue: unknown = '') {
|
||||
const pref = preferences.find((p) => p.category === category && p.name === name);
|
||||
|
||||
@@ -27,9 +29,9 @@ export function getPreferenceAsInt(preferences: PreferenceType[], category: stri
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export function getTeammateNameDisplaySetting(preferences: PreferenceType[], config?: ClientConfig, license?: ClientLicense) {
|
||||
export function getTeammateNameDisplaySetting(preferences: PreferenceType[] | PreferenceModel[], config?: ClientConfig, license?: ClientLicense) {
|
||||
const useAdminTeammateNameDisplaySetting = license?.LockTeammateNameDisplay === 'true' && config?.LockTeammateNameDisplay === 'true';
|
||||
const preference = getPreferenceValue(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT, '') as string;
|
||||
const preference = getPreferenceValue(preferences as PreferenceType[], Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT, '') as string;
|
||||
if (preference && !useAdminTeammateNameDisplaySetting) {
|
||||
return preference;
|
||||
} else if (config?.TeammateNameDisplay) {
|
||||
|
||||
59
app/hooks/device.ts
Normal file
59
app/hooks/device.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {DeviceEventEmitter, NativeModules, useWindowDimensions} from 'react-native';
|
||||
|
||||
import {Device} from '@constants';
|
||||
import {MM_TABLES} from '@constants/database';
|
||||
import DatabaseManager from '@database/manager';
|
||||
|
||||
import type GlobalModel from '@typings/database/models/app/global';
|
||||
|
||||
const {MattermostManaged} = NativeModules;
|
||||
const isRunningInSplitView = MattermostManaged.isRunningInSplitView;
|
||||
|
||||
export function usePermanentSidebar() {
|
||||
const [permanentSidebar, setPermanentSidebar] = useState(Device.IS_TABLET);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePermanentSidebar = async () => {
|
||||
if (Device.IS_TABLET) {
|
||||
const database = DatabaseManager.appDatabase?.database;
|
||||
if (database) {
|
||||
try {
|
||||
const enabled = await database.get(MM_TABLES.APP.GLOBAL).find(Device.PERMANENT_SIDEBAR_SETTINGS) as GlobalModel;
|
||||
setPermanentSidebar(enabled.value === 'true');
|
||||
} catch {
|
||||
setPermanentSidebar(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handlePermanentSidebar();
|
||||
|
||||
const listener = DeviceEventEmitter.addListener(Device.PERMANENT_SIDEBAR_SETTINGS, handlePermanentSidebar);
|
||||
|
||||
return () => {
|
||||
listener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return permanentSidebar;
|
||||
}
|
||||
|
||||
export function useSplitView() {
|
||||
const [isSplitView, setIsSplitView] = useState(false);
|
||||
const dimensions = useWindowDimensions();
|
||||
|
||||
useEffect(() => {
|
||||
if (Device.IS_TABLET) {
|
||||
isRunningInSplitView().then((result: {isSplitView: boolean}) => {
|
||||
setIsSplitView(result.isSplitView);
|
||||
});
|
||||
}
|
||||
}, [dimensions]);
|
||||
|
||||
return isSplitView;
|
||||
}
|
||||
@@ -129,7 +129,11 @@ class GlobalEventHandler {
|
||||
);
|
||||
}
|
||||
|
||||
fetchConfigAndLicense(serverUrl);
|
||||
const fetchTimeout = setTimeout(() => {
|
||||
// Defer the call to avoid collision with other request writting to the db
|
||||
fetchConfigAndLicense(serverUrl);
|
||||
clearTimeout(fetchTimeout);
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ import {Linking} from 'react-native';
|
||||
import {Notifications} from 'react-native-notifications';
|
||||
|
||||
import {Screens} from '@constants';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getActiveServerUrl, getServerCredentials} from '@init/credentials';
|
||||
import {queryThemeForCurrentTeam} from '@queries/servers/preference';
|
||||
import {goToScreen, resetToChannel, resetToSelectServer} from '@screens/navigation';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkType, DeepLinkWithData, LaunchProps, LaunchType} from '@typings/launch';
|
||||
import {parseDeepLink} from '@utils/url';
|
||||
|
||||
@@ -62,6 +65,10 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => {
|
||||
if (serverUrl) {
|
||||
const credentials = await getServerCredentials(serverUrl);
|
||||
if (credentials) {
|
||||
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
||||
if (database) {
|
||||
EphemeralStore.theme = await queryThemeForCurrentTeam(database);
|
||||
}
|
||||
launchToChannel({...props, serverUrl}, resetNavigation);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import {MM_TABLES} from '@constants/database';
|
||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type ChannelInfoModel from '@typings/database/models/servers/channel_info';
|
||||
import type MyChannelModel from '@typings/database/models/servers/my_channel';
|
||||
|
||||
const {SERVER: {CHANNEL}} = MM_TABLES;
|
||||
const {SERVER: {CHANNEL, MY_CHANNEL}} = MM_TABLES;
|
||||
|
||||
export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, teamId: string, channels: Channel[], channelMembers: ChannelMembership[]) => {
|
||||
const allChannelsForTeam = await queryAllChannelsForTeam(operator.database, teamId);
|
||||
@@ -43,7 +44,7 @@ export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, tea
|
||||
const channelRecords = operator.handleChannel({channels, prepareRecordsOnly: true});
|
||||
const channelInfoRecords = operator.handleChannelInfo({channelInfos, prepareRecordsOnly: true});
|
||||
const membershipRecords = operator.handleChannelMembership({channelMemberships: memberships, prepareRecordsOnly: true});
|
||||
const myChannelRecords = operator.handleMyChannel({myChannels: memberships, prepareRecordsOnly: true});
|
||||
const myChannelRecords = operator.handleMyChannel({channels, myChannels: memberships, prepareRecordsOnly: true});
|
||||
const myChannelSettingsRecords = operator.handleMyChannelSettings({settings: memberships, prepareRecordsOnly: true});
|
||||
|
||||
return [channelRecords, channelInfoRecords, membershipRecords, myChannelRecords, myChannelSettingsRecords];
|
||||
@@ -55,3 +56,25 @@ export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, tea
|
||||
export const queryAllChannelsForTeam = (database: Database, teamId: string) => {
|
||||
return database.get(CHANNEL).query(Q.where('team_id', teamId)).fetch() as Promise<ChannelModel[]>;
|
||||
};
|
||||
|
||||
export const queryMyChannel = async (database: Database, channelId: string) => {
|
||||
try {
|
||||
const member = await database.get(MY_CHANNEL).find(channelId) as MyChannelModel;
|
||||
return member;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const queryChannelByName = async (database: Database, channelName: string) => {
|
||||
try {
|
||||
const channels = await database.get(CHANNEL).query(Q.where('name', channelName)).fetch() as ChannelModel[];
|
||||
if (channels.length) {
|
||||
return channels[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ const {SERVER: {POST, POSTS_IN_CHANNEL}} = MM_TABLES;
|
||||
export const queryPostsInChannel = (database: Database, channelId: string): Promise<PostInChannelModel[]> => {
|
||||
try {
|
||||
return database.get(POSTS_IN_CHANNEL).query(
|
||||
Q.where('channel_id', channelId),
|
||||
Q.where('id', channelId),
|
||||
Q.experimentalSortBy('latest', Q.desc),
|
||||
).fetch() as Promise<PostInChannelModel[]>;
|
||||
} catch {
|
||||
@@ -21,12 +21,12 @@ export const queryPostsInChannel = (database: Database, channelId: string): Prom
|
||||
}
|
||||
};
|
||||
|
||||
export const queryPostsChunk = (database: Database, channelId: string, earlies: number, latest: number): Promise<PostModel[]> => {
|
||||
export const queryPostsChunk = (database: Database, channelId: string, earliest: number, latest: number): Promise<PostModel[]> => {
|
||||
try {
|
||||
return database.get(POST).query(
|
||||
Q.and(
|
||||
Q.where('channel_id', channelId),
|
||||
Q.where('create_at', Q.between(earlies, latest)),
|
||||
Q.where('create_at', Q.between(earliest, latest)),
|
||||
Q.where('delete_at', Q.eq(0)),
|
||||
),
|
||||
Q.experimentalSortBy('create_at', Q.desc),
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
|
||||
import {Database, Q} from '@nozbe/watermelondb';
|
||||
|
||||
import {Preferences} from '@constants';
|
||||
import {MM_TABLES} from '@constants/database';
|
||||
|
||||
import {queryCurrentTeamId} from './system';
|
||||
|
||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||
import type PreferenceModel from '@typings/database/models/servers/preference';
|
||||
|
||||
@@ -25,3 +28,17 @@ export const queryPreferencesByCategoryAndName = (database: Database, category:
|
||||
Q.where('name', name),
|
||||
).fetch() as Promise<PreferenceModel[]>;
|
||||
};
|
||||
|
||||
export const queryThemeForCurrentTeam = async (database: Database) => {
|
||||
const currentTeamId = await queryCurrentTeamId(database);
|
||||
const teamTheme = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_THEME, currentTeamId);
|
||||
if (teamTheme.length) {
|
||||
try {
|
||||
return JSON.parse(teamTheme[0].value) as Theme;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -27,6 +27,15 @@ export const queryCurrentChannelId = async (serverDatabase: Database) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const queryCurrentTeamId = async (serverDatabase: Database) => {
|
||||
try {
|
||||
const currentTeamId = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID) as SystemModel;
|
||||
return (currentTeamId?.value || '') as string;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const queryCurrentUserId = async (serverDatabase: Database) => {
|
||||
try {
|
||||
const currentUserId = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.CURRENT_USER_ID) as SystemModel;
|
||||
@@ -129,3 +138,32 @@ export const prepareCommonSystemValues = (
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const setCurrentChannelId = async (operator: ServerDataOperator, channelId: string) => {
|
||||
try {
|
||||
const models = await prepareCommonSystemValues(operator, {currentChannelId: channelId});
|
||||
if (models) {
|
||||
await operator.batchRecords(models);
|
||||
}
|
||||
|
||||
return {currentChannelId: channelId};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const setCurrentTeamAndChannelId = async (operator: ServerDataOperator, teamId?: string, channelId?: string) => {
|
||||
try {
|
||||
const models = await prepareCommonSystemValues(operator, {
|
||||
currentChannelId: channelId,
|
||||
currentTeamId: teamId,
|
||||
});
|
||||
if (models) {
|
||||
await operator.batchRecords(models);
|
||||
}
|
||||
|
||||
return {currentChannelId: channelId};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Database} from '@nozbe/watermelondb';
|
||||
import {Database, Q} from '@nozbe/watermelondb';
|
||||
|
||||
import {Database as DatabaseConstants} from '@constants';
|
||||
|
||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||
import type MyTeamModel from '@typings/database/models/servers/my_team';
|
||||
import type TeamChannelHistoryModel from '@typings/database/models/servers/team_channel_history';
|
||||
import type TeamModel from '@typings/database/models/servers/team';
|
||||
|
||||
const {TEAM, TEAM_CHANNEL_HISTORY} = DatabaseConstants.MM_TABLES.SERVER;
|
||||
const {MY_TEAM, TEAM, TEAM_CHANNEL_HISTORY} = DatabaseConstants.MM_TABLES.SERVER;
|
||||
|
||||
export const addChannelToTeamHistory = async (operator: ServerDataOperator, teamId: string, channelId: string, prepareRecordsOnly = false) => {
|
||||
let tch: TeamChannelHistory|undefined;
|
||||
|
||||
try {
|
||||
const teamChannelHistory = (await operator.database.get(TEAM_CHANNEL_HISTORY).find(teamId)) as TeamChannelHistoryModel;
|
||||
const channelIds = teamChannelHistory.channelIds;
|
||||
const channelIdSet = new Set(teamChannelHistory.channelIds);
|
||||
if (channelIdSet.has(channelId)) {
|
||||
channelIdSet.delete(channelId);
|
||||
}
|
||||
|
||||
const channelIds = Array.from(channelIdSet);
|
||||
channelIds.unshift(channelId);
|
||||
tch = {
|
||||
id: teamId,
|
||||
@@ -52,6 +58,15 @@ export const prepareMyTeams = (operator: ServerDataOperator, teams: Team[], memb
|
||||
}
|
||||
};
|
||||
|
||||
export const queryMyTeamById = async (database: Database, teamId: string): Promise<MyTeamModel|undefined> => {
|
||||
try {
|
||||
const myTeam = (await database.get(MY_TEAM).find(teamId)) as MyTeamModel;
|
||||
return myTeam;
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const queryTeamById = async (database: Database, teamId: string): Promise<TeamModel|undefined> => {
|
||||
try {
|
||||
const team = (await database.get(TEAM).find(teamId)) as TeamModel;
|
||||
@@ -60,3 +75,16 @@ export const queryTeamById = async (database: Database, teamId: string): Promise
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const queryTeamByName = async (database: Database, teamName: string): Promise<TeamModel|undefined> => {
|
||||
try {
|
||||
const team = (await database.get(TEAM).query(Q.where('name', teamName)).fetch()) as TeamModel[];
|
||||
if (team.length) {
|
||||
return team[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
100
app/screens/bottom_sheet/index.tsx
Normal file
100
app/screens/bottom_sheet/index.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {ReactNode, useEffect, useRef} from 'react';
|
||||
import {BackHandler, DeviceEventEmitter, StyleSheet, useWindowDimensions, View} from 'react-native';
|
||||
import {State, TapGestureHandler} from 'react-native-gesture-handler';
|
||||
import Animated from 'react-native-reanimated';
|
||||
import RNBottomSheet from 'reanimated-bottom-sheet';
|
||||
|
||||
import {Navigation} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {dismissModal} from '@screens/navigation';
|
||||
import {hapticFeedback} from '@utils/general';
|
||||
|
||||
import Indicator from './indicator';
|
||||
|
||||
type SlideUpPanelProps = {
|
||||
initialSnapIndex?: number;
|
||||
renderContent: () => ReactNode;
|
||||
snapPoints?: Array<string | number>;
|
||||
}
|
||||
|
||||
const BottomSheet = ({initialSnapIndex = 0, renderContent, snapPoints = ['90%', '50%', 50]}: SlideUpPanelProps) => {
|
||||
const sheetRef = useRef<RNBottomSheet>(null);
|
||||
const dimensions = useWindowDimensions();
|
||||
const theme = useTheme();
|
||||
const lastSnap = snapPoints.length - 1;
|
||||
|
||||
useEffect(() => {
|
||||
const listener = DeviceEventEmitter.addListener(Navigation.NAVIGATION_CLOSE_MODAL, () => sheetRef.current?.snapTo(lastSnap));
|
||||
|
||||
return () => listener.remove();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
sheetRef.current?.snapTo(1);
|
||||
return true;
|
||||
});
|
||||
|
||||
return () => listener.remove();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
hapticFeedback();
|
||||
sheetRef.current?.snapTo(initialSnapIndex);
|
||||
}, []);
|
||||
|
||||
const renderBackdrop = () => {
|
||||
return (
|
||||
<TapGestureHandler
|
||||
shouldCancelWhenOutside={true}
|
||||
maxDist={10}
|
||||
onHandlerStateChange={(event) => {
|
||||
if (event.nativeEvent.state === State.END && event.nativeEvent.oldState === State.ACTIVE) {
|
||||
sheetRef.current?.snapTo(lastSnap);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0, 0, 0, 0.5)'}}
|
||||
/>
|
||||
</TapGestureHandler>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContainer = () => (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
opacity: 1,
|
||||
padding: 16,
|
||||
height: '100%',
|
||||
width: Math.min(dimensions.width, 450),
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RNBottomSheet
|
||||
ref={sheetRef}
|
||||
snapPoints={snapPoints}
|
||||
borderRadius={10}
|
||||
initialSnap={initialSnapIndex}
|
||||
renderContent={renderContainer}
|
||||
onCloseEnd={() => dismissModal()}
|
||||
enabledBottomInitialAnimation={true}
|
||||
renderHeader={Indicator}
|
||||
enabledContentTapInteraction={false}
|
||||
/>
|
||||
{renderBackdrop()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomSheet;
|
||||
32
app/screens/bottom_sheet/indicator.tsx
Normal file
32
app/screens/bottom_sheet/indicator.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Animated, StyleSheet, View} from 'react-native';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dragIndicatorContainer: {
|
||||
marginVertical: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dragIndicator: {
|
||||
backgroundColor: 'white',
|
||||
height: 5,
|
||||
width: 62.5,
|
||||
opacity: 0.9,
|
||||
borderRadius: 25,
|
||||
},
|
||||
});
|
||||
|
||||
const Indicator = () => {
|
||||
return (
|
||||
<Animated.View
|
||||
style={styles.dragIndicatorContainer}
|
||||
>
|
||||
<View style={styles.dragIndicator}/>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Indicator;
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import React, {useMemo} from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
import React, {useMemo, useState} from 'react';
|
||||
import {Text, View, ScrollView} from 'react-native';
|
||||
import {SafeAreaView} from 'react-native-safe-area-context';
|
||||
|
||||
import {logout} from '@actions/remote/session';
|
||||
@@ -24,6 +24,12 @@ import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import FailedChannels from './failed_channels';
|
||||
import FailedTeams from './failed_teams';
|
||||
|
||||
import Markdown from '@components/markdown';
|
||||
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
|
||||
import md from './md.json';
|
||||
import ProgressiveImage from '@components/progressive_image';
|
||||
import JumboEmoji from '@components/jumbo_emoji';
|
||||
|
||||
type ChannelProps = WithDatabaseArgs & LaunchProps & {
|
||||
currentChannelId: SystemModel;
|
||||
currentTeamId: SystemModel;
|
||||
@@ -88,6 +94,14 @@ const Channel = ({currentChannelId, currentTeamId, time}: ChannelProps) => {
|
||||
);
|
||||
}, [currentTeamId.value, currentChannelId.value]);
|
||||
|
||||
const textStyle = getMarkdownTextStyles(theme);
|
||||
const blockStyle = getMarkdownBlockStyles(theme);
|
||||
const [inViewport, setInViewport] = useState(false);
|
||||
|
||||
setTimeout(async () => {
|
||||
setInViewport(true);
|
||||
}, 3000);
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={styles.flex}
|
||||
@@ -97,14 +111,52 @@ const Channel = ({currentChannelId, currentTeamId, time}: ChannelProps) => {
|
||||
<ServerVersion/>
|
||||
<StatusBar theme={theme}/>
|
||||
{renderComponent}
|
||||
<View style={styles.sectionContainer}>
|
||||
<Text
|
||||
onPress={doLogout}
|
||||
style={styles.sectionTitle}
|
||||
>
|
||||
{`Loaded in: ${time || 0}ms. Logout from ${serverUrl}`}
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView style={{paddingHorizontal: 10, width: '100%'}}>
|
||||
<ProgressiveImage
|
||||
id='file-123'
|
||||
thumbnailUri='data:image/png;base64,/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/APcrv4uftAfbNW1XT57Gz0C+1DUEgTVbWMS2EaFY4mO5lEajggNnJJZuDivYPh38Ytd/4Te8tteS4uvDs32aystTSS2lSa4bP7wLE25EYf3hzkEACuX+Ln7M3jjXvGmoax4f8X3EenapcK0ulmGF4owTM8rESq3BUQx4HUuSeBUfwI+BvxC0b4gWmp+Kp4ptJswZLv7dMJZ726MWFkUKm3EZJG7IJz/EAKebYepiqdLFUJqM4JpRi7J3a1mtOa1u+iv1aPppYjCqVWChBxkm9ndbe6m9mraadX0vb//Z'
|
||||
imageUri='http://192.168.1.6:8065/api/v4/files/9rcknudg9ig9xkz515faj374pw/preview'
|
||||
resizeMode='contain'
|
||||
onError={() => true}
|
||||
style={{width: 322.72727272727275, height: 132.96363636363637}}
|
||||
inViewPort={inViewport}
|
||||
/>
|
||||
<JumboEmoji
|
||||
isEdited={true}
|
||||
baseTextStyle={{
|
||||
color: theme.centerChannelColor,
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
}}
|
||||
value={md.jumbo}
|
||||
/>
|
||||
<Markdown
|
||||
value={md.value}
|
||||
theme={theme}
|
||||
textStyles={textStyle}
|
||||
blockStyles={blockStyle}
|
||||
isEdited={true}
|
||||
baseTextStyle={{
|
||||
color: theme.centerChannelColor,
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
}}
|
||||
mentionKeys={[{
|
||||
key: 'Elias',
|
||||
caseSensitive: false,
|
||||
}]}
|
||||
imagesMetadata={md.imagesMetadata}
|
||||
channelMentions={md.channelMentions}
|
||||
/>
|
||||
<View style={styles.sectionContainer}>
|
||||
<Text
|
||||
onPress={doLogout}
|
||||
style={styles.sectionTitle}
|
||||
>
|
||||
{`Loaded in: ${time || 0}ms. Logout from ${serverUrl}`}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
19
app/screens/channel/md.json
Normal file
19
app/screens/channel/md.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"test": "This is a file for testing purposes, should be deleted once is not needed",
|
||||
"value": ":smile: :thanks: :mattermost: :nonexistent:\n\n# Can you please have a look at those 3 files ?\n\n\nlet's add some text here\nand a second line\n\n```js\n1. @components/markdown/markdown.tsx\n2. @components/markdown/transform.ts\n3. @components/markdown/markdown_table_image/index.tsx\n4. another one\n```\n\n> this should be a quote\nof two lines\n\n1. ~~first~~\n2. *second* with a #hashtag\n3. **third**\n\n`code span of one line`\n\n| Syntax | Description |\n| ----------- | ----------- |\n| Header | Title |\n| Paragraph | Text |\n\n[google link](https://google.com)\n\nhttps://community.mattermost.com\n\nsome html <h3 id=\"custom-id\">My Great Heading</h3>\n\nthis is an at mention for @elias, @nonexistent and @nahumhbl. and @channel @user15.6.\n\nthis is a @groupMention.\n\n### Mentioning ~town-square and ~forth or a [direct link to the channel](http://192.168.1.6:8065/apps-team/channels/forth). Tap on them to switch channels.",
|
||||
"channelMentions": {
|
||||
"forth": {
|
||||
"display_name": "forth",
|
||||
"team_name":"apps-team"
|
||||
}
|
||||
},
|
||||
"imagesMetadata": {
|
||||
"http://www.microlife-dns.com/welcome-paper-poster-with-colorful-brush-strokes-vector-21849225.jpeg": {
|
||||
"width": 1000,
|
||||
"height": 412,
|
||||
"format": "jpeg",
|
||||
"frame_count": 0
|
||||
}
|
||||
},
|
||||
"jumbo": ":fire: :mattermost: :thanks:\n:the_horns: :not:"
|
||||
}
|
||||
@@ -60,6 +60,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
|
||||
// case 'AdvancedSettings':
|
||||
// screen = require('@screens/settings/advanced_settings').default;
|
||||
// break;
|
||||
case 'BottomSheet':
|
||||
screen = require('@screens/bottom_sheet').default;
|
||||
break;
|
||||
// case 'ChannelAddMembers':
|
||||
// screen = require('@screens/channel_add_members').default;
|
||||
// break;
|
||||
@@ -157,9 +160,6 @@ Navigation.setLazyComponentRegistrator((screenName) => {
|
||||
// case 'NotificationSettingsMobile':
|
||||
// screen = require('@screens/settings/notification_settings_mobile').default;
|
||||
// break;
|
||||
// case 'OptionsModal':
|
||||
// screen = require('@screens/options_modal').default;
|
||||
// break;
|
||||
// case 'Permalink':
|
||||
// screen = require('@screens/permalink').default;
|
||||
// break;
|
||||
@@ -220,7 +220,7 @@ Navigation.setLazyComponentRegistrator((screenName) => {
|
||||
}
|
||||
|
||||
if (screen) {
|
||||
Navigation.registerComponent(screenName, () => withGestures(withIntl(withManagedConfig(screen)), extraStyles));
|
||||
Navigation.registerComponent(screenName, () => withSafeAreaInsets(withGestures(withIntl(withManagedConfig(screen)), extraStyles)));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -228,6 +228,6 @@ export function registerScreens() {
|
||||
const channelScreen = require('@screens/channel').default;
|
||||
const serverScreen = require('@screens/server').default;
|
||||
|
||||
Navigation.registerComponent(Screens.CHANNEL, () => withSafeAreaInsets(withIntl(withServerDatabase(withManagedConfig(channelScreen)))));
|
||||
Navigation.registerComponent(Screens.CHANNEL, () => withSafeAreaInsets(withGestures(withIntl(withServerDatabase(withManagedConfig(channelScreen))), undefined)));
|
||||
Navigation.registerComponent(Screens.SERVER, () => withIntl(withManagedConfig(serverScreen)));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import EphemeralStore from '@store/ephemeral_store';
|
||||
import type {LaunchProps} from '@typings/launch';
|
||||
|
||||
function getThemeFromState() {
|
||||
if (EphemeralStore.theme) {
|
||||
return EphemeralStore.theme;
|
||||
}
|
||||
if (Appearance.getColorScheme() === 'dark') {
|
||||
return Preferences.THEMES.windows10;
|
||||
}
|
||||
@@ -52,28 +55,29 @@ export function resetToChannel(passProps = {}) {
|
||||
}],
|
||||
};
|
||||
|
||||
let platformStack: Layout = {stack};
|
||||
if (Platform.OS === 'android') {
|
||||
platformStack = {
|
||||
sideMenu: {
|
||||
left: {
|
||||
component: {
|
||||
id: Screens.MAIN_SIDEBAR,
|
||||
name: Screens.MAIN_SIDEBAR,
|
||||
},
|
||||
},
|
||||
center: {
|
||||
stack,
|
||||
},
|
||||
right: {
|
||||
component: {
|
||||
id: Screens.SETTINGS_SIDEBAR,
|
||||
name: Screens.SETTINGS_SIDEBAR,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const platformStack: Layout = {stack};
|
||||
|
||||
// if (Platform.OS === 'android') {
|
||||
// platformStack = {
|
||||
// sideMenu: {
|
||||
// left: {
|
||||
// component: {
|
||||
// id: Screens.MAIN_SIDEBAR,
|
||||
// name: Screens.MAIN_SIDEBAR,
|
||||
// },
|
||||
// },
|
||||
// center: {
|
||||
// stack,
|
||||
// },
|
||||
// right: {
|
||||
// component: {
|
||||
// id: Screens.SETTINGS_SIDEBAR,
|
||||
// name: Screens.SETTINGS_SIDEBAR,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// };
|
||||
// }
|
||||
|
||||
Navigation.setRoot({
|
||||
root: {
|
||||
|
||||
@@ -5,6 +5,7 @@ class EphemeralStore {
|
||||
allNavigationComponentIds: string[] = [];
|
||||
navigationComponentIdStack: string[] = [];
|
||||
navigationModalStack: string[] = [];
|
||||
theme: Theme | undefined;
|
||||
|
||||
getNavigationTopComponentId = () => this.navigationComponentIdStack[0];
|
||||
|
||||
|
||||
35
app/utils/draft/index.ts
Normal file
35
app/utils/draft/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {MessageDescriptor} from '@formatjs/intl/src/types';
|
||||
import {Alert, AlertButton} from 'react-native';
|
||||
|
||||
import type {IntlShape} from 'react-intl';
|
||||
|
||||
import {t} from '@utils/i18n';
|
||||
|
||||
export function errorBadChannel(intl: IntlShape) {
|
||||
const message = {
|
||||
id: t('mobile.server_link.unreachable_channel.error'),
|
||||
defaultMessage: 'This link belongs to a deleted channel or to a channel to which you do not have access.',
|
||||
};
|
||||
|
||||
return alertErrorWithFallback(intl, {}, message);
|
||||
}
|
||||
|
||||
export function permalinkBadTeam(intl: IntlShape) {
|
||||
const message = {
|
||||
id: t('mobile.server_link.unreachable_team.error'),
|
||||
defaultMessage: 'This link belongs to a deleted team or to a team to which you do not have access.',
|
||||
};
|
||||
|
||||
alertErrorWithFallback(intl, {}, message);
|
||||
}
|
||||
|
||||
export function alertErrorWithFallback(intl: IntlShape, error: any, fallback: MessageDescriptor, values?: Record<string, string>, buttons?: AlertButton[]) {
|
||||
let msg = error?.message;
|
||||
if (!msg || msg === 'Network request failed') {
|
||||
msg = intl.formatMessage(fallback, values);
|
||||
}
|
||||
Alert.alert('', msg, buttons);
|
||||
}
|
||||
1
app/utils/emoji/emoji.json
Normal file
1
app/utils/emoji/emoji.json
Normal file
File diff suppressed because one or more lines are too long
198
app/utils/emoji/helpers.ts
Normal file
198
app/utils/emoji/helpers.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import emojiRegex from 'emoji-regex';
|
||||
|
||||
import {Emojis, EmojiIndicesByAlias} from './';
|
||||
|
||||
const RE_NAMED_EMOJI = /(:([a-zA-Z0-9_+-]+):)/g;
|
||||
|
||||
const RE_UNICODE_EMOJI = emojiRegex();
|
||||
|
||||
const RE_EMOTICON: Record<string, RegExp> = {
|
||||
slightly_smiling_face: /(^|\s)(:-?\))(?=$|\s)/g, // :)
|
||||
wink: /(^|\s)(;-?\))(?=$|\s)/g, // ;)
|
||||
open_mouth: /(^|\s)(:o)(?=$|\s)/gi, // :o
|
||||
scream: /(^|\s)(:-o)(?=$|\s)/gi, // :-o
|
||||
smirk: /(^|\s)(:-?])(?=$|\s)/g, // :]
|
||||
smile: /(^|\s)(:-?d)(?=$|\s)/gi, // :D
|
||||
stuck_out_tongue_closed_eyes: /(^|\s)(x-d)(?=$|\s)/gi, // x-d
|
||||
stuck_out_tongue: /(^|\s)(:-?p)(?=$|\s)/gi, // :p
|
||||
rage: /(^|\s)(:-?[[@])(?=$|\s)/g, // :@
|
||||
slightly_frowning_face: /(^|\s)(:-?\()(?=$|\s)/g, // :(
|
||||
cry: /(^|\s)(:[`'’]-?\(|:'\(|:'\()(?=$|\s)/g, // :`(
|
||||
confused: /(^|\s)(:-?\/)(?=$|\s)/g, // :/
|
||||
confounded: /(^|\s)(:-?s)(?=$|\s)/gi, // :s
|
||||
neutral_face: /(^|\s)(:-?\|)(?=$|\s)/g, // :|
|
||||
flushed: /(^|\s)(:-?\$)(?=$|\s)/g, // :$
|
||||
mask: /(^|\s)(:-x)(?=$|\s)/gi, // :-x
|
||||
heart: /(^|\s)(<3|<3)(?=$|\s)/g, // <3
|
||||
broken_heart: /(^|\s)(<\/3|</3)(?=$|\s)/g, // </3
|
||||
};
|
||||
|
||||
const MAX_JUMBO_EMOJIS = 4;
|
||||
|
||||
function isEmoticon(text: string) {
|
||||
for (const emoticon of Object.keys(RE_EMOTICON)) {
|
||||
const reEmoticon = RE_EMOTICON[emoticon];
|
||||
const matchEmoticon = text.match(reEmoticon);
|
||||
if (matchEmoticon && matchEmoticon[0] === text) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getEmoticonName(value: string) {
|
||||
return Object.keys(RE_EMOTICON).find((key) => value.match(RE_EMOTICON[key]) !== null);
|
||||
}
|
||||
|
||||
export function hasEmojisOnly(message: string, customEmojis: Map<string, CustomEmoji>) {
|
||||
if (!message || message.length === 0 || (/^\s{4}/).test(message)) {
|
||||
return {isEmojiOnly: false, isJumboEmoji: false};
|
||||
}
|
||||
|
||||
const chunks = message.trim().replace(/\n/g, ' ').split(' ').filter((m) => m && m.length > 0);
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return {isEmojiOnly: false, isJumboEmoji: false};
|
||||
}
|
||||
|
||||
let emojiCount = 0;
|
||||
for (const chunk of chunks) {
|
||||
if (doesMatchNamedEmoji(chunk)) {
|
||||
const emojiName = chunk.substring(1, chunk.length - 1);
|
||||
if (EmojiIndicesByAlias.has(emojiName)) {
|
||||
emojiCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (customEmojis && customEmojis.has(emojiName)) {
|
||||
emojiCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const matchUnicodeEmoji = chunk.match(RE_UNICODE_EMOJI);
|
||||
if (matchUnicodeEmoji && matchUnicodeEmoji.join('') === chunk) {
|
||||
emojiCount += matchUnicodeEmoji.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isEmoticon(chunk)) {
|
||||
emojiCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
return {isEmojiOnly: false, isJumboEmoji: false};
|
||||
}
|
||||
|
||||
return {
|
||||
isEmojiOnly: true,
|
||||
isJumboEmoji: emojiCount > 0 && emojiCount <= MAX_JUMBO_EMOJIS,
|
||||
};
|
||||
}
|
||||
|
||||
export function doesMatchNamedEmoji(emojiName: string) {
|
||||
const match = emojiName.match(RE_NAMED_EMOJI);
|
||||
|
||||
if (match && match[0] === emojiName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getEmojiByName(emojiName: string) {
|
||||
if (EmojiIndicesByAlias.has(emojiName)) {
|
||||
return Emojis[EmojiIndicesByAlias.get(emojiName)!];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Since there is no shared logic between the web and mobile app
|
||||
// this is copied from the webapp as custom sorting logic for emojis
|
||||
|
||||
const defaultComparisonRule = (aName: string, bName: string) => {
|
||||
return aName.localeCompare(bName);
|
||||
};
|
||||
|
||||
const thumbsDownComparisonRule = (other: string) => (other === 'thumbsup' || other === '+1' ? 1 : 0);
|
||||
|
||||
const thumbsUpComparisonRule = (other: string) => (other === 'thumbsdown' || other === '-1' ? -1 : 0);
|
||||
|
||||
type Comparators = Record<string, ((other: string) => number)>;
|
||||
|
||||
const customComparisonRules: Comparators = {
|
||||
thumbsdown: thumbsDownComparisonRule,
|
||||
'-1': thumbsDownComparisonRule,
|
||||
thumbsup: thumbsUpComparisonRule,
|
||||
'+1': thumbsUpComparisonRule,
|
||||
};
|
||||
|
||||
function doDefaultComparison(aName: string, bName: string) {
|
||||
if (customComparisonRules[aName]) {
|
||||
return customComparisonRules[aName](bName) || defaultComparisonRule(aName, bName);
|
||||
}
|
||||
|
||||
return defaultComparisonRule(aName, bName);
|
||||
}
|
||||
|
||||
type EmojiType = {
|
||||
short_name: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function compareEmojis(emojiA: string | Partial<EmojiType>, emojiB: string | Partial<EmojiType>, searchedName: string) {
|
||||
if (!emojiA) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!emojiB) {
|
||||
return -1;
|
||||
}
|
||||
let aName;
|
||||
if (typeof emojiA === 'string') {
|
||||
aName = emojiA;
|
||||
} else {
|
||||
aName = 'short_name' in emojiA ? emojiA.short_name : emojiA.name;
|
||||
}
|
||||
let bName;
|
||||
if (typeof emojiB === 'string') {
|
||||
bName = emojiB;
|
||||
} else {
|
||||
bName = 'short_name' in emojiB ? emojiB.short_name : emojiB.name;
|
||||
}
|
||||
|
||||
if (!searchedName) {
|
||||
return doDefaultComparison(aName!, bName!);
|
||||
}
|
||||
|
||||
// Have the emojis that start with the search appear first
|
||||
const aPrefix = aName!.startsWith(searchedName);
|
||||
const bPrefix = bName!.startsWith(searchedName);
|
||||
|
||||
if (aPrefix && bPrefix) {
|
||||
return doDefaultComparison(aName!, bName!);
|
||||
} else if (aPrefix) {
|
||||
return -1;
|
||||
} else if (bPrefix) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Have the emojis that contain the search appear next
|
||||
const aIncludes = aName!.includes(searchedName);
|
||||
const bIncludes = bName!.includes(searchedName);
|
||||
|
||||
if (aIncludes && bIncludes) {
|
||||
return doDefaultComparison(aName!, bName!);
|
||||
} else if (aIncludes) {
|
||||
return -1;
|
||||
} else if (bIncludes) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return doDefaultComparison(aName!, bName!);
|
||||
}
|
||||
47
app/utils/emoji/index.js
Normal file
47
app/utils/emoji/index.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Platform} from 'react-native';
|
||||
import {FileSystem} from 'react-native-unimodules';
|
||||
|
||||
import {hashCode} from './security';
|
||||
|
||||
export async function deleteFileCache(serverUrl: string) {
|
||||
const serverDir = hashCode(serverUrl);
|
||||
const cacheDir = `${FileSystem.cacheDirectory}${serverDir}`;
|
||||
if (cacheDir) {
|
||||
const cacheDirInfo = await FileSystem.getInfoAsync(cacheDir);
|
||||
if (cacheDirInfo.exists) {
|
||||
if (Platform.OS === 'ios') {
|
||||
await FileSystem.deleteAsync(cacheDir, {idempotent: true});
|
||||
await FileSystem.makeDirectoryAsync(cacheDir, {intermediates: true});
|
||||
} else {
|
||||
const lstat = await FileSystem.readDirectoryAsync(cacheDir);
|
||||
lstat.forEach((stat: string) => {
|
||||
FileSystem.deleteAsync(stat, {idempotent: true});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
232
app/utils/file/index.ts
Normal file
232
app/utils/file/index.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import mimeDB from 'mime-db';
|
||||
import {Platform} from 'react-native';
|
||||
import {FileSystem} from 'react-native-unimodules';
|
||||
|
||||
import {hashCode} from '@utils/security';
|
||||
|
||||
const EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/;
|
||||
const CONTENT_DISPOSITION_REGEXP = /inline;filename=".*\.([a-z]+)";/i;
|
||||
const DEFAULT_SERVER_MAX_FILE_SIZE = 50 * 1024 * 1024;// 50 Mb
|
||||
|
||||
export const GENERAL_SUPPORTED_DOCS_FORMAT = [
|
||||
'application/json',
|
||||
'application/msword',
|
||||
'application/pdf',
|
||||
'application/rtf',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/x-x509-ca-cert',
|
||||
'application/xml',
|
||||
'text/csv',
|
||||
'text/plain',
|
||||
];
|
||||
|
||||
const SUPPORTED_DOCS_FORMAT = Platform.select({
|
||||
android: GENERAL_SUPPORTED_DOCS_FORMAT,
|
||||
ios: [
|
||||
...GENERAL_SUPPORTED_DOCS_FORMAT,
|
||||
'application/vnd.apple.pages',
|
||||
'application/vnd.apple.numbers',
|
||||
'application/vnd.apple.keynote',
|
||||
],
|
||||
});
|
||||
|
||||
const SUPPORTED_VIDEO_FORMAT = Platform.select({
|
||||
ios: ['video/mp4', 'video/x-m4v', 'video/quicktime'],
|
||||
android: ['video/3gpp', 'video/x-matroska', 'video/mp4', 'video/webm'],
|
||||
});
|
||||
|
||||
const types: Record<string, string> = {};
|
||||
const extensions: Record<string, readonly string[]> = {};
|
||||
|
||||
/**
|
||||
* Populate the extensions and types maps.
|
||||
* @private
|
||||
*/
|
||||
|
||||
function populateMaps() {
|
||||
// source preference (least -> most)
|
||||
const preference = ['nginx', 'apache', undefined, 'iana'];
|
||||
|
||||
Object.keys(mimeDB).forEach((type) => {
|
||||
const mime = mimeDB[type];
|
||||
const exts = mime.extensions;
|
||||
|
||||
if (!exts || !exts.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
extensions[type] = exts;
|
||||
|
||||
for (let i = 0; i < exts.length; i++) {
|
||||
const extension = exts[i];
|
||||
|
||||
if (types[extension]) {
|
||||
const from = preference.indexOf(mimeDB[types[extension]].source);
|
||||
const to = preference.indexOf(mime.source);
|
||||
|
||||
if (types[extension] !== 'application/octet-stream' &&
|
||||
(from > to || (from === to && types[extension].substr(0, 12) === 'application/'))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
types[extension] = type;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const vectorIconsDir = 'vectorIcons';
|
||||
const dirsToExclude = ['Cache.db', 'WebKit', 'WebView', vectorIconsDir];
|
||||
async function getDirectorySize(fileStats: FileSystem.FileInfo) {
|
||||
if (fileStats?.exists) {
|
||||
let total = 0;
|
||||
if (fileStats.isDirectory) {
|
||||
const exclude = dirsToExclude.find((f) => fileStats.uri.includes(f));
|
||||
if (!exclude) {
|
||||
const paths = await FileSystem.readDirectoryAsync(fileStats.uri);
|
||||
for await (const path of paths) {
|
||||
const info = await FileSystem.getInfoAsync(`${fileStats.uri}/${path}`, {size: true});
|
||||
if (info.isDirectory) {
|
||||
const dirSize = await getDirectorySize(info);
|
||||
total += dirSize;
|
||||
} else {
|
||||
total += (info.size || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
total = fileStats.size;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function getFileCacheSize() {
|
||||
if (FileSystem.cacheDirectory) {
|
||||
const cacheStats = await FileSystem.getInfoAsync(FileSystem.cacheDirectory);
|
||||
const size = await getDirectorySize(cacheStats);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function deleteFileCache(serverUrl: string) {
|
||||
const serverDir = hashCode(serverUrl);
|
||||
const cacheDir = `${FileSystem.cacheDirectory}/${serverDir}`;
|
||||
if (cacheDir) {
|
||||
const cacheDirInfo = await FileSystem.getInfoAsync(cacheDir);
|
||||
if (cacheDirInfo.exists) {
|
||||
if (Platform.OS === 'ios') {
|
||||
await FileSystem.deleteAsync(cacheDir, {idempotent: true});
|
||||
await FileSystem.makeDirectoryAsync(cacheDir, {intermediates: true});
|
||||
} else {
|
||||
const lstat = await FileSystem.readDirectoryAsync(cacheDir);
|
||||
lstat.forEach((stat: string) => {
|
||||
FileSystem.deleteAsync(stat, {idempotent: true});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function lookupMimeType(filename: string) {
|
||||
if (!Object.keys(extensions).length) {
|
||||
populateMaps();
|
||||
}
|
||||
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
return types[ext!] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function getExtensionFromMime(type: string) {
|
||||
if (!Object.keys(extensions).length) {
|
||||
populateMaps();
|
||||
}
|
||||
|
||||
if (!type || typeof type !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = EXTRACT_TYPE_REGEXP.exec(type);
|
||||
|
||||
// get extensions
|
||||
const exts = match && extensions[match[1].toLowerCase()];
|
||||
|
||||
if (!exts || !exts.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return exts[0];
|
||||
}
|
||||
|
||||
export function getExtensionFromContentDisposition(contentDisposition: string) {
|
||||
const match = CONTENT_DISPOSITION_REGEXP.exec(contentDisposition);
|
||||
let extension = match && match[1];
|
||||
if (extension) {
|
||||
if (!Object.keys(types).length) {
|
||||
populateMaps();
|
||||
}
|
||||
|
||||
extension = extension.toLowerCase();
|
||||
if (types[extension]) {
|
||||
return extension;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const getAllowedServerMaxFileSize = (config: ClientConfig) => {
|
||||
return config && config.MaxFileSize ? parseInt(config.MaxFileSize, 10) : DEFAULT_SERVER_MAX_FILE_SIZE;
|
||||
};
|
||||
|
||||
export const isGif = (file?: FileInfo) => {
|
||||
let mime = file?.mime_type || '';
|
||||
if (mime && mime.includes(';')) {
|
||||
mime = mime.split(';')[0];
|
||||
} else if (!mime && file?.name) {
|
||||
mime = lookupMimeType(file.name);
|
||||
}
|
||||
|
||||
return mime === 'image/gif';
|
||||
};
|
||||
|
||||
export const isImage = (file?: FileInfo) => (file?.has_preview_image || isGif(file) || file?.mime_type?.startsWith('image/'));
|
||||
|
||||
export const isDocument = (file?: FileInfo) => {
|
||||
let mime = file?.mime_type || '';
|
||||
if (mime && mime.includes(';')) {
|
||||
mime = mime.split(';')[0];
|
||||
} else if (!mime && file?.name) {
|
||||
mime = lookupMimeType(file.name);
|
||||
}
|
||||
|
||||
return SUPPORTED_DOCS_FORMAT!.includes(mime);
|
||||
};
|
||||
|
||||
export const isVideo = (file?: FileInfo) => {
|
||||
let mime = file?.mime_type || '';
|
||||
if (mime && mime.includes(';')) {
|
||||
mime = mime.split(';')[0];
|
||||
} else if (!mime && file?.name) {
|
||||
mime = lookupMimeType(file.name);
|
||||
}
|
||||
|
||||
return SUPPORTED_VIDEO_FORMAT!.includes(mime);
|
||||
};
|
||||
137
app/utils/gallery/index.ts
Normal file
137
app/utils/gallery/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Dimensions, Keyboard, Platform} from 'react-native';
|
||||
import {Options, SharedElementTransition, StackAnimationOptions, ViewAnimationOptions} from 'react-native-navigation';
|
||||
import parseUrl from 'url-parse';
|
||||
|
||||
import {goToScreen} from '@screens/navigation';
|
||||
import {isImage, lookupMimeType} from '@utils/file';
|
||||
import {generateId} from '@utils/general';
|
||||
|
||||
export function openGalleryAtIndex(index: number, files: FileInfo[]) {
|
||||
Keyboard.dismiss();
|
||||
requestAnimationFrame(() => {
|
||||
const screen = 'Gallery';
|
||||
const passProps = {
|
||||
index,
|
||||
files,
|
||||
};
|
||||
const windowHeight = Dimensions.get('window').height;
|
||||
const sharedElementTransitions: SharedElementTransition[] = [];
|
||||
|
||||
const contentPush = {} as ViewAnimationOptions;
|
||||
const contentPop = {} as ViewAnimationOptions;
|
||||
const file = files[index];
|
||||
|
||||
if (isImage(file)) {
|
||||
sharedElementTransitions.push({
|
||||
fromId: `image-${file.id}`,
|
||||
toId: `gallery-${file.id}`,
|
||||
duration: 300,
|
||||
interpolation: {type: 'accelerateDecelerate'},
|
||||
});
|
||||
} else {
|
||||
contentPush.y = {
|
||||
from: windowHeight,
|
||||
to: 0,
|
||||
duration: 300,
|
||||
interpolation: {type: 'decelerate'},
|
||||
};
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
contentPop.translationY = {
|
||||
from: 0,
|
||||
to: windowHeight,
|
||||
duration: 300,
|
||||
};
|
||||
} else {
|
||||
contentPop.y = {
|
||||
from: 0,
|
||||
to: windowHeight,
|
||||
duration: 300,
|
||||
};
|
||||
contentPop.alpha = {
|
||||
from: 1,
|
||||
to: 0,
|
||||
duration: 100,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const options: Options = {
|
||||
layout: {
|
||||
backgroundColor: '#000',
|
||||
componentBackgroundColor: '#000',
|
||||
orientation: ['portrait', 'landscape'],
|
||||
},
|
||||
topBar: {
|
||||
background: {
|
||||
color: '#000',
|
||||
},
|
||||
visible: Platform.OS === 'android',
|
||||
},
|
||||
animations: {
|
||||
push: {
|
||||
waitForRender: true,
|
||||
sharedElementTransitions,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (Object.keys(contentPush).length) {
|
||||
options.animations!.push = {
|
||||
...options.animations!.push,
|
||||
...Platform.select<ViewAnimationOptions | StackAnimationOptions>({
|
||||
android: contentPush,
|
||||
ios: {
|
||||
content: contentPush,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(contentPop).length) {
|
||||
options.animations!.pop = Platform.select<ViewAnimationOptions | StackAnimationOptions>({
|
||||
android: contentPop,
|
||||
ios: {
|
||||
content: contentPop,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
goToScreen(screen, '', passProps, options);
|
||||
});
|
||||
}
|
||||
|
||||
export function openGallerWithMockFile(uri: string, postId: string, height: number, width: number, fileId?: string) {
|
||||
const url = decodeURIComponent(uri);
|
||||
let filename = parseUrl(url.substr(url.lastIndexOf('/'))).pathname.replace('/', '');
|
||||
let extension = filename.split('.').pop();
|
||||
|
||||
if (!extension || extension === filename) {
|
||||
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
|
||||
filename = `${filename}${ext}`;
|
||||
extension = ext;
|
||||
}
|
||||
|
||||
const file: FileInfo = {
|
||||
id: fileId || generateId(),
|
||||
clientId: 'mock_client_id',
|
||||
create_at: Date.now(),
|
||||
delete_at: 0,
|
||||
extension,
|
||||
has_preview_image: true,
|
||||
height,
|
||||
mime_type: lookupMimeType(filename),
|
||||
name: filename,
|
||||
post_id: postId,
|
||||
size: 0,
|
||||
update_at: 0,
|
||||
uri,
|
||||
user_id: 'mock_user_id',
|
||||
width,
|
||||
};
|
||||
|
||||
openGalleryAtIndex(0, [file]);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import ReactNativeHapticFeedback, {HapticFeedbackTypes} from 'react-native-haptic-feedback';
|
||||
|
||||
type SortByCreatAt = (Session | Channel | Team | Post) & {
|
||||
create_at: number;
|
||||
}
|
||||
@@ -30,6 +32,13 @@ export const generateId = (): string => {
|
||||
return id;
|
||||
};
|
||||
|
||||
export function hapticFeedback(method: HapticFeedbackTypes = 'impactLight') {
|
||||
ReactNativeHapticFeedback.trigger(method, {
|
||||
enableVibrateFallback: false,
|
||||
ignoreAndroidSystemSettings: false,
|
||||
});
|
||||
}
|
||||
|
||||
export const sortByNewest = (a: SortByCreatAt, b: SortByCreatAt) => {
|
||||
if (a.create_at > b.create_at) {
|
||||
return -1;
|
||||
|
||||
75
app/utils/images/index.ts
Normal file
75
app/utils/images/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Dimensions} from 'react-native';
|
||||
|
||||
import {Device} from '@constants';
|
||||
import {IMAGE_MAX_HEIGHT, IMAGE_MIN_DIMENSION, MAX_GIF_SIZE, VIEWPORT_IMAGE_OFFSET, VIEWPORT_IMAGE_REPLY_OFFSET} from '@constants/image';
|
||||
|
||||
export const calculateDimensions = (height: number, width: number, viewPortWidth = 0, viewPortHeight = 0) => {
|
||||
if (!height || !width) {
|
||||
return {
|
||||
height: 0,
|
||||
width: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const ratio = height / width;
|
||||
const heightRatio = width / height;
|
||||
|
||||
let imageWidth = width;
|
||||
let imageHeight = height;
|
||||
|
||||
if (width > viewPortWidth) {
|
||||
imageWidth = viewPortWidth;
|
||||
imageHeight = imageWidth * ratio;
|
||||
} else if (width < IMAGE_MIN_DIMENSION) {
|
||||
imageWidth = IMAGE_MIN_DIMENSION;
|
||||
imageHeight = imageWidth * ratio;
|
||||
}
|
||||
|
||||
if ((imageHeight > IMAGE_MAX_HEIGHT || (viewPortHeight && imageHeight > viewPortHeight)) && viewPortHeight <= IMAGE_MAX_HEIGHT) {
|
||||
imageHeight = viewPortHeight || IMAGE_MAX_HEIGHT;
|
||||
imageWidth = imageHeight * heightRatio;
|
||||
} else if (imageHeight < IMAGE_MIN_DIMENSION && IMAGE_MIN_DIMENSION * heightRatio <= viewPortWidth) {
|
||||
imageHeight = IMAGE_MIN_DIMENSION;
|
||||
imageWidth = imageHeight * heightRatio;
|
||||
} else if (viewPortHeight && imageHeight > viewPortHeight) {
|
||||
imageHeight = viewPortHeight;
|
||||
imageWidth = imageHeight * heightRatio;
|
||||
}
|
||||
|
||||
return {
|
||||
height: imageHeight,
|
||||
width: imageWidth,
|
||||
};
|
||||
};
|
||||
|
||||
export function getViewPortWidth(isReplyPost: boolean, permanentSidebar = false) {
|
||||
const {width, height} = Dimensions.get('window');
|
||||
let portraitPostWidth = Math.min(width, height) - VIEWPORT_IMAGE_OFFSET;
|
||||
|
||||
if (permanentSidebar) {
|
||||
portraitPostWidth -= Device.TABLET_WIDTH;
|
||||
}
|
||||
|
||||
if (isReplyPost) {
|
||||
portraitPostWidth -= VIEWPORT_IMAGE_REPLY_OFFSET;
|
||||
}
|
||||
|
||||
return portraitPostWidth;
|
||||
}
|
||||
|
||||
// isGifTooLarge returns true if we think that the GIF may cause the device to run out of memory when rendered
|
||||
// based on the image's dimensions and frame count.
|
||||
export function isGifTooLarge(imageMetadata?: PostImage) {
|
||||
if (imageMetadata?.format !== 'gif') {
|
||||
// Not a gif or from an older server that doesn't count frames
|
||||
return false;
|
||||
}
|
||||
|
||||
const {frame_count: frameCount, height, width} = imageMetadata;
|
||||
|
||||
// Try to estimate the in-memory size of the gif to prevent the device out of memory
|
||||
return width * height * (frameCount || 1) > MAX_GIF_SIZE;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
color: theme.linkColor,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 17,
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
lineHeight: 25,
|
||||
},
|
||||
@@ -33,7 +33,7 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
paddingBottom: 8,
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 17,
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
lineHeight: 25,
|
||||
},
|
||||
@@ -41,7 +41,7 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
paddingBottom: 8,
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 17,
|
||||
fontSize: 19,
|
||||
fontWeight: '700',
|
||||
lineHeight: 25,
|
||||
},
|
||||
@@ -49,7 +49,7 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
paddingBottom: 8,
|
||||
},
|
||||
heading4: {
|
||||
fontSize: 17,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
lineHeight: 25,
|
||||
},
|
||||
@@ -65,7 +65,7 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
paddingBottom: 8,
|
||||
},
|
||||
heading6: {
|
||||
fontSize: 17,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
lineHeight: 25,
|
||||
},
|
||||
@@ -140,6 +140,7 @@ const languages: Record<string, string> = {
|
||||
html: 'HTML',
|
||||
java: 'Java',
|
||||
javascript: 'JavaScript',
|
||||
js: 'JavaScript',
|
||||
json: 'JSON',
|
||||
julia: 'Julia',
|
||||
kotlin: 'Kotlin',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export function preventDoubleTap(func: (...args: any) => any, doublePressDelay = 300) {
|
||||
export function preventDoubleTap(func: (...args: any) => any, doublePressDelay = 750) {
|
||||
let canPressWrapped = true;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
|
||||
@@ -44,6 +44,6 @@ describe('Prevent double tap', () => {
|
||||
test();
|
||||
expect(testFunction).toHaveBeenCalledTimes(2);
|
||||
done();
|
||||
}, 300);
|
||||
}, 750);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {StyleSheet} from 'react-native';
|
||||
import tinyColor from 'tinycolor2';
|
||||
|
||||
import {mergeNavigationOptions} from '@screens/navigation';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
|
||||
import type {Options} from 'react-native-navigation';
|
||||
|
||||
@@ -86,8 +87,8 @@ export function changeOpacity(oldColor: string, opacity: number): string {
|
||||
return `rgba(${red},${green},${blue},${alpha * opacity})`;
|
||||
}
|
||||
|
||||
export function concatStyles(...styles: any) {
|
||||
return [].concat(styles);
|
||||
export function concatStyles<T>(...styles: T[]) {
|
||||
return ([] as T[]).concat(...styles);
|
||||
}
|
||||
|
||||
export function setNavigatorStyles(componentId: string, theme: Theme) {
|
||||
@@ -116,6 +117,12 @@ export function setNavigatorStyles(componentId: string, theme: Theme) {
|
||||
mergeNavigationOptions(componentId, options);
|
||||
}
|
||||
|
||||
export function setNavigationStackStyles(theme: Theme) {
|
||||
EphemeralStore.allNavigationComponentIds.forEach((componentId) => {
|
||||
setNavigatorStyles(componentId, theme);
|
||||
});
|
||||
}
|
||||
|
||||
export function getKeyboardAppearanceFromTheme(theme: Theme) {
|
||||
return tinyColor(theme.centerChannelBg).isLight() ? 'light' : 'dark';
|
||||
}
|
||||
@@ -149,3 +156,35 @@ export function hexToHue(hexColor: string) {
|
||||
|
||||
return hue;
|
||||
}
|
||||
|
||||
function blendComponent(background: number, foreground: number, opacity: number): number {
|
||||
return ((1 - opacity) * background) + (opacity * foreground);
|
||||
}
|
||||
|
||||
export function blendColors(background: string, foreground: string, opacity: number): string {
|
||||
const backgroundComponents = getComponents(background);
|
||||
const foregroundComponents = getComponents(foreground);
|
||||
|
||||
const red = Math.floor(blendComponent(
|
||||
backgroundComponents.red,
|
||||
foregroundComponents.red,
|
||||
opacity,
|
||||
));
|
||||
const green = Math.floor(blendComponent(
|
||||
backgroundComponents.green,
|
||||
foregroundComponents.green,
|
||||
opacity,
|
||||
));
|
||||
const blue = Math.floor(blendComponent(
|
||||
backgroundComponents.blue,
|
||||
foregroundComponents.blue,
|
||||
opacity,
|
||||
));
|
||||
const alpha = blendComponent(
|
||||
backgroundComponents.alpha,
|
||||
foregroundComponents.alpha,
|
||||
opacity,
|
||||
);
|
||||
|
||||
return `rgba(${red},${green},${blue},${alpha})`;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {General, Preferences} from '@constants';
|
||||
import {UserModel} from '@database/models/server';
|
||||
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
|
||||
|
||||
export function displayUsername(user?: UserProfile, locale?: string, teammateDisplayNameSetting?: string, useFallbackUsername = true) {
|
||||
export function displayUsername(user?: UserProfile | UserModel, locale?: string, teammateDisplayNameSetting?: string, useFallbackUsername = true) {
|
||||
let name = useFallbackUsername ? getLocalizedMessage(locale || DEFAULT_LOCALE, t('channel_loader.someone'), 'Someone') : '';
|
||||
|
||||
if (user) {
|
||||
@@ -39,13 +40,24 @@ export function displayGroupMessageName(users: UserProfile[], locale?: string, t
|
||||
return names.sort(sortUsernames).join(', ');
|
||||
}
|
||||
|
||||
export function getFullName(user: UserProfile): string {
|
||||
if (user.first_name && user.last_name) {
|
||||
return `${user.first_name} ${user.last_name}`;
|
||||
} else if (user.first_name) {
|
||||
return user.first_name;
|
||||
} else if (user.last_name) {
|
||||
return user.last_name;
|
||||
export function getFullName(user: UserProfile | UserModel): string {
|
||||
let firstName: string;
|
||||
let lastName: string;
|
||||
|
||||
if (user instanceof UserModel) {
|
||||
firstName = user.firstName;
|
||||
lastName = user.lastName;
|
||||
} else {
|
||||
firstName = user.first_name;
|
||||
lastName = user.last_name;
|
||||
}
|
||||
|
||||
if (firstName && lastName) {
|
||||
return `${firstName} ${lastName}`;
|
||||
} else if (firstName) {
|
||||
return firstName;
|
||||
} else if (lastName) {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
return '';
|
||||
@@ -71,3 +83,45 @@ export function isGuest(roles: string): boolean {
|
||||
export function isSystemAdmin(roles: string): boolean {
|
||||
return isRoleInRoles(roles, General.SYSTEM_ADMIN_ROLE);
|
||||
}
|
||||
|
||||
export const getUsersByUsername = (users: UserModel[]) => {
|
||||
const usersByUsername: Dictionary<UserModel> = {};
|
||||
|
||||
for (const user of users) {
|
||||
usersByUsername[user.username] = user;
|
||||
}
|
||||
|
||||
return usersByUsername;
|
||||
};
|
||||
|
||||
export const getUserMentionKeys = (user: UserModel) => {
|
||||
const keys: UserMentionKey[] = [];
|
||||
|
||||
if (!user.notifyProps) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
if (user.notifyProps.mention_keys) {
|
||||
const mentions = user.notifyProps.mention_keys.split(',').map((key) => ({key}));
|
||||
keys.push(...mentions);
|
||||
}
|
||||
|
||||
if (user.notifyProps.first_name === 'true' && user.firstName) {
|
||||
keys.push({key: user.firstName, caseSensitive: true});
|
||||
}
|
||||
|
||||
if (user.notifyProps.channel === 'true') {
|
||||
keys.push(
|
||||
{key: '@channel'},
|
||||
{key: '@all'},
|
||||
{key: '@here'},
|
||||
);
|
||||
}
|
||||
|
||||
const usernameKey = `@${user.username}`;
|
||||
if (keys.findIndex((item) => item.key === usernameKey) === -1) {
|
||||
keys.push({key: usernameKey});
|
||||
}
|
||||
|
||||
return keys;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,24 @@
|
||||
"channel.channelHasGuests": "This channel has guests",
|
||||
"channel.hasGuests": "This group message has guests",
|
||||
"channel.isGuest": "This person is a guest",
|
||||
"emoji_picker.activities": "Activities",
|
||||
"emoji_picker.animals-nature": "Animals & Nature",
|
||||
"emoji_picker.custom": "Custom",
|
||||
"emoji_picker.flags": "Flags",
|
||||
"emoji_picker.food-drink": "Food & Drink",
|
||||
"emoji_picker.objects": "Objects",
|
||||
"emoji_picker.people-body": "People & Body",
|
||||
"emoji_picker.recent": "Recently Used",
|
||||
"emoji_picker.searchResults": "Search Results",
|
||||
"emoji_picker.smileys-emotion": "Smileys & Emotion",
|
||||
"emoji_picker.symbols": "Symbols",
|
||||
"emoji_picker.travel-places": "Travel & Places",
|
||||
"emoji_skin.dark_skin_tone": "dark skin tone",
|
||||
"emoji_skin.default": "default skin tone",
|
||||
"emoji_skin.light_skin_tone": "light skin tone",
|
||||
"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",
|
||||
"failed_action.fetch_channels": "Channels could not be loaded for {teamName}.",
|
||||
"failed_action.fetch_teams": "An error ocurred while loading the teams of this server",
|
||||
"failed_action.something_wrong": "Something went wrong",
|
||||
@@ -38,6 +56,7 @@
|
||||
"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.join_channel.error": "We couldn't join the channel {displayName}. Please check your connection and try again.",
|
||||
"mobile.launchError.notification": "Did not find a server for this notification",
|
||||
"mobile.link.error.text": "Unable to open the link.",
|
||||
"mobile.link.error.title": "Error",
|
||||
@@ -50,6 +69,11 @@
|
||||
"mobile.managed.not_secured.ios.touchId": "This device must be secured with a passcode to use Mattermost.\n\nGo to Settings > Touch ID & Passcode.",
|
||||
"mobile.managed.secured_by": "Secured by {vendor}",
|
||||
"mobile.managed.settings": "Go to settings",
|
||||
"mobile.markdown.code.copy_code": "Copy Code",
|
||||
"mobile.markdown.code.plusMoreLines": "+{count, number} more {count, plural, one {line} other {lines}}",
|
||||
"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.oauth.failed_to_login": "Your login attempt failed. Please try again.",
|
||||
"mobile.oauth.failed_to_open_link": "The link failed to open. Please try again.",
|
||||
"mobile.oauth.failed_to_open_link_no_browser": "The link failed to open. Please verify if a browser is installed in the current space.",
|
||||
@@ -57,15 +81,22 @@
|
||||
"mobile.oauth.something_wrong.okButon": "Ok",
|
||||
"mobile.oauth.switch_to_browser": "Please use your browser to complete the login",
|
||||
"mobile.oauth.try_again": "Try again",
|
||||
"mobile.post.cancel": "Cancel",
|
||||
"mobile.push_notification_reply.button": "Send",
|
||||
"mobile.push_notification_reply.placeholder": "Write a reply...",
|
||||
"mobile.push_notification_reply.title": "Reply",
|
||||
"mobile.request.invalid_request_method": "Invalid request method",
|
||||
"mobile.request.invalid_response": "Received invalid response from the server.",
|
||||
"mobile.routes.code": "{language} Code",
|
||||
"mobile.routes.code.noLanguage": "Code",
|
||||
"mobile.routes.login": "Login",
|
||||
"mobile.routes.loginOptions": "Login Chooser",
|
||||
"mobile.routes.mfa": "Multi-factor Authentication",
|
||||
"mobile.routes.sso": "Single Sign-On",
|
||||
"mobile.routes.table": "Table",
|
||||
"mobile.routes.user_profile": "Profile",
|
||||
"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_upgrade.alert_description": "This server version is unsupported and users will be exposed to compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Upgrading to server version {serverVersion} or later is required.",
|
||||
"mobile.server_upgrade.button": "OK",
|
||||
"mobile.server_upgrade.description": "\nA server upgrade is required to use the Mattermost app. Please ask your System Administrator for details.\n",
|
||||
@@ -85,6 +116,11 @@
|
||||
"password_send.error": "Please enter a valid email address.",
|
||||
"password_send.link": "If the account exists, a password reset email will be sent to:",
|
||||
"password_send.reset": "Reset my password",
|
||||
"permalink.show_dialog_warn.cancel": "Cancel",
|
||||
"permalink.show_dialog_warn.description": "You are about to join {channel} without explicitly being added by the channel admin. Are you sure you wish to join this private channel?",
|
||||
"permalink.show_dialog_warn.join": "Join",
|
||||
"permalink.show_dialog_warn.title": "Join private channel",
|
||||
"post_message_view.edited": "(edited)",
|
||||
"signup.email": "Email and Password",
|
||||
"signup.google": "Google Apps",
|
||||
"signup.office365": "Office 365",
|
||||
|
||||
BIN
assets/base/images/emojis/mattermost.png
Normal file
BIN
assets/base/images/emojis/mattermost.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -43,6 +43,7 @@
|
||||
7FABDFC22211A39000D0F595 /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FABDFC12211A39000D0F595 /* Section.swift */; };
|
||||
7FABE00A2212650600D0F595 /* ChannelsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FABE0092212650600D0F595 /* ChannelsViewController.swift */; };
|
||||
7FABE0562213884700D0F595 /* libUploadAttachments.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FABE04522137F2A00D0F595 /* libUploadAttachments.a */; };
|
||||
7FCEFB9326B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m in Sources */ = {isa = PBXBuildFile; fileRef = 7FCEFB9226B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m */; };
|
||||
7FEB109D1F61019C0039A015 /* MattermostManaged.m in Sources */ = {isa = PBXBuildFile; fileRef = 7FEB109A1F61019C0039A015 /* MattermostManaged.m */; };
|
||||
84E3264B229834C30055068A /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E325FF229834C30055068A /* Config.swift */; };
|
||||
9358B95F95184EE0A4DCE629 /* OpenSans-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D4B1B363C2414DA19C1AC521 /* OpenSans-Bold.ttf */; };
|
||||
@@ -213,6 +214,8 @@
|
||||
7FABDFC12211A39000D0F595 /* Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = "<group>"; };
|
||||
7FABE0092212650600D0F595 /* ChannelsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelsViewController.swift; sourceTree = "<group>"; };
|
||||
7FABE04022137F2900D0F595 /* UploadAttachments.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = UploadAttachments.xcodeproj; path = UploadAttachments/UploadAttachments.xcodeproj; sourceTree = "<group>"; };
|
||||
7FCEFB9126B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SDWebImageDownloaderOperation+Swizzle.h"; path = "Mattermost/SDWebImageDownloaderOperation+Swizzle.h"; sourceTree = "<group>"; };
|
||||
7FCEFB9226B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = "SDWebImageDownloaderOperation+Swizzle.m"; path = "Mattermost/SDWebImageDownloaderOperation+Swizzle.m"; sourceTree = "<group>"; };
|
||||
7FEB10991F61019C0039A015 /* MattermostManaged.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = MattermostManaged.h; path = Mattermost/MattermostManaged.h; sourceTree = "<group>"; };
|
||||
7FEB109A1F61019C0039A015 /* MattermostManaged.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = MattermostManaged.m; path = Mattermost/MattermostManaged.m; sourceTree = "<group>"; };
|
||||
7FFE32B51FD9CCAA0038C7A0 /* FLAnimatedImage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = FLAnimatedImage.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -340,6 +343,8 @@
|
||||
7FEB109A1F61019C0039A015 /* MattermostManaged.m */,
|
||||
7F292AA51E8ABB1100A450A3 /* splash.png */,
|
||||
7F0F4B0924BA173900E14C60 /* LaunchScreen.storyboard */,
|
||||
7FCEFB9126B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.h */,
|
||||
7FCEFB9226B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m */,
|
||||
);
|
||||
name = Mattermost;
|
||||
sourceTree = "<group>";
|
||||
@@ -809,6 +814,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7FCEFB9326B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m in Sources */,
|
||||
4953BF602368AE8600593328 /* SwimeProxy.swift in Sources */,
|
||||
13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */,
|
||||
7F151D3E221B062700FAD8F3 /* RuntimeUtils.swift in Sources */,
|
||||
|
||||
@@ -78,7 +78,7 @@ RCT_EXPORT_METHOD(deleteDatabaseDirectory: (NSString *)databaseName shouldRemov
|
||||
BOOL successCode = [fileManager removeItemAtPath:databaseDir error:&error];
|
||||
NSNumber * success= [NSNumber numberWithBool:successCode];
|
||||
|
||||
callback(@[error, success]);
|
||||
callback(@[(error ?: [NSNull null]), success]);
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
NSLog(@"%@", exception.reason);
|
||||
|
||||
10
ios/Mattermost/SDWebImageDownloaderOperation+Swizzle.h
Normal file
10
ios/Mattermost/SDWebImageDownloaderOperation+Swizzle.h
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
//
|
||||
// SDWebImageDownloaderOperation+Swizzle.h
|
||||
|
||||
#import "SDWebImageDownloaderOperation.h"
|
||||
|
||||
@interface SDWebImageDownloaderOperation (Swizzle)
|
||||
|
||||
@end
|
||||
101
ios/Mattermost/SDWebImageDownloaderOperation+Swizzle.m
Normal file
101
ios/Mattermost/SDWebImageDownloaderOperation+Swizzle.m
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
//
|
||||
// SDWebImageDownloaderOperation+Swizzle.m
|
||||
|
||||
|
||||
#import "SDWebImageDownloaderOperation+Swizzle.h"
|
||||
@import react_native_network_client;
|
||||
#import <objc/runtime.h>
|
||||
|
||||
@implementation SDWebImageDownloaderOperation (Swizzle)
|
||||
|
||||
+ (void) load {
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
[self swizzleInitMethod];
|
||||
[self swizzleURLSessionTaskDelegateMethod];
|
||||
});
|
||||
}
|
||||
|
||||
+ (void) swizzleInitMethod {
|
||||
Class class = [self class];
|
||||
|
||||
SEL originalSelector = @selector(initWithRequest:inSession:options:context:);
|
||||
SEL swizzledSelector = @selector(swizzled_initWithRequest:inSession:options:context:);
|
||||
|
||||
Method originalMethod = class_getInstanceMethod(class, originalSelector);
|
||||
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
|
||||
|
||||
method_exchangeImplementations(originalMethod, swizzledMethod);
|
||||
|
||||
}
|
||||
|
||||
+ (void) swizzleURLSessionTaskDelegateMethod {
|
||||
Class class = [self class];
|
||||
|
||||
SEL originalSelector = @selector(URLSession:task:didReceiveChallenge:completionHandler:);
|
||||
SEL swizzledSelector = @selector(swizzled_URLSession:task:didReceiveChallenge:completionHandler:);
|
||||
|
||||
Method originalMethod = class_getInstanceMethod(class, originalSelector);
|
||||
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
|
||||
|
||||
method_exchangeImplementations(originalMethod, swizzledMethod);
|
||||
}
|
||||
|
||||
#pragma mark - Method Swizzling
|
||||
|
||||
- (nonnull instancetype)swizzled_initWithRequest:(NSURLRequest *)request inSession:(NSURLSession *)session options:(SDWebImageDownloaderOptions)options context:(nullable SDWebImageContext *)context {
|
||||
SessionManager *nativeClientSessionManager = [SessionManager default];
|
||||
NSURL *sessionBaseUrl = [nativeClientSessionManager getSessionBaseUrlFor:request];
|
||||
if (sessionBaseUrl != nil) {
|
||||
// If we have a session configured for this request then use its configuration
|
||||
// to create a new session that SDWebImageDownloaderOperation will use for
|
||||
// this request. In addition, if we have an authorization header being added
|
||||
// to our session's requests, then we modify the request here as well using
|
||||
// our BearerAuthenticationAdapter.
|
||||
NSURLSessionConfiguration *configuration = [nativeClientSessionManager getSessionConfigurationFor:sessionBaseUrl];
|
||||
NSURLSession *newSession = [NSURLSession sessionWithConfiguration:configuration
|
||||
delegate:self
|
||||
delegateQueue:session.delegateQueue];
|
||||
NSURLRequest *authorizedRequest = [BearerAuthenticationAdapter addAuthorizationBearerTokenTo:request withSessionBaseUrlString:sessionBaseUrl.absoluteString];
|
||||
|
||||
return [self swizzled_initWithRequest:authorizedRequest inSession:newSession options:options context:context];
|
||||
}
|
||||
|
||||
return [self swizzled_initWithRequest:request inSession:session options:options context:context];
|
||||
}
|
||||
|
||||
|
||||
- (void)swizzled_URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *credential))completionHandler {
|
||||
SessionManager *nativeClientSessionManager = [SessionManager default];
|
||||
NSURL *sessionBaseUrl = [nativeClientSessionManager getSessionBaseUrlFor:task.currentRequest];
|
||||
if (sessionBaseUrl != nil) {
|
||||
// If we have a session configured for this request then we'll fetch and
|
||||
// apply the necessary credentials for NSURLAuthenticationMethodServerTrust
|
||||
// and NSURLAuthenticationMethodClientCertificate.
|
||||
NSURLCredential *credential = nil;
|
||||
NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
|
||||
|
||||
NSString *authenticationMethod = challenge.protectionSpace.authenticationMethod;
|
||||
if ([authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
|
||||
if ([nativeClientSessionManager getTrustSelfSignedServerCertificateFor:sessionBaseUrl]) {
|
||||
credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
|
||||
disposition = NSURLSessionAuthChallengeUseCredential;
|
||||
}
|
||||
} else if ([authenticationMethod isEqualToString:NSURLAuthenticationMethodClientCertificate]) {
|
||||
credential = [nativeClientSessionManager getCredentialFor:sessionBaseUrl];
|
||||
disposition = NSURLSessionAuthChallengeUseCredential;
|
||||
}
|
||||
|
||||
if (completionHandler) {
|
||||
completionHandler(disposition, credential);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[self swizzled_URLSession:session task:task didReceiveChallenge:challenge completionHandler:completionHandler];
|
||||
}
|
||||
|
||||
@end
|
||||
63
package-lock.json
generated
63
package-lock.json
generated
@@ -29,6 +29,7 @@
|
||||
"@react-native-cookies/cookies": "6.0.8",
|
||||
"@rudderstack/rudder-sdk-react-native": "1.0.12",
|
||||
"@sentry/react-native": "2.6.1",
|
||||
"@types/mime-db": "1.43.1",
|
||||
"commonmark": "0.30.0",
|
||||
"commonmark-react-renderer": "4.3.5",
|
||||
"deep-equal": "2.0.5",
|
||||
@@ -74,6 +75,7 @@
|
||||
"react-native-video": "5.1.1",
|
||||
"react-native-webview": "11.6.5",
|
||||
"react-native-youtube": "2.0.2",
|
||||
"reanimated-bottom-sheet": "1.0.0-alpha.22",
|
||||
"rn-placeholder": "3.0.3",
|
||||
"semver": "7.3.5",
|
||||
"serialize-error": "8.1.0",
|
||||
@@ -93,6 +95,8 @@
|
||||
"@babel/register": "7.14.5",
|
||||
"@react-native-community/eslint-config": "3.0.0",
|
||||
"@testing-library/react-native": "7.2.0",
|
||||
"@types/commonmark": "0.27.5",
|
||||
"@types/commonmark-react-renderer": "4.3.1",
|
||||
"@types/jest": "26.0.24",
|
||||
"@types/react": "17.0.15",
|
||||
"@types/react-intl": "3.0.0",
|
||||
@@ -7450,6 +7454,22 @@
|
||||
"@types/responselike": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/commonmark": {
|
||||
"version": "0.27.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/commonmark/-/commonmark-0.27.5.tgz",
|
||||
"integrity": "sha512-vIqgmHyLsc8Or3EWLz6QkhI8/v61FNeH0yxRupA7VqSbA2eFMoHHJAhZSHudplAV89wqg1CKSmShE016ziRXuw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/commonmark-react-renderer": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/commonmark-react-renderer/-/commonmark-react-renderer-4.3.1.tgz",
|
||||
"integrity": "sha512-FAP10ymnRsmJUZatePP/xgZiHDpht6RB3fkDnV36bgikOa7OuhKaWTz4jXCJ4x8/ND6ZJRmb5UZagJrXsc25Gg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/commonmark": "*",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.6.tgz",
|
||||
@@ -7576,6 +7596,11 @@
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mime-db": {
|
||||
"version": "1.43.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-db/-/mime-db-1.43.1.tgz",
|
||||
"integrity": "sha512-kGZJY+R+WnR5Rk+RPHUMERtb2qBRViIHCBdtUrY+NmwuGb8pQdfTqQiCKPrxpdoycl8KWm2DLdkpoSdt479XoQ=="
|
||||
},
|
||||
"node_modules/@types/minimatch": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
||||
@@ -27499,6 +27524,17 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/reanimated-bottom-sheet": {
|
||||
"version": "1.0.0-alpha.22",
|
||||
"resolved": "https://registry.npmjs.org/reanimated-bottom-sheet/-/reanimated-bottom-sheet-1.0.0-alpha.22.tgz",
|
||||
"integrity": "sha512-NxecCn+2iA4YzkFuRK5/b86GHHS2OhZ9VRgiM4q18AC20YE/psRilqxzXCKBEvkOjP5AaAvY0yfE7EkEFBjTvw==",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-gesture-handler": "*",
|
||||
"react-native-reanimated": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/recast": {
|
||||
"version": "0.20.4",
|
||||
"resolved": "https://registry.npmjs.org/recast/-/recast-0.20.4.tgz",
|
||||
@@ -38739,6 +38775,22 @@
|
||||
"@types/responselike": "*"
|
||||
}
|
||||
},
|
||||
"@types/commonmark": {
|
||||
"version": "0.27.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/commonmark/-/commonmark-0.27.5.tgz",
|
||||
"integrity": "sha512-vIqgmHyLsc8Or3EWLz6QkhI8/v61FNeH0yxRupA7VqSbA2eFMoHHJAhZSHudplAV89wqg1CKSmShE016ziRXuw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/commonmark-react-renderer": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/commonmark-react-renderer/-/commonmark-react-renderer-4.3.1.tgz",
|
||||
"integrity": "sha512-FAP10ymnRsmJUZatePP/xgZiHDpht6RB3fkDnV36bgikOa7OuhKaWTz4jXCJ4x8/ND6ZJRmb5UZagJrXsc25Gg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/commonmark": "*",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/debug": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.6.tgz",
|
||||
@@ -38865,6 +38917,11 @@
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"@types/mime-db": {
|
||||
"version": "1.43.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-db/-/mime-db-1.43.1.tgz",
|
||||
"integrity": "sha512-kGZJY+R+WnR5Rk+RPHUMERtb2qBRViIHCBdtUrY+NmwuGb8pQdfTqQiCKPrxpdoycl8KWm2DLdkpoSdt479XoQ=="
|
||||
},
|
||||
"@types/minimatch": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
||||
@@ -54612,6 +54669,12 @@
|
||||
"readable-stream": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"reanimated-bottom-sheet": {
|
||||
"version": "1.0.0-alpha.22",
|
||||
"resolved": "https://registry.npmjs.org/reanimated-bottom-sheet/-/reanimated-bottom-sheet-1.0.0-alpha.22.tgz",
|
||||
"integrity": "sha512-NxecCn+2iA4YzkFuRK5/b86GHHS2OhZ9VRgiM4q18AC20YE/psRilqxzXCKBEvkOjP5AaAvY0yfE7EkEFBjTvw==",
|
||||
"requires": {}
|
||||
},
|
||||
"recast": {
|
||||
"version": "0.20.4",
|
||||
"resolved": "https://registry.npmjs.org/recast/-/recast-0.20.4.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@react-native-cookies/cookies": "6.0.8",
|
||||
"@rudderstack/rudder-sdk-react-native": "1.0.12",
|
||||
"@sentry/react-native": "2.6.1",
|
||||
"@types/mime-db": "1.43.1",
|
||||
"commonmark": "0.30.0",
|
||||
"commonmark-react-renderer": "4.3.5",
|
||||
"deep-equal": "2.0.5",
|
||||
@@ -72,6 +73,7 @@
|
||||
"react-native-video": "5.1.1",
|
||||
"react-native-webview": "11.6.5",
|
||||
"react-native-youtube": "2.0.2",
|
||||
"reanimated-bottom-sheet": "1.0.0-alpha.22",
|
||||
"rn-placeholder": "3.0.3",
|
||||
"semver": "7.3.5",
|
||||
"serialize-error": "8.1.0",
|
||||
@@ -91,6 +93,8 @@
|
||||
"@babel/register": "7.14.5",
|
||||
"@react-native-community/eslint-config": "3.0.0",
|
||||
"@testing-library/react-native": "7.2.0",
|
||||
"@types/commonmark": "0.27.5",
|
||||
"@types/commonmark-react-renderer": "4.3.1",
|
||||
"@types/jest": "26.0.24",
|
||||
"@types/react": "17.0.15",
|
||||
"@types/react-intl": "3.0.0",
|
||||
|
||||
33
patches/@types+commonmark+0.27.5.patch
Normal file
33
patches/@types+commonmark+0.27.5.patch
Normal file
@@ -0,0 +1,33 @@
|
||||
diff --git a/node_modules/@types/commonmark/index.d.ts b/node_modules/@types/commonmark/index.d.ts
|
||||
index 35e9ed6..382cf98 100755
|
||||
--- a/node_modules/@types/commonmark/index.d.ts
|
||||
+++ b/node_modules/@types/commonmark/index.d.ts
|
||||
@@ -5,8 +5,8 @@
|
||||
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
|
||||
|
||||
export type NodeType =
|
||||
- 'text' |'softbreak' | 'linebreak' | 'emph' | 'strong' | 'html_inline' | 'link' | 'image' | 'code' | 'document' | 'paragraph' |
|
||||
- 'block_quote' | 'item' | 'list' | 'heading' | 'code_block' | 'html_block' | 'thematic_break' | 'custom_inline' | 'custom_block';
|
||||
+ 'text' |'softbreak' | 'linebreak' | 'emph' | 'strong' | 'html_inline' | 'link' | 'image' | 'code' | 'document' | 'paragraph' | 'mention_highlight' | 'at_mention' |
|
||||
+ 'block_quote' | 'item' | 'list' | 'heading' | 'code_block' | 'html_block' | 'thematic_break' | 'custom_inline' | 'custom_block' | 'table' | 'edited_indicator';
|
||||
|
||||
export class Node {
|
||||
constructor(nodeType: NodeType, sourcepos?: Position);
|
||||
@@ -125,6 +125,8 @@ export class Node {
|
||||
* https://github.com/jgm/commonmark.js/issues/74
|
||||
*/
|
||||
_listData: ListData;
|
||||
+
|
||||
+ mentionName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,6 +202,8 @@ export interface ParserOptions {
|
||||
*/
|
||||
smart?: boolean | undefined;
|
||||
time?: boolean | undefined;
|
||||
+ urlFilter?: (url: string) => boolean;
|
||||
+ minimumHashtagLength?: number;
|
||||
}
|
||||
|
||||
export interface HtmlRenderingOptions extends XmlRenderingOptions {
|
||||
21
patches/@types+commonmark-react-renderer+4.3.1.patch
Normal file
21
patches/@types+commonmark-react-renderer+4.3.1.patch
Normal file
@@ -0,0 +1,21 @@
|
||||
diff --git a/node_modules/@types/commonmark-react-renderer/index.d.ts b/node_modules/@types/commonmark-react-renderer/index.d.ts
|
||||
index 9ee5664..44d9a20 100755
|
||||
--- a/node_modules/@types/commonmark-react-renderer/index.d.ts
|
||||
+++ b/node_modules/@types/commonmark-react-renderer/index.d.ts
|
||||
@@ -88,6 +88,8 @@ declare namespace ReactRenderer {
|
||||
transformLinkUri?: ((uri: string) => string) | null | undefined;
|
||||
transformImageUri?: ((uri: string) => string) | null | undefined;
|
||||
linkTarget?: string | undefined;
|
||||
+ renderParagraphsInLists?: boolean;
|
||||
+ getExtraPropsForNode?: (node: any) => Record<string, any>;
|
||||
}
|
||||
|
||||
interface Renderer {
|
||||
@@ -113,6 +115,7 @@ interface ReactRenderer {
|
||||
uriTransformer: (uri: string) => string;
|
||||
types: string[];
|
||||
renderers: ReactRenderer.Renderers;
|
||||
+ forwardChildren: (props: any) => any;
|
||||
}
|
||||
|
||||
declare const ReactRenderer: ReactRenderer;
|
||||
@@ -1,66 +1,3 @@
|
||||
diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageCookieJar.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageCookieJar.java
|
||||
new file mode 100644
|
||||
index 0000000..a302394
|
||||
--- /dev/null
|
||||
+++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageCookieJar.java
|
||||
@@ -0,0 +1,45 @@
|
||||
+package com.dylanvann.fastimage;
|
||||
+
|
||||
+import android.webkit.CookieManager;
|
||||
+
|
||||
+import java.util.Collections;
|
||||
+import java.util.LinkedList;
|
||||
+import java.util.List;
|
||||
+
|
||||
+import okhttp3.Cookie;
|
||||
+import okhttp3.CookieJar;
|
||||
+import okhttp3.HttpUrl;
|
||||
+
|
||||
+public class FastImageCookieJar implements CookieJar {
|
||||
+ private CookieManager cookieManager;
|
||||
+
|
||||
+ private CookieManager getCookieManager() {
|
||||
+ if (cookieManager == null) {
|
||||
+ cookieManager = CookieManager.getInstance();
|
||||
+ }
|
||||
+
|
||||
+ return cookieManager;
|
||||
+ }
|
||||
+
|
||||
+ @Override
|
||||
+ public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
|
||||
+ // Do nothing
|
||||
+ }
|
||||
+
|
||||
+ @Override
|
||||
+ public List<Cookie> loadForRequest(HttpUrl url) {
|
||||
+ String cookie = getCookieManager().getCookie(url.toString());
|
||||
+
|
||||
+ if (cookie == null || cookie.isEmpty()) {
|
||||
+ return Collections.emptyList();
|
||||
+ }
|
||||
+
|
||||
+ String[] pairs = cookie.split(";");
|
||||
+ List<Cookie> cookies = new LinkedList<Cookie>();
|
||||
+ for (String pair : pairs) {
|
||||
+ cookies.add(Cookie.parse(url, pair));
|
||||
+ }
|
||||
+
|
||||
+ return cookies;
|
||||
+ }
|
||||
+}
|
||||
diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java
|
||||
index e659a61..1bdf34e 100644
|
||||
--- a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java
|
||||
+++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java
|
||||
@@ -43,6 +43,7 @@ public class FastImageOkHttpProgressGlideModule extends LibraryGlideModule {
|
||||
OkHttpClient client = OkHttpClientProvider
|
||||
.getOkHttpClient()
|
||||
.newBuilder()
|
||||
+ .cookieJar(new FastImageCookieJar())
|
||||
.addInterceptor(createInterceptor(progressListener))
|
||||
.build();
|
||||
OkHttpUrlLoader.Factory factory = new OkHttpUrlLoader.Factory(client);
|
||||
diff --git a/node_modules/react-native-fast-image/dist/index.d.ts b/node_modules/react-native-fast-image/dist/index.d.ts
|
||||
index 0b1afd5..2226715 100644
|
||||
--- a/node_modules/react-native-fast-image/dist/index.d.ts
|
||||
|
||||
5
types/api/config.d.ts
vendored
5
types/api/config.d.ts
vendored
@@ -75,6 +75,7 @@ interface ClientConfig {
|
||||
EnablePreviewFeatures: string;
|
||||
EnablePreviewModeBanner: string;
|
||||
EnablePublicLink: string;
|
||||
EnableSVGs: string;
|
||||
EnableSaml: string;
|
||||
EnableSignInWithEmail: string;
|
||||
EnableSignInWithUsername: string;
|
||||
@@ -83,7 +84,6 @@ interface ClientConfig {
|
||||
EnableSignUpWithGoogle: string;
|
||||
EnableSignUpWithOffice365: string;
|
||||
EnableSignUpWithOpenId: string;
|
||||
EnableSVGs: string;
|
||||
EnableTesting: string;
|
||||
EnableThemeSelection: string;
|
||||
EnableTutorial: string;
|
||||
@@ -104,6 +104,7 @@ interface ClientConfig {
|
||||
ExperimentalEnablePostMetadata: string;
|
||||
ExperimentalGroupUnreadChannels: string;
|
||||
ExperimentalHideTownSquareinLHS: string;
|
||||
ExperimentalNormalizeMarkdownLinks: string;
|
||||
ExperimentalPrimaryTeam: string;
|
||||
ExperimentalTimezone: string;
|
||||
ExperimentalTownSquareIsReadOnly: string;
|
||||
@@ -144,6 +145,7 @@ interface ClientConfig {
|
||||
RequireEmailVerification: string;
|
||||
RestrictDirectMessage: string;
|
||||
RunJobs: string;
|
||||
SQLDriverName: string;
|
||||
SamlFirstNameAttributeSet: string;
|
||||
SamlLastNameAttributeSet: string;
|
||||
SamlLoginButtonBorderColor: string;
|
||||
@@ -158,7 +160,6 @@ interface ClientConfig {
|
||||
ShowFullName: string;
|
||||
SiteName: string;
|
||||
SiteURL: string;
|
||||
SQLDriverName: string;
|
||||
SupportEmail: string;
|
||||
TeammateNameDisplay: string;
|
||||
TermsOfServiceLink: string;
|
||||
|
||||
1
types/database/database.d.ts
vendored
1
types/database/database.d.ts
vendored
@@ -177,6 +177,7 @@ export type HandleTOSArgs = PrepareOnly & {
|
||||
}
|
||||
|
||||
export type HandleMyChannelArgs = PrepareOnly & {
|
||||
channels: Channel[];
|
||||
myChannels: ChannelMembership[];
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ export default class MyChannelModel extends Model {
|
||||
/** last_viewed_at : The timestamp showing the user's last viewed post on this channel */
|
||||
lastViewedAt: number;
|
||||
|
||||
/** manually_unread : Determine if the user marked a post as unread */
|
||||
manuallyUnread: boolean;
|
||||
|
||||
/** mentions_count : The number of mentions on this channel */
|
||||
mentionsCount: number;
|
||||
|
||||
|
||||
12
types/database/models/servers/team.d.ts
vendored
12
types/database/models/servers/team.d.ts
vendored
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Relation} from '@nozbe/watermelondb';
|
||||
import {Query, Relation} from '@nozbe/watermelondb';
|
||||
import Model, {Associations} from '@nozbe/watermelondb/Model';
|
||||
|
||||
/**
|
||||
@@ -42,23 +42,23 @@ export default class TeamModel extends Model {
|
||||
allowedDomains: string;
|
||||
|
||||
/** channels : All the channels associated with this team */
|
||||
channels: ChannelModel[];
|
||||
channels: Query<ChannelModel>;
|
||||
|
||||
/** groupsInTeam : All the groups associated with this team */
|
||||
groupsInTeam: GroupsInTeamModel[];
|
||||
groupsInTeam: Query<GroupsInTeamModel>;
|
||||
|
||||
/** myTeam : Retrieves additional information about the team that this user is possibly part of. This query might yield no result if the user isn't part of a team. */
|
||||
myTeam: Relation<MyTeamModel>;
|
||||
|
||||
/** slashCommands : All the slash commands associated with this team */
|
||||
slashCommands: SlashCommandModel[];
|
||||
slashCommands: Query<SlashCommandModel>;
|
||||
|
||||
/** teamChannelHistory : A history of the channels in this team that has been visited, ordered by the most recent and capped to the last 5 */
|
||||
teamChannelHistory: Relation<TeamChannelHistoryModel>;
|
||||
|
||||
/** members : All the users associated with this team */
|
||||
members: TeamMembershipModel[];
|
||||
members: Query<TeamMembershipModel>;
|
||||
|
||||
/** teamSearchHistories : All the searches performed on this team */
|
||||
teamSearchHistories: TeamSearchHistoryModel[];
|
||||
teamSearchHistories: Query<TeamSearchHistoryModel>;
|
||||
}
|
||||
|
||||
7
types/global/markdown.d.ts
vendored
Normal file
7
types/global/markdown.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
type UserMentionKey= {
|
||||
key: string;
|
||||
caseSensitive?: boolean;
|
||||
};
|
||||
7
types/screens/gallery.d.ts
vendored
7
types/screens/gallery.d.ts
vendored
@@ -2,10 +2,11 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {intlShape} from 'react-intl';
|
||||
import {StyleProp, ViewStyle} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import type {IntlShape} from 'react-intl';
|
||||
|
||||
export interface CallbackFunctionWithoutArguments {
|
||||
(): void;
|
||||
}
|
||||
@@ -45,7 +46,7 @@ export interface PrepareFileRef {
|
||||
}
|
||||
|
||||
export interface FooterProps {
|
||||
intl: typeof intlShape;
|
||||
intl: IntlShape;
|
||||
file: FileInfo;
|
||||
}
|
||||
|
||||
@@ -71,7 +72,7 @@ export interface GalleryItemProps {
|
||||
file: FileInfo;
|
||||
deviceHeight: number;
|
||||
deviceWidth: number;
|
||||
intl?: typeof intlShape;
|
||||
intl?: IntlShape;
|
||||
isActive?: boolean;
|
||||
onDoubleTap?: CallbackFunctionWithoutArguments;
|
||||
style?: StyleProp<Animated.AnimateStyle>;
|
||||
|
||||
Reference in New Issue
Block a user