[Gekidou] post component tree and partial postList (#5637)

This commit is contained in:
Elias Nahum 2021-09-06 08:22:55 -03:00 committed by GitHub
parent 0139773c31
commit 5700ce7c86
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
186 changed files with 14905 additions and 650 deletions

133
app/actions/local/post.ts Normal file
View file

@ -0,0 +1,133 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {postActionWithCookie} from '@actions/remote/post';
import {ActionType, Post} from '@constants';
import DatabaseManager from '@database/manager';
import {queryPostById} from '@queries/servers/post';
import {queryCurrentUserId} from '@queries/servers/system';
import {generateId} from '@utils/general';
import type UserModel from '@typings/database/models/servers/user';
import type PostModel from '@typings/database/models/servers/post';
export const sendAddToChannelEphemeralPost = async (serverUrl: string, user: UserModel, addedUsernames: string[], messages: string[], channeId: string, postRootId = '') => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const timestamp = Date.now();
const posts = addedUsernames.map((addedUsername, index) => {
const message = messages[index];
return {
id: generateId(),
user_id: user.id,
channel_id: channeId,
message,
type: Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL as PostType,
create_at: timestamp,
edit_at: 0,
update_at: timestamp,
delete_at: 0,
is_pinned: false,
original_id: '',
hashtags: '',
pending_post_id: '',
reply_count: 0,
metadata: {},
participants: null,
root_id: postRootId,
props: {
username: user.username,
addedUsername,
},
} as Post;
});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: posts.map((p) => p.id),
posts,
});
return {posts};
};
export const sendEphemeralPost = async (serverUrl: string, message: string, channeId: string, rootId = '', userId?: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
if (!channeId) {
return {error: 'channel Id not defined'};
}
let authorId = userId;
if (!authorId) {
authorId = await queryCurrentUserId(operator.database);
}
const timestamp = Date.now();
const post = {
id: generateId(),
user_id: authorId,
channel_id: channeId,
message,
type: Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL as PostType,
create_at: timestamp,
edit_at: 0,
update_at: timestamp,
delete_at: 0,
is_pinned: false,
original_id: '',
hashtags: '',
pending_post_id: '',
reply_count: 0,
metadata: {},
participants: null,
root_id: rootId,
props: {},
} as Post;
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [post.id],
posts: [post],
});
return {post};
};
export const removePost = async (serverUrl: string, post: PostModel) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
if (post.type === Post.POST_TYPES.COMBINED_USER_ACTIVITY && post.props?.system_post_ids) {
const systemPostIds = post.props.system_post_ids as string[];
for await (const id of systemPostIds) {
const postModel = await queryPostById(operator.database, id);
if (postModel) {
await operator.database.write(async () => {
await postModel.destroyPermanently();
});
}
}
} else {
const postModel = await queryPostById(operator.database, post.id);
if (postModel) {
await operator.database.write(async () => {
await postModel.destroyPermanently();
});
}
}
return {post};
};
export const selectAttachmentMenuAction = (serverUrl: string, postId: string, actionId: string, selectedOption: string) => {
return postActionWithCookie(serverUrl, postId, actionId, '', selectedOption);
};

164
app/actions/remote/apps.ts Normal file
View file

@ -0,0 +1,164 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IntlShape} from 'react-intl';
import {sendEphemeralPost} from '@actions/local/post';
import CompassIcon from '@components/compass_icon';
import {Screens} from '@constants';
import {AppCallResponseTypes, AppCallTypes} from '@constants/apps';
import NetworkManager from '@init/network_manager';
import {showModal} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {makeCallErrorResponse} from '@utils/apps';
import type {Client} from '@client/rest';
import type PostModel from '@typings/database/models/servers/post';
export async function doAppCall<Res=unknown>(serverUrl: string, call: AppCallRequest, type: AppCallType, intl: IntlShape, theme: Theme) {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const res = await client.executeAppCall(call, type) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
case AppCallResponseTypes.OK:
return {data: res};
case AppCallResponseTypes.ERROR:
return {error: res};
case AppCallResponseTypes.FORM: {
if (!res.form) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.form.no_form',
defaultMessage: 'Response type is `form`, but no form was included in the response.',
});
return {error: makeCallErrorResponse(errMsg)};
}
const screen = EphemeralStore.getNavigationTopComponentId();
if (type === AppCallTypes.SUBMIT && screen !== Screens.APP_FORM) {
showAppForm(res.form, call, theme);
}
return {data: res};
}
case AppCallResponseTypes.NAVIGATE:
if (!res.navigate_to_url) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.navigate.no_url',
defaultMessage: 'Response type is `navigate`, but no url was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
}
if (type !== AppCallTypes.SUBMIT) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.navigate.no_submit',
defaultMessage: 'Response type is `navigate`, but the call was not a submission.',
});
return {error: makeCallErrorResponse(errMsg)};
}
// TODO: Add functionality to handle this
// handleGotoLocation(res.navigate_to_url, intl);
return {data: res};
default: {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: responseType,
});
return {error: makeCallErrorResponse(errMsg)};
}
}
} catch (error) {
const errMsg = error.message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
return {error: makeCallErrorResponse(errMsg)};
}
}
export function postEphemeralCallResponseForPost(serverUrl: string, response: AppCallResponse, message: string, post: PostModel) {
return sendEphemeralPost(
serverUrl,
message,
post.channelId,
post.rootId,
response.app_metadata?.bot_user_id,
);
}
export function postEphemeralCallResponseForChannel(serverUrl: string, response: AppCallResponse, message: string, channelID: string) {
return sendEphemeralPost(
serverUrl,
message,
channelID,
'',
response.app_metadata?.bot_user_id,
);
}
export function postEphemeralCallResponseForContext(serverUrl: string, response: AppCallResponse, message: string, context: AppContext) {
return sendEphemeralPost(
serverUrl,
message,
context.channel_id!,
context.root_id || context.post_id,
response.app_metadata?.bot_user_id,
);
}
export function postEphemeralCallResponseForCommandArgs(serverUrl: string, response: AppCallResponse, message: string, args: CommandArgs) {
return sendEphemeralPost(
serverUrl,
message,
args.channel_id,
args.root_id,
response.app_metadata?.bot_user_id,
);
}
const showAppForm = async (form: AppForm, call: AppCallRequest, theme: Theme) => {
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
let submitButtons = [{
id: 'submit-form',
showAsAction: 'always',
text: 'Submit',
}];
if (form.submit_buttons) {
const options = form.fields.find((f) => f.name === form.submit_buttons)?.options;
const newButtons = options?.map((o) => {
return {
id: 'submit-form_' + o.value,
showAsAction: 'always',
text: o.label,
};
});
if (newButtons && newButtons.length > 0) {
submitButtons = newButtons;
}
}
const options = {
topBar: {
leftButtons: [{
id: 'close-dialog',
icon: closeButton,
}],
rightButtons: submitButtons,
},
};
const passProps = {form, call};
showModal(Screens.APP_FORM, form.title || '', passProps, options);
};

View file

@ -7,11 +7,13 @@ import NetworkManager from '@init/network_manager';
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';
import {fetchProfilesPerChannels, fetchUsersByIds} from './user';
import type {Model} from '@nozbe/watermelondb';
import type {Client} from '@client/rest';
export type MyChannelsRequest = {
channels?: Channel[];
@ -19,8 +21,39 @@ export type MyChannelsRequest = {
error?: never;
}
export const addMembersToChannel = async (serverUrl: string, channelId: string, userIds: string[], postRootId = '', fetchOnly = false) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const promises = userIds.map((id) => client.addToChannel(id, channelId, postRootId));
const channelMemberships: ChannelMembership[] = await Promise.all(promises);
await fetchUsersByIds(serverUrl, userIds, false);
if (!fetchOnly) {
await operator.handleChannelMembership({
channelMemberships,
prepareRecordsOnly: false,
});
}
return {channelMemberships};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const fetchChannelByName = async (serverUrl: string, teamId: string, channelName: string, fetchOnly = false) => {
let client;
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
@ -45,7 +78,7 @@ export const fetchChannelByName = async (serverUrl: string, teamId: string, chan
};
export const fetchMyChannelsForTeam = async (serverUrl: string, teamId: string, includeDeleted = true, since = 0, fetchOnly = false, excludeDirect = false): Promise<MyChannelsRequest> => {
let client;
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
@ -140,7 +173,7 @@ export const joinChannel = async (serverUrl: string, userId: string, teamId: str
return {error: `${serverUrl} database not found`};
}
let client;
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {

View file

@ -1,10 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import NetworkManager from '@init/network_manager';
import type {ClientResponse} from '@mattermost/react-native-network-client';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {queryExpandedLinks} from '@queries/servers/system';
import {forceLogoutIfNecessary} from './session';
import type {Client} from '@client/rest';
export const doPing = async (serverUrl: string) => {
const client = await NetworkManager.createClient(serverUrl);
@ -41,3 +48,37 @@ export const doPing = async (serverUrl: string) => {
return {error: undefined};
};
export const getRedirectLocation = async (serverUrl: string, link: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const expandedLink = await client.getRedirectLocation(link);
if (expandedLink?.location) {
const storedLinks = await queryExpandedLinks(operator.database);
storedLinks[link] = expandedLink.location;
const expanded: IdValue = {
id: SYSTEM_IDENTIFIERS.EXPANDED_LINKS,
value: JSON.stringify(storedLinks),
};
await operator.handleSystem({
systems: [expanded],
prepareRecordsOnly: false,
});
}
return {expandedLink};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {ActionType, General} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getNeededAtMentionedUsernames} from '@helpers/api/user';
import NetworkManager from '@init/network_manager';
@ -9,10 +10,10 @@ import {queryRecentPostsInChannel} from '@queries/servers/post';
import {queryCurrentUserId, queryCurrentChannelId} from '@queries/servers/system';
import {queryAllUsers} from '@queries/servers/user';
import type {Client} from '@client/rest';
import {forceLogoutIfNecessary} from './session';
import type {Client} from '@client/rest';
type PostsRequest = {
error?: never;
order?: string[];
@ -186,9 +187,9 @@ export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOn
const authors = result.flat();
if (!fetchOnly && authors.length) {
operator.handleUsers({
await operator.handleUsers({
users: authors,
prepareRecordsOnly: true,
prepareRecordsOnly: false,
});
}
@ -202,6 +203,38 @@ export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOn
}
};
export const postActionWithCookie = async (serverUrl: string, postId: string, actionId: string, actionCookie: string, selectedOption = '') => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const data = await client.doPostActionWithCookie(postId, actionId, actionCookie, selectedOption);
if (data?.trigger_id) {
await operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.INTEGRATION_TRIGGER_ID,
value: data.trigger_id,
}],
prepareRecordsOnly: false,
});
}
return {data};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
const processPostsFetched = (serverUrl: string, actionType: string, data: {order: string[]; posts: Post[]; prev_post_id?: string}, fetchOnly = false) => {
const order = data.order;
const posts = Object.values(data.posts) as Post[];

View file

@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {queryCurrentUserId} from '@queries/servers/system';
import {forceLogoutIfNecessary} from './session';
import type {Client} from '@client/rest';
import type SystemModel from '@typings/database/models/servers/system';
export const addReaction = async (serverUrl: string, postId: string, emojiName: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const currentUserId = await queryCurrentUserId(operator.database);
const reaction = await client.addReaction(currentUserId, postId, emojiName);
await operator.handleReactions({
postsReactions: [{
post_id: postId,
reactions: [reaction],
}],
prepareRecordsOnly: false,
});
addRecentReaction(serverUrl, emojiName);
return {reaction};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const removeReaction = async (serverUrl: string, postId: string, emojiName: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const currentUserId = await queryCurrentUserId(database);
await client.removeReaction(currentUserId, postId, emojiName);
// should return one or no reaction
const reaction = await database.get(MM_TABLES.SERVER.REACTION).query(
Q.where('emoji_name', emojiName),
Q.where('post_id', postId),
Q.where('user_id', currentUserId),
).fetch();
if (reaction.length) {
await database.write(async () => {
await reaction[0].destroyPermanently();
});
}
return {reaction};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const addRecentReaction = async (serverUrl: string, emojiName: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const recent = [];
try {
const emojis = await operator.database.get(MM_TABLES.SERVER.SYSTEM).find(SYSTEM_IDENTIFIERS.RECENT_REACTIONS) as SystemModel;
recent.push(...emojis.value);
} catch {
// no previous values.. continue
}
try {
recent.unshift(emojiName);
await operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.RECENT_REACTIONS,
value: JSON.stringify(recent),
}],
prepareRecordsOnly: false,
});
return {error: undefined};
} catch (error) {
return {error};
}
};

View file

@ -1,12 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import deepEqual from 'deep-equal';
import {logError} from '@actions/remote/error';
import {forceLogoutIfNecessary} from '@actions/remote/session';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getServerCredentials} from '@init/credentials';
import NetworkManager from '@init/network_manager';
import {queryCommonSystemValues} from '@queries/servers/system';
export type ConfigAndLicenseRequest = {
config?: ClientConfig;
@ -68,19 +71,29 @@ export const fetchConfigAndLicense = async (serverUrl: string, fetchOnly = false
const credentials = await getServerCredentials(serverUrl);
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (credentials && operator) {
const systems: IdValue[] = [{
id: SYSTEM_IDENTIFIERS.CONFIG,
value: JSON.stringify(config),
}, {
id: SYSTEM_IDENTIFIERS.LICENSE,
value: JSON.stringify(license),
}];
operator.handleSystem({systems, prepareRecordsOnly: false}).
catch((error) => {
// eslint-disable-next-line no-console
console.log('An error ocurred while saving config & license', error);
const current = await queryCommonSystemValues(operator.database);
const systems: IdValue[] = [];
if (!deepEqual(config, current.config)) {
systems.push({
id: SYSTEM_IDENTIFIERS.CONFIG,
value: JSON.stringify(config),
});
}
if (!deepEqual(license, current.license)) {
systems.push({
id: SYSTEM_IDENTIFIERS.LICENSE,
value: JSON.stringify(license),
});
}
if (systems.length) {
operator.handleSystem({systems, prepareRecordsOnly: false}).
catch((error) => {
// eslint-disable-next-line no-console
console.log('An error ocurred while saving config & license', error);
});
}
}
}

View file

@ -1,13 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {logError} from '@actions/remote/error';
import {Q} from '@nozbe/watermelondb';
import {fetchRolesIfNeeded} from '@actions/remote/role';
import {Database} from '@constants';
import {Database, General} from '@constants';
import DatabaseManager from '@database/manager';
import {debounce} from '@helpers/api/general';
import analytics from '@init/analytics';
import NetworkManager from '@init/network_manager';
import {prepareUsers} from '@queries/servers/user';
import {prepareUsers, queryCurrentUser, queryUsersById, queryUsersByUsername} from '@queries/servers/user';
import {queryCurrentUserId} from '@queries/servers/system';
import type {Client} from '@client/rest';
import type {LoadMeArgs} from '@typings/database/database';
@ -244,7 +246,7 @@ export const updateMe = async (serverUrl: string, user: UserModel) => {
try {
data = await client.patchMe(user._raw);
} catch (e) {
logError(e);
forceLogoutIfNecessary(serverUrl, e);
return {error: e};
}
@ -259,3 +261,159 @@ export const updateMe = async (serverUrl: string, user: UserModel) => {
return {data};
};
let ids: string[] = [];
const debouncedFetchStatusesByIds = debounce((serverUrl: string) => {
fetchStatusByIds(serverUrl, [...new Set(ids)]);
}, 200, false, () => {
ids = [];
});
export const fetchStatusInBatch = (serverUrl: string, id: string) => {
ids = [...ids, id];
return debouncedFetchStatusesByIds.apply(null, [serverUrl]);
};
export const fetchStatusByIds = async (serverUrl: string, userIds: string[], fetchOnly = false) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
if (!userIds.length) {
return {statuses: []};
}
try {
const statuses = await client.getStatusesByIds(userIds);
if (!fetchOnly && DatabaseManager.serverDatabases[serverUrl]) {
const {database, operator} = DatabaseManager.serverDatabases[serverUrl];
if (operator) {
const users = await database.get(Database.MM_TABLES.SERVER.USER).query(Q.where('id', Q.oneOf(userIds))).fetch() as UserModel[];
for (const user of users) {
const status = statuses.find((s) => s.user_id === user.id);
user.prepareSatus(status?.status || General.OFFLINE);
}
await operator.batchRecords(users);
}
}
return {statuses};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const fetchUsersByIds = async (serverUrl: string, userIds: string[], fetchOnly = false) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
if (!userIds.length) {
return {users: []};
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const currentUserId = await queryCurrentUserId(operator.database);
const exisingUsers = await queryUsersById(operator.database, userIds);
const usersToLoad = userIds.filter((id) => (id !== currentUserId && !exisingUsers.find((u) => u.id === id)));
const users = await client.getProfilesByIds([...new Set(usersToLoad)]);
if (!fetchOnly) {
await operator.handleUsers({
users,
prepareRecordsOnly: false,
});
}
return {users};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const fetchUsersByUsernames = async (serverUrl: string, usernames: string[], fetchOnly = false) => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
if (!usernames.length) {
return {users: []};
}
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const currentUser = await queryCurrentUser(operator.database);
const exisingUsers = await queryUsersByUsername(operator.database, usernames);
const usersToLoad = usernames.filter((username) => (username !== currentUser?.username && !exisingUsers.find((u) => u.username === username)));
const users = await client.getProfilesByUsernames([...new Set(usersToLoad)]);
if (!fetchOnly) {
await operator.handleUsers({
users,
prepareRecordsOnly: false,
});
}
return {users};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const fetchMissinProfilesByIds = async (serverUrl: string, userIds: string[]) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const {users} = await fetchUsersByIds(serverUrl, userIds);
if (users) {
const statusToLoad = users.map((u) => u.id);
fetchStatusByIds(serverUrl, statusToLoad);
}
return {users};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const fetchMissinProfilesByUsernames = async (serverUrl: string, usernames: string[]) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const {users} = await fetchUsersByUsernames(serverUrl, usernames);
if (users) {
const statusToLoad = users.map((u) => u.id);
fetchStatusByIds(serverUrl, statusToLoad);
}
return {users};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};

View file

@ -22,7 +22,7 @@ export interface ClientPostsMix {
markPostAsUnread: (userId: string, postId: string) => Promise<any>;
pinPost: (postId: string) => Promise<any>;
unpinPost: (postId: string) => Promise<any>;
addReaction: (userId: string, postId: string, emojiName: string) => Promise<any>;
addReaction: (userId: string, postId: string, emojiName: string) => Promise<Reaction>;
removeReaction: (userId: string, postId: string, emojiName: string) => Promise<any>;
getReactionsForPost: (postId: string) => Promise<any>;
searchPostsWithParams: (teamId: string, params: any) => Promise<any>;

View file

@ -0,0 +1,258 @@
// 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, {ReactNode, useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import CompasIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Preferences, Screens, View as ViewConstants} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useTheme} from '@context/theme';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {displayUsername} 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';
type AutoCompleteSelectorArgs = {
config: SystemModel;
license: SystemModel;
preferences: PreferenceModel[];
}
type AutoCompleteSelectorProps = {
dataSource?: string;
disabled?: boolean;
errorText?: ReactNode;
getDynamicOptions?: (userInput?: string) => Promise<DialogOption[]>;
helpText?: ReactNode;
label?: string;
onSelected?: (selectedItem?: PostActionOption) => Promise<void>;
optional?: boolean;
options?: PostActionOption[];
placeholder: string;
roundedBorders?: boolean;
selected?: PostActionOption;
showRequiredAsterisk?: boolean;
teammateNameDisplay: string;
}
const {SERVER: {PREFERENCE, SYSTEM}} = MM_TABLES;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const input = {
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.1),
backgroundColor: changeOpacity(theme.centerChannelBg, 0.9),
paddingLeft: 10,
paddingRight: 30,
paddingVertical: 7,
height: 40,
};
return {
container: {
width: '100%',
marginBottom: 2,
marginRight: 8,
marginTop: 10,
},
roundedInput: {
...input,
borderRadius: 5,
},
input,
dropdownPlaceholder: {
top: 3,
marginLeft: 5,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
dropdownSelected: {
top: 3,
marginLeft: 5,
color: theme.centerChannelColor,
},
icon: {
position: 'absolute',
top: 13,
right: 12,
},
labelContainer: {
flexDirection: 'row',
marginTop: 15,
marginBottom: 10,
},
label: {
fontSize: 14,
color: theme.centerChannelColor,
marginLeft: 15,
},
optional: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 14,
marginLeft: 5,
},
helpText: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.5),
marginHorizontal: 15,
marginVertical: 10,
},
errorText: {
fontSize: 12,
color: theme.errorTextColor,
marginHorizontal: 15,
marginVertical: 10,
},
asterisk: {
color: theme.errorTextColor,
fontSize: 14,
},
disabled: {
opacity: 0.5,
},
};
});
const AutoCompleteSelector = ({
dataSource, disabled, errorText, getDynamicOptions, helpText, label, onSelected, optional = false,
options, placeholder, roundedBorders = true, selected, showRequiredAsterisk = false, teammateNameDisplay,
}: AutoCompleteSelectorProps) => {
const intl = useIntl();
const theme = useTheme();
const [itemText, setItemText] = useState(selected?.text);
const style = getStyleSheet(theme);
const title = placeholder || intl.formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'});
const goToSelectorScreen = useCallback(preventDoubleTap(() => {
const screen = Screens.INTEGRATION_SELECTOR;
goToScreen(screen, title, {dataSource, handleSelect, options, getDynamicOptions});
}), [dataSource, options, getDynamicOptions]);
const handleSelect = useCallback((item?: any) => {
if (!item) {
return;
}
let selectedText;
let selectedValue;
if (dataSource === ViewConstants.DATA_SOURCE_USERS) {
selectedText = displayUsername(item, undefined, teammateNameDisplay);
selectedValue = item.id;
} else if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) {
selectedText = item.display_name;
selectedValue = item.id;
} else {
selectedText = item.text;
selectedValue = item.value;
}
setItemText(selectedText);
if (onSelected) {
onSelected({text: selectedText, value: selectedValue});
}
}, []);
let text = title;
let selectedStyle = style.dropdownPlaceholder;
if (itemText) {
text = itemText;
selectedStyle = style.dropdownSelected;
}
let inputStyle = style.input;
if (roundedBorders) {
inputStyle = style.roundedInput;
}
let optionalContent;
let asterisk;
if (optional) {
optionalContent = (
<FormattedText
id='channel_modal.optional'
defaultMessage='(optional)'
style={style.optional}
/>
);
} else if (showRequiredAsterisk) {
asterisk = <Text style={style.asterisk}>{' *'}</Text>;
}
let labelContent;
if (label) {
labelContent = (
<View style={style.labelContainer}>
<Text style={style.label}>
{label}
</Text>
{asterisk}
{optionalContent}
</View>
);
}
let helpTextContent;
if (helpText) {
helpTextContent = <Text style={style.helpText}>{helpText}</Text>;
}
let errorTextContent;
if (errorText) {
errorTextContent = <Text style={style.errorText}>{errorText}</Text>;
}
return (
<View style={style.container}>
{labelContent}
<TouchableWithFeedback
disabled={disabled}
onPress={goToSelectorScreen}
style={disabled ? style.disabled : null}
type='opacity'
>
<View style={inputStyle}>
<Text
numberOfLines={1}
style={selectedStyle}
>
{text}
</Text>
<CompasIcon
name='chevron-down'
color={changeOpacity(theme.centerChannelColor, 0.5)}
style={style.icon}
/>
</View>
</TouchableWithFeedback>
{helpTextContent}
{errorTextContent}
</View>
);
};
const withPreferences = withObservables([], ({database}: WithDatabaseArgs) => ({
config: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(switchMap((cfg: SystemModel) => cfg.value)),
license: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE),
preferences: database.get(PREFERENCE).query(Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS)).observe(),
}));
const withTeammateNameDisplay = withObservables(['preferences', 'config', 'license'], ({config, license, preferences}: AutoCompleteSelectorArgs) => ({
teammateNameDisplay: of$(getTeammateNameDisplaySetting(preferences, config.value, license.value)),
}));
export default withDatabase(withPreferences(withTeammateNameDisplay(AutoCompleteSelector)));

View file

@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/custom_status/clear_button should match snapshot 1`] = `
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"flex": 1,
"height": 40,
"justifyContent": "center",
"opacity": 1,
"width": 40,
}
}
testID="clear_custom_status.button"
>
<Icon
name="close-circle"
size={20}
style={
Object {
"borderRadius": 1000,
"color": "rgba(63,67,80,0.52)",
}
}
/>
</View>
`;

View file

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/custom_status/custom_status_emoji should match snapshot 1`] = `
<Text
testID="custom_status_emoji.calendar"
>
<Text>
</Text>
</Text>
`;
exports[`components/custom_status/custom_status_emoji should match snapshot with props 1`] = `
<Text
testID="custom_status_emoji.calendar"
>
<Text>
</Text>
</Text>
`;

View file

@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/custom_status/custom_status_text should match snapshot 1`] = `
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.5)",
"fontSize": 17,
"includeFontPadding": false,
"textAlignVertical": "center",
},
undefined,
]
}
>
In a meeting
</Text>
`;
exports[`components/custom_status/custom_status_text should match snapshot with empty text 1`] = `
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.5)",
"fontSize": 17,
"includeFontPadding": false,
"textAlignVertical": "center",
},
undefined,
]
}
>
</Text>
`;

View file

@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import ClearButton from '@components/custom_status/clear_button';
import Preferences from '@constants/preferences';
import {fireEvent, render} from '@test/intl-test-helper';
describe('components/custom_status/clear_button', () => {
const baseProps = {
handlePress: jest.fn(),
testID: 'clear_custom_status.button',
theme: Preferences.THEMES.denim,
};
it('should match snapshot', () => {
const wrapper = render(
<ClearButton
{...baseProps}
/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('should call handlePress when press event is fired', () => {
const {getByTestId} = render(
<ClearButton
{...baseProps}
/>,
);
fireEvent.press(getByTestId(baseProps.testID));
expect(baseProps.handlePress).toBeCalled();
});
});

View file

@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {TouchableOpacity} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
interface Props {
handlePress: () => void;
size?: number;
containerSize?: number;
theme: Theme;
testID?: string;
iconName?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
button: {
borderRadius: 1000,
color: changeOpacity(theme.centerChannelColor, 0.52),
},
};
});
const ClearButton = ({handlePress, iconName = 'close-circle', size = 20, containerSize = 40, theme, testID}: Props) => {
const style = getStyleSheet(theme);
return (
<TouchableOpacity
onPress={preventDoubleTap(handlePress)}
style={[style.container, {height: containerSize, width: containerSize}]}
testID={testID}
>
<CompassIcon
name={iconName}
size={size}
style={style.button}
/>
</TouchableOpacity>
);
};
export default ClearButton;

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import Database from '@nozbe/watermelondb/Database';
describe('components/custom_status/custom_status_emoji', () => {
let database: Database | undefined;
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
});
const customStatus: UserCustomStatus = {
emoji: 'calendar',
text: 'In a meeting',
duration: '' as CustomStatusDuration.DONT_CLEAR,
};
it('should match snapshot', () => {
const wrapper = renderWithEverything(
<CustomStatusEmoji customStatus={customStatus}/>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('should match snapshot with props', () => {
const wrapper = renderWithEverything(
<CustomStatusEmoji
customStatus={customStatus}
emojiSize={34}
/>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,35 @@
// 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 Emoji from '@components/emoji';
interface ComponentProps {
customStatus: UserCustomStatus;
emojiSize?: number;
style?: TextStyle;
testID?: string;
}
const CustomStatusEmoji = ({customStatus, emojiSize, style, testID}: ComponentProps) => {
const testIdPrefix = testID ? `${testID}.` : '';
return (
<Text
style={style}
testID={`${testIdPrefix}custom_status_emoji.${customStatus.emoji}`}
>
<Emoji
size={emojiSize}
emojiName={customStatus.emoji!}
/>
</Text>
);
};
CustomStatusEmoji.defaultProps = {
emojiSize: 16,
};
export default CustomStatusEmoji;

View file

@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import CustomStatusText from '@components/custom_status/custom_status_text';
import Preferences from '@constants/preferences';
import {render} from '@test/intl-test-helper';
describe('components/custom_status/custom_status_text', () => {
const baseProps = {
text: 'In a meeting',
theme: Preferences.THEMES.denim,
};
it('should match snapshot', () => {
const wrapper = render(
<CustomStatusText
{...baseProps}
/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('should match snapshot with empty text', () => {
const wrapper = render(
<CustomStatusText
{...baseProps}
text={''}
/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,39 @@
// 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 FormattedText from '@components/formatted_text';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
interface ComponentProps {
text: string | typeof FormattedText;
theme: Theme;
textStyle?: TextStyle;
ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip';
numberOfLines?: number;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
label: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 17,
textAlignVertical: 'center',
includeFontPadding: false,
},
};
});
const CustomStatusText = ({text, theme, textStyle, ellipsizeMode, numberOfLines}: ComponentProps) => (
<Text
style={[getStyleSheet(theme).label, textStyle]}
ellipsizeMode={ellipsizeMode}
numberOfLines={numberOfLines}
>
{text}
</Text>
);
export default CustomStatusText;

View file

@ -14,7 +14,8 @@ import {
View,
} from 'react-native';
import FastImage, {ImageStyle} from 'react-native-fast-image';
import {of} from 'rxjs';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useServerUrl} from '@context/server_url';
@ -117,7 +118,7 @@ const Emoji = (props: Props) => {
return null;
}
return (
<View style={Platform.select({android: {flex: 1}})}>
<View style={Platform.select({ios: {flex: 1, justifyContent: 'center'}})}>
<FastImage
key={key}
source={image}
@ -138,7 +139,7 @@ const Emoji = (props: Props) => {
const key = Platform.OS === 'android' ? (`${imageUrl}-${height}-${width}`) : null;
return (
<View style={Platform.select({android: {flex: 1}})}>
<View style={Platform.select({ios: {flex: 1, justifyContent: 'center'}})}>
<FastImage
key={key}
style={[customEmojiStyle, {width, height}]}
@ -151,15 +152,16 @@ const Emoji = (props: Props) => {
};
const withSystemIds = withObservables([], ({database}: WithDatabaseArgs) => ({
config: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG),
enableCustomEmoji: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((config: SystemModel) => of$(config.value.EnableCustomEmoji)),
),
}));
const withCustomEmojis = withObservables(['config', 'emojiName'], ({config, database, emojiName}: WithDatabaseArgs & {config: SystemModel; emojiName: string}) => {
const cfg: ClientConfig = config.value;
const displayTextOnly = cfg.EnableCustomEmoji !== 'true';
const withCustomEmojis = withObservables(['enableCustomEmoji', 'emojiName'], ({enableCustomEmoji, database, emojiName}: WithDatabaseArgs & {enableCustomEmoji: string; emojiName: string}) => {
const displayTextOnly = enableCustomEmoji !== 'true';
return {
displayTextOnly: of(displayTextOnly),
displayTextOnly: of$(displayTextOnly),
customEmojis: database.get(MM_TABLES.SERVER.CUSTOM_EMOJI).query(Q.where('name', emojiName)).observe(),
};
});

View file

@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React from 'react';
import {Text, TextProps} from 'react-native';
type FormattedDateProps = TextProps & {
format: string;
timezone?: string | UserTimezone | null;
value: number | string | Date;
}
const FormattedDate = ({format, timezone, value, ...props}: FormattedDateProps) => {
let formattedDate = moment(value).format(format);
if (timezone) {
let zone = timezone as string;
if (typeof timezone === 'object') {
zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone;
}
formattedDate = moment.tz(value, zone).format(format);
}
return <Text {...props}>{formattedDate}</Text>;
};
FormattedDate.defaultProps = {
format: 'ddd, MMM DD, YYYY',
};
export default FormattedDate;

View file

@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Parser} from 'commonmark';
import Renderer from 'commonmark-react-renderer';
import React, {ReactElement, useRef} from 'react';
import {useIntl} from 'react-intl';
import {GestureResponderEvent, StyleProp, Text, TextStyle} from 'react-native';
import AtMention from '@components/markdown/at_mention';
import MarkdownLink from '@components/markdown/markdown_link';
import {useTheme} from '@context/theme';
import {getMarkdownTextStyles} from '@utils/markdown';
import {concatStyles, changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme';
import type {PrimitiveType} from 'intl-messageformat';
type Props = {
baseTextStyle: StyleProp<TextStyle>;
defaultMessage: string;
id: string;
onPostPress?: (e: GestureResponderEvent) => void;
style?: StyleProp<TextStyle>;
textStyles: {
[key: string]: TextStyle;
};
values?: Record<string, PrimitiveType>;
};
const TARGET_BLANK_URL_PREFIX = '!';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
message: {
color: changeOpacity(theme.centerChannelColor, 0.8),
fontSize: 15,
lineHeight: 22,
},
atMentionOpacity: {
opacity: 1,
},
};
});
const FormattedMarkdownText = ({baseTextStyle, defaultMessage, id, onPostPress, style, textStyles, values}: Props) => {
const intl = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const messageDescriptor = {id, defaultMessage};
const message = intl.formatMessage(messageDescriptor, values);
const txtStyles = getMarkdownTextStyles(theme);
const createRenderer = () => {
const renderers: any = {
text: renderText,
emph: Renderer.forwardChildren,
strong: Renderer.forwardChildren,
code: renderCodeSpan,
link: renderLink,
hardBreak: renderBreak,
softBreak: renderBreak,
paragraph: renderParagraph,
del: Renderer.forwardChildren,
html_inline: renderHTML,
html_block: renderHTML,
atMention: renderAtMention,
};
return new Renderer({
renderers,
renderParagraphsInLists: true,
});
};
const computeTextStyle = (base: StyleProp<TextStyle>, context: string[]) => {
return concatStyles(base, context.map((type) => txtStyles[type]));
};
const renderAtMention = ({context, mentionName}: {context: string[]; mentionName: string}) => {
return (
<AtMention
mentionStyle={textStyles.mention}
mentionName={mentionName}
onPostPress={onPostPress}
textStyle={[computeTextStyle(baseTextStyle, context), styles.atMentionOpacity]}
/>
);
};
const renderBreak = () => {
return <Text>{'\n'}</Text>;
};
const renderCodeSpan = ({context, literal}: {context: string[]; literal: string}) => {
const computed = computeTextStyle([styles.message, txtStyles.code], context);
return <Text style={computed}>{literal}</Text>;
};
const renderHTML = (props: never) => {
console.warn(`HTML used in FormattedMarkdownText component with id ${id}`); // eslint-disable-line no-console
return renderText(props);
};
const renderLink = ({children, href}: {children: ReactElement; href: string}) => {
const url = href[0] === TARGET_BLANK_URL_PREFIX ? href.substring(1, href.length) : href;
return <MarkdownLink href={url}>{children}</MarkdownLink>;
};
const renderParagraph = ({children}: {children: ReactElement}) => {
return <Text>{children}</Text>;
};
const renderText = ({context, literal}: {context: string[]; literal: string}) => {
const computed = computeTextStyle(style || styles.message, context);
return <Text style={computed}>{literal}</Text>;
};
const parser = useRef(new Parser()).current;
const renderer = useRef(createRenderer()).current;
const ast = parser.parse(message);
return renderer.render(ast) as ReactElement;
};
export default FormattedMarkdownText;

View file

@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React from 'react';
import {Text, TextProps} from 'react-native';
type FormattedTimeProps = TextProps & {
isMilitaryTime: boolean;
timezone: UserTimezone | string;
value: number | string | Date;
}
const FormattedTime = ({isMilitaryTime, timezone, value, ...props}: FormattedTimeProps) => {
const getFormattedTime = () => {
let format = 'H:mm';
if (!isMilitaryTime) {
const localeFormat = moment.localeData().longDateFormat('LT');
format = localeFormat?.includes('A') ? localeFormat : 'h:mm A';
}
let zone = timezone as string;
if (typeof timezone === 'object') {
zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone;
}
return timezone ? moment.tz(value, zone).format(format) : moment(value).format(format);
};
const formattedTime = getFormattedTime();
return (
<Text {...props}>
{formattedTime}
</Text>
);
};
export default FormattedTime;

View file

@ -4,12 +4,12 @@
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 {Platform, StyleProp, 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';
import {blendColors, makeStyleSheetFromTheme} from '@utils/theme';
type JumboEmojiProps = {
baseTextStyle: StyleProp<TextStyle>;
@ -51,17 +51,15 @@ const JumboEmoji = ({baseTextStyle, isEdited, value}: JumboEmojiProps) => {
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}})}
/>
<View>
<Emoji
emojiName={emojiName}
literal={literal}
testID='markdown_emoji'
textStyle={[baseTextStyle, style.jumboEmoji]}
/>
</View>
);
};
@ -125,7 +123,7 @@ const JumboEmoji = ({baseTextStyle, isEdited, value}: JumboEmojiProps) => {
if (isEdited) {
const editIndicatorNode = new Node('edited_indicator');
if (ast.lastChild && ['heading', 'paragraph'].includes(ast.lastChild.type)) {
ast.lastChild.appendChild(editIndicatorNode);
ast.appendChild(editIndicatorNode);
} else {
const node = new Node('paragraph');
node.appendChild(editIndicatorNode);

View file

@ -9,7 +9,7 @@ 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 {of as of$} from 'rxjs';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import {useTheme} from '@context/theme';
@ -29,21 +29,23 @@ import type SystemModel from '@typings/database/models/servers/system';
import type UserModelType from '@typings/database/models/servers/user';
type AtMentionProps = {
currentUserId: SystemModel;
currentUserId: string;
database: Database;
disableAtChannelMentionHighlight?: boolean;
groups: GroupModel[];
isSearchResult?: boolean;
mentionKeys: Array<{key: string }>;
mentionKeys?: Array<{key: string }>;
mentionName: string;
mentionStyle: TextStyle;
myGroups: GroupMembershipModel[];
onPostPress?: (e: GestureResponderEvent) => void;
teammateNameDisplay: string;
textStyle: StyleProp<TextStyle>;
textStyle?: StyleProp<TextStyle>;
users: UserModelType[];
}
type AtMentionArgs = {
currentUserId: SystemModel;
config: SystemModel;
license: SystemModel;
preferences: PreferenceModel[];
@ -61,6 +63,7 @@ const style = StyleSheet.create({
const AtMention = ({
currentUserId,
database,
disableAtChannelMentionHighlight,
groups,
isSearchResult,
mentionName,
@ -96,15 +99,15 @@ const AtMention = ({
return new UserModel(database.get(USER), {username: ''});
}, [users, mentionName]);
const userMentionKeys = useMemo(() => {
if (user.id !== currentUserId.value) {
return [];
}
if (mentionKeys) {
return mentionKeys;
}
if (user.id !== currentUserId) {
return [];
}
return getUserMentionKeys(user, groups, myGroups);
}, [currentUserId.value, groups, mentionKeys, myGroups, user]);
}, [currentUserId, groups, mentionKeys, myGroups, user]);
const getGroupFromMentionName = () => {
const mentionNameTrimmed = mentionName.toLowerCase().replace(/[._-]*$/, '');
@ -210,7 +213,7 @@ const AtMention = ({
const pattern = new RegExp(/\b(all|channel|here)(?:\.\B|_\b|\b)/, 'i');
const mentionMatch = pattern.exec(mentionName);
if (mentionMatch) {
if (mentionMatch && !disableAtChannelMentionHighlight) {
mention = mentionMatch.length > 1 ? mentionMatch[1] : mentionMatch[0];
suffix = mentionName.replace(mention, '');
isMention = true;
@ -264,24 +267,27 @@ const withPreferences = withObservables([], ({database}: WithDatabaseArgs) => ({
preferences: database.get(PREFERENCE).query(Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS)).observe(),
}));
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 withAtMention = withObservables(
['currentUserId', 'mentionName', 'preferences', 'config', 'license'],
({currentUserId, 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));
const teammateNameDisplay = of$(getTeammateNameDisplaySetting(preferences, config.value, license.value));
return {
groups: database.get(GROUP).query(Q.where('delete_at', Q.eq(0))).observe(),
myGroups: database.get(GROUP_MEMBERSHIP).query().observe(),
teammateNameDisplay,
users: database.get(USER).query(
Q.where('username', Q.like(
`%${Q.sanitizeLikeString(mn)}%`,
)),
).observeWithColumns(['username']),
};
});
return {
currentUserId: of$(currentUserId.value),
groups: database.get(GROUP).query(Q.where('delete_at', Q.eq(0))).observe(),
myGroups: database.get(GROUP_MEMBERSHIP).query().observe(),
teammateNameDisplay,
users: database.get(USER).query(
Q.where('username', Q.like(
`%${Q.sanitizeLikeString(mn)}%`,
)),
).observeWithColumns(['username']),
};
});
export default withDatabase(withPreferences(withAtMention(AtMention)));
export default withDatabase(withPreferences(withAtMention(React.memo(AtMention))));

View file

@ -4,7 +4,7 @@
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 {GestureResponderEvent, Platform, StyleProp, Text, TextStyle, View} from 'react-native';
import Emoji from '@components/emoji';
import FormattedText from '@components/formatted_text';
@ -26,16 +26,15 @@ import MarkdownTableRow, {MarkdownTableRowProps} from './markdown_table_row';
import MarkdownTableCell, {MarkdownTableCellProps} from './markdown_table_cell';
import {addListItemIndices, combineTextNodes, highlightMentions, pullOutImages} from './transform';
import type {MarkdownBlockStyles, MarkdownTextStyles, UserMentionKey} from '@typings/global/markdown';
type MarkdownProps = {
autolinkedUrlSchemes?: string[];
baseTextStyle: StyleProp<TextStyle>;
blockStyles: {
adjacentParagraph: ViewStyle;
horizontalRule: ViewStyle;
quoteBlockIcon: TextStyle;
};
blockStyles: MarkdownBlockStyles;
channelMentions?: ChannelMentions;
disableAtMentions?: boolean;
disableAtChannelMentionHighlight?: boolean;
disableChannelLink?: boolean;
disableGallery?: boolean;
disableHashtags?: boolean;
@ -47,9 +46,7 @@ type MarkdownProps = {
minimumHashtagLength?: number;
onPostPress?: (event: GestureResponderEvent) => void;
postId?: string;
textStyles: {
[key: string]: TextStyle;
};
textStyles: MarkdownTextStyles;
theme: Theme;
value: string | number;
}
@ -60,11 +57,11 @@ class Markdown extends PureComponent<MarkdownProps> {
blockStyles: {},
disableHashtags: false,
disableAtMentions: false,
disableAtChannelMentionHighlight: false,
disableChannelLink: false,
disableGallery: false,
value: '',
minimumHashtagLength: 3,
mentionKeys: [],
};
private parser: Parser;
@ -223,12 +220,13 @@ class Markdown extends PureComponent<MarkdownProps> {
return (
<AtMention
disableAtChannelMentionHighlight={this.props.disableAtChannelMentionHighlight}
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 || []}
mentionKeys={this.props.mentionKeys}
/>
);
};
@ -249,25 +247,12 @@ class Markdown extends PureComponent<MarkdownProps> {
};
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}
/>
);
};
@ -469,7 +454,7 @@ class Markdown extends PureComponent<MarkdownProps> {
if (this.props.isEdited) {
const editIndicatorNode = new Node('edited_indicator');
if (ast.lastChild && ['heading', 'paragraph'].includes(ast.lastChild.type)) {
ast.lastChild.appendChild(editIndicatorNode);
ast.appendChild(editIndicatorNode);
} else {
const node = new Node('paragraph');
node.appendChild(editIndicatorNode);

View file

@ -157,7 +157,7 @@ const MarkdownImage = ({
if (failed) {
return (
<CompassIcon
name='jumbo-attachment-image-broken'
name='file-image-broken-outline-large'
size={24}
/>
);
@ -205,6 +205,7 @@ const MarkdownImage = ({
<TouchableWithFeedback
onPress={handleLinkPress}
onLongPress={handleLinkLongPress}
style={{width, height}}
>
{image}
</TouchableWithFeedback>

View file

@ -8,7 +8,7 @@ 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 {of as of$} from 'rxjs';
import urlParse from 'url-parse';
import {switchToChannelByName} from '@actions/local/channel';
@ -176,8 +176,8 @@ const withConfigValues = withObservables(['config'], ({config}: {config: SystemM
const cfg: ClientConfig = config.value;
return {
experimentalNormalizeMarkdownLinks: of(cfg.ExperimentalNormalizeMarkdownLinks),
siteURL: of(cfg.SiteURL),
experimentalNormalizeMarkdownLinks: of$(cfg.ExperimentalNormalizeMarkdownLinks),
siteURL: of$(cfg.SiteURL),
};
});

View file

@ -89,7 +89,7 @@ const MarkTableImage = ({disabled, imagesMetadata, postId, serverURL, source}: M
if (failed) {
image = (
<CompassIcon
name='jumbo-attachment-image-broken'
name='file-image-broken-outline-large'
size={24}
/>
);

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,8 @@ import {Node, NodeType} from 'commonmark';
import {escapeRegex} from '@utils/markdown';
import type {UserMentionKey} from '@typings/global/markdown';
/* eslint-disable no-underscore-dangle */
const cjkPattern = /[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf\uac00-\ud7a3]/;
@ -136,7 +138,7 @@ export function highlightMentions(ast: Node, mentionKeys: UserMentionKey[]) {
const matches = mentionKeys.some((mention) => {
const mentionName = '@' + node.mentionName;
const flags = mention.caseSensitive ? '' : 'i';
const pattern = new RegExp(`${escapeRegex(mention.key)}\\.?`, flags);
const pattern = new RegExp(`@${escapeRegex(mention.key.replace('@', ''))}\\.?\\b`, flags);
return pattern.test(mentionName);
});

View file

@ -0,0 +1,233 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native';
import {fetchMissinProfilesByIds, fetchMissinProfilesByUsernames} from '@actions/remote/user';
import Markdown from '@components/markdown';
import SystemAvatar from '@app/components/system_avatar';
import SystemHeader from '@app/components/system_header';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Post as PostConstants} from '@constants';
import {useServerUrl} from '@context/server_url';
import {showModalOverCurrentContext} from '@screens/navigation';
import {emptyFunction} from '@utils/general';
import {getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import LastUsers from './last_users';
import {postTypeMessages} from './messages';
type Props = {
canDelete: boolean;
currentUserId?: string;
currentUsername?: string;
post: Post;
showJoinLeave: boolean;
testID?: string;
theme: Theme;
usernamesById: Record<string, string>;
style?: StyleProp<ViewStyle>;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
baseText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
fontSize: 15,
},
body: {
flex: 1,
paddingBottom: 2,
paddingTop: 2,
},
container: {
flexDirection: 'row',
},
content: {
flex: 1,
flexDirection: 'column',
marginRight: 12,
},
};
});
const CombinedUserActivity = ({
canDelete, currentUserId, currentUsername,
post, showJoinLeave, testID, theme, usernamesById = {}, style,
}: Props) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const itemTestID = `${testID}.${post.id}`;
const textStyles = getMarkdownTextStyles(theme);
const {allUserIds, allUsernames, messageData} = post.props.user_activity;
const styles = getStyleSheet(theme);
const content = [];
const removedUserIds: string[] = [];
const loadUserProfiles = () => {
if (allUserIds.length) {
fetchMissinProfilesByIds(serverUrl, allUserIds);
}
if (allUsernames.length) {
fetchMissinProfilesByUsernames(serverUrl, allUsernames);
}
};
const getUsernames = (userIds: string[]) => {
const someone = intl.formatMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'});
const you = intl.formatMessage({id: 'combined_system_message.you', defaultMessage: 'You'});
const usernames = userIds.reduce((acc: string[], id: string) => {
if (id !== currentUserId && id !== currentUsername) {
const name = usernamesById[id];
acc.push(name ? `@${name}` : someone);
}
return acc;
}, []);
if (currentUserId && userIds.includes(currentUserId)) {
usernames.unshift(you);
} else if (currentUsername && userIds.includes(currentUsername)) {
usernames.unshift(you);
}
return usernames;
};
const onLongPress = () => {
if (!canDelete) {
return;
}
const screen = 'PostOptions';
const passProps = {
canDelete,
isSystemMessage: true,
post,
};
Keyboard.dismiss();
requestAnimationFrame(() => {
showModalOverCurrentContext(screen, passProps);
});
};
const renderMessage = (postType: string, userIds: string[], actorId: string) => {
let actor = '';
if (usernamesById[actorId]) {
actor = `@${usernamesById[actorId]}`;
}
if (actor && (actorId === currentUserId || actorId === currentUsername)) {
actor = intl.formatMessage({id: 'combined_system_message.you', defaultMessage: 'You'}).toLowerCase();
}
const usernames = getUsernames(userIds);
const numOthers = usernames.length - 1;
if (numOthers > 1) {
return (
<LastUsers
key={postType + actorId}
actor={actor}
postType={postType}
theme={theme}
usernames={usernames}
/>
);
}
const firstUser = usernames[0];
const secondUser = usernames[1];
let localeHolder;
if (numOthers === 0) {
localeHolder = postTypeMessages[postType].one;
if (
(userIds[0] === currentUserId || userIds[0] === currentUsername) &&
postTypeMessages[postType].one_you
) {
localeHolder = postTypeMessages[postType].one_you;
}
} else {
localeHolder = postTypeMessages[postType].two;
}
const formattedMessage = intl.formatMessage(localeHolder, {firstUser, secondUser, actor});
return (
<Markdown
key={postType + actorId}
baseTextStyle={styles.baseText}
textStyles={textStyles}
value={formattedMessage}
theme={theme}
/>
);
};
useEffect(() => {
loadUserProfiles();
}, [allUserIds, allUsernames]);
for (const message of messageData) {
const {postType, actorId} = message;
let userIds = message.userIds;
if (!showJoinLeave && actorId !== currentUserId) {
const affectsCurrentUser = userIds.indexOf(currentUserId) !== -1;
if (affectsCurrentUser) {
// Only show the message that the current user was added, etc
userIds = [currentUserId];
} else {
// Not something the current user did or was affected by
continue;
}
}
if (postType === PostConstants.POST_TYPES.REMOVE_FROM_CHANNEL) {
removedUserIds.push(...userIds);
continue;
}
content.push(renderMessage(postType, userIds, actorId));
}
if (removedUserIds.length > 0) {
const uniqueRemovedUserIds = removedUserIds.filter((id, index, arr) => arr.indexOf(id) === index);
content.push(renderMessage(PostConstants.POST_TYPES.REMOVE_FROM_CHANNEL, uniqueRemovedUserIds, currentUserId || ''));
}
return (
<View
style={style}
testID={testID}
>
<TouchableWithFeedback
testID={itemTestID}
onPress={emptyFunction}
onLongPress={onLongPress}
delayLongPress={200}
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
cancelTouchOnPanning={true}
>
<View style={styles.container}>
<SystemAvatar theme={theme}/>
<View style={styles.content}>
<SystemHeader
createAt={post.create_at}
theme={theme}
/>
<View style={styles.body}>
{content}
</View>
</View>
</View>
</TouchableWithFeedback>
</View>
);
};
export default CombinedUserActivity;

View file

@ -0,0 +1,69 @@
// 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 {from as from$, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Permissions} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {generateCombinedPost, getPostIdsForCombinedUserActivityPost} from '@utils/post_list';
import {hasPermissionForPost} from '@utils/role';
import CombinedUserActivity from './combined_user_activity';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PostModel from '@typings/database/models/servers/post';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
type CombinedPostsInput = WithDatabaseArgs & {
currentUser: UserModel;
postId: string;
posts: PostModel[];
}
const {SERVER: {POST, SYSTEM, USER}} = MM_TABLES;
const withPostId = withObservables(['postId'], ({database, postId}: WithDatabaseArgs & {postId: string}) => {
const postIds = getPostIdsForCombinedUserActivityPost(postId);
return {
currentUser: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap((currentUserId: SystemModel) => database.get(USER).findAndObserve(currentUserId.value)),
),
posts: database.get(POST).query(
Q.where('id', Q.oneOf(postIds)),
).observe(),
};
});
const withCombinedPosts = withObservables(['currentUser', 'postId', 'posts'], ({currentUser, database, postId, posts}: CombinedPostsInput) => {
const post = generateCombinedPost(postId, posts);
const canDelete = from$(hasPermissionForPost(posts[0], currentUser, Permissions.DELETE_OTHERS_POSTS, false));
const usernamesById = database.get(USER).query(
Q.or(
Q.where('id', Q.oneOf(post.props.user_activity.allUserIds)),
Q.where('username', Q.oneOf(post.props.user_activity.allUsernames)),
),
).observe().pipe(
switchMap((users: UserModel[]) => {
// eslint-disable-next-line max-nested-callbacks
return of$(users.reduce((acc, user) => {
acc[user.id] = user.username;
return acc;
}, {} as Record<string, string>));
}),
);
return {
canDelete,
currentUserId: of$(currentUser.id),
post: of$(post),
usernamesById,
};
});
export default withDatabase(withPostId(withCombinedPosts(CombinedUserActivity)));

View file

@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {useIntl} from 'react-intl';
import {Text} from 'react-native';
import FormattedMarkdownText from '@components/formatted_markdown_text';
import FormattedText from '@components/formatted_text';
import Markdown from '@components/markdown';
import {getMarkdownTextStyles} from '@utils/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {postTypeMessages, systemMessages} from './messages';
type LastUsersProps = {
actor: string;
postType: string;
usernames: string[];
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
baseText: {
color: theme.centerChannelColor,
opacity: 0.6,
fontSize: 15,
},
linkText: {
color: theme.linkColor,
opacity: 0.8,
fontSize: 15,
},
};
});
const LastUsers = ({actor, postType, theme, usernames}: LastUsersProps) => {
const [expanded, setExpanded] = useState(false);
const intl = useIntl();
const style = getStyleSheet(theme);
const textStyles = getMarkdownTextStyles(theme);
const onPress = () => {
setExpanded(true);
};
if (expanded) {
const lastIndex = usernames.length - 1;
const lastUser = usernames[lastIndex];
const expandedMessage = postTypeMessages[postType].many_expanded;
const formattedMessage = intl.formatMessage(expandedMessage, {
users: usernames.slice(0, lastIndex).join(', '),
lastUser,
actor,
});
return (
<Markdown
baseTextStyle={style.baseText}
textStyles={textStyles}
value={formattedMessage}
theme={theme}
/>
);
}
const firstUser = usernames[0];
const numOthers = usernames.length - 1;
return (
<Text>
<FormattedMarkdownText
id={'last_users_message.first'}
defaultMessage={'{firstUser} and '}
values={{firstUser}}
baseTextStyle={style.baseText}
style={style.baseText}
textStyles={textStyles}
/>
<Text>{' '}</Text>
<Text
style={style.linkText}
onPress={onPress}
>
<FormattedText
id={'last_users_message.others'}
defaultMessage={'{numOthers} others '}
values={{numOthers}}
/>
</Text>
<FormattedMarkdownText
id={systemMessages[postType].id}
defaultMessage={systemMessages[postType].defaultMessage}
values={{actor}}
baseTextStyle={style.baseText}
style={style.baseText}
textStyles={textStyles}
/>
</Text>
);
};
export default LastUsers;

View file

@ -0,0 +1,192 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Post} from '@constants';
import {t} from '@i18n';
const {
JOIN_CHANNEL, ADD_TO_CHANNEL, REMOVE_FROM_CHANNEL, LEAVE_CHANNEL,
JOIN_TEAM, ADD_TO_TEAM, REMOVE_FROM_TEAM, LEAVE_TEAM,
} = Post.POST_TYPES;
export const postTypeMessages = {
[JOIN_CHANNEL]: {
one: {
id: t('combined_system_message.joined_channel.one'),
defaultMessage: '{firstUser} **joined the channel**.',
},
one_you: {
id: t('combined_system_message.joined_channel.one_you'),
defaultMessage: 'You **joined the channel**.',
},
two: {
id: t('combined_system_message.joined_channel.two'),
defaultMessage: '{firstUser} and {secondUser} **joined the channel**.',
},
many_expanded: {
id: t('combined_system_message.joined_channel.many_expanded'),
defaultMessage: '{users} and {lastUser} **joined the channel**.',
},
},
[ADD_TO_CHANNEL]: {
one: {
id: t('combined_system_message.added_to_channel.one'),
defaultMessage: '{firstUser} **added to the channel** by {actor}.',
},
one_you: {
id: t('combined_system_message.added_to_channel.one_you'),
defaultMessage: 'You were **added to the channel** by {actor}.',
},
two: {
id: t('combined_system_message.added_to_channel.two'),
defaultMessage: '{firstUser} and {secondUser} **added to the channel** by {actor}.',
},
many_expanded: {
id: t('combined_system_message.added_to_channel.many_expanded'),
defaultMessage: '{users} and {lastUser} were **added to the channel** by {actor}.',
},
},
[REMOVE_FROM_CHANNEL]: {
one: {
id: t('combined_system_message.removed_from_channel.one'),
defaultMessage: '{firstUser} was **removed from the channel**.',
},
one_you: {
id: t('combined_system_message.removed_from_channel.one_you'),
defaultMessage: 'You were **removed from the channel**.',
},
two: {
id: t('combined_system_message.removed_from_channel.two'),
defaultMessage: '{firstUser} and {secondUser} were **removed from the channel**.',
},
many_expanded: {
id: t('combined_system_message.removed_from_channel.many_expanded'),
defaultMessage: '{users} and {lastUser} were **removed from the channel**.',
},
},
[LEAVE_CHANNEL]: {
one: {
id: t('combined_system_message.left_channel.one'),
defaultMessage: '{firstUser} **left the channel**.',
},
one_you: {
id: t('combined_system_message.left_channel.one_you'),
defaultMessage: 'You **left the channel**.',
},
two: {
id: t('combined_system_message.left_channel.two'),
defaultMessage: '{firstUser} and {secondUser} **left the channel**.',
},
many_expanded: {
id: t('combined_system_message.left_channel.many_expanded'),
defaultMessage: '{users} and {lastUser} **left the channel**.',
},
},
[JOIN_TEAM]: {
one: {
id: t('combined_system_message.joined_team.one'),
defaultMessage: '{firstUser} **joined the team**.',
},
one_you: {
id: t('combined_system_message.joined_team.one_you'),
defaultMessage: 'You **joined the team**.',
},
two: {
id: t('combined_system_message.joined_team.two'),
defaultMessage: '{firstUser} and {secondUser} **joined the team**.',
},
many_expanded: {
id: t('combined_system_message.joined_team.many_expanded'),
defaultMessage: '{users} and {lastUser} **joined the team**.',
},
},
[ADD_TO_TEAM]: {
one: {
id: t('combined_system_message.added_to_team.one'),
defaultMessage: '{firstUser} **added to the team** by {actor}.',
},
one_you: {
id: t('combined_system_message.added_to_team.one_you'),
defaultMessage: 'You were **added to the team** by {actor}.',
},
two: {
id: t('combined_system_message.added_to_team.two'),
defaultMessage: '{firstUser} and {secondUser} **added to the team** by {actor}.',
},
many_expanded: {
id: t('combined_system_message.added_to_team.many_expanded'),
defaultMessage: '{users} and {lastUser} were **added to the team** by {actor}.',
},
},
[REMOVE_FROM_TEAM]: {
one: {
id: t('combined_system_message.removed_from_team.one'),
defaultMessage: '{firstUser} was **removed from the team**.',
},
one_you: {
id: t('combined_system_message.removed_from_team.one_you'),
defaultMessage: 'You were **removed from the team**.',
},
two: {
id: t('combined_system_message.removed_from_team.two'),
defaultMessage: '{firstUser} and {secondUser} were **removed from the team**.',
},
many_expanded: {
id: t('combined_system_message.removed_from_team.many_expanded'),
defaultMessage: '{users} and {lastUser} were **removed from the team**.',
},
},
[LEAVE_TEAM]: {
one: {
id: t('combined_system_message.left_team.one'),
defaultMessage: '{firstUser} **left the team**.',
},
one_you: {
id: t('combined_system_message.left_team.one_you'),
defaultMessage: 'You **left the team**.',
},
two: {
id: t('combined_system_message.left_team.two'),
defaultMessage: '{firstUser} and {secondUser} **left the team**.',
},
many_expanded: {
id: t('combined_system_message.left_team.many_expanded'),
defaultMessage: '{users} and {lastUser} **left the team**.',
},
},
};
export const systemMessages = {
[ADD_TO_CHANNEL]: {
id: t('last_users_message.added_to_channel.type'),
defaultMessage: 'were **added to the channel** by {actor}.',
},
[JOIN_CHANNEL]: {
id: t('last_users_message.joined_channel.type'),
defaultMessage: '**joined the channel**.',
},
[LEAVE_CHANNEL]: {
id: t('last_users_message.left_channel.type'),
defaultMessage: '**left the channel**.',
},
[REMOVE_FROM_CHANNEL]: {
id: t('last_users_message.removed_from_channel.type'),
defaultMessage: 'were **removed from the channel**.',
},
[ADD_TO_TEAM]: {
id: t('last_users_message.added_to_team.type'),
defaultMessage: 'were **added to the team** by {actor}.',
},
[JOIN_TEAM]: {
id: t('last_users_message.joined_team.type'),
defaultMessage: '**joined the team**.',
},
[LEAVE_TEAM]: {
id: t('last_users_message.left_team.type'),
defaultMessage: '**left the team**.',
},
[REMOVE_FROM_TEAM]: {
id: t('last_users_message.removed_from_team.type'),
defaultMessage: 'were **removed from the team**.',
},
};

View file

@ -0,0 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, View, ViewStyle} from 'react-native';
import FormattedDate from '@components/formatted_date';
import FormattedText from '@components/formatted_text';
import {makeStyleSheetFromTheme} from '@utils/theme';
type DateSeparatorProps = {
date: number | Date;
style?: StyleProp<ViewStyle>;
theme: Theme;
timezone?: UserTimezone | null;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
alignItems: 'center',
flexDirection: 'row',
height: 28,
marginTop: 8,
},
dateContainer: {
marginHorizontal: 15,
},
line: {
flex: 1,
height: 1,
backgroundColor: theme.centerChannelColor,
opacity: 0.2,
},
date: {
color: theme.centerChannelColor,
fontSize: 14,
fontWeight: '600',
},
};
});
export function isSameDay(a: Date, b: Date) {
return a.getDate() === b.getDate() && a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
}
export function isToday(date: Date) {
const now = new Date();
return isSameDay(date, now);
}
export function isYesterday(date: Date) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return isSameDay(date, yesterday);
}
const RecentDate = (props: DateSeparatorProps) => {
const {date, ...otherProps} = props;
const when = new Date(date);
if (isToday(when)) {
return (
<FormattedText
{...otherProps}
id='date_separator.today'
defaultMessage='Today'
/>
);
} else if (isYesterday(when)) {
return (
<FormattedText
{...otherProps}
id='date_separator.yesterday'
defaultMessage='Yesterday'
/>
);
}
return (
<FormattedDate
{...otherProps}
value={date}
/>
);
};
const DateSeparator = (props: DateSeparatorProps) => {
const styles = getStyleSheet(props.theme);
return (
<View style={[styles.container, props.style]}>
<View style={styles.line}/>
<View style={styles.dateContainer}>
<RecentDate
{...props}
style={styles.date}
/>
</View>
<View style={styles.line}/>
</View>
);
};
export default DateSeparator;

View file

@ -0,0 +1,272 @@
// 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, {ReactElement, useCallback} from 'react';
import {DeviceEventEmitter, FlatList, Platform, RefreshControl, StyleSheet, ViewToken} from 'react-native';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import Post from '@components/post_list/post';
import CombinedUserActivity from '@app/components/post_list/combined_user_activity';
import DateSeparator from '@components/post_list/date_separator';
import NewMessagesLine from '@components/post_list/new_message_line';
import {Preferences} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useTheme} from '@context/theme';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {emptyFunction} from '@utils/general';
import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList} from '@utils/post_list';
import type {WithDatabaseArgs} from '@typings/database/database';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
import type PostModel from '@typings/database/models/servers/post';
import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel';
import type PreferenceModel from '@typings/database/models/servers/preference';
import type SystemModel from '@typings/database/models/servers/system';
import UserModel from '@typings/database/models/servers/user';
type RefreshProps = {
children: ReactElement;
enabled: boolean;
onRefresh: () => void;
refreshing: boolean;
}
type Props = {
currentTimezone: UserTimezone | null;
currentUsername: string;
isTimezoneEnabled: boolean;
lastViewedAt: number;
posts: PostModel[];
shouldShowJoinLeaveMessages: boolean;
testID: string;
}
type ViewableItemsChanged = {
viewableItems: ViewToken[];
changed: ViewToken[];
}
const style = StyleSheet.create({
container: {
flex: 1,
scaleY: -1,
},
scale: {
...Platform.select({
android: {
scaleY: -1,
},
}),
},
});
const {SERVER: {MY_CHANNEL, POST, POSTS_IN_CHANNEL, PREFERENCE, SYSTEM, USER}} = MM_TABLES;
export const VIEWABILITY_CONFIG = {
itemVisiblePercentThreshold: 1,
minimumViewTime: 100,
};
const PostListRefreshControl = ({children, enabled, onRefresh, refreshing}: RefreshProps) => {
const props = {
onRefresh,
refreshing,
};
if (Platform.OS === 'android') {
return (
<RefreshControl
{...props}
enabled={enabled}
style={style.container}
>
{children}
</RefreshControl>
);
}
const refreshControl = <RefreshControl {...props}/>;
return React.cloneElement(
children,
{refreshControl, inverted: true},
);
};
const PostList = ({currentTimezone, currentUsername, isTimezoneEnabled, lastViewedAt, posts, shouldShowJoinLeaveMessages, testID}: Props) => {
const theme = useTheme();
const orderedPosts = preparePostList(posts, lastViewedAt, true, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, false);
const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => {
if (!viewableItems.length) {
return;
}
const viewableItemsMap = viewableItems.reduce((acc: Record<string, boolean>, {item, isViewable}) => {
if (isViewable) {
acc[item.id] = true;
}
return acc;
}, {});
DeviceEventEmitter.emit('scrolled', viewableItemsMap);
}, []);
const renderItem = useCallback(({item, index}) => {
if (typeof item === 'string') {
if (isStartOfNewMessages(item)) {
// postIds includes a date item after the new message indicator so 2
// needs to be added to the index for the length check to be correct.
const moreNewMessages = orderedPosts.length === index + 2;
// The date line and new message line each count for a line. So the
// goal of this is to check for the 3rd previous, which for the start
// of a thread would be null as it doesn't exist.
const checkForPostId = index < orderedPosts.length - 3;
return (
<NewMessagesLine
theme={theme}
moreMessages={moreNewMessages && checkForPostId}
testID={`${testID}.new_messages_line`}
style={style.scale}
/>
);
} else if (isDateLine(item)) {
return (
<DateSeparator
date={getDateForDateLine(item)}
theme={theme}
style={style.scale}
timezone={currentTimezone}
/>
);
}
if (isCombinedUserActivityPost(item)) {
const postProps = {
currentUsername,
postId: item,
style: Platform.OS === 'ios' ? style.scale : style.container,
testID: `${testID}.combined_user_activity`,
showJoinLeave: shouldShowJoinLeaveMessages,
theme,
};
return (<CombinedUserActivity {...postProps}/>);
}
}
let previousPost: PostModel|undefined;
let nextPost: PostModel|undefined;
if (index < posts.length - 1) {
const prev = orderedPosts.slice(index + 1).find((v) => typeof v !== 'string');
if (prev) {
previousPost = prev as PostModel;
}
}
if (index > 0) {
const next = orderedPosts.slice(0, index);
for (let i = next.length - 1; i >= 0; i--) {
const v = next[i];
if (typeof v !== 'string') {
nextPost = v;
break;
}
}
}
const postProps = {
highlightPinnedOrFlagged: true,
location: 'Channel',
nextPost,
previousPost,
shouldRenderReplyButton: true,
};
return (
<Post
key={item.id}
post={item}
style={style.scale}
testID={`${testID}.post`}
{...postProps}
/>
);
}, [orderedPosts, theme]);
return (
<PostListRefreshControl
enabled={false}
refreshing={false}
onRefresh={emptyFunction}
>
<FlatList
data={orderedPosts}
renderItem={renderItem}
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='handled'
keyExtractor={(item) => (typeof item === 'string' ? item : item.id)}
style={{flex: 1}}
contentContainerStyle={{paddingTop: 5}}
initialNumToRender={10}
maxToRenderPerBatch={Platform.select({android: 5})}
removeClippedSubviews={true}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={VIEWABILITY_CONFIG}
windowSize={30}
scrollEventThrottle={60}
/>
</PostListRefreshControl>
);
};
const withPosts = withObservables(['channelId'], ({database, channelId}: {channelId: string} & WithDatabaseArgs) => {
const currentUser = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap((currentUserId: SystemModel) => database.get(USER).findAndObserve(currentUserId.value)),
);
return {
currentTimezone: currentUser.pipe((switchMap((user: UserModel) => of$(user.timezone)))),
currentUsername: currentUser.pipe((switchMap((user: UserModel) => of$(user.username)))),
isTimezoneEnabled: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((config: SystemModel) => of$(config.value.ExperimentalTimezone === 'true')),
),
lastViewedAt: database.get(MY_CHANNEL).findAndObserve(channelId).pipe(
switchMap((myChannel: MyChannelModel) => of$(myChannel.lastViewedAt)),
),
posts: database.get(POSTS_IN_CHANNEL).query(
Q.where('channel_id', channelId),
Q.experimentalSortBy('latest', Q.desc),
).observe().pipe(
switchMap((postsInChannel: PostsInChannelModel[]) => {
if (!postsInChannel.length) {
return of$([]);
}
const {earliest, latest} = postsInChannel[0];
return database.get(POST).query(
Q.and(
Q.where('delete_at', 0),
Q.where('channel_id', channelId),
Q.where('create_at', Q.between(earliest, latest)),
),
Q.experimentalSortBy('create_at', Q.desc),
).observe();
}),
),
shouldShowJoinLeaveMessages: database.get(PREFERENCE).query(
Q.where('category', Preferences.CATEGORY_ADVANCED_SETTINGS),
Q.where('name', Preferences.ADVANCED_FILTER_JOIN_LEAVE),
).observe().pipe(
switchMap((preferences: PreferenceModel[]) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE, true))),
),
};
});
export default withDatabase(withPosts(React.memo(PostList)));

View file

@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, View, ViewStyle} from 'react-native';
import FormattedText from '@components/formatted_text';
import {makeStyleSheetFromTheme} from '@utils/theme';
type NewMessagesLineProps = {
moreMessages: boolean;
style?: StyleProp<ViewStyle>;
theme: Theme;
testID?: string;
}
function NewMessagesLine({moreMessages, style, testID, theme}: NewMessagesLineProps) {
const styles = getStyleFromTheme(theme);
let text = (
<FormattedText
id='posts_view.newMsg'
defaultMessage='New Messages'
style={styles.text}
testID={testID}
/>
);
if (moreMessages) {
text = (
<FormattedText
id='mobile.posts_view.moreMsg'
defaultMessage='More New Messages Above'
style={styles.text}
testID={testID}
/>
);
}
return (
<View style={[styles.container, style]}>
<View style={styles.line}/>
<View style={styles.textContainer}>
{text}
</View>
<View style={styles.line}/>
</View>
);
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
alignItems: 'center',
flexDirection: 'row',
height: 28,
},
textContainer: {
marginHorizontal: 15,
},
line: {
flex: 1,
height: 1,
backgroundColor: theme.newMessageSeparator,
},
text: {
fontSize: 14,
color: theme.newMessageSeparator,
},
};
});
export default NewMessagesLine;

View file

@ -0,0 +1,182 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {ReactNode, useRef} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
import FastImage from 'react-native-fast-image';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import CompassIcon from '@components/compass_icon';
import SystemAvatar from '@components/system_avatar';
import ProfilePicture from '@components/profile_picture';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {View as ViewConstant} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useServerUrl} from '@context/server_url';
import {useTheme} from '@context/theme';
import NetworkManager from '@init/network_manager';
import {showModal} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import type {ImageSource} from 'react-native-vector-icons/Icon';
import type {Client} from '@client/rest';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
import type SystemModel from '@typings/database/models/servers/system';
type AvatarProps = {
author: UserModel;
enablePostIconOverride?: boolean;
isAutoReponse: boolean;
isSystemPost: boolean;
pendingPostStyle?: StyleProp<ViewStyle>;
post: PostModel;
}
const style = StyleSheet.create({
buffer: {
marginRight: Platform.select({android: 2, ios: 3}),
},
profilePictureContainer: {
marginBottom: 5,
marginLeft: 12,
marginRight: Platform.select({android: 11, ios: 10}),
marginTop: 10,
},
});
const Avatar = ({author, enablePostIconOverride, isAutoReponse, isSystemPost, pendingPostStyle, post}: AvatarProps) => {
const closeButton = useRef<ImageSource>();
const intl = useIntl();
const theme = useTheme();
const serverUrl = useServerUrl();
let client: Client | undefined;
try {
client = NetworkManager.getClient(serverUrl);
} catch {
// do nothing, client is not set
}
if (isSystemPost && !isAutoReponse && !author.isBot) {
return (<SystemAvatar theme={theme}/>);
}
const fromWebHook = post.props?.from_webhook === 'true';
const iconOverride = enablePostIconOverride && post.props?.use_user_icon !== 'true';
if (fromWebHook && iconOverride) {
const isEmoji = Boolean(post.props?.override_icon_emoji);
const frameSize = ViewConstant.PROFILE_PICTURE_SIZE;
const pictureSize = isEmoji ? ViewConstant.PROFILE_PICTURE_EMOJI_SIZE : ViewConstant.PROFILE_PICTURE_SIZE;
const borderRadius = isEmoji ? 0 : ViewConstant.PROFILE_PICTURE_SIZE / 2;
const overrideIconUrl = client?.getAbsoluteUrl(post.props?.override_icon_url);
let iconComponent: ReactNode;
if (overrideIconUrl) {
const source = {uri: overrideIconUrl};
iconComponent = (
<FastImage
source={source}
style={{
height: pictureSize,
width: pictureSize,
}}
/>
);
} else {
iconComponent = (
<CompassIcon
name='webhook'
size={32}
/>
);
}
return (
<View style={[style.profilePictureContainer, pendingPostStyle]}>
<View
style={[{
borderRadius,
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
height: frameSize,
width: frameSize,
}, style.buffer]}
>
{iconComponent}
</View>
</View>
);
}
const onViewUserProfile = preventDoubleTap(() => {
const screen = 'UserProfile';
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
const passProps = {author};
if (!closeButton.current) {
closeButton.current = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
}
const options = {
topBar: {
leftButtons: [{
id: 'close-settings',
icon: closeButton.current,
testID: 'close.settings.button',
}],
},
};
Keyboard.dismiss();
showModal(screen, title, passProps, options);
});
let component = (
<ProfilePicture
author={author}
size={ViewConstant.PROFILE_PICTURE_SIZE}
iconSize={24}
showStatus={!isAutoReponse}
testID='post_profile_picture.profile_picture'
/>
);
if (!fromWebHook) {
component = (
<TouchableWithFeedback
onPress={onViewUserProfile}
type={'opacity'}
>
{component}
</TouchableWithFeedback>
);
}
return (
<View style={[style.profilePictureContainer, pendingPostStyle]}>
{component}
</View>
);
};
const withSystemIds = withObservables([], ({database}: WithDatabaseArgs) => ({
enablePostIconOverride: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((cfg: SystemModel) => of$(cfg.value.EnablePostIconOverride === 'true')),
),
}));
const withPost = withObservables(['post', 'enablePostIconOverride'], ({post, enablePostIconOverride}: {post: PostModel; enablePostIconOverride: boolean}) => {
return {
author: post.author.observe(),
enablePostIconOverride: of$(enablePostIconOverride),
};
});
export default withDatabase(withSystemIds(withPost(React.memo(Avatar))));

View file

@ -0,0 +1,237 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {ReactNode} from 'react';
import {useIntl} from 'react-intl';
import {Text} from 'react-native';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {removePost, sendAddToChannelEphemeralPost} from '@actions/local/post';
import {addMembersToChannel} from '@actions/remote/channel';
import AtMention from '@components/markdown/at_mention';
import FormattedText from '@components/formatted_text';
import {General} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useServerUrl} from '@context/server_url';
import {t} from '@i18n';
import {getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
import SystemModel from '@typings/database/models/servers/system';
type AddMembersProps = {
channelType: string | null;
currentUser: UserModel;
post: PostModel;
theme: Theme;
}
const {SERVER: {SYSTEM, USER}} = MM_TABLES;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
message: {
color: changeOpacity(theme.centerChannelColor, 0.6),
fontSize: 15,
lineHeight: 20,
},
};
});
const AddMembers = ({channelType, currentUser, post, theme}: AddMembersProps) => {
const intl = useIntl();
const styles = getStyleSheet(theme);
const textStyles = getMarkdownTextStyles(theme);
const serverUrl = useServerUrl();
const postId: string = post.props.add_channel_member?.post_id;
const noGroupsUsernames = post.props.add_channel_member?.not_in_groups_usernames;
let userIds: string[] = post.props.add_channel_member?.not_in_channel_user_ids;
let usernames: string[] = post.props.add_channel_member?.not_in_channel_usernames;
if (!postId || !channelType) {
return null;
}
if (!userIds) {
userIds = post.props.add_channel_member?.user_ids;
}
if (!usernames) {
usernames = post.props.add_channel_member?.usernames;
}
const handleAddChannelMember = () => {
if (post && post.channelId) {
addMembersToChannel(serverUrl, post.channelId, userIds, post.rootId, false);
if (post.rootId) {
const messages = usernames.map((addedUsername) => {
return intl.formatMessage(
{
id: 'api.channel.add_member.added',
defaultMessage: '{addedUsername} added to the channel by {username}.',
},
{
username: currentUser.username,
addedUsername,
},
);
});
sendAddToChannelEphemeralPost(serverUrl, currentUser, usernames, messages, post.channelId, post.rootId);
}
removePost(serverUrl, post);
}
};
const generateAtMentions = (names: string[]) => {
if (names.length === 1) {
return (
<AtMention
mentionName={names[0]}
mentionStyle={textStyles.mention}
/>
);
} else if (names.length > 1) {
function andSeparator(key: string) {
return (
<FormattedText
key={key}
id={'post_body.check_for_out_of_channel_mentions.link.and'}
defaultMessage={' and '}
style={textStyles}
/>
);
}
function commaSeparator(key: string) {
return <Text key={key}>{', '}</Text>;
}
return (
<Text>
{
names.map((username: string) => {
return (
<AtMention
key={username}
mentionStyle={textStyles.mention}
mentionName={username}
/>
);
}).reduce((acc: ReactNode[], el: ReactNode, idx: number, arr: ReactNode[]) => {
if (idx === 0) {
return [el];
} else if (idx === arr.length - 1) {
return [...acc, andSeparator(`separator-${idx}`), el];
}
return [...acc, commaSeparator(`commma-separator-${idx}`), el];
}, [])
}
</Text>
);
}
return '';
};
let linkId = '';
let linkText = '';
if (channelType === General.PRIVATE_CHANNEL) {
linkId = t('post_body.check_for_out_of_channel_mentions.link.private');
linkText = 'add them to this private channel';
} else if (channelType === General.OPEN_CHANNEL) {
linkId = t('post_body.check_for_out_of_channel_mentions.link.public');
linkText = 'add them to the channel';
}
let outOfChannelMessageID = '';
let outOfChannelMessageText = '';
const outOfChannelAtMentions = generateAtMentions(usernames);
if (usernames.length === 1) {
outOfChannelMessageID = t('post_body.check_for_out_of_channel_mentions.message.one');
outOfChannelMessageText = 'was mentioned but is not in the channel. Would you like to ';
} else if (usernames.length > 1) {
outOfChannelMessageID = t('post_body.check_for_out_of_channel_mentions.message.multiple');
outOfChannelMessageText = 'were mentioned but they are not in the channel. Would you like to ';
}
let outOfGroupsMessageID = '';
let outOfGroupsMessageText = '';
const outOfGroupsAtMentions = generateAtMentions(noGroupsUsernames);
if (noGroupsUsernames?.length) {
outOfGroupsMessageID = t('post_body.check_for_out_of_channel_groups_mentions.message');
outOfGroupsMessageText = 'did not get notified by this mention because they are not in the channel. They are also not a member of the groups linked to this channel.';
}
let outOfChannelMessage = null;
if (usernames.length) {
outOfChannelMessage = (
<Text>
{outOfChannelAtMentions}
{' '}
<FormattedText
id={outOfChannelMessageID}
defaultMessage={outOfChannelMessageText}
style={styles.message}
/>
<Text
style={textStyles.link}
testID='add_channel_member_link'
onPress={handleAddChannelMember}
>
<FormattedText
id={linkId}
defaultMessage={linkText}
/>
</Text>
<FormattedText
id={'post_body.check_for_out_of_channel_mentions.message_last'}
defaultMessage={'? They will have access to all message history.'}
style={styles.message}
/>
</Text>
);
}
let outOfGroupsMessage = null;
if (noGroupsUsernames?.length) {
outOfGroupsMessage = (
<Text>
{outOfGroupsAtMentions}
{' '}
<FormattedText
id={outOfGroupsMessageID}
defaultMessage={outOfGroupsMessageText}
style={styles.message}
/>
</Text>
);
}
return (
<>
{outOfChannelMessage}
{outOfGroupsMessage}
</>
);
};
const withChannelType = withObservables(['post'], ({database, post}: WithDatabaseArgs & {post: PostModel}) => ({
currentUser: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap((currentUserId: SystemModel) => database.get(USER).findAndObserve(currentUserId.value)),
),
channelType: post.channel.observe().pipe(
switchMap(
(channel: ChannelModel) => (channel ? of$(channel.type) : of$(null)),
),
),
}));
export default withDatabase(withChannelType(React.memo(AddMembers)));

View file

@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text, View, StyleSheet, StyleProp, TextStyle} from 'react-native';
import Emoji from '@components/emoji';
import {reEmoji, reEmoticon, reMain} from '@constants/emoji';
import {getEmoticonName} from '@utils/emoji/helpers';
type Props = {
message: string;
style: StyleProp<TextStyle>;
}
const styles = StyleSheet.create({
container: {
alignItems: 'flex-start',
flexDirection: 'row',
flexWrap: 'wrap',
},
});
const ButtonBindingText = ({message, style}: Props) => {
const components = [] as React.ReactNode[];
let text = message;
while (text) {
let match;
// See if the text starts with an emoji
if ((match = text.match(reEmoji))) {
components.push(
<Emoji
key={components.length}
literal={match[0]}
emojiName={match[1]}
textStyle={style}
/>,
);
text = text.substring(match[0].length);
continue;
}
// Or an emoticon
if ((match = text.match(reEmoticon))) {
const emoticonName = getEmoticonName(match[0]);
if (emoticonName) {
components.push(
<Emoji
key={components.length}
literal={match[0]}
emojiName={emoticonName}
textStyle={style}
/>,
);
text = text.substring(match[0].length);
continue;
}
}
// This is plain text, so capture as much text as possible until we hit the next possible emoji. Note that
// reMain always captures at least one character, so text will always be getting shorter
match = text.match(reMain);
if (!match) {
continue;
}
components.push(
<Text
key={components.length}
style={style}
>
{match[0]}
</Text>,
);
text = text.substring(match[0].length);
}
return (
<View style={styles.container}>
{components}
</View>
);
};
export default ButtonBindingText;

View file

@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import React, {useCallback, useRef} from 'react';
import {useIntl} from 'react-intl';
import Button from 'react-native-button';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/remote/apps';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@constants/apps';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useServerUrl} from '@context/server_url';
import {createCallContext, createCallRequest} from '@utils/apps';
import {getStatusColors} from '@utils/message_attachment_colors';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import ButtonBindingText from './button_binding_text';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
import type SystemModel from '@typings/database/models/servers/system';
type Props = {
currentTeamId: string;
binding: AppBinding;
post: PostModel;
teamID?: string;
theme: Theme;
}
const {SERVER: {SYSTEM}} = MM_TABLES;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const STATUS_COLORS = getStatusColors(theme);
return {
button: {
borderRadius: 4,
borderColor: changeOpacity(STATUS_COLORS.default, 0.25),
borderWidth: 2,
opacity: 1,
alignItems: 'center',
marginTop: 12,
justifyContent: 'center',
height: 36,
},
buttonDisabled: {backgroundColor: changeOpacity(theme.buttonBg, 0.3)},
text: {
color: STATUS_COLORS.default,
fontSize: 15,
fontWeight: '600',
lineHeight: 17,
},
};
});
const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) => {
const pressed = useRef(false);
const intl = useIntl();
const serverUrl = useServerUrl();
const style = getStyleSheet(theme);
const onPress = useCallback(preventDoubleTap(async () => {
if (!binding.call || pressed.current) {
return;
}
pressed.current = true;
const context = createCallContext(
binding.app_id,
AppBindingLocations.IN_POST + binding.location,
post.channelId,
teamID || currentTeamId,
post.id,
);
const call = createCallRequest(
binding.call,
context,
{post: AppExpandLevels.EXPAND_ALL},
);
const res = await doAppCall(serverUrl, call, AppCallTypes.SUBMIT, intl, theme);
pressed.current = false;
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.error || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
postEphemeralCallResponseForPost(serverUrl, errorResponse, errorMessage, post);
return;
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
postEphemeralCallResponseForPost(serverUrl, callResp, callResp.markdown, post);
}
return;
case AppCallResponseTypes.NAVIGATE:
case AppCallResponseTypes.FORM:
return;
default: {
const errorMessage = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
postEphemeralCallResponseForPost(serverUrl, callResp, errorMessage, post);
}
}
}), []);
return (
<Button
containerStyle={[style.button]}
disabledContainerStyle={style.buttonDisabled}
onPress={onPress}
>
<ButtonBindingText
message={binding.label}
style={style.text}
/>
</Button>
);
return null;
};
const withTeamId = withObservables(['post'], ({post}: {post: PostModel}) => ({
teamID: post.channel.observe().pipe(switchMap((channel: ChannelModel) => of$(channel.teamId))),
currentTeamId: post.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe(
switchMap((currentTeamId: SystemModel) => of$(currentTeamId.value)),
),
}));
export default withTeamId(React.memo(ButtonBinding));

View file

@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {LayoutChangeEvent, useWindowDimensions, ScrollView, View} from 'react-native';
import Animated from 'react-native-reanimated';
import Markdown from '@components/markdown';
import ShowMoreButton from '@app/components/post_list/post/body/message/show_more_button';
import {useShowMoreAnimatedStyle} from '@hooks/show_more';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
theme: Theme;
value: string;
}
const SHOW_MORE_HEIGHT = 54;
const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
message: {
color: theme.centerChannelColor,
fontSize: 15,
lineHeight: 20,
},
}));
const EmbedText = ({theme, value}: Props) => {
const [open, setOpen] = useState(false);
const [height, setHeight] = useState<number|undefined>();
const dimensions = useWindowDimensions();
const maxHeight = Math.round((dimensions.height * 0.4) + SHOW_MORE_HEIGHT);
const animatedStyle = useShowMoreAnimatedStyle(height, maxHeight, open);
const blockStyles = getMarkdownBlockStyles(theme);
const textStyles = getMarkdownTextStyles(theme);
const style = getStyles(theme);
const onLayout = useCallback((event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), []);
const onPress = () => setOpen(!open);
return (
<>
<Animated.View style={animatedStyle}>
<ScrollView
keyboardShouldPersistTaps={'always'}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
>
<View onLayout={onLayout}>
<Markdown
baseTextStyle={style.message}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}
theme={theme}
value={value}
/>
</View>
</ScrollView>
</Animated.View>
{(height || 0) > maxHeight &&
<ShowMoreButton
highlight={false}
theme={theme}
showMore={!open}
onPress={onPress}
/>
}
</>
);
};
export default EmbedText;

View file

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import Markdown from '@components/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
theme: Theme;
value: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
marginTop: 3,
flex: 1,
flexDirection: 'row',
},
title: {
color: theme.centerChannelColor,
fontWeight: '600',
marginBottom: 5,
fontSize: 14,
lineHeight: 20,
},
link: {
color: theme.linkColor,
},
};
});
const EmbedTitle = ({theme, value}: Props) => {
const style = getStyleSheet(theme);
return (
<View style={style.container}>
<Markdown
disableHashtags={true}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
autolinkedUrlSchemes={[]}
mentionKeys={[]}
theme={theme}
value={value}
baseTextStyle={style.title}
textStyles={{link: style.link}}
/>
</View>
);
};
export default EmbedTitle;

View file

@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import {copyAndFillBindings} from '@utils/apps';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import EmbedText from './embed_text';
import EmbedTitle from './embed_title';
import EmbedSubBindings from './embedded_sub_bindings';
import type PostModel from '@typings/database/models/servers/post';
type Props = {
embed: AppBinding;
post: PostModel;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.15),
borderLeftColor: changeOpacity(theme.linkColor, 0.6),
borderRightColor: changeOpacity(theme.centerChannelColor, 0.15),
borderTopColor: changeOpacity(theme.centerChannelColor, 0.15),
borderBottomWidth: 1,
borderLeftWidth: 3,
borderRightWidth: 1,
borderTopWidth: 1,
marginTop: 5,
padding: 12,
},
};
});
const EmbeddedBinding = ({embed, post, theme}: Props) => {
const style = getStyleSheet(theme);
const bindings: AppBinding[] | undefined = copyAndFillBindings(embed)?.bindings;
return (
<>
<View style={style.container}>
{Boolean(embed.label) &&
<EmbedTitle
theme={theme}
value={embed.label}
/>
}
{Boolean(embed.description) &&
<EmbedText
value={embed.description!}
theme={theme}
/>
}
{Boolean(bindings?.length) &&
<EmbedSubBindings
bindings={bindings!}
post={post}
theme={theme}
/>
}
</View>
</>
);
};
export default EmbeddedBinding;

View file

@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import ButtonBinding from './button_binding';
import BindingMenu from './menu_binding';
import type PostModel from '@typings/database/models/servers/post';
type Props = {
bindings: AppBinding[];
post: PostModel;
theme: Theme;
}
const EmbeddedSubBindings = ({bindings, post, theme}: Props) => {
const content = [] as React.ReactNode[];
bindings.forEach((binding) => {
if (!binding.app_id || !binding.call) {
return;
}
if (binding.bindings?.length) {
content.push(
<BindingMenu
key={binding.location}
binding={binding}
post={post}
theme={theme}
/>,
);
return;
}
content.push(
<ButtonBinding
key={binding.location}
binding={binding}
post={post}
theme={theme}
/>,
);
});
return content.length ? (<>{content}</>) : null;
};
export default EmbeddedSubBindings;

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import EmbeddedBinding from './embedded_binding';
import type PostModel from '@typings/database/models/servers/post';
type Props = {
post: PostModel;
theme: Theme;
}
const EmbeddedBindings = ({post, theme}: Props) => {
const content = [] as React.ReactNode[];
const embeds: AppBinding[] = post.props.app_bindings;
embeds.forEach((embed, i) => {
content.push(
<EmbeddedBinding
embed={embed}
key={'binding_' + i.toString()}
post={post}
theme={theme}
/>,
);
});
return (
<View style={{flex: 1, flexDirection: 'column'}}>
{content}
</View>
);
};
export default EmbeddedBindings;

View file

@ -0,0 +1,118 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/remote/apps';
import AutocompleteSelector from '@components/autocomplete_selector';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@constants/apps';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useServerUrl} from '@context/server_url';
import {createCallContext, createCallRequest} from '@utils/apps';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
import type SystemModel from '@typings/database/models/servers/system';
type Props = {
binding: AppBinding;
currentTeamId: string;
post: PostModel;
teamID?: string;
theme: Theme;
}
const {SERVER: {SYSTEM}} = MM_TABLES;
const MenuBinding = ({binding, currentTeamId, post, teamID, theme}: Props) => {
const [selected, setSelected] = useState<PostActionOption>();
const intl = useIntl();
const serverUrl = useServerUrl();
const onSelect = useCallback(async (picked?: PostActionOption) => {
if (!picked) {
return;
}
setSelected(picked);
const bind = binding.bindings?.find((b) => b.location === picked.value);
if (!bind) {
console.debug('Trying to select element not present in binding.'); //eslint-disable-line no-console
return;
}
if (!bind.call) {
return;
}
const context = createCallContext(
bind.app_id,
AppBindingLocations.IN_POST + bind.location,
post.channelId,
teamID || currentTeamId,
post.id,
);
const call = createCallRequest(
bind.call,
context,
{post: AppExpandLevels.EXPAND_ALL},
);
const res = await doAppCall(serverUrl, call, AppCallTypes.SUBMIT, intl, theme);
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.error || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
postEphemeralCallResponseForPost(serverUrl, res.error, errorMessage, post);
return;
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
postEphemeralCallResponseForPost(serverUrl, callResp, callResp.markdown, post);
}
return;
case AppCallResponseTypes.NAVIGATE:
case AppCallResponseTypes.FORM:
return;
default: {
const errorMessage = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
postEphemeralCallResponseForPost(serverUrl, callResp, errorMessage, post);
}
}
}, []);
const options = binding.bindings?.map<PostActionOption>((b: AppBinding) => ({text: b.label, value: b.location || ''}));
return (
<AutocompleteSelector
placeholder={binding.label}
options={options}
selected={selected}
onSelected={onSelect}
/>
);
};
const withTeamId = withObservables(['post'], ({post}: {post: PostModel}) => ({
teamID: post.channel.observe().pipe(switchMap((channel: ChannelModel) => of$(channel.teamId))),
currentTeamId: post.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe(
switchMap((currentTeamId: SystemModel) => of$(currentTeamId.value)),
),
}));
export default withTeamId(React.memo(MenuBinding));

View file

@ -0,0 +1,140 @@
// 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, useEffect, useRef, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {getRedirectLocation} from '@actions/remote/general';
import FileIcon from '@app/components/post_list/post/body/files/file_icon';
import ProgressiveImage from '@components/progressive_image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Device} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useServerUrl} from '@context/server_url';
import useDidUpdate from '@hooks/did_update';
import {useSplitView} from '@hooks/device';
import {generateId} from '@utils/general';
import {openGallerWithMockFile} from '@utils/gallery';
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
import {changeOpacity} from '@utils/theme';
import {isImageLink, isValidUrl} from '@utils/url';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
type ImagePreviewProps = {
expandedLink?: string;
isReplyPost: boolean;
link: string;
metadata: PostMetadata;
postId: string;
theme: Theme;
}
const styles = StyleSheet.create({
imageContainer: {
alignItems: 'flex-start',
justifyContent: 'flex-start',
marginBottom: 6,
marginTop: 10,
},
image: {
alignItems: 'center',
borderRadius: 3,
justifyContent: 'center',
marginVertical: 1,
},
});
const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme}: ImagePreviewProps) => {
const [error, setError] = useState(false);
const serverUrl = useServerUrl();
const fileId = useRef(generateId()).current;
const [imageUrl, setImageUrl] = useState(expandedLink || link);
const splitView = useSplitView();
const tabletOffset = !splitView && Device.IS_TABLET;
const imageProps = metadata.images![link];
const dimensions = calculateDimensions(imageProps.height, imageProps.width, getViewPortWidth(isReplyPost, tabletOffset));
const onError = useCallback(() => {
setError(true);
}, []);
const onPress = useCallback(() => {
openGallerWithMockFile(imageUrl, postId, imageProps.height, imageProps.width, fileId);
}, [imageUrl]);
useEffect(() => {
if (!isImageLink(link) && expandedLink === undefined) {
getRedirectLocation(serverUrl, link);
}
}, [link]);
useDidUpdate(() => {
if (expandedLink) {
setImageUrl(expandedLink);
} else if (link !== imageUrl) {
setImageUrl(link);
}
}, [link]);
useEffect(() => {
if (expandedLink && expandedLink !== imageUrl) {
setImageUrl(expandedLink);
}
}, [expandedLink]);
if (error || !isValidUrl(expandedLink || link) || isGifTooLarge(imageProps)) {
return (
<View style={[styles.imageContainer, {height: dimensions.height, borderWidth: 1, borderColor: changeOpacity(theme.centerChannelColor, 0.2)}]}>
<View style={[styles.image, {width: dimensions.width, height: dimensions.height}]}>
<FileIcon
failed={true}
theme={theme}
/>
</View>
</View>
);
}
// Note that the onPress prop of TouchableWithoutFeedback only works if its child is a View
return (
<TouchableWithFeedback
onPress={onPress}
style={[styles.imageContainer, {height: dimensions.height}]}
type={'none'}
>
<View>
<ProgressiveImage
id={fileId}
style={[styles.image, {width: dimensions.width, height: dimensions.height}]}
imageUri={imageUrl}
resizeMode='contain'
onError={onError}
/>
</View>
</TouchableWithFeedback>
);
};
const withExpandedLink = withObservables(['metadata'], ({database, metadata}: WithDatabaseArgs & {metadata: PostMetadata}) => {
const link = metadata.embeds?.[0].url;
return {
expandedLink: database.get(MM_TABLES.SERVER.SYSTEM).query(
Q.where('id', SYSTEM_IDENTIFIERS.EXPANDED_LINKS),
).observe().pipe(
switchMap((values: SystemModel[]) => (
(link && values.length) ? of$((values[0].value as Record<string, string>)[link]) : of$(undefined)),
),
),
link: of$(link),
};
});
export default withDatabase(withExpandedLink(React.memo(ImagePreview)));

View file

@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {isYoutubeLink} from '@utils/url';
import EmbeddedBindings from './embedded_bindings';
import ImagePreview from './image_preview';
import MessageAttachments from './message_attachments';
import Opengraph from './opengraph';
import YouTube from './youtube';
import type PostModel from '@typings/database/models/servers/post';
type ContentProps = {
isReplyPost: boolean;
post: PostModel;
theme: Theme;
}
const contentType: Record<string, string> = {
app_bindings: 'app_bindings',
image: 'image',
message_attachment: 'message_attachment',
opengraph: 'opengraph',
youtube: 'youtube',
};
const Content = ({isReplyPost, post, theme}: ContentProps) => {
let type: string = post.metadata?.embeds?.[0].type as string;
if (!type && post.props?.attachments?.length) {
type = contentType.app_bindings;
}
if (!type) {
return null;
}
switch (contentType[type]) {
case contentType.image:
return (
<ImagePreview
isReplyPost={isReplyPost}
metadata={post.metadata!}
postId={post.id}
theme={theme}
/>
);
case contentType.opengraph:
if (isYoutubeLink(post.metadata!.embeds![0].url)) {
return (
<YouTube
isReplyPost={isReplyPost}
metadata={post.metadata!}
/>
);
}
return (
<Opengraph
isReplyPost={isReplyPost}
metadata={post.metadata!}
postId={post.id}
removeLinkPreview={post.props?.remove_link_preview === 'true'}
theme={theme}
/>
);
case contentType.message_attachment:
if (post.props.attachments?.length) {
return (
<MessageAttachments
attachments={post.props.attachments}
metadata={post.metadata!}
postId={post.id}
theme={theme}
/>
);
}
break;
case contentType.app_bindings:
if (post.props.app_bindings?.length) {
return (
<EmbeddedBindings
post={post}
theme={theme}
/>
);
}
break;
}
return null;
};
export default Content;

View file

@ -0,0 +1,84 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AttachmentFooter it matches snapshot when both footer and footer_icon are provided 1`] = `
<View
style={
Object {
"flex": 1,
"flexDirection": "row",
"marginTop": 5,
}
}
>
<View
style={
Array [
Object {
"overflow": "hidden",
},
Object {
"height": 12,
"marginRight": 5,
"marginTop": 1,
"width": 12,
},
]
}
>
<FastImageView
resizeMode="cover"
source={
Object {
"uri": "https://images.com/image.png",
}
}
style={
Object {
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
/>
</View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Object {
"color": "rgba(63,67,80,0.5)",
"fontSize": 11,
}
}
>
This is the footer!
</Text>
</View>
`;
exports[`AttachmentFooter it matches snapshot when footer text is provided 1`] = `
<View
style={
Object {
"flex": 1,
"flexDirection": "row",
"marginTop": 5,
}
}
>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Object {
"color": "rgba(63,67,80,0.5)",
"fontSize": 11,
}
}
>
This is the footer!
</Text>
</View>
`;

View file

@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text, View, StyleSheet, StyleProp, TextStyle} from 'react-native';
import Emoji from '@components/emoji';
import {reEmoji, reEmoticon, reMain} from '@constants/emoji';
import {getEmoticonName} from '@utils/emoji/helpers';
type Props = {
message: string;
style: StyleProp<TextStyle>;
}
const styles = StyleSheet.create({
container: {
alignItems: 'flex-start',
flexDirection: 'row',
flexWrap: 'wrap',
},
});
const ActionButtonText = ({message, style}: Props) => {
const components = [] as React.ReactNode[];
let text = message;
while (text) {
let match;
// See if the text starts with an emoji
if ((match = text.match(reEmoji))) {
components.push(
<Emoji
key={components.length}
literal={match[0]}
emojiName={match[1]}
textStyle={style}
/>,
);
text = text.substring(match[0].length);
continue;
}
// Or an emoticon
if ((match = text.match(reEmoticon))) {
const emoticonName = getEmoticonName(match[0]);
if (emoticonName) {
components.push(
<Emoji
key={components.length}
literal={match[0]}
emojiName={emoticonName}
textStyle={style}
/>,
);
text = text.substring(match[0].length);
continue;
}
}
// This is plain text, so capture as much text as possible until we hit the next possible emoji. Note that
// reMain always captures at least one character, so text will always be getting shorter
match = text.match(reMain);
if (!match) {
continue;
}
components.push(
<Text
key={components.length}
style={style}
>
{match[0]}
</Text>,
);
text = text.substring(match[0].length);
}
return (
<View style={styles.container}>
{components}
</View>
);
};
export default ActionButtonText;

View file

@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import Button from 'react-native-button';
import {postActionWithCookie} from '@actions/remote/post';
import {useServerUrl} from '@context/server_url';
import {getStatusColors} from '@utils/message_attachment_colors';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import ActionButtonText from './action_button_text';
type Props = {
buttonColor?: string;
cookie?: string;
disabled?: boolean;
id: string;
name: string;
postId: string;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const STATUS_COLORS = getStatusColors(theme);
return {
button: {
borderRadius: 4,
borderColor: changeOpacity(STATUS_COLORS.default, 0.25),
borderWidth: 2,
opacity: 1,
alignItems: 'center',
marginTop: 12,
justifyContent: 'center',
height: 36,
},
buttonDisabled: {
backgroundColor: changeOpacity(theme.buttonBg, 0.3),
},
text: {
color: STATUS_COLORS.default,
fontSize: 15,
fontWeight: '600',
lineHeight: 17,
},
};
});
const ActionButton = ({buttonColor, cookie, disabled, id, name, postId, theme}: Props) => {
const presssed = useRef(false);
const serverUrl = useServerUrl();
const style = getStyleSheet(theme);
let customButtonStyle;
let customButtonTextStyle;
const onPress = useCallback(preventDoubleTap(async () => {
if (!presssed.current) {
presssed.current = true;
await postActionWithCookie(serverUrl, postId, id, cookie || '');
presssed.current = false;
}
}), [id, postId, cookie]);
if (buttonColor) {
const STATUS_COLORS = getStatusColors(theme);
const hexColor = STATUS_COLORS[buttonColor] || theme[buttonColor] || buttonColor;
customButtonStyle = {borderColor: changeOpacity(hexColor, 0.25), backgroundColor: '#ffffff'};
customButtonTextStyle = {color: hexColor};
}
return (
<Button
containerStyle={[style.button, customButtonStyle]}
disabledContainerStyle={style.buttonDisabled}
onPress={onPress}
disabled={disabled}
>
<ActionButtonText
message={name}
style={{...style.text, ...customButtonTextStyle}}
/>
</Button>
);
};
export default ActionButton;

View file

@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {selectAttachmentMenuAction} from '@actions/local/post';
import {useServerUrl} from '@context/server_url';
import AutocompleteSelector from '@components/autocomplete_selector';
type Props = {
dataSource?: string;
defaultOption?: string;
disabled?: boolean;
id: string;
name: string;
options?: PostActionOption[];
postId: string;
}
const ActionMenu = ({dataSource, defaultOption, disabled, id, name, options, postId}: Props) => {
let isSelected: PostActionOption | undefined;
const serverUrl = useServerUrl();
if (defaultOption && options) {
isSelected = options.find((option) => option.value === defaultOption);
}
const [selected, setSelected] = useState(isSelected);
const handleSelect = useCallback(async (selectedItem?: PostActionOption) => {
if (!selectedItem) {
return;
}
const result = await selectAttachmentMenuAction(serverUrl, postId, id, selectedItem.value);
if (result.data?.trigger_id) {
setSelected(selectedItem);
}
}, []);
return (
<AutocompleteSelector
placeholder={name}
dataSource={dataSource}
options={options}
selected={selected}
onSelected={handleSelect}
disabled={disabled}
/>
);
};
export default ActionMenu;

View file

@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import ActionButton from './action_button';
import ActionMenu from './action_menu';
type Props = {
actions: PostAction[];
postId: string;
theme: Theme;
}
const AttachmentActions = ({actions, postId, theme}: Props) => {
const content = [] as React.ReactNode[];
actions.forEach((action) => {
if (!action.id || !action.name) {
return;
}
switch (action.type) {
case 'select':
content.push(
<ActionMenu
key={action.id}
id={action.id}
name={action.name}
dataSource={action.data_source}
defaultOption={action.default_option}
options={action.options}
postId={postId}
disabled={action.disabled}
/>,
);
break;
case 'button':
default:
content.push(
<ActionButton
key={action.id}
id={action.id}
cookie={action.cookie}
name={action.name}
postId={postId}
disabled={action.disabled}
buttonColor={action.style}
theme={theme}
/>,
);
break;
}
});
return content.length ? (<>{content}</>) : null;
};
export default AttachmentActions;

View file

@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {Alert, Text, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {tryOpenURL} from '@utils/url';
type Props = {
icon?: string;
link?: string;
name?: string;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
flexDirection: 'row',
},
icon: {
height: 12,
marginRight: 3,
width: 12,
},
name: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 11,
},
link: {color: changeOpacity(theme.linkColor, 0.5)},
};
});
const AttachmentAuthor = ({icon, link, name, theme}: Props) => {
const intl = useIntl();
const style = getStyleSheet(theme);
const openLink = () => {
if (link) {
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(link, onError);
}
};
return (
<View style={style.container}>
{Boolean(icon) &&
<FastImage
source={{uri: icon}}
key='author_icon'
style={style.icon}
/>
}
{Boolean(name) &&
<Text
key='author_name'
style={[style.name, Boolean(link) && style.link]}
onPress={openLink}
>
{name}
</Text>
}
</View>
);
};
export default AttachmentAuthor;

View file

@ -0,0 +1,128 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, Text, TextStyle, View} from 'react-native';
import Markdown from '@components/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type {MarkdownBlockStyles, MarkdownTextStyles} from '@typings/global/markdown';
type Props = {
baseTextStyle: StyleProp<TextStyle>;
blockStyles?: MarkdownBlockStyles;
fields: MessageAttachmentField[];
metadata?: PostMetadata;
textStyles?: MarkdownTextStyles;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
field: {
alignSelf: 'stretch',
flexDirection: 'row',
},
flex: {
flex: 1,
},
headingContainer: {
alignSelf: 'stretch',
flexDirection: 'row',
marginBottom: 5,
marginTop: 10,
},
heading: {
color: theme.centerChannelColor,
fontWeight: '600',
},
table: {
flex: 1,
flexDirection: 'row',
},
};
});
const AttachmentFields = ({baseTextStyle, blockStyles, fields, metadata, textStyles, theme}: Props) => {
const style = getStyleSheet(theme);
const fieldTables = [];
let fieldInfos = [] as React.ReactNode[];
let rowPos = 0;
let lastWasLong = false;
let nrTables = 0;
fields.forEach((field, i) => {
if (rowPos === 2 || !(field.short === true) || lastWasLong) {
fieldTables.push(
<View
key={`attachment__table__${nrTables}`}
style={style.field}
>
{fieldInfos}
</View>,
);
fieldInfos = [];
rowPos = 0;
nrTables += 1;
lastWasLong = false;
}
fieldInfos.push(
<View
style={style.flex}
key={`attachment__field-${i.toString()}__${nrTables}`}
>
{Boolean(field.title) && (
<View
style={style.headingContainer}
key={`attachment__field-caption-${i.toString()}__${nrTables}`}
>
<View>
<Text style={style.heading}>
{field.title}
</Text>
</View>
</View>
)}
<View
style={style.flex}
key={`attachment__field-${i.toString()}__${nrTables}`}
>
<Markdown
baseTextStyle={baseTextStyle as never}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}
imagesMetadata={metadata?.images}
theme={theme}
value={(field.value || '')}
/>
</View>
</View>,
);
rowPos += 1;
lastWasLong = !(field.short === true);
});
if (fieldInfos.length > 0) { // Flush last fields
fieldTables.push(
<View
key={`attachment__table__${nrTables}`}
style={style.table}
>
{fieldInfos}
</View>,
);
}
return (
<View>
{fieldTables}
</View>
);
};
export default AttachmentFields;

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 Preferences from '@constants/preferences';
import {render} from '@test/intl-test-helper';
import AttachmentFooter from './attachment_footer';
describe('AttachmentFooter', () => {
const baseProps = {
text: 'This is the footer!',
icon: 'https://images.com/image.png',
theme: Preferences.THEMES.denim,
};
test('it matches snapshot when footer text is provided', () => {
const props = {
...baseProps,
icon: undefined,
};
const wrapper = render(<AttachmentFooter {...props}/>);
expect(wrapper.toJSON()).toMatchSnapshot();
});
test('it matches snapshot when both footer and footer_icon are provided', () => {
const wrapper = render(<AttachmentFooter {...baseProps}/>);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text, View, Platform} from 'react-native';
import FastImage from 'react-native-fast-image';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
icon?: string;
text: string;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
flexDirection: 'row',
marginTop: 5,
},
icon: {
height: 12,
width: 12,
marginRight: 5,
marginTop: Platform.select({android: 2, ios: 1}),
},
text: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 11,
},
};
});
const AttachmentFooter = ({icon, text, theme}: Props) => {
const style = getStyleSheet(theme);
return (
<View style={style.container}>
{Boolean(icon) &&
<FastImage
source={{uri: icon}}
key='footer_icon'
style={style.icon}
/>
}
<Text
key='footer_text'
style={style.text}
ellipsizeMode='tail'
numberOfLines={1}
>
{text}
</Text>
</View>
);
};
export default AttachmentFooter;

View file

@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef, useState} from 'react';
import {View} from 'react-native';
import FileIcon from '@app/components/post_list/post/body/files/file_icon';
import ProgressiveImage from '@components/progressive_image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Device} from '@constants';
import {useSplitView} from '@hooks/device';
import {generateId} from '@utils/general';
import {openGallerWithMockFile} from '@utils/gallery';
import {isGifTooLarge, calculateDimensions, getViewPortWidth} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {isValidUrl} from '@utils/url';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
attachmentMargin: {
marginTop: 2.5,
marginLeft: 2.5,
marginBottom: 5,
marginRight: 5,
},
container: {marginTop: 5},
imageContainer: {
borderColor: changeOpacity(theme.centerChannelColor, 0.1),
borderWidth: 1,
borderRadius: 2,
flex: 1,
},
image: {
alignItems: 'center',
borderRadius: 3,
justifyContent: 'center',
marginVertical: 1,
},
};
});
export type Props = {
imageMetadata: PostImage;
imageUrl: string;
postId: string;
theme: Theme;
}
const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => {
const [error, setError] = useState(false);
const fileId = useRef(generateId()).current;
const splitView = useSplitView();
const tabletOffset = !splitView && Device.IS_TABLET;
const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, getViewPortWidth(false, tabletOffset));
const style = getStyleSheet(theme);
const onError = useCallback(() => {
setError(true);
}, []);
const onPress = useCallback(() => {
openGallerWithMockFile(imageUrl, postId, imageMetadata.height, imageMetadata.width);
}, [imageUrl]);
if (error || !isValidUrl(imageUrl) || isGifTooLarge(imageMetadata)) {
return (
<View style={[style.imageContainer, {height, borderWidth: 1, borderColor: changeOpacity(theme.centerChannelColor, 0.2)}]}>
<View style={[style.image, {width, height}]}>
<FileIcon
failed={true}
theme={theme}
/>
</View>
</View>
);
}
return (
<TouchableWithFeedback
onPress={onPress}
style={[style.container, {width}]}
type={'none'}
>
<View
style={[style.imageContainer, {width, height}]}
>
<ProgressiveImage
id={fileId}
imageStyle={style.attachmentMargin}
imageUri={imageUrl}
onError={onError}
resizeMode='contain'
style={{height, width}}
/>
</View>
</TouchableWithFeedback>
);
};
export default AttachmentImage;

View file

@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, StyleSheet, TextStyle, View} from 'react-native';
import Markdown from '@components/markdown';
import type {MarkdownBlockStyles, MarkdownTextStyles} from '@typings/global/markdown';
type Props = {
baseTextStyle: StyleProp<TextStyle>;
blockStyles?: MarkdownBlockStyles;
metadata?: PostMetadata;
textStyles?: MarkdownTextStyles;
theme: Theme;
value?: string;
}
const style = StyleSheet.create({
container: {
marginTop: 5,
},
});
export default function AttachmentPreText(props: Props) {
const {
baseTextStyle,
blockStyles,
metadata,
value,
textStyles,
} = props;
if (!value) {
return null;
}
return (
<View style={style.container}>
<Markdown
baseTextStyle={baseTextStyle as never}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}
imagesMetadata={metadata?.images}
theme={props.theme}
value={value}
/>
</View>
);
}

View file

@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {LayoutChangeEvent, useWindowDimensions, ScrollView, StyleProp, StyleSheet, TextStyle, View} from 'react-native';
import Animated from 'react-native-reanimated';
import Markdown from '@components/markdown';
import ShowMoreButton from '@app/components/post_list/post/body/message/show_more_button';
import {useShowMoreAnimatedStyle} from '@hooks/show_more';
import type {MarkdownBlockStyles, MarkdownTextStyles} from '@typings/global/markdown';
type Props = {
baseTextStyle: StyleProp<TextStyle>;
blockStyles?: MarkdownBlockStyles;
hasThumbnail?: boolean;
metadata?: PostMetadata;
textStyles?: MarkdownTextStyles;
theme: Theme;
value?: string;
}
const SHOW_MORE_HEIGHT = 54;
const style = StyleSheet.create({
container: {
paddingRight: 12,
},
});
const AttachmentText = ({baseTextStyle, blockStyles, hasThumbnail, metadata, textStyles, theme, value}: Props) => {
const [open, setOpen] = useState(false);
const [height, setHeight] = useState<number|undefined>();
const dimensions = useWindowDimensions();
const maxHeight = Math.round((dimensions.height * 0.4) + SHOW_MORE_HEIGHT);
const animatedStyle = useShowMoreAnimatedStyle(height, maxHeight, open);
const onLayout = useCallback((event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), []);
const onPress = () => setOpen(!open);
return (
<View style={hasThumbnail && style.container}>
<Animated.View style={animatedStyle}>
<ScrollView
keyboardShouldPersistTaps={'always'}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
>
<View onLayout={onLayout}>
<Markdown
baseTextStyle={baseTextStyle as never}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}
imagesMetadata={metadata?.images}
value={value}
theme={theme}
/>
</View>
</ScrollView>
</Animated.View>
{(height || 0) > maxHeight &&
<ShowMoreButton
highlight={false}
theme={theme}
showMore={!open}
onPress={onPress}
/>
}
</View>
);
};
export default AttachmentText;

View file

@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import FastImage from 'react-native-fast-image';
type Props = {
uri: string;
}
const style = StyleSheet.create({
container: {
position: 'absolute',
right: 10,
top: 10,
},
image: {
height: 45,
width: 45,
},
});
const AttachmentThumbnail = ({uri}: Props) => {
return (
<View style={style.container}>
<FastImage
source={{uri}}
resizeMode='contain'
style={style.image}
/>
</View>
);
};
export default AttachmentThumbnail;

View file

@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {Alert, Text, View} from 'react-native';
import Markdown from '@components/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {tryOpenURL} from '@utils/url';
type Props = {
link?: string;
theme: Theme;
value?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
flexDirection: 'row',
marginTop: 3,
},
link: {color: theme.linkColor},
title: {
color: theme.centerChannelColor,
fontSize: 14,
fontWeight: '600',
lineHeight: 20,
marginBottom: 5,
},
};
});
const AttachmentTitle = ({link, theme, value}: Props) => {
const intl = useIntl();
const style = getStyleSheet(theme);
const openLink = () => {
if (link) {
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(link, onError);
}
};
let title;
if (link) {
title = (
<Text
style={[style.title, Boolean(link) && style.link]}
onPress={openLink}
>
{value}
</Text>
);
} else {
title = (
<Markdown
isEdited={false}
isReplyPost={false}
disableHashtags={true}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
autolinkedUrlSchemes={[]}
mentionKeys={[]}
theme={theme}
value={value}
baseTextStyle={style.title}
textStyles={{link: style.link}}
/>
);
}
return (
<View style={style.container}>
{title}
</View>
);
};
export default AttachmentTitle;

View file

@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import MessageAttachment from './message_attachment';
type Props = {
attachments: MessageAttachment[];
postId: string;
metadata?: PostMetadata;
theme: Theme;
}
const styles = StyleSheet.create({
content: {
flex: 1,
flexDirection: 'column',
},
});
const MessageAttachments = ({attachments, metadata, postId, theme}: Props) => {
const content = [] as React.ReactNode[];
attachments.forEach((attachment, i) => {
content.push(
<MessageAttachment
attachment={attachment}
key={'att_' + i.toString()}
metadata={metadata}
postId={postId}
theme={theme}
/>,
);
});
return (
<View style={styles.content}>
{content}
</View>
);
};
export default MessageAttachments;

View file

@ -0,0 +1,142 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {getStatusColors} from '@utils/message_attachment_colors';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {isValidUrl} from '@utils/url';
import AttachmentActions from './attachment_actions';
import AttachmentAuthor from './attachment_author';
import AttachmentFields from './attachment_fields';
import AttachmentFooter from './attachment_footer';
import AttachmentImage from './attachment_image';
import AttachmentPreText from './attachment_pretext';
import AttachmentText from './attachment_text';
import AttachmentThumbnail from './attachment_thumbnail';
import AttachmentTitle from './attachment_title';
type Props = {
attachment: MessageAttachment;
metadata?: PostMetadata;
postId: string;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.15),
borderRightColor: changeOpacity(theme.centerChannelColor, 0.15),
borderTopColor: changeOpacity(theme.centerChannelColor, 0.15),
borderBottomWidth: 1,
borderRightWidth: 1,
borderTopWidth: 1,
marginTop: 5,
padding: 12,
},
border: {
borderLeftColor: changeOpacity(theme.linkColor, 0.6),
borderLeftWidth: 3,
},
message: {
color: theme.centerChannelColor,
fontSize: 15,
lineHeight: 20,
},
};
});
export default function MessageAttachment({attachment, metadata, postId, theme}: Props) {
const style = getStyleSheet(theme);
const blockStyles = getMarkdownBlockStyles(theme);
const textStyles = getMarkdownTextStyles(theme);
const STATUS_COLORS = getStatusColors(theme);
let borderStyle;
if (attachment.color) {
if (attachment.color[0] === '#') {
borderStyle = {borderLeftColor: attachment.color};
} else if (STATUS_COLORS[attachment.color]) {
borderStyle = {borderLeftColor: STATUS_COLORS[attachment.color]};
}
}
return (
<>
<AttachmentPreText
baseTextStyle={style.message}
blockStyles={blockStyles}
metadata={metadata}
textStyles={textStyles}
theme={theme}
value={attachment.pretext}
/>
<View style={[style.container, style.border, borderStyle]}>
{Boolean(attachment.author_icon && attachment.author_name) &&
<AttachmentAuthor
icon={attachment.author_icon}
link={attachment.author_link}
name={attachment.author_name}
theme={theme}
/>
}
{Boolean(attachment.title) &&
<AttachmentTitle
link={attachment.title_link}
theme={theme}
value={attachment.title}
/>
}
{isValidUrl(attachment.thumb_url) &&
<AttachmentThumbnail uri={attachment.thumb_url}/>
}
{Boolean(attachment.text) &&
<AttachmentText
baseTextStyle={style.message}
blockStyles={blockStyles}
hasThumbnail={Boolean(attachment.thumb_url)}
metadata={metadata}
textStyles={textStyles}
value={attachment.text}
theme={theme}
/>
}
{Boolean(attachment.fields?.length) &&
<AttachmentFields
baseTextStyle={style.message}
blockStyles={blockStyles}
fields={attachment.fields}
metadata={metadata}
textStyles={textStyles}
theme={theme}
/>
}
{Boolean(attachment.footer) &&
<AttachmentFooter
icon={attachment.footer_icon}
text={attachment.footer}
theme={theme}
/>
}
{Boolean(attachment.actions?.length) &&
<AttachmentActions
actions={attachment.actions!}
postId={postId}
theme={theme}
/>
}
{Boolean(metadata?.images?.[attachment.image_url]) &&
<AttachmentImage
imageUrl={attachment.image_url}
imageMetadata={metadata!.images![attachment.image_url]}
postId={postId}
theme={theme}
/>
}
</View>
</>
);
}

View file

@ -0,0 +1,201 @@
// 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 {useIntl} from 'react-intl';
import {Alert, Text, View} from 'react-native';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Preferences} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {tryOpenURL} from '@utils/url';
import OpengraphImage from './opengraph_image';
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';
type OpengraphProps = {
isReplyPost: boolean;
metadata: PostMetadata;
postId: string;
showLinkPreviews: boolean;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRadius: 3,
borderWidth: 1,
flex: 1,
marginTop: 10,
padding: 10,
},
flex: {flex: 1},
siteDescription: {
color: changeOpacity(theme.centerChannelColor, 0.7),
fontSize: 13,
marginBottom: 10,
},
siteName: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 12,
marginBottom: 10,
},
siteTitle: {
color: theme.linkColor,
fontSize: 14,
marginBottom: 10,
},
};
});
const selectOpenGraphData = (url: string, metadata: PostMetadata) => {
if (!metadata.embeds) {
return undefined;
}
return metadata.embeds.find((embed) => {
return embed.type === 'opengraph' && embed.url === url ? embed.data : undefined;
})?.data;
};
const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: OpengraphProps) => {
const intl = useIntl();
const link = metadata.embeds![0]!.url;
const openGraphData = selectOpenGraphData(link, metadata);
if (!showLinkPreviews || !openGraphData) {
return null;
}
const style = getStyleSheet(theme);
const hasImage = Boolean(
openGraphData?.images &&
openGraphData.images instanceof Array &&
openGraphData.images.length &&
metadata.images);
const goToLink = () => {
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(link, onError);
};
let siteName;
if (openGraphData.site_name) {
siteName = (
<View style={style.flex}>
<Text
style={style.siteName}
numberOfLines={1}
ellipsizeMode='tail'
>
{openGraphData.site_name as string}
</Text>
</View>
);
}
const title = openGraphData.title || openGraphData.url || link;
let siteTitle;
if (title) {
siteTitle = (
<View style={style.wrapper}>
<TouchableWithFeedback
style={style.flex}
onPress={goToLink}
type={'opacity'}
>
<Text
style={[style.siteTitle, {marginRight: isReplyPost ? 10 : 0}]}
numberOfLines={3}
ellipsizeMode='tail'
>
{title as string}
</Text>
</TouchableWithFeedback>
</View>
);
}
let siteDescription;
if (openGraphData.description) {
siteDescription = (
<View style={style.flex}>
<Text
style={style.siteDescription}
numberOfLines={5}
ellipsizeMode='tail'
>
{openGraphData.description as string}
</Text>
</View>
);
}
return (
<View style={style.container}>
{siteName}
{siteTitle}
{siteDescription}
{hasImage &&
<OpengraphImage
isReplyPost={isReplyPost}
openGraphImages={openGraphData.images as never[]}
metadata={metadata}
postId={postId}
theme={theme}
/>
}
</View>
);
};
const withOpenGraphInput = withObservables(
['removeLinkPreview'], ({database, removeLinkPreview}: WithDatabaseArgs & {removeLinkPreview: boolean}) => {
if (removeLinkPreview) {
return {showLinkPreviews: of$(false)};
}
const showLinkPreviews = database.get(MM_TABLES.SERVER.PREFERENCE).query(
Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS),
Q.where('name', Preferences.LINK_PREVIEW_DISPLAY),
).observe().pipe(
switchMap(
(preferences: PreferenceModel[]) => database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
// eslint-disable-next-line max-nested-callbacks
switchMap((config: SystemModel) => {
const cfg: ClientConfig = config.value;
const previewsEnabled = getPreferenceAsBool(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.LINK_PREVIEW_DISPLAY, true);
return of$(previewsEnabled && cfg.EnableLinkPreviews === 'true');
}),
),
),
);
return {showLinkPreviews};
});
export default withDatabase(withOpenGraphInput(React.memo(Opengraph)));

View file

@ -0,0 +1,111 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import {useWindowDimensions, View} from 'react-native';
import FastImage, {Source} from 'react-native-fast-image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Device as DeviceConstant, View as ViewConstants} from '@constants';
import {openGallerWithMockFile} from '@utils/gallery';
import {generateId} from '@utils/general';
import {calculateDimensions} from '@utils/images';
import {BestImage, getNearestPoint} from '@utils/opengraph';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {isValidUrl} from '@utils/url';
type OpengraphImageProps = {
isReplyPost: boolean;
metadata: PostMetadata;
openGraphImages: never[];
postId: string;
theme: Theme;
}
const MAX_IMAGE_HEIGHT = 150;
const VIEWPORT_IMAGE_OFFSET = 93;
const VIEWPORT_IMAGE_REPLY_OFFSET = 13;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
imageContainer: {
alignItems: 'center',
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderWidth: 1,
borderRadius: 3,
marginTop: 5,
},
image: {
borderRadius: 3,
},
};
});
const getViewPostWidth = (isReplyPost: boolean, deviceHeight: number, deviceWidth: number) => {
const deviceSize = deviceWidth > deviceHeight ? deviceHeight : deviceWidth;
const viewPortWidth = deviceSize - VIEWPORT_IMAGE_OFFSET - (isReplyPost ? VIEWPORT_IMAGE_REPLY_OFFSET : 0);
const tabletOffset = DeviceConstant.IS_TABLET ? ViewConstants.TABLET.SIDEBAR_WIDTH : 0;
return viewPortWidth - tabletOffset;
};
const OpengraphImage = ({isReplyPost, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => {
const fileId = useRef(generateId()).current;
const dimensions = useWindowDimensions();
const style = getStyleSheet(theme);
const bestDimensions = {
height: MAX_IMAGE_HEIGHT,
width: getViewPostWidth(isReplyPost, dimensions.height, dimensions.width),
};
const bestImage = getNearestPoint(bestDimensions, openGraphImages, 'width', 'height') as BestImage;
const imageUrl = (bestImage.secure_url || bestImage.url)!;
const imagesMetadata = metadata.images;
let ogImage;
if (imagesMetadata && imagesMetadata[imageUrl]) {
ogImage = imagesMetadata[imageUrl];
}
if (!ogImage) {
ogImage = openGraphImages.find((i: BestImage) => i.url === imageUrl || i.secure_url === imageUrl);
}
// Fallback when the ogImage does not have dimensions but there is a metaImage defined
const metaImages = imagesMetadata ? Object.values(imagesMetadata) : null;
if ((!ogImage?.width || !ogImage?.height) && metaImages?.length) {
ogImage = metaImages[0];
}
let imageDimensions = bestDimensions;
if (ogImage?.width && ogImage?.height) {
imageDimensions = calculateDimensions(ogImage.height, ogImage.width, getViewPostWidth(isReplyPost, dimensions.height, dimensions.width));
}
const onPress = useCallback(() => {
openGallerWithMockFile(imageUrl, postId, imageDimensions.height, imageDimensions.width, fileId);
}, []);
const source: Source = {};
if (isValidUrl(imageUrl)) {
source.uri = imageUrl;
}
const dimensionsStyle = {width: imageDimensions.width, height: imageDimensions.height};
return (
<View style={[style.imageContainer, dimensionsStyle]}>
<TouchableWithFeedback
onPress={onPress}
type={'none'}
>
<FastImage
style={[style.image, dimensionsStyle]}
source={source}
resizeMode='contain'
nativeID={`image-${fileId}`}
/>
</TouchableWithFeedback>
</View>
);
};
export default OpengraphImage;

View file

@ -0,0 +1,196 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, Image, Platform, StatusBar, StyleSheet} from 'react-native';
import {YouTubeStandaloneAndroid, YouTubeStandaloneIOS} from 'react-native-youtube';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import ProgressiveImage from '@components/progressive_image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Device} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useSplitView} from '@hooks/device';
import {emptyFunction} from '@utils/general';
import {calculateDimensions, getViewPortWidth} from '@utils/images';
import {getYouTubeVideoId, tryOpenURL} from '@utils/url';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
type YouTubeProps = {
googleDeveloperKey?: string;
isReplyPost: boolean;
metadata: PostMetadata;
}
const MAX_YOUTUBE_IMAGE_HEIGHT = 202;
const MAX_YOUTUBE_IMAGE_WIDTH = 360;
const timeRegex = /[\\?&](t|start|time_continue)=([0-9]+h)?([0-9]+m)?([0-9]+s?)/;
const styles = StyleSheet.create({
imageContainer: {
alignItems: 'flex-start',
justifyContent: 'flex-start',
marginBottom: 6,
marginTop: 10,
},
image: {
alignItems: 'center',
borderRadius: 3,
justifyContent: 'center',
marginVertical: 1,
},
playButton: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
const YouTube = ({googleDeveloperKey, isReplyPost, metadata}: YouTubeProps) => {
const intl = useIntl();
const splitView = useSplitView();
const link = metadata.embeds![0].url;
const videoId = getYouTubeVideoId(link);
const tabletOffset = !splitView && Device.IS_TABLET;
const dimensions = calculateDimensions(
MAX_YOUTUBE_IMAGE_HEIGHT,
MAX_YOUTUBE_IMAGE_WIDTH,
getViewPortWidth(isReplyPost, tabletOffset),
);
const getYouTubeTime = () => {
const time = link.match(timeRegex);
if (!time || !time[0]) {
return 0;
}
const hours = time[2] ? time[2].match(/([0-9]+)h/) : null;
const minutes = time[3] ? time[3].match(/([0-9]+)m/) : null;
const seconds = time[4] ? time[4].match(/([0-9]+)s?/) : null;
let ticks = 0;
if (hours && hours[1]) {
ticks += parseInt(hours[1], 10) * 3600;
}
if (minutes && minutes[1]) {
ticks += parseInt(minutes[1], 10) * 60;
}
if (seconds && seconds[1]) {
ticks += parseInt(seconds[1], 10);
}
return ticks;
};
const playYouTubeVideo = useCallback(() => {
const startTime = getYouTubeTime();
if (googleDeveloperKey) {
if (Platform.OS === 'ios') {
YouTubeStandaloneIOS.
playVideo(videoId, startTime).
then(playYouTubeVideoEnded).
catch(playYouTubeVideoError);
return;
}
YouTubeStandaloneAndroid.playVideo({
apiKey: googleDeveloperKey,
videoId,
autoplay: true,
startTime,
}).catch(playYouTubeVideoError);
} else {
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(link, onError);
}
}, []);
const playYouTubeVideoEnded = () => {
if (Platform.OS === 'ios') {
StatusBar.setHidden(false);
}
};
const playYouTubeVideoError = (errorMessage: string) => {
Alert.alert(
intl.formatMessage({
id: 'mobile.youtube_playback_error.title',
defaultMessage: 'YouTube playback error',
}),
intl.formatMessage({
id: 'mobile.youtube_playback_error.description',
defaultMessage: 'An error occurred while trying to play the YouTube video.\nDetails: {details}',
}, {
details: errorMessage,
}),
);
};
let imgUrl;
if (metadata.images) {
imgUrl = Object.keys(metadata.images)[0];
}
if (!imgUrl) {
// Fallback to default YouTube thumbnail if available
imgUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
}
return (
<TouchableWithFeedback
style={[styles.imageContainer, {height: dimensions.height}]}
onPress={playYouTubeVideo}
type={'opacity'}
>
<ProgressiveImage
id={imgUrl}
isBackgroundImage={true}
imageUri={imgUrl}
style={[styles.image, dimensions]}
resizeMode='cover'
onError={emptyFunction}
>
<TouchableWithFeedback
style={styles.playButton}
onPress={playYouTubeVideo}
type={'opacity'}
>
<Image source={require('@assets/images/icons/youtube-play-icon.png')}/>
</TouchableWithFeedback>
</ProgressiveImage>
</TouchableWithFeedback>
);
};
const withGoogleKey = withObservables([], ({database}: WithDatabaseArgs) => ({
googleDeveloperKey: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((config: SystemModel) => {
const cfg: ClientConfig = config.value;
return of$(cfg.GoogleDeveloperKey);
}),
),
}));
export default withDatabase(withGoogleKey(React.memo(YouTube)));

View file

@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, StyleSheet, View} from 'react-native';
import {removePost} from '@actions/local/post';
import CompassIcon from '@components/compass_icon';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import Navigation from '@constants/navigation';
import {useServerUrl} from '@context/server_url';
import {showModalOverCurrentContext} from '@screens/navigation';
import type PostModel from '@typings/database/models/servers/post';
type FailedProps = {
post: PostModel;
theme: Theme;
}
const styles = StyleSheet.create({
bottomSheet: {
flex: 1,
},
retry: {
justifyContent: 'center',
marginLeft: 10,
},
});
// TODO: Add Create post local action
const retryPost = (serverUrl: string, post: PostModel) => post;
const Failed = ({post, theme}: FailedProps) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const onPress = useCallback(() => {
const renderContent = () => {
return (
<View
testID='at_mention.bottom_sheet'
style={styles.bottomSheet}
>
<SlideUpPanelItem
icon='send-outline'
onPress={() => {
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
retryPost(serverUrl, post);
}}
testID='post.failed.retry'
text={intl.formatMessage({id: 'mobile.post.failed_retry', defaultMessage: 'Try Again'})}
/>
<SlideUpPanelItem
destructive={true}
icon='close-circle-outline'
onPress={() => {
DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL);
removePost(serverUrl, post);
}}
testID='post.failed.delete'
text={intl.formatMessage({id: 'mobile.post.failed_delete', defaultMessage: 'Delete Message'})}
/>
</View>
);
};
showModalOverCurrentContext('BottomSheet', {
renderContent,
snapPoints: [3 * ITEM_HEIGHT, 10],
});
}, []);
return (
<TouchableWithFeedback
onPress={onPress}
style={styles.retry}
type={'opacity'}
>
<CompassIcon
name='information-outline'
size={26}
color={theme.errorTextColor}
/>
</TouchableWithFeedback>
);
};
export default Failed;

View file

@ -0,0 +1,203 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client';
import React, {forwardRef, useImperativeHandle, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Platform, StatusBar, StatusBarStyle, StyleSheet, View} from 'react-native';
import FileViewer from 'react-native-file-viewer';
import {FileSystem} from 'react-native-unimodules';
import tinyColor from 'tinycolor2';
import ProgressBar from '@components/progress_bar';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Device} from '@constants';
import {DOWNLOAD_TIMEOUT} from '@constants/network';
import {useServerUrl} from '@context/server_url';
import NetworkManager from '@init/network_manager';
import {alertDownloadDocumentDisabled, alertDownloadFailed, alertFailedToOpenDocument} from '@utils/document';
import {getLocalFilePathFromFile} from '@utils/file';
import FileIcon from './file_icon';
import type {Client} from '@client/rest';
export type DocumentFileRef = {
handlePreviewPress: () => void;
}
type DocumentFileProps = {
backgroundColor?: string;
canDownloadFiles: boolean;
file: FileInfo;
theme: Theme;
}
const {DOCUMENTS_PATH} = Device;
const styles = StyleSheet.create({
progress: {
justifyContent: 'flex-end',
height: 48,
left: 2,
top: 5,
width: 44,
},
});
const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({backgroundColor, canDownloadFiles, file, theme}: DocumentFileProps, ref) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const [didCancel, setDidCancel] = useState(false);
const [downloading, setDownloading] = useState(false);
const [preview, setPreview] = useState(false);
const [progress, setProgress] = useState(0);
let client: Client | undefined;
try {
client = NetworkManager.getClient(serverUrl);
} catch {
// do nothing
}
const downloadTask = useRef<ProgressPromise<ClientResponse>>();
const cancelDownload = () => {
setDidCancel(true);
if (downloadTask.current?.cancel) {
downloadTask.current.cancel();
}
};
const downloadAndPreviewFile = async () => {
const path = getLocalFilePathFromFile(DOCUMENTS_PATH, serverUrl, file);
setDidCancel(false);
try {
let exists = false;
if (path) {
const info = await FileSystem.getInfoAsync(path);
exists = info.exists;
}
if (exists) {
openDocument();
} else {
setDownloading(true);
downloadTask.current = client?.apiClient.download(client?.getFileRoute(file.id!), path!, {timeoutInterval: DOWNLOAD_TIMEOUT});
downloadTask.current?.progress?.(setProgress);
await downloadTask.current;
setProgress(1);
openDocument();
}
} catch (error) {
if (path) {
FileSystem.deleteAsync(path, {idempotent: true});
}
setDownloading(false);
setProgress(0);
if (error.message !== 'cancelled') {
alertDownloadFailed(intl);
}
}
};
const handlePreviewPress = async () => {
if (!canDownloadFiles) {
alertDownloadDocumentDisabled(intl);
return;
}
if (downloading && progress < 1) {
cancelDownload();
} else if (downloading) {
setProgress(0);
setDidCancel(true);
setDownloading(false);
} else {
downloadAndPreviewFile();
}
};
const onDonePreviewingFile = () => {
setProgress(0);
setDownloading(false);
setPreview(false);
setStatusBarColor();
};
const openDocument = () => {
if (!didCancel && !preview) {
const path = getLocalFilePathFromFile(DOCUMENTS_PATH, serverUrl, file);
setPreview(true);
setStatusBarColor('dark-content');
FileViewer.open(path!, {
displayName: file.name,
onDismiss: onDonePreviewingFile,
showOpenWithDialog: true,
showAppsSuggestions: true,
}).then(() => {
setDownloading(false);
setProgress(0);
}).catch(() => {
alertFailedToOpenDocument(file, intl);
onDonePreviewingFile();
if (path) {
FileSystem.deleteAsync(path, {idempotent: true});
}
});
}
};
const setStatusBarColor = (style: StatusBarStyle = 'light-content') => {
if (Platform.OS === 'ios') {
if (style) {
StatusBar.setBarStyle(style, true);
} else {
const headerColor = tinyColor(theme.sidebarHeaderBg);
let barStyle: StatusBarStyle = 'light-content';
if (headerColor.isLight() && Platform.OS === 'ios') {
barStyle = 'dark-content';
}
StatusBar.setBarStyle(barStyle, true);
}
}
};
useImperativeHandle(ref, () => ({
handlePreviewPress,
}), []);
const icon = (
<FileIcon
backgroundColor={backgroundColor}
file={file}
theme={theme}
/>
);
let fileAttachmentComponent = icon;
if (downloading) {
fileAttachmentComponent = (
<>
{icon}
<View style={[StyleSheet.absoluteFill, styles.progress]}>
<ProgressBar
progress={progress || 0.1}
color={theme.buttonBg}
/>
</View>
</>
);
}
return (
<TouchableWithFeedback
onPress={handlePreviewPress}
type={'opacity'}
>
{fileAttachmentComponent}
</TouchableWithFeedback>
);
});
export default DocumentFile;

View file

@ -0,0 +1,133 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef} from 'react';
import {View} from 'react-native';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {isDocument, isImage} from '@utils/file';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import DocumentFile, {DocumentFileRef} from './document_file';
import FileIcon from './file_icon';
import FileInfo from './file_info';
import ImageFile from './image_file';
import ImageFileOverlay from './image_file_overlay';
type FileProps = {
canDownloadFiles: boolean;
file: FileInfo;
index: number;
inViewPort: boolean;
isSingleImage: boolean;
nonVisibleImagesCount: number;
onPress: (index: number) => void;
theme: Theme;
wrapperWidth?: number;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
fileWrapper: {
flex: 1,
flexDirection: 'row',
marginTop: 10,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.4),
borderRadius: 5,
},
iconWrapper: {
marginTop: 7.8,
marginRight: 6,
marginBottom: 8.2,
marginLeft: 8,
},
};
});
const File = ({
canDownloadFiles, file, index = 0, inViewPort = false, isSingleImage = false,
nonVisibleImagesCount = 0, onPress, theme, wrapperWidth = 300,
}: FileProps) => {
const document = useRef<DocumentFileRef>(null);
const style = getStyleSheet(theme);
const handlePress = () => {
onPress(index);
};
const handlePreviewPress = () => {
if (document.current) {
document.current.handlePreviewPress();
} else {
handlePress();
}
};
if (isImage(file)) {
return (
<TouchableWithFeedback
onPress={handlePreviewPress}
type={'opacity'}
>
<ImageFile
file={file}
inViewPort={inViewPort}
wrapperWidth={wrapperWidth}
isSingleImage={isSingleImage}
resizeMode={'cover'}
theme={theme}
/>
{Boolean(nonVisibleImagesCount) &&
<ImageFileOverlay
theme={theme}
value={nonVisibleImagesCount}
/>
}
</TouchableWithFeedback>
);
}
if (isDocument(file)) {
return (
<View style={[style.fileWrapper]}>
<View style={style.iconWrapper}>
<DocumentFile
ref={document}
canDownloadFiles={canDownloadFiles}
file={file}
theme={theme}
/>
</View>
<FileInfo
file={file}
onPress={handlePreviewPress}
theme={theme}
/>
</View>
);
}
return (
<View style={[style.fileWrapper]}>
<View style={style.iconWrapper}>
<TouchableWithFeedback
onPress={handlePreviewPress}
type={'opacity'}
>
<FileIcon
file={file}
theme={theme}
/>
</TouchableWithFeedback>
</View>
<FileInfo
file={file}
onPress={handlePreviewPress}
theme={theme}
/>
</View>
);
};
export default File;

View file

@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {getFileType} from '@utils/file';
type FileIconProps = {
backgroundColor?: string;
defaultImage?: boolean;
failed?: boolean;
file?: FileInfo;
iconColor?: string;
iconSize?: number;
smallImage?: boolean;
theme: Theme;
}
const BLUE_ICON = '#338AFF';
const RED_ICON = '#ED522A';
const GREEN_ICON = '#1CA660';
const GRAY_ICON = '#999999';
const FAILED_ICON_NAME_AND_COLOR = ['file-image-broken-outline-large', GRAY_ICON];
const ICON_NAME_AND_COLOR_FROM_FILE_TYPE: Record<string, string[]> = {
audio: ['file-audio-outline-large', BLUE_ICON],
code: ['file-code-outline-large', BLUE_ICON],
image: ['file-image-outline-large', BLUE_ICON],
smallImage: ['image-outline', BLUE_ICON],
other: ['file-generic-outline-large', BLUE_ICON],
patch: ['file-patch-outline-large', BLUE_ICON],
pdf: ['file-pdf-outline-large', RED_ICON],
presentation: ['file-powerpoint-outline-large', RED_ICON],
spreadsheet: ['file-excel-outline-large', GREEN_ICON],
text: ['file-text-outline-large', GRAY_ICON],
video: ['file-video-outline-large', BLUE_ICON],
word: ['file-word-outline-large', BLUE_ICON],
zip: ['file-zip-outline-large', BLUE_ICON],
};
const styles = StyleSheet.create({
fileIconWrapper: {
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',
},
});
const FileIcon = ({
backgroundColor, defaultImage = false, failed = false, file,
iconColor, iconSize = 48, smallImage = false, theme,
}: FileIconProps) => {
const getFileIconNameAndColor = () => {
if (failed) {
return FAILED_ICON_NAME_AND_COLOR;
}
if (defaultImage) {
if (smallImage) {
return ICON_NAME_AND_COLOR_FROM_FILE_TYPE.smallImage;
}
return ICON_NAME_AND_COLOR_FROM_FILE_TYPE.image;
}
if (file) {
const fileType = getFileType(file);
return ICON_NAME_AND_COLOR_FROM_FILE_TYPE[fileType] || ICON_NAME_AND_COLOR_FROM_FILE_TYPE.other;
}
return ICON_NAME_AND_COLOR_FROM_FILE_TYPE.other;
};
const [iconName, defaultIconColor] = getFileIconNameAndColor();
const color = iconColor || defaultIconColor;
const bgColor = backgroundColor || theme?.centerChannelBg || 'transparent';
return (
<View style={[styles.fileIconWrapper, {backgroundColor: bgColor}]}>
<CompassIcon
name={iconName}
size={iconSize}
color={color}
/>
</View>
);
};
export default FileIcon;

View file

@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text, View} from 'react-native';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {getFormattedFileSize} from '@utils/file';
import {makeStyleSheetFromTheme} from '@utils/theme';
type FileInfoProps = {
file: FileInfo;
onPress: () => void;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
attachmentContainer: {
flex: 1,
justifyContent: 'center',
},
fileDownloadContainer: {
flexDirection: 'row',
marginTop: 3,
},
fileInfo: {
fontSize: 14,
color: theme.centerChannelColor,
},
fileName: {
flexDirection: 'column',
flexWrap: 'wrap',
fontSize: 14,
fontWeight: '600',
color: theme.centerChannelColor,
paddingRight: 10,
},
};
});
const FileInfo = ({file, onPress, theme}: FileInfoProps) => {
const style = getStyleSheet(theme);
return (
<TouchableWithFeedback
onPress={onPress}
type={'opacity'}
style={style.attachmentContainer}
>
<>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={style.fileName}
>
{file.name.trim()}
</Text>
<View style={style.fileDownloadContainer}>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={style.fileInfo}
>
{`${getFormattedFileSize(file)}`}
</Text>
</View>
</>
</TouchableWithFeedback>
);
};
export default FileInfo;

View file

@ -0,0 +1,205 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {StyleProp, StyleSheet, useWindowDimensions, View, ViewStyle} from 'react-native';
import ProgressiveImage from '@components/progressive_image';
import {useServerUrl} from '@context/server_url';
import NetworkManager from '@init/network_manager';
import {calculateDimensions} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import FileIcon from './file_icon';
import type {ResizeMode} from 'react-native-fast-image';
import type {Client} from '@client/rest';
type ImageFileProps = {
backgroundColor?: string;
file: FileInfo;
inViewPort?: boolean;
isSingleImage?: boolean;
resizeMode?: ResizeMode;
theme: Theme;
wrapperWidth?: number;
}
type ProgressiveImageProps = {
defaultSource?: {uri: string};
imageUri?: string;
inViewPort?: boolean;
thumbnailUri?: string;
}
const SMALL_IMAGE_MAX_HEIGHT = 48;
const SMALL_IMAGE_MAX_WIDTH = 48;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
imagePreview: {
...StyleSheet.absoluteFillObject,
},
fileImageWrapper: {
borderRadius: 5,
overflow: 'hidden',
},
boxPlaceholder: {
paddingBottom: '100%',
},
failed: {
justifyContent: 'center',
alignItems: 'center',
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRadius: 4,
borderWidth: 1,
},
smallImageBorder: {
borderRadius: 5,
},
smallImageOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 4,
},
singleSmallImageWrapper: {
height: SMALL_IMAGE_MAX_HEIGHT,
width: SMALL_IMAGE_MAX_WIDTH,
overflow: 'hidden',
},
}));
const ImageFile = ({
backgroundColor, file, inViewPort, isSingleImage,
resizeMode = 'cover', theme, wrapperWidth,
}: ImageFileProps) => {
const serverUrl = useServerUrl();
const [failed, setFailed] = useState(false);
const dimensions = useWindowDimensions();
const style = getStyleSheet(theme);
let image;
let client: Client | undefined;
try {
client = NetworkManager.getClient(serverUrl);
} catch {
// do nothing
}
const getImageDimensions = () => {
if (isSingleImage) {
const viewPortHeight = Math.max(dimensions.height, dimensions.width) * 0.45;
return calculateDimensions(file?.height, file?.width, wrapperWidth, viewPortHeight);
}
return undefined;
};
const handleError = useCallback(() => {
setFailed(true);
}, []);
const imageProps = () => {
const props: ProgressiveImageProps = {};
if (file.localPath) {
props.defaultSource = {uri: file.localPath};
} else if (file.id) {
if (file.mini_preview && file.mime_type) {
props.thumbnailUri = `data:${file.mime_type};base64,${file.mini_preview}`;
} else {
props.thumbnailUri = client?.getFileThumbnailUrl(file.id, 0);
}
props.imageUri = client?.getFilePreviewUrl(file.id, 0);
props.inViewPort = inViewPort;
}
return props;
};
if (file.height <= SMALL_IMAGE_MAX_HEIGHT || file.width <= SMALL_IMAGE_MAX_WIDTH) {
let wrapperStyle: StyleProp<ViewStyle> = style.fileImageWrapper;
if (isSingleImage) {
wrapperStyle = style.singleSmallImageWrapper;
if (file.width > SMALL_IMAGE_MAX_WIDTH) {
wrapperStyle = [wrapperStyle, {width: '100%'}];
}
}
image = (
<ProgressiveImage
id={file.id!}
style={{height: file.height, width: file.width}}
tintDefaultSource={!file.localPath && !failed}
onError={handleError}
resizeMode={'contain'}
{...imageProps()}
/>
);
if (failed) {
image = (
<FileIcon
failed={failed}
file={file}
backgroundColor={backgroundColor}
theme={theme}
/>
);
}
return (
<View
style={[
wrapperStyle,
style.smallImageBorder,
{
borderColor: changeOpacity(theme.centerChannelColor, 0.4),
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
},
]}
>
{!isSingleImage && <View style={style.boxPlaceholder}/>}
<View style={style.smallImageOverlay}>
{image}
</View>
</View>
);
}
const imageDimensions = getImageDimensions();
image = (
<ProgressiveImage
id={file.id!}
style={[isSingleImage ? null : style.imagePreview, imageDimensions]}
tintDefaultSource={!file.localPath && !failed}
onError={handleError}
resizeMode={resizeMode}
{...imageProps()}
/>
);
if (failed) {
image = (
<View style={[isSingleImage ? null : style.imagePreview, style.failed, imageDimensions]}>
<FileIcon
failed={failed}
file={file}
backgroundColor={backgroundColor}
theme={theme}
/>
</View>
);
}
return (
<View
style={style.fileImageWrapper}
>
{!isSingleImage && <View style={style.boxPlaceholder}/>}
{image}
</View>
);
};
export default ImageFile;

View file

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {PixelRatio, StyleSheet, Text, useWindowDimensions, View} from 'react-native';
import {makeStyleSheetFromTheme} from '@utils/theme';
type ImageFileOverlayProps = {
theme: Theme;
value: number;
}
const getStyleSheet = (scale: number, th: Theme) => {
const style = makeStyleSheetFromTheme((theme: Theme) => {
return {
moreImagesWrapper: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: 5,
},
moreImagesText: {
color: theme.sidebarHeaderTextColor,
fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale)),
fontFamily: 'Open Sans',
textAlign: 'center',
},
};
});
return style(th);
};
const ImageFileOverlay = ({theme, value}: ImageFileOverlayProps) => {
const dimensions = useWindowDimensions();
const scale = dimensions.width / 320;
const style = getStyleSheet(scale, theme);
return null;
return (
<View style={style.moreImagesWrapper}>
<Text style={style.moreImagesText}>
{`+${value}`}
</Text>
</View>
);
};
export default ImageFileOverlay;

View file

@ -0,0 +1,210 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {useEffect, useMemo, useRef, useState} from 'react';
import {DeviceEventEmitter, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Device} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useServerUrl} from '@context/server_url';
import {useSplitView} from '@hooks/device';
import NetworkManager from '@init/network_manager';
import {isGif, isImage} from '@utils/file';
import {openGalleryAtIndex} from '@utils/gallery';
import {getViewPortWidth} from '@utils/images';
import {preventDoubleTap} from '@utils/tap';
import File from './file';
import type {Client} from '@client/rest';
import type {WithDatabaseArgs} from '@typings/database/database';
import type FileModel from '@typings/database/models/servers/file';
import type PostModel from '@typings/database/models/servers/post';
import type SystemModel from '@typings/database/models/servers/system';
type FilesProps = {
authorId: string;
canDownloadFiles: boolean;
failed?: boolean;
files: FileModel[];
isReplyPost: boolean;
postId: string;
theme: Theme;
}
const MAX_VISIBLE_ROW_IMAGES = 4;
const styles = StyleSheet.create({
row: {
flex: 1,
flexDirection: 'row',
marginTop: 5,
},
container: {
flex: 1,
},
gutter: {
marginLeft: 8,
},
failed: {
opacity: 0.5,
},
});
const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId, theme}: FilesProps) => {
const [inViewPort, setInViewPort] = useState(false);
const serverUrl = useServerUrl();
const isSplitView = useSplitView();
const imageAttachments = useRef<FileInfo[]>([]).current;
const nonImageAttachments = useRef<FileInfo[]>([]).current;
const filesInfo: FileInfo[] = useMemo(() => files.map((f) => ({
id: f.id,
user_id: authorId,
post_id: postId,
create_at: 0,
delete_at: 0,
update_at: 0,
name: f.name,
extension: f.extension,
mini_preview: f.imageThumbnail,
size: f.size,
mime_type: f.mimeType,
height: f.height,
has_preview_image: Boolean(f.imageThumbnail),
localPath: f.localPath,
width: f.width,
})), [files]);
let client: Client | undefined;
try {
client = NetworkManager.getClient(serverUrl);
} catch {
// do nothing
}
if (!imageAttachments.length && !nonImageAttachments.length) {
filesInfo.reduce((info, file) => {
if (isImage(file)) {
let uri;
if (file.localPath) {
uri = file.localPath;
} else {
uri = isGif(file) ? client?.getFileUrl(file.id!, 0) : client?.getFilePreviewUrl(file.id!, 0);
}
info.imageAttachments.push({...file, uri});
} else {
info.nonImageAttachments.push(file);
}
return info;
}, {imageAttachments, nonImageAttachments});
}
const filesForGallery = useRef<FileInfo[]>(imageAttachments.concat(nonImageAttachments)).current;
const attachmentIndex = (fileId: string) => {
return filesForGallery.findIndex((file) => file.id === fileId) || 0;
};
const handlePreviewPress = preventDoubleTap((idx: number) => {
openGalleryAtIndex(idx, filesForGallery);
});
const isSingleImage = () => (files.length === 1 && isImage(files[0]));
const renderItems = (items: FileInfo[], moreImagesCount = 0, includeGutter = false) => {
const singleImage = isSingleImage();
let nonVisibleImagesCount: number;
let container: StyleProp<ViewStyle> = styles.container;
const containerWithGutter = [container, styles.gutter];
return items.map((file, idx) => {
if (moreImagesCount && idx === MAX_VISIBLE_ROW_IMAGES - 1) {
nonVisibleImagesCount = moreImagesCount;
}
if (idx !== 0 && includeGutter) {
container = containerWithGutter;
}
return (
<View
style={container}
key={file.id}
>
<File
key={file.id}
canDownloadFiles={canDownloadFiles}
file={file}
index={attachmentIndex(file.id!)}
onPress={handlePreviewPress}
theme={theme}
isSingleImage={singleImage}
nonVisibleImagesCount={nonVisibleImagesCount}
wrapperWidth={getViewPortWidth(isReplyPost, (!isSplitView && Device.IS_TABLET))}
inViewPort={inViewPort}
/>
</View>
);
});
};
const renderImageRow = () => {
if (imageAttachments.length === 0) {
return null;
}
const visibleImages = imageAttachments.slice(0, MAX_VISIBLE_ROW_IMAGES);
const tabletOffset = !isSplitView && Device.IS_TABLET;
const portraitPostWidth = getViewPortWidth(isReplyPost, tabletOffset);
let nonVisibleImagesCount;
if (imageAttachments.length > MAX_VISIBLE_ROW_IMAGES) {
nonVisibleImagesCount = imageAttachments.length - MAX_VISIBLE_ROW_IMAGES;
}
return (
<View style={[styles.row, {width: portraitPostWidth}]}>
{ renderItems(visibleImages, nonVisibleImagesCount, true) }
</View>
);
};
useEffect(() => {
const onScrollEnd = DeviceEventEmitter.addListener('scrolled', (viewableItems) => {
if (postId in viewableItems) {
setInViewPort(true);
}
});
return () => onScrollEnd.remove();
}, []);
return (
<View style={[failed && styles.failed]}>
{renderImageRow()}
{renderItems(nonImageAttachments)}
</View>
);
};
const withConfigAndLicense = withObservables([], ({database}: WithDatabaseArgs) => ({
enableMobileFileDownload: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((cfg: SystemModel) => of$(cfg.value.EnableMobileFileDownload !== 'false')),
),
complianceDisabled: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(
switchMap((lc: SystemModel) => of$(lc.value.IsLicensed === 'false' || lc.value.Compliance === 'false')),
),
}));
const withCanDownload = withObservables(
['enableMobileFileDownload', 'complianceDisabled', 'post'],
({enableMobileFileDownload, complianceDisabled, post}: {enableMobileFileDownload: boolean; complianceDisabled: boolean; post: PostModel}) => {
return {
authorId: of$(post.userId),
canDownloadFiles: of$(complianceDisabled || enableMobileFileDownload),
postId: of$(post.id),
};
});
export default withDatabase(withConfigAndLicense(withCanDownload(React.memo(Files))));

View file

@ -0,0 +1,192 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {StyleProp, View, ViewStyle} from 'react-native';
import FormattedText from '@components/formatted_text';
import JumboEmoji from '@components/jumbo_emoji';
import {THREAD} from '@constants/screens';
import {isEdited as postEdited} from '@utils/post';
import {makeStyleSheetFromTheme} from '@utils/theme';
import AddMembers from './add_members';
import Content from './content';
import Failed from './failed';
import Files from './files';
import Message from './message';
import Reactions from './reactions';
import type PostModel from '@typings/database/models/servers/post';
import FileModel from '@typings/database/models/servers/file';
type BodyProps = {
appsEnabled: boolean;
files: FileModel[];
hasReactions: boolean;
highlight: boolean;
highlightReplyBar: boolean;
isEphemeral: boolean;
isFirstReply?: boolean;
isJumboEmoji: boolean;
isLastReply?: boolean;
isPendingOrFailed: boolean;
isPostAddChannelMember: boolean;
location: string;
post: PostModel;
showAddReaction?: boolean;
theme: Theme;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
messageBody: {
paddingBottom: 2,
paddingTop: 2,
flex: 1,
},
messageContainer: {width: '100%'},
replyBar: {
backgroundColor: theme.centerChannelColor,
opacity: 0.1,
marginLeft: 1,
marginRight: 7,
width: 3,
flexBasis: 3,
},
replyBarFirst: {paddingTop: 10},
replyBarLast: {paddingBottom: 10},
replyMention: {
backgroundColor: theme.mentionHighlightBg,
opacity: 1,
},
message: {
color: theme.centerChannelColor,
fontSize: 15,
lineHeight: 20,
},
messageContainerWithReplyBar: {
flexDirection: 'row',
width: '100%',
},
};
});
const Body = ({
appsEnabled, files, hasReactions, highlight, highlightReplyBar,
isEphemeral, isFirstReply, isJumboEmoji, isLastReply, isPendingOrFailed, isPostAddChannelMember,
location, post, showAddReaction, theme,
}: BodyProps) => {
const style = getStyleSheet(theme);
const isEdited = postEdited(post);
const hasBeenDeleted = Boolean(post.deleteAt);
let body;
let message;
const isReplyPost = Boolean(post.rootId && (!isEphemeral || !hasBeenDeleted) && location !== THREAD);
const hasContent = (post.metadata?.embeds?.length || (appsEnabled && post.props?.app_bindings?.length)) || post.props?.attachments?.length;
const replyBarStyle = useCallback((): StyleProp<ViewStyle>|undefined => {
if (!isReplyPost) {
return undefined;
}
const barStyle = [style.replyBar];
if (isFirstReply) {
barStyle.push(style.replyBarFirst);
}
if (isLastReply) {
barStyle.push(style.replyBarLast);
}
if (highlightReplyBar) {
barStyle.push(style.replyMention);
}
return barStyle;
}, []);
if (hasBeenDeleted) {
body = (
<FormattedText
style={style.message}
id='post_body.deleted'
defaultMessage='(message deleted)'
/>
);
} else if (isPostAddChannelMember) {
message = (
<AddMembers
post={post}
theme={theme}
/>
);
} else if (isJumboEmoji) {
message = (
<JumboEmoji
baseTextStyle={style.message}
isEdited={isEdited}
value={post.message}
/>
);
} else if (post.message.length) {
message = (
<Message
highlight={highlight}
isEdited={isEdited}
isPendingOrFailed={isPendingOrFailed}
isReplyPost={isReplyPost}
location={location}
post={post}
theme={theme}
/>
);
}
if (!hasBeenDeleted) {
body = (
<View style={style.messageBody}>
{message}
{hasContent &&
<Content
isReplyPost={isReplyPost}
post={post}
theme={theme}
/>
}
{files.length > 0 &&
<Files
failed={post.props?.failed}
files={files}
post={post}
isReplyPost={isReplyPost}
theme={theme}
/>
}
{hasReactions && showAddReaction &&
<Reactions
post={post}
theme={theme}
/>
}
</View>
);
}
return (
<View style={style.messageContainerWithReplyBar}>
<View style={replyBarStyle()}/>
{body}
{post.props?.failed &&
<Failed
post={post}
theme={theme}
/>
}
</View>
);
};
export default Body;

View file

@ -0,0 +1,42 @@
// 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 {from as from$} from 'rxjs';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {queryGroupForPosts} from '@helpers/database/groups';
import Message from './message';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PostModel from '@typings/database/models/servers/post';
import type SystemModel from '@typings/database/models/servers/system';
const {SERVER: {GROUP, SYSTEM, USER}} = MM_TABLES;
const withPreferences = withObservables([], ({database}: WithDatabaseArgs) => ({
currentUserId: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID),
groups: database.get(GROUP).query(Q.where('delete_at', Q.eq(0))).observe(), // Needed for when a group is added or removed
}));
type MessageInputArgs = {
currentUserId: SystemModel;
post: PostModel;
}
const withMessageInput = withObservables(
['currentUserId', 'post'],
({database, currentUserId, post}: WithDatabaseArgs & MessageInputArgs) => {
const currentUser = database.get(USER).findAndObserve(currentUserId.value);
const groupsForPosts = from$(queryGroupForPosts(post));
return {
currentUser,
groupsForPosts,
};
});
export default withDatabase(withPreferences(withMessageInput(Message)));

View file

@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import {LayoutChangeEvent, useWindowDimensions, ScrollView, View} from 'react-native';
import Animated from 'react-native-reanimated';
import Markdown from '@components/markdown';
import {SEARCH} from '@constants/screens';
import {useShowMoreAnimatedStyle} from '@hooks/show_more';
import {getMentionKeysForPost} from '@utils/post';
import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
import ShowMoreButton from './show_more_button';
import type PostModel from '@typings/database/models/servers/post';
import type GroupModel from '@typings/database/models/servers/group';
import type UserModel from '@typings/database/models/servers/user';
type MessageProps = {
currentUser: UserModel;
groupsForPosts: GroupModel[];
highlight: boolean;
isEdited: boolean;
isPendingOrFailed: boolean;
isReplyPost: boolean;
location: string;
post: PostModel;
theme: Theme;
}
const SHOW_MORE_HEIGHT = 54;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
messageContainer: {
width: '100%',
},
reply: {
paddingRight: 10,
},
message: {
color: theme.centerChannelColor,
fontSize: 15,
lineHeight: 20,
},
pendingPost: {
opacity: 0.5,
},
};
});
const Message = ({currentUser, groupsForPosts, highlight, isEdited, isPendingOrFailed, isReplyPost, location, post, theme}: MessageProps) => {
const [open, setOpen] = useState(false);
const [height, setHeight] = useState<number|undefined>();
const dimensions = useWindowDimensions();
const maxHeight = Math.round((dimensions.height * 0.5) + SHOW_MORE_HEIGHT);
const animatedStyle = useShowMoreAnimatedStyle(height, maxHeight, open);
const style = getStyleSheet(theme);
const blockStyles = getMarkdownBlockStyles(theme);
const textStyles = getMarkdownTextStyles(theme);
const mentionKeys = useMemo(() => {
return getMentionKeysForPost(currentUser, post, groupsForPosts);
}, [currentUser, post.message, groupsForPosts]);
const onLayout = useCallback((event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), []);
const onPress = () => setOpen(!open);
return (
<>
<Animated.View style={animatedStyle}>
<ScrollView
keyboardShouldPersistTaps={'always'}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
>
<View
style={[style.messageContainer, (isReplyPost && style.reply), (isPendingOrFailed && style.pendingPost)]}
onLayout={onLayout}
>
<Markdown
baseTextStyle={style.message}
blockStyles={blockStyles}
channelMentions={post.props?.channel_mentions}
imagesMetadata={post.metadata?.images}
isEdited={isEdited}
isReplyPost={isReplyPost}
isSearchResult={location === SEARCH}
postId={post.id}
textStyles={textStyles}
value={post.message}
mentionKeys={mentionKeys}
theme={theme}
/>
</View>
</ScrollView>
</Animated.View>
{(height || 0) > maxHeight &&
<ShowMoreButton
highlight={highlight}
theme={theme}
showMore={!open}
onPress={onPress}
/>
}
</>
);
};
export default React.memo(Message);

View file

@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type ShowMoreButtonProps = {
highlight: boolean;
onPress: () => void;
showMore: boolean;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
button: {
flex: 1,
flexDirection: 'row',
},
buttonContainer: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.centerChannelBg,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRadius: 22,
borderWidth: 1,
height: 44,
width: 44,
paddingTop: 7,
},
container: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
flexDirection: 'row',
position: 'relative',
top: 10,
marginBottom: 10,
},
dividerLeft: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
flex: 1,
height: 1,
marginRight: 10,
},
dividerRight: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
flex: 1,
height: 1,
marginLeft: 10,
},
gradient: {
flex: 1,
height: 50,
position: 'absolute',
top: -50,
width: '100%',
},
sign: {
color: theme.linkColor,
},
};
});
const ShowMoreButton = ({highlight, onPress, showMore = true, theme}: ShowMoreButtonProps) => {
const style = getStyleSheet(theme);
let iconName = 'chevron-down';
if (!showMore) {
iconName = 'chevron-up';
}
let gradientColors = [
changeOpacity(theme.centerChannelBg, 0),
changeOpacity(theme.centerChannelBg, 0.75),
theme.centerChannelBg,
];
if (highlight) {
gradientColors = [
changeOpacity(theme.mentionHighlightBg, 0),
changeOpacity(theme.mentionHighlightBg, 0.15),
changeOpacity(theme.mentionHighlightBg, 0.5),
];
}
return (
<View>
{showMore &&
<LinearGradient
colors={gradientColors}
locations={[0, 0.7, 1]}
style={style.gradient}
/>
}
<View style={style.container}>
<View style={style.dividerLeft}/>
<TouchableWithFeedback
onPress={onPress}
style={style.buttonContainer}
type={'opacity'}
>
<View
style={style.button}
testID={`show_more.button.${iconName}`}
>
<CompassIcon
name={iconName}
size={28}
style={style.sign}
/>
</View>
</TouchableWithFeedback>
<View style={style.dividerRight}/>
</View>
</View>
);
};
export default ShowMoreButton;

View file

@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import {from as from$, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {General, Permissions} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {hasPermissionForPost} from '@utils/role';
import {isSystemAdmin} from '@utils/user';
import Reactions from './reactions';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
type WithReactionsInput = WithDatabaseArgs & {
experimentalTownSquareIsReadOnly: boolean;
post: PostModel;
currentUser: UserModel;
}
const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({
currentUser: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap((currentUserId: SystemModel) =>
database.get(MM_TABLES.SERVER.USER).findAndObserve(currentUserId.value),
),
),
experimentalTownSquareIsReadOnly: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((cfg: SystemModel) => of$(cfg.value.ExperimentalTownSquareIsReadOnly === 'true')),
),
}));
const withReactions = withObservables(['experimentalTownSquareIsReadOnly', 'post', 'currentUser'], ({experimentalTownSquareIsReadOnly, post, currentUser}: WithReactionsInput) => {
const disabled = post.channel.observe().pipe(
switchMap((channel: ChannelModel) => {
return of$(channel.deleteAt > 0 ||
(channel?.name === General.DEFAULT_CHANNEL && !isSystemAdmin(currentUser.roles) && experimentalTownSquareIsReadOnly));
}),
);
return {
canAddReaction: from$(hasPermissionForPost(post, currentUser, Permissions.ADD_REACTION, true)),
canRemoveReaction: from$(hasPermissionForPost(post, currentUser, Permissions.REMOVE_REACTION, true)),
currentUserId: of$(currentUser.id),
disabled,
postId: of$(post.id),
reactions: post.reactions.observe(),
};
});
export default withDatabase(withSystem(withReactions(Reactions)));

View file

@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {Platform, Text} from 'react-native';
import Emoji from '@components/emoji';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type ReactionProps = {
count: number;
emojiName: string;
highlight: boolean;
onPress: (emojiName: string, highlight: boolean) => void;
onLongPress: () => void;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
count: {
color: theme.linkColor,
marginLeft: 6,
},
customEmojiStyle: {marginHorizontal: 3},
highlight: {backgroundColor: changeOpacity(theme.linkColor, 0.1)},
reaction: {
alignItems: 'center',
borderRadius: 2,
borderColor: changeOpacity(theme.linkColor, 0.4),
borderWidth: 1,
flexDirection: 'row',
height: 30,
marginRight: 6,
marginBottom: 5,
marginTop: 10,
paddingHorizontal: 6,
paddingBottom: Platform.select({android: 2}),
},
};
});
const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: ReactionProps) => {
const styles = getStyleSheet(theme);
const handlePress = useCallback(() => {
onPress(emojiName, highlight);
}, [highlight]);
return (
<TouchableWithFeedback
onPress={handlePress}
onLongPress={onLongPress}
delayLongPress={350}
style={[styles.reaction, (highlight && styles.highlight)]}
type={'opacity'}
>
<Emoji
emojiName={emojiName}
size={20}
textStyle={{color: 'black', fontWeight: 'bold'}}
customEmojiStyle={styles.customEmojiStyle}
testID={`reaction.emoji.${emojiName}`}
/>
<Text
style={styles.count}
testID={`reaction.emoji.${emojiName}.count`}
>
{count}
</Text>
</TouchableWithFeedback>
);
};
export default Reaction;

View file

@ -0,0 +1,171 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef} from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import {addReaction, removeReaction} from '@actions/remote/reactions';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {MAX_ALLOWED_REACTIONS} from '@constants/emoji';
import {useServerUrl} from '@context/server_url';
import {showModal, showModalOverCurrentContext} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import Reaction from './reaction';
import type ReactionModel from '@typings/database/models/servers/reaction';
type ReactionsProps = {
canAddReaction: boolean;
canRemoveReaction: boolean;
disabled: boolean;
currentUserId: string;
postId: string;
reactions: ReactionModel[];
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
addReaction: {
color: changeOpacity(theme.centerChannelColor, 0.5),
},
reaction: {
alignItems: 'center',
justifyContent: 'center',
borderRadius: 2,
borderColor: changeOpacity(theme.centerChannelColor, 0.3),
borderWidth: 1,
flexDirection: 'row',
height: 30,
marginRight: 6,
marginBottom: 5,
marginTop: 10,
paddingVertical: 2,
paddingHorizontal: 6,
width: 40,
},
reactionsContainer: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
alignContent: 'flex-start',
},
};
});
const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, postId, reactions, theme}: ReactionsProps) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const pressed = useRef(false);
if (!reactions) {
return null;
}
const styles = getStyleSheet(theme);
const buildReactionsMap = () => {
const highlightedReactions: string[] = [];
const reactionsByName = reactions.reduce((acc, reaction) => {
if (reaction) {
if (acc.has(reaction.emojiName)) {
acc.get(reaction.emojiName)!.push(reaction);
} else {
acc.set(reaction.emojiName, [reaction]);
}
if (reaction.userId === currentUserId) {
highlightedReactions.push(reaction.emojiName);
}
}
return acc;
}, new Map<string, ReactionModel[]>());
return {reactionsByName, highlightedReactions};
};
const handleAddReactionToPost = (emoji: string) => {
addReaction(serverUrl, postId, emoji);
};
const handleAddReaction = preventDoubleTap(() => {
const screen = 'AddReaction';
const title = intl.formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'});
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
const passProps = {
closeButton,
onEmojiPress: handleAddReactionToPost,
};
showModal(screen, title, passProps);
});
const handleReactionPress = async (emoji: string, remove: boolean) => {
pressed.current = true;
if (remove && canRemoveReaction && !disabled) {
await removeReaction(serverUrl, postId, emoji);
} else if (!remove && canAddReaction && !disabled) {
await addReaction(serverUrl, postId, emoji);
}
pressed.current = false;
};
const showReactionList = () => {
const screen = 'ReactionList';
const passProps = {
postId,
};
if (!pressed.current) {
showModalOverCurrentContext(screen, passProps);
}
};
let addMoreReactions = null;
const {reactionsByName, highlightedReactions} = buildReactionsMap();
if (!disabled && canAddReaction && reactionsByName.size < MAX_ALLOWED_REACTIONS) {
addMoreReactions = (
<TouchableWithFeedback
key='addReaction'
onPress={handleAddReaction}
style={[styles.reaction]}
type={'opacity'}
>
<CompassIcon
name='emoticon-plus-outline'
size={24}
style={styles.addReaction}
/>
</TouchableWithFeedback>
);
}
return (
<View style={styles.reactionsContainer}>
{
Array.from(reactionsByName.keys()).map((r) => {
return (
<Reaction
key={r}
count={reactionsByName.get(r)!.length}
emojiName={r}
highlight={highlightedReactions.includes(r)}
onPress={handleReactionPress}
onLongPress={showReactionList}
theme={theme}
/>
);
})
}
{addMoreReactions}
</View>
);
};
export default React.memo(Reactions);

View file

@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import FormattedText from '@components/formatted_text';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type HeaderCommentedOnProps = {
locale: string;
name: string;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
commentedOn: {
color: changeOpacity(theme.centerChannelColor, 0.65),
marginBottom: 3,
lineHeight: 21,
},
};
});
const HeaderCommentedOn = ({locale, name, theme}: HeaderCommentedOnProps) => {
const style = getStyleSheet(theme);
let apostrophe;
if (locale.toLowerCase().startsWith('en')) {
if (name.slice(-1) === 's') {
apostrophe = '\'';
} else {
apostrophe = '\'s';
}
}
return (
<FormattedText
id='post_body.commentedOn'
defaultMessage='Commented on {name}{apostrophe} message: '
values={{
name,
apostrophe,
}}
style={style.commentedOn}
testID='post_header.commented_on'
/>
);
};
export default HeaderCommentedOn;

View file

@ -0,0 +1,148 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Text, useWindowDimensions, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {showModal} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type {ImageSource} from 'react-native-vector-icons/Icon';
type HeaderDisplayNameProps = {
commentCount: number;
displayName?: string;
isAutomation: boolean;
rootPostAuthor?: string;
shouldRenderReplyButton?: boolean;
theme: Theme;
userId: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
displayName: {
color: theme.centerChannelColor,
fontSize: 15,
fontWeight: '600',
flexGrow: 1,
paddingVertical: 2,
},
displayNameContainer: {
maxWidth: '60%',
marginRight: 5,
marginBottom: 3,
},
displayNameContainerBotReplyWidth: {
maxWidth: '50%',
},
displayNameContainerLandscape: {
maxWidth: '80%',
},
displayNameContainerLandscapeBotReplyWidth: {
maxWidth: '70%',
},
};
});
const HeaderDisplayName = ({
commentCount, displayName, isAutomation, rootPostAuthor, shouldRenderReplyButton, theme, userId,
}: HeaderDisplayNameProps) => {
const closeButton = useRef<ImageSource>();
const dimensions = useWindowDimensions();
const intl = useIntl();
const style = getStyleSheet(theme);
const onPress = useCallback(preventDoubleTap(() => {
const screen = 'UserProfile';
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
const passProps = {userId};
if (!closeButton.current) {
closeButton.current = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
}
const options = {
topBar: {
leftButtons: [{
id: 'close-user-profile',
icon: closeButton.current,
testID: 'close.user_profile.button',
}],
},
};
Keyboard.dismiss();
showModal(screen, title, passProps, options);
}), []);
const calcNameWidth = () => {
const isLandscape = dimensions.width > dimensions.height;
const showReply = shouldRenderReplyButton || (!rootPostAuthor && commentCount > 0);
const reduceWidth = showReply && isAutomation;
if (reduceWidth && isLandscape) {
return style.displayNameContainerLandscapeBotReplyWidth;
} else if (isLandscape) {
return style.displayNameContainerLandscape;
} else if (reduceWidth) {
return style.displayNameContainerBotReplyWidth;
}
return undefined;
};
const displayNameWidth = calcNameWidth();
const displayNameStyle = [style.displayNameContainer, displayNameWidth];
if (isAutomation) {
return (
<View style={displayNameStyle}>
<Text
style={style.displayName}
ellipsizeMode={'tail'}
numberOfLines={1}
testID='post_header.display_name'
>
{displayName}
</Text>
</View>
);
} else if (displayName) {
return (
<TouchableWithFeedback
onPress={onPress}
style={displayNameStyle}
type={'opacity'}
>
<Text
style={style.displayName}
ellipsizeMode={'tail'}
numberOfLines={1}
testID='post_header.display_name'
>
{displayName}
</Text>
</TouchableWithFeedback>
);
}
return (
<View style={style.displayNameContainer}>
<FormattedText
id='channel_loader.someone'
defaultMessage='Someone'
style={style.displayName}
testID='post_header.display_name'
/>
</View>
);
};
export default HeaderDisplayName;

View file

@ -0,0 +1,147 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
import FormattedTime from '@components/formatted_time';
import {CHANNEL, THREAD} from '@constants/screens';
import {useTheme} from '@context/theme';
import {postUserDisplayName} from '@utils/post';
import {displayUsername, getUserCustomStatus, getUserTimezone, isCustomStatusExpired} from '@utils/user';
import {makeStyleSheetFromTheme} from '@utils/theme';
import HeaderCommentedOn from './commented_on';
import HeaderDisplayName from './display_name';
import HeaderReply from './reply';
import HeaderTag from './tag';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
type HeaderProps = {
author: UserModel;
commentCount: number;
currentUser: UserModel;
enablePostUsernameOverride: boolean;
isAutoResponse: boolean;
isCustomStatusEnabled: boolean;
isEphemeral: boolean;
isMilitaryTime: boolean;
isPendingOrFailed: boolean;
isSystemPost: boolean;
isTimezoneEnabled: boolean;
isWebHook: boolean;
location: string;
post: PostModel;
rootPostAuthor?: UserModel;
shouldRenderReplyButton?: boolean;
teammateNameDisplay: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
marginTop: 10,
},
pendingPost: {
opacity: 0.5,
},
wrapper: {
flex: 1,
flexDirection: 'row',
},
time: {
color: theme.centerChannelColor,
fontSize: 12,
marginTop: 5,
opacity: 0.5,
flex: 1,
},
customStatusEmoji: {
color: theme.centerChannelColor,
marginRight: 4,
marginTop: 1,
},
};
});
const Header = (props: HeaderProps) => {
const {
author, commentCount = 0, currentUser, enablePostUsernameOverride, isAutoResponse, isCustomStatusEnabled,
isEphemeral, isMilitaryTime, isPendingOrFailed, isSystemPost, isTimezoneEnabled, isWebHook,
location, post, rootPostAuthor, shouldRenderReplyButton, teammateNameDisplay,
} = props;
const theme = useTheme();
const style = getStyleSheet(theme);
const pendingPostStyle = isPendingOrFailed ? style.pendingPost : undefined;
const isReplyPost = Boolean(post.rootId && !isEphemeral);
const showReply = !isReplyPost && (location !== THREAD) && (shouldRenderReplyButton || (!rootPostAuthor && commentCount > 0));
const displayName = enablePostUsernameOverride ? postUserDisplayName(post, author, teammateNameDisplay, enablePostUsernameOverride) : '';
const rootAuthorDisplayName = rootPostAuthor ? displayUsername(rootPostAuthor, currentUser.locale, teammateNameDisplay, true) : undefined;
const customStatus = getUserCustomStatus(author);
const customStatusExpired = isCustomStatusExpired(author);
const showCustomStatusEmoji = Boolean(
isCustomStatusEnabled && displayName &&
!(isSystemPost || author.isBot || isAutoResponse || isWebHook) &&
customStatus,
);
return (
<>
<View style={[style.container, pendingPostStyle]}>
<View style={style.wrapper}>
<HeaderDisplayName
commentCount={commentCount}
displayName={displayName}
isAutomation={author.isBot || isAutoResponse || isWebHook}
rootPostAuthor={rootAuthorDisplayName}
shouldRenderReplyButton={shouldRenderReplyButton}
theme={theme}
userId={post.userId}
/>
{showCustomStatusEmoji && customStatusExpired && Boolean(customStatus?.emoji) && (
<CustomStatusEmoji
customStatus={customStatus!}
style={style.customStatusEmoji}
testID='post_header'
/>
)}
{(!isSystemPost || isAutoResponse) &&
<HeaderTag
isAutoResponder={isAutoResponse}
isAutomation={isWebHook || author.isBot}
isGuest={author.isGuest}
/>
}
<FormattedTime
timezone={isTimezoneEnabled ? getUserTimezone(currentUser) : ''}
isMilitaryTime={isMilitaryTime}
value={post.createAt}
style={style.time}
testID='post_header.date_time'
/>
{showReply && commentCount > 0 &&
<HeaderReply
commentCount={commentCount}
location={location}
post={post}
theme={theme}
/>
}
</View>
</View>
{Boolean(rootAuthorDisplayName) && location === CHANNEL &&
<HeaderCommentedOn
locale={currentUser.locale}
name={rootAuthorDisplayName!}
theme={theme}
/>
}
</>
);
};
export default React.memo(Header);

View file

@ -0,0 +1,70 @@
// 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 {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {getPreferenceAsBool, getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {isMinimumServerVersion} from '@utils/helpers';
import Header from './header';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PostModel from '@typings/database/models/servers/post';
import type SystemModel from '@typings/database/models/servers/system';
import type PreferenceModel from '@typings/database/models/servers/preference';
type HeaderInputProps = {
config: ClientConfig;
license: ClientLicense;
preferences: PreferenceModel[];
post: PostModel;
};
const withBaseHeaderProps = withObservables([], ({database}: WithDatabaseArgs & {post: PostModel}) => ({
config: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(switchMap((cfg: SystemModel) => of$(cfg.value))),
license: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(switchMap((lcs: SystemModel) => of$(lcs.value))),
preferences: database.get(MM_TABLES.SERVER.PREFERENCE).query(Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS)).observe(),
}));
const withHeaderProps = withObservables(
['preferences', 'post'],
({config, post, license, database, preferences}: WithDatabaseArgs & HeaderInputProps) => {
const author = post.author.observe();
const enablePostUsernameOverride = of$(config.EnablePostUsernameOverride === 'true');
const isTimezoneEnabled = of$(config.ExperimentalTimezone === 'true');
const isMilitaryTime = of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false));
const teammateNameDisplay = of$(getTeammateNameDisplaySetting(preferences, config, license));
const isCustomStatusEnabled = of$(config.EnableCustomUserStatuses === 'true' && isMinimumServerVersion(config.Version, 5, 36));
const commentCount = database.get(MM_TABLES.SERVER.POST).query(
Q.and(
Q.where('root_id', (post.rootId || post.id)),
Q.where('delete_at', Q.eq(0)),
),
).observeCount();
const rootPostAuthor = post.root.observe().pipe(switchMap((root) => {
if (root.length) {
return root[0].author.observe();
}
return of$(null);
}));
return {
author,
commentCount,
enablePostUsernameOverride,
isCustomStatusEnabled,
isMilitaryTime,
isTimezoneEnabled,
teammateNameDisplay,
rootPostAuthor,
};
});
export default withDatabase(withBaseHeaderProps(withHeaderProps(Header)));

View file

@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {DeviceEventEmitter, Text, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {SEARCH} from '@constants/screens';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type PostModel from '@typings/database/models/servers/post';
type HeaderReplyProps = {
commentCount: number;
location: string;
post: PostModel;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
replyWrapper: {
flex: 1,
justifyContent: 'flex-end',
},
replyIconContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'flex-end',
minWidth: 40,
paddingTop: 2,
paddingBottom: 10,
flex: 1,
},
replyText: {
fontSize: 12,
marginLeft: 2,
marginTop: 2,
color: theme.linkColor,
},
};
});
const HeaderReply = ({commentCount, location, post, theme}: HeaderReplyProps) => {
const style = getStyleSheet(theme);
const onPress = useCallback(preventDoubleTap(() => {
DeviceEventEmitter.emit('goToThread', post);
}), []);
return (
<View
testID='post_header.reply'
style={style.replyWrapper}
>
<TouchableWithFeedback
onPress={onPress}
style={style.replyIconContainer}
type={'opacity'}
>
<CompassIcon
name='reply-outline'
size={18}
color={theme.linkColor}
/>
{location !== SEARCH && commentCount > 0 &&
<Text
style={style.replyText}
testID='post_header.reply.count'
>
{commentCount}
</Text>
}
</TouchableWithFeedback>
</View>
);
};
export default HeaderReply;

View file

@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet} from 'react-native';
import Tag, {BotTag, GuestTag} from '@components/tag';
import {t} from '@i18n';
type HeaderTagProps = {
isAutomation?: boolean;
isAutoResponder?: boolean;
isGuest?: boolean;
}
const style = StyleSheet.create({
tag: {
marginLeft: 0,
marginRight: 5,
marginBottom: 5,
},
});
const HeaderTag = ({
isAutomation, isAutoResponder, isGuest,
}: HeaderTagProps) => {
if (isAutomation) {
return (
<BotTag
style={style.tag}
testID='post_header.bot_tag'
/>
);
} else if (isGuest) {
return (
<GuestTag
style={style.tag}
testID='post_header.guest_tag'
/>
);
} else if (isAutoResponder) {
return (
<Tag
id={t('post_info.auto_responder')}
defaultMessage={'AUTOMATIC REPLY'}
style={style.tag}
/>
);
}
return null;
};
export default HeaderTag;

View file

@ -0,0 +1,147 @@
// 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 {from as from$, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Permissions, Preferences} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {appsEnabled} from '@utils/apps';
import {hasJumboEmojiOnly} from '@utils/emoji/helpers';
import {areConsecutivePosts, isPostEphemeral} from '@utils/post';
import {canManageChannelMembers, hasPermissionForPost} from '@utils/role';
import Post from './post';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PreferenceModel from '@typings/database/models/servers/preference';
import type PostModel from '@typings/database/models/servers/post';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
import PostsInThreadModel from '@typings/database/models/servers/posts_in_thread';
import CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
const {SERVER: {CUSTOM_EMOJI, POST, PREFERENCE, SYSTEM, USER}} = MM_TABLES;
type PropsInput = WithDatabaseArgs & {
featureFlagAppsEnabled?: string;
currentUser: UserModel;
nextPost: PostModel | undefined;
post: PostModel;
previousPost: PostModel | undefined;
}
async function shouldHighlightReplyBar(currentUser: UserModel, post: PostModel, postsInThread: PostsInThreadModel) {
let commentsNotifyLevel = Preferences.COMMENTS_NEVER;
let threadCreatedByCurrentUser = false;
let rootPost: PostModel | undefined;
const myPosts = await postsInThread.collections.get(POST).query(
Q.and(
Q.where('root_id', post.id || post.rootId),
Q.where('create_at', Q.between(postsInThread.earliest, postsInThread.latest)),
Q.where('user_id', currentUser.id),
),
).fetch();
const threadRepliedToByCurrentUser = myPosts.length > 0;
const root = await post.root.fetch();
if (root.length) {
rootPost = root[0];
}
if (rootPost?.userId === currentUser.id) {
threadCreatedByCurrentUser = true;
}
if (currentUser.notifyProps?.comments) {
commentsNotifyLevel = currentUser.notifyProps.comments;
}
const fromCurrentUser = post.userId !== currentUser.id || Boolean(post.props?.from_webhook);
if (!fromCurrentUser) {
if (commentsNotifyLevel === Preferences.COMMENTS_ANY && (threadCreatedByCurrentUser || threadRepliedToByCurrentUser)) {
return true;
} else if (commentsNotifyLevel === Preferences.COMMENTS_ROOT && threadCreatedByCurrentUser) {
return true;
}
}
return false;
}
const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({
featureFlagAppsEnabled: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(switchMap((cfg: SystemModel) => of$(cfg.value.FeatureFlagAppsEnabled))),
currentUser: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap((currentUserId: SystemModel) => database.get(USER).findAndObserve(currentUserId.value)),
),
}));
const withPost = withObservables(
['currentUser', 'post', 'previousPost', 'nextPost'],
({featureFlagAppsEnabled, currentUser, database, post, previousPost, nextPost}: PropsInput) => {
let isFirstReply = of$(true);
let isJumboEmoji = of$(false);
let isLastReply = of$(true);
let isPostAddChannelMember = of$(false);
const isOwner = currentUser.id === post.userId;
const canDelete = from$(hasPermissionForPost(post, currentUser, isOwner ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS, false));
const isConsecutivePost = post.author.observe().pipe(switchMap(
(user: UserModel) => {
return of$(Boolean(post && previousPost && !user.isBot && post.rootId && areConsecutivePosts(post, previousPost)));
},
));
const isEphemeral = of$(isPostEphemeral(post));
const isFlagged = database.get(PREFERENCE).query(
Q.where('category', Preferences.CATEGORY_FLAGGED_POST),
Q.where('name', post.id),
).observe().pipe(switchMap((pref: PreferenceModel[]) => of$(Boolean(pref.length))));
if (post.props?.add_channel_member && isPostEphemeral(post)) {
isPostAddChannelMember = from$(canManageChannelMembers(post, currentUser));
}
const highlightReplyBar = post.postsInThread.observe().pipe(
switchMap((postsInThreads: PostsInThreadModel[]) => {
if (postsInThreads.length) {
return from$(shouldHighlightReplyBar(currentUser, post, postsInThreads[0]));
}
return of$(false);
}));
if (post.rootId) {
const differentThreadSequence = previousPost?.rootId ? previousPost?.rootId !== post.rootId : previousPost?.id !== post.rootId;
isFirstReply = of$(differentThreadSequence || (previousPost?.id === post.rootId || previousPost?.rootId === post.rootId));
isLastReply = of$(!(nextPost?.rootId === post.rootId));
}
if (post.message.length && !(/^\s{4}/).test(post.message)) {
isJumboEmoji = post.collections.get(CUSTOM_EMOJI).query().observe().pipe(
// eslint-disable-next-line max-nested-callbacks
switchMap((customEmojis: CustomEmojiModel[]) => of$(hasJumboEmojiOnly(post.message, customEmojis.map((c) => c.name))),
));
}
const partialConfig: Partial<ClientConfig> = {
FeatureFlagAppsEnabled: featureFlagAppsEnabled,
};
return {
appsEnabled: of$(appsEnabled(partialConfig)),
canDelete,
currentUser,
files: post.files.observe(),
highlightReplyBar,
isConsecutivePost,
isEphemeral,
isFirstReply,
isFlagged,
isJumboEmoji,
isLastReply,
isPostAddChannelMember,
post: post.observe(),
reactionsCount: post.reactions.observeCount(),
};
});
export default withDatabase(withSystem(withPost(Post)));

View file

@ -0,0 +1,286 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactNode, useRef} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, Keyboard, StyleProp, View, ViewStyle} from 'react-native';
import {showPermalink} from '@actions/local/permalink';
import {removePost} from '@actions/local/post';
import SystemAvatar from '@app/components/system_avatar';
import SystemHeader from '@app/components/system_header';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import * as Screens from '@constants/screens';
import {useTheme} from '@context/theme';
import {useServerUrl} from '@context/server_url';
import {showModalOverCurrentContext} from '@screens/navigation';
import {fromAutoResponder, isFromWebhook, isPostPendingOrFailed, isSystemMessage} from '@utils/post';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import Avatar from './avatar';
import Body from './body';
import Header from './header';
import PreHeader from './pre_header';
import SystemMessage from './system_message';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
import FileModel from '@typings/database/models/servers/file';
type PostProps = {
appsEnabled: boolean;
canDelete: boolean;
currentUser: UserModel;
files: FileModel[];
highlight?: boolean;
highlightPinnedOrFlagged?: boolean;
highlightReplyBar: boolean;
isConsecutivePost?: boolean;
isEphemeral: boolean;
isFirstReply?: boolean;
isFlagged?: boolean;
isJumboEmoji: boolean;
isLastReply?: boolean;
isPostAddChannelMember: boolean;
location: string;
post: PostModel;
reactionsCount: number;
shouldRenderReplyButton?: boolean;
showAddReaction?: boolean;
skipFlaggedHeader?: boolean;
skipPinnedHeader?: boolean;
style?: StyleProp<ViewStyle>;
testID?: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
consecutive: {marginTop: 0},
consecutivePostContainer: {
marginBottom: 10,
marginRight: 10,
marginLeft: 47,
marginTop: 10,
},
container: {flexDirection: 'row'},
highlight: {backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.5)},
highlightBar: {
backgroundColor: theme.mentionHighlightBg,
opacity: 1,
},
highlightPinnedOrFlagged: {backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.2)},
pendingPost: {opacity: 0.5},
postStyle: {
overflow: 'hidden',
flex: 1,
},
replyBar: {
backgroundColor: theme.centerChannelColor,
opacity: 0.1,
marginLeft: 1,
marginRight: 7,
width: 3,
flexBasis: 3,
},
replyBarFirst: {paddingTop: 10},
replyBarLast: {paddingBottom: 10},
rightColumn: {
flex: 1,
flexDirection: 'column',
marginRight: 12,
},
rightColumnPadding: {paddingBottom: 3},
};
});
const Post = ({
appsEnabled, canDelete, currentUser, files, highlight, highlightPinnedOrFlagged = true, highlightReplyBar,
isConsecutivePost, isEphemeral, isFirstReply, isFlagged, isJumboEmoji, isLastReply, isPostAddChannelMember,
location, post, reactionsCount, shouldRenderReplyButton, skipFlaggedHeader, skipPinnedHeader, showAddReaction = true, style,
testID,
}: PostProps) => {
const pressDetected = useRef(false);
const intl = useIntl();
const serverUrl = useServerUrl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const isAutoResponder = fromAutoResponder(post);
const isPendingOrFailed = isPostPendingOrFailed(post);
const isSystemPost = isSystemMessage(post);
const isWebHook = isFromWebhook(post);
const handlePress = preventDoubleTap(() => {
pressDetected.current = true;
if (post) {
if (location === Screens.THREAD) {
Keyboard.dismiss();
} else if (location === Screens.SEARCH) {
showPermalink(serverUrl, '', post.id, intl);
return;
}
const isValidSystemMessage = isAutoResponder || !isSystemPost;
if (post.deleteAt !== 0 && isValidSystemMessage && !isPendingOrFailed) {
if ([Screens.CHANNEL, Screens.PERMALINK].includes(location)) {
DeviceEventEmitter.emit('goToThread', post);
}
} else if ((isEphemeral || post.deleteAt > 0)) {
removePost(serverUrl, post);
}
const pressTimeout = setTimeout(() => {
pressDetected.current = false;
clearTimeout(pressTimeout);
}, 300);
}
});
const showPostOptions = () => {
if (!post) {
return;
}
const hasBeenDeleted = (post.deleteAt !== 0);
if (isSystemPost && (!canDelete || hasBeenDeleted)) {
return;
}
if (isPendingOrFailed || isEphemeral) {
return;
}
const screen = 'PostOptions';
const passProps = {
location,
post,
showAddReaction,
};
Keyboard.dismiss();
const postOptionsRequest = requestAnimationFrame(() => {
showModalOverCurrentContext(screen, passProps);
cancelAnimationFrame(postOptionsRequest);
});
};
const highlightFlagged = isFlagged && !skipFlaggedHeader;
const hightlightPinned = post.isPinned && !skipPinnedHeader;
const itemTestID = `${testID}.${post.id}`;
const rightColumnStyle = [styles.rightColumn, (post.rootId && isLastReply && styles.rightColumnPadding)];
const pendingPostStyle: StyleProp<ViewStyle> | undefined = isPendingOrFailed ? styles.pendingPost : undefined;
let highlightedStyle: StyleProp<ViewStyle>;
if (highlight) {
highlightedStyle = styles.highlight;
} else if ((highlightFlagged || hightlightPinned) && highlightPinnedOrFlagged) {
highlightedStyle = styles.highlightPinnedOrFlagged;
}
let header: ReactNode;
let postAvatar: ReactNode;
let consecutiveStyle: StyleProp<ViewStyle>;
if (isConsecutivePost) {
consecutiveStyle = styles.consective;
postAvatar = <View style={styles.consecutivePostContainer}/>;
} else {
postAvatar = isAutoResponder ? (
<SystemAvatar theme={theme}/>
) : (
<Avatar
isAutoReponse={isAutoResponder}
isSystemPost={isSystemPost}
pendingPostStyle={pendingPostStyle}
post={post}
/>
);
if (isSystemPost && !isAutoResponder) {
header = (
<SystemHeader
createAt={post.createAt}
theme={theme}
/>
);
} else {
header = (
<Header
currentUser={currentUser}
isAutoResponse={isAutoResponder}
isEphemeral={isEphemeral}
isPendingOrFailed={isPendingOrFailed}
isSystemPost={isSystemPost}
isWebHook={isWebHook}
location={location}
post={post}
shouldRenderReplyButton={shouldRenderReplyButton}
/>
);
}
}
let body;
if (isSystemPost && !isEphemeral && !isAutoResponder) {
body = (
<SystemMessage
post={post}
/>
);
} else {
body = (
<Body
appsEnabled={appsEnabled}
files={files}
hasReactions={reactionsCount > 0}
highlight={Boolean(highlightedStyle)}
highlightReplyBar={highlightReplyBar}
isEphemeral={isEphemeral}
isFirstReply={isFirstReply}
isJumboEmoji={isJumboEmoji}
isLastReply={isLastReply}
isPendingOrFailed={isPendingOrFailed}
isPostAddChannelMember={isPostAddChannelMember}
location={location}
post={post}
showAddReaction={showAddReaction}
theme={theme}
/>
);
}
return (
<View
testID={testID}
style={[styles.postStyle, style, highlightedStyle]}
>
<TouchableWithFeedback
testID={itemTestID}
onPress={handlePress}
onLongPress={showPostOptions}
delayLongPress={200}
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
cancelTouchOnPanning={true}
>
<>
<PreHeader
isConsecutivePost={isConsecutivePost}
isFlagged={isFlagged}
isPinned={post.isPinned}
skipFlaggedHeader={skipFlaggedHeader}
skipPinnedHeader={skipPinnedHeader}
/>
<View style={[styles.container, consecutiveStyle]}>
{postAvatar}
<View style={rightColumnStyle}>
{header}
{body}
</View>
</View>
</>
</TouchableWithFeedback>
</View>
);
};
export default React.memo(Post);

View file

@ -0,0 +1,119 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {t} from '@i18n';
import {makeStyleSheetFromTheme} from '@utils/theme';
type PreHeaderProps = {
isConsecutivePost?: boolean;
isFlagged?: boolean;
isPinned: boolean;
skipFlaggedHeader?: boolean;
skipPinnedHeader?: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
flexDirection: 'row',
height: 15,
marginLeft: 10,
marginRight: 10,
marginTop: 10,
},
consecutive: {
marginBottom: 3,
},
iconsContainer: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'flex-end',
marginRight: 10,
width: 35,
},
icon: {
color: theme.linkColor,
},
iconsSeparator: {
marginRight: 5,
},
rightColumn: {
flex: 1,
flexDirection: 'column',
marginLeft: 2,
},
text: {
color: theme.linkColor,
fontSize: 13,
lineHeight: 15,
},
};
});
const PreHeader = ({isConsecutivePost, isFlagged, isPinned, skipFlaggedHeader, skipPinnedHeader}: PreHeaderProps) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const isPinnedAndFlagged = isPinned && isFlagged && !skipFlaggedHeader && !skipPinnedHeader;
let text;
if (isPinnedAndFlagged) {
text = {
id: t('mobile.post_pre_header.pinned_flagged'),
defaultMessage: 'Pinned and Saved',
};
} else if (isPinned && !skipPinnedHeader) {
text = {
id: t('mobile.post_pre_header.pinned'),
defaultMessage: 'Pinned',
};
} else if (isFlagged && !skipFlaggedHeader) {
text = {
id: t('mobile.post_pre_header.flagged'),
defaultMessage: 'Saved',
};
}
if (!text) {
return null;
}
return (
<View style={[style.container, (isConsecutivePost && style.consecutive)]}>
<View style={style.iconsContainer}>
{isPinned && !skipPinnedHeader &&
<CompassIcon
name='pin-outline'
size={14}
style={style.icon}
/>
}
{isPinnedAndFlagged &&
<View style={style.iconsSeparator}/>
}
{isFlagged && !skipFlaggedHeader &&
<CompassIcon
name='bookmark-outline'
size={14}
style={style.icon}
/>
}
</View>
<View style={style.rightColumn}>
<FormattedText
{...text}
style={style.text}
testID='post_pre_header.text'
/>
</View>
</View>
);
};
export default PreHeader;

View file

@ -0,0 +1,358 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] = `
<View
style={
Array [
Object {
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
>
<Text>
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
}
}
testID="markdown_text"
>
updated the channel display name from: old displayname to: new displayname
</Text>
</Text>
</View>
`;
exports[`renderSystemMessage uses renderer for Channel Header update 1`] = `
<View
style={
Array [
Object {
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
>
<Text>
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
}
}
testID="markdown_text"
>
updated the channel header from: old header to: new header
</Text>
</Text>
</View>
`;
exports[`renderSystemMessage uses renderer for Channel Purpose update 1`] = `
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
}
}
>
@username updated the channel purpose from: old purpose to: new purpose
</Text>
`;
exports[`renderSystemMessage uses renderer for Guest added and join to channel 1`] = `
<View
style={
Array [
Object {
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
>
<Text>
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
}
}
testID="markdown_text"
>
joined the channel as a guest.
</Text>
</Text>
</View>
`;
exports[`renderSystemMessage uses renderer for Guest added and join to channel 2`] = `
<View
style={
Array [
Object {
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
>
<Text>
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@other.user
</Text>
</Text>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
}
}
testID="markdown_text"
>
added to the channel as a guest by
</Text>
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@username.
</Text>
</Text>
</Text>
</View>
`;
exports[`renderSystemMessage uses renderer for OLD archived channel without a username 1`] = `
<View
style={
Array [
Object {
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
>
<Text>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
}
}
testID="markdown_text"
>
archived the channel
</Text>
</Text>
</View>
`;
exports[`renderSystemMessage uses renderer for archived channel 1`] = `
<View
style={
Array [
Object {
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
>
<Text>
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
}
}
testID="markdown_text"
>
archived the channel
</Text>
</Text>
</View>
`;
exports[`renderSystemMessage uses renderer for unarchived channel 1`] = `
<View
style={
Array [
Object {
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
>
<Text>
<Text
style={
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
},
Object {
"opacity": 1,
},
]
}
>
<Text
style={Array []}
>
@username
</Text>
</Text>
<Text
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontSize": 15,
"lineHeight": 20,
}
}
testID="markdown_text"
>
unarchived the channel
</Text>
</Text>
</View>
`;

View file

@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import type PostModel from '@typings/database/models/servers/post';
import SystemMessage from './system_message';
const withPost = withObservables(['post'], ({post}: {post: PostModel}) => ({
author: post.author.observe(),
}));
export default withPost(SystemMessage);

View file

@ -0,0 +1,271 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {StyleProp, Text, TextStyle, ViewStyle} from 'react-native';
import Markdown from '@components/markdown';
import {Post} from '@constants';
import {useTheme} from '@context/theme';
import {t} from '@i18n';
import {getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type {PrimitiveType} from 'intl-messageformat';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
type SystemMessageProps = {
author?: UserModel;
post: PostModel;
}
type RenderersProps = SystemMessageProps & {
intl: IntlShape;
styles: {
messageStyle: StyleProp<ViewStyle>;
textStyles: {
[key: string]: TextStyle;
};
};
theme: Theme;
}
type RenderMessageProps = RenderersProps & {
localeHolder: {
id: string;
defaultMessage: string;
};
skipMarkdown?: boolean;
values: Record<string, PrimitiveType>;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
systemMessage: {
color: changeOpacity(theme.centerChannelColor, 0.6),
fontSize: 15,
lineHeight: 20,
},
};
});
const renderUsername = (value = '') => {
if (value) {
return (value[0] === '@') ? value : `@${value}`;
}
return value;
};
const renderMessage = ({styles, intl, localeHolder, theme, values, skipMarkdown = false}: RenderMessageProps) => {
const {messageStyle, textStyles} = styles;
if (skipMarkdown) {
return (
<Text style={messageStyle}>
{intl.formatMessage(localeHolder, values)}
</Text>
);
}
return (
<Markdown
baseTextStyle={messageStyle}
disableGallery={true}
textStyles={textStyles}
value={intl.formatMessage(localeHolder, values)}
theme={theme}
/>
);
};
const renderHeaderChangeMessage = ({post, author, styles, intl, theme}: RenderersProps) => {
let values;
if (!author?.username) {
return null;
}
const username = renderUsername(author.username);
const oldHeader = post.props?.old_header;
const newHeader = post.props?.new_header;
let localeHolder;
if (post.props?.new_header) {
if (post.props?.old_header) {
localeHolder = {
id: t('mobile.system_message.update_channel_header_message_and_forget.updated_from'),
defaultMessage: '{username} updated the channel header from: {oldHeader} to: {newHeader}',
};
values = {username, oldHeader, newHeader};
return renderMessage({post, styles, intl, localeHolder, values, theme});
}
localeHolder = {
id: t('mobile.system_message.update_channel_header_message_and_forget.updated_to'),
defaultMessage: '{username} updated the channel header to: {newHeader}',
};
values = {username, oldHeader, newHeader};
return renderMessage({post, styles, intl, localeHolder, values, theme});
} else if (post.props?.old_header) {
localeHolder = {
id: t('mobile.system_message.update_channel_header_message_and_forget.removed'),
defaultMessage: '{username} removed the channel header (was: {oldHeader})',
};
values = {username, oldHeader, newHeader};
return renderMessage({post, styles, intl, localeHolder, values, theme});
}
return null;
};
const renderPurposeChangeMessage = ({post, author, styles, intl, theme}: RenderersProps) => {
let values;
if (!author?.username) {
return null;
}
const username = renderUsername(author.username);
const oldPurpose = post.props?.old_purpose;
const newPurpose = post.props?.new_purpose;
let localeHolder;
if (post.props?.new_purpose) {
if (post.props?.old_purpose) {
localeHolder = {
id: t('mobile.system_message.update_channel_purpose_message.updated_from'),
defaultMessage: '{username} updated the channel purpose from: {oldPurpose} to: {newPurpose}',
};
values = {username, oldPurpose, newPurpose};
return renderMessage({post, styles, intl, localeHolder, values, skipMarkdown: true, theme});
}
localeHolder = {
id: t('mobile.system_message.update_channel_purpose_message.updated_to'),
defaultMessage: '{username} updated the channel purpose to: {newPurpose}',
};
values = {username, oldPurpose, newPurpose};
return renderMessage({post, styles, intl, localeHolder, values, skipMarkdown: true, theme});
} else if (post.props?.old_purpose) {
localeHolder = {
id: t('mobile.system_message.update_channel_purpose_message.removed'),
defaultMessage: '{username} removed the channel purpose (was: {oldPurpose})',
};
values = {username, oldPurpose, newPurpose};
return renderMessage({post, styles, intl, localeHolder, values, skipMarkdown: true, theme});
}
return null;
};
const renderDisplayNameChangeMessage = ({post, author, styles, intl, theme}: RenderersProps) => {
const oldDisplayName = post.props?.old_displayname;
const newDisplayName = post.props?.new_displayname;
if (!(author?.username)) {
return null;
}
const username = renderUsername(author.username);
const localeHolder = {
id: t('mobile.system_message.update_channel_displayname_message_and_forget.updated_from'),
defaultMessage: '{username} updated the channel display name from: {oldDisplayName} to: {newDisplayName}',
};
const values = {username, oldDisplayName, newDisplayName};
return renderMessage({post, styles, intl, localeHolder, values, theme});
};
const renderArchivedMessage = ({post, author, styles, intl, theme}: RenderersProps) => {
const username = renderUsername(author?.username);
const localeHolder = {
id: t('mobile.system_message.channel_archived_message'),
defaultMessage: '{username} archived the channel',
};
const values = {username};
return renderMessage({post, styles, intl, localeHolder, values, theme});
};
const renderUnarchivedMessage = ({post, author, styles, intl, theme}: RenderersProps) => {
if (!author?.username) {
return null;
}
const username = renderUsername(author.username);
const localeHolder = {
id: t('mobile.system_message.channel_unarchived_message'),
defaultMessage: '{username} unarchived the channel',
};
const values = {username};
return renderMessage({post, styles, intl, localeHolder, values, theme});
};
const renderAddGuestToChannelMessage = ({post, styles, intl, theme}: RenderersProps) => {
if (!post.props.username || !post.props.addedUsername) {
return null;
}
const username = renderUsername(post.props.username);
const addedUsername = renderUsername(post.props.addedUsername);
const localeHolder = {
id: t('api.channel.add_guest.added'),
defaultMessage: '{addedUsername} added to the channel as a guest by {username}.',
};
const values = {username, addedUsername};
return renderMessage({post, styles, intl, localeHolder, values, theme});
};
const renderGuestJoinChannelMessage = ({post, styles, intl, theme}: RenderersProps) => {
if (!post.props.username) {
return null;
}
const username = renderUsername(post.props.username);
const localeHolder = {
id: t('api.channel.guest_join_channel.post_and_forget'),
defaultMessage: '{username} joined the channel as a guest.',
};
const values = {username};
return renderMessage({post, styles, intl, localeHolder, values, theme});
};
const systemMessageRenderers = {
[Post.POST_TYPES.HEADER_CHANGE]: renderHeaderChangeMessage,
[Post.POST_TYPES.DISPLAYNAME_CHANGE]: renderDisplayNameChangeMessage,
[Post.POST_TYPES.PURPOSE_CHANGE]: renderPurposeChangeMessage,
[Post.POST_TYPES.CHANNEL_DELETED]: renderArchivedMessage,
[Post.POST_TYPES.CHANNEL_UNARCHIVED]: renderUnarchivedMessage,
[Post.POST_TYPES.GUEST_JOIN_CHANNEL]: renderGuestJoinChannelMessage,
[Post.POST_TYPES.ADD_GUEST_TO_CHANNEL]: renderAddGuestToChannelMessage,
};
export const SystemMessage = ({post, author}: SystemMessageProps) => {
const intl = useIntl();
const theme = useTheme();
const renderer = systemMessageRenderers[post.type];
if (!renderer) {
return null;
}
const style = getStyleSheet(theme);
const textStyles = getMarkdownTextStyles(theme);
const styles = {messageStyle: style.systemMessage, textStyles};
return renderer({post, author, styles, intl, theme});
};
export default React.memo(SystemMessage);

View file

@ -0,0 +1,202 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Post} from '@constants';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {SystemMessage} from './system_message';
const baseProps = {
author: {
id: 'me',
username: 'username',
firstName: 'Test',
lastName: 'Author',
},
};
describe('renderSystemMessage', () => {
let database;
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
});
test('uses renderer for Channel Header update', () => {
const post = {
props: {
old_header: 'old header',
new_header: 'new header',
},
type: Post.POST_TYPES.HEADER_CHANGE,
};
const {getByText, toJSON} = renderWithEverything(
<SystemMessage
post={post}
{...baseProps}
/>,
{database},
);
expect(toJSON()).toMatchSnapshot();
expect(getByText('@username')).toBeTruthy();
expect(getByText(' updated the channel header from: old header to: new header')).toBeTruthy();
});
test('uses renderer for Channel Display Name update', () => {
const post = {
props: {
old_displayname: 'old displayname',
new_displayname: 'new displayname',
},
type: Post.POST_TYPES.DISPLAYNAME_CHANGE,
};
const {getByText, toJSON} = renderWithEverything(
<SystemMessage
post={post}
{...baseProps}
/>,
{database},
);
expect(toJSON()).toMatchSnapshot();
expect(getByText('@username')).toBeTruthy();
expect(getByText(' updated the channel display name from: old displayname to: new displayname')).toBeTruthy();
});
test('uses renderer for Channel Purpose update', () => {
const post = {
props: {
old_purpose: 'old purpose',
new_purpose: 'new purpose',
},
type: Post.POST_TYPES.PURPOSE_CHANGE,
};
const {getByText, toJSON} = renderWithEverything(
<SystemMessage
post={post}
{...baseProps}
/>,
{database},
);
expect(toJSON()).toMatchSnapshot();
expect(getByText('@username updated the channel purpose from: old purpose to: new purpose')).toBeTruthy();
});
test('uses renderer for archived channel', () => {
const post = {
type: Post.POST_TYPES.CHANNEL_DELETED,
};
const {getByText, toJSON} = renderWithEverything(
<SystemMessage
post={post}
{...baseProps}
/>,
{database},
);
expect(toJSON()).toMatchSnapshot();
expect(getByText('@username')).toBeTruthy();
expect(getByText(' archived the channel')).toBeTruthy();
});
test('uses renderer for OLD archived channel without a username', () => {
const post = {
props: {},
type: Post.POST_TYPES.CHANNEL_DELETED,
};
const {getByText, toJSON} = renderWithEverything(
<SystemMessage
{...baseProps}
post={post}
author={undefined}
/>,
{database},
);
expect(toJSON()).toMatchSnapshot();
expect(getByText('archived the channel')).toBeTruthy();
});
test('uses renderer for unarchived channel', () => {
const post = {
type: Post.POST_TYPES.CHANNEL_UNARCHIVED,
};
const viewOne = renderWithEverything(
<SystemMessage
{...baseProps}
post={post}
/>,
{database},
);
expect(viewOne.toJSON()).toMatchSnapshot();
expect(viewOne.getByText('@username')).toBeTruthy();
expect(viewOne.getByText(' unarchived the channel')).toBeTruthy();
const viewTwo = renderWithEverything(
<SystemMessage
{...baseProps}
post={post}
author={undefined}
/>,
{database},
);
expect(viewTwo.toJSON()).toBeNull();
expect(viewTwo.queryByText('archived the channel')).toBeFalsy();
});
test('is null for non-qualifying system messages', () => {
const post = {
postType: 'not_relevant',
};
const renderedMessage = renderWithEverything(
<SystemMessage
{...baseProps}
post={post}
/>,
{database},
);
expect(renderedMessage.toJSON()).toBeNull();
});
test('uses renderer for Guest added and join to channel', () => {
const post = {
props: {
username: 'username',
},
type: Post.POST_TYPES.GUEST_JOIN_CHANNEL,
};
const joined = renderWithEverything(
<SystemMessage
post={post}
{...baseProps}
/>,
{database},
);
expect(joined.toJSON()).toMatchSnapshot();
expect(joined.getByText('@username')).toBeTruthy();
expect(joined.getByText(' joined the channel as a guest.')).toBeTruthy();
post.type = Post.POST_TYPES.ADD_GUEST_TO_CHANNEL;
post.props = {
username: 'username',
addedUsername: 'other.user',
};
const added = renderWithEverything(
<SystemMessage
post={post}
{...baseProps}
/>,
{database},
);
expect(added.toJSON()).toMatchSnapshot();
expect(added.getByText('@other.user')).toBeTruthy();
expect(added.getByText(' added to the channel as a guest by ')).toBeTruthy();
expect(added.getByText('@username.')).toBeTruthy();
});
});

View file

@ -5,9 +5,9 @@ import React, {useEffect} from 'react';
import {Platform, StyleProp, View, ViewProps, ViewStyle} from 'react-native';
import FastImage from 'react-native-fast-image';
// import {fetchStatusInBatch} from '@actions/remote/user';
// import UserStatus from '@components/user_status';
import {fetchStatusInBatch} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import UserStatus from '@components/user_status';
import {useServerUrl} from '@context/server_url';
import {useTheme} from '@context/theme';
import NetworkManager from '@init/network_manager';
@ -73,7 +73,7 @@ const ProfilePicture = ({author, iconSize, showStatus = true, size = 64, statusS
useEffect(() => {
if (author && !author.status && showStatus) {
// fetchStatusInBatch(serverUrl, author.id);
fetchStatusInBatch(serverUrl, author.id);
}
}, []);
@ -86,10 +86,10 @@ const ProfilePicture = ({author, iconSize, showStatus = true, size = 64, statusS
if (author?.status && !author.isBot && showStatus) {
statusIcon = (
<View style={[style.statusWrapper, statusStyle, {borderRadius: statusSize / 2}]}>
{/* <UserStatus
<UserStatus
size={statusSize}
status={author.status}
/> */}
/>
</View>
);
}

View file

@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Animated, LayoutChangeEvent, StyleSheet, StyleProp, View, ViewStyle} from 'react-native';
type ProgressBarProps = {
color: string;
progress: number;
style?: StyleProp<ViewStyle>;
}
const styles = StyleSheet.create({
container: {
height: 4,
borderRadius: 2,
backgroundColor: 'rgba(255, 255, 255, 0.16)',
overflow: 'hidden',
width: '100%',
},
progressBar: {
flex: 1,
},
});
const ProgressBar = ({color, progress, style}: ProgressBarProps) => {
const timer = useRef(new Animated.Value(progress)).current;
const [width, setWidth] = useState(0);
useEffect(() => {
const animation = Animated.timing(timer, {
duration: 200,
useNativeDriver: true,
isInteraction: false,
toValue: progress,
});
animation.start();
return animation.stop;
}, [progress]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
setWidth(e.nativeEvent.layout.width);
}, []);
const translateX = timer.interpolate({
inputRange: [0, 1],
outputRange: [(-0.5 * width), 0],
});
const scaleX = timer.interpolate({
inputRange: [0, 1],
outputRange: [0.0001, 1],
});
return (
<View
onLayout={onLayout}
style={[styles.container, style]}
>
<Animated.View
style={[
styles.progressBar, {
backgroundColor: color,
width,
transform: [
{translateX},
{scaleX},
],
},
]}
/>
</View>
);
};
export default ProgressBar;

View file

@ -23,7 +23,7 @@ type ProgressiveImageProps = {
isBackgroundImage?: boolean;
onError: () => void;
resizeMode?: ResizeMode;
style?: ViewStyle;
style?: StyleProp<ViewStyle>;
thumbnailUri?: string;
tintDefaultSource?: boolean;
};

Some files were not shown because too many files have changed in this diff Show more