[Gekidou]: MM-39757 - Recent mentions (#5823)

* [Gekidou]: MM-39757 - Recent mentions

* Refactor channel_info to a separate component

* Fixes schema tests

* Fixes channel_info theme color

* Removes RECEIVED_MENTIONS table and model

Removes RECEIVED_MENTIONS table and model and saves recent_mentions in
the SYSTEM table under the recentMentions ID.

* Cleanup recent_mentions handler

* Adds i18n in recent_mentions screen

* Observe changes on the post messages

* Addresses review comments

* Batches records

* Addresses review comments

* Addresses review comments

* Addresses review comments

* Addresses review comments

* Fetches channels and users needed for mentions

Fetching mentions from all teams might result in missing info like user
profiles, and channels missing from the DB.
This commit fetches all missing users and channels.

* Adds empty state for recent mentions

* Prepares all missing models for channels

* Addresses review comments

* Fixes mention keys for recent mentions

User mention keys when asking for mentions should not include general
purpose ones, like @channel, @all, @here.

Fixes ActivityIndicator color in recent mentions screen.

* Removes top margin of mention message

* Addresses review comments

* Fixes group.name undefined
This commit is contained in:
Kyriakos Z 2021-12-16 12:26:43 +02:00 committed by GitHub
parent 13c67348eb
commit 675d8495b3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 839 additions and 159 deletions

View file

@ -131,3 +131,27 @@ export const removePost = async (serverUrl: string, post: PostModel) => {
export const selectAttachmentMenuAction = (serverUrl: string, postId: string, actionId: string, selectedOption: string) => {
return postActionWithCookie(serverUrl, postId, actionId, '', selectedOption);
};
export const processPostsFetched = async (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[];
const previousPostId = data.prev_post_id;
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
await operator.handlePosts({
actionType,
order,
posts,
previousPostId,
});
}
}
return {
posts,
order,
previousPostId,
};
};

View file

@ -1,11 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//
import Model from '@nozbe/watermelondb/Model';
import {processPostsFetched} from '@actions/local/post';
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';
import {prepareMissingChannelsForAllTeams, queryAllMyChannelIds} from '@queries/servers/channel';
import {queryRecentPostsInChannel} from '@queries/servers/post';
import {queryCurrentUserId, queryCurrentChannelId} from '@queries/servers/system';
import {queryAllUsers} from '@queries/servers/user';
@ -235,26 +239,56 @@ export const postActionWithCookie = async (serverUrl: string, postId: string, ac
}
};
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[];
const previousPostId = data.prev_post_id;
export async function getMissingChannelsFromPosts(serverUrl: string, posts: Post[], fetchOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
operator.handlePosts({
actionType,
order,
posts,
previousPostId,
});
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const channelIds = await queryAllMyChannelIds(operator.database);
const channelPromises: Array<Promise<Channel>> = [];
const userPromises: Array<Promise<ChannelMembership>> = [];
posts.forEach((post) => {
const id = post.channel_id;
if (channelIds.indexOf(id) === -1) {
channelPromises.push(client.getChannel(id));
userPromises.push(client.getMyChannelMember(id));
}
});
const channels = await Promise.all(channelPromises);
const channelMemberships = await Promise.all(userPromises);
if (!fetchOnly && channels.length && channelMemberships.length) {
const modelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships) as Array<Promise<Model[]>>;
if (modelPromises && modelPromises.length) {
const channelModelsArray = await Promise.all(modelPromises);
if (channelModelsArray.length) {
const models = channelModelsArray.flatMap((mdls) => {
if (!mdls || mdls.length) {
return [];
}
return mdls;
});
if (models) {
operator.batchRecords(models);
}
}
}
}
return {
posts,
order,
previousPostId,
channels,
channelMemberships,
};
};
}

View file

@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Model from '@nozbe/watermelondb/Model';
import {processPostsFetched} from '@actions/local/post';
import {prepareMissingChannelsForAllTeams} from '@app/queries/servers/channel';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {queryCurrentUser} from '@queries/servers/user';
import {fetchPostAuthors, getMissingChannelsFromPosts} from './post';
import {forceLogoutIfNecessary} from './session';
import type {Client} from '@client/rest';
type PostSearchRequest = {
error?: unknown;
order?: string[];
posts?: Post[];
}
export async function getRecentMentions(serverUrl: string): Promise<PostSearchRequest> {
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};
}
let posts: Record<string, Post> = {};
let postsArray: Post[] = [];
let order: string[] = [];
try {
const currentUser = await queryCurrentUser(operator.database);
if (!currentUser) {
return {
posts: [],
order: [],
};
}
const terms = currentUser.userMentionKeys.map(({key}) => key).join(' ').trim() + ' ';
const data = await client.searchPosts('', terms, true);
posts = data.posts || {};
order = data.order || [];
const promises: Array<Promise<Model[]>> = [];
postsArray = order.map((id) => posts[id]);
const mentions: IdValue = {
id: SYSTEM_IDENTIFIERS.RECENT_MENTIONS,
value: JSON.stringify(order),
};
promises.push(operator.handleSystem({
systems: [mentions],
prepareRecordsOnly: true,
}));
if (postsArray.length) {
const {authors} = await fetchPostAuthors(serverUrl, postsArray, true);
const {channels, channelMemberships} = await getMissingChannelsFromPosts(serverUrl, postsArray, true) as {channels: Channel[]; channelMemberships: ChannelMembership[]};
if (authors?.length) {
promises.push(
operator.handleUsers({
users: authors,
prepareRecordsOnly: true,
}),
);
}
if (channels?.length && channelMemberships?.length) {
const channelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships) as Array<Promise<Model[]>>;
if (channelPromises && channelPromises.length) {
promises.push(...channelPromises);
}
}
promises.push(
operator.handlePosts({
actionType: '',
order: [],
posts: postsArray,
previousPostId: '',
prepareRecordsOnly: true,
}),
);
}
const modelArrays = await Promise.all(promises);
const models = modelArrays.flatMap((mdls) => {
if (!mdls || !mdls.length) {
return [];
}
return mdls;
});
if (models.length) {
await operator.batchRecords(models);
}
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
return {
order,
posts: postsArray,
};
}
export const searchPosts = async (serverUrl: string, params: PostSearchParams): Promise<PostSearchRequest> => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
let data;
try {
data = await client.searchPosts('', params.terms, params.is_or_search);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
return processPostsFetched(serverUrl, '', data, false);
};

View file

@ -25,7 +25,7 @@ export interface ClientPostsMix {
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>;
searchPostsWithParams: (teamId: string, params: PostSearchParams) => Promise<any>;
searchPosts: (teamId: string, terms: string, isOrSearch: boolean) => Promise<any>;
doPostAction: (postId: string, actionId: string, selectedOption?: string) => Promise<any>;
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string) => Promise<any>;
@ -194,13 +194,10 @@ const ClientPosts = (superclass: any) => class extends superclass {
);
};
searchPostsWithParams = async (teamId: string, params: any) => {
this.analytics.trackAPI('api_posts_search', {team_id: teamId});
return this.doFetch(
`${this.getTeamRoute(teamId)}/posts/search`,
{method: 'post', body: params},
);
searchPostsWithParams = async (teamId: string, params: PostSearchParams) => {
this.analytics.trackAPI('api_posts_search');
const endpoint = teamId ? `${this.getTeamRoute(teamId)}/posts/search` : `${this.getPostsRoute()}/search`;
return this.doFetch(endpoint, {method: 'post', body: params});
};
searchPosts = async (teamId: string, terms: string, isOrSearch: boolean) => {

View file

@ -20,11 +20,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
container: {
alignItems: 'center',
flexDirection: 'row',
height: 28,
marginTop: 8,
},
dateContainer: {
marginHorizontal: 15,
marginVertical: 8,
},
line: {
flex: 1,
@ -94,12 +90,10 @@ const DateSeparator = (props: DateSeparatorProps) => {
return (
<View style={[styles.container, props.style]}>
<View style={styles.line}/>
<View style={styles.dateContainer}>
<RecentDate
{...props}
style={styles.date}
/>
</View>
<RecentDate
{...props}
style={styles.date}
/>
<View style={styles.line}/>
</View>
);

View file

@ -44,8 +44,7 @@ const style = StyleSheet.create({
},
profilePictureContainer: {
marginBottom: 5,
marginLeft: 12,
marginRight: Platform.select({android: 11, ios: 10}),
marginRight: 10,
marginTop: 10,
},
});

View file

@ -80,7 +80,7 @@ const Header = (props: HeaderProps) => {
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 displayName = postUserDisplayName(post, author, teammateNameDisplay, enablePostUsernameOverride);
const rootAuthorDisplayName = rootPostAuthor ? displayUsername(rootPostAuthor, currentUser.locale, teammateNameDisplay, true) : undefined;
const customStatus = getUserCustomStatus(author);
const customStatusExpired = isCustomStatusExpired(author);

View file

@ -14,8 +14,7 @@ type Props = {
const styles = StyleSheet.create({
profilePictureContainer: {
marginBottom: 5,
marginLeft: 12,
marginRight: 13,
marginRight: 10,
marginTop: 10,
},
});

View file

@ -53,12 +53,13 @@ export const SYSTEM_IDENTIFIERS = {
CURRENT_USER_ID: 'currentUserId',
DATA_RETENTION_POLICIES: 'dataRetentionPolicies',
EXPANDED_LINKS: 'expandedLinks',
RECENT_REACTIONS: 'recentReactions',
INTEGRATION_TRIGGER_ID: 'IntegreationTriggerId',
LICENSE: 'license',
WEBSOCKET: 'WebSocket',
TEAM_HISTORY: 'teamHistory',
RECENT_CUSTOM_STATUS: 'recentCustomStatus',
RECENT_MENTIONS: 'recentMentions',
RECENT_REACTIONS: 'recentReactions',
TEAM_HISTORY: 'teamHistory',
WEBSOCKET: 'WebSocket',
};
export const GLOBAL_IDENTIFIERS = {

View file

@ -21,6 +21,7 @@ export const SERVER = 'Server';
export const SETTINGS_SIDEBAR = 'SettingsSidebar';
export const SSO = 'SSO';
export const THREAD = 'Thread';
export const MENTIONS = 'Mentions';
export default {
ABOUT,
@ -43,4 +44,5 @@ export default {
SETTINGS_SIDEBAR,
SSO,
THREAD,
MENTIONS,
};

View file

@ -8,15 +8,15 @@ export {default as CustomEmojiModel} from './custom_emoji';
export {default as DraftModel} from './draft';
export {default as FileModel} from './file';
export {default as GroupMembershipModel} from './group_membership';
export {default as GroupModel} from './group';
export {default as GroupsChannelModel} from './groups_channel';
export {default as GroupsTeamModel} from './groups_team';
export {default as GroupModel} from './group';
export {default as MyChannelSettingsModel} from './my_channel_settings';
export {default as MyChannelModel} from './my_channel';
export {default as MyChannelSettingsModel} from './my_channel_settings';
export {default as MyTeamModel} from './my_team';
export {default as PostModel} from './post';
export {default as PostsInChannelModel} from './posts_in_channel';
export {default as PostsInThreadModel} from './posts_in_thread';
export {default as PostModel} from './post';
export {default as PreferenceModel} from './preference';
export {default as ReactionModel} from './reaction';
export {default as RoleModel} from './role';
@ -24,7 +24,7 @@ export {default as SlashCommandModel} from './slash_command';
export {default as SystemModel} from './system';
export {default as TeamChannelHistoryModel} from './team_channel_history';
export {default as TeamMembershipModel} from './team_membership';
export {default as TeamSearchHistoryModel} from './team_search_history';
export {default as TeamModel} from './team';
export {default as TeamSearchHistoryModel} from './team_search_history';
export {default as TermsOfServiceModel} from './terms_of_service';
export {default as UserModel} from './user';

View file

@ -14,6 +14,7 @@ import type PostModel from '@typings/database/models/servers/post';
import type PreferenceModel from '@typings/database/models/servers/preference';
import type ReactionModel from '@typings/database/models/servers/reaction';
import type TeamMembershipModel from '@typings/database/models/servers/team_membership';
import type {UserMentionKey} from '@typings/global/markdown';
const {
CHANNEL,
@ -141,4 +142,45 @@ export default class UserModel extends Model {
u.status = status;
});
};
get mentionKeys() {
let keys: UserMentionKey[] = [];
if (!this.notifyProps) {
return keys;
}
if (this.notifyProps.mention_keys) {
keys = keys.concat(this.notifyProps.mention_keys.split(',').map((key) => {
return {key};
}));
}
if (this.notifyProps.first_name === 'true' && this.firstName) {
keys.push({key: this.firstName, caseSensitive: true});
}
if (this.notifyProps.channel === 'true') {
keys.push({key: '@channel'});
keys.push({key: '@all'});
keys.push({key: '@here'});
}
const usernameKey = '@' + this.username;
if (keys.findIndex((key) => key.key === usernameKey) === -1) {
keys.push({key: usernameKey});
}
return keys;
}
get userMentionKeys() {
const mentionKeys = this.mentionKeys;
return mentionKeys.filter((m) => (
m.key !== '@all' &&
m.key !== '@channel' &&
m.key !== '@here'
));
}
}

View file

@ -31,7 +31,7 @@ const {
export interface PostHandlerMix {
handleDraft: ({drafts, prepareRecordsOnly}: HandleDraftArgs) => Promise<DraftModel[]>;
handleFiles: ({files, prepareRecordsOnly}: HandleFilesArgs) => Promise<FileModel[]>;
handlePosts: ({actionType, order, posts, previousPostId}: HandlePostsArgs) => Promise<void>;
handlePosts: ({actionType, order, posts, previousPostId, prepareRecordsOnly}: HandlePostsArgs) => Promise<Model[]>;
handlePostsInChannel: (posts: Post[]) => Promise<void>;
handlePostsInThread: (rootPosts: PostsInThread[]) => Promise<void>;
}
@ -73,12 +73,12 @@ const PostHandler = (superclass: any) => class extends superclass {
* @param {string | undefined} handlePosts.previousPostId
* @returns {Promise<void>}
*/
handlePosts = async ({actionType, order, posts, previousPostId = ''}: HandlePostsArgs): Promise<void> => {
handlePosts = async ({actionType, order, posts, previousPostId = '', prepareRecordsOnly = false}: HandlePostsArgs): Promise<Model[]> => {
const tableName = POST;
// We rely on the posts array; if it is empty, we stop processing
if (!posts.length) {
return;
return [];
}
const emojis: CustomEmoji[] = [];
@ -197,9 +197,11 @@ const PostHandler = (superclass: any) => class extends superclass {
}
}
if (batch.length) {
if (batch.length && !prepareRecordsOnly) {
await this.batchRecords(batch);
}
return batch;
};
/**

View file

@ -49,9 +49,9 @@ export const serverSchema: AppSchema = appSchema({
MyChannelSchema,
MyChannelSettingsSchema,
MyTeamSchema,
PostsInChannelSchema,
PostInThreadSchema,
PostSchema,
PostsInChannelSchema,
PreferenceSchema,
ReactionSchema,
RoleSchema,

View file

@ -14,9 +14,9 @@ export {default as GroupsTeamSchema} from './groups_team';
export {default as MyChannelSchema} from './my_channel';
export {default as MyChannelSettingsSchema} from './my_channel_settings';
export {default as MyTeamSchema} from './my_team';
export {default as PostsInChannelSchema} from './posts_in_channel';
export {default as PostInThreadSchema} from './posts_in_thread';
export {default as PostSchema} from './post';
export {default as PostsInChannelSchema} from './posts_in_channel';
export {default as PreferenceSchema} from './preference';
export {default as ReactionSchema} from './reaction';
export {default as RoleSchema} from './role';

View file

@ -19,6 +19,34 @@ import type PostModel from '@typings/database/models/servers/post';
const {SERVER: {CHANNEL, MY_CHANNEL, CHANNEL_MEMBERSHIP}} = MM_TABLES;
export function prepareMissingChannelsForAllTeams(operator: ServerDataOperator, channels: Channel[], channelMembers: ChannelMembership[]): Array<Promise<Model[]>> | undefined {
const channelInfos: ChannelInfo[] = [];
const memberships = channelMembers.map((cm) => ({...cm, id: cm.channel_id}));
for (const c of channels) {
channelInfos.push({
id: c.id,
header: c.header,
purpose: c.purpose,
guest_count: 0,
member_count: 0,
pinned_post_count: 0,
});
}
try {
const channelRecords: Promise<Model[]> = operator.handleChannel({channels, prepareRecordsOnly: true});
const channelInfoRecords: Promise<Model[]> = operator.handleChannelInfo({channelInfos, prepareRecordsOnly: true});
const membershipRecords: Promise<Model[]> = operator.handleChannelMembership({channelMemberships: memberships, prepareRecordsOnly: true});
const myChannelRecords: Promise<Model[]> = operator.handleMyChannel({channels, myChannels: memberships, prepareRecordsOnly: true});
const myChannelSettingsRecords: Promise<Model[]> = operator.handleMyChannelSettings({settings: memberships, prepareRecordsOnly: true});
return [channelRecords, channelInfoRecords, membershipRecords, myChannelRecords, myChannelSettingsRecords];
} catch {
return undefined;
}
}
export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, teamId: string, channels: Channel[], channelMembers: ChannelMembership[]) => {
const allChannelsForTeam = await queryAllChannelsForTeam(operator.database, teamId);
const channelInfos: ChannelInfo[] = [];
@ -190,3 +218,7 @@ export const deleteChannelMembership = async (operator: ServerDataOperator, user
return {error};
}
};
export const queryAllMyChannelMembershipIds = async (database: Database, userId: string) => {
return database.get(CHANNEL_MEMBERSHIP).query(Q.where('user_id', Q.eq(userId))).fetchIds();
};

View file

@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View, Text, StyleSheet} from 'react-native';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
import type TeamModel from '@typings/database/models/servers/team';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flex: 1,
flexDirection: 'row',
marginTop: 8,
},
channel: {
...typography('Body', 75, 'SemiBold'),
color: theme.centerChannelColor,
marginRight: 5,
},
teamContainer: {
borderColor: theme.centerChannelColor,
borderLeftWidth: StyleSheet.hairlineWidth,
},
team: {
...typography('Body', 75, 'Light'),
color: theme.centerChannelColor,
marginLeft: 5,
},
}));
type Props = {
channelName: ChannelModel['displayName'];
post: PostModel;
teamName: TeamModel['displayName'];
}
function ChannelInfo({channelName, teamName}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View style={styles.container}>
<Text style={styles.channel}>{channelName}</Text>
{Boolean(teamName) && (
<View style={styles.teamContainer}>
<Text style={styles.team}>{teamName}</Text>
</View>
)}
</View>
);
}
export default ChannelInfo;

View file

@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import {switchMap, of as of$} from 'rxjs';
import ChannelInfo from './channel_info';
import type PostModel from '@typings/database/models/servers/post';
import type TeamModel from '@typings/database/models/servers/team';
const enhance = withObservables(['post'], ({post}: {post: PostModel}) => {
const channel = post.channel.observe();
return {
channelName: channel.pipe(
switchMap((chan) => of$(chan.displayName)),
),
teamName: channel.pipe(
switchMap((chan) => chan.team || of$(null)),
switchMap((team: TeamModel|null) => of$(team?.displayName || null)),
),
};
});
export default enhance(ChannelInfo);

View file

@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import {useTheme} from '@app/context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme';
import {typography} from '@app/utils/typography';
import FormattedText from '@components/formatted_text';
// @ts-expect-error svg extension
import Mention from './mention_icon.svg';
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 40,
},
title: {
color: theme.centerChannelColor,
...typography('Heading', 400),
},
paragraph: {
marginTop: 8,
textAlign: 'center',
color: changeOpacity(theme.centerChannelColor, 0.72),
...typography('Body', 200),
},
icon: {
alignItems: 'center',
justifyContent: 'center',
},
}));
function EmptyMentions() {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View style={styles.container}>
<Mention style={styles.icon}/>
<FormattedText
defaultMessage='No Mentions yet'
id='mentions.empty.title'
style={styles.title}
testID='empty.mentions.title'
/>
<FormattedText
defaultMessage={'You\'ll see messages here when someone mentions you or uses terms you\'re monitoring.'}
id='mentions.empty.paragraph'
style={styles.paragraph}
testID='mentions.empty.paragraph'
/>
</View>
);
}
export default EmptyMentions;

View file

@ -0,0 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import Mention from './mention';
const enhance = withObservables(['post'], ({post}) => ({
post,
}));
export default enhance(Mention);

View file

@ -0,0 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View, StyleSheet} from 'react-native';
import Avatar from '@components/post_list/post/avatar';
import Message from '@components/post_list/post/body/message';
import Header from '@components/post_list/post/header';
import SystemAvatar from '@components/system_avatar';
import SystemHeader from '@components/system_header';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {fromAutoResponder, isFromWebhook, isSystemMessage, isEdited as postEdited} from '@utils/post';
import ChannelInfo from '../channel_info';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
currentUser: UserModel;
post: PostModel;
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 20,
},
content: {
flexDirection: 'row',
paddingBottom: 8,
},
rightColumn: {
flex: 1,
flexDirection: 'column',
marginRight: 12,
},
message: {
flex: 1,
},
});
function Mention({post, currentUser}: Props) {
const theme = useTheme();
const isAutoResponder = fromAutoResponder(post);
const isSystemPost = isSystemMessage(post);
const isWebHook = isFromWebhook(post);
const isEdited = postEdited(post);
const postAvatar = isAutoResponder ? (
<SystemAvatar theme={theme}/>
) : (
<Avatar
isAutoReponse={isAutoResponder}
isSystemPost={isSystemPost}
post={post}
/>
);
const header = isSystemPost && !isAutoResponder ? (
<SystemHeader
createAt={post.createAt}
theme={theme}
/>
) : (
<Header
currentUser={currentUser}
isAutoResponse={isAutoResponder}
differentThreadSequence={true}
isEphemeral={false}
isPendingOrFailed={false}
isSystemPost={isSystemPost}
isWebHook={isWebHook}
location={Screens.MENTIONS}
post={post}
shouldRenderReplyButton={false}
/>
);
return (
<View style={styles.container}>
<ChannelInfo post={post}/>
<View style={styles.content}>
{postAvatar}
<View style={styles.rightColumn}>
{header}
<View style={styles.message}>
<Message
highlight={false}
isEdited={isEdited}
isPendingOrFailed={false}
isReplyPost={false}
location={Screens.MENTIONS}
post={post}
theme={theme}
/>
</View>
</View>
</View>
</View>
);
}
export default Mention;

View file

@ -0,0 +1,10 @@
<svg width="155" height="153" viewBox="0 0 155 153" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="80.5" cy="133" rx="54.5" ry="3" fill="black" fill-opacity="0.06"/>
<path d="M70.27 59.7713H129.709C130.798 59.7676 131.878 59.9781 132.885 60.3906C133.893 60.8032 134.81 61.4099 135.583 62.1759C136.355 62.9419 136.97 63.8524 137.39 64.8552C137.81 65.8581 138.028 66.9338 138.032 68.0208V105.718C138.028 106.805 137.81 107.88 137.39 108.883C136.97 109.886 136.355 110.797 135.583 111.563C134.81 112.329 133.893 112.935 132.885 113.348C131.878 113.76 130.798 113.971 129.709 113.967H120.937V128.078L107.779 113.967H70.2911C69.2018 113.971 68.1224 113.76 67.1146 113.348C66.1068 112.935 65.1904 112.329 64.4175 111.563C63.6447 110.797 63.0306 109.886 62.6103 108.883C62.1901 107.88 61.9719 106.805 61.9682 105.718V68.0208C61.9756 65.8291 62.8538 63.7298 64.4101 62.1833C65.9664 60.6368 68.0737 59.7694 70.27 59.7713Z" fill="#1E325C"/>
<path d="M107.779 113.967H70.291C69.2017 113.971 68.1224 113.76 67.1146 113.348C66.1068 112.935 65.1903 112.329 64.4175 111.563C63.6446 110.797 63.0305 109.886 62.6103 108.883C62.19 107.88 61.9718 106.805 61.9681 105.718V82.832C61.9681 82.832 64.5857 103.995 65.0559 105.858C65.526 107.721 66.4594 110.508 70.8805 110.97C75.3016 111.432 107.779 113.967 107.779 113.967Z" fill="#0A111F"/>
<path d="M131.863 75.5561C131.259 73.4228 130.238 71.4298 128.859 69.6919C127.48 67.954 125.77 66.5055 123.828 65.4298C123.713 65.3717 123.621 65.2773 123.565 65.1611C123.51 65.045 123.494 64.9138 123.522 64.788C123.549 64.6623 123.617 64.5492 123.716 64.4663C123.815 64.3835 123.938 64.3357 124.067 64.3303C127.814 64.1062 135.379 64.9046 133.007 75.5001C132.982 75.6294 132.914 75.7466 132.815 75.8329C132.715 75.9192 132.589 75.9697 132.457 75.9761C132.325 75.9826 132.195 75.9447 132.088 75.8685C131.98 75.7924 131.901 75.6823 131.863 75.5561Z" fill="#32539A"/>
<path d="M107.838 26.4362L32.1885 26.4362C30.8021 26.4315 29.4284 26.6994 28.1458 27.2245C26.8632 27.7496 25.6967 28.5217 24.7131 29.4966C23.7295 30.4716 22.9479 31.6303 22.413 32.9067C21.8782 34.1831 21.6004 35.5521 21.5958 36.9356V84.9135C21.6004 86.297 21.8782 87.666 22.413 88.9424C22.9479 90.2188 23.7295 91.3775 24.7131 92.3524C25.6967 93.3274 26.8632 94.0995 28.1458 94.6246C29.4284 95.1497 30.8021 95.4175 32.1885 95.4129H43.3529V113.372L60.0994 95.4129H107.811C109.198 95.4175 110.572 95.1497 111.854 94.6246C113.137 94.0995 114.303 93.3274 115.287 92.3524C116.27 91.3775 117.052 90.2188 117.587 88.9424C118.122 87.666 118.399 86.297 118.404 84.9135V36.9356C118.395 34.1462 117.277 31.4743 115.296 29.5061C113.316 27.5378 110.633 26.4338 107.838 26.4362Z" fill="#1C58D9"/>
<path d="M60.0995 95.4128H107.811C109.198 95.4175 110.572 95.1496 111.854 94.6245C113.137 94.0994 114.303 93.3273 115.287 92.3524C116.271 91.3774 117.052 90.2187 117.587 88.9423C118.122 87.6659 118.4 86.2969 118.404 84.9134V55.7862C118.404 55.7862 115.073 82.7209 114.474 85.0917C113.876 87.4625 112.688 91.0098 107.061 91.5981C101.434 92.1863 60.0995 95.4128 60.0995 95.4128Z" fill="black" fill-opacity="0.16"/>
<path d="M29.4467 46.5259C30.2154 43.8109 31.5147 41.2743 33.2698 39.0625C35.025 36.8506 37.2012 35.007 39.6733 33.6379C39.8197 33.5641 39.9374 33.4438 40.008 33.296C40.0786 33.1482 40.0981 32.9812 40.0633 32.8212C40.0286 32.6612 39.9417 32.5171 39.8161 32.4117C39.6906 32.3063 39.5336 32.2454 39.3696 32.2386C34.6002 31.9534 24.9721 32.9695 27.9909 46.4546C28.0228 46.6192 28.109 46.7684 28.2359 46.8783C28.3628 46.9881 28.523 47.0523 28.6908 47.0605C28.8586 47.0687 29.0243 47.0205 29.1613 46.9236C29.2984 46.8267 29.3989 46.6866 29.4467 46.5259Z" fill="white" fill-opacity="0.16"/>
<path d="M70.3926 66.9654C72.0377 66.9654 73.4981 66.3443 74.7739 65.1021C76.0496 63.8263 76.6875 62.3491 76.6875 60.6704C76.6875 58.9582 76.0664 57.481 74.8242 56.2388C73.582 54.9965 72.1048 54.3754 70.3926 54.3754C68.7139 54.3754 67.2367 55.0133 65.9609 56.2891C64.7187 57.5649 64.0976 59.0253 64.0976 60.6704C64.0976 62.2819 64.7187 63.7591 65.9609 65.1021C67.3038 66.3443 68.7811 66.9654 70.3926 66.9654ZM70.3926 39.6704C76.1336 39.6704 81.0688 41.7352 85.1983 45.8647C89.3278 49.9941 91.3926 54.9294 91.3926 60.6704V63.692C91.3926 65.7064 90.6875 67.5025 89.2775 69.0805C87.8674 70.457 86.1216 71.1452 84.0401 71.1452C81.5221 71.1452 79.4573 70.1045 77.8458 68.0229C75.7643 70.1045 73.2967 71.1452 70.4429 71.1452C67.5892 71.1452 65.1216 70.1045 63.0401 68.0229C60.9585 65.9414 59.9178 63.4906 59.9178 60.6704C59.9178 57.8503 60.9417 55.3659 62.9897 53.2172C65.0712 51.2028 67.5389 50.1956 70.3926 50.1956C73.2463 50.1956 75.7307 51.2028 77.8458 53.2172C79.8602 55.3323 80.8674 57.8167 80.8674 60.6704V63.692C80.8674 64.5313 81.1863 65.2867 81.8242 65.9582C82.4621 66.6296 83.2007 66.9654 84.0401 66.9654C84.913 66.9654 85.6516 66.6464 86.2559 66.0085C86.8602 65.3707 87.1624 64.5985 87.1624 63.692V60.6704C87.1624 56.0373 85.5173 52.0925 82.2271 48.8359C78.9705 45.5457 75.0257 43.9006 70.3926 43.9006C65.7595 43.9006 61.7979 45.5457 58.5077 48.8359C55.2511 52.0925 53.6228 56.0373 53.6228 60.6704C53.6228 65.3035 55.2511 69.2651 58.5077 72.5553C61.7979 75.8119 65.7595 77.4402 70.3926 77.4402H80.8674V81.6704H70.3926C64.7187 81.6704 59.8003 79.5889 55.6372 75.4258C51.4741 71.2627 49.3926 66.3275 49.3926 60.6201C49.3926 54.9126 51.4741 49.9941 55.6372 45.8647C59.8003 41.7352 64.7187 39.6704 70.3926 39.6704Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -0,0 +1,53 @@
// 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 compose from 'lodash/fp/compose';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {SystemModel, UserModel} from '@app/database/models/server';
import {getTimezone} from '@app/utils/user';
import {SYSTEM_IDENTIFIERS, MM_TABLES} from '@constants/database';
import RecentMentionsScreen from './recent_mentions';
import type {WithDatabaseArgs} from '@typings/database/database';
const {USER, SYSTEM, POST} = MM_TABLES.SERVER;
const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
const currentUser = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap((currentUserId) => database.get<UserModel>(USER).findAndObserve(currentUserId.value)),
);
return {
mentions: database.get<SystemModel>(SYSTEM).query(
Q.where('id', SYSTEM_IDENTIFIERS.RECENT_MENTIONS),
Q.take(1),
).observeWithColumns(['value']).pipe(
switchMap((rows) => {
if (!rows.length || !rows[0].value.length) {
return of$([]);
}
const row = rows[0];
return database.get(POST).query(
Q.where('id', Q.oneOf(row.value)),
Q.sortBy('create_at', Q.asc),
).observe();
}),
),
currentUser,
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user.timezone))))),
isTimezoneEnabled: database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')),
),
};
});
export default compose(
withDatabase,
enhance,
)(RecentMentionsScreen);

View file

@ -1,48 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useRoute} from '@react-navigation/native';
import React, {useCallback, useState} from 'react';
import {Text} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {SafeAreaView} from 'react-native-safe-area-context';
const RecentMentionsScreen = () => {
const route = useRoute();
const params = route.params! as {direction: string};
const toLeft = params.direction === 'left';
const [start, setStart] = useState(false);
const onLayout = useCallback(() => {
setStart(true);
}, []);
const animated = useAnimatedStyle(() => {
if (start) {
return {
opacity: withTiming(1, {duration: 150}),
transform: [{translateX: withTiming(0, {duration: 150})}],
};
}
return {
opacity: withTiming(0, {duration: 150}),
transform: [{translateX: withTiming(toLeft ? -25 : 25, {duration: 150})}],
};
}, [start]);
return (
<SafeAreaView
style={{flex: 1, backgroundColor: 'brown'}}
>
<Animated.View
onLayout={onLayout}
style={[{flex: 1, justifyContent: 'center', alignItems: 'center'}, animated]}
>
<Text style={{fontSize: 20, color: '#fff'}}>{'Recent Mentions Screen'}</Text>
</Animated.View>
</SafeAreaView>
);
};
export default RecentMentionsScreen;

View file

@ -0,0 +1,169 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useIsFocused, useRoute} from '@react-navigation/native';
import React, {useCallback, useState, useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet, View, ActivityIndicator, FlatList} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {SafeAreaView, Edge} from 'react-native-safe-area-context';
import {getRecentMentions} from '@actions/remote/search';
import NavigationHeader from '@components/navigation_header';
import DateSeparator from '@components/post_list/date_separator';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {UserModel} from '@database/models/server';
import {useCollapsibleHeader} from '@hooks/header';
import {getDateForDateLine, isDateLine, selectOrderedPosts} from '@utils/post_list';
import EmptyState from './components/empty';
import Mention from './components/mention';
import type PostModel from '@typings/database/models/servers/post';
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
const EDGES: Edge[] = ['bottom', 'left', 'right'];
type Props = {
currentTimezone: string | null;
currentUser: UserModel;
isTimezoneEnabled: boolean;
mentions: PostModel[];
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
empty: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
});
const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezoneEnabled}: Props) => {
const theme = useTheme();
const route = useRoute();
const isFocused = useIsFocused();
const {formatMessage} = useIntl();
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(true);
const serverUrl = useServerUrl();
const params = route.params! as {direction: string};
const toLeft = params.direction === 'left';
const translateSide = toLeft ? -25 : 25;
const opacity = useSharedValue(isFocused ? 1 : 0);
const translateX = useSharedValue(isFocused ? 0 : translateSide);
const title = formatMessage({id: 'screen.mentions.title', defaultMessage: 'Recent Mentions'});
const subtitle = formatMessage({id: 'screen.mentions.subtitle', defaultMessage: 'Messages you\'ve been mentioned in'});
const isLargeTitle = true;
useEffect(() => {
setLoading(true);
getRecentMentions(serverUrl).finally(() => {
setLoading(false);
});
}, [serverUrl]);
const {scrollPaddingTop, scrollRef, scrollValue, onScroll} = useCollapsibleHeader<FlatList<string>>(isLargeTitle, Boolean(subtitle), false);
const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop}), [scrollPaddingTop]);
const posts = useMemo(() => selectOrderedPosts(mentions, 0, false, '', false, isTimezoneEnabled, currentTimezone, false).reverse(), [mentions]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await getRecentMentions(serverUrl);
setRefreshing(false);
}, [serverUrl]);
useEffect(() => {
opacity.value = isFocused ? 1 : 0;
translateX.value = isFocused ? 0 : translateSide;
}, [isFocused]);
const animated = useAnimatedStyle(() => {
return {
opacity: withTiming(opacity.value, {duration: 150}),
transform: [{translateX: withTiming(translateX.value, {duration: 150})}],
};
}, []);
const renderEmptyList = useCallback(() => (
<View style={[styles.empty, paddingTop]}>
{loading ? (
<ActivityIndicator
color={theme.centerChannelColor}
size='large'
/>
) : (
<EmptyState/>
)}
</View>
), [loading]);
const renderItem = useCallback(({item}) => {
if (typeof item === 'string') {
if (isDateLine(item)) {
return (
<DateSeparator
date={getDateForDateLine(item)}
theme={theme}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
}
return null;
}
return (
<Mention
currentUser={currentUser}
post={item}
/>
);
}, [currentUser]);
return (
<>
<NavigationHeader
isLargeTitle={isLargeTitle}
showBackButton={false}
subtitle={subtitle}
title={title}
hasSearch={false}
scrollValue={scrollValue}
forwardedRef={scrollRef}
/>
<SafeAreaView
style={styles.flex}
edges={EDGES}
>
<Animated.View style={[styles.flex, animated]}>
<AnimatedFlatList
ref={scrollRef}
contentContainerStyle={paddingTop}
ListEmptyComponent={renderEmptyList()}
data={posts}
scrollToOverflowEnabled={true}
showsVerticalScrollIndicator={false}
progressViewOffset={scrollPaddingTop}
scrollEventThrottle={16}
indicatorStyle='black'
onScroll={onScroll}
onRefresh={handleRefresh}
refreshing={refreshing}
renderItem={renderItem}
/>
</Animated.View>
</SafeAreaView>
</>
);
};
export default RecentMentionsScreen;

View file

@ -82,6 +82,10 @@ export function isEmail(email: string): boolean {
return (/^[^ ,@]+@[^ ,@]+$/).test(email);
}
export function identity<T>(arg: T): T {
return arg;
}
export function safeParseJSON(rawJson: string | Record<string, unknown> | unknown[]) {
let data = rawJson;
try {

View file

@ -61,37 +61,13 @@ export function postUserDisplayName(post: PostModel, author?: UserModel, teammat
}
export const getMentionKeysForPost = (user: UserModel, post: PostModel, groups: GroupModel[] | null) => {
const keys: UserMentionKey[] = [];
if (!user.notifyProps) {
return keys;
}
if (user.notifyProps.mention_keys) {
const mentions = user.notifyProps.mention_keys.split(',').map((key) => ({key}));
keys.push(...mentions);
}
if (user.notifyProps.first_name === 'true' && user.firstName) {
keys.push({key: user.firstName, caseSensitive: true});
}
if (user.notifyProps.channel === 'true' && !post.props?.mentionHighlightDisabled) {
keys.push(
{key: '@channel'},
{key: '@all'},
{key: '@here'},
);
}
const usernameKey = `@${user.username}`;
if (keys.findIndex((item) => item.key === usernameKey) === -1) {
keys.push({key: usernameKey});
}
const keys: UserMentionKey[] = user.mentionKeys;
if (groups?.length) {
for (const group of groups) {
keys.push({key: `@${group.name}`});
if (group.name && group.name.trim()) {
keys.push({key: `@${group.name}`});
}
}
}

View file

@ -168,7 +168,7 @@ function isJoinLeavePostForUsername(post: PostModel, currentUsername: string): b
}
// are we going to do something with selectedPostId as in v1?
function selectOrderedPosts(
export function selectOrderedPosts(
posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUsername: string, showJoinLeave: boolean,
timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false) {
if (posts.length === 0) {

View file

@ -12,7 +12,6 @@ import {toTitleCase} from '@utils/helpers';
import type GroupModel from '@typings/database/models/servers/group';
import type GroupMembershipModel from '@typings/database/models/servers/group_membership';
import type {UserMentionKey} from '@typings/global/markdown';
import type {IntlShape} from 'react-intl';
export function displayUsername(user?: UserProfile | UserModel, locale?: string, teammateDisplayNameSetting?: string, useFallbackUsername = true) {
@ -105,33 +104,7 @@ export const getUsersByUsername = (users: UserModel[]) => {
};
export const getUserMentionKeys = (user: UserModel, groups: GroupModel[], userGroups: GroupMembershipModel[]) => {
const keys: UserMentionKey[] = [];
if (!user.notifyProps) {
return keys;
}
if (user.notifyProps.mention_keys) {
const mentions = user.notifyProps.mention_keys.split(',').map((key) => ({key}));
keys.push(...mentions);
}
if (user.notifyProps.first_name === 'true' && user.firstName) {
keys.push({key: user.firstName, caseSensitive: true});
}
if (user.notifyProps.channel === 'true') {
keys.push(
{key: '@channel'},
{key: '@all'},
{key: '@here'},
);
}
const usernameKey = `@${user.username}`;
if (keys.findIndex((item) => item.key === usernameKey) === -1) {
keys.push({key: usernameKey});
}
const keys = user.mentionKeys;
if (groups.length && userGroups.length) {
const groupMentions = userGroups.reduce((result: Array<{key: string}>, ug: GroupMembershipModel) => {

View file

@ -296,6 +296,8 @@
"post_info.system": "System",
"post_message_view.edited": "(edited)",
"posts_view.newMsg": "New Messages",
"screen.mentions.subtitle": "Messages you've been mentioned in",
"screen.mentions.title": "Recent Mentions",
"screen.search.placeholder": "Search messages & files",
"search_bar.search": "Search",
"status_dropdown.set_away": "Away",

View file

@ -141,3 +141,8 @@ type MessageAttachmentField = {
value: any;
short: boolean;
}
type PostSearchParams = {
terms: string;
is_or_search: boolean;
};

View file

@ -84,6 +84,7 @@ export type HandlePostsArgs = {
order: string[];
previousPostId?: string;
posts: Post[];
prepareRecordsOnly?: boolean;
};
export type SanitizeReactionsArgs = {

View file

@ -93,4 +93,10 @@ export default class UserModel extends Model {
/** prepareStatus: Prepare the model to update the user status in a batch operation */
prepareStatus: (status: string) => void;
/* user mentions keys may include @channel, @all, @here depending on notify_props */
mentionKeys: UserMentionKey[];
/* user mentions keys always excluding @channel, @all, @here */
userMentionKeys: UserMentionKey[];
}