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:
Avinash Lingaloo
2021-08-02 20:30:17 +04:00
committed by GitHub
parent 8d2bd32897
commit e8ce78f39d
91 changed files with 5701 additions and 224 deletions

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -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 = {}) => {

View File

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

View File

@@ -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})}`,

View 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)));

View 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;

View 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)));

View 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)));

View 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;

View 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;

View 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;

View 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;

View 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;

View 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)));

View 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;

View 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;

View 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);

View 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;

View 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);

View 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;

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

View 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;

View 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;

View 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;

View 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;

View File

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

View File

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

View 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',
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
});
},
},
],
);
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View 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;

View File

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

View 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![welcome image](http://www.microlife-dns.com/welcome-paper-poster-with-colorful-brush-strokes-vector-21849225.jpeg)\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:"
}

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

198
app/utils/emoji/helpers.ts Normal file
View 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)(:[`']-?\(|:&#x27;\(|:&#39;\()(?=$|\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|&lt;3)(?=$|\s)/g, // <3
broken_heart: /(^|\s)(<\/3|&lt;&#x2F;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

File diff suppressed because one or more lines are too long

View File

@@ -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
View 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
View 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]);
}

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

View File

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

View File

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

View File

@@ -44,6 +44,6 @@ describe('Prevent double tap', () => {
test();
expect(testFunction).toHaveBeenCalledTimes(2);
done();
}, 300);
}, 750);
});
});

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -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 */,

View File

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

View 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

View 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
View File

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

View File

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

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

View 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;

View File

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

View File

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

View File

@@ -177,6 +177,7 @@ export type HandleTOSArgs = PrepareOnly & {
}
export type HandleMyChannelArgs = PrepareOnly & {
channels: Channel[];
myChannels: ChannelMembership[];
};

View File

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

View File

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

View File

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