* Added scaffolding for unrevealed BoR post * Displayed reveal UI * Displayed expiry timer * WIP * Displayed own post indicator and blur text * restored button * Ungrouped BoR posts * WIP * WIP * DIsplayed error message on revealing * Added last run check on mobile app cleanup job * Cleanup * lint fix * i18n-fix * Added tests * test: add test suite for revealBoRPost function * test: add unrevealed burn on read post test file * feat: add tests for UnrevealedBurnOnReadPost component * test: update UnrevealedBurnOnReadPost test with PostModel type * test: replace toBeTruthy with toBeVisible for component visibility assertions * test: add initial test file for expiry timer component * test: add comprehensive tests for ExpiryCountdown component * refactor: clean up test formatting and remove redundant test case * fix: adjust test timing for ExpiryCountdown onExpiry callback * test: fix timer test by advancing timers in smaller increments * Added tests * Updated tests * fixed accidental change * restored package.resolved * WIP review fixes * Review fixes * Review fixes * Fixed tests * restored package.resolved * WIP * test: add test for skipping BoR post cleanup within 15 minutes * test: add comprehensive test cases for expiredBoRPostCleanup * WIP * WIP * test: add bor.test.ts placeholder file * test: add comprehensive tests for BoR utility functions * test: add comprehensive tests for formatTime function * WIP * test: add comprehensive tests for getLastBoRPostCleanupRun function * Added tests * removed a commented code * post list optimization * test: add test case for updating unrevealed burn-on-read post * fix: handle error logging in expiredBoRPostCleanup test * test: add test case for updateLastBoRCleanupRun error handling * review fixes * lint fixes * Added WS event handling (#9320) * Added WS event handling * test: add burn on read websocket action test * test: add tests for handleBoRPostRevealedEvent in burn_on_read * Added tests * test: add comprehensive error handling test cases for burn on read * Added tests and error handling * BoR post - restricted actions (#9315) * Restricted post actions for BoR post type * Prevent opening thread fr nBoR post * WIP * fix: remove redundant observable wrapping in post options * fixed a change * removed broken * restored package.resolved * Added tests * Awaiting for last run to be set * Added tests for validating expiry timer behaviour (#9338) * Added tests for validating expiry timer behaviour * No need of use event setup * Bor ux fixes (#9336) * WIP * UI and delete fixes * lint fixes * fixed tests * Improved mocking * Improved test
459 lines
16 KiB
TypeScript
459 lines
16 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 {isExpiredBoRPost} from '@utils/bor';
|
|
import {toMilliseconds} from '@utils/datetime';
|
|
import {isFromWebhook} from '@utils/post';
|
|
import {ensureString, includes, isArrayOf, isStringArray, secureGetFromRecord} from '@utils/types';
|
|
|
|
import type {PostList, PostWithPrevAndNext} from '@typings/components/post_list';
|
|
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: PostList) {
|
|
let lastPostIsUserActivity = false;
|
|
let combinedCount = 0;
|
|
const out: PostList = [];
|
|
let changed = false;
|
|
|
|
for (let i = 0; i < orderedPosts.length; i++) {
|
|
const item = orderedPosts[i];
|
|
if (item.type === 'start-of-new-messages' || item.type === 'date' || item.type === 'thread-overview') {
|
|
// Not a post, so it won't be combined
|
|
out.push(item);
|
|
|
|
lastPostIsUserActivity = false;
|
|
combinedCount = 0;
|
|
|
|
continue;
|
|
} else if (item.type === 'post' && item.value.currentPost.deleteAt) {
|
|
out.push(item);
|
|
|
|
lastPostIsUserActivity = false;
|
|
combinedCount = 0;
|
|
} else {
|
|
const postIsUserActivity = item.type === 'post' && includes(Post.USER_ACTIVITY_POST_TYPES, item.value.currentPost.type);
|
|
if (postIsUserActivity && lastPostIsUserActivity && combinedCount < MAX_COMBINED_SYSTEM_POSTS) {
|
|
out[out.length - 1].value += '_' + item.value.currentPost.id;
|
|
} else if (postIsUserActivity) {
|
|
out.push({type: 'user-activity', value: `${COMBINED_USER_ACTIVITY}${item.value.currentPost.id}`});
|
|
combinedCount = 1;
|
|
changed = true;
|
|
} else {
|
|
out.push(item);
|
|
combinedCount = 0;
|
|
}
|
|
|
|
lastPostIsUserActivity = postIsUserActivity;
|
|
}
|
|
}
|
|
|
|
if (!changed) {
|
|
return orderedPosts;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function comparePostTypes(a: MessageData, b: MessageData) {
|
|
const aPriority = secureGetFromRecord(postTypePriority, a.postType) ?? 99;
|
|
const bPriority = secureGetFromRecord(postTypePriority, b.postType) ?? 99;
|
|
return aPriority - bPriority;
|
|
}
|
|
|
|
function extractUserActivityData(userActivities: Record<PostType, UserActivityValue>): UserActivityProp {
|
|
const messageData: MessageData[] = [];
|
|
const allUserIds: string[] = [];
|
|
const allUsernames: string[] = [];
|
|
Object.entries(userActivities).forEach(([postType, values]: [PostType, UserActivityValue]) => {
|
|
if (
|
|
postType === Post.POST_TYPES.ADD_TO_TEAM ||
|
|
postType === Post.POST_TYPES.ADD_TO_CHANNEL ||
|
|
postType === Post.POST_TYPES.REMOVE_FROM_CHANNEL
|
|
) {
|
|
if (Array.isArray(values)) {
|
|
throw new Error('Invalid Post activity data');
|
|
}
|
|
Object.entries(values).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;
|
|
}
|
|
|
|
// We can be more lax with the types here because the recursive function only checks
|
|
// whether it is an array, or comparison with strings, so it should be safe enough.
|
|
if (Array.isArray(post.props.user_activity_posts)) {
|
|
for (const childPost of post.props.user_activity_posts as PostModel[]) {
|
|
if (childPost && 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;
|
|
}
|
|
|
|
export function selectOrderedPostsWithPrevAndNext(
|
|
posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUserId: string, currentUsername: string, showJoinLeave: boolean,
|
|
currentTimezone: string | null, isThreadScreen = false, savedPostIds = new Set<string>(),
|
|
): PostList {
|
|
return selectOrderedPosts(
|
|
posts, lastViewedAt, indicateNewMessages,
|
|
currentUserId, currentUsername, showJoinLeave,
|
|
currentTimezone, isThreadScreen, savedPostIds, true,
|
|
);
|
|
}
|
|
|
|
export function selectOrderedPosts(
|
|
posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUserId: string, currentUsername: string, showJoinLeave: boolean,
|
|
currentTimezone: string | null, isThreadScreen = false, savedPostIds = new Set<string>(), includePrevNext = false) {
|
|
if (posts.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const out: PostList = [];
|
|
let lastDate;
|
|
let addedNewMessagesIndicator = false;
|
|
|
|
// Iterating through the posts from oldest to newest
|
|
for (let i = posts.length - 1; i >= 0; i--) {
|
|
const post: PostWithPrevAndNext = {currentPost: posts[i]};
|
|
post.isSaved = savedPostIds.has(post.currentPost.id);
|
|
|
|
if (!isThreadScreen && isExpiredBoRPost(post.currentPost)) {
|
|
continue;
|
|
}
|
|
|
|
if (includePrevNext) {
|
|
post.nextPost = posts[i - 1];
|
|
if (!isThreadScreen || out[out.length - 1]?.type !== 'thread-overview') {
|
|
post.previousPost = posts[i + 1];
|
|
}
|
|
}
|
|
|
|
if (
|
|
!post ||
|
|
(post.currentPost.type === Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL && !isThreadScreen)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
// Filter out join/leave messages if necessary
|
|
if (shouldFilterJoinLeavePost(post.currentPost, 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.currentPost.createAt);
|
|
const currentOffset = toMilliseconds({minutes: postDate.getTimezoneOffset()});
|
|
if (currentTimezone) {
|
|
const zone = moment.tz.zone(currentTimezone);
|
|
if (zone) {
|
|
const timezoneOffset = toMilliseconds({minutes: zone.utcOffset(post.currentPost.createAt)});
|
|
postDate.setTime(post.currentPost.createAt + (currentOffset - timezoneOffset));
|
|
}
|
|
}
|
|
|
|
if (!lastDate || lastDate.toDateString() !== postDate.toDateString()) {
|
|
out.push({type: 'date', value: DATE_LINE + postDate.getTime()});
|
|
|
|
lastDate = postDate;
|
|
}
|
|
|
|
if (
|
|
lastViewedAt &&
|
|
post.currentPost.createAt > lastViewedAt &&
|
|
(post.currentPost.userId !== currentUserId || isFromWebhook(post.currentPost)) &&
|
|
!addedNewMessagesIndicator &&
|
|
indicateNewMessages
|
|
) {
|
|
out.push({type: 'start-of-new-messages', value: START_OF_NEW_MESSAGES});
|
|
addedNewMessagesIndicator = true;
|
|
}
|
|
|
|
out.push({type: 'post', value: post});
|
|
|
|
if (isThreadScreen && i === posts.length - 1) {
|
|
out.push({type: 'thread-overview', value: THREAD_OVERVIEW});
|
|
}
|
|
}
|
|
|
|
// Flip it back to newest to oldest
|
|
return out.reverse();
|
|
}
|
|
|
|
type ActivityData = {
|
|
ids: string[];
|
|
usernames: string[];
|
|
}
|
|
type UserActivityValue = string[] | Record<string, ActivityData>
|
|
function combineUserActivitySystemPost(systemPosts: PostModel[]) {
|
|
const userActivities = systemPosts.reduce((acc: Record<string, UserActivityValue>, post: PostModel) => {
|
|
const postType = post.type;
|
|
const 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 = ensureString(post.props?.addedUserId) || ensureString(post.props?.removedUserId);
|
|
const username = ensureString(post.props?.addedUsername) || ensureString(post.props?.removedUsername);
|
|
if (combinedPostType) {
|
|
if (Array.isArray(combinedPostType)) {
|
|
throw new Error('Invalid Post activity data');
|
|
}
|
|
|
|
const userCombinedPostType = combinedPostType[post.userId];
|
|
const users = userCombinedPostType || {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[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,
|
|
user_id: '',
|
|
metadata: {},
|
|
};
|
|
}
|
|
|
|
export function getDateForDateLine(item: string) {
|
|
return parseInt(item.substring(DATE_LINE.length), 10);
|
|
}
|
|
|
|
export function getPostIdsForCombinedUserActivityPost(item: string) {
|
|
if (!item.startsWith(COMBINED_USER_ACTIVITY)) {
|
|
throw new Error(`Invalid prefix, expected string to start with '${COMBINED_USER_ACTIVITY}'`);
|
|
}
|
|
|
|
return item.substring(COMBINED_USER_ACTIVITY.length).split('_');
|
|
}
|
|
|
|
export function preparePostList(
|
|
posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUserId: string, currentUsername: string, showJoinLeave: boolean,
|
|
currentTimezone: string | null, isThreadScreen = false, savedPostIds = new Set<string>()) {
|
|
const orderedPosts = selectOrderedPostsWithPrevAndNext(posts, lastViewedAt, indicateNewMessages, currentUserId, currentUsername, showJoinLeave, currentTimezone, isThreadScreen, savedPostIds);
|
|
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 (!includes(joinLeavePostTypes, post.type)) {
|
|
return false;
|
|
}
|
|
|
|
// Don't filter out join/leave messages about the current user
|
|
return !isJoinLeavePostForUsername(post, currentUsername);
|
|
}
|
|
|
|
export type MessageData = {
|
|
actorId?: string;
|
|
postType: PostType;
|
|
userIds: string[];
|
|
}
|
|
|
|
function isMessageData(v: unknown): v is MessageData {
|
|
if (typeof v !== 'object' || !v) {
|
|
return false;
|
|
}
|
|
|
|
if (('actorId' in v) && typeof v.actorId !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
if (!('postType' in v) || typeof v.postType !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
if (!('userIds' in v) || !isStringArray(v.userIds)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export type UserActivityProp = {
|
|
allUserIds: string[];
|
|
allUsernames: string[];
|
|
messageData: MessageData[];
|
|
}
|
|
|
|
export function isUserActivityProp(v: unknown): v is UserActivityProp {
|
|
if (typeof v !== 'object' || !v) {
|
|
return false;
|
|
}
|
|
|
|
if (!('allUserIds' in v) || !isStringArray(v.allUserIds)) {
|
|
return false;
|
|
}
|
|
|
|
if (!('allUsernames' in v) || !isStringArray(v.allUsernames)) {
|
|
return false;
|
|
}
|
|
|
|
if (!('messageData' in v) || !isArrayOf(v.messageData, isMessageData)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|