Files
mattermost-mobile/app/utils/post_list/index.ts
Daniel Espino García 5fae120826 Add support for review app (#6772)
* Add app review

* Use overlay instead of modal

* Add fixes for ios

* i18n-extract

* Add to milliseconds function

* Address review feedback

* Add try to queryGlobalValue

* added app review illustration

* add feedback illustration

* Add animations and feedback bot message

* Restrict reviews to build environment variable

* Fix bug with "dont ask anymore"

* Add check for only supported servers

* Add missing change

* Use for await

Co-authored-by: Daniel Espino <danielespino@MacBook-Pro-de-Daniel.local>
Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
2022-11-24 18:52:15 +01:00

390 lines
13 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import {Post} from '@constants';
import {toMilliseconds} from '@utils/datetime';
import {isFromWebhook} from '@utils/post';
import type PostModel from '@typings/database/models/servers/post';
const joinLeavePostTypes = [
Post.POST_TYPES.JOIN_LEAVE,
Post.POST_TYPES.JOIN_CHANNEL,
Post.POST_TYPES.LEAVE_CHANNEL,
Post.POST_TYPES.ADD_REMOVE,
Post.POST_TYPES.ADD_TO_CHANNEL,
Post.POST_TYPES.REMOVE_FROM_CHANNEL,
Post.POST_TYPES.JOIN_TEAM,
Post.POST_TYPES.LEAVE_TEAM,
Post.POST_TYPES.ADD_TO_TEAM,
Post.POST_TYPES.REMOVE_FROM_TEAM,
Post.POST_TYPES.COMBINED_USER_ACTIVITY,
];
const postTypePriority = {
[Post.POST_TYPES.JOIN_TEAM]: 0,
[Post.POST_TYPES.ADD_TO_TEAM]: 1,
[Post.POST_TYPES.LEAVE_TEAM]: 2,
[Post.POST_TYPES.REMOVE_FROM_TEAM]: 3,
[Post.POST_TYPES.JOIN_CHANNEL]: 4,
[Post.POST_TYPES.ADD_TO_CHANNEL]: 5,
[Post.POST_TYPES.LEAVE_CHANNEL]: 6,
[Post.POST_TYPES.REMOVE_FROM_CHANNEL]: 7,
[Post.POST_TYPES.PURPOSE_CHANGE]: 8,
[Post.POST_TYPES.HEADER_CHANGE]: 9,
[Post.POST_TYPES.JOIN_LEAVE]: 10,
[Post.POST_TYPES.DISPLAYNAME_CHANGE]: 11,
[Post.POST_TYPES.CONVERT_CHANNEL]: 12,
[Post.POST_TYPES.CHANNEL_DELETED]: 13,
[Post.POST_TYPES.CHANNEL_UNARCHIVED]: 14,
[Post.POST_TYPES.ADD_REMOVE]: 15,
[Post.POST_TYPES.EPHEMERAL]: 16,
};
export const COMBINED_USER_ACTIVITY = 'user-activity-';
export const DATE_LINE = 'date-';
export const START_OF_NEW_MESSAGES = 'start-of-new-messages';
export const THREAD_OVERVIEW = 'thread-overview';
export const MAX_COMBINED_SYSTEM_POSTS = 100;
function combineUserActivityPosts(orderedPosts: Array<PostModel | string>) {
let lastPostIsUserActivity = false;
let combinedCount = 0;
const out: Array<PostModel | string> = [];
let changed = false;
for (let i = 0; i < orderedPosts.length; i++) {
const post = orderedPosts[i];
if (typeof post === 'string') {
if (post === START_OF_NEW_MESSAGES || post.startsWith(DATE_LINE)) {
// Not a post, so it won't be combined
out.push(post);
lastPostIsUserActivity = false;
combinedCount = 0;
continue;
}
} else if (post.deleteAt) {
out.push(post);
lastPostIsUserActivity = false;
combinedCount = 0;
} else {
const postIsUserActivity = Post.USER_ACTIVITY_POST_TYPES.includes(post.type);
if (postIsUserActivity && lastPostIsUserActivity && combinedCount < MAX_COMBINED_SYSTEM_POSTS) {
// Add the ID to the previous combined post
out[out.length - 1] += '_' + post.id;
combinedCount += 1;
changed = true;
} else if (postIsUserActivity) {
// Start a new combined post, even if the "combined" post is only a single post
out.push(COMBINED_USER_ACTIVITY + post.id);
combinedCount = 1;
changed = true;
} else {
out.push(post);
combinedCount = 0;
}
lastPostIsUserActivity = postIsUserActivity;
}
}
if (!changed) {
return orderedPosts;
}
return out;
}
function comparePostTypes(a: typeof postTypePriority, b: typeof postTypePriority) {
return postTypePriority[a.postType] - postTypePriority[b.postType];
}
function extractUserActivityData(userActivities: any) {
const messageData: any[] = [];
const allUserIds: string[] = [];
const allUsernames: string[] = [];
Object.entries(userActivities).forEach(([postType, values]: [string, any]) => {
if (
postType === Post.POST_TYPES.ADD_TO_TEAM ||
postType === Post.POST_TYPES.ADD_TO_CHANNEL ||
postType === Post.POST_TYPES.REMOVE_FROM_CHANNEL
) {
Object.keys(values).map((key) => [key, values[key]]).forEach(([actorId, users]) => {
if (Array.isArray(users)) {
throw new Error('Invalid Post activity data');
}
const {ids, usernames} = users;
messageData.push({postType, userIds: [...usernames, ...ids], actorId});
if (ids.length > 0) {
allUserIds.push(...ids);
}
if (usernames.length > 0) {
allUsernames.push(...usernames);
}
allUserIds.push(actorId);
});
} else {
if (!Array.isArray(values)) {
throw new Error('Invalid Post activity data');
}
messageData.push({postType, userIds: values});
allUserIds.push(...values);
}
});
messageData.sort(comparePostTypes);
function reduceUsers(acc: Set<string>, curr: string) {
if (!acc.has(curr)) {
acc.add(curr);
}
return acc;
}
return {
allUserIds: Array.from(allUserIds.reduce(reduceUsers, new Set<string>())),
allUsernames: Array.from(allUsernames.reduce(reduceUsers, new Set<string>())),
messageData,
};
}
function isJoinLeavePostForUsername(post: PostModel, currentUsername: string): boolean {
if (!post.props || !currentUsername) {
return false;
}
if (post.props.user_activity_posts) {
for (const childPost of post.props.user_activity_posts as PostModel[]) {
if (isJoinLeavePostForUsername(childPost, currentUsername)) {
// If any of the contained posts are for this user, the client will
// need to figure out how to render the post
return true;
}
}
}
return post.props.username === currentUsername ||
post.props.addedUsername === currentUsername ||
post.props.removedUsername === currentUsername;
}
// are we going to do something with selectedPostId as in v1?
export function selectOrderedPosts(
posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUserId: string, currentUsername: string, showJoinLeave: boolean,
timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false) {
if (posts.length === 0) {
return [];
}
const out: Array<PostModel|string> = [];
let lastDate;
let addedNewMessagesIndicator = false;
// Iterating through the posts from oldest to newest
for (let i = posts.length - 1; i >= 0; i--) {
const post = posts[i];
if (
!post ||
(post.type === Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL && !isThreadScreen)
) {
continue;
}
// Filter out join/leave messages if necessary
if (shouldFilterJoinLeavePost(post, showJoinLeave, currentUsername)) {
continue;
}
// Push on a date header if the last post was on a different day than the current one
const postDate = new Date(post.createAt);
if (timezoneEnabled) {
const currentOffset = toMilliseconds({minutes: postDate.getTimezoneOffset()});
if (currentTimezone) {
const zone = moment.tz.zone(currentTimezone);
if (zone) {
const timezoneOffset = toMilliseconds({minutes: zone.utcOffset(post.createAt)});
postDate.setTime(post.createAt + (currentOffset - timezoneOffset));
}
}
}
if (!lastDate || lastDate.toDateString() !== postDate.toDateString()) {
out.push(DATE_LINE + postDate.getTime());
lastDate = postDate;
}
if (
lastViewedAt &&
post.createAt > lastViewedAt &&
(post.userId !== currentUserId || isFromWebhook(post)) &&
!addedNewMessagesIndicator &&
indicateNewMessages
) {
out.push(START_OF_NEW_MESSAGES);
addedNewMessagesIndicator = true;
}
out.push(post);
if (isThreadScreen && i === posts.length - 1) {
out.push(THREAD_OVERVIEW);
}
}
// Flip it back to newest to oldest
return out.reverse();
}
function combineUserActivitySystemPost(systemPosts: PostModel[]) {
const userActivities = systemPosts.reduce((acc: any, post: PostModel) => {
const postType = post.type;
let userActivityProps = acc;
const combinedPostType = userActivityProps[postType];
if (
postType === Post.POST_TYPES.ADD_TO_TEAM ||
postType === Post.POST_TYPES.ADD_TO_CHANNEL ||
postType === Post.POST_TYPES.REMOVE_FROM_CHANNEL
) {
const userId = post.props.addedUserId || post.props.removedUserId;
const username = post.props.addedUsername || post.props.removedUsername;
if (combinedPostType) {
if (Array.isArray(combinedPostType[post.userId])) {
throw new Error('Invalid Post activity data');
}
const users = combinedPostType[post.userId] || {ids: [], usernames: []};
if (userId) {
if (!users.ids.includes(userId)) {
users.ids.push(userId);
}
} else if (username && !users.usernames.includes(username)) {
users.usernames.push(username);
}
combinedPostType[post.userId] = users;
} else {
const users = {
ids: [] as string[],
usernames: [] as string[],
};
if (userId) {
users.ids.push(userId);
} else if (username) {
users.usernames.push(username);
}
userActivityProps[postType] = {
[post.userId]: users,
};
}
} else {
const propsUserId = post.userId;
if (combinedPostType) {
if (!Array.isArray(combinedPostType)) {
throw new Error('Invalid Post activity data');
}
if (!combinedPostType.includes(propsUserId)) {
userActivityProps[postType] = [...combinedPostType, propsUserId];
}
} else {
userActivityProps = {...userActivityProps, [postType]: [propsUserId]};
}
}
return userActivityProps;
}, {});
return extractUserActivityData(userActivities);
}
export function generateCombinedPost(combinedId: string, systemPosts: PostModel[]): Post {
// All posts should be in the same chann
const channelId = systemPosts[0].channelId;
// Assume that the last post is the oldest one
const createAt = systemPosts[systemPosts.length - 1].createAt;
const messages = systemPosts.map((post) => post.message);
const message = messages.join('\n');
return {
id: combinedId,
root_id: '',
original_id: '',
channel_id: channelId,
create_at: createAt,
delete_at: 0,
edit_at: 0,
update_at: 0,
is_pinned: false,
message,
hashtags: '',
pending_post_id: '',
reply_count: 0,
props: {
messages,
user_activity: combineUserActivitySystemPost(systemPosts),
user_activity_posts: systemPosts,
system_post_ids: systemPosts.map((post) => post.id),
},
type: Post.POST_TYPES.COMBINED_USER_ACTIVITY as PostType,
user_id: '',
metadata: {},
};
}
export function getDateForDateLine(item: string) {
return parseInt(item.substring(DATE_LINE.length), 10);
}
export function getPostIdsForCombinedUserActivityPost(item: string) {
return item.substring(COMBINED_USER_ACTIVITY.length).split('_');
}
export function isCombinedUserActivityPost(item: string) {
return (/^user-activity-(?:[^_]+_)*[^_]+$/).test(item);
}
export function isDateLine(item: string) {
return Boolean(item?.startsWith(DATE_LINE));
}
export function isStartOfNewMessages(item: string) {
return item === START_OF_NEW_MESSAGES;
}
export function isThreadOverview(item: string) {
return item === THREAD_OVERVIEW;
}
export function preparePostList(
posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUserId: string, currentUsername: string, showJoinLeave: boolean,
timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false) {
const orderedPosts = selectOrderedPosts(posts, lastViewedAt, indicateNewMessages, currentUserId, currentUsername, showJoinLeave, timezoneEnabled, currentTimezone, isThreadScreen);
return combineUserActivityPosts(orderedPosts);
}
// Returns true if a post should be hidden when the user has Show Join/Leave Messages disabled
export function shouldFilterJoinLeavePost(post: PostModel, showJoinLeave: boolean, currentUsername: string): boolean {
if (showJoinLeave) {
return false;
}
// Don't filter out non-join/leave messages
if (joinLeavePostTypes.indexOf(post.type) === -1) {
return false;
}
// Don't filter out join/leave messages about the current user
return !isJoinLeavePostForUsername(post, currentUsername);
}