[Gekidou] post component tree and partial postList (#5637)
This commit is contained in:
parent
0139773c31
commit
5700ce7c86
186 changed files with 14905 additions and 650 deletions
133
app/actions/local/post.ts
Normal file
133
app/actions/local/post.ts
Normal 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
164
app/actions/remote/apps.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
114
app/actions/remote/reactions.ts
Normal file
114
app/actions/remote/reactions.ts
Normal 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};
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
258
app/components/autocomplete_selector/index.tsx
Normal file
258
app/components/autocomplete_selector/index.tsx
Normal 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)));
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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>
|
||||
`;
|
||||
37
app/components/custom_status/clear_button.test.tsx
Normal file
37
app/components/custom_status/clear_button.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
52
app/components/custom_status/clear_button.tsx
Normal file
52
app/components/custom_status/clear_button.tsx
Normal 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;
|
||||
42
app/components/custom_status/custom_status_emoji.test.tsx
Normal file
42
app/components/custom_status/custom_status_emoji.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
35
app/components/custom_status/custom_status_emoji.tsx
Normal file
35
app/components/custom_status/custom_status_emoji.tsx
Normal 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;
|
||||
35
app/components/custom_status/custom_status_text.test.tsx
Normal file
35
app/components/custom_status/custom_status_text.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
39
app/components/custom_status/custom_status_text.tsx
Normal file
39
app/components/custom_status/custom_status_text.tsx
Normal 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;
|
||||
|
|
@ -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(),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
31
app/components/formatted_date/index.tsx
Normal file
31
app/components/formatted_date/index.tsx
Normal 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;
|
||||
125
app/components/formatted_markdown_text/index.tsx
Normal file
125
app/components/formatted_markdown_text/index.tsx
Normal 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;
|
||||
39
app/components/formatted_time/index.tsx
Normal file
39
app/components/formatted_time/index.tsx
Normal 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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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))));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
3011
app/components/markdown/transform.test.ts
Normal file
3011
app/components/markdown/transform.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
69
app/components/post_list/combined_user_activity/index.ts
Normal file
69
app/components/post_list/combined_user_activity/index.ts
Normal 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)));
|
||||
104
app/components/post_list/combined_user_activity/last_users.tsx
Normal file
104
app/components/post_list/combined_user_activity/last_users.tsx
Normal 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;
|
||||
192
app/components/post_list/combined_user_activity/messages.ts
Normal file
192
app/components/post_list/combined_user_activity/messages.ts
Normal 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**.',
|
||||
},
|
||||
};
|
||||
107
app/components/post_list/date_separator/index.tsx
Normal file
107
app/components/post_list/date_separator/index.tsx
Normal 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;
|
||||
272
app/components/post_list/index.tsx
Normal file
272
app/components/post_list/index.tsx
Normal 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)));
|
||||
73
app/components/post_list/new_message_line/index.tsx
Normal file
73
app/components/post_list/new_message_line/index.tsx
Normal 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;
|
||||
182
app/components/post_list/post/avatar/index.tsx
Normal file
182
app/components/post_list/post/avatar/index.tsx
Normal 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))));
|
||||
237
app/components/post_list/post/body/add_members/index.tsx
Normal file
237
app/components/post_list/post/body/add_members/index.tsx
Normal 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)));
|
||||
|
|
@ -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;
|
||||
|
|
@ -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));
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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));
|
||||
|
|
@ -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)));
|
||||
96
app/components/post_list/post/body/content/index.tsx
Normal file
96
app/components/post_list/post/body/content/index.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
201
app/components/post_list/post/body/content/opengraph/index.tsx
Normal file
201
app/components/post_list/post/body/content/opengraph/index.tsx
Normal 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)));
|
||||
|
|
@ -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;
|
||||
196
app/components/post_list/post/body/content/youtube/index.tsx
Normal file
196
app/components/post_list/post/body/content/youtube/index.tsx
Normal 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)));
|
||||
90
app/components/post_list/post/body/failed/index.tsx
Normal file
90
app/components/post_list/post/body/failed/index.tsx
Normal 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;
|
||||
203
app/components/post_list/post/body/files/document_file.tsx
Normal file
203
app/components/post_list/post/body/files/document_file.tsx
Normal 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;
|
||||
133
app/components/post_list/post/body/files/file.tsx
Normal file
133
app/components/post_list/post/body/files/file.tsx
Normal 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;
|
||||
90
app/components/post_list/post/body/files/file_icon.tsx
Normal file
90
app/components/post_list/post/body/files/file_icon.tsx
Normal 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;
|
||||
73
app/components/post_list/post/body/files/file_info.tsx
Normal file
73
app/components/post_list/post/body/files/file_info.tsx
Normal 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;
|
||||
205
app/components/post_list/post/body/files/image_file.tsx
Normal file
205
app/components/post_list/post/body/files/image_file.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
210
app/components/post_list/post/body/files/index.tsx
Normal file
210
app/components/post_list/post/body/files/index.tsx
Normal 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))));
|
||||
192
app/components/post_list/post/body/index.tsx
Normal file
192
app/components/post_list/post/body/index.tsx
Normal 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;
|
||||
42
app/components/post_list/post/body/message/index.ts
Normal file
42
app/components/post_list/post/body/message/index.ts
Normal 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)));
|
||||
113
app/components/post_list/post/body/message/message.tsx
Normal file
113
app/components/post_list/post/body/message/message.tsx
Normal 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);
|
||||
125
app/components/post_list/post/body/message/show_more_button.tsx
Normal file
125
app/components/post_list/post/body/message/show_more_button.tsx
Normal 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;
|
||||
57
app/components/post_list/post/body/reactions/index.ts
Normal file
57
app/components/post_list/post/body/reactions/index.ts
Normal 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)));
|
||||
76
app/components/post_list/post/body/reactions/reaction.tsx
Normal file
76
app/components/post_list/post/body/reactions/reaction.tsx
Normal 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;
|
||||
171
app/components/post_list/post/body/reactions/reactions.tsx
Normal file
171
app/components/post_list/post/body/reactions/reactions.tsx
Normal 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);
|
||||
50
app/components/post_list/post/header/commented_on/index.tsx
Normal file
50
app/components/post_list/post/header/commented_on/index.tsx
Normal 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;
|
||||
148
app/components/post_list/post/header/display_name/index.tsx
Normal file
148
app/components/post_list/post/header/display_name/index.tsx
Normal 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;
|
||||
147
app/components/post_list/post/header/header.tsx
Normal file
147
app/components/post_list/post/header/header.tsx
Normal 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);
|
||||
70
app/components/post_list/post/header/index.ts
Normal file
70
app/components/post_list/post/header/index.ts
Normal 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)));
|
||||
81
app/components/post_list/post/header/reply/index.tsx
Normal file
81
app/components/post_list/post/header/reply/index.tsx
Normal 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;
|
||||
54
app/components/post_list/post/header/tag/index.tsx
Normal file
54
app/components/post_list/post/header/tag/index.tsx
Normal 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;
|
||||
147
app/components/post_list/post/index.ts
Normal file
147
app/components/post_list/post/index.ts
Normal 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)));
|
||||
286
app/components/post_list/post/post.tsx
Normal file
286
app/components/post_list/post/post.tsx
Normal 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);
|
||||
119
app/components/post_list/post/pre_header/index.tsx
Normal file
119
app/components/post_list/post/pre_header/index.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
`;
|
||||
14
app/components/post_list/post/system_message/index.tsx
Normal file
14
app/components/post_list/post/system_message/index.tsx
Normal 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);
|
||||
271
app/components/post_list/post/system_message/system_message.tsx
Normal file
271
app/components/post_list/post/system_message/system_message.tsx
Normal 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);
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
77
app/components/progress_bar/index.tsx
Normal file
77
app/components/progress_bar/index.tsx
Normal 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;
|
||||
|
|
@ -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
Loading…
Reference in a new issue